From 817af0235755b01d6614a53def817dc0f4187ea5 Mon Sep 17 00:00:00 2001 From: LoveChengke Date: Sun, 11 Oct 2026 11:49:32 +0800 Subject: [PATCH] =?UTF-8?q?feat(permissions,clients,ai,system,server,web,c?= =?UTF-8?q?lient,shared)!:=20=E6=9D=83=E9=99=90=E7=BB=84=20+=20=E5=AE=A2?= =?UTF-8?q?=E6=88=B7=E7=AB=AF=E8=AE=BE=E5=A4=87=E8=BF=9C=E6=8E=A7=E4=B8=8E?= =?UTF-8?q?=E8=87=AA=E5=8A=A8=E6=9B=B4=E6=96=B0=20+=20=E5=A4=A7=E6=A8=A1?= =?UTF-8?q?=E5=9E=8B=E5=8A=A9=E6=89=8B=20+=20=E7=B3=BB=E7=BB=9F=E8=AF=8A?= =?UTF-8?q?=E6=96=AD=EF=BC=881.3.0=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 功能级准入改为「权限键」,`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,历史通知按本地日期回填),升级前请先备份。 --- AGENTS.md | 262 +++- deploy/docker-compose.sqlite.yml | 2 +- deploy/docker-compose.yml | 2 +- deploy/install.sh | 16 +- deploy/package.runtime.json | 2 +- docs/dev/api.md | 35 +- docs/dev/architecture.md | 17 +- docs/dev/data-model.md | 43 +- docs/dev/index.md | 2 +- docs/dev/permissions.md | 51 +- docs/dev/release.md | 8 +- docs/get-started/client.md | 2 +- docs/get-started/server.md | 11 +- docs/management/database.md | 4 +- docs/management/index.md | 2 +- docs/management/production.md | 32 +- docs/management/upgrade.md | 2 +- docs/reference.md | 275 +++- package.json | 3 +- packages/classisland-plugin/manifest.yml | 2 +- packages/classisland-plugin/scripts/build.mjs | 18 +- .../src/Services/BridgeService.cs | 2 +- packages/desktop-client/package.json | 5 +- packages/desktop-client/scripts/dist-win.mjs | 63 +- .../desktop-client/src/island/IslandApp.vue | 23 + .../desktop-client/src/main/auto-update.ts | 304 +++++ packages/desktop-client/src/main/channels.ts | 33 + .../desktop-client/src/main/client-agent.ts | 414 ++++++ packages/desktop-client/src/main/config.ts | 120 +- .../desktop-client/src/main/crash-guard.ts | 187 +++ packages/desktop-client/src/main/index.ts | 121 +- packages/desktop-client/src/main/ipc.ts | 96 +- packages/desktop-client/src/main/island.ts | 92 +- packages/desktop-client/src/main/logger.ts | 111 +- packages/desktop-client/src/main/smoke.ts | 33 + packages/desktop-client/src/main/update.ts | 5 +- packages/desktop-client/src/preload/index.ts | 13 +- packages/desktop-client/src/renderer/App.vue | 19 + .../src/renderer/cache/self-test.ts | 9 + .../src/renderer/island/bridge.ts | 79 +- .../src/renderer/layouts/ClientLayout.vue | 154 ++- .../src/renderer/router/index.ts | 23 + .../desktop-client/src/renderer/stores/app.ts | 59 +- .../desktop-client/src/renderer/stores/ui.ts | 91 +- .../src/renderer/styles/index.css | 64 + .../src/renderer/views/NotificationView.vue | 216 ++- .../views/settings/DeveloperSettingsView.vue | 450 ++++++ .../views/settings/GeneralSettingsView.vue | 315 ++++- .../desktop-client/src/types/desktop.d.ts | 43 + packages/server/package.json | 2 +- .../migration.sql | 173 +++ packages/server/prisma/schema.prisma | 180 +++ packages/server/prisma/seed.ts | 37 + .../scripts/apply-column-migrations.cjs | 12 + packages/server/scripts/verify-e2e.mjs | 791 ++++++++++- packages/server/src/index.ts | 4 + packages/server/src/lib/bulk.ts | 38 + packages/server/src/lib/http.ts | 8 + packages/server/src/lib/mappers.ts | 4 + packages/server/src/lib/permissions.ts | 67 + packages/server/src/lib/secrets.ts | 37 + packages/server/src/lib/settings.ts | 77 ++ packages/server/src/lib/snapshot.ts | 13 + packages/server/src/middleware/auth.ts | 101 +- packages/server/src/middleware/error.ts | 17 +- packages/server/src/modules/ai/ai.module.ts | 62 + packages/server/src/modules/ai/ai.schemas.ts | 28 + packages/server/src/modules/ai/ai.service.ts | 367 +++++ .../src/modules/archives/archives.module.ts | 66 +- .../server/src/modules/calls/calls.module.ts | 4 +- .../src/modules/classes/classes.module.ts | 20 +- .../src/modules/clients/clients.module.ts | 101 ++ .../src/modules/clients/clients.schemas.ts | 62 + .../src/modules/clients/clients.service.ts | 372 +++++ .../src/modules/courses/courses.module.ts | 8 +- .../src/modules/courses/courses.service.ts | 24 +- .../src/modules/database/database.module.ts | 6 +- .../src/modules/grades/grades.module.ts | 16 +- .../src/modules/homeworks/homeworks.module.ts | 30 +- .../modules/homeworks/homeworks.schemas.ts | 21 + .../modules/homeworks/homeworks.service.ts | 62 + .../src/modules/imports/imports.module.ts | 8 +- .../modules/imports/table-import.service.ts | 31 +- .../integrations/integrations.module.ts | 18 +- .../notifications/notifications.module.ts | 49 +- .../notifications/notifications.schemas.ts | 43 + .../notifications/notifications.service.ts | 140 +- .../modules/permissions/permissions.module.ts | 97 ++ .../permissions/permissions.schemas.ts | 47 + .../permissions/permissions.service.ts | 326 +++++ packages/server/src/modules/registry.ts | 8 + .../src/modules/schedules/schedules.module.ts | 8 +- .../src/modules/students/students.module.ts | 18 +- .../src/modules/system/system.module.ts | 62 + .../src/modules/system/system.schemas.ts | 14 + .../src/modules/system/system.service.ts | 153 +++ .../src/modules/teachers/teachers.module.ts | 45 +- .../src/modules/teachers/teachers.service.ts | 2 +- .../server/src/modules/term/term.module.ts | 10 +- .../modules/update/client-releases.service.ts | 220 +++ .../src/modules/update/update.module.ts | 93 +- .../src/modules/update/update.schemas.ts | 41 + packages/server/src/types/express.d.ts | 5 +- packages/shared/package.json | 2 +- packages/shared/src/constants.ts | 57 +- packages/shared/src/permissions.ts | 243 ++++ packages/shared/src/types.ts | 438 +++++- packages/shared/src/utils.ts | 21 + packages/web-admin/package.json | 2 +- packages/web-admin/src/api/http.ts | 3 +- packages/web-admin/src/api/index.ts | 219 ++- packages/web-admin/src/icons.ts | 15 + .../web-admin/src/layouts/AdminLayout.vue | 426 +++++- packages/web-admin/src/router/index.ts | 47 +- packages/web-admin/src/stores/auth.ts | 69 +- packages/web-admin/src/stores/ui.ts | 126 +- packages/web-admin/src/styles/index.css | 222 +++ .../web-admin/src/views/AiSettingsView.vue | 452 ++++++ .../web-admin/src/views/AssistantView.vue | 644 +++++++++ packages/web-admin/src/views/ClientsView.vue | 1216 +++++++++++++++++ packages/web-admin/src/views/GradesView.vue | 63 +- .../web-admin/src/views/HomeworksView.vue | 194 ++- packages/web-admin/src/views/LoginView.vue | 35 + .../web-admin/src/views/NotificationsView.vue | 141 +- .../web-admin/src/views/PermissionsView.vue | 614 +++++++++ .../web-admin/src/views/SchedulesView.vue | 16 +- scripts/dist-server.mjs | 40 +- scripts/nsis/server-installer.nsi | 19 +- scripts/verify-packaged.mjs | 18 +- website/download.html | 2 +- website/index.html | 6 +- 131 files changed, 12995 insertions(+), 538 deletions(-) create mode 100644 packages/desktop-client/src/main/auto-update.ts create mode 100644 packages/desktop-client/src/main/channels.ts create mode 100644 packages/desktop-client/src/main/client-agent.ts create mode 100644 packages/desktop-client/src/main/crash-guard.ts create mode 100644 packages/desktop-client/src/renderer/views/settings/DeveloperSettingsView.vue create mode 100644 packages/server/prisma/migrations/20261010120000_add_permission_groups_and_clients/migration.sql create mode 100644 packages/server/src/lib/bulk.ts create mode 100644 packages/server/src/lib/permissions.ts create mode 100644 packages/server/src/lib/secrets.ts create mode 100644 packages/server/src/lib/settings.ts create mode 100644 packages/server/src/modules/ai/ai.module.ts create mode 100644 packages/server/src/modules/ai/ai.schemas.ts create mode 100644 packages/server/src/modules/ai/ai.service.ts create mode 100644 packages/server/src/modules/clients/clients.module.ts create mode 100644 packages/server/src/modules/clients/clients.schemas.ts create mode 100644 packages/server/src/modules/clients/clients.service.ts create mode 100644 packages/server/src/modules/permissions/permissions.module.ts create mode 100644 packages/server/src/modules/permissions/permissions.schemas.ts create mode 100644 packages/server/src/modules/permissions/permissions.service.ts create mode 100644 packages/server/src/modules/system/system.module.ts create mode 100644 packages/server/src/modules/system/system.schemas.ts create mode 100644 packages/server/src/modules/system/system.service.ts create mode 100644 packages/server/src/modules/update/client-releases.service.ts create mode 100644 packages/server/src/modules/update/update.schemas.ts create mode 100644 packages/web-admin/src/views/AiSettingsView.vue create mode 100644 packages/web-admin/src/views/AssistantView.vue create mode 100644 packages/web-admin/src/views/ClientsView.vue create mode 100644 packages/web-admin/src/views/PermissionsView.vue diff --git a/AGENTS.md b/AGENTS.md index f4dd407..b6fa448 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -118,10 +118,13 @@ website/ 产品官网(**纯静态、无构建步骤**, -x64-server-setup.exe),它们是离线兜底链接的唯一依据。 packages/shared/src/ types.ts / constants.ts / permissions.ts / motion.ts / utils.ts / index.ts packages/server/ - prisma/schema.prisma 数据模型(16 个 model,无 enum、无 @db.*;连接串在 prisma.config.ts) - prisma/migrations/ 16 个迁移(到 add_schedule_source 为止是旧模型;之后 6 个是 + prisma/schema.prisma 数据模型(24 个 model,无 enum、无 @db.*;连接串在 prisma.config.ts) + prisma/migrations/ 17 个迁移(到 add_schedule_source 为止是旧模型;之后 6 个是 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.config.ts Prisma 7 配置:schema / migrations / seed / 连接串 scripts/verify-e2e.mjs 后端端到端验收(条数由脚本统计,2026-10-06 实测 205 项) @@ -134,10 +137,12 @@ packages/server/ password schemas session term version(读 package.json) web-static src/middleware/ auth validate error security src/realtime/ socket.ts(房间)+ bus.ts(事件总线) - src/modules/ 17 个功能模块 + registry.ts + module.types.ts - (含 archives 毕业归档、term 学期周次) -packages/web-admin/src/ api stores router layouts views(14) components(5) composables styles config.ts - (含 ArchivesView 毕业归档、TermWeeksView 学期周次) + src/modules/ 21 个功能模块 + registry.ts + module.types.ts + (含 archives 毕业归档、term 学期周次、permissions 权限组、 + clients 客户端心跳与远控、system 开发者模式/开机自启、ai 大模型助手) +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/ src/main/ 主进程:index / config(含作业短语与看板偏好)/ ipc / island / tray / update / logger / smoke 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 硬性要求) | -| pnpm | 11.8.0(`packageManager` 锁定 11.8.0),宿主就是上面这个 node | +| 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)。本机可用的本体在 `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`,取全局配置) | | 依赖 | 已装(707 包,`pnpm install` 实测 2m45s) | | 后端 `.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:` 盘不存在。 > 另有一份旧副本在 `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 diff --git a/packages/desktop-client/src/renderer/cache/self-test.ts b/packages/desktop-client/src/renderer/cache/self-test.ts index 510a5d5..8087374 100644 --- a/packages/desktop-client/src/renderer/cache/self-test.ts +++ b/packages/desktop-client/src/renderer/cache/self-test.ts @@ -1048,6 +1048,15 @@ export async function layoutChromeSelfTest(): Promise { // 2.2) 导轨必须"一眼看全":先把设置组展开(11 行图标 = 最坏情况),再断言菜单区没有纵向溢出。 // 折叠态页脚必须把**高度**也收成 0 —— 只淡出不留高度的话,那 ~70px 会把最后两个图标 // 顶出可视区,用户就得上下滑动(反馈原话:"菜单图标无法完全显示需要上下滑动")。 + // + // ⚠️ 量之前必须等页脚真的收成 0 高:宽度到 64px 与页脚收拢各有一条 260ms 过渡, + // 而 `--nav-ease`(Material 曲线)在 74% 处就越过"宽度 ≤ 80px"这个等待条件, + // 此时页脚还剩十几像素 —— 直接量 `.menu` 会读出"溢出 16px"的**假红** + // (实测出现过一次:同一份产物上一次全绿、下一次红)。这里等的是**稳态几何**, + // 与这条断言本来的意图一致(CSS 里那句"量到的是稳态")。 + const railFooter = document.querySelector('.aside-footer'); + await waitUntil(() => (railFooter?.offsetHeight ?? 0) <= 1, 2000); + const settingsItem = Array.from( document.querySelectorAll('.aside .el-sub-menu .el-menu-item'), ).find((element) => diff --git a/packages/desktop-client/src/renderer/island/bridge.ts b/packages/desktop-client/src/renderer/island/bridge.ts index 16ed5f1..eec5850 100644 --- a/packages/desktop-client/src/renderer/island/bridge.ts +++ b/packages/desktop-client/src/renderer/island/bridge.ts @@ -6,9 +6,9 @@ import { type NotificationDto, type ScheduleDto, } from '@classhelper/shared'; -import { scheduleApi } from '../api/index.js'; +import { notificationApi, scheduleApi } from '../api/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 { useNotificationStore } from '../stores/notifications.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 { + // 冒烟保护:`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 在挂载时订阅一次。 diff --git a/packages/desktop-client/src/renderer/layouts/ClientLayout.vue b/packages/desktop-client/src/renderer/layouts/ClientLayout.vue index 1b485ab..477428d 100644 --- a/packages/desktop-client/src/renderer/layouts/ClientLayout.vue +++ b/packages/desktop-client/src/renderer/layouts/ClientLayout.vue @@ -25,9 +25,9 @@ const ui = useUiStore(); * * - **一级项**:课表 / 作业 / 通知 / 成绩 —— 直接铺在侧栏上,**不再套「学习」分组**。 * 分组标题白占一行、还得多点一次展开,折叠成图标导轨时尤其明显(用户要求拆掉)。 - * - **分组**:只剩「设置」(通用 / 外观 / 灵动岛 / 提醒 / 账号 / 关于,各自是独立路由)。 - * 6 个子项平铺太长,保留分组与"默认只展开当前路由所在分组"。折叠后是图标导轨, - * 分组子项与一级项一样以图标列出(见 styles/index.css 的折叠态规则)。 + * - **分组**:只剩「设置」(通用 / 外观 / 灵动岛 / 提醒 / 账号 / 关于,各自是独立路由; + * 开发者模式下再多一个「开发者」)。6 个子项平铺太长,保留分组与"默认只展开当前路由所在分组"。 + * 折叠后是图标导轨,分组子项与一级项一样以图标列出(见 styles/index.css 的折叠态规则)。 * * 顶栏左侧汉堡按钮把整栏收成图标导轨(状态持久化在主进程配置里)。 */ @@ -38,20 +38,38 @@ const primaryItems = [ { path: '/grades', title: '成绩', icon: 'Trophy' }, ]; -const menuGroups = [ +/** 「设置」分组的常规子项(顺序即侧栏顺序) */ +const settingItems = [ + { path: '/settings/general', title: '通用', icon: 'Tools' }, + { path: '/settings/appearance', title: '外观', icon: 'Brush' }, + { path: '/settings/island', title: '灵动岛', icon: 'MagicStick' }, + { path: '/settings/reminder', title: '提醒', icon: 'AlarmClock' }, + { path: '/settings/account', title: '账号', icon: 'UserFilled' }, + { 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: [ - { path: '/settings/general', title: '通用', icon: 'Tools' }, - { path: '/settings/appearance', title: '外观', icon: 'Brush' }, - { path: '/settings/island', title: '灵动岛', icon: 'MagicStick' }, - { path: '/settings/reminder', title: '提醒', icon: 'AlarmClock' }, - { path: '/settings/account', title: '账号', icon: 'UserFilled' }, - { path: '/settings/about', title: '关于', icon: 'InfoFilled' }, - ], + 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 }); +/** + * 底片的**裁剪框**:与 `.menu` 的滚动视口逐像素重合。 + * + * 底片挂在 `.aside` 上,而菜单项是在 `.menu` 里滚动的(`.menu` 是 `flex:1 + min-height:0 + + * overflow-y:auto`,设置组展开时内容约 606px 会超过窗口高度)。激活项被滚出视口时, + * 底片会跟着它跑到品牌区或页脚上 —— 套一层视口大小的 `overflow:hidden`, + * 就是让底片"只在菜单看得见的地方存在"。 + */ +const clipStyle = ref>({ + top: '0px', + left: '0px', + width: '0px', + height: '0px', +}); + function syncIndicator(): void { const aside = document.querySelector('.aside.ch-nav'); - const active = aside?.querySelector('.el-menu-item.is-active'); - if (!aside || !active) { + const scroller = aside?.querySelector('.menu') ?? null; + if (!aside || !scroller) { indicator.value = { ...indicator.value, visible: false }; return; } 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('.el-menu-item.is-active'); + if (!active) { + indicator.value = { ...indicator.value, visible: false }; + return; + } const rect = active.getBoundingClientRect(); indicator.value = { - x: rect.left - asideRect.left, - y: rect.top - asideRect.top, + x: rect.left - boxRect.left, + y: rect.top - boxRect.top, width: rect.width, height: rect.height, 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` * 已在文档里,量完再让浏览器首帧绘制 —— 指示器第一次出现就是正确位置,不会从左上角滑过去。 @@ -125,6 +197,11 @@ function scheduleIndicatorSync(): void { onMounted(() => { syncIndicator(); const aside = document.querySelector('.aside.ch-nav'); + if (aside) { + // 菜单自己的滚动容器:翻菜单时底片必须跟着走(用户反馈的「上下滑动时项目选择块不移动」) + menuScroller = aside.querySelector('.menu'); + menuScroller?.addEventListener('scroll', handleMenuScroll, { passive: true }); + } if (aside && typeof ResizeObserver !== 'undefined') { asideObserver = new ResizeObserver(scheduleIndicatorSync); asideObserver.observe(aside); @@ -134,6 +211,10 @@ onMounted(() => { onUnmounted(() => { if (indicatorFrame) cancelAnimationFrame(indicatorFrame); indicatorFrame = 0; + if (scrollSettleTimer) window.clearTimeout(scrollSettleTimer); + scrollSettleTimer = 0; + menuScroller?.removeEventListener('scroll', handleMenuScroll); + menuScroller = null; asideObserver?.disconnect(); asideObserver = null; }); @@ -172,7 +253,7 @@ const activeMenu = computed(() => route.path); /** 当前路由所属的分组标题(用于"只默认展开所在分组",与 ClassIsland 一致) */ 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,别拿某个组的名字顶上去 return group?.title ?? null; }); @@ -190,7 +271,7 @@ const openedGroups = ref(activeGroupTitle.value ? [activeGroupTitle.va watch( () => route.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; // 展开当前组(不主动关闭用户已展开的其它组) await nextTick(); @@ -215,7 +296,7 @@ function go(name: string): void { /** 开发期自检:菜单路径必须存在于路由表中 */ if (import.meta.env.DEV) { 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) { if (!knownPaths.has(item.path)) { console.error(`[ClientLayout] 菜单项未注册对应路由:${item.path}`); @@ -301,8 +382,16 @@ onUnmounted(() => { ClassHelper - - -
    -
  • +
    + + + + -
    -
    - - - {{ PRIORITY_LABELS[item.priority as NotificationPriority] }} - - {{ item.title }} -
    -
    {{ item.content }}
    + + + + + + + 清除筛选 + +
    + + +
    +
    +
    + {{ dayLabel(group.date) }} + {{ group.date }} · {{ group.items.length }} 条
    -
    -
    {{ relativeTime(item.createdAt) }}
    -
    {{ formatDate(item.createdAt, true) }}
    - 已读 - 未读 -
    -
  • -
+
    +
  • +
    +
    + + + {{ PRIORITY_LABELS[item.priority as NotificationPriority] }} + + {{ item.title }} +
    +
    {{ item.content }}
    +
    +
    +
    {{ relativeTime(item.createdAt) }}
    +
    {{ formatDate(item.createdAt, true) }}
    + 已读 + 未读 +
    +
  • +
+ +
@@ -147,6 +281,30 @@ onUnmounted(() => { diff --git a/packages/desktop-client/src/renderer/views/settings/GeneralSettingsView.vue b/packages/desktop-client/src/renderer/views/settings/GeneralSettingsView.vue index ac88f65..0a64b10 100644 --- a/packages/desktop-client/src/renderer/views/settings/GeneralSettingsView.vue +++ b/packages/desktop-client/src/renderer/views/settings/GeneralSettingsView.vue @@ -1,12 +1,14 @@ @@ -238,6 +426,77 @@ onMounted(async () => { 恢复默认 + + + +
+
+ 开机自启 +

登录 Windows 后自动启动 ClassHelper;教室机器建议打开。

+
+ +
+
+
+ 自动更新 +

+ 安装只在 + 不上课且窗口已隐藏 5 分钟后 + 进行,下载随时可能发生。 +

+

服务端可以统一要求更新,关掉它只会把安装推迟到手动处理。

+
+ +
+
+ 打开日志目录 + 日志文件 main.log,排查问题时可发给管理员 +
+
+ + + +
+
+ 关怀模式 +

+ 放大正文与控件字号(页面标题、表格、表单、卡片标题一起变大);圆角、投影与侧栏宽度等几何尺寸保持原样,侧栏图标导轨也不放大。 +

+

+ 「开启后同时放大 AI 助手按钮」这一条只在 Web 管理端生效 —— 本客户端目前没有助手入口。 +

+
+ +
+
+
+ 开发者模式 +

显示调试页面与详细日志,仅排查问题时打开。

+

+ debug 级日志已开启;侧栏「设置 → 开发者」可查看版本、设备上报、更新检查、日志目录与 DevTools。 +

+
+ +
+
@@ -265,6 +524,35 @@ onMounted(async () => { description="断网后课表、作业、通知、成绩都会从本地缓存读取;恢复网络后自动向服务器同步最新数据。" /> + + + +

+ 本机每 30 秒向服务端上报一次状态(是否在上课、当前版本、已下载的更新), + 服务端据此下发统一更新与远控命令。本机标识尾号用于在服务端设备列表里核对这台电脑。 +

+ + + + {{ agent?.online ? '已连接(近期上报成功)' : '未连接(等待上报)' }} + + + + {{ appVersion }} + + + {{ agent?.deviceKeyTail || '—' }} + + {{ lastHeartbeatText }} + {{ updateTargetText }} + {{ pendingUpdateText }} + {{ lastErrorText }} + +
+ 立即上报 + 刷新状态 +
+
@@ -282,4 +570,29 @@ onMounted(async () => { .ml-8 { 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; +} diff --git a/packages/desktop-client/src/types/desktop.d.ts b/packages/desktop-client/src/types/desktop.d.ts index 468d144..d12def9 100644 --- a/packages/desktop-client/src/types/desktop.d.ts +++ b/packages/desktop-client/src/types/desktop.d.ts @@ -9,6 +9,10 @@ import type { IslandState, UpdateInfo, } from '@classhelper/shared'; +import type { ClientAgentStatus } from '../main/client-agent.js'; + +/** 服务端联动状态:渲染进程要显示它,因此在这里转出(`desktop.d.ts` 是两端契约的单一入口) */ +export type { ClientAgentStatus }; /** 作业页展示偏好(看板 / 列表,看板外观) */ export interface HomeworkBoardSettings { @@ -66,6 +70,25 @@ export interface DesktopStoredConfig { * 在「关于」页手动检查时仍会正常显示结果。 */ ignoredUpdateVersion: string; + /** + * 本机设备标识(UUID):这台机器在服务端的身份。 + * + * 服务端按它 upsert 设备记录(在线状态 / 待执行命令 / 已下载版本都挂在它上面), + * **首次读取配置时自动生成并落盘,之后永不变**;渲染进程只读,不允许改写。 + */ + deviceKey: string; + /** 是否开机自启(登录 Windows 后静默启动,不弹主窗口) */ + autoLaunch: boolean; + /** 是否接受服务端统一下发的自动更新(下载随时进行,安装只在空闲时) */ + autoUpdate: boolean; + /** 开发者模式:把日志调到 debug 级,并在设置里显示「开发者」页 */ + developerMode: boolean; + /** 关怀模式:整体放大正文与控件字号(不动圆角/阴影/侧栏几何) */ + careMode: boolean; + /** 已下载待安装的更新版本(空串 = 无) */ + pendingUpdateVersion: string; + /** 已下载待安装的安装包绝对路径(空串 = 无) */ + pendingUpdateFile: string; } export interface DesktopAppInfo { @@ -165,6 +188,26 @@ export interface DesktopBridge { onIslandMarkRead(handler: (id: string) => void): void; /** 订阅"灵动岛点了标为已读(多条通知的整批)",一次把这一批都标记已读 */ onIslandMarkAllRead(handler: (ids: string[]) => void): void; + + /* 开机自启 / 日志 / 服务端联动 */ + /** 读当前是否已注册为开机自启(以系统登录项为准,不是配置文件里的值) */ + getAutoLaunch(): Promise; + /** 设置开机自启:写配置 + 同步系统登录项,返回生效后的值 */ + setAutoLaunch(enabled: boolean): Promise; + /** 打开日志目录(排查问题用;日志文件是 main.log) */ + openLogDir(): Promise; + /** + * 打开主窗口的开发者工具(detached 独立窗口)。 + * + * 只在**非冒烟模式**下允许:冒烟验证会给窗口注入大量合成事件与截图, + * 多一个 DevTools 窗口会抢焦点、改变窗口尺寸,把那些断言打乱。 + * 返回是否真的打开了(冒烟模式下返回 false)。 + */ + openDevTools(): Promise; + /** 读服务端联动状态(设置页「服务端联动状态」区块) */ + getClientAgentStatus(): Promise; + /** 立即向服务端上报一次,返回上报后的最新状态 */ + beatClientNow(): Promise; } /** 灵动岛窗口自身的桥接(只暴露订阅状态与发送操作) */ diff --git a/packages/server/package.json b/packages/server/package.json index c3a9916..5c47288 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -1,6 +1,6 @@ { "name": "@classhelper/server", - "version": "1.2.0", + "version": "1.3.0", "private": true, "description": "ClassHelper后端服务:REST API + Socket.IO 实时推送 + Prisma 数据访问", "type": "module", diff --git a/packages/server/prisma/migrations/20261010120000_add_permission_groups_and_clients/migration.sql b/packages/server/prisma/migrations/20261010120000_add_permission_groups_and_clients/migration.sql new file mode 100644 index 0000000..252b6c2 --- /dev/null +++ b/packages/server/prisma/migrations/20261010120000_add_permission_groups_and_clients/migration.sql @@ -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"); diff --git a/packages/server/prisma/schema.prisma b/packages/server/prisma/schema.prisma index b09de24..e10e8f8 100644 --- a/packages/server/prisma/schema.prisma +++ b/packages/server/prisma/schema.prisma @@ -43,6 +43,8 @@ model User { role String @default("TEACHER") /// 手机号(教师联系方式,可空) phone String @default("") + /// 关怀模式(大字版界面):本人开关,Web 端与客户端各记一份 + careMode Boolean @default(false) createdAt DateTime @default(now()) updatedAt DateTime @updatedAt @@ -52,6 +54,8 @@ model User { courses Course[] createdHomeworks Homework[] @relation("HomeworkCreator") createdNotices Notification[] @relation("NotificationCreator") + /// 分配的权限组;**没有任何分组时**回落角色默认模板(见 shared 的 ROLE_DEFAULT_PERMISSIONS) + permissionGroups UserPermissionGroup[] @@index([role]) } @@ -240,6 +244,7 @@ model Class { timeLayouts TimeLayout[] integrationDevices IntegrationDevice[] classIslandPushes ClassIslandPush[] + clientDevices ClientDevice[] @@index([teacherId]) @@index([enrollmentYear]) @@ -353,6 +358,9 @@ model Notification { title String content String priority String @default("NORMAL") + /// 发布所属日期(**本地日期 YYYY-MM-DD,不带时区**):按天归档的唯一依据。 + /// 与 Homework.assignDate 同口径(shared 的 dayKeyLocal),**不要**用 createdAt 切 UTC。 + assignDate String @default("") createdBy String createdAt DateTime @default(now()) updatedAt DateTime @updatedAt @@ -362,6 +370,7 @@ model Notification { reads NotificationRead[] @@index([classId, createdAt]) + @@index([classId, assignDate]) } /// 通知已读记录 @@ -516,3 +525,174 @@ model ClassIslandPush { @@index([classId, createdAt]) @@index([deviceId, ackedAt]) } + +/* -------------------------------------------------------------------------- */ +/* 权限组 / 全局设置 / 客户端设备 / 远控命令 / 客户端安装包 / 大模型审计 */ +/* -------------------------------------------------------------------------- */ + +/// 权限组:管理员自定义的一组**权限键**(`shared` 的 `PERMISSION_KEYS`),可分配给多个账号。 +/// +/// 语义(与 `packages/shared/src/permissions.ts` 的目录一一对应): +/// - 账号**没有**任何 `UserPermissionGroup` 时,生效集合 = 角色默认模板(`ROLE_DEFAULT_PERMISSIONS`); +/// - 一旦分了组,生效集合 = 这些组的键并集(可以比默认更少 ⇒ 也能用来**收回**权限); +/// - `ADMIN` 永远补上 `permission:manage` / `system:manage`,避免把自己锁死。 +model PermissionGroup { + id String @id @default(cuid()) + name String @unique + description String @default("") + /// 内置组:只在种子/引导时创建,管理员不可删除(可改成员与键) + builtin Boolean @default(false) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + items PermissionGroupItem[] + users UserPermissionGroup[] +} + +/// 权限组里的一个权限键(一行一个键,便于查询与去重) +model PermissionGroupItem { + id String @id @default(cuid()) + groupId String + /// 权限键,取值必须是 shared 的 PERMISSION_KEYS 之一(服务端 zod 校验) + key String + createdAt DateTime @default(now()) + + group PermissionGroup @relation(fields: [groupId], references: [id], onDelete: Cascade) + + @@unique([groupId, key]) + @@index([groupId]) +} + +/// 账号 ←→ 权限组 的分配关系(一个账号可属多个组) +model UserPermissionGroup { + id String @id @default(cuid()) + userId String + groupId String + createdAt DateTime @default(now()) + + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + group PermissionGroup @relation(fields: [groupId], references: [id], onDelete: Cascade) + + @@unique([userId, groupId]) + @@index([userId]) + @@index([groupId]) +} + +/// 全局键值设置(大模型配置、开发者模式等)。 +/// +/// 值一律存字符串(复杂结构存 JSON 文本),键名常量在 `src/lib/settings.ts`; +/// 密钥类值(如大模型 API Key)加密后存这里,接口**只进不出**。 +model AppSetting { + key String @id + value String @default("") + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt +} + +/// ClassHelper 客户端设备(装在学生机/教室机上的桌面客户端)。 +/// +/// 身份由客户端主进程生成并持久化的 `deviceKey`(UUID)标识,心跳每 30 秒一次: +/// 服务端由此知道**每台机器在哪个班、版本多少、是否在线**,并把更新与远控 +/// 指令按「查库 + 心跳拉取」的范式下发(不新增长连接握手,照抄 +/// `IntegrationDevice.requestScheduleReport` 的做法)。 +model ClientDevice { + id String @id @default(cuid()) + /// 该设备当前登录的班级(未登录时为空) + classId String? + /// 设备唯一标识(客户端主进程生成,UUID) + deviceKey String @unique + /// 设备名(默认取 os.hostname(),管理员可改名) + name String @default("") + /// 客户端版本号(来自 app.getVersion()) + clientVersion String @default("") + /// 系统与架构,如 win32-x64 + platform String @default("") + /// 是否接受服务端统一下发的自动更新 + autoUpdate Boolean @default(true) + /// 服务端指定的目标版本('' = 不指定,按最新版走) + updateTargetVersion String @default("") + /// 已下载待安装的版本('' = 无) + pendingVersion String @default("") + /// 最近一次心跳时间(**在线判定看它**) + lastHeartbeatAt DateTime? + /// 最近一次心跳来源 IP(排查用) + lastIp String @default("") + /// 客户端上报的当前状态说明(如"上课中/已隐藏",仅展示用) + stateNote String @default("") + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + class Class? @relation(fields: [classId], references: [id], onDelete: SetNull) + commands ClientCommand[] + + @@index([classId]) + @@index([lastHeartbeatAt]) +} + +/// 下发给客户端设备的指令(远控:关机 / 重启 / 注销 / 锁屏)。 +/// +/// 客户端心跳时拉取未确认且未过期的指令,**执行前必须在界面上二次确认**, +/// 执行后回执(`ackedAt` + `result`)。 +model ClientCommand { + id String @id @default(cuid()) + deviceId String + /// shutdown | restart | logoff | lock + kind String + /// 附加参数(JSON 文本,预留:延时秒数、提示语) + payload String @default("") + createdBy String? + createdAt DateTime @default(now()) + /// 过期时间:超过后不再下发(避免机器离线后几天突然关机) + expiresAt DateTime? + /// 客户端确认执行的时间 + ackedAt DateTime? + /// 执行结果说明(成功 / 失败原因) + result String @default("") + /// 管理员撤销的时间(非空即不再下发) + cancelledAt DateTime? + + device ClientDevice @relation(fields: [deviceId], references: [id], onDelete: Cascade) + + @@index([deviceId, ackedAt]) + @@index([createdAt]) +} + +/// 服务端缓存的客户端安装包:教室机器**没有外网**时也能统一更新。 +/// +/// 管理员上传后落在 `data/client-releases/`,客户端从 +/// `GET /api/update/client/download/:version` 下载(继承守卫与限流)。 +model ClientRelease { + id String @id @default(cuid()) + version String @unique + fileName String + /// 安装包 sha256(客户端下载后必须校验) + sha256 String @default("") + /// 字节数(管理员上传时记录,供下载进度与展示用) + size Int @default(0) + /// 发布说明(展示给管理员) + notes String @default("") + createdBy String? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt +} + +/// 大模型指令审计:谁在什么时候用"一句话"做了什么、结果如何。 +/// +/// 只记**意图与结果**,不记密钥;`rawReply` 供排查模型输出格式问题时查看。 +model AiAudit { + id String @id @default(cuid()) + userId String + /// 用户原话 + prompt String + /// 模型解析出的意图(如 homework.publish / notification.publish / unknown) + intent String @default("") + /// pending | confirmed | rejected | failed + status String @default("pending") + /// 结果说明(失败原因、生成的标题等) + message String @default("") + /// 模型原始回复(截断保存,排查用) + rawReply String @default("") + createdAt DateTime @default(now()) + + @@index([userId, createdAt]) +} diff --git a/packages/server/prisma/seed.ts b/packages/server/prisma/seed.ts index d3b13bb..a8931b9 100644 --- a/packages/server/prisma/seed.ts +++ b/packages/server/prisma/seed.ts @@ -70,6 +70,17 @@ function createRandom(seed: number): () => number { async function resetDatabase(): Promise { // 顺序敏感:先删子表,再删主表 + // 权限组 / 客户端设备 / 远端指令 / 安装包缓存 / 大模型审计(2026-10-10 新增表) + // 注意:ClientDevice.classId 是 SetNull,不显式删会留下"孤儿设备"(班级已删、设备还在) + await prisma.clientCommand.deleteMany(); + await prisma.clientDevice.deleteMany(); + await prisma.clientRelease.deleteMany(); + await prisma.aiAudit.deleteMany(); + await prisma.userPermissionGroup.deleteMany(); + await prisma.permissionGroupItem.deleteMany(); + await prisma.permissionGroup.deleteMany(); + // 全局设置(大模型配置 / 开发者模式)不属于业务数据,但演示库重置时一并清掉,保证可复现 + await prisma.appSetting.deleteMany(); await prisma.grade.deleteMany(); await prisma.homeworkStatus.deleteMany(); await prisma.homework.deleteMany(); @@ -117,6 +128,32 @@ async function main(): Promise { data: { username: 'teacher2', name: '李老师', role: 'TEACHER', passwordHash: teacherHash }, }); + // 内置权限组示例:**没有分配给任何账号**,所以不影响默认行为(演示「创建权限组」这条需求)。 + // 一旦把某个老师加进组,他的生效权限就**只由组的并集决定**(可以比角色默认更少 ⇒ 也能收回权限)。 + await prisma.permissionGroup.create({ + data: { + name: '教务协作(示例)', + description: '可查看班级与课表、发布作业与通知、录入成绩;不能创建班级或管理教师账号', + builtin: true, + items: { + create: [ + 'class:read', + 'schedule:read', + 'homework:read', + 'homework:write', + 'notification:read', + 'notification:publish', + 'grade:read', + 'grade:write', + 'dashboard:read', + 'term:read', + 'client:read', + 'ai:use', + ].map((key) => ({ key })), + }, + }, + }); + // 班级账号:班级码(ClassHelper 班级端「班级登录」的账号)+ 班级密码(默认 123456,可在班级管理里重置) // // 称呼按「入学年份 + 班号」生成:2026 级是高一(当前学年 2026-2027),2025 级是高二。 diff --git a/packages/server/scripts/apply-column-migrations.cjs b/packages/server/scripts/apply-column-migrations.cjs index dfdf409..7786344 100644 --- a/packages/server/scripts/apply-column-migrations.cjs +++ b/packages/server/scripts/apply-column-migrations.cjs @@ -68,6 +68,18 @@ const COLUMNS = [ sql: `ALTER TABLE "User" ADD COLUMN "phone" TEXT NOT NULL DEFAULT ''`, note: '教师手机号', }, + { + table: 'User', + column: 'careMode', + sql: `ALTER TABLE "User" ADD COLUMN "careMode" BOOLEAN NOT NULL DEFAULT false`, + note: '关怀模式(大字版界面)', + }, + { + table: 'Notification', + column: 'assignDate', + sql: `ALTER TABLE "Notification" ADD COLUMN "assignDate" TEXT NOT NULL DEFAULT ''`, + note: '通知所属日期(本地日期 YYYY-MM-DD,按天归档用)', + }, ]; /** diff --git a/packages/server/scripts/verify-e2e.mjs b/packages/server/scripts/verify-e2e.mjs index c399759..9e58da7 100644 --- a/packages/server/scripts/verify-e2e.mjs +++ b/packages/server/scripts/verify-e2e.mjs @@ -167,9 +167,35 @@ async function main() { method: 'POST', body: { username: 'teacher2', password: 'teacher123' }, }); - const teacher2Token = teacher2Login.payload?.data?.token; - const teacher2Id = teacher2Login.payload?.data?.user?.id; + let teacher2Token = teacher2Login.payload?.data?.token; + let teacher2Id = teacher2Login.payload?.data?.user?.id; const teacher1ClassIds = new Set(classes.map((item) => item.id)); + // 演示库里的密码可能被人工改过(本机 teacher1 就有被改成 123456 的情况):登录失败时由管理员 + // 重置为验证环境已知值再重登。**teacher2 是"科任老师/班级隔离"整组用例的主体**, + // 上一版漏了这条兜底,密码一改后面整片用例都只能跑空分支。 + if (!teacher2Token) { + const staffList = await api('/teachers', { token: adminToken }); + const fallbackTeacher2Id = + teacher2Id ?? (staffList.payload?.data ?? []).find((item) => item.username === 'teacher2')?.id; + if (fallbackTeacher2Id) { + await api(`/teachers/${fallbackTeacher2Id}/reset-password`, { + method: 'POST', + token: adminToken, + body: { newPassword: 'e2e-teacher2-123456' }, + }); + const retryTeacher2 = await api('/auth/login', { + method: 'POST', + body: { username: 'teacher2', password: 'e2e-teacher2-123456' }, + }); + teacher2Token = retryTeacher2.payload?.data?.token; + teacher2Id = retryTeacher2.payload?.data?.user?.id; + record( + '教师 teacher2 密码被改过,已由管理员重置为验证环境已知值(登录环境自愈)', + Boolean(teacher2Token), + `status=${retryTeacher2.status}`, + ); + } + } const teacher2Classes = (await api('/classes', { token: teacher2Token })).payload?.data ?? []; /** 本次验证临时创建的班级(收尾统一删除,不污染演示数据) */ const probeClassIds = []; @@ -1191,6 +1217,13 @@ async function main() { if (subjectCourse) { const otherCourse = (await api(`/courses?classId=${subjectClassId}`, { token: adminToken })) .payload?.data?.find((item) => item.teacherId !== teacher2Id); + // 先确认"别人任教的科目"这个前提存在:缺了它,下面那条用例会因为"没带科目" + // 而被拒(403),看着是绿的,其实没验到"非任教科目"这条规则。 + record( + '存在"别人任教科目"用于越权校验', + Boolean(otherCourse?.id), + `科目=${otherCourse?.name ?? '-'}`, + ); const foreignHomework = await api('/homeworks', { method: 'POST', token: teacher2Token, @@ -1285,6 +1318,641 @@ async function main() { } } + // ---------------------------------------------------------------- 6.5 通知按天归档 + 批量发布 + { + const DAY_KEY = /^\d{4}-\d{2}-\d{2}$/; + + // 按天归档:通知也有"所属日期"(assignDate,本地日期),晚间发布不会被 UTC 切片算到第二天 + const daysRes = await api(`/notifications/days?classId=${classId}`, { token: teacherToken }); + const days = daysRes.payload?.data?.days ?? []; + record( + '通知按天归档 GET /notifications/days(按所属日期汇总条数)', + daysRes.status === 200 && + days.length > 0 && + days.every((item) => DAY_KEY.test(String(item.date)) && item.count > 0), + `status=${daysRes.status} 天数=${days.length} 样例=${JSON.stringify(days[0] ?? null)}`, + ); + + const creatorsRes = await api(`/notifications/creators?classId=${classId}`, { token: teacherToken }); + const creators = creatorsRes.payload?.data?.creators ?? []; + record( + '通知按发布人归档 GET /notifications/creators(带名字与条数)', + creatorsRes.status === 200 && + creators.length > 0 && + creators.every((item) => item.id && item.name && item.count > 0), + `status=${creatorsRes.status} 发布人=${creators.length} 样例=${JSON.stringify(creators[0] ?? null)}`, + ); + + // 按天筛选:返回的每条通知 assignDate 必须等于筛选的日期 + const targetDay = days[0]?.date; + if (targetDay) { + const byDay = await api(`/notifications?classId=${classId}&date=${targetDay}`, { token: teacherToken }); + const rows = byDay.payload?.data ?? []; + record( + '通知按天筛选(?date=)只返回该天的通知', + byDay.status === 200 && rows.length > 0 && rows.every((item) => item.assignDate === targetDay), + `date=${targetDay} 条数=${rows.length}`, + ); + } + + const badDay = await api(`/notifications/days?classId=${classId}&from=2026-13-45`, { token: teacherToken }); + record('通知归档接口拒绝非法日期(422)', badDay.status === 422, `status=${badDay.status}`); + + // 批量发布通知:一次提交多个班,返回逐班结果 + const bulkClassIds = foreignClass ? [classId, foreignClass.id] : [classId]; + const bulkNotice = await api('/notifications/bulk', { + method: 'POST', + token: adminToken, + body: { + classIds: bulkClassIds, + title: `批量发布回归 ${Date.now()}`, + content: '批量发布通知用例(管理员对多个班级一次发布)', + priority: 'NORMAL', + }, + }); + const bulkNoticeData = bulkNotice.payload?.data; + record( + '批量发布通知 POST /notifications/bulk(逐班结果)', + bulkNotice.status === 201 && + bulkNoticeData?.total === bulkClassIds.length && + bulkNoticeData?.succeeded === bulkClassIds.length && + (bulkNoticeData?.results ?? []).every((item) => item.ok && item.id), + `status=${bulkNotice.status} total=${bulkNoticeData?.total} 成功=${bulkNoticeData?.succeeded}`, + ); + for (const item of bulkNoticeData?.results ?? []) { + if (item.id) await api(`/notifications/${item.id}`, { method: 'DELETE', token: adminToken }); + } + + if (foreignClass) { + // 教师批量发到"别人的班":那个班逐班失败,自己的班照常发布,整批不能变成 500 + const mixed = await api('/notifications/bulk', { + method: 'POST', + token: teacherToken, + body: { + classIds: [classId, foreignClass.id], + title: `批量越权回归 ${Date.now()}`, + content: '其中一个班没有发布权限', + priority: 'NORMAL', + }, + }); + const mixedData = mixed.payload?.data; + const denied = (mixedData?.results ?? []).find((item) => item.classId === foreignClass.id); + record( + '批量发布通知:无权限的班逐班失败、有权限的班照常发布', + mixed.status === 201 && mixedData?.succeeded === 1 && denied?.ok === false, + `status=${mixed.status} 成功=${mixedData?.succeeded} 失败code=${denied?.code ?? '-'} 失败原因=${denied?.message ?? '-'}`, + ); + for (const item of mixedData?.results ?? []) { + if (item.id) await api(`/notifications/${item.id}`, { method: 'DELETE', token: teacherToken }); + } + + // 批量布置作业:跨班按**科目名称**匹配课程(课程是班级内的,不可能共用一个 courseId) + const bulkHomework = await api('/homeworks/bulk', { + method: 'POST', + token: adminToken, + body: { + classIds: [classId, foreignClass.id], + subjectName: '语文', + title: `批量布置回归 ${Date.now()}`, + content: '批量布置作业用例(按科目名称跨班匹配)', + }, + }); + const bulkHomeworkData = bulkHomework.payload?.data; + record( + '批量布置作业 POST /homeworks/bulk(逐班结果 + 按科目名称匹配课程)', + bulkHomework.status === 201 && + bulkHomeworkData?.total === 2 && + (bulkHomeworkData?.results ?? []).every((item) => typeof item.ok === 'boolean'), + `status=${bulkHomework.status} 成功=${bulkHomeworkData?.succeeded} 失败=${bulkHomeworkData?.failed} ` + + `逐班=${JSON.stringify((bulkHomeworkData?.results ?? []).map((item) => item.code ?? 'ok'))}`, + ); + for (const item of bulkHomeworkData?.results ?? []) { + if (item.id) await api(`/homeworks/${item.id}`, { method: 'DELETE', token: adminToken }); + } + } + } + + // ---------------------------------------------------------------- 6.6 权限组 + { + const meTeacher = await api('/permissions/me', { token: teacherToken }); + const meTeacherData = meTeacher.payload?.data; + const meAdmin = await api('/permissions/me', { token: adminToken }); + const meAdminData = meAdmin.payload?.data; + record( + '当前账号生效权限 GET /permissions/me(教师有 homework:write、无 permission:manage)', + meTeacher.status === 200 && + Array.isArray(meTeacherData?.permissions) && + meTeacherData.permissions.includes('homework:write') && + !meTeacherData.permissions.includes('permission:manage'), + `status=${meTeacher.status} 权限数=${(meTeacherData?.permissions ?? []).length} ` + + `fromGroups=${meTeacherData?.fromGroups}`, + ); + record( + '管理员生效权限是全集(含 permission:manage 与 remote:control)', + meAdmin.status === 200 && + (meAdminData?.permissions ?? []).includes('permission:manage') && + (meAdminData?.permissions ?? []).includes('remote:control'), + `status=${meAdmin.status} 权限数=${(meAdminData?.permissions ?? []).length}`, + ); + + // 权限键目录是 permission:manage 专属:教师 403、管理员拿到整棵树 + const catalogDenied = await api('/permissions/catalog', { token: teacherToken }); + const catalog = await api('/permissions/catalog', { token: adminToken }); + const catalogData = catalog.payload?.data; + record( + '权限目录 GET /permissions/catalog(教师 403 / 管理员拿到 keys+categories+defaultRoles)', + catalogDenied.status === 403 && + catalog.status === 200 && + Array.isArray(catalogData?.keys) && + catalogData.keys.includes('homework:write') && + Array.isArray(catalogData?.categories) && + typeof catalogData?.defaultRoles === 'object' && + catalogData.defaultRoles !== null, + `教师=${catalogDenied.status} 管理员=${catalog.status} 键数=${(catalogData?.keys ?? []).length} ` + + `分类数=${(catalogData?.categories ?? []).length}`, + ); + + // 权限组可以比角色默认**更少**:只给 dashboard:read 的组分配给 teacher2 后,他只剩这一个键 + const groupName = `e2e-只读-${Date.now().toString().slice(-6)}`; + const createdGroup = await api('/permissions/groups', { + method: 'POST', + token: adminToken, + body: { name: groupName, description: '验证用临时权限组(收尾删除)', keys: ['dashboard:read'] }, + }); + const permissionGroupId = createdGroup.payload?.data?.id; + + // 成员列表是"账号 id"的权威来源:登录响应里的 user.id 只是会话用户, + // 权限页的成员 id 要按 username 从 GET /permissions/users 取(管理员本人也要在这里找) + const membersRes = await api('/permissions/users', { token: adminToken }); + const members = membersRes.payload?.data ?? []; + const teacher2Member = members.find((item) => item.username === 'teacher2') ?? null; + const adminMember = members.find((item) => item.username === 'admin') ?? null; + + // teacher2 的密码按脚本既有做法自愈(本机可能被人工改过),失败就重置成已知值再登 + const teacher2Password = 'teacher123'; + let teacher2Auth = await api('/auth/login', { + method: 'POST', + body: { username: 'teacher2', password: teacher2Password }, + }); + if (teacher2Auth.status !== 200 && teacher2Member) { + await api(`/teachers/${teacher2Member.id}/reset-password`, { + method: 'POST', + token: adminToken, + body: { newPassword: teacher2Password }, + }); + teacher2Auth = await api('/auth/login', { + method: 'POST', + body: { username: 'teacher2', password: teacher2Password }, + }); + } + const permissionTestToken = teacher2Auth.payload?.data?.token; + + const assignLess = permissionGroupId + ? await api(`/permissions/users/${teacher2Member?.id}/groups`, { + method: 'PUT', + token: adminToken, + body: { groupIds: [permissionGroupId] }, + }) + : { status: 0, payload: null }; + const meLess = permissionTestToken + ? await api('/permissions/me', { token: permissionTestToken }) + : { status: 0, payload: null }; + const teacher2LessPermissions = meLess.payload?.data?.permissions ?? []; + record( + '权限组可以比角色默认更少(只给 dashboard:read 的组 → 生效权限恰好只有这一个键)', + Boolean(permissionGroupId) && + assignLess.status === 200 && + teacher2LessPermissions.length === 1 && + teacher2LessPermissions[0] === 'dashboard:read', + `建组=${createdGroup.status} 分配=${assignLess.status} 生效权限=${JSON.stringify(teacher2LessPermissions)}`, + ); + + const assignDefault = await api(`/permissions/users/${teacher2Member?.id}/groups`, { + method: 'PUT', + token: adminToken, + body: { groupIds: [] }, + }); + const meReset = permissionTestToken + ? await api('/permissions/me', { token: permissionTestToken }) + : { status: 0, payload: null }; + const teacher2ResetPermissions = meReset.payload?.data?.permissions ?? []; + record( + '清空权限组后回到角色默认模板(PUT /users/:id/groups 传空数组 → 含 homework:write)', + assignDefault.status === 200 && + teacher2ResetPermissions.includes('homework:write') && + !teacher2ResetPermissions.includes('permission:manage'), + `status=${assignDefault.status} 生效权限数=${teacher2ResetPermissions.length} ` + + `含 homework:write=${teacher2ResetPermissions.includes('homework:write')}`, + ); + + // 管理员固定拥有全部权限,不接受分配 + const assignAdmin = await api(`/permissions/users/${adminMember?.id}/groups`, { + method: 'PUT', + token: adminToken, + body: { groupIds: permissionGroupId ? [permissionGroupId] : [] }, + }); + record( + '管理员不接受权限组分配(PUT /users/:id/groups → 400)', + Boolean(adminMember) && + assignAdmin.status === 400 && + String(assignAdmin.payload?.message ?? '').includes('权限组'), + `status=${assignAdmin.status} 响应=${assignAdmin.payload?.message ?? ''}`, + ); + + // 内置权限组不可删除:先找一个 builtin 的(**演示库当前没有内置组**,此时只报事实、不算失败) + const groupsList = await api('/permissions/groups', { token: adminToken }); + const builtinGroup = (groupsList.payload?.data ?? []).find((item) => item.builtin === true) ?? null; + if (builtinGroup) { + const deleteBuiltin = await api(`/permissions/groups/${builtinGroup.id}`, { method: 'DELETE', token: adminToken }); + record( + '内置权限组不可删除(DELETE /permissions/groups/:id → 400)', + deleteBuiltin.status === 400, + `内置组=${builtinGroup.name} status=${deleteBuiltin.status} 响应=${deleteBuiltin.payload?.message ?? ''}`, + ); + } else { + record( + '内置权限组不可删除(DELETE /permissions/groups/:id → 400)', + true, + `跳过:当前库里没有任何 builtin=true 的权限组(GET /permissions/groups 返回 ${(groupsList.payload?.data ?? []).length} 个,种子里的「教务协作(示例)」不在库里)`, + ); + } + + // 收尾:teacher2 必须回到角色默认(否则后面所有以他为科任老师的用例全被权限组污染),临时组删掉 + await api(`/permissions/users/${teacher2Member?.id}/groups`, { + method: 'PUT', + token: adminToken, + body: { groupIds: [] }, + }); + if (permissionGroupId) { + await api(`/permissions/groups/${permissionGroupId}`, { method: 'DELETE', token: adminToken }); + } + const groupsAfter = await api('/permissions/groups', { token: adminToken }); + record( + '权限组用例收尾:teacher2 分组已清空、临时权限组已删除', + !(groupsAfter.payload?.data ?? []).some((item) => item.id === permissionGroupId), + `剩余权限组=${(groupsAfter.payload?.data ?? []).length}`, + ); + } + + // ---------------------------------------------------------------- 6.7 客户端设备与远控 + { + const deviceKey = `e2e-device-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`; + const heartbeatBase = { + deviceKey, + name: 'E2E 测试机', + clientVersion: '1.0.0', + platform: 'win32', + stateNote: '待机', + }; + + // 心跳不强求权限键(教室端会话权限集为空),但**必须登录**:没有 JWT 一律 401 + const heartbeatNoAuth = await api('/clients/heartbeat', { method: 'POST', body: heartbeatBase }); + const heartbeat = await api('/clients/heartbeat', { method: 'POST', token: adminToken, body: heartbeatBase }); + const heartbeatData = heartbeat.payload?.data; + record( + '客户端心跳 POST /clients/heartbeat(未登录 401 / 登录后 200 且返回 deviceId 与 command 字段)', + heartbeatNoAuth.status === 401 && + heartbeat.status === 200 && + Boolean(heartbeatData?.deviceId) && + 'command' in (heartbeatData ?? {}), + `未登录=${heartbeatNoAuth.status} 登录=${heartbeat.status} deviceId=${heartbeatData?.deviceId ?? '-'} ` + + `command=${JSON.stringify(heartbeatData?.command ?? null)}`, + ); + + // 越权:remote:control 默认只有管理员有 + const teacherCommandDenied = await api('/clients/commands', { + method: 'POST', + token: teacherToken, + body: { deviceIds: [heartbeatData?.deviceId ?? 'none'], kind: 'lock', note: '越权用例' }, + }); + record( + '远控命令越权被拒(教师无 remote:control → 403)', + teacherCommandDenied.status === 403, + `status=${teacherCommandDenied.status} 响应=${teacherCommandDenied.payload?.message ?? ''}`, + ); + + // 设备列表:刚心跳的这台要在线 + const devices = await api('/clients/devices', { token: adminToken }); + const device = (devices.payload?.data ?? []).find((item) => item.deviceKey === deviceKey) ?? null; + record( + '设备列表 GET /clients/devices(刚心跳的设备在线)', + devices.status === 200 && Boolean(device) && device.online === true, + `status=${devices.status} 设备=${device?.name ?? '未找到'} online=${device?.online}`, + ); + + // 下发锁屏命令(带备注)→ 下一次心跳必须带回这条命令 + const command = await api('/clients/commands', { + method: 'POST', + token: adminToken, + body: { deviceIds: [device?.id ?? heartbeatData?.deviceId ?? 'none'], kind: 'lock', note: 'E2E 远控用例' }, + }); + const heartbeatWithCommand = await api('/clients/heartbeat', { + method: 'POST', + token: adminToken, + body: { ...heartbeatBase, stateNote: '待机' }, + }); + const delivered = heartbeatWithCommand.payload?.data?.command; + record( + '远控命令下发 POST /clients/commands(lock)并在下次心跳送达(kind=lock)', + command.status === 201 && Boolean(delivered?.id) && delivered?.kind === 'lock', + `下发=${command.status} 送达=${JSON.stringify(delivered ?? null)}`, + ); + + const ackBody = { deviceKey, ok: true, result: 'e2e' }; + const ackFirst = await api(`/clients/commands/${delivered?.id}/ack`, { + method: 'POST', + token: adminToken, + body: ackBody, + }); + // 幂等:客户端重试回执不报错、也不覆盖第一次结果 + const ackSecond = await api(`/clients/commands/${delivered?.id}/ack`, { + method: 'POST', + token: adminToken, + body: { deviceKey, ok: false, result: '重试不应覆盖结果' }, + }); + record( + '命令回执 POST /clients/commands/:id/ack(首次 200,重复回执幂等仍 200)', + ackFirst.status === 200 && ackSecond.status === 200, + `首次=${ackFirst.status}(${ackFirst.payload?.data?.result ?? '-'}) 重复=${ackSecond.status}(${ackSecond.payload?.data?.result ?? '-'})`, + ); + + const devicesAfterAck = await api('/clients/devices', { token: adminToken }); + const ackedDevice = (devicesAfterAck.payload?.data ?? []).find((item) => item.deviceKey === deviceKey) ?? null; + record( + '回执后设备不再有待执行命令(pendingCommand 为空)', + Boolean(ackedDevice) && ackedDevice.pendingCommand === null, + `pendingCommand=${JSON.stringify(ackedDevice?.pendingCommand ?? null)}`, + ); + + // 归属校验:别人拿自己的 deviceKey 回执同一 id 必须被拒 + const otherDeviceKey = `${deviceKey}-other`; + const otherHeartbeat = await api('/clients/heartbeat', { + method: 'POST', + token: adminToken, + body: { ...heartbeatBase, deviceKey: otherDeviceKey, name: 'E2E 另一台测试机' }, + }); + const otherDeviceId = otherHeartbeat.payload?.data?.deviceId ?? null; + const ackWrongDevice = await api(`/clients/commands/${delivered?.id}/ack`, { + method: 'POST', + token: adminToken, + body: { deviceKey: otherDeviceKey, ok: true, result: 'e2e-other' }, + }); + record( + '回执归属校验(别的 deviceKey 回执同一命令 → 403)', + ackWrongDevice.status === 403, + `status=${ackWrongDevice.status} code=${ackWrongDevice.payload?.code ?? '-'} ` + + `响应=${ackWrongDevice.payload?.message ?? ''}`, + ); + + // 收尾:两台测试机都清掉(否则会长期挂在设备列表里) + const deleteTestDevice = device + ? await api(`/clients/devices/${device.id}`, { method: 'DELETE', token: adminToken }) + : { status: 0, payload: null }; + const deleteOtherDevice = otherDeviceId + ? await api(`/clients/devices/${otherDeviceId}`, { method: 'DELETE', token: adminToken }) + : { status: 0, payload: null }; + const deviceAfterDelete = await api('/clients/devices', { token: adminToken }); + const leftoverKeys = (deviceAfterDelete.payload?.data ?? []) + .filter((item) => item.deviceKey === deviceKey || item.deviceKey === otherDeviceKey) + .map((item) => item.deviceKey); + record( + '客户端设备用例收尾:两台测试机都已移除', + deleteTestDevice.status === 200 && deleteOtherDevice.status === 200 && leftoverKeys.length === 0, + `删除测试机=${deleteTestDevice.status} 删除另一台=${deleteOtherDevice.status} ` + + `残留=${leftoverKeys.length ? leftoverKeys.join(',') : '无'}`, + ); + } + + // ---------------------------------------------------------------- 6.8 客户端安装包 + { + const releases = await api('/update/client/releases', { token: adminToken }); + record( + '客户端安装包列表 GET /update/client/releases', + releases.status === 200 && Array.isArray(releases.payload?.data), + `status=${releases.status} 版本数=${(releases.payload?.data ?? []).length}`, + ); + + // 登记只写元数据,**但要求磁盘上真的有这个版本的安装包**:不存在的版本必须被拒(400) + const fakeVersion = '9.9.9-e2e'; + const registerMissing = await api('/update/client/releases', { + method: 'POST', + token: adminToken, + body: { version: fakeVersion }, + }); + const releasesAfterRegister = await api('/update/client/releases', { token: adminToken }); + record( + '登记不存在的客户端安装包版本被拒(400,且不会落库)', + registerMissing.status === 400 && + !(releasesAfterRegister.payload?.data ?? []).some((item) => item.version === fakeVersion), + `status=${registerMissing.status} code=${registerMissing.payload?.code ?? '-'} ` + + `响应=${registerMissing.payload?.message ?? ''}`, + ); + + // 版本白名单:含路径分隔符的版本号连 schema 都过不去 + const badVersion = await api('/update/client/releases', { + method: 'POST', + token: adminToken, + body: { version: '../evil' }, + }); + record( + '版本号白名单(POST /update/client/releases 传 ../evil → 400/422)', + badVersion.status === 400 || badVersion.status === 422, + `status=${badVersion.status} code=${badVersion.payload?.code ?? '-'} ` + + `响应=${badVersion.payload?.message ?? ''}`, + ); + + // 下载接口本身不挂权限键,但**要登录**:不带 token 会被 401 挡在鉴权层 + const downloadNoAuth = await api(`/update/client/download/${encodeURIComponent(fakeVersion)}`); + const downloadMissing = await api(`/update/client/download/${encodeURIComponent(fakeVersion)}`, { + token: adminToken, + }); + record( + '下载不存在的客户端安装包(未登录 401 / 已登录 404)', + downloadNoAuth.status === 401 && downloadMissing.status === 404, + `未登录=${downloadNoAuth.status} 已登录=${downloadMissing.status} ` + + `响应=${downloadMissing.payload?.message ?? ''}`, + ); + } + + // ---------------------------------------------------------------- 6.9 大模型助手(一律 mock,绝不联网) + { + const aiSettingsDenied = await api('/ai/settings', { token: teacherToken }); + const aiSettings = await api('/ai/settings', { token: adminToken }); + const aiSettingsData = aiSettings.payload?.data ?? {}; + const aiSettingsText = JSON.stringify(aiSettingsData); + record( + '大模型设置 GET /ai/settings(教师 403 / 管理员 200,且响应里没有任何密钥字段)', + aiSettingsDenied.status === 403 && + aiSettings.status === 200 && + typeof aiSettingsData.enabled === 'boolean' && + 'provider' in aiSettingsData && + 'baseUrl' in aiSettingsData && + 'model' in aiSettingsData && + 'hasApiKey' in aiSettingsData && + 'keyHint' in aiSettingsData && + 'systemPromptExtra' in aiSettingsData && + !aiSettingsText.includes('apiKey'), + `教师=${aiSettingsDenied.status} 管理员=${aiSettings.status} 字段=${Object.keys(aiSettingsData).join(',')}`, + ); + + // 助手没开启时 /interpret 会 400:先开启,且**强制走离线 mock 解析**(绝不联网) + const enableAi = await api('/ai/settings', { + method: 'PUT', + token: adminToken, + body: { enabled: true, provider: 'mock' }, + }); + record( + '开启大模型助手并固定为离线 mock(PUT /ai/settings)', + enableAi.status === 200 && enableAi.payload?.data?.enabled === true && enableAi.payload?.data?.provider === 'mock', + `status=${enableAi.status} enabled=${enableAi.payload?.data?.enabled} provider=${enableAi.payload?.data?.provider}`, + ); + + const promptText = '给2026级1班布置作业,把卷子写完'; + const interpret = await api('/ai/interpret', { + method: 'POST', + token: teacherToken, + body: { text: promptText }, + }); + const interpretData = interpret.payload?.data; + record( + '助手解析布置作业(POST /ai/interpret → create_homework,需确认、班级非空、有预览)', + interpret.status === 200 && + interpretData?.action === 'create_homework' && + interpretData?.needConfirm === true && + Boolean(interpretData?.params?.classId) && + Boolean(interpretData?.preview?.summary), + `status=${interpret.status} action=${interpretData?.action} needConfirm=${interpretData?.needConfirm} ` + + `classId=${interpretData?.params?.classId ?? '-'} summary=${interpretData?.preview?.summary ?? '-'}`, + ); + + const unknownInterpret = await api('/ai/interpret', { + method: 'POST', + token: teacherToken, + body: { text: '今天天气不错' }, + }); + record( + '助手听不懂时返回 unknown(不猜动作)', + unknownInterpret.status === 200 && unknownInterpret.payload?.data?.action === 'unknown', + `status=${unknownInterpret.status} action=${unknownInterpret.payload?.data?.action}`, + ); + + const audit = await api('/ai/audit', { token: adminToken }); + const auditRows = audit.payload?.data ?? []; + record( + '助手使用审计 GET /ai/audit(能看到刚写入的那句话)', + audit.status === 200 && auditRows.some((item) => item.prompt === promptText), + `status=${audit.status} 记录数=${auditRows.length}`, + ); + + // 收尾:助手必须还原成关闭(否则别的东西会以为已经配好了模型) + const disableAi = await api('/ai/settings', { + method: 'PUT', + token: adminToken, + body: { enabled: false }, + }); + record( + '大模型助手用例收尾:enabled 已还原为 false', + disableAi.status === 200 && disableAi.payload?.data?.enabled === false, + `status=${disableAi.status} enabled=${disableAi.payload?.data?.enabled}`, + ); + } + + // ---------------------------------------------------------------- 6.10 系统设置 + { + const systemDenied = await api('/system/settings', { token: teacherToken }); + const systemSettings = await api('/system/settings', { token: adminToken }); + record( + '系统设置 GET /system/settings(教师 403 / 管理员 200)', + systemDenied.status === 403 && + systemSettings.status === 200 && + typeof systemSettings.payload?.data?.developerMode === 'boolean', + `教师=${systemDenied.status} 管理员=${systemSettings.status} ` + + `developerMode=${systemSettings.payload?.data?.developerMode}`, + ); + + // 开发者模式:开启后错误响应会带上堆栈,所以**必须**还原 + const enableDeveloper = await api('/system/settings', { + method: 'PUT', + token: adminToken, + body: { developerMode: true }, + }); + const readBackDeveloper = await api('/system/settings', { token: adminToken }); + const disableDeveloper = await api('/system/settings', { + method: 'PUT', + token: adminToken, + body: { developerMode: false }, + }); + const readBackDisabled = await api('/system/settings', { token: adminToken }); + record( + '开发者模式可开关并回读(PUT /system/settings → GET 一致,随后还原为 false)', + enableDeveloper.status === 200 && + readBackDeveloper.payload?.data?.developerMode === true && + disableDeveloper.status === 200 && + readBackDisabled.payload?.data?.developerMode === false, + `开启=${enableDeveloper.status} 回读=${readBackDeveloper.payload?.data?.developerMode} ` + + `还原=${readBackDisabled.payload?.data?.developerMode}`, + ); + + const autostart = await api('/system/autostart', { token: adminToken }); + const autostartData = autostart.payload?.data; + const enableAutostart = await api('/system/autostart', { + method: 'PUT', + token: adminToken, + body: { enabled: true }, + }); + record( + '开机自启:开发机不支持(GET supported=false,PUT 拒绝 400)', + autostart.status === 200 && + autostartData?.supported === false && + typeof autostartData?.detail === 'string' && + autostartData.detail.length > 0 && + enableAutostart.status === 400, + `GET=${autostart.status} supported=${autostartData?.supported} mechanism=${autostartData?.mechanism} ` + + `detail=${autostartData?.detail ?? '-'} PUT=${enableAutostart.status} ` + + `响应=${enableAutostart.payload?.message ?? ''}`, + ); + + const diagnostics = await api('/system/diagnostics', { token: adminToken }); + const diagnosticsText = JSON.stringify(diagnostics.payload?.data ?? {}); + record( + '运行诊断 GET /system/diagnostics(200 且不泄漏 apiKey/password/token/secret)', + diagnostics.status === 200 && + !/apiKey|password|token|secret/i.test(diagnosticsText), + `status=${diagnostics.status} 字段=${Object.keys(diagnostics.payload?.data ?? {}).join(',')}`, + ); + } + + // ---------------------------------------------------------------- 6.11 批量发布补漏 + { + // 一个不存在的班级 + 一个真实班级:单班失败不能把整批带崩(逐班结果,不整批回滚) + const missingClassId = `not-exist-${Date.now().toString(36)}`; + const mixedBulk = await api('/notifications/bulk', { + method: 'POST', + token: adminToken, + body: { + classIds: [missingClassId, classId], + title: `批量失败隔离回归 ${Date.now()}`, + content: '其中一个班不存在,另一个班照常发布', + priority: 'NORMAL', + }, + }); + const mixedBulkData = mixedBulk.payload?.data; + const missingResult = (mixedBulkData?.results ?? []).find((item) => item.classId === missingClassId); + record( + '批量发布:不存在的班级逐班失败、真实班级照常成功(failed>=1 且 succeeded>=1)', + mixedBulk.status === 201 && + (mixedBulkData?.failed ?? 0) >= 1 && + (mixedBulkData?.succeeded ?? 0) >= 1 && + missingResult?.ok === false, + `status=${mixedBulk.status} total=${mixedBulkData?.total} 成功=${mixedBulkData?.succeeded} ` + + `失败=${mixedBulkData?.failed} 失败code=${missingResult?.code ?? '-'}`, + ); + // 清理:只删成功落库的那几条(失败的班没有 id) + for (const item of mixedBulkData?.results ?? []) { + if (item.id) await api(`/notifications/${item.id}`, { method: 'DELETE', token: adminToken }); + } + } + // ---------------------------------------------------------------- 7. 权限隔离 if (foreignClass) { const studentCrossClass = await api(`/classes/${foreignClass.id}`, { token: studentToken }); @@ -1684,6 +2352,76 @@ async function main() { `status=${gradesByHeadTeacher.status} 跳过=${gradesByHeadTeacher.payload?.data?.skipped} 新增=${gradesByHeadTeacher.payload?.data?.inserted}`, ); + // 8.3.1 科目归属越权回归(修复"非该科科任老师可以布置/导入其他科内容"): + // ① 新建成同名课程必须被拒绝 —— 否则谁都能借"新建科目"把自己写成该科任课老师, + // 后面那道 assertCanManageSubjectContent 的科目门随即失效; + // ② 班主任导入**别人任教科目**的成绩必须逐行拒绝(原先只判到班级这一层)。 + { + const guardedCourseName = `越权校验科目${Date.now().toString(36).slice(-5).toUpperCase()}`; + const firstCourse = await api('/courses', { + method: 'POST', + token: adminToken, + body: { classId, name: guardedCourseName }, + }); + const firstCourseId = firstCourse.payload?.data?.id; + record( + '新建科目成功(用于越权校验)', + firstCourse.status === 201 && Boolean(firstCourseId), + `status=${firstCourse.status} id=${firstCourseId ?? '-'}`, + ); + + const duplicateCourse = await api('/courses', { + method: 'POST', + token: adminToken, + body: { classId, name: guardedCourseName }, + }); + record( + '同名课程被拒绝并返回已有课程 id(COURSE_EXISTS)', + duplicateCourse.status === 409 && + duplicateCourse.payload?.code === 'COURSE_EXISTS' && + duplicateCourse.payload?.details?.courseId === firstCourseId, + `status=${duplicateCourse.status} code=${duplicateCourse.payload?.code} details=${JSON.stringify(duplicateCourse.payload?.details ?? {})}`, + ); + + // 这门课的任课老师是**管理员**(course.create 会把创建者写成任课老师): + // 班主任导入它的成绩必须被拒,且不能落库。 + const guardedCsv = [ + '学号,考试名称,分数,总分,科目', + `${targetStudent?.studentNo ?? '202601'},越权校验考试,88,100,${guardedCourseName}`, + ].join(NL); + const guardedImport = await api('/imports/table/commit', { + method: 'POST', + token: teacherToken, + body: { + kind: 'grades', + classId, + fileName: 'guarded.csv', + contentBase64: Buffer.from(`\ufeff${guardedCsv}${NL}`, 'utf8').toString('base64'), + mapping: { + studentNo: '学号', + examName: '考试名称', + score: '分数', + totalScore: '总分', + courseName: '科目', + }, + mode: 'append', + }, + }); + const guardedErrors = guardedImport.payload?.data?.errors ?? []; + record( + '班主任导入他人任教科目成绩被逐行拒绝(不落库)', + guardedImport.status === 200 && + guardedImport.payload?.data?.inserted === 0 && + guardedImport.payload?.data?.failed === 1 && + String(guardedErrors[0]?.message ?? '').includes('不是你任教的科目'), + `新增=${guardedImport.payload?.data?.inserted} 失败=${guardedImport.payload?.data?.failed} 提示=${guardedErrors[0]?.message ?? '-'}`, + ); + + if (firstCourseId) { + await api(`/courses/${firstCourseId}`, { method: 'DELETE', token: adminToken }); + } + } + // 8.4 学生名单导入(仅管理员)+ 数据清理 const importedUsername = `IMP${Date.now().toString(36).slice(-6)}`.toUpperCase(); // 学生导入模板已无"初始密码"列(学生没有账号属性);表头是「学号」 @@ -2433,17 +3171,48 @@ async function main() { // ---------------------------------------------------------------- 数据库管理(仅管理员) // 覆盖:状态 / 越权 403 / 连接测试 / 备份→导入导出→恢复 roundtrip / 定时配置校验 / 切换校验分支。 // 注意:**不真跑切换**(会改写 .env 与 schema.prisma),只验证拒绝分支;真实切换需人工复验。 + // 数据表清单随模型演进增长(2026-10-10 加了权限组 / 客户端管理 8 张表后共 23 张)。 + // **不写死总数**:断言"服务端快照枚举 == 这份清单",加模型时只改这一处。 + const REQUIRED_DB_TABLES = [ + 'User', + 'ArchivedYear', + 'Class', + 'Student', + 'Course', + 'StudentClassTransfer', + 'Schedule', + 'TimeLayout', + 'Homework', + 'HomeworkStatus', + 'Notification', + 'NotificationRead', + 'Grade', + 'IntegrationDevice', + 'ClassIslandPush', + 'PermissionGroup', + 'PermissionGroupItem', + 'UserPermissionGroup', + 'AppSetting', + 'ClientDevice', + 'ClientCommand', + 'ClientRelease', + 'AiAudit', + ]; const dbStatus = await api('/database/status', { token: adminToken }); const dbStatusData = dbStatus.payload?.data ?? {}; + const dbStatusTableNames = Array.isArray(dbStatusData.tables) + ? dbStatusData.tables.map((item) => item.name) + : []; record( - '数据库状态(管理员):已连接且返回 15 张表行数', + '数据库状态(管理员):已连接且返回全部数据表行数', dbStatus.status === 200 && dbStatusData.connected === true && Array.isArray(dbStatusData.tables) && - dbStatusData.tables.length === 15 && - dbStatusData.tables.every((item) => item.count >= 0), + dbStatusData.tables.every((item) => item.count >= 0) && + dbStatusTableNames.length === REQUIRED_DB_TABLES.length && + REQUIRED_DB_TABLES.every((name) => dbStatusTableNames.includes(name)), `provider=${dbStatusData.provider} 延迟=${dbStatusData.latencyMs}ms 版本=${dbStatusData.version} 表=${ - (dbStatusData.tables ?? []).length + dbStatusTableNames.length }`, ); @@ -2556,13 +3325,17 @@ async function main() { } catch { snapshotParsed = null; } + const snapshotTableNames = Object.keys(snapshotParsed?.data ?? {}); record( - '导出快照(JSON 格式正确、15 张表、账号数 > 0)', + '导出快照(JSON 格式正确、全部数据表、账号数 > 0)', dbExport.status === 200 && snapshotParsed?.format === 'classhelper-snapshot' && - Object.keys(snapshotParsed?.data ?? {}).length === 15 && + snapshotTableNames.length === REQUIRED_DB_TABLES.length && + REQUIRED_DB_TABLES.every((name) => snapshotTableNames.includes(name)) && (snapshotParsed?.counts?.User ?? 0) > 0, - `status=${dbExport.status} 格式=${snapshotParsed?.format ?? '-'} 用户=${snapshotParsed?.counts?.User ?? '-'}`, + `status=${dbExport.status} 格式=${snapshotParsed?.format ?? '-'} 表=${snapshotTableNames.length} 用户=${ + snapshotParsed?.counts?.User ?? '-' + }`, ); // 造一条临时通知 → 快照导入(应被清掉)→ 再造一条 → 从备份恢复(也应被清掉) diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts index 9c86928..67b54a8 100644 --- a/packages/server/src/index.ts +++ b/packages/server/src/index.ts @@ -7,6 +7,7 @@ import { env } from './config/env.js'; import { disconnectPrisma, pingDatabase } from './lib/db.js'; import { ensureInitialAdmin, ensureSchema } from './lib/db-bootstrap.js'; import { logger } from './lib/logger.js'; +import { refreshDeveloperMode } from './lib/settings.js'; import { resolveWebDistDir } from './lib/web-static.js'; import { initRealtime } from './realtime/socket.js'; import { startBackupScheduler } from './modules/database/database.service.js'; @@ -51,6 +52,9 @@ async function prepareDatabase(): Promise { await prepareDatabase(); +// 开发者模式的内存缓存:错误中间件每请求都要问一次,不可能为此查库(见 lib/settings.ts) +await refreshDeveloperMode(); + const app = createApp(); const httpServer = createServer(app); const io = initRealtime(httpServer); diff --git a/packages/server/src/lib/bulk.ts b/packages/server/src/lib/bulk.ts new file mode 100644 index 0000000..7be9272 --- /dev/null +++ b/packages/server/src/lib/bulk.ts @@ -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 }), + }; + } + + 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)]; +} diff --git a/packages/server/src/lib/http.ts b/packages/server/src/lib/http.ts index d1c1871..84767a1 100644 --- a/packages/server/src/lib/http.ts +++ b/packages/server/src/lib/http.ts @@ -19,6 +19,14 @@ export type ApiErrorCode = | 'FORBIDDEN' | 'NOT_FOUND' | 'CONFLICT' + /** 课程:该班级已有同名课程(前端应复用 `details.courseId`,而不是另建一门) */ + | 'COURSE_EXISTS' + /** 大模型:功能未开启 / 还没配置服务地址与密钥 */ + | 'AI_NOT_CONFIGURED' + /** 大模型:调用超时或网络不可达 */ + | 'AI_TIMEOUT' + /** 大模型:返回的内容不是可用的结构化动作(已记日志,不把原始回复回传给前端) */ + | 'AI_INVALID_OUTPUT' | 'TOO_MANY_REQUESTS' /** 上课时间段发布紧急通知需要二次确认(前端据此弹出全屏警告 + 3 秒倒计时) */ | 'URGENT_DURING_CLASS' diff --git a/packages/server/src/lib/mappers.ts b/packages/server/src/lib/mappers.ts index 66d001f..282b753 100644 --- a/packages/server/src/lib/mappers.ts +++ b/packages/server/src/lib/mappers.ts @@ -378,6 +378,8 @@ export interface NotificationLike { priority: string; createdBy: string; createdAt: Date; + /** 通知所属日期(YYYY-MM-DD);老库可能为空串,DTO 层按 createdAt 本地日期兜底 */ + assignDate?: string; creator?: TeacherBriefLike | null; reads?: NotificationReadLike[]; } @@ -403,6 +405,8 @@ export function toNotificationDto( priority: item.priority as NotificationPriority, createdBy: item.createdBy, createdAt: toIso(item.createdAt), + // 老库兜底:assignDate 为空时按 createdAt 的本地日期回退,前端不必处理空值 + assignDate: item.assignDate || dayKeyLocal(item.createdAt), creator: item.creator ? toTeacherBrief(item.creator) : null, read: own ? true : false, readAt: toIsoOrNull(own?.readAt), diff --git a/packages/server/src/lib/permissions.ts b/packages/server/src/lib/permissions.ts new file mode 100644 index 0000000..2c141af --- /dev/null +++ b/packages/server/src/lib/permissions.ts @@ -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> { + const permissions = new Set(); + + 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(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]; +} diff --git a/packages/server/src/lib/secrets.ts b/packages/server/src/lib/secrets.ts new file mode 100644 index 0000000..d1fc4f9 --- /dev/null +++ b/packages/server/src/lib/secrets.ts @@ -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 ''; + } +} diff --git a/packages/server/src/lib/settings.ts b/packages/server/src/lib/settings.ts new file mode 100644 index 0000000..29f8842 --- /dev/null +++ b/packages/server/src/lib/settings.ts @@ -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 { + const row = await prisma.appSetting.findUnique({ where: { key }, select: { value: true } }); + return row?.value ?? ''; +} + +/** 批量读(避免逐键一条 SQL) */ +export async function getSettings(keys: readonly SettingKey[]): Promise> { + const rows = await prisma.appSetting.findMany({ + where: { key: { in: [...keys] } }, + select: { key: true, value: true }, + }); + const map: Record = {}; + 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 { + await prisma.appSetting.upsert({ + where: { key }, + create: { key, value }, + update: { value }, + }); +} + +export async function getBooleanSetting(key: SettingKey, fallback = false): Promise { + const raw = await getSetting(key); + if (raw === '') return fallback; + return raw === 'true' || raw === '1'; +} + +export async function setBooleanSetting(key: SettingKey, value: boolean): Promise { + 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 { + developerModeCache = await getBooleanSetting(SETTING_KEYS.developerMode, false); +} diff --git a/packages/server/src/lib/snapshot.ts b/packages/server/src/lib/snapshot.ts index e8361db..f848bf1 100644 --- a/packages/server/src/lib/snapshot.ts +++ b/packages/server/src/lib/snapshot.ts @@ -26,9 +26,18 @@ export const SNAPSHOT_VERSION = 1; * `StudentClassTransfer` 存调班与转出历史;`ArchivedYear` 是毕业归档的届别档案。 * 历史上那两张 `ClassTeacher`(无科目的协作关系)与 `Enrollment`(与 Student.classId 双写) * 已随 2026-10-06 的重构删除。 + * + * 2026-10-10 新增:权限组(`PermissionGroup` / `PermissionGroupItem` / `UserPermissionGroup`)、 + * 全局设置 `AppSetting`(大模型配置与开发者模式开关)、客户端设备与远控 + * (`ClientDevice` / `ClientCommand`)、客户端安装包缓存 `ClientRelease`、大模型审计 `AiAudit`。 + * **加新表一定同步这里**,否则备份/换库会静默丢表。 */ export const SNAPSHOT_TABLES = [ 'User', + 'PermissionGroup', + 'PermissionGroupItem', + 'UserPermissionGroup', + 'AppSetting', 'ArchivedYear', 'Class', 'Student', @@ -43,6 +52,10 @@ export const SNAPSHOT_TABLES = [ 'Grade', 'IntegrationDevice', 'ClassIslandPush', + 'ClientDevice', + 'ClientCommand', + 'ClientRelease', + 'AiAudit', ] as const; export type SnapshotTable = (typeof SNAPSHOT_TABLES)[number]; diff --git a/packages/server/src/middleware/auth.ts b/packages/server/src/middleware/auth.ts index 7139ebb..6fc0a7b 100644 --- a/packages/server/src/middleware/auth.ts +++ b/packages/server/src/middleware/auth.ts @@ -1,12 +1,17 @@ import type { Request, RequestHandler } from 'express'; -import type { UserRole } from '@classhelper/shared'; +import type { PermissionKey, UserRole } from '@classhelper/shared'; +import { PERMISSION_LABELS } from '@classhelper/shared'; import { prisma } from '../lib/db.js'; import { ApiError } from '../lib/http.js'; +import { loadEffectivePermissions } from '../lib/permissions.js'; import { extractBearerToken, verifyToken, type TokenPayload } from '../lib/jwt.js'; /** * 认证中间件:解析 Bearer token 并回查数据库, * 保证角色 / 班级变更(例如管理员换了班主任)立即生效,不被过期载荷影响。 + * + * 2026-10-10 起**顺带装载生效权限集合**(`req.auth.permissions`): + * 角色判据换成权限键判据(`requirePermission`),角色只用来决定"没有分组时的默认模板"。 */ export function authenticate(): RequestHandler { return async (req, _res, next) => { @@ -24,18 +29,16 @@ export function authenticate(): RequestHandler { }); if (!record) throw ApiError.unauthorized('班级不存在或已被删除'); - req.auth = { - token, - user: { - sub: record.id, - username: record.code, - name: record.name, - role: 'CLASS_DEVICE', - classId: record.id, - classSession: true, - classCode: record.code, - }, + const user: TokenPayload = { + sub: record.id, + username: record.code, + name: record.name, + role: 'CLASS_DEVICE', + classId: record.id, + classSession: true, + classCode: record.code, }; + req.auth = { token, user, permissions: await loadEffectivePermissions(user) }; next(); return; } @@ -47,16 +50,14 @@ export function authenticate(): RequestHandler { }); if (!user) throw ApiError.unauthorized('账号不存在或已被删除'); - req.auth = { - token, - user: { - sub: user.id, - username: user.username, - name: user.name, - role: user.role as UserRole, - classId: null, - }, + const authUser: TokenPayload = { + sub: user.id, + username: user.username, + name: user.name, + role: user.role as UserRole, + classId: null, }; + req.auth = { token, user: authUser, permissions: await loadEffectivePermissions(authUser) }; next(); } catch (error) { next(error); @@ -64,7 +65,7 @@ export function authenticate(): RequestHandler { }; } -/** 角色守卫 */ +/** 角色守卫(仍保留:少数场景要按角色而不是按权限键判断,如班级端专属入口) */ export function requireRole(...roles: UserRole[]): RequestHandler { return (req, _res, next) => { const user = req.auth?.user; @@ -80,8 +81,64 @@ export function requireRole(...roles: UserRole[]): RequestHandler { }; } +/** + * 权限守卫:要求当前登录主体拥有**全部**列出的权限键。 + * + * 权限集合来自 {@link loadEffectivePermissions}(权限组并集,或角色默认模板)。 + * 提示文案用中文权限名,用户看到的是"缺少「导入成绩」权限"而不是一堆键名。 + */ +export function requirePermission(...keys: PermissionKey[]): RequestHandler { + return (req, _res, next) => { + if (!req.auth?.user) { + next(ApiError.unauthorized()); + return; + } + const permissions = req.auth.permissions ?? new Set(); + 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(); + if (keys.length > 0 && !keys.some((key) => permissions.has(key))) { + const labels = keys.map((key) => `「${PERMISSION_LABELS[key]}」`).join('、'); + next(ApiError.forbidden(`当前账号缺少权限(${labels} 中至少需要一项),请联系管理员在「权限组」里分配`)); + return; + } + next(); + }; +} + /** 取出当前登录用户,未认证时抛 401 */ export function getAuthUser(req: Request): TokenPayload { if (!req.auth?.user) throw ApiError.unauthorized(); return req.auth.user; } + +/** 取出当前登录主体的生效权限集合(未认证时抛 401) */ +export function getAuthPermissions(req: Request): ReadonlySet { + if (!req.auth?.user) throw ApiError.unauthorized(); + return req.auth.permissions ?? new Set(); +} + +/** 当前主体是否有某个权限键(页面/服务里做条件分支用,找不到也当无权限) */ +export function hasAuthPermission(req: Request, key: PermissionKey): boolean { + return (req.auth?.permissions ?? new Set()).has(key); +} diff --git a/packages/server/src/middleware/error.ts b/packages/server/src/middleware/error.ts index 9d99c55..aedbaf1 100644 --- a/packages/server/src/middleware/error.ts +++ b/packages/server/src/middleware/error.ts @@ -3,6 +3,7 @@ import { ZodError } from 'zod'; import type { ApiErrorResponse } from '@classhelper/shared'; import { ApiError } from '../lib/http.js'; import { logger } from '../lib/logger.js'; +import { isDeveloperMode } from '../lib/settings.js'; import { env } from '../config/env.js'; function sendError( @@ -36,7 +37,21 @@ export const errorHandler: ErrorRequestHandler = (error, req, res, _next) => { if (error instanceof ApiError) { if (error.status >= 500) logger.error(error.message, error.details); - sendError(res, error.status, error.message, error.code, error.details); + /** + * 开发者模式:把 `error.stack` 一并回传,否则"服务端 500"在浏览器里只有一句中文, + * 排查时还得去服务器上翻日志。**500 才带**、且只在管理员显式打开开发者模式时生效 + * (开关见 `modules/system`),普通用户看到的东西一个字段都不多。 + */ + const withStack = + isDeveloperMode() && error.status >= 500 && error.stack + ? { + ...(error.details && typeof error.details === 'object' + ? (error.details as Record) + : { details: error.details }), + stack: error.stack, + } + : error.details; + sendError(res, error.status, error.message, error.code, withStack); return; } diff --git a/packages/server/src/modules/ai/ai.module.ts b/packages/server/src/modules/ai/ai.module.ts new file mode 100644 index 0000000..8ad6878 --- /dev/null +++ b/packages/server/src/modules/ai/ai.module.ts @@ -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(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(req); + sendOk(res, await aiService.interpret(user, input), '助手已解析,请确认后发布'); + }, +); + +router.get('/audit', requirePermission('ai:manage'), validate({ query: aiAuditQuerySchema }), async (req, res) => { + const query = validatedQuery(req); + sendOk(res, await aiService.listAudit(query.limit ?? 50), '获取助手使用记录成功'); +}); + +export const aiModule = defineModule({ + name: 'ai', + basePath: API_PATHS.ai, + router, +}); diff --git a/packages/server/src/modules/ai/ai.schemas.ts b/packages/server/src/modules/ai/ai.schemas.ts new file mode 100644 index 0000000..f54f74f --- /dev/null +++ b/packages/server/src/modules/ai/ai.schemas.ts @@ -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; +export type AiInterpretInput = z.infer; +export type AiAuditQuery = z.infer; diff --git a/packages/server/src/modules/ai/ai.service.ts b/packages/server/src/modules/ai/ai.service.ts new file mode 100644 index 0000000..2bfe74d --- /dev/null +++ b/packages/server/src/modules/ai/ai.service.ts @@ -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 { + 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 { + 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 { + 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 { + // 优先用句子里**真正提到的**班级(「给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> { + 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, +): Promise { + 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 { + 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 = {}; + 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; + } 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; + } 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; + } + } 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 { + 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(), + })); +} diff --git a/packages/server/src/modules/archives/archives.module.ts b/packages/server/src/modules/archives/archives.module.ts index b9b18f2..1d9b2b4 100644 --- a/packages/server/src/modules/archives/archives.module.ts +++ b/packages/server/src/modules/archives/archives.module.ts @@ -1,7 +1,7 @@ import { Router } from 'express'; import { sendCreated, sendOk } from '../../lib/http.js'; import { idParamSchema } from '../../lib/schemas.js'; -import { authenticate, getAuthUser, requireRole } from '../../middleware/auth.js'; +import { authenticate, getAuthUser, requirePermission } from '../../middleware/auth.js'; import { validate, validatedBody, validatedParams, validatedQuery } from '../../middleware/validate.js'; import { defineModule } from '../module.types.js'; import { @@ -25,13 +25,18 @@ router.use(authenticate()); */ /** GET /api/archives - 届别列表 */ -router.get('/', requireRole('ADMIN'), validate({ query: listArchivesQuerySchema }), async (req, res) => { - const user = getAuthUser(req); - sendOk(res, await archiveService.listArchivedYears(user), '获取届别列表成功'); -}); +router.get( + '/', + requirePermission('archive:manage'), + validate({ query: listArchivesQuerySchema }), + async (req, res) => { + const user = getAuthUser(req); + sendOk(res, await archiveService.listArchivedYears(user), '获取届别列表成功'); + }, +); /** GET /api/archives/summary - 归档页顶部汇总 + 可归档的届别建议 */ -router.get('/summary', requireRole('ADMIN'), async (req, res) => { +router.get('/summary', requirePermission('archive:manage'), async (req, res) => { sendOk( res, { @@ -43,16 +48,21 @@ router.get('/summary', requireRole('ADMIN'), async (req, res) => { }); /** GET /api/archives/:id - 某届详情(班级 + 毕业生 + 转出的学生) */ -router.get('/:id', requireRole('ADMIN'), validate({ params: idParamSchema }), async (req, res) => { - const user = getAuthUser(req); - const { id } = validatedParams<{ id: string }>(req); - sendOk(res, await archiveService.getArchivedYearDetail(user, id), '获取届别详情成功'); -}); +router.get( + '/:id', + requirePermission('archive:manage'), + validate({ params: idParamSchema }), + async (req, res) => { + const user = getAuthUser(req); + const { id } = validatedParams<{ id: string }>(req); + sendOk(res, await archiveService.getArchivedYearDetail(user, id), '获取届别详情成功'); + }, +); /** GET /api/archives/:id/classes/:classId/content - 归档班级的作业与通知(只读) */ router.get( '/:id/classes/:classId/content', - requireRole('ADMIN'), + requirePermission('archive:manage'), validate({ params: archiveClassParamSchema, query: archivedContentQuerySchema }), async (req, res) => { const user = getAuthUser(req); @@ -67,19 +77,29 @@ router.get( ); /** POST /api/archives - 执行毕业归档(按届别整届归档) */ -router.post('/', requireRole('ADMIN'), validate({ body: createArchiveSchema }), async (req, res) => { - const user = getAuthUser(req); - const result = await archiveService.createArchive(user, validatedBody(req)); - sendCreated(res, result, `${result.name} 已归档,共 ${result.classCount} 个班级`); -}); +router.post( + '/', + requirePermission('archive:manage'), + validate({ body: createArchiveSchema }), + async (req, res) => { + const user = getAuthUser(req); + const result = await archiveService.createArchive(user, validatedBody(req)); + sendCreated(res, result, `${result.name} 已归档,共 ${result.classCount} 个班级`); + }, +); /** DELETE /api/archives/:id - 撤销归档(管理员误操作的退路,不删任何数据) */ -router.delete('/:id', requireRole('ADMIN'), validate({ params: idParamSchema }), async (req, res) => { - const user = getAuthUser(req); - const { id } = validatedParams<{ id: string }>(req); - await archiveService.deleteArchive(user, id); - sendOk(res, { id }, '已撤销归档,班级与学生回到在读状态'); -}); +router.delete( + '/:id', + requirePermission('archive:manage'), + validate({ params: idParamSchema }), + async (req, res) => { + const user = getAuthUser(req); + const { id } = validatedParams<{ id: string }>(req); + await archiveService.deleteArchive(user, id); + sendOk(res, { id }, '已撤销归档,班级与学生回到在读状态'); + }, +); export const archivesModule = defineModule({ name: 'archives', diff --git a/packages/server/src/modules/calls/calls.module.ts b/packages/server/src/modules/calls/calls.module.ts index 4120cf3..6366373 100644 --- a/packages/server/src/modules/calls/calls.module.ts +++ b/packages/server/src/modules/calls/calls.module.ts @@ -1,6 +1,6 @@ import { Router } from 'express'; import { sendCreated } from '../../lib/http.js'; -import { authenticate, getAuthUser, requireRole } from '../../middleware/auth.js'; +import { authenticate, getAuthUser, requirePermission } from '../../middleware/auth.js'; import { validate } from '../../middleware/validate.js'; import { defineModule } from '../module.types.js'; import { createCallSchema, type CreateCallInput } from './calls.schemas.js'; @@ -13,7 +13,7 @@ router.use(authenticate()); * POST /api/calls - 叫人(老师点名让学生过来) * ClassHelper 班级端会在灵动岛上立即弹出"请 XXX 同学找 XXX 老师",无论是否在上课。 */ -router.post('/', requireRole('ADMIN', 'TEACHER'), validate({ body: createCallSchema }), async (req, res) => { +router.post('/', requirePermission('call:create'), validate({ body: createCallSchema }), async (req, res) => { const user = getAuthUser(req); sendCreated(res, await callService.createCall(user, req.body as CreateCallInput), '已通知该学生'); }); diff --git a/packages/server/src/modules/classes/classes.module.ts b/packages/server/src/modules/classes/classes.module.ts index 340e2db..8b2e5c0 100644 --- a/packages/server/src/modules/classes/classes.module.ts +++ b/packages/server/src/modules/classes/classes.module.ts @@ -1,7 +1,7 @@ import { Router } from 'express'; import { sendCreated, sendOk } from '../../lib/http.js'; import { idParamSchema } from '../../lib/schemas.js'; -import { authenticate, getAuthUser, requireRole } from '../../middleware/auth.js'; +import { authenticate, getAuthUser, requirePermission } from '../../middleware/auth.js'; import { validate, validatedBody, @@ -56,7 +56,7 @@ router.get('/', validate({ query: listClassesQuerySchema }), async (req, res) => */ router.post( '/promote', - requireRole('ADMIN'), + requirePermission('class:manage'), validate({ body: promoteClassesSchema }), async (req, res) => { const user = getAuthUser(req); @@ -73,7 +73,7 @@ router.get('/:id', validate({ params: idParamSchema }), async (req, res) => { }); /** POST /api/classes - 创建班级(称呼由入学年份 + 班号生成,如「2026级1班」) */ -router.post('/', requireRole('ADMIN'), validate({ body: createClassSchema }), async (req, res) => { +router.post('/', requirePermission('class:manage'), validate({ body: createClassSchema }), async (req, res) => { const user = getAuthUser(req); sendCreated(res, await classService.createClass(user, req.body as CreateClassInput), '班级创建成功'); }); @@ -81,7 +81,7 @@ router.post('/', requireRole('ADMIN'), validate({ body: createClassSchema }), as /** PATCH /api/classes/:id - 编辑班级(年级 / 届别 / 教学周数),仅管理员 */ router.patch( '/:id', - requireRole('ADMIN'), + requirePermission('class:manage'), validate({ params: idParamSchema, body: updateClassSchema }), async (req, res) => { const user = getAuthUser(req); @@ -93,7 +93,7 @@ router.patch( /** DELETE /api/classes/:id - 删除班级 */ router.delete( '/:id', - requireRole('ADMIN'), + requirePermission('class:manage'), validate({ params: idParamSchema }), async (req, res) => { const user = getAuthUser(req); @@ -109,7 +109,7 @@ router.delete( */ router.patch( '/:id/class-account', - requireRole('ADMIN'), + requirePermission('class:manage'), validate({ params: idParamSchema, body: updateClassAccountSchema }), async (req, res) => { const user = getAuthUser(req); @@ -128,7 +128,7 @@ router.patch( */ router.patch( '/:id/head-teacher', - requireRole('ADMIN'), + requirePermission('class:manage'), validate({ params: idParamSchema, body: assignHeadTeacherSchema }), async (req, res) => { const user = getAuthUser(req); @@ -147,7 +147,7 @@ router.patch( */ router.put( '/:id/subject-teachers', - requireRole('ADMIN'), + requirePermission('class:manage'), validate({ params: idParamSchema, body: assignSubjectTeachersSchema }), async (req, res) => { const user = getAuthUser(req); @@ -244,7 +244,7 @@ router.get('/:id/students', validate({ params: idParamSchema }), async (req, res /** POST /api/classes/:id/students - 把学生加入班级(按学号,已存在则转入并留调班痕迹) */ router.post( '/:id/students', - requireRole('ADMIN', 'TEACHER'), + requirePermission('roster:manage'), validate({ params: idParamSchema, body: addStudentSchema }), async (req, res) => { const user = getAuthUser(req); @@ -256,7 +256,7 @@ router.post( /** DELETE /api/classes/:id/students/:studentId - 移出学生(回到「未分班」,名单记录保留) */ router.delete( '/:id/students/:studentId', - requireRole('ADMIN', 'TEACHER'), + requirePermission('roster:manage'), validate({ params: classStudentParamSchema }), async (req, res) => { const user = getAuthUser(req); diff --git a/packages/server/src/modules/clients/clients.module.ts b/packages/server/src/modules/clients/clients.module.ts new file mode 100644 index 0000000..ed84ad1 --- /dev/null +++ b/packages/server/src/modules/clients/clients.module.ts @@ -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(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(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(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(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(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, +}); diff --git a/packages/server/src/modules/clients/clients.schemas.ts b/packages/server/src/modules/clients/clients.schemas.ts new file mode 100644 index 0000000..0bea4b3 --- /dev/null +++ b/packages/server/src/modules/clients/clients.schemas.ts @@ -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; +export type ClientCommandAckInput = z.infer; +export type CreateClientCommandInput = z.infer; +export type ListClientDevicesQuery = z.infer; +export type PushClientUpdateInput = z.infer; diff --git a/packages/server/src/modules/clients/clients.service.ts b/packages/server/src/modules/clients/clients.service.ts new file mode 100644 index 0000000..2704921 --- /dev/null +++ b/packages/server/src/modules/clients/clients.service.ts @@ -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 { + 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 { + 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 { + 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 { + 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(); + 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> { + 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 { + 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 { + 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 { + 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 }; +} diff --git a/packages/server/src/modules/courses/courses.module.ts b/packages/server/src/modules/courses/courses.module.ts index 1d5dd47..1ce173f 100644 --- a/packages/server/src/modules/courses/courses.module.ts +++ b/packages/server/src/modules/courses/courses.module.ts @@ -1,7 +1,7 @@ import { Router } from 'express'; import { sendCreated, sendOk } from '../../lib/http.js'; import { idParamSchema } from '../../lib/schemas.js'; -import { authenticate, getAuthUser, requireRole } from '../../middleware/auth.js'; +import { authenticate, getAuthUser, requirePermission } from '../../middleware/auth.js'; import { validate, validatedParams, validatedQuery } from '../../middleware/validate.js'; import { defineModule } from '../module.types.js'; import { @@ -26,7 +26,7 @@ router.get('/', validate({ query: listCoursesQuerySchema }), async (req, res) => /** POST /api/courses - 新增课程 */ router.post( '/', - requireRole('ADMIN', 'TEACHER'), + requirePermission('schedule:write'), validate({ body: createCourseSchema }), async (req, res) => { const user = getAuthUser(req); @@ -37,7 +37,7 @@ router.post( /** PATCH /api/courses/:id - 修改课程 */ router.patch( '/:id', - requireRole('ADMIN', 'TEACHER'), + requirePermission('schedule:write'), validate({ params: idParamSchema, body: updateCourseSchema }), async (req, res) => { const user = getAuthUser(req); @@ -49,7 +49,7 @@ router.patch( /** DELETE /api/courses/:id - 删除课程 */ router.delete( '/:id', - requireRole('ADMIN', 'TEACHER'), + requirePermission('schedule:write'), validate({ params: idParamSchema }), async (req, res) => { const user = getAuthUser(req); diff --git a/packages/server/src/modules/courses/courses.service.ts b/packages/server/src/modules/courses/courses.service.ts index cab8b27..8b0f7da 100644 --- a/packages/server/src/modules/courses/courses.service.ts +++ b/packages/server/src/modules/courses/courses.service.ts @@ -1,4 +1,4 @@ -import type { CourseDto } from '@classhelper/shared'; +import type { CourseDto } from '@classhelper/shared'; import { assertCanManageSchedule, classScopeWhere, resolveClassScope } from '../../lib/access.js'; import { prisma } from '../../lib/db.js'; import { ApiError } from '../../lib/http.js'; @@ -21,6 +21,28 @@ export async function listCourses(user: TokenPayload, classId?: string): Promise export async function createCourse(user: TokenPayload, input: CreateCourseInput): Promise { await assertCanManageSchedule(user, input.classId); + /* + * 同名去重(一个班级里科目名唯一)。 + * + * 这条不是"友好提示",是**权限护栏**:`course.create` 会把创建者写成这门课的任课老师, + * 于是只要能再建一门同名的「数学」,创建者就凭空成了「数学」的老师 —— + * 后面那道 `assertCanManageSubjectContent` 的科目门随即形同虚设 + * (可以布置该科作业、录入该科成绩、删该科内容)。这就是"非该科科任老师 + * 能布置其他科作业"的入口。 + * + * 处理办法是把**已有**课程的 id 回给前端(409 + details.courseId),让前端复用它; + * 权限判定于是回到"你是不是这门课的任课老师"这个唯一事实上。 + */ + const existing = await prisma.course.findFirst({ + where: { classId: input.classId, name: input.name }, + select: { id: true }, + }); + if (existing) { + throw new ApiError(409, 'COURSE_EXISTS', `该班级已有名为「${input.name}」的课程`, { + courseId: existing.id, + }); + } + const created = await prisma.course.create({ data: { name: input.name, classId: input.classId, teacherId: user.sub }, include: { teacher: teacherSelect }, diff --git a/packages/server/src/modules/database/database.module.ts b/packages/server/src/modules/database/database.module.ts index 770d77d..62134ab 100644 --- a/packages/server/src/modules/database/database.module.ts +++ b/packages/server/src/modules/database/database.module.ts @@ -2,7 +2,7 @@ import fs from 'node:fs'; import path from 'node:path'; import { Router } from 'express'; import { sendCreated, sendOk } from '../../lib/http.js'; -import { authenticate, requireRole } from '../../middleware/auth.js'; +import { authenticate, requirePermission } from '../../middleware/auth.js'; import { validate, validatedBody, validatedParams } from '../../middleware/validate.js'; import { defineModule } from '../module.types.js'; import { @@ -22,7 +22,7 @@ import * as databaseService from './database.service.js'; /** * 数据库管理(仅管理员):状态检测 / 连接测试 / 备份恢复 / 导入导出 / 定时备份 / 一键切换。 * - * 改动数据库属于最高危操作,全部接口挂在 requireRole('ADMIN') 之下; + * 改动数据库属于最高危操作,全部接口挂在 requirePermission('database:manage') 之下; * 前端入口同样只对 ADMIN 显示(Web 端「数据库管理」页)。 * * 一键切换是异步任务:POST /switch 返回 jobId,前端轮询 GET /switch/jobs/:id 展示步骤进度。 @@ -31,7 +31,7 @@ import * as databaseService from './database.service.js'; const router = Router(); router.use(authenticate()); -router.use(requireRole('ADMIN')); +router.use(requirePermission('database:manage')); /** GET /api/database/status - 当前数据库状态(连接/版本/大小/各表行数/备份与定时配置) */ router.get('/status', async (_req, res) => { diff --git a/packages/server/src/modules/grades/grades.module.ts b/packages/server/src/modules/grades/grades.module.ts index 1cbd309..9a505b7 100644 --- a/packages/server/src/modules/grades/grades.module.ts +++ b/packages/server/src/modules/grades/grades.module.ts @@ -1,7 +1,7 @@ import { Router } from 'express'; import { sendCreated, sendOk } from '../../lib/http.js'; import { idParamSchema } from '../../lib/schemas.js'; -import { authenticate, getAuthUser, requireRole } from '../../middleware/auth.js'; +import { authenticate, getAuthUser, requirePermission } from '../../middleware/auth.js'; import { validate, validatedParams, validatedQuery } from '../../middleware/validate.js'; import { defineModule } from '../module.types.js'; import { @@ -56,7 +56,7 @@ router.get( /** GET /api/grades/stats?classId=&courseId=&examName= - 成绩统计(图表) */ router.get( '/stats', - requireRole('ADMIN', 'TEACHER'), + requirePermission('grade:read'), validate({ query: statsQuerySchema }), async (req, res) => { const user = getAuthUser(req); @@ -68,7 +68,7 @@ router.get( /** GET /api/grades?classId=&courseId=&studentId=&examName= - 成绩列表(教师) */ router.get( '/', - requireRole('ADMIN', 'TEACHER'), + requirePermission('grade:read'), validate({ query: listGradesQuerySchema }), async (req, res) => { const user = getAuthUser(req); @@ -83,7 +83,7 @@ router.get( ); /** POST /api/grades - 录入单条成绩(仅自己任教科目) */ -router.post('/', requireRole('ADMIN', 'TEACHER'), validate({ body: createGradeSchema }), async (req, res) => { +router.post('/', requirePermission('grade:write'), validate({ body: createGradeSchema }), async (req, res) => { const user = getAuthUser(req); sendCreated( res, @@ -100,7 +100,7 @@ router.post('/', requireRole('ADMIN', 'TEACHER'), validate({ body: createGradeSc */ router.patch( '/levels', - requireRole('ADMIN', 'TEACHER'), + requirePermission('grade:write'), validate({ body: updateGradeLevelsSchema }), async (req, res) => { const user = getAuthUser(req); @@ -112,7 +112,7 @@ router.patch( /** POST /api/grades/bulk - 批量录入 / 导入成绩 */ router.post( '/bulk', - requireRole('ADMIN', 'TEACHER'), + requirePermission('grade:write'), validate({ body: bulkCreateGradeSchema }), async (req, res) => { const user = getAuthUser(req); @@ -124,7 +124,7 @@ router.post( /** PATCH /api/grades/:id - 修改成绩(含单个改等级) */ router.patch( '/:id', - requireRole('ADMIN', 'TEACHER'), + requirePermission('grade:write'), validate({ params: idParamSchema, body: updateGradeSchema }), async (req, res) => { const user = getAuthUser(req); @@ -136,7 +136,7 @@ router.patch( /** DELETE /api/grades/:id - 删除成绩 */ router.delete( '/:id', - requireRole('ADMIN', 'TEACHER'), + requirePermission('grade:write'), validate({ params: idParamSchema }), async (req, res) => { const user = getAuthUser(req); diff --git a/packages/server/src/modules/homeworks/homeworks.module.ts b/packages/server/src/modules/homeworks/homeworks.module.ts index c8d3939..b2a13dd 100644 --- a/packages/server/src/modules/homeworks/homeworks.module.ts +++ b/packages/server/src/modules/homeworks/homeworks.module.ts @@ -1,16 +1,18 @@ import { Router } from 'express'; import { sendCreated, sendOk } from '../../lib/http.js'; import { idParamSchema } from '../../lib/schemas.js'; -import { authenticate, getAuthUser, requireRole } from '../../middleware/auth.js'; +import { authenticate, getAuthUser, requirePermission } from '../../middleware/auth.js'; import { validate, validatedParams, validatedQuery } from '../../middleware/validate.js'; import { defineModule } from '../module.types.js'; import { + bulkCreateHomeworkSchema, createHomeworkSchema, listHomeworkDaysQuerySchema, listHomeworksQuerySchema, updateHomeworkSchema, updateHomeworkStatusSchema, updateHomeworkSubmissionsSchema, + type BulkCreateHomeworkInput, type CreateHomeworkInput, type ListHomeworkDaysInput, type UpdateHomeworkInput, @@ -71,10 +73,32 @@ router.post( }, ); +/** + * POST /api/homeworks/bulk - 一次给多个班级布置同一份作业(逐班结果) + * + * 与 POST / 的区别:批量发布是 Web 管理端的"多班同发"工作流,因此这里挂 `homework:write`; + * 教室机器的班级账号仍走单班录入(POST /,权限在服务层判,见 AGENTS.md §5 第 20 条)。 + * 每个班的科目归属依然由 createHomework 里的 assertCanManageSubjectContent 逐个判定 —— + * 批量不是绕过科目门的后门(§5 第 5 条)。 + */ +router.post( + '/bulk', + requirePermission('homework:write'), + validate({ body: bulkCreateHomeworkSchema }), + async (req, res) => { + const user = getAuthUser(req); + const result = await homeworkService.bulkCreateHomeworks( + user, + req.body as BulkCreateHomeworkInput, + ); + sendCreated(res, result, `批量发布完成:成功 ${result.succeeded} 个班,失败 ${result.failed} 个班`); + }, +); + /** PATCH /api/homeworks/:id - 修改作业 */ router.patch( '/:id', - requireRole('ADMIN', 'TEACHER'), + requirePermission('homework:write'), validate({ params: idParamSchema, body: updateHomeworkSchema }), async (req, res) => { const user = getAuthUser(req); @@ -90,7 +114,7 @@ router.patch( /** DELETE /api/homeworks/:id - 删除作业 */ router.delete( '/:id', - requireRole('ADMIN', 'TEACHER'), + requirePermission('homework:write'), validate({ params: idParamSchema }), async (req, res) => { const user = getAuthUser(req); diff --git a/packages/server/src/modules/homeworks/homeworks.schemas.ts b/packages/server/src/modules/homeworks/homeworks.schemas.ts index a968987..bce3baf 100644 --- a/packages/server/src/modules/homeworks/homeworks.schemas.ts +++ b/packages/server/src/modules/homeworks/homeworks.schemas.ts @@ -37,6 +37,26 @@ export const createHomeworkSchema = z.object({ assignDate: dayKeySchema.nullish(), }); +/** + * 一次给多个班级布置同一份作业(批量发布)。 + * + * 上限 50 个班是刻意设的:逐班串行落库 + 逐班判定科目归属, + * 一批塞几百个班会长时间占住请求。 + */ +export const bulkCreateHomeworkSchema = z.object({ + classIds: z + .array(z.string().min(1)) + .min(1, '请至少选择一个班级') + .max(50, '一次最多发布 50 个班级'), + courseId: z.string().min(1).nullish(), + /** 按科目名称指定(跨班只能用名称:课程是班级内的,同一门课在每个班是不同的记录) */ + subjectName: z.string().trim().min(1).max(50).nullish(), + title: z.string().trim().min(1, '请输入作业标题').max(120), + content: z.string().min(1, '请输入作业内容').max(5000), + assignDate: dayKeySchema.nullish(), + attachmentUrl: attachmentUrlSchema, +}); + export const updateHomeworkSchema = z.object({ courseId: z.string().min(1).nullish(), /** 改作业所属日期(把作业挪到另一天) */ @@ -62,6 +82,7 @@ export const updateHomeworkSubmissionsSchema = z.object({ export type ListHomeworkDaysInput = z.infer; export type CreateHomeworkInput = z.infer; +export type BulkCreateHomeworkInput = z.infer; export type UpdateHomeworkInput = z.infer; export type UpdateHomeworkStatusInput = z.infer; export type UpdateHomeworkSubmissionsInput = z.infer; diff --git a/packages/server/src/modules/homeworks/homeworks.service.ts b/packages/server/src/modules/homeworks/homeworks.service.ts index 38895c5..1463d49 100644 --- a/packages/server/src/modules/homeworks/homeworks.service.ts +++ b/packages/server/src/modules/homeworks/homeworks.service.ts @@ -2,6 +2,9 @@ import { SOCKET_EVENTS, dayKeyLocal, shiftDayKey, + type BulkCreateHomeworkRequest, + type BulkPublishClassResult, + type BulkPublishResult, type HomeworkDaysDto, type HomeworkDto, type HomeworkStatusDto, @@ -15,9 +18,11 @@ import { isClassDevice, resolveClassScope, } from '../../lib/access.js'; +import { normalizeBulkClassIds, summarizeBulk, toBulkFailure } from '../../lib/bulk.js'; import { prisma } from '../../lib/db.js'; import { ApiError } from '../../lib/http.js'; import type { TokenPayload } from '../../lib/jwt.js'; +import { logger } from '../../lib/logger.js'; import { toHomeworkDto, toHomeworkStatusDto } from '../../lib/mappers.js'; import { personalIdWhere, resolvePersonalIds } from '../../lib/session.js'; import { emitToClass } from '../../realtime/bus.js'; @@ -182,6 +187,63 @@ export async function createHomework(user: TokenPayload, input: CreateHomeworkIn return dto; } +/** + * 批量布置作业:一次给多个班级发同一份内容。 + * + * 逐班串行调用 `createHomework`(复用同一套科目归属断言 + 实时广播 + 班级端归属规则), + * **某个班失败只记进结果**:例如批量里含一个"语文由别的老师任教"的班, + * 那个班带着「不是你任教的科目」的失败原因回来,其余班照常发布。 + * + * 科目用 `subjectName` 而不是 courseId:课程是班级内的,同一门课在每个班是不同的记录, + * 跨班只能用名称逐个查(查不到只让那个班失败,不自动建课)。 + */ +export async function bulkCreateHomeworks( + user: TokenPayload, + input: BulkCreateHomeworkRequest, +): Promise { + const classIds = normalizeBulkClassIds(input.classIds); + const results: BulkPublishClassResult[] = []; + + for (const classId of classIds) { + try { + // 批量跨班只能用科目「名称」定位课程:同一门课在每个班是不同的 Course 记录。 + // 该班没有这门课时只让这个班失败 —— 不自动建课(否则一次批量会在 N 个班里凭空 + // 建出课程,还会让发起人在这些班里变成"任课老师",绕过科目维度的写权限)。 + let courseId = input.courseId ?? null; + if (input.subjectName) { + const course = await prisma.course.findFirst({ + where: { classId, name: input.subjectName }, + select: { id: true }, + }); + if (!course) { + throw ApiError.badRequest( + `该班级没有「${input.subjectName}」这门课程(跨班发布按科目名称匹配,请先让班主任/管理员在本班建课并分配任课老师,或改选其他科目)`, + ); + } + courseId = course.id; + } + + const dto = await createHomework(user, { + classId, + courseId, + title: input.title, + content: input.content, + assignDate: input.assignDate ?? null, + attachmentUrl: input.attachmentUrl ?? null, + }); + results.push({ classId, ok: true, id: dto.id }); + } catch (error) { + results.push(toBulkFailure(classId, error, '布置作业')); + } + } + + const summary = summarizeBulk(results); + logger.info( + `批量布置作业:目标 ${summary.total} 个班级,成功 ${summary.succeeded} 个,失败 ${summary.failed} 个(发起人=${user.username})`, + ); + return summary; +} + export async function updateHomework( user: TokenPayload, homeworkId: string, diff --git a/packages/server/src/modules/imports/imports.module.ts b/packages/server/src/modules/imports/imports.module.ts index addbd9f..07448f1 100644 --- a/packages/server/src/modules/imports/imports.module.ts +++ b/packages/server/src/modules/imports/imports.module.ts @@ -1,7 +1,7 @@ import { Router } from 'express'; import { ApiError, sendOk } from '../../lib/http.js'; import { idParamSchema } from '../../lib/schemas.js'; -import { authenticate, getAuthUser, requireRole } from '../../middleware/auth.js'; +import { authenticate, getAuthUser, requireAnyPermission } from '../../middleware/auth.js'; import { validate, validatedBody, validatedParams, validatedQuery } from '../../middleware/validate.js'; import { defineModule } from '../module.types.js'; import { @@ -48,7 +48,7 @@ function resolveImportKind(value: unknown): (typeof IMPORT_KINDS)[number] { * GET /api/imports/template?kind=grades|students|teachers&format=csv|xlsx - 下载导入模板 * 成绩模板:管理员或班主任(空白模板,不含任何业务数据);学生/教师名单模板:仅管理员 */ -router.get('/template', requireRole('ADMIN', 'TEACHER'), (req, res) => { +router.get('/template', requireAnyPermission('import:students', 'grade:import', 'import:teachers', 'import:schedule', 'import:class-teachers'), (req, res) => { const kind = resolveImportKind(req.query.kind); assertImportKindAllowed(kind, getAuthUser(req).role); const format = req.query.format === 'xlsx' ? 'xlsx' : 'csv'; @@ -72,7 +72,7 @@ router.get('/template', requireRole('ADMIN', 'TEACHER'), (req, res) => { */ router.post( '/table/preview', - requireRole('ADMIN', 'TEACHER'), + requireAnyPermission('import:students', 'grade:import', 'import:teachers', 'import:schedule', 'import:class-teachers'), validate({ body: tableFileSchema }), (req, res) => { const input = validatedBody(req); @@ -89,7 +89,7 @@ router.post( */ router.post( '/table/commit', - requireRole('ADMIN', 'TEACHER'), + requireAnyPermission('import:students', 'grade:import', 'import:teachers', 'import:schedule', 'import:class-teachers'), validate({ body: tableCommitSchema }), async (req, res) => { const input = validatedBody(req); diff --git a/packages/server/src/modules/imports/table-import.service.ts b/packages/server/src/modules/imports/table-import.service.ts index 013fe6e..ad8945f 100644 --- a/packages/server/src/modules/imports/table-import.service.ts +++ b/packages/server/src/modules/imports/table-import.service.ts @@ -7,7 +7,7 @@ import type { TokenPayload } from '../../lib/jwt.js'; import { logger } from '../../lib/logger.js'; import { hashPassword } from '../../lib/password.js'; import { resolveUserClassRole } from '../../lib/access.js'; -import { canImport } from '@classhelper/shared'; +import { canImport, canManageSubjectContent } from '@classhelper/shared'; import type { TableCommitInput, TableFileInput } from './imports.schemas.js'; /** @@ -337,9 +337,19 @@ export async function commitGrades(user: TokenPayload, input: TableCommitInput): const byName = new Map(students.map((item) => [item.name.toLowerCase(), item])); const courses = await prisma.course.findMany({ where: { classId }, - select: { id: true, name: true }, + select: { id: true, name: true, teacherId: true }, }); + /* + * 科目维度的第二道门(`assertImportAllowed` 只到"班级"这一层)。 + * + * 成绩是**逐行落到具体科目**上的,而班主任并不自动是本班所有科目的老师 —— + * 少了这道检查,班主任就能导入别的老师任教科目的成绩,班里会平白多出一份 + * 不属于那门课的记录(且覆盖语义的 append/overwrite 会把它写进同一张表)。 + * 判定沿用 shared 的 canManageSubjectContent,与 Web 端单条录入、作业发布**同一份规则**。 + */ + const classRole = await resolveUserClassRole(user, classId); + const errors: ImportRowError[] = []; const warnings: string[] = []; let inserted = 0; @@ -390,6 +400,23 @@ export async function commitGrades(user: TokenPayload, input: TableCommitInput): if (courseName && !course) warnings.push(`第 ${rowNumber} 行:科目「${courseName}」不存在,已按"不指定科目"导入`); + // 该科目的成绩能不能由**当前这个人**写(管理员全通;挂了科目就只能是该科任课老师) + const canWriteCourse = canManageSubjectContent({ + classRole, + hasSubject: Boolean(course), + isSubjectTeacherOfCourse: course ? course.teacherId === user.sub : false, + allowClassDevice: false, + }); + if (!canWriteCourse) { + errors.push({ + row: rowNumber, + message: course + ? `「${course.name}」不是你任教的科目,不能导入该科目的成绩` + : '不指定科目的成绩只有班主任或管理员可以导入,请填写科目列后再导入', + }); + continue; + } + // 表格里带等级列就按"自定义等级"落库(教师明确给了等级),否则按百分制换算 const levelType = levelText ? 'custom' : 'percent'; const existing = await prisma.grade.findFirst({ diff --git a/packages/server/src/modules/integrations/integrations.module.ts b/packages/server/src/modules/integrations/integrations.module.ts index d1b658d..a8738cf 100644 --- a/packages/server/src/modules/integrations/integrations.module.ts +++ b/packages/server/src/modules/integrations/integrations.module.ts @@ -1,6 +1,6 @@ import { Router } from 'express'; import { ApiError, sendCreated, sendOk } from '../../lib/http.js'; -import { authenticate, getAuthUser, requireRole } from '../../middleware/auth.js'; +import { authenticate, getAuthUser, requirePermission } from '../../middleware/auth.js'; import { validate, validatedBody, validatedParams, validatedQuery } from '../../middleware/validate.js'; import { defineModule } from '../module.types.js'; import * as gradeService from '../grades/grades.service.js'; @@ -117,7 +117,7 @@ router.use(authenticate()); */ router.get( '/devices', - requireRole('ADMIN', 'TEACHER'), + requirePermission('device:manage'), validate({ query: listDevicesQuerySchema }), async (req, res) => { const { classId } = validatedQuery<{ classId?: string }>(req); @@ -131,14 +131,14 @@ router.get( * 管理员看全部班级;普通教师看自己担任班主任的班级 ∪ 任课的班级。 * 每条含:班级、设备名/设备号、在线/离线、最后在线时间、最后心跳时间。 */ -router.get('/classisland/status', requireRole('ADMIN', 'TEACHER'), async (req, res) => { +router.get('/classisland/status', requirePermission('device:manage'), async (req, res) => { sendOk(res, await integrationService.listClassHelperStatus(getAuthUser(req)), '获取在线状态成功'); }); /** POST /api/integrations/devices - 新建设备并一次性返回明文令牌(管理员或本班班主任) */ router.post( '/devices', - requireRole('ADMIN', 'TEACHER'), + requirePermission('device:manage'), validate({ body: createDeviceSchema }), async (req, res) => { const result = await integrationService.createDevice( @@ -152,7 +152,7 @@ router.post( /** PATCH /api/integrations/devices/:id - 改名 / 启停 / 调整同步开关 */ router.patch( '/devices/:id', - requireRole('ADMIN', 'TEACHER'), + requirePermission('device:manage'), validate({ params: deviceIdParamSchema, body: updateDeviceSchema }), async (req, res) => { const { id } = validatedParams<{ id: string }>(req); @@ -174,7 +174,7 @@ router.patch( */ router.post( '/devices/:id/request-schedule', - requireRole('ADMIN', 'TEACHER'), + requirePermission('device:manage'), validate({ params: deviceIdParamSchema }), async (req, res) => { const { id } = validatedParams<{ id: string }>(req); @@ -186,7 +186,7 @@ router.post( /** POST /api/integrations/devices/:id/token - 重置令牌(旧令牌立即失效) */ router.post( '/devices/:id/token', - requireRole('ADMIN', 'TEACHER'), + requirePermission('device:manage'), validate({ params: deviceIdParamSchema }), async (req, res) => { const { id } = validatedParams<{ id: string }>(req); @@ -197,7 +197,7 @@ router.post( /** DELETE /api/integrations/devices/:id - 删除设备 */ router.delete( '/devices/:id', - requireRole('ADMIN', 'TEACHER'), + requirePermission('device:manage'), validate({ params: deviceIdParamSchema }), async (req, res) => { const { id } = validatedParams<{ id: string }>(req); @@ -209,7 +209,7 @@ router.delete( /** POST /api/integrations/classisland/notify - 把提醒下发到该班的 ClassIsland 设备 */ router.post( '/classisland/notify', - requireRole('ADMIN', 'TEACHER'), + requirePermission('classisland:notify'), validate({ body: sendNotificationSchema }), async (req, res) => { const result = await integrationService.sendNotification( diff --git a/packages/server/src/modules/notifications/notifications.module.ts b/packages/server/src/modules/notifications/notifications.module.ts index 503ef4f..4c952a4 100644 --- a/packages/server/src/modules/notifications/notifications.module.ts +++ b/packages/server/src/modules/notifications/notifications.module.ts @@ -1,32 +1,46 @@ import { Router } from 'express'; import { sendCreated, sendOk } from '../../lib/http.js'; import { idParamSchema } from '../../lib/schemas.js'; -import { authenticate, getAuthUser, requireRole } from '../../middleware/auth.js'; +import { authenticate, getAuthUser, requirePermission } from '../../middleware/auth.js'; import { validate, validatedParams, validatedQuery } from '../../middleware/validate.js'; import { defineModule } from '../module.types.js'; import { + bulkCreateNotificationSchema, createNotificationSchema, listNotificationsQuerySchema, + notificationArchiveQuerySchema, notificationScopeQuerySchema, + type BulkCreateNotificationInput, type CreateNotificationInput, + type ListNotificationQuery, + type NotificationArchiveQuery, } from './notifications.schemas.js'; import * as notificationService from './notifications.service.js'; const router = Router(); router.use(authenticate()); -/** GET /api/notifications?classId=&priority=&unreadOnly= - 通知列表 */ +/** GET /api/notifications?classId=&priority=&unreadOnly=&date=&from=&to=&createdBy= - 通知列表 */ router.get('/', validate({ query: listNotificationsQuerySchema }), async (req, res) => { const user = getAuthUser(req); - const query = validatedQuery<{ - classId?: string; - priority?: string; - unreadOnly: boolean; - keyword?: string; - }>(req); + const query = validatedQuery(req); sendOk(res, await notificationService.listNotifications(user, query), '获取通知列表成功'); }); +/** GET /api/notifications/days?classId=&from=&to=&createdBy= - 按天归档(日期 + 条数) */ +router.get('/days', validate({ query: notificationArchiveQuerySchema }), async (req, res) => { + const user = getAuthUser(req); + const query = validatedQuery(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(req); + sendOk(res, await notificationService.listNotificationCreators(user, query), '获取通知发布人归档成功'); +}); + /** GET /api/notifications/unread-count?classId= - 未读数(客户端红点) */ router.get('/unread-count', validate({ query: notificationScopeQuerySchema }), async (req, res) => { const user = getAuthUser(req); @@ -44,7 +58,7 @@ router.post('/read-all', validate({ body: notificationScopeQuerySchema }), async /** POST /api/notifications - 发布通知(发布后广播 notification:new) */ router.post( '/', - requireRole('ADMIN', 'TEACHER'), + requirePermission('notification:publish'), validate({ body: createNotificationSchema }), async (req, res) => { const user = getAuthUser(req); @@ -53,6 +67,21 @@ router.post( }, ); +/** POST /api/notifications/bulk - 一次给多个班级发同一条通知(逐班结果,不整批回滚) */ +router.post( + '/bulk', + requirePermission('notification:publish'), + validate({ body: bulkCreateNotificationSchema }), + async (req, res) => { + const user = getAuthUser(req); + const result = await notificationService.bulkCreateNotifications( + user, + req.body as BulkCreateNotificationInput, + ); + sendCreated(res, result, `批量发布完成:成功 ${result.succeeded} 个班,失败 ${result.failed} 个班`); + }, +); + /** POST /api/notifications/:id/read - 标记已读 */ router.post('/:id/read', validate({ params: idParamSchema }), async (req, res) => { const user = getAuthUser(req); @@ -63,7 +92,7 @@ router.post('/:id/read', validate({ params: idParamSchema }), async (req, res) = /** DELETE /api/notifications/:id - 删除通知 */ router.delete( '/:id', - requireRole('ADMIN', 'TEACHER'), + requirePermission('notification:delete'), validate({ params: idParamSchema }), async (req, res) => { const user = getAuthUser(req); diff --git a/packages/server/src/modules/notifications/notifications.schemas.ts b/packages/server/src/modules/notifications/notifications.schemas.ts index 7f5e9b3..9ef1722 100644 --- a/packages/server/src/modules/notifications/notifications.schemas.ts +++ b/packages/server/src/modules/notifications/notifications.schemas.ts @@ -1,13 +1,36 @@ import { z } from 'zod'; +import { isDayKey } from '@classhelper/shared'; import { booleanFlagSchema } from '../../lib/schemas.js'; export const notificationPrioritySchema = z.enum(['LOW', 'NORMAL', 'HIGH', 'URGENT']); +/** 本地日期(YYYY-MM-DD):按天归档/筛选用它,不要用 createdAt 的 UTC 切片 */ +const dayKeySchema = z + .string() + .trim() + .refine(isDayKey, { message: '日期格式应为 YYYY-MM-DD' }); + export const listNotificationsQuerySchema = z.object({ classId: z.string().min(1).optional(), priority: notificationPrioritySchema.optional(), unreadOnly: booleanFlagSchema, keyword: z.string().trim().max(64).optional(), + /** 只看某一天;与 from/to 二选一 */ + date: dayKeySchema.optional(), + /** 日期区间(含边界) */ + from: dayKeySchema.optional(), + to: dayKeySchema.optional(), + /** 只看某位发布人 */ + createdBy: z.string().min(1).optional(), +}); + +/** GET /notifications/days 与 GET /notifications/creators 的筛选条件(不含 date 自身) */ +export const notificationArchiveQuerySchema = z.object({ + classId: z.string().min(1).optional(), + priority: notificationPrioritySchema.optional(), + createdBy: z.string().min(1).optional(), + from: dayKeySchema.optional(), + to: dayKeySchema.optional(), }); export const createNotificationSchema = z.object({ @@ -26,5 +49,25 @@ export const notificationScopeQuerySchema = z.object({ classId: z.string().min(1).optional(), }); +/** + * 一次给多个班级发同一条通知(批量发布)。 + * + * 上限 50 个班是刻意设的:批量发布是逐班串行落库 + 逐班推 ClassIsland, + * 一次塞几百个班会长时间占住请求,也会把教室大屏刷屏。 + */ +export const bulkCreateNotificationSchema = z.object({ + classIds: z + .array(z.string().min(1)) + .min(1, '请至少选择一个班级') + .max(50, '一次最多发布 50 个班级'), + title: z.string().trim().min(1, '请输入通知标题').max(120), + content: z.string().min(1, '请输入通知内容').max(5000), + priority: notificationPrioritySchema.optional(), + confirmDuringClass: z.boolean().optional(), +}); + export type CreateNotificationInput = z.infer; export type NotificationPriorityInput = z.infer; +export type ListNotificationQuery = z.infer; +export type NotificationArchiveQuery = z.infer; +export type BulkCreateNotificationInput = z.infer; diff --git a/packages/server/src/modules/notifications/notifications.service.ts b/packages/server/src/modules/notifications/notifications.service.ts index 48d1dce..3c2216a 100644 --- a/packages/server/src/modules/notifications/notifications.service.ts +++ b/packages/server/src/modules/notifications/notifications.service.ts @@ -1,4 +1,15 @@ -import { SOCKET_EVENTS, type NotificationDto } from '@classhelper/shared'; +import { + SOCKET_EVENTS, + dayKeyLocal, + type BulkCreateNotificationRequest, + type BulkPublishClassResult, + type BulkPublishResult, + type NotificationCreatorSummary, + type NotificationCreatorsDto, + type NotificationDaysDto, + type NotificationDto, +} from '@classhelper/shared'; +import { normalizeBulkClassIds, summarizeBulk, toBulkFailure } from '../../lib/bulk.js'; import { assertClassAccess, assertCanPublishNotification, @@ -23,6 +34,37 @@ export interface ListNotificationOptions { priority?: string; unreadOnly?: boolean; keyword?: string; + date?: string; + from?: string; + to?: string; + createdBy?: string; +} + +/** 归档筛选条件:按天 / 日期区间 / 发布人 / 优先级(assignDate 是本地日期字符串,字典序即时间序) */ +function archiveWhere(options: { + priority?: string; + createdBy?: string; + date?: string; + from?: string; + to?: string; +}): Record { + if (options.date) { + return { + ...(options.priority ? { priority: options.priority } : {}), + ...(options.createdBy ? { createdBy: options.createdBy } : {}), + assignDate: options.date, + }; + } + + const range = { + ...(options.from ? { gte: options.from } : {}), + ...(options.to ? { lte: options.to } : {}), + }; + return { + ...(options.priority ? { priority: options.priority } : {}), + ...(options.createdBy ? { createdBy: options.createdBy } : {}), + ...(Object.keys(range).length > 0 ? { assignDate: range } : {}), + }; } export async function listNotifications( @@ -38,7 +80,7 @@ export async function listNotifications( const notifications = await prisma.notification.findMany({ where: { ...classScopeWhere(scope), - ...(options.priority ? { priority: options.priority } : {}), + ...archiveWhere(options), ...(options.keyword ? { OR: [{ title: { contains: options.keyword } }, { content: { contains: options.keyword } }] } : {}), @@ -55,6 +97,98 @@ export async function listNotifications( return notifications.map((item) => toNotificationDto(item, { studentIds: personalIds, withStatus: isStaff })); } +/** + * 按天归档:列出「有通知的日期 + 当天条数」,供 Web 端左侧日期栏使用。 + * + * 用 `assignDate`(本地日期)而不是 `createdAt`:晚上发的通知不该被算到第二天。 + * 传了 `date` 也刻意忽略 —— 日期栏是"归档索引",不该被当前选中的那天过滤掉。 + */ +export async function listNotificationDays( + user: TokenPayload, + options: { classId?: string; priority?: string; createdBy?: string; from?: string; to?: string }, +): Promise { + 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 { + 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 { + const classIds = normalizeBulkClassIds(input.classIds); + const results: BulkPublishClassResult[] = []; + + for (const classId of classIds) { + try { + const dto = await createNotification(user, { + classId, + title: input.title, + content: input.content, + priority: input.priority, + confirmDuringClass: input.confirmDuringClass, + }); + results.push({ classId, ok: true, id: dto.id }); + } catch (error) { + results.push(toBulkFailure(classId, error, '发布通知')); + } + } + + const summary = summarizeBulk(results); + logger.info( + `批量发布通知:目标 ${summary.total} 个班级,成功 ${summary.succeeded} 个,失败 ${summary.failed} 个(发起人=${user.username})`, + ); + return summary; +} + /** * 发布通知并实时广播到 class:{classId} 房间。 * @@ -92,6 +226,8 @@ export async function createNotification( content: input.content, priority, createdBy: user.sub, + // 按天归档用「本地日期」:晚间发布的通知不会被 UTC 切片算到第二天(与作业同口径) + assignDate: dayKeyLocal(new Date()), }, include: { creator: creatorSelect, reads: true }, }); diff --git a/packages/server/src/modules/permissions/permissions.module.ts b/packages/server/src/modules/permissions/permissions.module.ts new file mode 100644 index 0000000..fe98c28 --- /dev/null +++ b/packages/server/src/modules/permissions/permissions.module.ts @@ -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(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(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(req); + sendOk(res, await permissionService.assignUserGroups(id, input), '权限组分配已保存'); + }, +); + +export const permissionsModule = defineModule({ + name: 'permissions', + basePath: '/permissions', + router, +}); diff --git a/packages/server/src/modules/permissions/permissions.schemas.ts b/packages/server/src/modules/permissions/permissions.schemas.ts new file mode 100644 index 0000000..b9785c4 --- /dev/null +++ b/packages/server/src/modules/permissions/permissions.schemas.ts @@ -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; +export type UpdatePermissionGroupInput = z.infer; +export type AssignUserGroupsInput = z.infer; diff --git a/packages/server/src/modules/permissions/permissions.service.ts b/packages/server/src/modules/permissions/permissions.service.ts new file mode 100644 index 0000000..26957d0 --- /dev/null +++ b/packages/server/src/modules/permissions/permissions.service.ts @@ -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(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(); + 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, + roleDefaults: Object.fromEntries( + roles.map((role) => [role, [...ROLE_DEFAULT_PERMISSIONS[role]]]), + ) as Record, + unremovable: [...ADMIN_UNREMOVABLE_PERMISSIONS], + }; +} + +/** GET /api/permissions/groups - 权限组列表(内置组排在最前,其余按创建时间) */ +export async function listGroups(): Promise { + 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 { + 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 { + 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, +): PermissionMemberDto { + const groupIds = row.permissionGroups.map((link) => link.groupId); + const union = new Set(); + 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 { + 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( + 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 { + 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( + 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 { + 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, + }; +} diff --git a/packages/server/src/modules/registry.ts b/packages/server/src/modules/registry.ts index 8b77a41..48836d4 100644 --- a/packages/server/src/modules/registry.ts +++ b/packages/server/src/modules/registry.ts @@ -1,7 +1,9 @@ +import { aiModule } from './ai/ai.module.js'; import { archivesModule } from './archives/archives.module.js'; import { authModule } from './auth/auth.module.js'; import { callsModule } from './calls/calls.module.js'; import { classesModule } from './classes/classes.module.js'; +import { clientsModule } from './clients/clients.module.js'; import { coursesModule } from './courses/courses.module.js'; import { databaseModule } from './database/database.module.js'; import { dashboardModule } from './dashboard/dashboard.module.js'; @@ -10,8 +12,10 @@ import { homeworksModule } from './homeworks/homeworks.module.js'; import { importsModule } from './imports/imports.module.js'; import { integrationsModule } from './integrations/integrations.module.js'; import { notificationsModule } from './notifications/notifications.module.js'; +import { permissionsModule } from './permissions/permissions.module.js'; import { schedulesModule } from './schedules/schedules.module.js'; import { studentsModule } from './students/students.module.js'; +import { systemModule } from './system/system.module.js'; import { teachersModule } from './teachers/teachers.module.js'; import { termModule } from './term/term.module.js'; import { updateModule } from './update/update.module.js'; @@ -26,16 +30,20 @@ import type { ApiModule } from './module.types.js'; */ export const apiModules: ApiModule[] = [ authModule, + aiModule, classesModule, + clientsModule, coursesModule, schedulesModule, homeworksModule, notificationsModule, + permissionsModule, callsModule, importsModule, integrationsModule, gradesModule, studentsModule, + systemModule, teachersModule, archivesModule, termModule, diff --git a/packages/server/src/modules/schedules/schedules.module.ts b/packages/server/src/modules/schedules/schedules.module.ts index a9c13b3..f3fe082 100644 --- a/packages/server/src/modules/schedules/schedules.module.ts +++ b/packages/server/src/modules/schedules/schedules.module.ts @@ -1,7 +1,7 @@ import { Router } from 'express'; import { sendCreated, sendOk } from '../../lib/http.js'; import { idParamSchema } from '../../lib/schemas.js'; -import { authenticate, getAuthUser, requireRole } from '../../middleware/auth.js'; +import { authenticate, getAuthUser, requirePermission } from '../../middleware/auth.js'; import { validate, validatedParams, validatedQuery } from '../../middleware/validate.js'; import { defineModule } from '../module.types.js'; import { @@ -58,7 +58,7 @@ router.get('/current', validate({ query: classStatusQuerySchema }), async (req, /** POST /api/schedules - 新增课表 */ router.post( '/', - requireRole('ADMIN', 'TEACHER'), + requirePermission('schedule:write'), validate({ body: createScheduleSchema }), async (req, res) => { const user = getAuthUser(req); @@ -73,7 +73,7 @@ router.post( /** PATCH /api/schedules/:id - 修改课表 */ router.patch( '/:id', - requireRole('ADMIN', 'TEACHER'), + requirePermission('schedule:write'), validate({ params: idParamSchema, body: updateScheduleSchema }), async (req, res) => { const user = getAuthUser(req); @@ -89,7 +89,7 @@ router.patch( /** DELETE /api/schedules/:id - 删除课表 */ router.delete( '/:id', - requireRole('ADMIN', 'TEACHER'), + requirePermission('schedule:write'), validate({ params: idParamSchema }), async (req, res) => { const user = getAuthUser(req); diff --git a/packages/server/src/modules/students/students.module.ts b/packages/server/src/modules/students/students.module.ts index e2a89ed..9a1d190 100644 --- a/packages/server/src/modules/students/students.module.ts +++ b/packages/server/src/modules/students/students.module.ts @@ -1,7 +1,7 @@ import { Router } from 'express'; import { sendCreated, sendOk } from '../../lib/http.js'; import { idParamSchema } from '../../lib/schemas.js'; -import { authenticate, getAuthUser, requireRole } from '../../middleware/auth.js'; +import { authenticate, getAuthUser, requirePermission } from '../../middleware/auth.js'; import { validate, validatedParams, validatedQuery } from '../../middleware/validate.js'; import { defineModule } from '../module.types.js'; import { @@ -33,7 +33,7 @@ router.use(authenticate()); */ router.get( '/', - requireRole('ADMIN', 'TEACHER'), + requirePermission('roster:manage'), validate({ query: listStudentsQuerySchema }), async (req, res) => { const user = getAuthUser(req); @@ -45,7 +45,7 @@ router.get( /** GET /api/students/transfers - 调班 / 转出历史(管理员看全部,也可按学生或班级过滤) */ router.get( '/transfers', - requireRole('ADMIN', 'TEACHER'), + requirePermission('roster:manage'), validate({ query: listTransfersQuerySchema }), async (req, res) => { const user = getAuthUser(req); @@ -62,7 +62,7 @@ router.get( */ router.post( '/transfer', - requireRole('ADMIN'), + requirePermission('class:manage'), validate({ body: transferStudentsSchema }), async (req, res) => { const user = getAuthUser(req); @@ -74,7 +74,7 @@ router.post( /** POST /api/students/transfer-out - 学生转出(学籍离开本校,不是调班) */ router.post( '/transfer-out', - requireRole('ADMIN'), + requirePermission('class:manage'), validate({ body: transferOutStudentsSchema }), async (req, res) => { const user = getAuthUser(req); @@ -86,7 +86,7 @@ router.post( /** GET /api/students/:id/transfers - 单个学生的调班 / 转出历史 */ router.get( '/:id/transfers', - requireRole('ADMIN', 'TEACHER'), + requirePermission('roster:manage'), validate({ params: idParamSchema }), async (req, res) => { const user = getAuthUser(req); @@ -99,7 +99,7 @@ router.get( /** POST /api/students - 新建学生(名单记录,不是账号) */ router.post( '/', - requireRole('ADMIN', 'TEACHER'), + requirePermission('roster:manage'), validate({ body: createStudentSchema }), async (req, res) => { const user = getAuthUser(req); @@ -114,7 +114,7 @@ router.post( /** PATCH /api/students/:id - 修改学生信息 / 调班 */ router.patch( '/:id', - requireRole('ADMIN', 'TEACHER'), + requirePermission('roster:manage'), validate({ params: idParamSchema, body: updateStudentSchema }), async (req, res) => { const user = getAuthUser(req); @@ -130,7 +130,7 @@ router.patch( /** DELETE /api/students/:id - 删除学生名单记录 */ router.delete( '/:id', - requireRole('ADMIN', 'TEACHER'), + requirePermission('roster:manage'), validate({ params: idParamSchema }), async (req, res) => { const user = getAuthUser(req); diff --git a/packages/server/src/modules/system/system.module.ts b/packages/server/src/modules/system/system.module.ts new file mode 100644 index 0000000..30d8e9b --- /dev/null +++ b/packages/server/src/modules/system/system.module.ts @@ -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(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(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, +}); diff --git a/packages/server/src/modules/system/system.schemas.ts b/packages/server/src/modules/system/system.schemas.ts new file mode 100644 index 0000000..d86dc19 --- /dev/null +++ b/packages/server/src/modules/system/system.schemas.ts @@ -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; +export type UpdateAutostartInput = z.infer; diff --git a/packages/server/src/modules/system/system.service.ts b/packages/server/src/modules/system/system.service.ts new file mode 100644 index 0000000..8688330 --- /dev/null +++ b/packages/server/src/modules/system/system.service.ts @@ -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 { + return { developerMode: await getBooleanSetting(SETTING_KEYS.developerMode, false) }; +} + +export async function updateSystemSettings( + user: TokenPayload, + input: UpdateSystemSettingsInput, +): Promise { + if (input.developerMode !== undefined) { + await setBooleanSetting(SETTING_KEYS.developerMode, input.developerMode); + logger.info(`开发者模式已${input.developerMode ? '开启' : '关闭'}(操作人=${user.username})`); + } + return getSystemSettings(); +} + +export async function getAutostartStatus(): Promise { + 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 { + 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 { + 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, + }; +} diff --git a/packages/server/src/modules/teachers/teachers.module.ts b/packages/server/src/modules/teachers/teachers.module.ts index cc31db7..586c7f1 100644 --- a/packages/server/src/modules/teachers/teachers.module.ts +++ b/packages/server/src/modules/teachers/teachers.module.ts @@ -1,7 +1,7 @@ import { Router } from 'express'; import { sendCreated, sendOk } from '../../lib/http.js'; import { idParamSchema } from '../../lib/schemas.js'; -import { authenticate, requireRole } from '../../middleware/auth.js'; +import { authenticate, requirePermission } from '../../middleware/auth.js'; import { validate, validatedParams, validatedQuery } from '../../middleware/validate.js'; import { defineModule } from '../module.types.js'; import { @@ -25,20 +25,30 @@ router.use(authenticate()); */ /** GET /api/teachers?keyword= - 教师列表(教师管理页 + 分配协作教师时使用) */ -router.get('/', requireRole('ADMIN'), validate({ query: listTeachersQuerySchema }), async (req, res) => { - const { keyword } = validatedQuery<{ keyword?: string }>(req); - sendOk(res, await teacherService.listTeachers(keyword), '获取教师列表成功'); -}); +router.get( + '/', + requirePermission('teacher:manage'), + validate({ query: listTeachersQuerySchema }), + async (req, res) => { + const { keyword } = validatedQuery<{ keyword?: string }>(req); + sendOk(res, await teacherService.listTeachers(keyword), '获取教师列表成功'); + }, +); /** POST /api/teachers - 录入教师账号(仅管理员) */ -router.post('/', requireRole('ADMIN'), validate({ body: createTeacherSchema }), async (req, res) => { - sendCreated(res, await teacherService.createTeacher(req.body as CreateTeacherInput), '教师账号创建成功'); -}); +router.post( + '/', + requirePermission('teacher:manage'), + validate({ body: createTeacherSchema }), + async (req, res) => { + sendCreated(res, await teacherService.createTeacher(req.body as CreateTeacherInput), '教师账号创建成功'); + }, +); /** PATCH /api/teachers/:id - 编辑教师(姓名 / 用户名 / 角色) */ router.patch( '/:id', - requireRole('ADMIN'), + requirePermission('teacher:manage'), validate({ params: idParamSchema, body: updateTeacherSchema }), async (req, res) => { const { id } = validatedParams<{ id: string }>(req); @@ -49,7 +59,7 @@ router.patch( /** POST /api/teachers/:id/reset-password - 重置教师密码 */ router.post( '/:id/reset-password', - requireRole('ADMIN'), + requirePermission('teacher:manage'), validate({ params: idParamSchema, body: resetTeacherPasswordSchema }), async (req, res) => { const { id } = validatedParams<{ id: string }>(req); @@ -59,11 +69,16 @@ router.post( ); /** DELETE /api/teachers/:id - 删除教师账号(有班级职责/课程时拒绝,见 service) */ -router.delete('/:id', requireRole('ADMIN'), validate({ params: idParamSchema }), async (req, res) => { - const { id } = validatedParams<{ id: string }>(req); - await teacherService.deleteTeacher(id); - sendOk(res, { id }, '教师账号已删除'); -}); +router.delete( + '/:id', + requirePermission('teacher:manage'), + validate({ params: idParamSchema }), + async (req, res) => { + const { id } = validatedParams<{ id: string }>(req); + await teacherService.deleteTeacher(id); + sendOk(res, { id }, '教师账号已删除'); + }, +); export const teachersModule = defineModule({ name: 'teachers', diff --git a/packages/server/src/modules/teachers/teachers.service.ts b/packages/server/src/modules/teachers/teachers.service.ts index 72a0977..df2a3e8 100644 --- a/packages/server/src/modules/teachers/teachers.service.ts +++ b/packages/server/src/modules/teachers/teachers.service.ts @@ -11,7 +11,7 @@ import type { } from './teachers.schemas.js'; /** - * 教师管理(**仅管理员**,路由层已用 requireRole('ADMIN') 兜住): + * 教师管理(**仅管理员**,路由层已用 requirePermission('teacher:manage') 兜住): * 录入教师是管理员专属操作,班主任与科任老师都没有该权限。 * 学生名单在 students 模块,两个模块结构保持一致(列表 / 新建 / 编辑 / 重置密码 / 删除)。 */ diff --git a/packages/server/src/modules/term/term.module.ts b/packages/server/src/modules/term/term.module.ts index 4fd7627..1b81be7 100644 --- a/packages/server/src/modules/term/term.module.ts +++ b/packages/server/src/modules/term/term.module.ts @@ -1,6 +1,6 @@ import { Router } from 'express'; import { sendOk } from '../../lib/http.js'; -import { authenticate, getAuthUser, requireRole } from '../../middleware/auth.js'; +import { authenticate, getAuthUser, requirePermission } from '../../middleware/auth.js'; import { validate, validatedBody, validatedQuery } from '../../middleware/validate.js'; import { defineModule } from '../module.types.js'; import { @@ -23,7 +23,7 @@ router.use(authenticate()); * - **写**:仅管理员 —— 周次是全校口径,改错会让所有班的课表整体错位; * - `classId` 留空 = 全校默认;某个班有自己的配置时优先用它。 */ -router.get('/', requireRole('ADMIN', 'TEACHER'), validate({ query: termQuerySchema }), async (req, res) => { +router.get('/', requirePermission('term:read'), validate({ query: termQuerySchema }), async (req, res) => { const user = getAuthUser(req); const { classId } = validatedQuery<{ classId?: string }>(req); sendOk(res, await termService.getTermWeeks(user, classId), '获取学期周次成功'); @@ -32,7 +32,7 @@ router.get('/', requireRole('ADMIN', 'TEACHER'), validate({ query: termQuerySche /** PUT /api/term - 保存逐周区间(整表覆盖,仅管理员) */ router.put( '/', - requireRole('ADMIN'), + requirePermission('term:write'), validate({ body: updateTermWeeksSchema }), async (req, res) => { const user = getAuthUser(req); @@ -51,7 +51,7 @@ router.put( */ router.post( '/auto', - requireRole('ADMIN'), + requirePermission('term:write'), validate({ body: autoTermWeeksSchema }), async (req, res) => { const user = getAuthUser(req); @@ -70,7 +70,7 @@ router.post( */ router.get( '/holidays', - requireRole('ADMIN'), + requirePermission('term:write'), validate({ query: holidayQuerySchema }), async (req, res) => { const { year } = validatedQuery<{ year?: number }>(req); diff --git a/packages/server/src/modules/update/client-releases.service.ts b/packages/server/src/modules/update/client-releases.service.ts new file mode 100644 index 0000000..09cae95 --- /dev/null +++ b/packages/server/src/modules/update/client-releases.service.ts @@ -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 模块的备份目录同口径:/../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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 }; +} diff --git a/packages/server/src/modules/update/update.module.ts b/packages/server/src/modules/update/update.module.ts index fcf1db2..7b2cdb2 100644 --- a/packages/server/src/modules/update/update.module.ts +++ b/packages/server/src/modules/update/update.module.ts @@ -1,8 +1,17 @@ import { Router } from 'express'; -import { sendOk } from '../../lib/http.js'; -import { authenticate, getAuthUser } from '../../middleware/auth.js'; +import { sendCreated, sendOk, ApiError } from '../../lib/http.js'; +import { authenticate, getAuthUser, requirePermission } from '../../middleware/auth.js'; +import { validate, validatedBody, validatedParams } from '../../middleware/validate.js'; import { defineModule } from '../module.types.js'; +import * as clientReleases from './client-releases.service.js'; import * as updateService from './update.service.js'; +import { + clientReleaseVersionParamSchema, + fetchClientReleaseSchema, + registerClientReleaseSchema, + type FetchClientReleaseInput, + type RegisterClientReleaseInput, +} from './update.schemas.js'; const router = Router(); router.use(authenticate()); @@ -25,6 +34,86 @@ router.get('/check', async (req, res) => { sendOk(res, await updateService.checkForUpdates({ force }), '更新检查完成'); }); +/* ------------------------------- 客户端安装包的服务端缓存(统一下发更新的基础) */ + +/** + * GET /api/update/client/releases - 服务端已缓存了哪些客户端版本 + * + * 教室机器没有外网,所以"统一推送更新"必须有这一层中继:服务端替它们把安装包拉下来, + * 客户端再从 `/client/download/:version` 取。 + */ +router.get('/client/releases', requirePermission('client:manage'), async (_req, res) => { + sendOk(res, await clientReleases.listReleases(), '获取安装包列表成功'); +}); + +/** + * POST /api/update/client/fetch - 让服务端从 GitHub 下载某个版本的客户端安装包并登记 + * + * 服务端(Node)不受浏览器 CSP 约束,这是唯一能"一键代下"的地方; + * 下不动(学校出口没有外网)时退回 `POST /client/releases` 手工登记。 + */ +router.post( + '/client/fetch', + requirePermission('client:update-push'), + validate({ body: fetchClientReleaseSchema }), + async (req, res) => { + const user = getAuthUser(req); + const input = validatedBody(req); + sendCreated(res, await clientReleases.fetchReleaseFromGithub(user.sub, input), '安装包已下载并登记'); + }, +); + +/** + * POST /api/update/client/releases - 登记一份已经放进磁盘的安装包 + * + * 文件要求放在 `/../data/client-releases/<版本>/` 下(文件名默认按发布约定推导)。 + * 这条路径也是自动化验收用的:不需要外网、不需要传 100MB 二进制。 + */ +router.post( + '/client/releases', + requirePermission('client:update-push'), + validate({ body: registerClientReleaseSchema }), + async (req, res) => { + const user = getAuthUser(req); + const input = validatedBody(req); + sendCreated(res, await clientReleases.registerRelease(user.sub, input), '安装包已登记'); + }, +); + +/** DELETE /api/update/client/releases/:version - 删除缓存(元数据 + 磁盘文件) */ +router.delete( + '/client/releases/:version', + requirePermission('client:update-push'), + validate({ params: clientReleaseVersionParamSchema }), + async (req, res) => { + const { version } = validatedParams<{ version: string }>(req); + sendOk(res, await clientReleases.deleteRelease(version), '安装包已删除'); + }, +); + +/** + * GET /api/update/client/download/:version - 下载安装包(客户端自动更新走它) + * + * **故意不挂权限键**:下载方是教室里的班级端会话(角色 CLASS_DEVICE,权限集为空), + * 它代表那个班;真正的保护是"必须已登录 + 限流中间件"。 + * `res.download` 自带 Content-Length / Range 支持,不需要另写流式助手。 + */ +router.get( + '/client/download/:version', + validate({ params: clientReleaseVersionParamSchema }), + async (req, res) => { + const { version } = validatedParams<{ version: string }>(req); + const found = await clientReleases.resolveReleaseFile(version); + if (!found) throw ApiError.notFound('服务端没有缓存这个版本的安装包'); + res.download(found.file, found.fileName, (error) => { + if (error) { + // 客户端中途取消下载也会走到这里,只记日志,不要往已发出的响应里再写东西 + res.locals.downloadError = error instanceof Error ? error.message : String(error); + } + }); + }, +); + export const updateModule = defineModule({ name: 'update', basePath: '/update', diff --git a/packages/server/src/modules/update/update.schemas.ts b/packages/server/src/modules/update/update.schemas.ts new file mode 100644 index 0000000..2455123 --- /dev/null +++ b/packages/server/src/modules/update/update.schemas.ts @@ -0,0 +1,41 @@ +import { z } from 'zod'; + +/** + * 安装包文件名/版本号只允许这些字符。 + * + * 它们会被拼进磁盘路径(`data/client-releases/<版本>/<文件名>`), + * 所以必须挡住 `..`、`/`、`\` —— 否则一个 `version=../../` 就能写到仓库外面去。 + */ +const SAFE_SEGMENT = /^[0-9A-Za-z._-]+$/; + +export const clientReleaseVersionSchema = z + .string() + .trim() + .min(1, '请输入版本号') + .max(40, '版本号最多 40 个字符') + .refine((value) => SAFE_SEGMENT.test(value), { + message: '版本号只能包含字母、数字、点、下划线与短横线', + }); + +/** `POST /api/update/client/fetch`:让服务端替教室机器从 GitHub 拉一份安装包 */ +export const fetchClientReleaseSchema = z.object({ + version: clientReleaseVersionSchema, +}); + +/** `POST /api/update/client/releases`:登记一份已经放进磁盘的安装包(手工兜底与自动化用) */ +export const registerClientReleaseSchema = z.object({ + version: clientReleaseVersionSchema, + fileName: z + .string() + .trim() + .min(1) + .max(120) + .refine((value) => SAFE_SEGMENT.test(value), { message: '文件名只能包含字母、数字、点、下划线与短横线' }) + .optional(), + notes: z.string().trim().max(500).optional(), +}); + +export const clientReleaseVersionParamSchema = z.object({ version: clientReleaseVersionSchema }); + +export type FetchClientReleaseInput = z.infer; +export type RegisterClientReleaseInput = z.infer; diff --git a/packages/server/src/types/express.d.ts b/packages/server/src/types/express.d.ts index 4d2e6eb..06225ea 100644 --- a/packages/server/src/types/express.d.ts +++ b/packages/server/src/types/express.d.ts @@ -1,8 +1,9 @@ +import type { PermissionKey } from '@classhelper/shared'; import type { TokenPayload } from '../lib/jwt.js'; /** * 扩展 Express 的 Request 类型。 - * - auth:认证中间件解析出的当前用户 + * - auth:认证中间件解析出的当前用户 + 生效权限集合 * - validatedQuery / validatedParams:校验中间件产出的类型安全数据 * (Express 5 中 req.query 是只读 getter,不能直接覆盖,因此单独存放) */ @@ -12,6 +13,8 @@ declare global { auth?: { token: string; user: TokenPayload; + /** 生效权限集合(权限组并集,或角色默认模板;班级端为空集) */ + permissions?: ReadonlySet; }; validatedQuery?: unknown; validatedParams?: unknown; diff --git a/packages/shared/package.json b/packages/shared/package.json index 0cc6cbb..4082c8c 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -1,6 +1,6 @@ { "name": "@classhelper/shared", - "version": "1.2.0", + "version": "1.3.0", "private": true, "description": "ClassHelper - 三端共享的类型、常量与工具函数", "type": "module", diff --git a/packages/shared/src/constants.ts b/packages/shared/src/constants.ts index 9c787a7..358cbb4 100644 --- a/packages/shared/src/constants.ts +++ b/packages/shared/src/constants.ts @@ -1,4 +1,11 @@ -import type { GradeLevelType, NotificationPriority, StudentGender, StudentStatus, UserRole } from './types.js'; +import type { + ClientCommandKind, + GradeLevelType, + NotificationPriority, + StudentGender, + StudentStatus, + UserRole, +} from './types.js'; /** API 前缀 */ export const API_PREFIX = '/api'; @@ -33,6 +40,14 @@ export const API_PATHS = { database: '/database', /** 更新检查:Web 端经服务端转发查 GitHub 上的最新 Release(浏览器受 CSP 限制不能直连) */ update: '/update', + /** 权限组:权限目录、组的增删改、成员分配、当前账号生效权限 */ + permissions: '/permissions', + /** ClassHelper 客户端设备:心跳、设备清单、统一下发更新与远控指令 */ + clients: '/clients', + /** 大模型助手:设置(仅管理员)、一句话解析、使用记录 */ + ai: '/ai', + /** 系统设置与诊断:开发者模式、服务端开机自启、运行诊断(仅管理员) */ + system: '/system', dashboard: '/dashboard/summary', } as const; @@ -654,3 +669,43 @@ export const UPDATE_CHECK_CACHE_TTL_MS = 30 * 60 * 1000; /** 单次检查的超时:教室机 / 机房的网络常常很慢,但也不能把界面卡住 */ export const UPDATE_CHECK_TIMEOUT_MS = 8_000; + +/* -------------------------------------------- 客户端设备(ClassHelper 桌面端) */ + +/** + * 桌面客户端向服务端上报心跳的间隔(30 秒,与班级端在线状态刷新一致)。 + * + * 为什么**不用 Socket.IO 事件**下发更新与远控:教室机的连接经常断(代理、休眠、重启), + * 而「这台机器什么版本、要不要更新、有没有待执行命令」必须下次上线就能补上, + * 所以统一用「DB 记状态 + 客户端定时拉取」的范式(与 `IntegrationDevice.requestScheduleReport` 同一套路), + * 不引入新的长连接握手。 + */ +export const CLIENT_HEARTBEAT_INTERVAL_MS = 30_000; + +/** 「在线」判定窗口:容忍连续丢两次心跳(30 秒 × 3) */ +export const CLIENT_ONLINE_WINDOW_MS = 90_000; + +/** 远控命令种类:只有这四种,全部用系统命令实现 */ +export const CLIENT_COMMAND_KINDS: readonly ClientCommandKind[] = ['shutdown', 'restart', 'logoff', 'lock']; + +export const CLIENT_COMMAND_LABELS: Record = { + shutdown: '关机', + restart: '重启', + logoff: '注销', + lock: '锁屏', +}; + +/** 命令有效期:超过这个时间还没被拉走就作废(避免机器离线几天后一开机就被昨天的命令关掉) */ +export const CLIENT_COMMAND_EXPIRE_MS = 10 * 60 * 1000; + +/** 客户端回执里 result 的长度上限(一句中文足够) */ +export const CLIENT_COMMAND_RESULT_MAX = 200; + +/** + * 自动安装更新前的「空闲」判据:主窗口已隐藏满这么久才装。 + * 上课时段(渲染进程上报的课表状态)与窗口可见都会把安装往后推。 + */ +export const CLIENT_UPDATE_IDLE_MS = 5 * 60 * 1000; + +/** 服务端缓存客户端安装包的目录(相对 `/../data`,见 update 模块) */ +export const CLIENT_RELEASE_DIR = 'client-releases'; diff --git a/packages/shared/src/permissions.ts b/packages/shared/src/permissions.ts index a14cafb..0cab0b7 100644 --- a/packages/shared/src/permissions.ts +++ b/packages/shared/src/permissions.ts @@ -145,6 +145,249 @@ export function canImport(role: UserRole, classRole: ClassRole, kind: ImportKind return canManageGrades(classRole); } +/* -------------------------------------------------------------------------- */ +/* 权限键目录(权限组的唯一事实来源) */ +/* -------------------------------------------------------------------------- */ + +/** + * 权限键:三端共用的**唯一权限标识**。 + * + * - 服务端:路由级用 `requirePermission(key)`,班级维度的细门仍走下面那些 + * `canXxx`(科目归属这类**数据完整性**规则故意不可被权限组覆盖); + * - Web 管理端:按钮/菜单显隐、权限组管理页的勾选项; + * - 桌面客户端:功能入口显隐。 + * + * 键名用 `领域:动作` 的形式,新增功能要**先在这里加键**,再去两端用。 + * **不要**在页面或服务里硬编码角色判断来代替它(AGENTS.md §5 第 3 条)。 + */ +export const PERMISSION_KEYS = [ + // 班级与人员 + 'class:read', + 'class:manage', + 'roster:manage', + 'teacher:manage', + 'schedule:read', + 'schedule:write', + // 内容发布 + 'homework:read', + 'homework:write', + 'notification:read', + 'notification:publish', + 'notification:delete', + 'call:create', + // 成绩与导入 + 'grade:read', + 'grade:write', + 'grade:import', + 'student:grade-query', + 'import:students', + 'import:teachers', + 'import:schedule', + 'import:class-teachers', + // 设备与联动 + 'device:manage', + 'classisland:notify', + 'client:read', + 'client:manage', + 'client:update-push', + 'remote:control', + // 学期与归档 + 'term:read', + 'term:write', + 'archive:manage', + 'dashboard:read', + // 系统与安全 + 'database:manage', + 'update:check', + 'update:force', + 'ai:use', + 'ai:manage', + 'permission:manage', + 'system:manage', + 'log:read', + 'developer:mode', +] as const; + +export type PermissionKey = (typeof PERMISSION_KEYS)[number]; + +/** 权限键的中文名(权限组管理页与文档展示,别再各写一份) */ +export const PERMISSION_LABELS: Record = { + 'class:read': '查看班级与班级列表', + 'class:manage': '创建 / 修改 / 删除班级(含班级账号密码)', + 'roster:manage': '学生名单管理(增删 / 调班 / 导入)', + 'teacher:manage': '教师账号管理(新建 / 导入 / 重置密码 / 删除)', + 'schedule:read': '查看课表', + 'schedule:write': '课表与课程编辑', + 'homework:read': '查看作业', + 'homework:write': '发布 / 修改 / 删除作业(仍按任教科目录入)', + 'notification:read': '查看通知', + 'notification:publish': '发布通知', + 'notification:delete': '删除通知', + 'call:create': '叫人', + 'grade:read': '查看成绩与成绩汇总', + 'grade:write': '录入 / 修改成绩(仍按任教科目录入)', + 'grade:import': '导入成绩', + 'student:grade-query': '按学号查询学生成绩明细', + 'import:students': '导入学生名单', + 'import:teachers': '导入教师名单', + 'import:schedule': '导入课表时间配置', + 'import:class-teachers': '导入班级任课关系', + 'device:manage': '联动设备管理(ClassIsland 设备令牌 / 课表上报)', + 'classisland:notify': '向 ClassIsland 下发提醒', + 'client:read': '查看 ClassHelper 客户端在线状态与版本', + 'client:manage': '管理客户端设备(改名 / 停用 / 删除)', + 'client:update-push': '向客户端统一下发更新', + 'remote:control': '远控客户端电脑(关机 / 重启 / 注销 / 锁屏)', + 'term:read': '查看学期周次', + 'term:write': '修改学期周次与开学日期', + 'archive:manage': '毕业归档', + 'dashboard:read': '查看统计看板', + 'database:manage': '数据库快照 / 导出 / 切库', + 'update:check': '检查服务端更新', + 'update:force': '强制检查更新(绕过缓存)', + 'ai:use': '使用大模型指令(一句话发布)', + 'ai:manage': '大模型设置(开关 / 服务地址 / 密钥 / 模型)', + 'permission:manage': '权限组与用户权限管理', + 'system:manage': '全局系统设置(含开发者模式)', + 'log:read': '查看服务端日志', + 'developer:mode': '开发者模式', +}; + +/** 权限组管理页的分组展示(只影响排序与标题,不改变语义) */ +export const PERMISSION_CATEGORIES: readonly { title: string; keys: readonly PermissionKey[] }[] = [ + { + title: '班级与人员', + keys: ['class:read', 'class:manage', 'roster:manage', 'teacher:manage', 'schedule:read', 'schedule:write'], + }, + { + title: '内容发布', + keys: [ + 'homework:read', + 'homework:write', + 'notification:read', + 'notification:publish', + 'notification:delete', + 'call:create', + ], + }, + { + title: '成绩与导入', + keys: [ + 'grade:read', + 'grade:write', + 'grade:import', + 'student:grade-query', + 'import:students', + 'import:teachers', + 'import:schedule', + 'import:class-teachers', + ], + }, + { + title: '设备与联动', + keys: ['device:manage', 'classisland:notify', 'client:read', 'client:manage', 'client:update-push', 'remote:control'], + }, + { title: '学期与统计', keys: ['term:read', 'term:write', 'archive:manage', 'dashboard:read'] }, + { + title: '系统与安全', + keys: [ + 'database:manage', + 'update:check', + 'update:force', + 'ai:use', + 'ai:manage', + 'permission:manage', + 'system:manage', + 'log:read', + 'developer:mode', + ], + }, +]; + +/** + * 每个权限键的**默认角色**(镜像改造前各路由的 `requireRole(...)`,逐条对齐)。 + * + * 这是"没有分过任何权限组"时的回落模板:行为与权限组功能上线前完全一致。 + * 一旦管理员给某个账号分了组,生效集合就**只由组的并集决定**(可以比默认更少)。 + * + * 注意 `CLASS_DEVICE` 不出现在任何默认里:班级端的能力由班级维度断言 + * (`canManageSubjectContent(..., { allowClassDevice })` 等)决定,不受权限组影响。 + */ +export const PERMISSION_DEFAULT_ROLES: Record = { + 'class:read': ['ADMIN', 'TEACHER'], + 'class:manage': ['ADMIN'], + 'roster:manage': ['ADMIN', 'TEACHER'], + 'teacher:manage': ['ADMIN'], + 'schedule:read': ['ADMIN', 'TEACHER'], + 'schedule:write': ['ADMIN', 'TEACHER'], + 'homework:read': ['ADMIN', 'TEACHER'], + 'homework:write': ['ADMIN', 'TEACHER'], + 'notification:read': ['ADMIN', 'TEACHER'], + 'notification:publish': ['ADMIN', 'TEACHER'], + 'notification:delete': ['ADMIN', 'TEACHER'], + 'call:create': ['ADMIN', 'TEACHER'], + 'grade:read': ['ADMIN', 'TEACHER'], + 'grade:write': ['ADMIN', 'TEACHER'], + 'grade:import': ['ADMIN', 'TEACHER'], + 'student:grade-query': ['ADMIN', 'TEACHER'], + 'import:students': ['ADMIN', 'TEACHER'], + 'import:teachers': ['ADMIN'], + 'import:schedule': ['ADMIN', 'TEACHER'], + 'import:class-teachers': ['ADMIN'], + 'device:manage': ['ADMIN', 'TEACHER'], + 'classisland:notify': ['ADMIN', 'TEACHER'], + 'client:read': ['ADMIN', 'TEACHER'], + 'client:manage': ['ADMIN'], + 'client:update-push': ['ADMIN'], + 'remote:control': ['ADMIN'], + 'term:read': ['ADMIN', 'TEACHER'], + 'term:write': ['ADMIN'], + 'archive:manage': ['ADMIN'], + 'dashboard:read': ['ADMIN', 'TEACHER'], + 'database:manage': ['ADMIN'], + 'update:check': ['ADMIN', 'TEACHER'], + 'update:force': ['ADMIN'], + 'ai:use': ['ADMIN', 'TEACHER'], + 'ai:manage': ['ADMIN'], + 'permission:manage': ['ADMIN'], + 'system:manage': ['ADMIN'], + 'log:read': ['ADMIN'], + 'developer:mode': ['ADMIN'], +}; + +/** 全部权限键(管理员的生效集合;也用在校验 zod 的枚举上) */ +export const ALL_PERMISSION_KEYS: readonly PermissionKey[] = PERMISSION_KEYS; + +/** + * 角色默认权限清单(由 {@link PERMISSION_DEFAULT_ROLES} 派生)。 + * + * `ADMIN` 是全集(服务端还会强制补上 `permission:manage` / `system:manage`,防止把自己锁死)。 + */ +export const ROLE_DEFAULT_PERMISSIONS: Record = { + ADMIN: PERMISSION_KEYS, + TEACHER: PERMISSION_KEYS.filter((key) => PERMISSION_DEFAULT_ROLES[key].includes('TEACHER')), + CLASS_DEVICE: [], +}; + +/** 取某个角色的默认权限集合(无分组时的回落模板) */ +export function defaultPermissionsForRole(role: UserRole): PermissionKey[] { + return [...ROLE_DEFAULT_PERMISSIONS[role]]; +} + +/** 判断权限集合里是否有某个键(`Set` 或数组都接受) */ +export function hasPermission(permissions: ReadonlySet | readonly string[], key: PermissionKey): boolean { + const list: readonly string[] = Array.isArray(permissions) ? permissions : [...permissions]; + return list.includes(key); +} + +/** 判断是否含任意一个键(服务端 requirePermission 是"全部满足",这里留给页面做"任一"判断) */ +export function hasAnyPermission( + permissions: ReadonlySet | readonly string[], + keys: readonly PermissionKey[], +): boolean { + return keys.some((key) => hasPermission(permissions, key)); +} + /** 人类可读的权限矩阵(README / 设置页展示,单一事实来源) */ export const PERMISSION_MATRIX: readonly { action: string; diff --git a/packages/shared/src/types.ts b/packages/shared/src/types.ts index 94a9733..e6411a1 100644 --- a/packages/shared/src/types.ts +++ b/packages/shared/src/types.ts @@ -4,6 +4,10 @@ * 后端返回值与前端调用方都必须依赖它,避免契约漂移。 */ +// 只导入类型(PermissionKey 由 permissions.ts 的 PERMISSION_KEYS 推导): +// 两个文件之间是**类型层面**的相互引用,编译后都会被抹掉,运行时不存在循环依赖。 +import type { PermissionKey } from './permissions.js'; + /** * 账号角色:管理员 / 教师 / ClassHelper 班级端。 * @@ -733,6 +737,13 @@ export interface NotificationDto { priority: NotificationPriority; createdBy: string; createdAt: string; + /** + * 通知所属日期(YYYY-MM-DD,**不带时区**)。 + * + * 与 createdAt 的区别:createdAt 是"什么时候发的",assignDate 是"这天的通知"。 + * Web 端与客户端的按天归档、日期筛选都用它(晚间发布不会被 UTC 切片算到第二天)。 + */ + assignDate: string; creator?: ClassTeacherBrief | null; /** 当前用户是否已读 */ read?: boolean; @@ -777,6 +788,99 @@ export interface NotificationQueryParams { priority?: NotificationPriority; unreadOnly?: boolean; keyword?: string; + /** 只看某一天(YYYY-MM-DD),与 from/to 二选一 */ + date?: string; + /** 日期区间起点(YYYY-MM-DD,含当天) */ + from?: string; + /** 日期区间终点(YYYY-MM-DD,含当天) */ + to?: string; + /** 只看某位发布人的通知 */ + createdBy?: string; +} + +/** 某一天有通知(用于按天归档的日期列表) */ +export interface NotificationDaySummary { + /** YYYY-MM-DD */ + date: string; + /** 当天通知条数 */ + count: number; +} + +/** GET /api/notifications/days 的返回 */ +export interface NotificationDaysDto { + days: NotificationDaySummary[]; +} + +/** 通知发布人汇总(按发布人归档时的筛选项) */ +export interface NotificationCreatorSummary { + id: string; + name: string; + username: string; + /** 该发布人在当前筛选范围内的通知条数 */ + count: number; +} + +/** GET /api/notifications/creators 的返回 */ +export interface NotificationCreatorsDto { + creators: NotificationCreatorSummary[]; +} + +/** + * 批量发布的单班结果。 + * + * 批量发布**逐班判定**:某个班没有权限、或正在上课而通知是紧急的,都不会让整批失败 —— + * 失败的那个班带着错误码与中文原因回到这里,成功的班照常落库并实时推送。 + */ +export interface BulkPublishClassResult { + classId: string; + ok: boolean; + /** 成功时:新建的实体 id(通知 / 作业) */ + id?: string; + /** 失败时:错误码(FORBIDDEN / URGENT_DURING_CLASS / CLASS_NOT_FOUND …) */ + code?: string; + /** 失败时:可直接展示给用户的中文原因 */ + message?: string; + /** 失败时的附加信息(如 URGENT_DURING_CLASS 的当前课程与下一次课) */ + details?: Record; +} + +/** 批量发布结果汇总 */ +export interface BulkPublishResult { + total: number; + succeeded: number; + failed: number; + results: BulkPublishClassResult[]; +} + +/** POST /api/notifications/bulk:一次给多个班级发同一条通知 */ +export interface BulkCreateNotificationRequest { + /** 目标班级 id 列表(1~50 个) */ + classIds: string[]; + title: string; + content: string; + priority?: NotificationPriority; + /** 有班级正在上课且优先级为紧急时必须置 true(前端二次确认后重试) */ + confirmDuringClass?: boolean; +} + +/** POST /api/homeworks/bulk:一次给多个班级布置同一份作业 */ +export interface BulkCreateHomeworkRequest { + /** 目标班级 id 列表(1~50 个) */ + classIds: string[]; + courseId?: string | null; + /** + * 按**科目名称**指定科目(批量场景用这个,而不是 courseId)。 + * + * 课程是"班级内"的:同一门「语文」在每个班是不同的 Course 记录,跨班不可能共用一个 courseId。 + * 传名称后由服务端在每个目标班里查同名课程;某个班没有这门课时**只让那个班失败** + * (结果里带「该班级没有…这门课程」),**不会自动建课**。与 courseId 同时传时以本字段为准。 + */ + subjectName?: string | null; + title: string; + content: string; + /** 作业所属日期(YYYY-MM-DD);不传按服务器当天处理 */ + assignDate?: string | null; + attachmentUrl?: string | null; } /* ------------------------------------------------------------------ 成绩 */ @@ -979,8 +1083,15 @@ export interface IslandState { inClass: boolean; /** 当前课的结束时间(HH:mm),用于"下课后自动弹出" */ currentPeriodEnd: string | null; - /** 本轮展示原因:新消息 / 下课后补发 / 紧急插播 / 叫人 */ - reason: 'new' | 'after-class' | 'urgent' | 'call' | null; + /** + * 本轮展示原因:新消息 / 下课后补发 / 紧急插播 / 叫人 / 收回中。 + * + * `closing` 是"用户点了知道了 / 标为已读"之后的**收回动画**阶段:状态在同一帧就已经落地 + * (`active` 清空、`queued` 清空),主进程只是把形态切回胶囊让渲染进程把卡片收回去, + * 待 `CLOSE_ANIMATION_MS` 之后再淡出隐藏。它不是跨进程握手,渲染进程也不需要任何回执 + * (见 AGENTS.md §5 第 34 条)。 + */ + reason: 'new' | 'after-class' | 'urgent' | 'call' | 'closing' | null; /** * 多条通知时是否已点过"展开更多"(展开后显示全部放得下的通知)。 * 由主进程持有:卡片高度与窗口包围盒都由它决定,渲染进程只负责照此渲染。 @@ -1601,3 +1712,326 @@ export interface UpdateInfo { assets: UpdateAssetDto[]; checkedAt: string; } + +/* ------------------------------------------------------------------ 权限组 */ + +/** + * 一个权限组:名字 + 描述 + 一组权限键(`PermissionGroupItem`)。 + * + * 生效规则见服务端 `lib/permissions.ts` 的 `loadEffectivePermissions`: + * 账号**有分组**时取所属组的键并集(因此能比角色默认更少 ⇒ 可以用来收权); + * **没有任何分组**时回落角色默认模板(逐条镜像权限组上线前各路由的 requireRole)。 + */ +export interface PermissionGroupDto { + id: string; + name: string; + description: string; + keys: PermissionKey[]; + memberCount: number; + /** 内置组(种子创建)不可删除,只允许改成员与键 */ + builtin: boolean; + createdAt: string; + updatedAt: string; +} + +/** 权限页里的一个成员(管理员/教师账号) */ +export interface PermissionMemberDto { + id: string; + username: string; + name: string; + role: UserRole; + /** 所属权限组;空数组 = 走角色默认模板 */ + groupIds: string[]; + /** 生效权限数量(ADMIN 恒为全集;班级端账号恒为 0,见权限页的说明) */ + permissionCount: number; + /** 该账号固定拥有全部权限,不接受分配(管理员) */ + fixed: boolean; +} + +/** `GET /api/permissions/catalog`:权限键目录(与 shared 的常量一一对应,供权限页渲染) */ +export interface PermissionCatalogDto { + keys: PermissionKey[]; + labels: Record; + categories: { title: string; keys: PermissionKey[] }[]; + /** 每个键"默认给哪些角色"(权限页的提示文案用) */ + defaultRoles: Record; + /** 每个角色的默认模板("没有分组时会是什么") */ + roleDefaults: Record; + /** 管理员不可被剥夺的键(前端置灰、服务端也会合并回去) */ + unremovable: PermissionKey[]; +} + +export interface CreatePermissionGroupRequest { + name: string; + description?: string; + keys?: PermissionKey[]; +} + +export interface UpdatePermissionGroupRequest { + name?: string; + description?: string; + keys?: PermissionKey[]; +} + +/** `PUT /api/permissions/users/:id/groups`:整体替换某账号的权限组 */ +export interface AssignUserGroupsRequest { + groupIds: string[]; +} + +/** + * `GET /api/permissions/me`:当前账号的生效权限。 + * + * 两端用它决定"入口显不显示",而**不是**再写一套权限规则(AGENTS.md §5 第 3 条): + * 真正的拦截始终在服务端。 + */ +export interface MyPermissionsDto { + role: UserRole; + permissions: PermissionKey[]; + /** true = 权限来自权限组;false = 走角色默认模板(未分配任何组) */ + fromGroups: boolean; +} + +/* ------------------------------------- 客户端设备 / 远控 / 客户端更新 */ + +/** 远控命令种类(与 shared 的常量 `CLIENT_COMMAND_KINDS` 一一对应) */ +export type ClientCommandKind = 'shutdown' | 'restart' | 'logoff' | 'lock'; + +/** 一条下发给教室客户端电脑的命令 */ +export interface ClientCommandDto { + id: string; + deviceId: string; + kind: ClientCommandKind; + /** 给人看的说明:动作描述 + 管理员填的备注 */ + payload: string; + createdAt: string; + /** 过期时间:客户端拉到时已过期就丢弃并回执「已过期」(避免机器离线几天后突然关机) */ + expiresAt: string | null; + ackedAt: string | null; + /** 执行结果说明(未回执为空串) */ + result: string; +} + +/** Web 端「客户端设备」列表里的一台机器 */ +export interface ClientDeviceDto { + id: string; + classId: string | null; + className: string | null; + deviceKey: string; + name: string; + /** 本机版本(客户端主进程 `app.getVersion()`) */ + clientVersion: string; + platform: string; + /** 是否接受服务端统一下发的自动更新 */ + autoUpdate: boolean; + /** 服务端指定的目标版本;`''` = 不指定 */ + updateTargetVersion: string; + /** 已下载待安装的版本;`''` = 无 */ + pendingVersion: string; + lastHeartbeatAt: string | null; + lastIp: string; + /** 客户端上报的状态说明(如「上课中」「窗口已隐藏」) */ + stateNote: string; + /** 由最后一次心跳与 `CLIENT_ONLINE_WINDOW_MS` 判定 */ + online: boolean; + /** 该设备当前待执行(未回执、未撤销、未过期)的命令;没有则 null */ + pendingCommand: ClientCommandDto | null; + createdAt: string; + updatedAt: string; +} + +/** `POST /api/clients/heartbeat`:客户端每 30 秒一次的上报 */ +export interface ClientHeartbeatRequest { + deviceKey: string; + name?: string; + clientVersion?: string; + platform?: string; + pendingVersion?: string | null; + stateNote?: string; +} + +/** 服务端为某个版本缓存好的安装包信息(教室没有外网时也走服务端下载) */ +export interface ClientUpdateInfoDto { + version: string; + fileName: string; + size: number; + sha256: string; + notes: string; + /** 下载路径(**含 `/api` 前缀**;客户端用「服务器地址 + 它」直接下载) */ + url: string; +} + +/** `POST /api/clients/heartbeat` 的返回:更新下发与远控命令都在这里带回 */ +export interface ClientHeartbeatResponse { + deviceId: string; + serverTime: string; + /** 服务端要求升到的版本;null = 不要求 */ + updateTargetVersion: string | null; + /** 目标版本的安装包信息;null = 服务端还没有这份安装包(客户端只能提示手动更新) */ + update: ClientUpdateInfoDto | null; + /** 待执行命令(最早的一条);null = 无 */ + command: ClientCommandDto | null; +} + +/** `POST /api/clients/commands/:id/ack`:客户端执行后的回执 */ +export interface ClientCommandAckRequest { + /** 既是身份也是护栏:班级端令牌全班共用,靠它确认回执来自命令指向的那台机器 */ + deviceKey: string; + ok: boolean; + result?: string; +} + +/** 一台设备上的操作结果(远控命令 / 更新推送共用) */ +export interface ClientTargetResult { + deviceId: string; + deviceName: string; + ok: boolean; + code?: string; + message?: string; +} + +/** 批量下发结果(远控命令 / 更新推送共用) */ +export interface ClientPushResult { + total: number; + succeeded: number; + failed: number; + results: ClientTargetResult[]; +} + +/** `POST /api/clients/commands`:给选中的电脑下发远控命令 */ +export interface CreateClientCommandRequest { + deviceIds: string[]; + kind: ClientCommandKind; + /** 备注(会显示在被控机器上的二次确认弹窗里) */ + note?: string; +} + +/** `POST /api/clients/updates`:推送更新(按设备、按班级或全部) */ +export interface PushClientUpdateRequest { + deviceIds?: string[]; + classIds?: string[]; + all?: boolean; + /** 目标版本;省略则用服务端缓存的最新版本 */ + version?: string | null; + /** true = 取消下发(回到"不要求更新"的状态) */ + clear?: boolean; +} + +/** `GET /api/update/client/releases`:服务端已缓存的客户端安装包 */ +export interface ClientReleaseDto { + version: string; + fileName: string; + size: number; + sha256: string; + notes: string; + /** 文件是否真的在磁盘上(登记的元数据可能因为手工清理而失效) */ + fileReady: boolean; + createdAt: string; + updatedAt: string; +} + +/* ------------------------------------------------- 全局设置 / 服务端系统状态 */ + +/** `GET/PUT /api/system/settings`:全局设置(目前只有开发者模式,仅 ADMIN 可改) */ +export interface SystemSettingsDto { + /** 开发者模式:错误响应带堆栈、日志降到 debug 级 */ + developerMode: boolean; +} + +/** `GET/PUT /api/system/autostart`:服务端开机自启状态与切换 */ +export interface AutostartStatusDto { + /** 当前形态是否支持切换(开发机/免安装/容器里不支持 —— 避免把开发机的自启项写坏) */ + supported: boolean; + enabled: boolean; + /** run-key=Windows 注册表 Run 项 / scheduled-task / systemd / unsupported */ + mechanism: 'run-key' | 'scheduled-task' | 'systemd' | 'unsupported'; + /** 给人看的说明:命令、路径,或"为什么不支持" */ + detail: string; +} + +/** `GET /api/system/diagnostics`:开发者模式下的运行诊断(**不含任何密钥**) */ +export interface SystemDiagnosticsDto { + version: string; + nodeVersion: string; + platform: string; + uptimeSeconds: number; + databaseProvider: string; + serverRoot: string; + dataDir: string; + clientReleaseCount: number; + onlineClientCount: number; + moduleCount: number; + developerMode: boolean; +} + +/* ------------------------------------------------------------------ 大模型助手 */ + +/** 大模型服务端实现:openai=真接口 / mock=离线确定性结果(自动化验收用,不联网) */ +export type AiProvider = 'openai' | 'mock'; + +/** `GET/PUT /api/ai/settings`:大模型设置(**API Key 只进不出**) */ +export interface AiSettingsDto { + enabled: boolean; + provider: AiProvider; + baseUrl: string; + model: string; + /** 是否已配置密钥(界面据此显示"已配置/未配置",永不回传密钥本身) */ + hasApiKey: boolean; + /** 密钥尾 4 位(便于确认换对了,空串表示未配置) */ + keyHint: string; + /** 追加到系统提示词的学校自定义要求 */ + systemPromptExtra: string; +} + +export interface UpdateAiSettingsRequest { + enabled?: boolean; + provider?: AiProvider; + baseUrl?: string; + model?: string; + /** 传字符串=换成新密钥;传 null=清除;不传=保持不变 */ + apiKey?: string | null; + systemPromptExtra?: string; +} + +/** 助手能执行的动作(都是既有发布入口的封装,绝不直接写库) */ +export type AiActionKind = 'create_homework' | 'create_notification' | 'call_student' | 'unknown'; + +/** `POST /api/ai/interpret`:只解析、不落库;确认后由前端调既有端点 */ +export interface AiInterpretRequest { + /** 老师原话,例如「给高一二班布置作业,把卷子写完」 */ + text: string; + /** 有上下文班级时一并给出(助手据此缩小候选) */ + classId?: string; +} + +/** 助手解析出的一次动作(params 用与手动入口**同一份 zod 校验**后再返回) */ +export interface AiInterpretResult { + action: AiActionKind; + /** 可直接提交给既有端点的参数(已通过 zod 校验) */ + params: Record; + /** 给人看的预览(班级名 / 科目名 / 学生名等已解析成中文) */ + preview: { + summary: string; + className?: string | null; + courseName?: string | null; + studentName?: string | null; + warnings: string[]; + }; + /** 模型自评置信度 0~1(低于 0.5 时界面提示"请仔细核对") */ + confidence: number; + /** 一律为 true:助手的结果必须由老师确认后才落库 */ + needConfirm: boolean; + /** 模型原始回复(仅在开发者模式下回传,便于排查格式问题) */ + rawReply?: string | null; +} + +/** `GET /api/ai/audit`:助手使用审计(谁在什么时候让助手做了什么) */ +export interface AiAuditDto { + id: string; + userId: string; + userName: string; + prompt: string; + intent: string; + status: string; + message: string; + createdAt: string; +} diff --git a/packages/shared/src/utils.ts b/packages/shared/src/utils.ts index 6b9fda2..fd822cd 100644 --- a/packages/shared/src/utils.ts +++ b/packages/shared/src/utils.ts @@ -524,3 +524,24 @@ export function isNewerVersion( if (!normalizeVersion(latest)) return false; return compareVersions(latest, current) > 0; } + +/** + * 客户端安装包的**文件名约定**。 + * + * 与 GitHub Release 上上传的 ASCII 名、以及官网下载页的离线兜底直链 + * **必须一致**(AGENTS.md §4.4:改名要三处一起改)。 + */ +export function clientReleaseFileName(version: string): string { + return `ClassHelper-${normalizeVersion(version)}-x64-client-setup.exe`; +} + +/** + * 服务端从 GitHub 拉取客户端安装包的直链。 + * + * 服务端替教室机器"代下"再缓存(教室常常没有外网)—— Web 端**不能**直连 GitHub + * (CSP 的 connect-src 只有 self / ws / CORS_ORIGIN,见 AGENTS.md §5 第 52 条)。 + */ +export function clientReleaseDownloadUrl(version: string): string { + const tag = `v${normalizeVersion(version)}`; + return `https://github.com/LoveChengke/ClassHelper/releases/download/${tag}/${clientReleaseFileName(version)}`; +} diff --git a/packages/web-admin/package.json b/packages/web-admin/package.json index f3b87be..019727d 100644 --- a/packages/web-admin/package.json +++ b/packages/web-admin/package.json @@ -1,6 +1,6 @@ { "name": "@classhelper/web-admin", - "version": "1.2.0", + "version": "1.3.0", "private": true, "description": "ClassHelper Web 管理端(Vue 3 + Vite + Element Plus)", "type": "module", diff --git a/packages/web-admin/src/api/http.ts b/packages/web-admin/src/api/http.ts index 64d1132..8d5256b 100644 --- a/packages/web-admin/src/api/http.ts +++ b/packages/web-admin/src/api/http.ts @@ -58,7 +58,8 @@ async function request(config: AxiosRequestConfig): Promise { export const api = { get: (url: string, params?: Record): Promise => request({ method: 'GET', url, params }), - post: (url: string, data?: unknown): Promise => request({ method: 'POST', url, data }), + post: (url: string, data?: unknown, config?: AxiosRequestConfig): Promise => + request({ method: 'POST', url, data, ...config }), patch: (url: string, data?: unknown): Promise => request({ method: 'PATCH', url, data }), put: (url: string, data?: unknown): Promise => request({ method: 'PUT', url, data }), delete: (url: string): Promise => request({ method: 'DELETE', url }), diff --git a/packages/web-admin/src/api/index.ts b/packages/web-admin/src/api/index.ts index 1b6f6ba..725d489 100644 --- a/packages/web-admin/src/api/index.ts +++ b/packages/web-admin/src/api/index.ts @@ -1,8 +1,13 @@ import { API_PATHS, + type AiAuditDto, + type AiInterpretResult, + type AiSettingsDto, + type ApiErrorResponse, type ArchivedClassContentDto, type ArchivedYearDetailDto, type ArchivedYearDto, + type AssignUserGroupsRequest, type ClassDetailDto, type ClassDto, type AutoTermWeeksRequest, @@ -10,15 +15,25 @@ import { type ClassPlanImportResultDto, type ClassPlanPreviewDto, type ClassStatusDto, + type ClientDeviceDto, + type ClientPushResult, + type ClientReleaseDto, type CourseDto, type CreateArchiveRequest, - type CreateClassRequest, type CreateCallRequest, + type CreateClassRequest, + type CreateClientCommandRequest, type CreateCourseRequest, type CreateGradeRequest, type CreateHomeworkRequest, + type BulkCreateHomeworkRequest, + type BulkCreateNotificationRequest, + type BulkPublishResult, + type NotificationCreatorsDto, + type NotificationDaysDto, type CreateIntegrationDeviceRequest, type CreateNotificationRequest, + type CreatePermissionGroupRequest, type CreateScheduleRequest, type CreateStudentRequest, type DashboardSummary, @@ -38,8 +53,13 @@ import { type IntegrationDeviceTokenDto, type LoginRequest, type LoginResponse, + type MyPermissionsDto, type NotificationDto, type PaginatedResult, + type PermissionCatalogDto, + type PermissionGroupDto, + type PermissionMemberDto, + type PushClientUpdateRequest, type ScheduleDto, type ScheduleWeekView, type SendClassIslandNotificationRequest, @@ -50,16 +70,20 @@ import { type StudentStatus, type StudentTransferDto, type SubjectTeacherDto, + type SystemDiagnosticsDto, + type SystemSettingsDto, type TableImportPreview, type TableImportResult, type TermWeeksDto, type TimeLayoutDto, type TimeLayoutImportResult, type TimeLayoutParsePreview, + type UpdateAiSettingsRequest, type UpdateClassAccountRequest, type UpdateClassRequest, type UpdateInfo, type UpdateIntegrationDeviceRequest, + type UpdatePermissionGroupRequest, type UpdateStudentRequest, type UpdateTermWeeksRequest, type UserDto, @@ -131,6 +155,22 @@ export const courseApi = { remove: (id: string): Promise<{ id: string }> => api.delete(`${API_PATHS.courses}/${id}`), }; +/** + * 从「同名课程已存在」的失败里取出那门已有课程的 id。 + * + * 服务端对 `POST /courses` 做了 (班级 + 科目名) 去重(返回 409 `COURSE_EXISTS` + + * `details.courseId`),因为课程创建会把创建者写成任课老师 —— 若放行同名第二门课, + * 谁都能借"新建科目"把自己变成别人的科目的老师,绕过科目维度的写权限。 + * 前端拿到这个 id 就**复用既有课程**:权限判定随即回到"你是不是这门课的老师", + * 该 403 的地方就 403(而不是悄悄多出一门归属自己的同名课)。 + */ +export function courseIdFromExistsError(error: unknown): string | null { + const data = (error as { response?: { data?: ApiErrorResponse } })?.response?.data; + if (!data || data.code !== 'COURSE_EXISTS') return null; + const details = data.details as { courseId?: unknown } | null | undefined; + return typeof details?.courseId === 'string' ? details.courseId : null; +} + /* ------------------------------------------------------------------ 课表 */ export const scheduleApi = { @@ -163,6 +203,9 @@ export const homeworkApi = { api.get(`${API_PATHS.homeworks}/days`, params), detail: (id: string): Promise => api.get(`${API_PATHS.homeworks}/${id}`), create: (payload: CreateHomeworkRequest): Promise => api.post(API_PATHS.homeworks, payload), + /** 一次给多个班级布置同一份作业(服务端逐班判定并返回逐班结果) */ + bulkCreate: (payload: BulkCreateHomeworkRequest): Promise => + api.post(`${API_PATHS.homeworks}/bulk`, payload), update: (id: string, payload: Partial): Promise => api.patch(`${API_PATHS.homeworks}/${id}`, payload), remove: (id: string): Promise<{ id: string }> => api.delete(`${API_PATHS.homeworks}/${id}`), @@ -181,9 +224,35 @@ export const notificationApi = { priority?: string; unreadOnly?: boolean; keyword?: string; + /** 只看某一天(YYYY-MM-DD,按通知所属日期 assignDate) */ + date?: string; + /** 日期区间(含边界) */ + from?: string; + to?: string; + /** 只看某位发布人 */ + createdBy?: string; }): Promise => api.get(API_PATHS.notifications, params), + /** 按天归档:有通知的日期 + 当天条数 */ + days: (params: { + classId?: string; + priority?: string; + createdBy?: string; + from?: string; + to?: string; + }): Promise => api.get(`${API_PATHS.notifications}/days`, params), + /** 按发布人归档:发布人 + 各人的通知条数 */ + creators: (params: { + classId?: string; + priority?: string; + date?: string; + from?: string; + to?: string; + }): Promise => api.get(`${API_PATHS.notifications}/creators`, params), create: (payload: CreateNotificationRequest): Promise => api.post(API_PATHS.notifications, payload), + /** 一次给多个班级发同一条通知(服务端逐班判定并返回逐班结果) */ + bulkCreate: (payload: BulkCreateNotificationRequest): Promise => + api.post(`${API_PATHS.notifications}/bulk`, payload), remove: (id: string): Promise<{ id: string }> => api.delete(`${API_PATHS.notifications}/${id}`), unreadCount: (classId?: string): Promise<{ count: number }> => api.get(`${API_PATHS.notifications}/unread-count`, classId ? { classId } : undefined), @@ -504,3 +573,151 @@ export const updateApi = { check: (force = false): Promise => api.get(`${API_PATHS.update}/check${force ? '?force=1' : ''}`), }; + +/* ------------------------------------------------------------------ 权限组与用户权限 */ + +/** + * 权限组管理(服务端 `modules/permissions`)。 + * + * 权限生效规则:**有权限组时取各组的并集**(因此也能用来收回权限), + * 没有任何组时回落到角色默认模板;管理员固定拥有全集,班级端不受权限组影响。 + * + * 除 `me` 之外,本组接口都要求当前账号有 `permission:manage`(否则服务端 403)。 + */ +export const permissionApi = { + /** 当前账号的生效权限(任何登录账号都能读自己这一份) */ + me: (): Promise => api.get(`${API_PATHS.permissions}/me`), + /** 权限目录:全部键 / 中文名 / 分类 / 各键的默认角色 / 管理员不可剥夺的键 */ + catalog: (): Promise => api.get(`${API_PATHS.permissions}/catalog`), + listGroups: (): Promise => api.get(`${API_PATHS.permissions}/groups`), + createGroup: (payload: CreatePermissionGroupRequest): Promise => + api.post(`${API_PATHS.permissions}/groups`, payload), + /** `keys` 传了就**整体替换**该组的权限键(不传则保持原样) */ + updateGroup: (id: string, payload: UpdatePermissionGroupRequest): Promise => + api.patch(`${API_PATHS.permissions}/groups/${id}`, payload), + /** 内置权限组不能删除(服务端会 400),调用前页面已经禁用了按钮 */ + removeGroup: (id: string): Promise<{ id: string }> => api.delete(`${API_PATHS.permissions}/groups/${id}`), + /** 可分配权限的成员(管理员与教师;管理员是固定行,不能分配) */ + listMembers: (): Promise => api.get(`${API_PATHS.permissions}/users`), + /** 分配权限组:传空数组 = 回到角色默认模板 */ + assignGroups: (userId: string, payload: AssignUserGroupsRequest): Promise => + api.put(`${API_PATHS.permissions}/users/${userId}/groups`, payload), +}; + +/* ------------------------------------------------------------------ 客户端设备(远控与版本管理) */ + +/** + * 「客户端设备」页的接口。 + * + * 权限(与后端 `clients.module.ts` 逐个对齐): + * - `list` / `remove`:`client:manage` + * - `commands`:`remote:control`(关机 / 重启 / 注销 / 锁屏,机器上还会再弹一次确认框) + * - `pushUpdate`:`client:update-push` + */ +export const clientApi = { + /** 设备列表(自动心跳上报产生的记录);`classId` 留空 = 全部班级 */ + devices: (classId?: string): Promise => + api.get(`${API_PATHS.clients}/devices`, classId ? { classId } : undefined), + /** + * 下发远控命令。 + * + * 同一台机器上未执行的旧命令会被服务端**自动撤销**(否则"先关机、后重启"会排队执行)。 + */ + commands: (payload: CreateClientCommandRequest): Promise => + api.post(`${API_PATHS.clients}/commands`, payload), + /** 统一下发客户端更新:按设备 / 按班级 / 全部,或 `clear: true` 取消下发 */ + pushUpdate: (payload: PushClientUpdateRequest): Promise => + api.post(`${API_PATHS.clients}/updates`, payload), + /** 移除一台设备记录(换机 / 旧机器清理;只删记录,不影响那台机器本身) */ + removeDevice: (id: string): Promise<{ id: string }> => api.delete(`${API_PATHS.clients}/devices/${id}`), +}; + +/** GitHub 代下与「登记 → 校验 sha256」都可能要几十秒到几分钟,够长才不至于半路超时 */ +const CLIENT_RELEASE_FETCH_TIMEOUT_MS = 10 * 60 * 1000; + +/** + * 客户端安装包缓存管理(服务端 `modules/update/client-releases.service.ts`)。 + * + * 教室机器常常没有外网,所以安装包由**服务端替教室下载**再缓存: + * Web 端不直连 GitHub(CSP 的 connect-src 只放行 self 与 WebSocket,见 AGENTS.md §5 第 52 条)。 + * + * 权限:`list` 要 `client:manage`,`fetch` / `register` / `remove` 要 `client:update-push`。 + */ +export const clientReleaseApi = { + /** 服务端已缓存的安装包列表 */ + list: (): Promise => api.get(`${API_PATHS.update}/client/releases`), + /** + * 让服务端从 GitHub 下载指定版本并登记。 + * + * ⚠️ **必须显式放宽超时**:axios 实例默认 20 秒,而服务端自己允许下载 10 分钟 + * (`client-releases.service.ts` 的 `FETCH_TIMEOUT_MS`)。用默认值会在服务端还在下载时 + * 就报"请求超时",用户会以为失败而重复点。这里与服务端取同一个上限。 + */ + fetch: (version: string): Promise => + api.post( + `${API_PATHS.update}/client/fetch`, + { version }, + { timeout: CLIENT_RELEASE_FETCH_TIMEOUT_MS }, + ), + /** 登记一份已经手工放进 `data/client-releases/<版本>/` 的安装包 */ + register: (payload: { version: string; fileName?: string; notes?: string }): Promise => + api.post(`${API_PATHS.update}/client/releases`, payload), + /** 删除缓存(元数据 + 磁盘文件) */ + remove: (version: string): Promise<{ version: string }> => + api.delete(`${API_PATHS.update}/client/releases/${encodeURIComponent(version)}`), +}; + +/* ------------------------------------------------------------------ 大模型助手 */ + +/** + * 大模型助手的接口前缀(取自共享契约 `API_PATHS.ai`,与服务端 `modules/ai` 的挂载点同源)。 + */ +const AI_BASE = API_PATHS.ai; + +/** + * 大模型助手(服务端 `modules/ai`)。 + * + * 权限:`settings` / `audit` 要 `ai:manage`(仅管理员);`interpret` 要 `ai:use`(教师也有)。 + * + * ⚠️ **助手只解析、不落库**:`interpret` 只是把一句中文换成结构化参数, + * 真正写数据仍然由前端在老师确认后调既有的发布接口(作业 / 通知 / 叫人)。 + */ +export const aiApi = { + /** + * 读取配置。 + * + * 只有 `ai:manage` 能读(教师读会 403,而响应拦截器会弹一个错误提示), + * 所以助手页**不要**无条件调它——教师侧靠"探测式":直接尝试解析, + * 服务端回了 `AI_NOT_CONFIGURED` 再切空态。 + */ + settings: (): Promise => api.get(`${AI_BASE}/settings`), + /** + * 保存配置。 + * + * `apiKey` 的三种语义(服务端约定):传字符串 = 换新、传 `null` = 清除、**不传 = 保持原样**。 + * 因此"留空表示不修改"时不要往 body 里塞 `apiKey: ''`,那会被当成一个空密钥。 + */ + updateSettings: (payload: UpdateAiSettingsRequest): Promise => + api.put(`${AI_BASE}/settings`, payload), + /** 把一句话解析成待确认的操作;`text` 上限 500 字(超出由服务端 400) */ + interpret: (payload: { text: string; classId?: string }): Promise => + api.post(`${AI_BASE}/interpret`, payload), + /** 使用记录(谁、什么时候、说了什么、解析成什么、结果如何),倒序 */ + audit: (limit = 50): Promise => api.get(`${AI_BASE}/audit`, { limit }), +}; + +/* ------------------------------------------------------------------ 系统设置与诊断 */ + +/** + * 系统设置与诊断(服务端 `modules/system`),全部要 `system:manage`(仅管理员)。 + */ +const SYSTEM_BASE = API_PATHS.system; + +export const systemApi = { + settings: (): Promise => api.get(`${SYSTEM_BASE}/settings`), + /** 目前只有 `developerMode` 一项;保存后服务端返回完整设置 */ + updateSettings: (payload: Partial): Promise => + api.put(`${SYSTEM_BASE}/settings`, payload), + /** 只读诊断信息(版本 / Node / 平台 / 运行时长 / 数据库 / 目录 / 模块数) */ + diagnostics: (): Promise => api.get(`${SYSTEM_BASE}/diagnostics`), +}; diff --git a/packages/web-admin/src/icons.ts b/packages/web-admin/src/icons.ts index e6ca2a4..9797bff 100644 --- a/packages/web-admin/src/icons.ts +++ b/packages/web-admin/src/icons.ts @@ -7,11 +7,15 @@ import { Connection, CopyDocument, Document, + Expand, + Fold, FolderAdd, FolderOpened, Guide, Lock, + MagicStick, Menu, + Monitor, Moon, Notebook, Odometer, @@ -20,6 +24,7 @@ import { Right, School, Search, + Setting, Sunny, Sort, Switch, @@ -29,6 +34,7 @@ import { User, UserFilled, WarningFilled, + ZoomIn, } from '@element-plus/icons-vue'; /** @@ -54,11 +60,17 @@ const APP_ICONS = { Connection, CopyDocument, Document, + /* 侧栏折叠/展开(顶栏汉堡按钮),与桌面客户端用同一对图标 */ + Expand, + Fold, FolderAdd, FolderOpened, Guide, Lock, + /* 大模型助手入口 */ + MagicStick, Menu, + Monitor, Moon, Notebook, Odometer, @@ -67,6 +79,7 @@ const APP_ICONS = { Right, School, Search, + Setting, Sunny, Sort, Switch, @@ -76,6 +89,8 @@ const APP_ICONS = { User, UserFilled, WarningFilled, + /* 关怀模式(放大字号与控件) */ + ZoomIn, } as const; /** 供 `scripts/check-icons.mjs` 读取的名单(构建期静态校验用) */ diff --git a/packages/web-admin/src/layouts/AdminLayout.vue b/packages/web-admin/src/layouts/AdminLayout.vue index ace43d6..6e3eaad 100644 --- a/packages/web-admin/src/layouts/AdminLayout.vue +++ b/packages/web-admin/src/layouts/AdminLayout.vue @@ -2,12 +2,12 @@ import { computed, nextTick, onMounted, onUnmounted, reactive, ref, watch } from 'vue'; import { useRoute, useRouter } from 'vue-router'; import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from 'element-plus'; -import { STORAGE_KEYS } from '@classhelper/shared'; +import { STORAGE_KEYS, type PermissionKey, type UserRole } from '@classhelper/shared'; import { authApi } from '@/api'; import UpdateCheckDialog from '@/components/UpdateCheckDialog.vue'; import { useAuthStore } from '@/stores/auth'; import { useRealtimeStore } from '@/stores/realtime'; -import { useUiStore } from '@/stores/ui'; +import { useUiStore, CARE_SCALE_DEFAULT, CARE_SCALE_MAX, CARE_SCALE_MIN } from '@/stores/ui'; import { useResponsive } from '@/composables/useResponsive'; import { useThemeReveal } from '@/composables/motion'; import { APP_TITLE } from '@/config'; @@ -32,6 +32,49 @@ function handleThemeToggle(event: MouseEvent): void { revealThemeChange(event, () => ui.toggleTheme()); } +/* ---------------------------------------------------------------- 侧栏折叠 */ + +/** + * 顶栏左侧的汉堡按钮(仅桌面端;≤768px 侧栏已经收进抽屉,用的是另一个 `nav-toggle`)。 + * + * 它**只改一个布尔值**:栏宽、文字的淡出、图标位置、行高、滚动条全都交给 + * `styles/index.css` 里 `.ch-nav.is-collapsed …` 那一段(那里有完整推导)—— + * 模板里没有任何 `v-if` 增删菜单项,折叠才是连续的收拢动画而不是"重排一下"。 + * + * 缓动刻意与桌面客户端同一套参数(`--nav-dur` / `--nav-ease`,AGENTS.md §5 第 49 条): + * 这里不要换成 EASE_OUT 之类的"起步快"曲线 —— 两端观感要一致,且自动化会量到中间态。 + */ +const collapseLabel = computed(() => (ui.sidebarCollapsed ? '展开菜单' : '收起菜单')); + +/* ---------------------------------------------------------------- 关怀模式 */ + +/** + * 关怀模式的入口放在顶栏的 `el-popover` 里,**不放进用户下拉菜单**: + * 下拉菜单点一下(甚至点里面的滑块)就会收起,滑块根本拖不动。 + * 气泡里的开关与滑块都不是"点一下就走"的控件,放在气泡里正好。 + */ +/** 放大倍数按百分比显示(1.2 → 120%),比裸小数好读 */ +const careScalePercent = computed(() => `${Math.round(ui.careScale * 100)}%`); + +/** el-switch 的 payload 是 `boolean | string | number` 的联合(active-value 可自定义),这里收口 */ +function handleCareModeChange(value: boolean | string | number): void { + ui.setCareMode(value === true); +} + +/** el-slider 单值模式发 `number`、范围模式发 `number[]`,这里只接单值 */ +function handleCareScaleChange(value: number | number[]): void { + ui.setCareScale(Array.isArray(value) ? (value[0] ?? CARE_SCALE_DEFAULT) : value); +} + +/* -------------------------------------------------------------- AI 助手入口 */ + +/** 顶栏的 AI 助手快捷入口:与侧栏菜单项同一道**生效权限**门控(默认管理员与教师都有) */ +const canUseAssistant = computed(() => auth.can('ai:use')); + +function openAssistant(): void { + void router.push('/assistant'); +} + /** * 菜单项与可见角色(与后端 `@classhelper/shared/permissions` 矩阵保持一致): * - 班级管理 / 学生管理 / 教师管理:仅管理员(班主任与科任老师都没有班级增删改与人员分配权限) @@ -41,8 +84,20 @@ function handleThemeToggle(event: MouseEvent): void { * - 作业 / 通知:所有教师 * - ClassIsland 联动:管理员 + 教师(设备令牌按班签发,页面内再按"能否管理该班"控制按钮) * - 数据库管理:仅管理员(备份/恢复/导入导出/一键切换是最高危操作,后端同样 requireRole('ADMIN')) + * - 权限组管理 / 客户端设备:`roles` 只是粗筛(管理员 + 教师),真正的门控是**生效权限** + * (`permission:manage` / `client:manage`)—— 教师被某个权限组授予它之后同样该看见 + * (见 `visibleMenuItems`) */ -const menuItems = [ +interface MenuItem { + path: string; + title: string; + icon: string; + roles: UserRole[]; + /** 可选的**生效权限**门控:有值时还要 `auth.can()` 通过才显示(角色判不出来这一层) */ + permission?: PermissionKey; +} + +const menuItems: MenuItem[] = [ { path: '/dashboard', title: '仪表盘', icon: 'Odometer', roles: ['ADMIN', 'TEACHER'] }, { path: '/classes', title: '班级管理', icon: 'School', roles: ['ADMIN'] }, { path: '/students', title: '学生管理', icon: 'User', roles: ['ADMIN'] }, @@ -51,13 +106,57 @@ const menuItems = [ { path: '/homeworks', title: '作业发布', icon: 'Notebook', roles: ['ADMIN', 'TEACHER'] }, { path: '/notifications', title: '通知发布', icon: 'Bell', roles: ['ADMIN', 'TEACHER'] }, { path: '/grades', title: '成绩录入', icon: 'Trophy', roles: ['ADMIN', 'TEACHER'] }, + { + path: '/assistant', + title: 'AI 助手', + icon: 'MagicStick', + // `ai:use` 默认给 ADMIN + TEACHER(教师用一句话发布作业/通知/叫人), + // 但仍走**生效权限**细筛:管理员把 ai:use 从某个权限组收回后这里就该消失。 + roles: ['ADMIN', 'TEACHER'], + permission: 'ai:use', + }, { path: '/integrations', title: 'ClassIsland 联动', icon: 'Connection', roles: ['ADMIN', 'TEACHER'] }, + { + path: '/permissions', + title: '权限组', + icon: 'Lock', + roles: ['ADMIN', 'TEACHER'], + permission: 'permission:manage', + }, + { + path: '/clients', + title: '客户端设备', + icon: 'Monitor', + // 与「权限组」同样按**生效权限**门控:教师默认没有 client:manage(见 shared/permissions.ts + // 的默认模板只给 ADMIN),所以这里不能只写 roles —— 被权限组授予后教师也该看见。 + roles: ['ADMIN', 'TEACHER'], + permission: 'client:manage', + }, + { + path: '/ai-settings', + title: '大模型设置', + icon: 'Setting', + // 与「权限组」「客户端设备」同一道门控:只有 ai:manage(默认仅管理员)才看得见, + // 页面里的配置读写也要求它(教师访问会 403)。 + roles: ['ADMIN', 'TEACHER'], + permission: 'ai:manage', + }, { path: '/database', title: '数据库管理', icon: 'Coin', roles: ['ADMIN'] }, ]; -/** 当前账号可见的菜单(班级/学生管理仅管理员可见) */ +/** + * 当前账号可见的菜单。 + * + * 两道筛子:`roles` 是粗筛(角色级,与后端 `requireRole` 对齐),`permission` 是细筛 + * (生效权限级)——「权限组」必须走后者:教师被权限组授予 `permission:manage` 后 + * 角色还是 TEACHER,靠 `roles` 判断不出来。管理员在 `can()` 里短路为 true, + * 所以这里不会因为权限还没拉回来而少显示一项。 + */ const visibleMenuItems = computed(() => - menuItems.filter((item) => (auth.role ? item.roles.includes(auth.role) : false)), + menuItems.filter((item) => { + if (!auth.role || !item.roles.includes(auth.role)) return false; + return !item.permission || auth.can(item.permission); + }), ); /** 高亮当前菜单:直接比较路由路径,避免依赖路由名 */ @@ -96,25 +195,53 @@ function asideElement(): HTMLElement | null { } const indicator = ref({ x: 0, y: 0, width: 0, height: 0, visible: false }); +/** 指示器的裁剪框 = 菜单**视口**(滚动容器自身),滚动时激活项滚出去就跟着被裁掉 */ +const indicatorClip = ref({ top: 0, left: 0, width: 0, height: 0 }); + +/** 菜单滚动容器:桌面侧栏里的 .el-menu(小屏是抽屉里的另一个 el-menu,没有指示器) */ +function menuScroller(): HTMLElement | null { + return asideElement()?.querySelector('.layout-menu') ?? null; +} function syncIndicator(): void { const aside = asideElement(); - const active = aside?.querySelector('.el-menu-item.is-active'); - if (!aside || !active) { + const scroller = menuScroller(); + const active = scroller?.querySelector('.el-menu-item.is-active'); + if (!aside || !scroller || !active) { indicator.value = { ...indicator.value, visible: false }; return; } const asideRect = aside.getBoundingClientRect(); + const box = scroller.getBoundingClientRect(); + /* + * 以菜单视口为基准量,而不是以整个侧栏为基准。 + * + * 菜单列是可滚动的(见 .layout-menu 的 overflow):量侧栏的话,激活项被滚出视口后 + * 指示器会"贴"到品牌区/页脚上。裁剪框跟着菜单视口,指示器就只在菜单里可见。 + */ + indicatorClip.value = { + top: box.top - asideRect.top, + left: box.left - asideRect.left, + width: box.width, + height: box.height, + }; const rect = active.getBoundingClientRect(); indicator.value = { - x: rect.left - asideRect.left, - y: rect.top - asideRect.top, + x: rect.left - box.left, + y: rect.top - box.top, width: rect.width, height: rect.height, visible: rect.width > 0 && rect.height > 0, }; } +const indicatorClipStyle = computed(() => ({ + top: `${indicatorClip.value.top}px`, + left: `${indicatorClip.value.left}px`, + width: `${indicatorClip.value.width}px`, + height: `${indicatorClip.value.height}px`, +})); + const indicatorStyle = computed(() => ({ width: `${indicator.value.width}px`, height: `${indicator.value.height}px`, @@ -124,6 +251,11 @@ const indicatorStyle = computed(() => ({ let asideObserver: ResizeObserver | null = null; let indicatorFrame = 0; +let scrollerEl: HTMLElement | null = null; +let scrollSettleTimer = 0; +/** 正在跟随滚动:这期间关掉过渡,否则每帧都重新起一段 397ms 的弹簧,看起来"慢吞吞地追" */ +const indicatorFollowing = ref(false); +const SCROLL_SETTLE_MS = 140; /** * 把测量推迟到下一帧再跑。 @@ -141,13 +273,39 @@ function scheduleIndicatorSync(): void { }); } +/** + * 滚动菜单时**同步**量一次(不走 rAF 版):滚动本身已经在合成器上跑, + * 再隔一帧读数会看到指示器明显落后于菜单。 + */ +function handleMenuScroll(): void { + syncIndicator(); + indicatorFollowing.value = true; + window.clearTimeout(scrollSettleTimer); + scrollSettleTimer = window.setTimeout(() => { + indicatorFollowing.value = false; + }, SCROLL_SETTLE_MS); +} + +/** 绑定/解绑菜单滚动监听(小屏没有侧栏,切回来时要重新绑) */ +function bindMenuScroller(): void { + const next = menuScroller(); + if (next === scrollerEl) return; + scrollerEl?.removeEventListener('scroll', handleMenuScroll); + scrollerEl = next; + scrollerEl?.addEventListener('scroll', handleMenuScroll, { passive: true }); +} + /** * 首次测量放在 onMounted(而不是 nextTick):子组件先于父组件挂载完成, * 此刻 `.el-menu-item.is-active` 已在文档里,量完再让浏览器首帧绘制 —— * 于是指示器**第一次出现就是正确位置**,不会从左上角滑过去。 */ onMounted(() => { + // 生效权限(`GET /permissions/me`):菜单里的「权限组」要等它回来才显示。 + // 失败不阻塞布局(store 内已静默吞掉),管理员在 can() 里短路、不受这一拍影响。 + void auth.ensurePermissions(); syncIndicator(); + bindMenuScroller(); const aside = asideElement(); if (aside && typeof ResizeObserver !== 'undefined') { asideObserver = new ResizeObserver(scheduleIndicatorSync); @@ -158,6 +316,9 @@ onMounted(() => { onUnmounted(() => { if (indicatorFrame) cancelAnimationFrame(indicatorFrame); indicatorFrame = 0; + window.clearTimeout(scrollSettleTimer); + scrollerEl?.removeEventListener('scroll', handleMenuScroll); + scrollerEl = null; asideObserver?.disconnect(); asideObserver = null; }); @@ -182,6 +343,11 @@ watch( watch(isMobile, (mobile) => { if (!mobile) drawerVisible.value = false; + // 侧栏是按 v-if 挂载/卸载的:从抽屉切回常驻侧栏后,新的 .el-menu 需要重新绑滚动监听 + void nextTick(() => { + syncIndicator(); + bindMenuScroller(); + }); }); /** @@ -351,12 +517,13 @@ const tourSteps = computed(() => [ + + diff --git a/packages/web-admin/src/views/ClientsView.vue b/packages/web-admin/src/views/ClientsView.vue new file mode 100644 index 0000000..96a9424 --- /dev/null +++ b/packages/web-admin/src/views/ClientsView.vue @@ -0,0 +1,1216 @@ + + + + + diff --git a/packages/web-admin/src/views/GradesView.vue b/packages/web-admin/src/views/GradesView.vue index e487753..34dbb8f 100644 --- a/packages/web-admin/src/views/GradesView.vue +++ b/packages/web-admin/src/views/GradesView.vue @@ -19,7 +19,7 @@ import { type StudentDto, type StudentGradeDetailDto, } from '@classhelper/shared'; -import { classApi, courseApi, gradeApi } from '@/api'; +import { classApi, courseApi, courseIdFromExistsError, gradeApi } from '@/api'; import ScoreBarChart from '@/components/ScoreBarChart.vue'; import ScoreLineChart from '@/components/ScoreLineChart.vue'; import TableImportDialog from '@/components/TableImportDialog.vue'; @@ -100,7 +100,6 @@ async function onClassChange(): Promise { courses.value = await courseApi.list(filter.classId); students.value = await classApi.students(filter.classId); await loadGrades(); - await loadTeacherSuggestions(); } /* ------------------------------------------------------------ 图表 @@ -132,9 +131,40 @@ async function resolveCourseId(value: string): Promise { const name = value.slice(SUBJECT_PREFIX.length); const existing = courses.value.find((item) => item.name === name); if (existing) return existing.id; - const created = await courseApi.create({ classId: filter.classId, name }); - courses.value = [...courses.value, created]; - return created.id; + try { + const created = await courseApi.create({ classId: filter.classId, name }); + courses.value = [...courses.value, created]; + return created.id; + } catch (error) { + // 该班已有同名课程(本地列表过期):复用那一门(服务端 409 COURSE_EXISTS + details.courseId); + // 复用之后成绩的科目写权限照常判定,不会"因为选了新建就变成自己的科目" + const reusedId = courseIdFromExistsError(error); + if (reusedId) return reusedId; + throw error; + } +} + +/** + * 录入下拉里可选的课程:**只列自己任教的科目**(管理员不受限)。 + * + * 与后端 `assertCanManageSubjectContent` 同源:班主任也不自动拥有本班所有科目的成绩录入权, + * 所以下拉里不给"别人的科目",避免老师选完提交才吃 403。 + */ +const ownCourses = computed(() => + auth.isAdmin ? courses.value : courses.value.filter((item) => item.teacherId === auth.user?.id), +); + +/** 能不能在该班新建科目(与课表管理同权限:管理员或本班班主任) */ +const canCreateCourse = computed(() => { + if (auth.isAdmin) return true; + return classes.value.find((item) => item.id === filter.classId)?.teacherId === auth.user?.id; +}); + +/** 录入弹窗的默认科目:优先沿用页面筛选,但筛的是别人的科目时退回"我的科目"第一条 */ +function defaultFormCourseId(): string { + const list = ownCourses.value; + if (filter.courseId && list.some((item) => item.id === filter.courseId)) return filter.courseId; + return list[0]?.id ?? ''; } /* ------------------------------------------------------------ 单条录入 */ @@ -156,7 +186,7 @@ function openSingle(): void { return; } singleForm.studentId = students.value[0]?.id ?? ''; - singleForm.courseId = filter.courseId || courses.value[0]?.id || ''; + singleForm.courseId = defaultFormCourseId(); singleForm.examName = filter.examName || '期中考试'; singleForm.score = 90; singleForm.totalScore = 100; @@ -202,7 +232,7 @@ function openBulk(): void { ElMessage.warning('该班级暂无学生'); return; } - bulkForm.courseId = filter.courseId || courses.value[0]?.id || ''; + bulkForm.courseId = defaultFormCourseId(); bulkForm.examName = filter.examName || '期中考试'; bulkForm.totalScore = 100; bulkForm.levelType = 'percent'; @@ -269,7 +299,7 @@ function openLevelBatch(): void { ElMessage.warning('请先选择班级'); return; } - levelBatchForm.courseId = filter.courseId || courses.value[0]?.id || ''; + levelBatchForm.courseId = defaultFormCourseId(); levelBatchForm.examName = filter.examName || examOptions.value[0] || ''; levelBatchForm.levelType = 'letter'; levelBatchVisible.value = true; @@ -355,11 +385,6 @@ async function removeGrade(row: GradeDto): Promise { /* ------------------------------------------------------------ 教师视角建议:按考试分组统计 */ -const teacherCourseSuggestions = ref([]); -async function loadTeacherSuggestions(): Promise { - teacherCourseSuggestions.value = courses.value; -} - function onGradeEvent(): void { void loadGrades(); } @@ -523,8 +548,8 @@ onUnmounted(() => { - - + + { - + { - - + + { return SUBJECT_CATALOG.filter((name) => !existing.has(name)); }); +/** + * 发布弹窗里可选的课程:**只列自己任教的科目**(管理员不受限)。 + * + * 展示侧的收口与服务端 `assertCanManageSubjectContent` 同源:班主任也不自动拥有 + * 本班所有科目的作业权,所以下拉里不给"别人的科目"这个选项 —— 否则老师选了、 + * 提交、再吃一个 403,只会以为是系统坏了。留空(不指定科目)仍然允许班主任用。 + */ +const ownFormCourses = computed(() => + auth.isAdmin ? formCourses.value : formCourses.value.filter((item) => item.teacherId === auth.user?.id), +); + +/** 该班里能不能新建科目:与课表管理同权限(管理员或本班班主任) */ +const canCreateCourse = computed(() => { + if (auth.isAdmin) return true; + return classes.value.find((item) => item.id === form.classId)?.teacherId === auth.user?.id; +}); + async function loadFormCourses(): Promise { if (!form.classId) { formCourses.value = []; @@ -292,6 +311,14 @@ async function resolveCourseId(): Promise { formCourses.value = [...formCourses.value, created]; return created.id; } catch (error) { + // 该班已有同名课程(本地列表过期或别人刚建):复用那一门 —— + // 服务端对同名课程返回 409 COURSE_EXISTS + details.courseId(不许再建第二门同名的, + // 否则创建者会凭空成为该科任课老师,绕过科目维度的写权限)。 + const reusedId = courseIdFromExistsError(error); + if (reusedId) { + await loadFormCourses(); + return reusedId; + } // 新建课程需要"管理员或本班班主任"权限(与课表管理一致),科任老师会被 403 拦截 ElMessage.error( `新建科目「${name}」失败:${ @@ -334,6 +361,89 @@ async function submitForm(): Promise { await loadHomeworks(); } +/* ------------------------------------------------------------------ 批量布置(多班同发) */ + +/** + * 批量布置:一次给多个班级布置同一份作业。 + * + * 科目按**名称**提交(`subjectName`),不是一个 courseId —— 课程是"班级内"的: + * 同一门「语文」在每个班是不同的 Course 记录,没法共用一个 id。服务端在每个目标班里查同名课程, + * 某个班没有这门课时只让那个班失败(结果里带原因),不会自动建课、也不会拖垮整批。 + */ +const bulkVisible = ref(false); +const bulkSending = ref(false); +const bulkForm = reactive({ + classIds: [] as string[], + subjectName: '', + title: '', + content: '', + attachmentUrl: '', + assignDate: dayKeyLocal(new Date()), +}); + +function openBulk(): void { + bulkForm.classIds = filter.classId ? [filter.classId] : []; + bulkForm.subjectName = ''; + bulkForm.title = ''; + bulkForm.content = ''; + bulkForm.attachmentUrl = ''; + // 默认跟当前「按天查看」的日期一致 + bulkForm.assignDate = filter.date || dayKeyLocal(new Date()); + bulkVisible.value = true; +} + +function classNameOf(classId: string): string { + return classes.value.find((item) => item.id === classId)?.name ?? classId; +} + +async function submitBulk(): Promise { + if (bulkForm.classIds.length === 0) { + ElMessage.warning('请至少选择一个目标班级'); + return; + } + if (!bulkForm.title.trim()) { + ElMessage.warning('请输入作业标题'); + return; + } + if (!bulkForm.content.trim()) { + ElMessage.warning('请输入作业内容'); + return; + } + + bulkSending.value = true; + try { + const result = await homeworkApi.bulkCreate({ + classIds: bulkForm.classIds, + subjectName: bulkForm.subjectName || null, + title: bulkForm.title.trim(), + content: bulkForm.content, + assignDate: bulkForm.assignDate || null, + attachmentUrl: bulkForm.attachmentUrl.trim() || null, + }); + + const failures = result.results.filter((item) => !item.ok); + if (failures.length === 0) { + ElMessage.success(`作业已发布到 ${result.succeeded} 个班级,实时推送已发出`); + } else { + const detail = failures + .map((item) => `${classNameOf(item.classId)}(${item.message ?? '失败'})`) + .join(';'); + ElMessage({ + type: result.succeeded > 0 ? 'warning' : 'error', + message: `成功 ${result.succeeded} 个班,失败 ${failures.length} 个班|${detail}`, + duration: 8000, + showClose: true, + }); + } + + // 全部失败时把弹窗留着,方便老师改科目/改班级后直接重试 + bulkVisible.value = result.succeeded === 0; + if (result.succeeded > 0) await loadHomeworks(); + } finally { + bulkSending.value = false; + } +} + async function removeHomework(row: HomeworkDto): Promise { await ElMessageBox.confirm(`删除作业「${row.title}」?`, '确认', { type: 'warning' }); await homeworkApi.remove(row.id); @@ -414,6 +524,7 @@ onUnmounted(() => { @clear="loadHomeworks" /> 发布作业 + 批量布置 @@ -460,10 +571,18 @@ onUnmounted(() => { :loading="formCoursesLoading" style="width: 100%" > - - + + - + { {{ formCoursesLoading ? '正在加载该班课程…' - : '该班还没有的科目在「其他科目(新建)」里,选中后会自动建课再发布(需班主任/管理员)' + : canCreateCourse + ? '只列你任教的科目;该班还没有的科目在「其他科目(新建)」里,选中后自动建课再发布' + : '只列你任教的科目;需要别的科目请先由班主任/管理员在课表管理里建好并分配任课老师' }} @@ -506,6 +627,69 @@ onUnmounted(() => { + + + + + + + + + + + + +
+ 按科目名称匹配:服务端在每个班里找同名课程,某个班没有这门课时只有那个班失败(不会自动建课) +
+
+ + + + + + + + + + + + +
+ +
+ + + + + + + diff --git a/packages/web-admin/src/views/SchedulesView.vue b/packages/web-admin/src/views/SchedulesView.vue index db7fc93..b4cabaf 100644 --- a/packages/web-admin/src/views/SchedulesView.vue +++ b/packages/web-admin/src/views/SchedulesView.vue @@ -18,7 +18,7 @@ import { type ScheduleWeekView, type WeekParity, } from '@classhelper/shared'; -import { classApi, courseApi, dashboardApi, scheduleApi } from '@/api'; +import { classApi, courseApi, courseIdFromExistsError, dashboardApi, scheduleApi } from '@/api'; import ClassPlanImportDialog from '@/components/ClassPlanImportDialog.vue'; import { useAuthStore } from '@/stores/auth'; import { useRealtimeStore } from '@/stores/realtime'; @@ -122,9 +122,17 @@ async function resolveCourseId(): Promise { const name = value.slice(SUBJECT_PREFIX.length); const existing = courses.value.find((item) => item.name === name); if (existing) return existing.id; - const created = await courseApi.create({ classId: query.classId, name }); - courses.value = [...courses.value, created]; - return created.id; + try { + const created = await courseApi.create({ classId: query.classId, name }); + courses.value = [...courses.value, created]; + return created.id; + } catch (error) { + // 该班已有同名课程(本地列表过期):复用那一门,绝不另建第二门同名的课 + // —— 课程创建会把创建者写成任课老师,重复建课正是"把自己变成别人科目的老师"的入口 + const reusedId = courseIdFromExistsError(error); + if (!reusedId) throw error; + return reusedId; + } } /** diff --git a/scripts/dist-server.mjs b/scripts/dist-server.mjs index 0598f09..ca3cc87 100644 --- a/scripts/dist-server.mjs +++ b/scripts/dist-server.mjs @@ -4,14 +4,22 @@ * 交付产物统一落到 releases/server/<版本>/(releases/ 不入库,与客户端/插件同级): * * 产出(Windows): - * releases/server/<版本>/免安装/ 可直接运行的免安装目录 * releases/server/<版本>/安装包/ClassHelper 服务端-<版本>-x64-setup.exe Windows 安装程序(NSIS) * releases/server/<版本>/构建中间/server-installer.generated.nsi NSIS 生成脚本(可重新生成) * * 产出(Linux,加 --platform linux,即 pnpm dist:server:linux): * releases/server/<版本>/linux-x64/classhelper-server-linux-x64-<版本>.tar.gz Linux 一键安装器的输入 * releases/server/<版本>/linux-x64/classhelper-server-linux-x64-<版本>.tar.gz.sha256 - * releases/server/<版本>/linux-x64/classhelper-server-linux-x64/ tar 的解包目录(同内容留档) + * + * 交付产物里**没有免安装目录**。组装出来的运行时(Node + node_modules + dist + web,Windows 侧 + * 约 335MB)只是打安装程序 / 打 tar.gz 的中间物,留在 `.cache/server-staging/<版本>/` 下, + * 打完就删: + * · NSIS 把整个 staging 目录编进 setup.exe,编完没人再读它; + * · Linux 侧 tar 已经把它压成 tar.gz,那份解包目录与 tar 内容完全一样,纯属重复留档。 + * 迭代打包用的 `--reuse-deps`(跳过两分钟 npm install)改从 `.cache/server-staging/` 复用, + * 所以反复打包时中间物会一直在缓存里 —— 想彻底腾空间就删 `.cache/`。 + * **别把 staging 挪回 releases/**:交付目录里混着一个能直接双击运行的副本, + * 既容易被误当成"这是我该拷走的东西",又让每个版本多占 335MB。 * * 为什么 Linux 包要在 Linux 上构建:`@libsql/linux-x64-gnu`、`@prisma/adapter-libsql` 都是 * 平台相关依赖,在 Windows 上 npm install 出来的 node_modules 装到 Linux 跑不起来。 @@ -47,7 +55,7 @@ const outRoot = path.join(root, 'releases', 'server', VERSION); const INSTALLER_DIR = path.join(outRoot, '安装包'); const GENERATED_DIR = path.join(outRoot, '构建中间'); -/** win = 免安装目录 + NSIS 安装程序;linux = 带运维工具的 tar.gz(给 deploy/install.sh 用) */ +/** win = 组装运行时 + NSIS 安装程序;linux = 带运维工具的 tar.gz(给 deploy/install.sh 用) */ const PLATFORM = (() => { const index = process.argv.indexOf('--platform'); const value = index >= 0 ? process.argv[index + 1] : process.platform === 'win32' ? 'win' : 'linux'; @@ -57,10 +65,12 @@ const PLATFORM = (() => { const isLinux = PLATFORM === 'linux'; // Linux 侧 staging 的目录名会原样成为 tar 里的顶层目录(deploy/install.sh 用 --strip-components=1 -// 展开),保持 ASCII 不动;Windows 侧目录名只影响本地留档,直接用中文分类名。 +// 展开),保持 ASCII 不动。 +// 两侧都放在 .cache/ 下:staging 是纯粹的一次性中间物(见文件头),不该出现在交付目录里; +// 放缓存里同时让 `--reuse-deps` 有地方复用上一轮的 node_modules。 const staging = isLinux - ? path.join(outRoot, 'linux-x64', 'classhelper-server-linux-x64') - : path.join(outRoot, '免安装'); + ? path.join(root, '.cache', 'server-staging', VERSION, 'linux-x64', 'classhelper-server-linux-x64') + : path.join(root, '.cache', 'server-staging', VERSION, 'win'); const LINUX_TARBALL = path.join( outRoot, @@ -922,6 +932,13 @@ function main() { } log(`Linux 安装包:${path.relative(root, file)}(${sizeMb(LINUX_TARBALL)} MB)`); log(`校验文件:${path.relative(root, shaFile)}`); + // tar.gz 才是交付物;生成它的那份解包目录与 tar 内容一模一样,删掉。 + try { + fs.rmSync(staging, { recursive: true, force: true }); + log(`已清理组装中间物 ${path.relative(root, staging)}`); + } catch (error) { + log(`组装中间物清理失败(不影响交付物):${error.message}`); + } log('完成。部署方式:sudo bash deploy/install.sh --package <上面的 tar.gz>'); return; } @@ -930,9 +947,18 @@ function main() { writeRuntimeScripts(); writeReadme(); + const stagingMb = sizeMb(staging); const installer = buildInstaller(); - log(`免安装目录:${path.relative(root, staging)}(${sizeMb(staging)} MB)`); + // NSIS 已经把整个 staging 目录编进 setup.exe,编完这份组装结果就没人再读了 —— + // 删掉,别让交付目录/缓存里堆着一个能直接双击运行的 335MB 副本(见文件头)。 + try { + fs.rmSync(staging, { recursive: true, force: true }); + log(`已清理组装中间物 ${path.relative(root, staging)}(${stagingMb} MB)`); + } catch (error) { + log(`组装中间物清理失败(不影响交付物):${error.message}`); + } + if (installer) log( `安装程序:${path.relative(root, installer)}(${Math.round(fs.statSync(installer).size / 1024 / 1024)} MB)`, diff --git a/scripts/nsis/server-installer.nsi b/scripts/nsis/server-installer.nsi index f14ab21..6a1a3f1 100644 --- a/scripts/nsis/server-installer.nsi +++ b/scripts/nsis/server-installer.nsi @@ -33,7 +33,7 @@ ShowUninstDetails show !define MUI_UNICON "${ICON_FILE}" !define MUI_ABORTWARNING !define MUI_FINISHPAGE_TITLE "安装完成" -!define MUI_FINISHPAGE_TEXT "ClassHelper 服务端已安装完成。$\r$\n$\r$\n安装程序已将服务加入当前用户的开机自启项,浏览器打开 http://127.0.0.1:${APP_PORT}/ 即可使用管理端。$\r$\n$\r$\n默认账号:admin / admin123(首次登录后请立即修改密码)" +!define MUI_FINISHPAGE_TEXT "ClassHelper 服务端已安装完成。$\r$\n$\r$\n若勾选了「开机自启动」,服务会在登录 Windows 后自动运行(可随时在管理端「系统设置」里关闭)。浏览器打开 http://127.0.0.1:${APP_PORT}/ 即可使用管理端。$\r$\n$\r$\n默认账号:admin / admin123(首次登录后请立即修改密码)" !define MUI_FINISHPAGE_RUN !define MUI_FINISHPAGE_RUN_TEXT "立即启动服务并打开管理端" !define MUI_FINISHPAGE_RUN_FUNCTION LaunchService @@ -42,6 +42,10 @@ ShowUninstDetails show !insertmacro MUI_PAGE_WELCOME !insertmacro MUI_PAGE_LICENSE "${STAGING}\LICENSE.txt" !insertmacro MUI_PAGE_DIRECTORY +; 组件页:让"开机自启动"成为**可选**项(需求:「支持选择开机自启动」)。 +; 默认勾选(保持既有行为),取消勾选则完全不写注册表 Run 项 —— +; 装完之后仍然可以在 Web 端「系统设置」里随时开关(PUT /api/system/autostart)。 +!insertmacro MUI_PAGE_COMPONENTS !insertmacro MUI_PAGE_INSTFILES !insertmacro MUI_PAGE_FINISH @@ -100,8 +104,8 @@ Section "主程序" SecMain ; 桌面快捷方式(管理端进浏览器) CreateShortCut "$DESKTOP\${PRODUCT}.lnk" "http://127.0.0.1:${APP_PORT}/" - ; 开机自启(当前用户) - WriteRegStr HKCU "Software\Microsoft\Windows\CurrentVersion\Run" "ClassHelperServer" '"$INSTDIR\start.cmd"' + ; 开机自启改到**组件页里的可选 Section**(见下面的 SecAutostart): + ; 这里不再无条件写注册表,由用户在组件页勾选决定(需求:「支持选择开机自启动」)。 ; 注册到"应用和功能"列表,便于通过系统设置卸载 ${GetSize} "$INSTDIR" "/S=0K" $0 $1 $2 @@ -117,6 +121,15 @@ Section "主程序" SecMain ExecShell "open" "$INSTDIR\start.cmd" "" SW_SHOWMINIMIZED SectionEnd +; ------------------------------------------------------------------ 开机自启动 +; +; 独立成一个 Section,于是它会出现在组件页上(默认勾选 = 与历史行为一致), +; 用户可以取消勾选、装完之后也能在 Web 端「系统设置」里随时开关 +; (服务端接口 `PUT /api/system/autostart`,Windows 走的就是这个注册表 Run 项)。 +Section "开机自启动(登录 Windows 后自动启动服务,推荐)" SecAutostart + WriteRegStr HKCU "Software\Microsoft\Windows\CurrentVersion\Run" "ClassHelperServer" '"$INSTDIR\start.cmd"' +SectionEnd + ; ------------------------------------------------------------------ 卸载 Section "Uninstall" diff --git a/scripts/verify-packaged.mjs b/scripts/verify-packaged.mjs index 5c8c9d0..cbeefa7 100644 --- a/scripts/verify-packaged.mjs +++ b/scripts/verify-packaged.mjs @@ -26,18 +26,26 @@ const argOf = (name) => { return index >= 0 ? argv[index + 1] : undefined; }; -// 默认取 releases/client/<版本>/免安装/(dist-win 的产物落点,见 AGENTS.md §4) -const clientVersion = JSON.parse(fs.readFileSync(path.join(repoRoot, 'package.json'), 'utf8')).version; -const defaultExe = path.join(repoRoot, 'releases', 'client', clientVersion, '免安装', 'ClassHelper.exe'); +// 默认取「装好的客户端」:打包产物里已经没有解包目录(见 dist-win.mjs 文件头), +// 所以默认值只能是安装后的落点,其余情况一律用 --exe 显式指定。 +const installedExe = path.join( + process.env.LOCALAPPDATA ?? path.join(process.env.USERPROFILE ?? '', 'AppData', 'Local'), + 'Programs', + 'ClassHelper', + 'ClassHelper.exe', +); -const exe = path.resolve(argOf('--exe') ?? defaultExe); +const exe = path.resolve(argOf('--exe') ?? installedExe); const classCode = argOf('--code') ?? process.env.ELECTRON_SMOKE_CLASS_CODE ?? 'G101'; const classPassword = argOf('--password') ?? process.env.ELECTRON_SMOKE_CLASS_PASSWORD ?? '123456'; const forceOffline = argv.includes('--offline'); if (!fs.existsSync(exe)) { console.error(`[packaged-smoke] 找不到打包后的 EXE:${exe}`); - console.error('[packaged-smoke] 先执行 pnpm dist:win,或用 --exe 指定已安装的客户端路径'); + console.error( + '[packaged-smoke] 用 --exe 指定:装好的客户端(默认 %LOCALAPPDATA%\\Programs\\ClassHelper\\' + + 'ClassHelper.exe)、或解压后的便携版所在目录', + ); process.exit(2); } diff --git a/website/download.html b/website/download.html index a59d7a1..29691a8 100644 --- a/website/download.html +++ b/website/download.html @@ -18,7 +18,7 @@ - + diff --git a/website/index.html b/website/index.html index b1ff210..f659181 100644 --- a/website/index.html +++ b/website/index.html @@ -44,7 +44,7 @@ ClassHelper - 1.2.0 + 1.3.0