From c0ebf88138117471d2d9d99b70e66dc20fa264ce Mon Sep 17 00:00:00 2001
From: LoveChengke
Date: Sat, 17 Oct 2026 03:06:46 +0800
Subject: [PATCH] =?UTF-8?q?chore(release):=201.3.3=20=E5=8F=91=E5=B8=83?=
=?UTF-8?q?=E5=B9=B6=E9=80=80=E5=BD=B9=E6=97=A7=E8=81=94=E5=8A=A8?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
AGENTS.md | 323 +++---
README.md | 128 ++-
deploy/docker-compose.sqlite.yml | 2 +-
deploy/docker-compose.yml | 2 +-
deploy/package.runtime.json | 2 +-
docs/app/calls.md | 5 +-
docs/app/index.md | 2 +-
docs/app/notifications.md | 31 +-
docs/app/schedules.md | 22 +-
docs/dev/api.md | 209 ++--
docs/dev/architecture.md | 31 +-
docs/dev/classisland-plugin.md | 149 ---
docs/dev/data-model.md | 70 +-
docs/dev/index.md | 15 +-
docs/dev/permissions.md | 115 ++-
docs/dev/realtime.md | 4 +-
docs/dev/release.md | 36 +-
docs/dev/review-2026-10-17.md | 50 +
docs/dev/testing.md | 96 +-
docs/dev/toc.yml | 4 +-
docs/faq.md | 4 +-
docs/get-started/classisland.md | 120 ---
docs/get-started/client.md | 10 +-
docs/get-started/first-content.md | 22 +-
docs/get-started/index.md | 18 +-
docs/get-started/requirements.md | 3 +-
docs/get-started/setup-class.md | 2 +-
docs/get-started/toc.yml | 2 -
docs/get-started/web-admin.md | 26 +-
docs/images/web-integrations.jpg | Bin 65965 -> 0 bytes
docs/index.md | 12 +-
docs/management/upgrade.md | 9 +-
docs/reference.md | 822 ++++++---------
package.json | 15 +-
packages/classisland-plugin/.gitignore | 4 -
.../ClassHelper.ClassIslandPlugin.csproj | 45 -
.../Properties/launchSettings.json | 10 -
packages/classisland-plugin/README.md | 163 ---
packages/classisland-plugin/icon.png | Bin 85233 -> 0 bytes
packages/classisland-plugin/manifest.yml | 19 -
packages/classisland-plugin/scripts/build.mjs | 168 ---
.../classisland-plugin/scripts/verify.mjs | 405 --------
.../src/Interop/ClassHelperClient.cs | 485 ---------
.../src/Models/PluginSettings.cs | 159 ---
packages/classisland-plugin/src/Plugin.cs | 171 ----
.../src/Services/BridgeService.cs | 647 ------------
.../ClassHelperNotificationProvider.cs | 260 -----
.../src/Services/ClassPlanWriter.cs | 299 ------
.../src/Services/ScheduleMapper.cs | 272 -----
.../src/Views/BridgeSettingsPage.axaml | 150 ---
.../src/Views/BridgeSettingsPage.axaml.cs | 198 ----
packages/desktop-client/package.json | 2 +-
packages/desktop-client/scripts/smoke.mjs | 165 +--
.../desktop-client/src/island/IslandApp.vue | 62 +-
.../desktop-client/src/main/auto-update.ts | 124 ++-
.../desktop-client/src/main/client-agent.ts | 116 ++-
packages/desktop-client/src/main/config.ts | 22 +-
.../desktop-client/src/main/crash-guard.ts | 17 +-
packages/desktop-client/src/main/index.ts | 179 ++--
packages/desktop-client/src/main/ipc.ts | 6 +-
packages/desktop-client/src/main/island.ts | 56 +-
.../desktop-client/src/main/smoke-quick.ts | 106 ++
packages/desktop-client/src/main/smoke.ts | 87 +-
packages/desktop-client/src/preload/index.ts | 3 +
packages/desktop-client/src/renderer/App.vue | 8 -
.../desktop-client/src/renderer/api/index.ts | 35 +-
.../desktop-client/src/renderer/cache/db.ts | 67 +-
.../src/renderer/cache/index.ts | 11 +-
.../src/renderer/cache/scope.ts | 22 +
.../src/renderer/cache/self-test.ts | 37 +-
.../renderer/components/OnboardingWelcome.vue | 8 +-
.../src/renderer/composables/confirm.ts | 12 +
.../src/renderer/island/bridge.ts | 27 +-
.../src/renderer/layouts/ClientLayout.vue | 29 +-
.../desktop-client/src/renderer/stores/app.ts | 91 +-
.../src/renderer/stores/auth.ts | 21 +-
.../src/renderer/stores/notifications.ts | 45 +-
.../src/renderer/stores/realtime.ts | 53 +-
.../src/renderer/views/GradeView.vue | 2 +-
.../src/renderer/views/LoginView.vue | 2 -
.../src/renderer/views/ScheduleView.vue | 13 +-
.../views/settings/AccountSettingsView.vue | 5 +-
.../views/settings/DeveloperSettingsView.vue | 12 +-
.../views/settings/GeneralSettingsView.vue | 16 +-
.../views/settings/IslandSettingsView.vue | 5 +-
.../views/settings/ReminderSettingsView.vue | 116 +--
.../desktop-client/src/types/desktop.d.ts | 17 +-
packages/server/package.json | 7 +-
.../migration.sql | 13 +
.../migration.sql | 6 +
packages/server/prisma/schema.prisma | 130 +--
.../scripts/apply-column-migrations.cjs | 11 +-
packages/server/scripts/build.mjs | 20 +
.../server/scripts/verify-classisland.mjs | 679 -------------
packages/server/scripts/verify-e2e.mjs | 143 ++-
packages/server/src/index.ts | 3 +
.../src/lib/backfill-notification-dates.ts | 24 +
packages/server/src/lib/db-bootstrap.ts | 17 +
packages/server/src/lib/http.ts | 10 +-
packages/server/src/lib/mappers.ts | 9 -
packages/server/src/lib/permissions.ts | 59 +-
packages/server/src/lib/schemas.ts | 2 +-
packages/server/src/lib/snapshot.ts | 15 +-
packages/server/src/middleware/auth.ts | 13 +-
packages/server/src/modules/ai/ai.service.ts | 61 +-
.../server/src/modules/auth/auth.module.ts | 4 +-
.../server/src/modules/calls/calls.service.ts | 24 +-
.../src/modules/classes/classes.module.ts | 76 +-
.../src/modules/classes/classes.schemas.ts | 19 +-
.../src/modules/classes/classes.service.ts | 124 +--
.../src/modules/clients/clients.module.ts | 22 +-
.../src/modules/clients/clients.schemas.ts | 7 +
.../src/modules/clients/clients.service.ts | 104 +-
.../src/modules/dashboard/dashboard.module.ts | 12 +-
.../src/modules/grades/grades.module.ts | 63 +-
.../src/modules/grades/grades.service.ts | 19 +-
.../src/modules/imports/class-plan.service.ts | 9 +-
.../src/modules/imports/imports.module.ts | 62 +-
.../modules/imports/table-import.service.ts | 45 +-
.../src/modules/integrations/device-auth.ts | 104 --
.../integrations/integrations.module.ts | 255 -----
.../integrations/integrations.schemas.ts | 133 ---
.../integrations/integrations.service.ts | 957 ------------------
.../integrations/write-back.service.ts | 271 -----
.../notifications/notifications.schemas.ts | 12 +-
.../notifications/notifications.service.ts | 58 +-
.../modules/permissions/permissions.module.ts | 2 +-
.../permissions/permissions.service.ts | 92 +-
packages/server/src/modules/registry.ts | 2 -
.../modules/schedules/schedules.schemas.ts | 2 +-
.../modules/schedules/schedules.service.ts | 8 +-
.../src/modules/students/students.module.ts | 6 +-
.../src/modules/students/students.service.ts | 36 +-
.../src/modules/teachers/teachers.module.ts | 4 +-
.../server/src/modules/term/term.service.ts | 26 +-
.../src/modules/update/update.module.ts | 24 +-
packages/server/src/realtime/socket.ts | 6 +-
packages/server/src/types/express.d.ts | 10 -
packages/shared/package.json | 2 +-
packages/shared/src/constants.ts | 57 +-
packages/shared/src/permissions.ts | 48 +-
packages/shared/src/types.ts | 386 +------
packages/shared/src/utils.ts | 2 +-
packages/web-admin/package.json | 2 +-
packages/web-admin/src/api/index.ts | 85 +-
.../src/components/TableImportDialog.vue | 15 +-
.../web-admin/src/layouts/AdminLayout.vue | 47 +-
packages/web-admin/src/router/index.ts | 28 +-
packages/web-admin/src/stores/auth.ts | 10 +-
packages/web-admin/src/stores/realtime.ts | 39 +-
packages/web-admin/src/styles/index.css | 3 +-
.../web-admin/src/views/AiSettingsView.vue | 36 +-
packages/web-admin/src/views/ArchivesView.vue | 34 +-
.../web-admin/src/views/AssistantView.vue | 27 +-
packages/web-admin/src/views/ClassesView.vue | 119 ++-
.../web-admin/src/views/ClientHubView.vue | 762 --------------
packages/web-admin/src/views/ClientsView.vue | 135 ++-
packages/web-admin/src/views/GradesView.vue | 217 ++--
.../web-admin/src/views/HomeworksView.vue | 203 ++--
.../web-admin/src/views/IntegrationsView.vue | 816 ---------------
.../web-admin/src/views/NotificationsView.vue | 159 +--
.../web-admin/src/views/PermissionsView.vue | 23 +-
.../web-admin/src/views/SchedulesView.vue | 69 +-
packages/web-admin/src/views/StudentsView.vue | 34 +-
.../web-admin/src/views/TermWeeksView.vue | 55 +-
scripts/icons/render.cjs | 7 +-
scripts/lib/smoke-class.mjs | 110 +-
scripts/ui-smoke/live-probe.mjs | 13 +-
scripts/ui-smoke/main.cjs | 283 ++----
scripts/ui-smoke/run.mjs | 25 +-
scripts/verify-client-agent.mjs | 254 +++++
scripts/verify-client-migrations.mjs | 96 ++
scripts/verify-clients-api.mjs | 233 +++++
scripts/verify-packaged.mjs | 60 +-
scripts/verify-review-api.mjs | 358 +++++++
scripts/verify-review-client.mjs | 309 ++++++
scripts/version.mjs | 42 +-
website/assets/download.js | 25 +-
website/assets/main.js | 14 +-
website/assets/shots/web-integrations.jpg | Bin 65965 -> 0 bytes
website/download.html | 14 +-
website/index.html | 56 +-
website/tools/capture-shots.mjs | 1 -
代码审查报告-2026-10-17.md | 607 +++++++++++
184 files changed, 5450 insertions(+), 11705 deletions(-)
delete mode 100644 docs/dev/classisland-plugin.md
create mode 100644 docs/dev/review-2026-10-17.md
delete mode 100644 docs/get-started/classisland.md
delete mode 100644 docs/images/web-integrations.jpg
delete mode 100644 packages/classisland-plugin/.gitignore
delete mode 100644 packages/classisland-plugin/ClassHelper.ClassIslandPlugin.csproj
delete mode 100644 packages/classisland-plugin/Properties/launchSettings.json
delete mode 100644 packages/classisland-plugin/README.md
delete mode 100644 packages/classisland-plugin/icon.png
delete mode 100644 packages/classisland-plugin/manifest.yml
delete mode 100644 packages/classisland-plugin/scripts/build.mjs
delete mode 100644 packages/classisland-plugin/scripts/verify.mjs
delete mode 100644 packages/classisland-plugin/src/Interop/ClassHelperClient.cs
delete mode 100644 packages/classisland-plugin/src/Models/PluginSettings.cs
delete mode 100644 packages/classisland-plugin/src/Plugin.cs
delete mode 100644 packages/classisland-plugin/src/Services/BridgeService.cs
delete mode 100644 packages/classisland-plugin/src/Services/ClassHelperNotificationProvider.cs
delete mode 100644 packages/classisland-plugin/src/Services/ClassPlanWriter.cs
delete mode 100644 packages/classisland-plugin/src/Services/ScheduleMapper.cs
delete mode 100644 packages/classisland-plugin/src/Views/BridgeSettingsPage.axaml
delete mode 100644 packages/classisland-plugin/src/Views/BridgeSettingsPage.axaml.cs
create mode 100644 packages/desktop-client/src/main/smoke-quick.ts
create mode 100644 packages/desktop-client/src/renderer/cache/scope.ts
create mode 100644 packages/desktop-client/src/renderer/composables/confirm.ts
create mode 100644 packages/server/prisma/migrations/20261016090000_retire_integration_and_schedule_updates/migration.sql
create mode 100644 packages/server/prisma/migrations/20261017090000_permission_read_keys/migration.sql
create mode 100644 packages/server/scripts/build.mjs
delete mode 100644 packages/server/scripts/verify-classisland.mjs
create mode 100644 packages/server/src/lib/backfill-notification-dates.ts
delete mode 100644 packages/server/src/modules/integrations/device-auth.ts
delete mode 100644 packages/server/src/modules/integrations/integrations.module.ts
delete mode 100644 packages/server/src/modules/integrations/integrations.schemas.ts
delete mode 100644 packages/server/src/modules/integrations/integrations.service.ts
delete mode 100644 packages/server/src/modules/integrations/write-back.service.ts
delete mode 100644 packages/web-admin/src/views/ClientHubView.vue
delete mode 100644 packages/web-admin/src/views/IntegrationsView.vue
create mode 100644 scripts/verify-client-agent.mjs
create mode 100644 scripts/verify-client-migrations.mjs
create mode 100644 scripts/verify-clients-api.mjs
create mode 100644 scripts/verify-review-api.mjs
create mode 100644 scripts/verify-review-client.mjs
delete mode 100644 website/assets/shots/web-integrations.jpg
create mode 100644 代码审查报告-2026-10-17.md
diff --git a/AGENTS.md b/AGENTS.md
index 84acba1..bad8ea8 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -1,6 +1,24 @@
# AGENTS.md — ClassHelper(Class Helper)
本文件写给在本仓库里干活的 AI 编码代理。先读这里,再动代码。
+
+**2026-10-16 更新**:当前仓库位于 `F:\ClassHelper`,F 盘可用。ClassIsland 联动插件、设备令牌、提醒通道与镜像接口已退役;离线课表 JSON 导入和界面设计参考保留。下文历史联动部署记录不再适用。
+客户端维护只走 `/api/clients/*`,页面为 `ClientsView.vue`;默认权限按数据库中对应角色的默认组生效。
+**2026-10-17 更新**:日常 `verify:desktop` / `verify:packaged` 默认是后台快速检查,不创建可见灵动岛或托盘;
+完整桌面动画、透明度与截图验收必须显式加 `--visual`(`pnpm verify:desktop:visual`)。
+下文关于置顶验证窗口、鼠标穿透与截图时序的历史约定仅适用于视觉模式。不要为了日常修改反复占用用户桌面。
+联网冒烟显式启用,使用临时教师和班级;禁止回退到真实班级、重置其密码或向其发送测试通知。
+
+本次 PowerShell 会话的 PATH 不含 pnpm:需要先把 `C:\Users\hck16\AppData\Local\deepseek-harness\bin`
+加到 PATH 前面,再按下文设置 `DSH_NODE` 和 `DSH_PNPM_BIN`。Git Bash 外层找不到 pnpm 时可执行:
+
+```bash
+export DSH_NODE='D:\Node.js\node.exe'
+export DSH_PNPM_BIN='C:\Users\hck16\AppData\Local\node\corepack\v1\pnpm\11.8.0\bin\pnpm.cjs'
+node "C:/Users/hck16/AppData/Local/node/corepack/v1/pnpm/11.8.0/bin/pnpm.cjs" typecheck
+```
+
+外层直接调用用于解决找不到命令;环境变量让内部嵌套的 pnpm 仍使用真实运行时。
文中「本机环境」「已知坑」两节按**实测**写成;若与本机现状不符,以本机实测为准并顺手改掉这里。
---
@@ -9,13 +27,12 @@
班级信息管理系统,**pnpm monorepo**,一份代码产出多个交付物:
-| 交付物 | 位置 | 形态 |
-| -------------------- | ----------------------------- | ------------------------------------------------------------------------------ |
-| 后端服务 | `packages/server` | Express 5 + Prisma 7 + Socket.IO,`/api` 前缀 |
-| Web 管理端 | `packages/web-admin` | Vue 3 + Vite + Element Plus(教师/管理员用,PWA 可安装) |
-| 桌面客户端 | `packages/desktop-client` | Electron + Vue 3(学生用,含「灵动岛」浮窗) |
-| 共享契约 | `packages/shared` | 三端共用的类型 / 常量 / 权限 / 工具函数 |
-| ClassIsland 联动插件 | `packages/classisland-plugin` | .NET 8 / C#(装在教室的 ClassIsland 上:上报课表与上课状态、弹出老师发的提醒) |
+| 交付物 | 位置 | 形态 |
+| ---------- | ------------------------- | -------------------------------------------------------- |
+| 后端服务 | `packages/server` | Express 5 + Prisma 7 + Socket.IO,`/api` 前缀 |
+| Web 管理端 | `packages/web-admin` | Vue 3 + Vite + Element Plus(教师/管理员用,PWA 可安装) |
+| 桌面客户端 | `packages/desktop-client` | Electron + Vue 3(学生用,含「灵动岛」浮窗) |
+| 共享契约 | `packages/shared` | 三端共用的类型 / 常量 / 权限 / 工具函数 |
核心链路:
@@ -28,9 +45,6 @@
▼
Socket.IO ──► class:{classId} 房间 ──► 学生桌面客户端实时更新 UI
-ClassIsland(教室机器)──► ClassHelper 联动插件 ──► /api/integrations/classisland/*(设备令牌鉴权)
- ▲ │
- └──── 老师下发的提醒(上报返回值里顺带带回)◄┘
```
- 远端:`LoveChengke/ClassHelper`(仓库名大小写不敏感,本地 remote 存的是小写 `classhelper.git`)
@@ -137,7 +151,7 @@ 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/ 21 个功能模块 + registry.ts + module.types.ts
+ src/modules/ 20 个功能模块 + 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
@@ -150,37 +164,27 @@ packages/desktop-client/
src/renderer/ ClassHelper 班级端渲染进程:api / cache(IndexedDB) / stores / views / island / router
src/types/desktop.d.ts 主进程 ↔ 渲染进程契约
scripts/ build-main / dev / smoke / dist-win
-packages/classisland-plugin/ .NET 8 插件(独立于 pnpm workspace,不参与 pnpm install)
- manifest.yml 清单:id=classhelper.classisland.bridge / apiVersion=2.0.0.0 / version=1.2.0.0
- ClassHelper.ClassIslandPlugin.csproj(TargetFramework=net8.0)
- src/Plugin.cs 入口:读配置 → 注册提醒提供方 / 设置页 / BridgeService
- src/Models/PluginSettings.cs
- src/Services/ BridgeService(上报·接收·镜像)/ ScheduleMapper / ClassPlanWriter
- ClassHelperNotificationProvider(把提醒显示到 ClassIsland 上)
- src/Views/BridgeSettingsPage.axaml(.cs):Avalonia 设置页
- src/Interop/ClassHelperClient.cs:HTTP 客户端 + 与服务端逐个字段对齐的 DTO
- scripts/ build.mjs(编译 / 打包 .cipx)/ verify.mjs(静态契约校验,实测 74 项)
```
---
## 3. 本机环境(2026-09-30 实测)
-| 项 | 状态 |
-| ---------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
-| Node | **v22.15.0**(`D:\Node.js\node.exe`;根 `engines` 要求 ≥ 20.19,Prisma 7 的 engine 约束是 `^20.19 \|\| ^22.12 \|\| >=24.0`,v22.15 都满足) |
+| 项 | 状态 |
+| ---------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| 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` |
-| Prisma Client | 已生成到 `packages/server/src/generated/prisma`(gitignored) |
-| 数据库 | SQLite `packages/server/prisma/dev.db`:10 个迁移已应用、种子已写入(用户 18 / 班级 3 / 课程 54 / 课表 93 / 作业 15 / 通知 12 / 成绩 90) |
-| `shared/dist` | 已构建(`pnpm build:shared`)—— **它是三端与 seed 的前置产物,缺了会 `ERR_MODULE_NOT_FOUND`** |
-| Electron 二进制 | 已手动装好(`node_modules/.pnpm/electron@44.3.0/node_modules/electron/dist/electron.exe`,246 MB) |
-| .NET SDK | ✅ **8.0.425**(`C:\Program Files\dotnet\sdk`,`dotnet --list-sdks` 有输出)⇒ 插件可编译/打包;**别再照旧文档说"没装"**(`dotnet` 在 PATH 里,`--list-sdks` 才是权威判据) |
-| ClassIsland | **2.1.0.1,装在 `D:\Classisland`**(启动器 `D:\Classisland\ClassIsland.exe`,实际进程 `D:\Classisland\app-2.1.0.1-0\ClassIsland.Desktop.exe`) |
-| ClassIsland 数据 | 数据根 **`D:\Classisland\data`**:`Config/`、`Profiles/`、`Plugins/`(当前为空,尚未装本插件)、`Logs/`、`Cache/`、`Settings.json` |
-| 桌面会话 | 有(`ClassIsland.Desktop.exe` 跑在 session 1);**Electron GUI 在 AI 会话里也能跑**(需先修完整性标签,见 §7 第 7 条) |
+| npm registry | `https://registry.npmjs.org/`(仓库内无 `.npmrc`,取全局配置) |
+| 依赖 | 已装(707 包,`pnpm install` 实测 2m45s) |
+| 后端 `.env` | 已由 `.env.example` 生成(**gitignored**),`JWT_SECRET` 为本机随机值,`TERM_START_DATE=2026-09-07` |
+| Prisma Client | 已生成到 `packages/server/src/generated/prisma`(gitignored) |
+| 数据库 | SQLite `packages/server/prisma/dev.db`:10 个迁移已应用、种子已写入(用户 18 / 班级 3 / 课程 54 / 课表 93 / 作业 15 / 通知 12 / 成绩 90) |
+| `shared/dist` | 已构建(`pnpm build:shared`)—— **它是三端与 seed 的前置产物,缺了会 `ERR_MODULE_NOT_FOUND`** |
+| Electron 二进制 | 已手动装好(`node_modules/.pnpm/electron@44.3.0/node_modules/electron/dist/electron.exe`,246 MB) |
+| .NET SDK | ✅ **8.0.425**(`C:\Program Files\dotnet\sdk`,`dotnet --list-sdks` 有输出)⇒ 插件可编译/打包;**别再照旧文档说"没装"**(`dotnet` 在 PATH 里,`--list-sdks` 才是权威判据) |
+| ClassIsland | **2.1.0.1,装在 `D:\Classisland`**(启动器 `D:\Classisland\ClassIsland.exe`,实际进程 `D:\Classisland\app-2.1.0.1-0\ClassIsland.Desktop.exe`) |
+| ClassIsland 数据 | 数据根 **`D:\Classisland\data`**:`Config/`、`Profiles/`、`Plugins/`(当前为空,尚未装本插件)、`Logs/`、`Cache/`、`Settings.json` |
+| 桌面会话 | 有(`ClassIsland.Desktop.exe` 跑在 session 1);**Electron GUI 在 AI 会话里也能跑**(需先修完整性标签,见 §7 第 7 条) |
> **旧文档里的 `F:\classisland` / `F:\data` 已失效**:本机只有 `C:` 与 `D:`,`F:` 盘不存在。
> 另有一份旧副本在 `C:\Users\胡\Desktop\classisland`(数据在它自己的 `data/` 下,2026-09-18 用过),当前以 `D:\Classisland` 为准。
@@ -218,10 +222,10 @@ packages/classisland-plugin/ .NET 8 插件(独立于 pnpm workspace,不参
`typecheck` 报出 `[pnpm] pnpm not found`。要么别设,要么先 `Remove-Item Env:DSH_PNPM`。
- 也不要用 `node
@@ -100,44 +30,14 @@ onMounted(async () => {
- ClassIsland 联动
-
- {{ classIslandStatusText }}
-
- {{ classIslandStatus?.pluginVersion ?? '—' }} /
- {{ classIslandStatus?.classIslandVersion ?? '—' }}
-
-
+ 通知提醒
- 通知模式
- :老师在 ClassHelper 上发布通知 / 叫人时,提醒弹在灵动岛还是 ClassIsland,
- 由这台教室机器决定。选择会同步到班级,服务端据此决定是否推送到教室的 ClassIsland。
-
-
- applyChannel(value as ClassIslandNotificationChannel)
- "
- >
-
- {{ item.label }}
-
-
-
- {{ CLASSISLAND_NOTIFICATION_CHANNEL_HINTS[appStore.notificationChannel] }}
-
-
- 提示:本班还没有接入 ClassIsland 设备(在 Web 端「ClassHelper 联动」里接入后才能选"只在
- ClassIsland 上弹")。
-
+ 通知与叫人通过 ClassHelper 客户端和灵动岛提醒。普通通知在上课期间暂存,下课后补弹;紧急通知立即提醒。
@@ -150,9 +50,7 @@ onMounted(async () => {
打开后,可以在「成绩」页看到本班成绩与排名。学生本身没有账号、不能登录,
查成绩就是由这台教室机器代做。关闭后本机查成绩会被服务端拒绝,教师端不受影响。
-
- 本开关由管理员在服务端「班级管理」和「客户端设备」里统一设置,教室机器不提供本地开关。
-
+ 本开关由管理员在服务端「班级管理」里统一设置,教室机器不提供本地开关。
{{ gradeQueryEnabled ? '当前:已允许查看成绩与排名' : '当前:已由管理员关闭' }}
diff --git a/packages/desktop-client/src/types/desktop.d.ts b/packages/desktop-client/src/types/desktop.d.ts
index d12def9..da07cb0 100644
--- a/packages/desktop-client/src/types/desktop.d.ts
+++ b/packages/desktop-client/src/types/desktop.d.ts
@@ -2,13 +2,7 @@
* 主进程与渲染进程之间的桥接契约(preload 通过 contextBridge 暴露)。
* 渲染进程只依赖这些最小 API,不接触 Node.js。
*/
-import type {
- ClassIslandNotificationChannel,
- IslandAppearance,
- IslandNotification,
- IslandState,
- UpdateInfo,
-} from '@classhelper/shared';
+import type { IslandAppearance, IslandNotification, IslandState, UpdateInfo } from '@classhelper/shared';
import type { ClientAgentStatus } from '../main/client-agent.js';
/** 服务端联动状态:渲染进程要显示它,因此在这里转出(`desktop.d.ts` 是两端契约的单一入口) */
@@ -45,13 +39,6 @@ export interface DesktopStoredConfig {
* 录入作业时点一下就追加到标题/内容里,少打字(例如 P、大本、背诵)。
*/
homeworkPhrases: string[];
- /**
- * 通知显示位置(both / client / classisland)。
- *
- * 这台教室机器"提醒到底弹在哪个端"由它决定:值同时存本地(决定本机要不要弹窗/上岛)
- * 与班级记录(服务端据此决定要不要推给 ClassIsland)。
- */
- notificationChannel: ClassIslandNotificationChannel;
/**
* 是否已看过「初次启动引导」。
*
@@ -170,6 +157,7 @@ export interface DesktopBridge {
/* 灵动岛 */
/** 把一条通知投递到灵动岛(payload 必须是纯数据,说明见 saveConfig) */
islandPush(payload: { notification: IslandNotification; context?: IslandPushContext }): void;
+ islandRemoveNotification(id: string): void;
/** 同步上课状态:进入上课隐藏,下课后自动弹出暂存通知 */
islandSetClassState(payload: IslandClassStatePayload): void;
/** 读取灵动岛当前状态(设置页/冒烟验证用) */
@@ -255,6 +243,7 @@ declare global {
/** 冒烟验证钩子,仅在 ELECTRON_SMOKE_TEST=1 时由渲染进程注册 */
__classhelperSmoke__?: {
cacheSelfTest(): Promise<{ ok: boolean; detail: string }>;
+ cacheIsolationSelfTest(): Promise<{ ok: boolean; detail: string }>;
offlineScenario(): Promise<{ ok: boolean; detail: string }>;
layoutNavigationSelfTest(): Promise<{ ok: boolean; detail: string }>;
onlineScenario(credentials?: {
diff --git a/packages/server/package.json b/packages/server/package.json
index 0b8782a..330cd99 100644
--- a/packages/server/package.json
+++ b/packages/server/package.json
@@ -1,6 +1,6 @@
{
"name": "@classhelper/server",
- "version": "1.3.2",
+ "version": "1.3.3",
"private": true,
"description": "ClassHelper后端服务:REST API + Socket.IO 实时推送 + Prisma 数据访问",
"type": "module",
@@ -9,7 +9,7 @@
"dev": "tsx watch src/index.ts",
"start": "node dist/index.js",
"start:tsx": "tsx src/index.ts",
- "build": "tsc -p tsconfig.build.json",
+ "build": "node scripts/build.mjs",
"typecheck": "tsc -p tsconfig.json",
"db:generate": "prisma generate",
"db:migrate": "prisma migrate dev",
@@ -18,8 +18,7 @@
"db:seed": "prisma db seed",
"db:reset": "prisma migrate reset --force",
"db:studio": "prisma studio",
- "verify:e2e": "node scripts/verify-e2e.mjs",
- "verify:classisland": "node scripts/verify-classisland.mjs"
+ "verify:e2e": "node scripts/verify-e2e.mjs"
},
"dependencies": {
"@classhelper/shared": "workspace:*",
diff --git a/packages/server/prisma/migrations/20261016090000_retire_integration_and_schedule_updates/migration.sql b/packages/server/prisma/migrations/20261016090000_retire_integration_and_schedule_updates/migration.sql
new file mode 100644
index 0000000..4b7ee82
--- /dev/null
+++ b/packages/server/prisma/migrations/20261016090000_retire_integration_and_schedule_updates/migration.sql
@@ -0,0 +1,13 @@
+-- 移除退役联动数据;业务课表保留并归为手工管理。
+DROP TABLE IF EXISTS "ClassIslandPush";
+DROP TABLE IF EXISTS "IntegrationDevice";
+ALTER TABLE "Class" DROP COLUMN "notificationChannel";
+UPDATE "Schedule" SET "source" = 'manual' WHERE "source" = 'classisland';
+
+-- 默认组按角色绑定,改名也不会失去对应关系。
+ALTER TABLE "PermissionGroup" ADD COLUMN "defaultRole" TEXT;
+CREATE UNIQUE INDEX "PermissionGroup_defaultRole_key" ON "PermissionGroup"("defaultRole");
+DELETE FROM "PermissionGroupItem" WHERE "key" IN ('device:manage', 'classisland:notify');
+
+ALTER TABLE "ClientDevice" ADD COLUMN "updateMode" TEXT NOT NULL DEFAULT 'idle';
+ALTER TABLE "ClientDevice" ADD COLUMN "updateNotBefore" DATETIME;
diff --git a/packages/server/prisma/migrations/20261017090000_permission_read_keys/migration.sql b/packages/server/prisma/migrations/20261017090000_permission_read_keys/migration.sql
new file mode 100644
index 0000000..73c431c
--- /dev/null
+++ b/packages/server/prisma/migrations/20261017090000_permission_read_keys/migration.sql
@@ -0,0 +1,6 @@
+-- 未实现独立接口的权限不再展示;原有管理组继承对应只读能力。
+DELETE FROM "PermissionGroupItem" WHERE "key" IN ('log:read', 'developer:mode');
+INSERT OR IGNORE INTO "PermissionGroupItem" ("id", "groupId", "key")
+SELECT "groupId" || ':roster:read', "groupId", 'roster:read' FROM "PermissionGroupItem" WHERE "key" = 'roster:manage';
+INSERT OR IGNORE INTO "PermissionGroupItem" ("id", "groupId", "key")
+SELECT "groupId" || ':teacher:read', "groupId", 'teacher:read' FROM "PermissionGroupItem" WHERE "key" = 'teacher:manage';
diff --git a/packages/server/prisma/schema.prisma b/packages/server/prisma/schema.prisma
index 6c39303..625e48c 100644
--- a/packages/server/prisma/schema.prisma
+++ b/packages/server/prisma/schema.prisma
@@ -219,11 +219,8 @@ model Class {
/// 本学期教学周数(班主任可调,默认 20):课表周次选择与 weekEnd 默认值都用它
termWeeks Int @default(20)
/// 本班学期开始日期(YYYY-MM-DD,本地日期);留空则用全局配置(.env 的 TERM_START_DATE)。
- /// 管理员改它会重算「当前第几周」,并随镜像课表下发给教室的 ClassIsland。
+ /// 管理员改它会重算「当前第几周」,并广播课表更新。
termStartDate String?
- /// 通知显示位置(由 ClassHelper 班级端在设置页里选):
- /// both=班级端与 ClassIsland 都弹 / client=只弹班级端 / classisland=只弹 ClassIsland
- notificationChannel String @default("both")
/// 是否允许 ClassHelper 班级端按学号查询本班学生的成绩明细(默认开启)。
/// 关闭后班级端查成绩一律 403;教师端不受影响。
studentGradeQueryEnabled Boolean @default(true)
@@ -243,8 +240,6 @@ model Class {
grades Grade[]
exams Exam[]
timeLayouts TimeLayout[]
- integrationDevices IntegrationDevice[]
- classIslandPushes ClassIslandPush[]
clientDevices ClientDevice[]
@@index([teacherId])
@@ -295,9 +290,6 @@ model Schedule {
/// 单双周:ALL=每周(默认)/ ODD=单周 / EVEN=双周(ClassIsland WeekCountDiv + WeekCountDivTotal 的映射)
weekParity String @default("ALL")
/// 该行的来源:manual=手工建立(Web 端录入 / ClassIsland JSON 导入)
- /// / classisland=插件上报同步得到。
- /// 插件每次全量上报时只清理「classisland 来源且本次未再上报」的行(解决"老师在 ClassIsland
- /// 里删掉一节、服务端却永远留着"的幽灵课),老师手排的课一律不删。
source String @default("manual")
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@ -436,120 +428,9 @@ model Grade {
@@index([courseId])
}
-/// ClassIsland 联动设备(一台装有联动插件的机器 = 某个班的 ClassHelper 班级端)
-///
-/// 说明:
-/// 1) 令牌是「设备级」的,不是用户级:插件在本机保存明文,服务端只存 sha256 哈希 + 前缀提示;
-/// 2) 同一班级下按 deviceKey(机器码)唯一:插件重装后带同一 deviceKey 会自动重绑到原记录,
-/// 避免设备列表里堆满重复项;
-/// 3) 状态字段是「最后一次上报的快照」,Web 端据此展示"某班现在上什么课";
-/// 4) `lastHeartbeatAt` 每次上报都刷新(在线判定用它),`lastSeenAt` 只在**带运行状态**的
-/// 上报时刷新 —— 这样"能连上但一直没上报状态"的设备不会被误判成完全正常。
-model IntegrationDevice {
- id String @id @default(cuid())
- classId String
- /// 设备显示名(默认取机器名)
- name String
- /// 设备唯一标识(插件上报的机器码)
- deviceKey String
- /// 联动方式:plugin=实时插件联动 / import=仅一次性导入
- mode String @default("plugin")
- /// 是否接入(关闭后插件上报与下发一律 403)
- enabled Boolean @default(true)
- /// 设备令牌的 sha256(明文只在创建/重置时返回一次)
- tokenHash String
- /// 令牌前缀(chci_xxxxxxxx),设备列表里用于人眼识别与排查
- tokenHint String
- /// 是否允许插件把 ClassIsland 的课表回传到本服务
- ///
- /// **默认关闭**(2026-10-06 起):课表以服务端为准,由服务端**自动下发**到教室;
- /// 「从教室机器取一次课表」改成 Web 端的手动动作(点击前会弹警告),
- /// 因为自动回传会在老师手排课之后被教室的旧课表悄悄覆盖。
- syncScheduleToServer Boolean @default(false)
- /// 是否允许把本班课表镜像回 ClassIsland(由插件执行)。
- ///
- /// **2026-10-11 起含义收窄为"允许下发"**:不再是"自动同步的主方向" —— 插件以前每 15 秒
- /// 上报一次、每次上报完都顺手镜像一遍(`MirrorSchedule` 开着时),教室的课表被反复重写;
- /// 现在镜像只由 `requestMirrorSchedule` 这个一次性待办触发,这里只表示"这台设备接不接受下发"。
- mirrorScheduleToClassIsland Boolean @default(true)
- /// 待办的「立即上报一次课表」请求:Web 端点「从教室机器获取课表」时置位,
- /// 插件在下一次心跳看到它就把当前课表推上来并清除标记。
- requestScheduleReport Boolean @default(false)
- /// 待办的「立即下发一次课表」请求:Web 端点「下发课表到教室」时置位,
- /// 插件在下一次心跳看到它就去拉本班课表并**强制替换**写进 ClassIsland,
- /// 拉取成功即由服务端清除标记(见 `pullClassPlanForDevice`)。
- ///
- /// 为什么要一次性待办而不是"开关":教室机常常离线,一个实时下发的长连接握手不值得,
- /// 而后台定时镜像会与老师在教室本地做的调整互相打架(2026-10-11 实测:
- /// 插件每 15 秒重写一遍课表,服务端那边看起来就是课表被反复删改)。
- requestMirrorSchedule Boolean @default(false)
- classIslandVersion String?
- pluginVersion String?
- /// 最后一次**带运行状态**的上报时间
- lastSeenAt DateTime?
- /// 最后一次心跳时间(每次上报都刷新);「在线」看它
- lastHeartbeatAt DateTime?
- /// 以下为最后一次上报的快照
- classPlanLoaded Boolean @default(false)
- currentSubject String?
- currentTimeState String?
- currentPeriodStart String?
- currentPeriodEnd String?
- nextSubject String?
- createdAt DateTime @default(now())
- updatedAt DateTime @updatedAt
-
- class Class @relation(fields: [classId], references: [id], onDelete: Cascade)
- pushes ClassIslandPush[]
-
- @@unique([classId, deviceKey])
- @@index([classId])
- @@index([tokenHash])
-}
-
-/// 下发给 ClassIsland 的提醒:教师发通知 → 学生机器全屏弹出。
-/// 落库是为了「设备离线时通知不丢」——插件重连后拉取未确认的一条立即弹出。
-model ClassIslandPush {
- id String @id @default(cuid())
- classId String
- deviceId String?
- title String
- content String
- /// 显示时长(秒):映射到 ClassIsland 的 NotificationContent.Duration
- durationSeconds Int @default(8)
- /// 语音朗读内容;null = 不朗读
- speechContent String?
- urgent Boolean @default(false)
- /// 提醒类型:notification=通知类 / call=叫人(叫人算「主动通知」,上课时段也立刻弹)
- kind String @default("notification")
- /// 发起人的用户 id(教师/管理员)
- createdBy String?
- /// 同时落库到通知中心的通知 id(saveToNotifications 时写入)
- notificationId String?
- /// 插件确认弹出的时间(确认后不再重复下发)
- ackedAt DateTime?
- createdAt DateTime @default(now())
- /// 过期时间:超过后不再补发(避免学生几天后突然弹出旧提醒)
- expiresAt DateTime
-
- class Class @relation(fields: [classId], references: [id], onDelete: Cascade)
- device IntegrationDevice? @relation(fields: [deviceId], references: [id], onDelete: SetNull)
-
- @@index([classId, createdAt])
- @@index([deviceId, ackedAt])
-}
-
-/* -------------------------------------------------------------------------- */
-/* 权限组 / 全局设置 / 客户端设备 / 远控命令 / 客户端安装包 / 大模型审计 */
-/* -------------------------------------------------------------------------- */
-
-/// 权限组:管理员自定义的一组**权限键**(`shared` 的 `PERMISSION_KEYS`),可分配给多个账号。
-///
-/// 语义(与 `packages/shared/src/permissions.ts` 的目录一一对应):
-/// - 账号**没有**任何 `UserPermissionGroup` 时,生效集合 = 角色默认模板(`ROLE_DEFAULT_PERMISSIONS`);
-/// - 一旦分了组,生效集合 = 这些组的键并集(可以比默认更少 ⇒ 也能用来**收回**权限);
-/// - `ADMIN` 永远补上 `permission:manage` / `system:manage`,避免把自己锁死。
model PermissionGroup {
+ /// 角色默认组;自定义组为空,改名不影响对应角色。
+ defaultRole String? @unique
id String @id @default(cuid())
name String @unique
description String @default("")
@@ -606,8 +487,7 @@ model AppSetting {
///
/// 身份由客户端主进程生成并持久化的 `deviceKey`(UUID)标识,心跳每 30 秒一次:
/// 服务端由此知道**每台机器在哪个班、版本多少、是否在线**,并把更新与远控
-/// 指令按「查库 + 心跳拉取」的范式下发(不新增长连接握手,照抄
-/// `IntegrationDevice.requestScheduleReport` 的做法)。
+/// 指令按「查库 + 心跳拉取」的范式下发,不新增长连接握手。
model ClientDevice {
id String @id @default(cuid())
/// 该设备当前登录的班级(未登录时为空)
@@ -624,6 +504,8 @@ model ClientDevice {
autoUpdate Boolean @default(true)
/// 服务端指定的目标版本('' = 不指定,按最新版走)
updateTargetVersion String @default("")
+ updateMode String @default("idle")
+ updateNotBefore DateTime?
/// 已下载待安装的版本('' = 无)
pendingVersion String @default("")
/// 最近一次心跳时间(**在线判定看它**)
diff --git a/packages/server/scripts/apply-column-migrations.cjs b/packages/server/scripts/apply-column-migrations.cjs
index 7786344..d8301f1 100644
--- a/packages/server/scripts/apply-column-migrations.cjs
+++ b/packages/server/scripts/apply-column-migrations.cjs
@@ -1,9 +1,8 @@
/**
* 维护脚本:给"已有数据库"补齐结构性新增列(不依赖安装程序升级)。
*
- * 为什么需要它:安装版服务端只在**库为空**时执行随包迁移 SQL(首次启动建表);
- * 用安装程序覆盖升级时,升级逻辑会自动补齐新迁移。若像本地联调那样**手工覆盖 dist**
- * 部署,就需要显式跑一次本脚本。
+ * 服务端启动时会自动应用完整迁移。本脚本仅用于排查旧库缺列,
+ * 不能替代正式迁移(例如移除联动表、创建权限组索引)。
*
* 用法(用安装目录自带的 node 即可,无需 SDK):传入**已编译的 `lib/db.js`**,
* 直接复用服务端自己配置好的 Prisma 客户端(Prisma 7 需要 driver adapter,不能裸 new):
@@ -56,12 +55,6 @@ const COLUMNS = [
sql: `ALTER TABLE "Grade" ADD COLUMN "level" TEXT NOT NULL DEFAULT ''`,
note: '成绩等级文本(percent 下由得分率换算,可手改)',
},
- {
- table: 'IntegrationDevice',
- column: 'lastHeartbeatAt',
- sql: `ALTER TABLE "IntegrationDevice" ADD COLUMN "lastHeartbeatAt" DATETIME`,
- note: 'ClassHelper 班级端最后一次心跳(在线判定看它)',
- },
{
table: 'User',
column: 'phone',
diff --git a/packages/server/scripts/build.mjs b/packages/server/scripts/build.mjs
new file mode 100644
index 0000000..101f63e
--- /dev/null
+++ b/packages/server/scripts/build.mjs
@@ -0,0 +1,20 @@
+/** 先清理编译目录,确保已移除的模块不会残留在下一次交付物中。 */
+import fs from 'node:fs';
+import path from 'node:path';
+import { fileURLToPath } from 'node:url';
+import { createRequire } from 'node:module';
+import { spawnSync } from 'node:child_process';
+
+const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
+const require = createRequire(import.meta.url);
+fs.rmSync(path.join(root, 'dist'), { recursive: true, force: true });
+const result = spawnSync(
+ process.execPath,
+ [require.resolve('typescript/bin/tsc'), '-p', 'tsconfig.build.json'],
+ {
+ cwd: root,
+ stdio: 'inherit',
+ },
+);
+if (result.error) throw result.error;
+process.exitCode = result.status ?? 1;
diff --git a/packages/server/scripts/verify-classisland.mjs b/packages/server/scripts/verify-classisland.mjs
deleted file mode 100644
index 1b5afba..0000000
--- a/packages/server/scripts/verify-classisland.mjs
+++ /dev/null
@@ -1,679 +0,0 @@
-#!/usr/bin/env node
-/**
- * ClassIsland 联动验证(插件侧接口 + 下发链路)。
- *
- * 覆盖范围:
- * 1) 设备令牌鉴权:无令牌 / 错令牌一律 401,令牌作废后立即失效
- * 2) 插件上报 → 服务端写库(课表 + 节次时间 + 状态快照)幂等
- * 3) 教师下发提醒 → 落库 → 插件 pending 拉到 → ack 后不再补发
- * 4) 停用设备后插件上报被 403 拦下
- * 5) 课表镜像拉取(mirrorScheduleToClassIsland)返回 ClassIsland 口径的数据
- *
- * 用法:
- * 1) 另开终端启动后端:pnpm dev:server
- * 2) 执行:node packages/server/scripts/verify-classisland.mjs
- *
- * 环境变量:
- * VERIFY_BASE_URL 默认 http://127.0.0.1:4000(脚本直连,不受系统代理影响)
- *
- * 注意:本脚本只操作「新建设备 + 该设备对应班级」的数据,
- * 结束时会把新建的设备删除;上报的课表会写进该班(与导入模块语义一致,可被覆盖)。
- */
-const BASE_URL = process.env.VERIFY_BASE_URL ?? `http://127.0.0.1:${process.env.PORT ?? 4000}`;
-
-const results = [];
-
-function record(name, ok, detail = '') {
- results.push({ name, ok, detail });
- console.log(`${ok ? '\u2713' : '\u2717'} ${name}${detail ? ` (${detail})` : ''}`);
-}
-
-async function api(path, { method = 'GET', token, deviceToken, body } = {}) {
- const headers = { 'Content-Type': 'application/json' };
- if (token) headers.Authorization = `Bearer ${token}`;
- if (deviceToken) headers['X-ClassIsland-Token'] = deviceToken;
- const response = await fetch(`${BASE_URL}/api${path}`, {
- method,
- headers,
- body: body === undefined ? undefined : JSON.stringify(body),
- });
- const payload = await response.json().catch(() => null);
- return { status: response.status, payload };
-}
-
-/** 造一份插件会上报的课表(周一/周二,含单双周) */
-function sampleSchedule() {
- return {
- mode: 'merge',
- entries: [
- {
- dayOfWeek: 1,
- startTime: '08:00',
- endTime: '08:45',
- subject: '语文',
- teacherName: '王老师',
- weekParity: 'ALL',
- weekCountDiv: 0,
- weekCountDivTotal: 0,
- planName: '周一课表',
- },
- {
- dayOfWeek: 1,
- startTime: '08:55',
- endTime: '09:40',
- subject: '数学',
- teacherName: '李老师',
- weekParity: 'ODD',
- weekCountDiv: 1,
- weekCountDivTotal: 2,
- planName: '周一课表',
- },
- {
- dayOfWeek: 2,
- startTime: '08:00',
- endTime: '08:45',
- subject: '英语',
- teacherName: '张老师',
- weekParity: 'ALL',
- weekCountDiv: 0,
- weekCountDivTotal: 0,
- planName: '周二课表',
- },
- ],
- };
-}
-
-function sampleTimeLayout() {
- return {
- name: 'ClassIsland 验证时间表',
- mode: 'replace',
- items: [
- { index: 1, name: '第 1 节', startTime: '08:00', endTime: '08:45', type: 'class', skipped: false },
- { index: 2, name: '课间', startTime: '08:45', endTime: '08:55', type: 'break', skipped: false },
- { index: 3, name: '第 2 节', startTime: '08:55', endTime: '09:40', type: 'class', skipped: false },
- ],
- };
-}
-
-/** 按候选密码依次登录,返回可用 token(不同环境的默认密码可能不同) */
-async function loginWithCandidates(username, passwords) {
- for (const password of passwords) {
- const result = await api('/auth/login', { method: 'POST', body: { username, password } });
- const token = result.payload?.data?.token;
- if (token) return token;
- }
- return null;
-}
-
-/**
- * 反复「拉取待提醒 → 逐条确认」,直到该设备视角下没有积压。
- *
- * 为什么验证脚本要做这件事:`GET /integrations/classisland/pending` 是
- * 「本班全部未确认且未过期的提醒,按时间升序取前 20 条」。而 `verify:desktop` /
- * `verify:web` 等冒烟会**真的往演示班发通知**(AGENTS.md §5 第 38 条),
- * 那些条如果没被确认就会一直堆着 —— 攒够 20 条之后,本脚本刚发的提醒会被挤到窗口之外,
- * 表现为「待提醒里找不到刚发的那条」,与代码对不对完全无关(2026-10-05 实测踩过:
- * 库里积了 45 条历次冒烟残条,本脚本从 42/42 掉到 35/40)。
- *
- * 这里做的事情与真实插件重连后的行为完全一致(补弹积压并逐条确认),
- * 因此不会掩盖任何产品侧问题。可见范围也一致:只处理 `deviceId` 为空的班级广播
- * 与本设备自己的推送,别的设备的目标推送取不到、也确认不了。
- */
-async function drainPending(deviceToken) {
- let total = 0;
- for (let round = 0; round < 20; round += 1) {
- const pending = await api('/integrations/classisland/pending', { deviceToken });
- const items = pending.payload?.data?.notifications ?? [];
- if (items.length === 0) break;
- for (const item of items) {
- await api('/integrations/classisland/ack', { method: 'POST', deviceToken, body: { id: item.id } });
- total += 1;
- }
- }
- return total;
-}
-
-async function main() {
- console.log(`\n=== ClassIsland 联动验证(${BASE_URL})===\n`);
-
- const health = await api('/health');
- if (health.status !== 200) {
- console.log('后端未启动,请先运行 pnpm dev:server\n');
- process.exit(1);
- }
- const modules = health.payload?.data?.modules ?? [];
- record('integrations 模块已挂载', modules.includes('integrations'), modules.join(', '));
- if (!modules.includes('integrations')) process.exit(1);
-
- // ---------------------------------------------------------------- 登录
- // 演示库里的教师密码由 .env 的 DEFAULT_TEACHER_PASSWORD 决定(种子脚本用它建号),
- // 因此这里按候选列表尝试,避免「换了环境就整脚本失败」。
- const teacherToken = await loginWithCandidates('teacher1', ['teacher123', '123456']);
- record('教师登录 teacher1', Boolean(teacherToken));
- if (!teacherToken) process.exit(1);
-
- // 管理员用于取班级码(只有管理员/班主任能看到班级码),学生隔离用例需要它
- const adminToken = await loginWithCandidates('admin', ['admin123']);
-
- const classes = await api('/classes', { token: teacherToken });
- const target = (classes.payload?.data ?? [])[0];
- record('取到可管理的班级', Boolean(target?.id), target ? `${target.name} (${target.id})` : '无');
- if (!target?.id) process.exit(1);
-
- // 叫人用例需要一位学生;班级账号用来验证"教室客户端自己选显示位置"
- const students = await api(`/classes/${target.id}/students`, { token: teacherToken });
- const firstStudent = (students.payload?.data ?? [])[0];
-
- /** 用班级码 + 班级密码登录,拿班级账号 token(教室机器的身份) */
- async function classLoginToken() {
- if (adminToken) {
- const detail = await api(`/classes/${target.id}`, { token: adminToken });
- const code = detail.payload?.data?.code;
- if (code) {
- for (const password of ['123456', 'class123']) {
- const result = await api('/auth/class-login', {
- method: 'POST',
- body: { code, password },
- });
- const token = result.payload?.data?.token;
- if (token) return token;
- }
- }
- }
- return null;
- }
-
- // ---------------------------------------------------------------- 1. 新建设备
- const created = await api('/integrations/devices', {
- method: 'POST',
- token: teacherToken,
- body: { classId: target.id, name: '验证用设备', syncScheduleToServer: true },
- });
- const deviceToken = created.payload?.data?.token;
- const deviceId = created.payload?.data?.device?.id;
- record(
- '新建设备并返回设备令牌',
- created.status === 201 && typeof deviceToken === 'string' && deviceToken.startsWith('chci_'),
- `status=${created.status} hint=${created.payload?.data?.device?.tokenHint ?? '-'}`,
- );
- if (!deviceToken || !deviceId) process.exit(1);
-
- // ---------------------------------------------------------------- 2. 令牌鉴权
- const noToken = await api('/integrations/classisland/report', { method: 'POST', body: {} });
- record('无令牌上报被拒(401)', noToken.status === 401, `status=${noToken.status}`);
- const badToken = await api('/integrations/classisland/report', {
- method: 'POST',
- deviceToken: 'chci_deadbeef',
- body: {},
- });
- record('错误令牌上报被拒(401)', badToken.status === 401, `status=${badToken.status}`);
-
- // ---------------------------------------------------------------- 3. 上报(状态 + 课表 + 节次)
- const reportBody = {
- pluginVersion: '1.1.2.0',
- classIslandVersion: '2.1.0.0',
- state: {
- inClass: true,
- subject: '语文',
- nextSubject: '数学',
- timeState: 'OnClass',
- periodStart: '08:00',
- periodEnd: '08:45',
- week: 1,
- classPlanLoaded: true,
- clientTime: new Date().toISOString(),
- },
- schedule: sampleSchedule(),
- timeLayout: sampleTimeLayout(),
- };
- const report1 = await api('/integrations/classisland/report', {
- method: 'POST',
- deviceToken,
- body: reportBody,
- });
- const r1 = report1.payload?.data;
- record(
- '插件上报课表写入成功',
- report1.status === 200 && r1?.scheduleApplied === true && r1.scheduleCreated + r1.scheduleUpdated === 3,
- `新增=${r1?.scheduleCreated} 更新=${r1?.scheduleUpdated} 周=${r1?.week}`,
- );
- record('插件上报节次时间写入成功', r1?.timeLayoutApplied === true, `applied=${r1?.timeLayoutApplied}`);
- record('上报回传服务端周次与时间', typeof r1?.serverTime === 'string' && Number.isInteger(r1?.week));
-
- // 幂等:重复上报不应产生重复行
- const report2 = await api('/integrations/classisland/report', {
- method: 'POST',
- deviceToken,
- body: reportBody,
- });
- const r2 = report2.payload?.data;
- record(
- '重复上报幂等(不新增、只更新)',
- r2?.scheduleCreated === 0 && r2?.scheduleUpdated === 3,
- `新增=${r2?.scheduleCreated} 更新=${r2?.scheduleUpdated}`,
- );
-
- // 设备列表里的状态快照
- const devices = await api(`/integrations/devices?classId=${target.id}`, { token: teacherToken });
- const listed = (devices.payload?.data ?? []).find((item) => item.id === deviceId);
- record(
- '设备状态快照已更新(Web 端可展示)',
- Boolean(listed?.lastSeenAt) &&
- listed?.currentSubject === '语文' &&
- listed?.currentTimeState === 'OnClass',
- `科目=${listed?.currentSubject} 状态=${listed?.currentTimeState} ClassIsland=${listed?.classIslandVersion}`,
- );
-
- // ---------------------------------------------------------------- 4. 下发提醒
- // 先确认掉本班的历史积压(历次冒烟留下的未确认提醒),否则 /pending 的前 20 条窗口
- // 会被它们占满,下面「插件拉取到待提醒」等用例会拿不到刚发的这条(理由见 drainPending)
- const drained = await drainPending(deviceToken);
- if (drained > 0) {
- console.log(`(已确认本班 ${drained} 条历史积压提醒,避免挤占待提醒窗口)\n`);
- }
-
- const notify = await api('/integrations/classisland/notify', {
- method: 'POST',
- token: teacherToken,
- body: {
- classId: target.id,
- title: '请到讲台领资料',
- content: '下课后来讲台领取期中复习资料',
- durationSeconds: 12,
- speech: true,
- },
- });
- const n1 = notify.payload?.data;
- record(
- '教师下发提醒(落库 + 标记设备数)',
- notify.status === 200 && n1?.targetCount >= 1 && Boolean(n1?.notificationId),
- `设备数=${n1?.targetCount} 通知id=${n1?.notificationId ? '有' : '无'}`,
- );
-
- const pending = await api('/integrations/classisland/pending', { deviceToken });
- const items = pending.payload?.data?.notifications ?? [];
- const picked = items.find((item) => item.title === '请到讲台领资料');
- record(
- '插件拉取到待提醒(离线补齐链路可用)',
- pending.status === 200 && Boolean(picked),
- `待提醒数=${items.length} 时长=${picked?.durationSeconds}s 朗读=${Boolean(picked?.speechContent)}`,
- );
-
- if (picked) {
- const ack = await api('/integrations/classisland/ack', {
- method: 'POST',
- deviceToken,
- body: { id: picked.id },
- });
- record(
- '插件确认提醒已弹出',
- ack.status === 200 && ack.payload?.data?.acked === true,
- `status=${ack.status}`,
- );
- const pending2 = await api('/integrations/classisland/pending', { deviceToken });
- const again = (pending2.payload?.data?.notifications ?? []).some((item) => item.id === picked.id);
- record('确认后不再补发(避免重复弹窗)', !again, `仍在待提醒中=${again}`);
- }
-
- // ---------------------------------------------------------------- 4.5 其他发布入口也会联动 ClassIsland
- // 需求:"在其他页面发通知也可以联动 ClassIsland"。通知发布页 / 叫人走的是各自的接口,
- // 这里逐个验证它们真的会生成待弹出提醒(而不是只有联动页那个专用接口才联动)。
- const classChannel = await api(`/classes/${target.id}/notification-channel`, { token: teacherToken });
- record(
- '班级有"通知显示位置"(默认两端都弹)',
- classChannel.status === 200 && classChannel.payload?.data?.notificationChannel === 'both',
- `channel=${classChannel.payload?.data?.notificationChannel}`,
- );
-
- const published = await api('/notifications', {
- method: 'POST',
- token: teacherToken,
- body: { classId: target.id, title: '通知页联动验证', content: '从通知发布页发的通知', priority: 'NORMAL' },
- });
- const pendingAfterPublish = await api('/integrations/classisland/pending', { deviceToken });
- const publishPush = (pendingAfterPublish.payload?.data?.notifications ?? []).find(
- (item) => item.title === '通知页联动验证',
- );
- record(
- '通知发布页发的通知也会推送到 ClassIsland',
- published.status === 201 && Boolean(publishPush),
- `status=${published.status} 待提醒=${(pendingAfterPublish.payload?.data?.notifications ?? []).length}`,
- );
- // kind 决定插件"上课时段弹不弹":通知类会被暂存,叫人属于主动通知要立刻弹
- record(
- '通知类推送的 kind=notification(上课时段插件会暂存)',
- publishPush?.kind === 'notification',
- `kind=${publishPush?.kind}`,
- );
- if (publishPush) {
- await api('/integrations/classisland/ack', {
- method: 'POST',
- deviceToken,
- body: { id: publishPush.id },
- });
- }
-
- const call = await api('/calls', {
- method: 'POST',
- token: teacherToken,
- body: { classId: target.id, studentId: firstStudent?.id, message: '叫人联动验证' },
- });
- const pendingAfterCall = await api('/integrations/classisland/pending', { deviceToken });
- const callPush = (pendingAfterCall.payload?.data?.notifications ?? []).find(
- (item) => item.content === '叫人联动验证',
- );
- record(
- '叫人也会推送到 ClassIsland',
- Boolean(firstStudent) && call.status === 201 && Boolean(callPush),
- firstStudent ? `status=${call.status}` : '班级里没有学生,跳过',
- );
- record(
- '叫人推送的 kind=call(算主动通知,上课时段也立刻弹)',
- callPush?.kind === 'call',
- `kind=${callPush?.kind}`,
- );
- if (callPush) {
- await api('/integrations/classisland/ack', {
- method: 'POST',
- deviceToken,
- body: { id: callPush.id },
- });
- }
-
- // 教室在客户端里选"只在 ClassHelper 客户端显示"后,任何入口都不该再打扰 ClassIsland
- const setClient = await api(`/classes/${target.id}/notification-channel`, {
- method: 'PATCH',
- token: teacherToken,
- body: { notificationChannel: 'client' },
- });
- record(
- '可把"通知显示位置"改成只在客户端显示',
- setClient.status === 200 && setClient.payload?.data?.notificationChannel === 'client',
- `channel=${setClient.payload?.data?.notificationChannel}`,
- );
-
- const mutedPublish = await api('/notifications', {
- method: 'POST',
- token: teacherToken,
- body: { classId: target.id, title: '不该上 ClassIsland 的通知', content: '教室选择了只在客户端显示' },
- });
- const mutedNotify = await api('/integrations/classisland/notify', {
- method: 'POST',
- token: teacherToken,
- body: { classId: target.id, title: '联动页也不该下发', content: '同样被教室的设置拦下' },
- });
- const pendingMuted = await api('/integrations/classisland/pending', { deviceToken });
- const mutedItems = pendingMuted.payload?.data?.notifications ?? [];
- record(
- '显示位置=client 时通知不再推送 ClassIsland',
- mutedPublish.status === 201 && !mutedItems.some((item) => item.title === '不该上 ClassIsland 的通知'),
- `待提醒=${mutedItems.length}`,
- );
- record(
- '显示位置=client 时联动页的下发也会被跳过(并说明原因)',
- mutedNotify.status === 200 &&
- mutedNotify.payload?.data?.skipped === 'channel-client' &&
- String(mutedNotify.payload?.message ?? '').includes('只在 ClassHelper 客户端显示'),
- `skipped=${mutedNotify.payload?.data?.skipped} message=${mutedNotify.payload?.message}`,
- );
-
- // 班级账号(教室机器)自己就能读写这项设置 —— 需求就是"由 ClassIsland 之外的客户端自己选"
- const classSessionToken = await classLoginToken();
- const readByClass = await api(`/classes/${target.id}/notification-channel`, {
- token: classSessionToken,
- });
- record(
- '班级账号可读取通知显示位置',
- readByClass.status === 200 && readByClass.payload?.data?.notificationChannel === 'client',
- `channel=${readByClass.payload?.data?.notificationChannel}`,
- );
- const setByClass = await api(`/classes/${target.id}/notification-channel`, {
- method: 'PATCH',
- token: classSessionToken,
- body: { notificationChannel: 'both' },
- });
- record(
- '班级账号可修改通知显示位置(由教室客户端自己选)',
- setByClass.status === 200 && setByClass.payload?.data?.notificationChannel === 'both',
- `channel=${setByClass.payload?.data?.notificationChannel}`,
- );
- const badChannel = await api(`/classes/${target.id}/notification-channel`, {
- method: 'PATCH',
- token: teacherToken,
- body: { notificationChannel: 'nope' },
- });
- record('非法显示位置被拒(422)', badChannel.status === 422, `status=${badChannel.status}`);
-
- // ---------------------------------------------------------------- 5. 课表镜像
- //
- // 同步方向在 2026-10-06 反转:**服务端 → 教室自动下发**成为主方向(默认开启),
- // 「把教室课表传上来」改成 Web 端的一次性人工动作。因此这里先验证"默认就下发",
- // 再验证"关掉之后确实不下发"。
- const mirrorDefault = await api('/integrations/classisland/class-plan', { deviceToken });
- record(
- '课表默认由服务端自动下发(新方向:服务端 → 教室)',
- mirrorDefault.status === 200 && mirrorDefault.payload?.data?.classPlan?.entries?.length >= 0,
- `status=${mirrorDefault.status} data=${mirrorDefault.payload?.data === null ? 'null' : 'object'}`,
- );
-
- const disabled = await api(`/integrations/devices/${deviceId}`, {
- method: 'PATCH',
- token: teacherToken,
- body: { mirrorScheduleToClassIsland: false },
- });
- record(
- '关闭「镜像课表到 ClassIsland」',
- disabled.status === 200 && disabled.payload?.data?.mirrorScheduleToClassIsland === false,
- `status=${disabled.status}`,
- );
-
- const mirrorOff = await api('/integrations/classisland/class-plan', { deviceToken });
- record(
- '关掉镜像后不再下发课表',
- mirrorOff.status === 200 &&
- (mirrorOff.payload?.data === null || mirrorOff.payload?.data === undefined),
- `data=${mirrorOff.payload?.data === null ? 'null' : 'object'}`,
- );
-
- const enabled = await api(`/integrations/devices/${deviceId}`, {
- method: 'PATCH',
- token: teacherToken,
- body: { mirrorScheduleToClassIsland: true },
- });
- record(
- '重新打开「镜像课表到 ClassIsland」',
- enabled.status === 200 && enabled.payload?.data?.mirrorScheduleToClassIsland === true,
- `status=${enabled.status}`,
- );
-
- const mirror = await api('/integrations/classisland/class-plan', { deviceToken });
- const plan = mirror.payload?.data?.classPlan;
- const weekDays = (plan?.entries ?? []).map((item) => item.weekDay);
- record(
- '拉取课表为 ClassIsland 口径(0=周日 … 6=周六)',
- mirror.status === 200 &&
- Array.isArray(plan?.entries) &&
- plan.entries.length >= 3 &&
- weekDays.every((day) => day >= 0 && day <= 6),
- `条目=${plan?.entries?.length} weekDay=[${weekDays.join(',')}] 档案名=${plan?.profileName}`,
- );
- record(
- '单双周换算为 WeekCountDiv/Total',
- (plan?.entries ?? []).some((item) => item.weekCountDivTotal === 2),
- (plan?.entries ?? [])
- .map((item) => `${item.subject}:${item.weekCountDiv}/${item.weekCountDivTotal}`)
- .join(' '),
- );
- // ClassInfo.Index 与"第 i 个上课时间点"一一对应,因此镜像条目必须带起止时间,
- // 否则插件只能按数组顺序硬排,一旦某天少一节整份课表就错位(这是最容易漏的一处契约)
- const timeOk = (plan?.entries ?? []).every(
- (item) => /^\d{2}:\d{2}$/.test(item.startTime ?? '') && /^\d{2}:\d{2}$/.test(item.endTime ?? ''),
- );
- record(
- '镜像条目带起止时间(插件据此重建上课时间点)',
- (plan?.entries ?? []).length > 0 && timeOk,
- (plan?.entries ?? [])
- .slice(0, 3)
- .map((item) => `${item.subject} ${item.startTime}-${item.endTime}`)
- .join(' | '),
- );
- const classPoints = (plan?.timeLayouts?.[0]?.layouts ?? []).filter((item) => item.timeType === 0);
- record(
- '镜像时间表含上课时间点(与课表条目对得上)',
- classPoints.length > 0 && (plan?.entries ?? []).length >= classPoints.length / 2,
- `上课点=${classPoints.length} 条目=${(plan?.entries ?? []).length}`,
- );
-
- /* ---------------------------------------------------------------- 5.1 课表同步:幽灵行清理 + 机器码回填 */
- // 老师在 Web 端手排的一节课(source=manual):插件同步时**绝不能**删它
- const courseList = await api(`/courses?classId=${target.id}`, { token: teacherToken });
- const courseId = (courseList.payload?.data ?? [])[0]?.id;
- const manualCreated = await api('/schedules', {
- method: 'POST',
- token: teacherToken,
- body: {
- classId: target.id,
- courseId,
- dayOfWeek: 7,
- startTime: '21:00',
- endTime: '21:45',
- weekStart: 1,
- weekEnd: 20,
- weekParity: 'ALL',
- },
- });
- const manualScheduleId = manualCreated.payload?.data?.id;
- record(
- '(准备)Web 端手排一节课(source=manual)',
- manualCreated.status === 201 && Boolean(manualScheduleId),
- `status=${manualCreated.status}`,
- );
-
- const entry = (startTime, endTime) => ({
- dayOfWeek: 6,
- startTime,
- endTime,
- subject: '语文',
- weekParity: 'ALL',
- });
-
- /** 该班课表里"周六 19:xx"的行(插件上报写入的那些) */
- const listSaturday = async () => {
- const list = await api(`/schedules?classId=${target.id}`, { token: teacherToken });
- return (list.payload?.data ?? []).filter(
- (item) => item.dayOfWeek === 6 && item.startTime.startsWith('19:'),
- );
- };
-
- // 第一次上报:两节(第二节随后会被"老师在 ClassIsland 里删掉")
- const ghostReport = await api('/integrations/classisland/report', {
- method: 'POST',
- deviceToken,
- body: {
- deviceKey: 'smoke-machine-01',
- schedule: { mode: 'merge', entries: [entry('19:00', '19:45'), entry('19:50', '20:35')] },
- },
- });
- record(
- '插件上报两节课(其中一节随后会被删除)',
- ghostReport.payload?.data?.scheduleApplied === true,
- `status=${ghostReport.status}`,
- );
-
- const beforePrune = await listSaturday();
- record('上报后两节都在库里', beforePrune.length === 2, `周六 19 点=${beforePrune.length} 节`);
-
- // 第二次上报:只报第二节 → 第一节应当被清理。
- // 原先 merge 只 upsert、从不删除,老师删掉的课会永远留在服务端(幽灵课),
- // ClassHelper 班级端与 Web 端会一直显示一节实际上已经不上的课。
- const pruneReport = await api('/integrations/classisland/report', {
- method: 'POST',
- deviceToken,
- body: {
- deviceKey: 'smoke-machine-01',
- schedule: { mode: 'merge', entries: [entry('19:50', '20:35')] },
- },
- });
- const afterPrune = await listSaturday();
- record(
- '插件侧删除的课被同步清理(不再留幽灵课)',
- pruneReport.payload?.data?.scheduleApplied === true &&
- afterPrune.length === 1 &&
- afterPrune[0]?.startTime === '19:50',
- `周六 19 点=${afterPrune.map((item) => item.startTime).join(',') || '空'}`,
- );
-
- // 只清理"插件同步出来的行":老师手排的课必须活着
- const scheduleList = await api(`/schedules?classId=${target.id}`, { token: teacherToken });
- const manualAlive = (scheduleList.payload?.data ?? []).some((item) => item.id === manualScheduleId);
- record('Web 端手排的课不被插件同步删除', manualAlive, `手排行 id=${manualScheduleId ?? '-'}`);
-
- // 机器码回填:设备列表里不再是 pending-…
- const deviceList = await api(`/integrations/devices?classId=${target.id}`, { token: teacherToken });
- const backfilledKey =
- (deviceList.payload?.data ?? []).find((item) => item.id === deviceId)?.deviceKey ?? '';
- record(
- '插件上报的机器码已回填到设备记录',
- backfilledKey === 'smoke-machine-01',
- `deviceKey=${backfilledKey}`,
- );
-
- // 清理:把手排的课与本次上报写进去的课都删掉,避免污染演示数据
- if (manualScheduleId) {
- await api(`/schedules/${manualScheduleId}`, { method: 'DELETE', token: teacherToken });
- }
- for (const row of await listSaturday()) {
- await api(`/schedules/${row.id}`, { method: 'DELETE', token: teacherToken });
- }
- record('清理:本次验证写入的课表与手排课已删除', (await listSaturday()).length === 0, '周六 19 点已清空');
-
- // ---------------------------------------------------------------- 6. 停用设备
- await api(`/integrations/devices/${deviceId}`, {
- method: 'PATCH',
- token: teacherToken,
- body: { enabled: false },
- });
- const blocked = await api('/integrations/classisland/report', {
- method: 'POST',
- deviceToken,
- body: { state: { inClass: false, clientTime: new Date().toISOString() } },
- });
- record('停用设备后插件上报被拒(403)', blocked.status === 403, `status=${blocked.status}`);
-
- // ---------------------------------------------------------------- 7. 权限隔离
- // 班级码只有管理员/班主任可见,班级密码也是默认值,因此这里按管理员信息补齐
- let classCode = target.code ?? null;
- if (!classCode && adminToken) {
- const all = await api('/classes', { token: adminToken });
- classCode = (all.payload?.data ?? []).find((item) => item.id === target.id)?.code ?? null;
- }
- const studentLogin = await api('/auth/class-login', {
- method: 'POST',
- body: { code: classCode ?? '', password: '123456' },
- });
- const studentToken = studentLogin.payload?.data?.token;
- if (studentToken) {
- const forbidden = await api('/integrations/devices', { token: studentToken });
- record('学生无法查看联动设备列表(403)', forbidden.status === 403, `status=${forbidden.status}`);
- } else {
- record('班级账号登录(跳过学生隔离用例)', false, `status=${studentLogin.status}`);
- }
-
- // ---------------------------------------------------------------- 清理
- const removed = await api(`/integrations/devices/${deviceId}`, { method: 'DELETE', token: teacherToken });
- record('删除验证设备(清理)', removed.status === 200, `status=${removed.status}`);
-
- const failed = results.filter((item) => !item.ok);
- console.log(`\n=== 结果:${results.length - failed.length}/${results.length} 通过 ===`);
- if (failed.length > 0) {
- console.log('失败用例:');
- for (const item of failed) console.log(` - ${item.name} (${item.detail})`);
- process.exit(1);
- }
- console.log('ClassIsland 联动链路全部通过。\n');
-}
-
-main().catch((error) => {
- console.error('验证脚本异常:', error);
- process.exit(1);
-});
diff --git a/packages/server/scripts/verify-e2e.mjs b/packages/server/scripts/verify-e2e.mjs
index 9e58da7..58ff209 100644
--- a/packages/server/scripts/verify-e2e.mjs
+++ b/packages/server/scripts/verify-e2e.mjs
@@ -476,7 +476,11 @@ async function main() {
try {
const { payload, elapsed } = await notificationWait;
- record('ClassHelper 班级端 5 秒内收到 notification:new', elapsed < TIME_LIMIT_MS, `${elapsed}ms · ${payload?.title}`);
+ record(
+ 'ClassHelper 班级端 5 秒内收到 notification:new',
+ elapsed < TIME_LIMIT_MS,
+ `${elapsed}ms · ${payload?.title}`,
+ );
} catch (error) {
record('ClassHelper 班级端 5 秒内收到 notification:new', false, error.message);
}
@@ -534,7 +538,11 @@ async function main() {
try {
const { payload, elapsed } = await homeworkWait;
- record('ClassHelper 班级端 5 秒内收到 homework:new', elapsed < TIME_LIMIT_MS, `${elapsed}ms · ${payload?.title}`);
+ record(
+ 'ClassHelper 班级端 5 秒内收到 homework:new',
+ elapsed < TIME_LIMIT_MS,
+ `${elapsed}ms · ${payload?.title}`,
+ );
} catch (error) {
record('ClassHelper 班级端 5 秒内收到 homework:new', false, error.message);
}
@@ -705,8 +713,8 @@ async function main() {
'管理员设置某班某科的任课老师(PUT /subject-teachers)',
adminAssignSubject.status === 200 &&
(adminAssignSubject.payload?.data ?? []).some(
- (item) => item.subjectName === probeSubject &&
- item.teacherId === teacher2Login.payload?.data?.user?.id,
+ (item) =>
+ item.subjectName === probeSubject && item.teacherId === teacher2Login.payload?.data?.user?.id,
),
`status=${adminAssignSubject.status}`,
);
@@ -1145,11 +1153,7 @@ async function main() {
token: adminToken,
body: { assignments: [{ subjectName: '音乐', teacherId: teacher2Login.payload?.data?.user?.id }] },
});
- record(
- '管理员分配任课老师成功(200)',
- adminAssign.status === 200,
- `status=${adminAssign.status}`,
- );
+ record('管理员分配任课老师成功(200)', adminAssign.status === 200, `status=${adminAssign.status}`);
// 解除 = 把该科还回班主任(Course.teacherId 是必填列,不能留空)
const adminUnassign = await api(`/classes/${classId}/subject-teachers`, {
method: 'PUT',
@@ -1183,8 +1187,9 @@ async function main() {
// 新规则:**作业按科目授权**。
// 科任老师只能布置自己任教科目(Course.teacherId)的作业;
// 不指定科目的作业只有班主任与管理员能发 —— 两条都要验到。
- const subjectCourse = (await api(`/courses?classId=${subjectClassId}`, { token: adminToken }))
- .payload?.data?.find((item) => item.teacherId === teacher2Id);
+ const subjectCourse = (
+ await api(`/courses?classId=${subjectClassId}`, { token: adminToken })
+ ).payload?.data?.find((item) => item.teacherId === teacher2Id);
const subjectHomework = await api('/homeworks', {
method: 'POST',
token: subjectClassId ? teacher2Token : teacherToken,
@@ -1215,8 +1220,9 @@ async function main() {
);
// 科任老师改**别人任教科目**的作业同样被拒
if (subjectCourse) {
- const otherCourse = (await api(`/courses?classId=${subjectClassId}`, { token: adminToken }))
- .payload?.data?.find((item) => item.teacherId !== teacher2Id);
+ const otherCourse = (
+ await api(`/courses?classId=${subjectClassId}`, { token: adminToken })
+ ).payload?.data?.find((item) => item.teacherId !== teacher2Id);
// 先确认"别人任教的科目"这个前提存在:缺了它,下面那条用例会因为"没带科目"
// 而被拒(403),看着是绿的,其实没验到"非任教科目"这条规则。
record(
@@ -1355,7 +1361,9 @@ async function main() {
);
}
- const badDay = await api(`/notifications/days?classId=${classId}&from=2026-13-45`, { token: teacherToken });
+ const badDay = await api(`/notifications/days?classId=${classId}&from=2026-13-45`, {
+ token: teacherToken,
+ });
record('通知归档接口拒绝非法日期(422)', badDay.status === 422, `status=${badDay.status}`);
// 批量发布通知:一次提交多个班,返回逐班结果
@@ -1545,25 +1553,32 @@ async function main() {
`含 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('权限组'),
+ '管理员可以分配权限组(PUT /users/:id/groups → 200)',
+ Boolean(adminMember) && assignAdmin.status === 200 && assignAdmin.payload?.data?.fixed === false,
`status=${assignAdmin.status} 响应=${assignAdmin.payload?.message ?? ''}`,
);
+ await api(`/permissions/users/${adminMember?.id}/groups`, {
+ method: 'PUT',
+ token: adminToken,
+ body: { groupIds: [] },
+ });
+
// 内置权限组不可删除:先找一个 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 });
+ const deleteBuiltin = await api(`/permissions/groups/${builtinGroup.id}`, {
+ method: 'DELETE',
+ token: adminToken,
+ });
record(
'内置权限组不可删除(DELETE /permissions/groups/:id → 400)',
deleteBuiltin.status === 400,
@@ -1607,7 +1622,11 @@ async function main() {
// 心跳不强求权限键(教室端会话权限集为空),但**必须登录**:没有 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 heartbeat = await api('/clients/heartbeat', {
+ method: 'POST',
+ token: adminToken,
+ body: heartbeatBase,
+ });
const heartbeatData = heartbeat.payload?.data;
record(
'客户端心跳 POST /clients/heartbeat(未登录 401 / 登录后 200 且返回 deviceId 与 command 字段)',
@@ -1644,7 +1663,11 @@ async function main() {
const command = await api('/clients/commands', {
method: 'POST',
token: adminToken,
- body: { deviceIds: [device?.id ?? heartbeatData?.deviceId ?? 'none'], kind: 'lock', note: 'E2E 远控用例' },
+ body: {
+ deviceIds: [device?.id ?? heartbeatData?.deviceId ?? 'none'],
+ kind: 'lock',
+ note: 'E2E 远控用例',
+ },
});
const heartbeatWithCommand = await api('/clients/heartbeat', {
method: 'POST',
@@ -1677,7 +1700,8 @@ async function main() {
);
const devicesAfterAck = await api('/clients/devices', { token: adminToken });
- const ackedDevice = (devicesAfterAck.payload?.data ?? []).find((item) => item.deviceKey === deviceKey) ?? null;
+ const ackedDevice =
+ (devicesAfterAck.payload?.data ?? []).find((item) => item.deviceKey === deviceKey) ?? null;
record(
'回执后设备不再有待执行命令(pendingCommand 为空)',
Boolean(ackedDevice) && ackedDevice.pendingCommand === null,
@@ -1803,7 +1827,9 @@ async function main() {
});
record(
'开启大模型助手并固定为离线 mock(PUT /ai/settings)',
- enableAi.status === 200 && enableAi.payload?.data?.enabled === true && enableAi.payload?.data?.provider === 'mock',
+ 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}`,
);
@@ -1916,8 +1942,7 @@ async function main() {
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),
+ diagnostics.status === 200 && !/apiKey|password|token|secret/i.test(diagnosticsText),
`status=${diagnostics.status} 字段=${Object.keys(diagnostics.payload?.data ?? {}).join(',')}`,
);
}
@@ -2930,8 +2955,8 @@ async function main() {
body: { username: 'smoke_denied_teacher', name: '越权教师', password: 'smoke123456' },
});
record(
- '仅管理员可录入教师(教师读取/新建均 403)',
- teacherSeesTeachers.status === 403 && teacherCreatesTeacher.status === 403,
+ '教师名单只读与管理分离(默认教师可读取,但不能新建)',
+ teacherSeesTeachers.status === 200 && teacherCreatesTeacher.status === 403,
`列表=${teacherSeesTeachers.status} 新建=${teacherCreatesTeacher.status} message=${teacherCreatesTeacher.payload?.message ?? ''}`,
);
@@ -3187,8 +3212,6 @@ async function main() {
'Notification',
'NotificationRead',
'Grade',
- 'IntegrationDevice',
- 'ClassIslandPush',
'PermissionGroup',
'PermissionGroupItem',
'UserPermissionGroup',
@@ -3511,13 +3534,12 @@ async function main() {
// force 只对管理员生效:教师带 force=1 也必须命中缓存(否则任意登录用户都能刷爆匿名限流)
const updateTeacherForced = await api('/update/check?force=1', { token: teacherToken });
record(
- '更新检查:教师可查(公开信息),但 force 仅管理员生效',
+ '更新检查:默认教师可查,强制刷新需要 update:force',
updateTeacherForced.status === 200 &&
updateTeacherForced.payload?.data?.checkedAt === updateData.checkedAt,
`status=${updateTeacherForced.status} 命中缓存=${updateTeacherForced.payload?.data?.checkedAt === updateData.checkedAt}`,
);
-
/* ---------------------------------------------------------------- 13. 学期周次(仅管理员可写) */
// 需求:「第几周的周次数据可以由 admin 修改,并对所有 ClassIsland 下发修改」。
// 线性推算遇到调休/补课会整体错位,所以支持逐周指定日期区间。
@@ -3624,62 +3646,13 @@ async function main() {
`configured=${termRestored.payload?.data?.configured}`,
);
- /* ---------------------------------------------------------------- 14. 课表同步方向反转 */
-
- // 自建一台临时设备(收尾删掉):不依赖演示库里有没有接入过设备
- const tempDevice = await api('/integrations/devices', {
- method: 'POST',
- token: adminToken,
- body: { classId, name: 'E2E 课表方向校验设备' },
- });
- const tempDeviceId = tempDevice.payload?.data?.device?.id;
+ const retiredIntegration = await api('/integrations/devices', { token: adminToken });
record(
- '课表方向:新建设备默认「服务端自动下发、不自动回传」',
- tempDevice.status === 201 &&
- tempDevice.payload?.data?.device?.syncScheduleToServer === false &&
- tempDevice.payload?.data?.device?.mirrorScheduleToClassIsland === true,
- `status=${tempDevice.status} syncScheduleToServer=${tempDevice.payload?.data?.device?.syncScheduleToServer} mirrorScheduleToClassIsland=${tempDevice.payload?.data?.device?.mirrorScheduleToClassIsland}`,
+ '退役联动接口不再提供服务',
+ retiredIntegration.status === 404,
+ `status=${retiredIntegration.status}`,
);
- if (tempDeviceId) {
- const pullRequest = await api(`/integrations/devices/${tempDeviceId}/request-schedule`, {
- method: 'POST',
- token: adminToken,
- });
- record(
- '从教室机器获取课表:管理员可发起(POST /request-schedule)',
- pullRequest.status === 200 && pullRequest.payload?.data?.requested === true,
- `status=${pullRequest.status} ${pullRequest.payload?.message ?? ''}`,
- );
-
- // 置位后再读设备列表应当看到待办(插件下一次心跳看到就会立刻把课表推上来)
- const afterRequest = await api(`/integrations/devices?classId=${classId}`, { token: adminToken });
- const requestedDevice = (afterRequest.payload?.data ?? []).find((item) => item.id === tempDeviceId);
- record(
- '从教室机器获取课表:待办标记已落库(插件下次心跳会照做)',
- requestedDevice?.requestScheduleReport === true,
- `requestScheduleReport=${requestedDevice?.requestScheduleReport}`,
- );
-
- // 教师对**别人班**的设备不能发起
- const deniedPull = await api(`/integrations/devices/${tempDeviceId}/request-schedule`, {
- method: 'POST',
- token: teacher2Token,
- });
- record(
- '从教室机器获取课表:非本班教师被拒绝(403)',
- deniedPull.status === 403,
- `status=${deniedPull.status}`,
- );
-
- // 收尾:删掉临时设备
- const removedDevice = await api(`/integrations/devices/${tempDeviceId}`, {
- method: 'DELETE',
- token: adminToken,
- });
- record('课表方向用例收尾:临时设备已删除', removedDevice.status === 200, `status=${removedDevice.status}`);
- }
-
socket.close();
// ---------------------------------------------------------------- 汇总
diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts
index 67b54a8..4f9494c 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 { backfillNotificationDates } from './lib/backfill-notification-dates.js';
import { refreshDeveloperMode } from './lib/settings.js';
import { resolveWebDistDir } from './lib/web-static.js';
import { initRealtime } from './realtime/socket.js';
@@ -51,6 +52,8 @@ async function prepareDatabase(): Promise {
}
await prepareDatabase();
+const backfilledDates = await backfillNotificationDates();
+if (backfilledDates) logger.info(`已补齐 ${backfilledDates} 条历史通知的所属日期`);
// 开发者模式的内存缓存:错误中间件每请求都要问一次,不可能为此查库(见 lib/settings.ts)
await refreshDeveloperMode();
diff --git a/packages/server/src/lib/backfill-notification-dates.ts b/packages/server/src/lib/backfill-notification-dates.ts
new file mode 100644
index 0000000..2bc8592
--- /dev/null
+++ b/packages/server/src/lib/backfill-notification-dates.ts
@@ -0,0 +1,24 @@
+import { dayKeyLocal } from '@classhelper/shared';
+import { prisma } from './db.js';
+
+/** 旧叫人通知没有所属日期;用服务进程的本地日期口径幂等回填,兼容 SQLite 与 MySQL。 */
+export async function backfillNotificationDates(): Promise {
+ let updated = 0;
+ for (;;) {
+ const rows = await prisma.notification.findMany({
+ where: { assignDate: '' },
+ select: { id: true, createdAt: true },
+ take: 200,
+ });
+ if (!rows.length) return updated;
+ await prisma.$transaction(
+ rows.map((row) =>
+ prisma.notification.update({
+ where: { id: row.id },
+ data: { assignDate: dayKeyLocal(row.createdAt) },
+ }),
+ ),
+ );
+ updated += rows.length;
+ }
+}
diff --git a/packages/server/src/lib/db-bootstrap.ts b/packages/server/src/lib/db-bootstrap.ts
index da36998..534429d 100644
--- a/packages/server/src/lib/db-bootstrap.ts
+++ b/packages/server/src/lib/db-bootstrap.ts
@@ -90,6 +90,23 @@ async function loadAppliedMigrations(client: {
`CREATE TABLE IF NOT EXISTS "${MIGRATION_LEDGER}" ("name" TEXT NOT NULL PRIMARY KEY, "appliedAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP)`,
);
+ // 开发库/源码部署由 Prisma 管理迁移;覆盖安装时接纳其已完成记录,
+ // 不能重跑已被后续迁移改过的旧 SQL(例如已移除的 User.classId 索引)。
+ const prismaLedger = await prisma.$queryRawUnsafe>(
+ `SELECT name FROM sqlite_master WHERE type = 'table' AND name = '_prisma_migrations'`,
+ );
+ if (prismaLedger.length > 0) {
+ const completed = await prisma.$queryRawUnsafe>(
+ `SELECT migration_name FROM "_prisma_migrations" WHERE finished_at IS NOT NULL AND rolled_back_at IS NULL`,
+ );
+ for (const row of completed) {
+ await client.execute({
+ sql: `INSERT OR IGNORE INTO "${MIGRATION_LEDGER}" ("name") VALUES (?)`,
+ args: [row.migration_name],
+ } as never);
+ }
+ }
+
const rows = await prisma.$queryRawUnsafe>(
`SELECT name FROM "${MIGRATION_LEDGER}"`,
);
diff --git a/packages/server/src/lib/http.ts b/packages/server/src/lib/http.ts
index 84767a1..aec7d9e 100644
--- a/packages/server/src/lib/http.ts
+++ b/packages/server/src/lib/http.ts
@@ -2,8 +2,14 @@ import type { Response } from 'express';
import type { ApiResponse } from '@classhelper/shared';
/** 成功响应:{ success: true, data, message } */
-export function sendOk(res: Response, data: T, message = 'ok', status = 200): void {
- const body: ApiResponse = { success: true, data, message };
+export function sendOk(
+ res: Response,
+ data: T,
+ message = 'ok',
+ status = 200,
+ legacyFields: object = {},
+): void {
+ const body: ApiResponse = { ...legacyFields, success: true, data, message };
res.status(status).json(body);
}
diff --git a/packages/server/src/lib/mappers.ts b/packages/server/src/lib/mappers.ts
index 282b753..c2a6a2e 100644
--- a/packages/server/src/lib/mappers.ts
+++ b/packages/server/src/lib/mappers.ts
@@ -3,7 +3,6 @@ import type {
ArchivedClassDto,
ArchivedYearDto,
ClassDto,
- ClassIslandNotificationChannel,
ClassTeacherBrief,
CourseBrief,
CourseDto,
@@ -191,8 +190,6 @@ export interface ClassLike {
teacherId: string;
/** 本学期教学周数(班主任可调,默认 20) */
termWeeks?: number;
- /** 通知显示位置:both / client / classisland(由 ClassHelper 班级端设置) */
- notificationChannel?: string | null;
/** 班级端按学号查成绩的开关 */
studentGradeQueryEnabled?: boolean | null;
/** 毕业归档所属届别;null = 在读 */
@@ -209,11 +206,6 @@ export interface ClassLike {
};
}
-/** 通知显示位置:库里存字符串,出参收敛成联合类型(非法值一律按默认 both 处理) */
-export function toNotificationChannel(value: string | null | undefined): ClassIslandNotificationChannel {
- return value === 'client' || value === 'classisland' ? value : 'both';
-}
-
export function toClassDto(item: ClassLike): ClassDto {
return {
id: item.id,
@@ -223,7 +215,6 @@ export function toClassDto(item: ClassLike): ClassDto {
classIndex: item.classIndex ?? null,
teacherId: item.teacherId,
termWeeks: item.termWeeks ?? 20,
- notificationChannel: toNotificationChannel(item.notificationChannel),
// 默认开启:老库补列时的默认值也是 true,两边保持一致
studentGradeQueryEnabled: item.studentGradeQueryEnabled ?? true,
archivedYearId: item.archivedYearId ?? null,
diff --git a/packages/server/src/lib/permissions.ts b/packages/server/src/lib/permissions.ts
index 2c141af..4853a53 100644
--- a/packages/server/src/lib/permissions.ts
+++ b/packages/server/src/lib/permissions.ts
@@ -12,13 +12,13 @@ import type { TokenPayload } from './jwt.js';
* 生效权限集合的**唯一加载入口**(权限组功能的全部运行时逻辑都收在这里)。
*
* 语义(与 shared 的 `PERMISSION_DEFAULT_ROLES` 一一对应):
- * - `ADMIN`:全集。另外**永远**保留 {@link ADMIN_UNREMOVABLE_PERMISSIONS},避免管理员
+ * - `ADMIN`:按权限组计算,另外**永远**保留 {@link ADMIN_UNREMOVABLE_PERMISSIONS},避免管理员
* 把自己(或最后一个管理员)锁在权限页之外;
* - `CLASS_DEVICE`(ClassHelper 班级端):**空集,且不受权限组影响** —— 班级端的会话主体是
* 班级而非账号,它的能力由班级维度断言(`canManageSubjectContent(..., { allowClassDevice })`
* 等)决定,给某个班级端"分配权限组"没有意义;
* - 其他账号:**有分组**时取这些组的键并集(因此可以比默认更少 ⇒ 也能用来收回权限);
- * **没有任何分组**时回落角色默认模板(逐条镜像权限组上线前各路由的 `requireRole`)。
+ * **没有任何分组**时使用数据库中可编辑的角色默认组(逐条镜像权限组上线前各路由的 `requireRole`)。
*
* 每次请求实时查询(不缓存):权限改动**立即生效**,代价是每请求多一条 `findMany`
* (账号规模很小,这个代价换"改了立刻生效"是值的)。
@@ -28,10 +28,7 @@ export async function loadEffectivePermissions(user: TokenPayload): Promise item.key) : defaultPermissionsForRole(user.role)) {
+ if ((PERMISSION_KEYS as readonly string[]).includes(key)) permissions.add(key as PermissionKey);
+ }
+ if (user.role === 'ADMIN') for (const key of ADMIN_UNREMOVABLE_PERMISSIONS) permissions.add(key);
return permissions;
}
@@ -50,17 +54,48 @@ export async function loadEffectivePermissions(user: TokenPayload): Promise | null = null;
+export function ensureDefaultGroups(): Promise {
+ if (!defaultGroupsReady)
+ defaultGroupsReady = createDefaultGroups().finally(() => {
+ defaultGroupsReady = null;
+ });
+ return defaultGroupsReady;
+}
+async function createDefaultGroups(): Promise {
+ for (const role of ['ADMIN', 'TEACHER'] as const) {
+ if (await prisma.permissionGroup.findUnique({ where: { defaultRole: role }, select: { id: true } }))
+ continue;
+ const baseName = `${role === 'ADMIN' ? '管理员' : '教师'}默认权限`;
+ let name = baseName;
+ let suffix = 1;
+ // 旧库可能已有同名自定义组,保留它并为角色默认组选一个不冲突的名称。
+ while (await prisma.permissionGroup.findUnique({ where: { name }, select: { id: true } })) {
+ name = `${baseName}(系统 ${suffix++})`;
+ }
+ await prisma.permissionGroup.upsert({
+ where: { defaultRole: role },
+ update: {},
+ create: {
+ id: `default-permission-${role.toLowerCase()}`,
+ defaultRole: role,
+ name,
+ builtin: true,
+ description: '未分配其他权限组时自动生效;可调整权限。管理员始终保留权限与系统管理入口。',
+ items: { create: ROLE_DEFAULT_PERMISSIONS[role].map((key) => ({ key })) },
+ },
+ });
+ }
+}
+
/** 管理员不可被剥夺的权限键(防锁死) */
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/schemas.ts b/packages/server/src/lib/schemas.ts
index 1e4a4de..8c5a290 100644
--- a/packages/server/src/lib/schemas.ts
+++ b/packages/server/src/lib/schemas.ts
@@ -46,7 +46,7 @@ export const attachmentUrlSchema = z
.max(500)
.refine(
(value) =>
- value === '' || value.startsWith('/') || /^https?:\/\//i.test(value),
+ value === '' || (/^\/(?![\/\\])/.test(value) && !value.includes('\\')) || /^https?:\/\//i.test(value),
{ message: '附件链接需为 http(s):// 地址或站内相对路径' },
)
.nullish();
diff --git a/packages/server/src/lib/snapshot.ts b/packages/server/src/lib/snapshot.ts
index f848bf1..84bef34 100644
--- a/packages/server/src/lib/snapshot.ts
+++ b/packages/server/src/lib/snapshot.ts
@@ -50,8 +50,6 @@ export const SNAPSHOT_TABLES = [
'Notification',
'NotificationRead',
'Grade',
- 'IntegrationDevice',
- 'ClassIslandPush',
'ClientDevice',
'ClientCommand',
'ClientRelease',
@@ -132,7 +130,11 @@ export function assertValidSnapshot(value: unknown): asserts value is DatabaseSn
}
}
-async function setForeignKeys(prisma: PrismaClient, provider: DatabaseProvider, enabled: boolean): Promise {
+async function setForeignKeys(
+ prisma: PrismaClient,
+ provider: DatabaseProvider,
+ enabled: boolean,
+): Promise {
const sql =
provider === 'mysql'
? `SET FOREIGN_KEY_CHECKS=${enabled ? 1 : 0}`
@@ -164,7 +166,12 @@ export async function restoreSnapshot(
}
}
for (const table of SNAPSHOT_TABLES) {
- const rows = snapshot.data[table];
+ const rows = snapshot.data[table].map((row) => {
+ if (table !== 'Class' || typeof row !== 'object' || row === null) return row;
+ const copy = { ...(row as Record) };
+ delete copy.notificationChannel;
+ return copy;
+ });
for (let index = 0; index < rows.length; index += CHUNK_SIZE) {
await delegates[table].createMany({ data: rows.slice(index, index + CHUNK_SIZE) });
}
diff --git a/packages/server/src/middleware/auth.ts b/packages/server/src/middleware/auth.ts
index 6fc0a7b..7ef5528 100644
--- a/packages/server/src/middleware/auth.ts
+++ b/packages/server/src/middleware/auth.ts
@@ -104,6 +104,15 @@ export function requirePermission(...keys: PermissionKey[]): RequestHandler {
};
}
+/** 班级端按班级范围开放读取;教师/管理员仍按可编辑的功能权限校验。 */
+export function requireStaffPermission(...keys: PermissionKey[]): RequestHandler {
+ const guard = requirePermission(...keys);
+ return (req, res, next) => {
+ if (req.auth?.user.role === 'CLASS_DEVICE') return next();
+ return guard(req, res, next);
+ };
+}
+
/**
* 权限守卫(任一满足):只要拥有其中**一个**权限键就放行。
*
@@ -119,7 +128,9 @@ export function requireAnyPermission(...keys: PermissionKey[]): RequestHandler {
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} 中至少需要一项),请联系管理员在「权限组」里分配`));
+ next(
+ ApiError.forbidden(`当前账号缺少权限(${labels} 中至少需要一项),请联系管理员在「权限组」里分配`),
+ );
return;
}
next();
diff --git a/packages/server/src/modules/ai/ai.service.ts b/packages/server/src/modules/ai/ai.service.ts
index 2bfe74d..b984c28 100644
--- a/packages/server/src/modules/ai/ai.service.ts
+++ b/packages/server/src/modules/ai/ai.service.ts
@@ -1,6 +1,12 @@
-import { dayKeyLocal, type AiActionKind, type AiAuditDto, type AiInterpretResult, type AiSettingsDto } from '@classhelper/shared';
+import {
+ dayKeyLocal,
+ type AiActionKind,
+ type AiAuditDto,
+ type AiInterpretResult,
+ type AiSettingsDto,
+} from '@classhelper/shared';
import { z } from 'zod';
-import { getAccessibleClassIds } from '../../lib/access.js';
+import { getAccessibleClassIds, assertClassAccess } from '../../lib/access.js';
import { prisma } from '../../lib/db.js';
import { ApiError } from '../../lib/http.js';
import type { TokenPayload } from '../../lib/jwt.js';
@@ -17,7 +23,7 @@ import type { AiInterpretInput, UpdateAiSettingsInput } from './ai.schemas.js';
* 设计边界(照 approved 计划):
* 1. 模型只输出结构化意图(`{action, params, confidence}`),服务端用**与手动入口同一份 zod**
* 二次校验后返回预览;真正落库由前端确认后调既有端点(`POST /homeworks` 等),
- * 于是权限断言、上课时段紧急通知保护、ClassIsland 推送全部自动复用(AGENTS.md §5 第 5/14 条);
+ * 于是权限断言、上课时段紧急通知保护全部自动复用(AGENTS.md §5 第 5/14 条);
* 2. **密钥只进不出**:`AppSetting` 里存的是 `encryptSecret()` 的密文,任何 GET 都不回传;
* 3. `provider: 'mock'` 用确定性规则离线解析 —— 自动化验收**绝不访问外网**(§5 第 52 条),
* 真实模型走 `provider: 'openai'` 时的 `{baseUrl}/chat/completions`。
@@ -98,6 +104,7 @@ interface AssistantContext {
}
async function resolveContext(user: TokenPayload, classId?: string): Promise {
+ if (classId) await assertClassAccess(user, classId);
const classIds = await getAccessibleClassIds(user);
const classes = await prisma.class.findMany({
where: { id: { in: classIds } },
@@ -201,7 +208,11 @@ async function callOpenAi(
signal: AbortSignal.timeout(DEFAULT_TIMEOUT_MS),
});
} catch (error) {
- throw new ApiError(504, 'AI_TIMEOUT', `无法连接大模型服务:${error instanceof Error ? error.message : String(error)}`);
+ throw new ApiError(
+ 504,
+ 'AI_TIMEOUT',
+ `无法连接大模型服务:${error instanceof Error ? error.message : String(error)}`,
+ );
}
if (!response.ok) {
@@ -253,12 +264,13 @@ async function buildPreview(
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;
+ 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 || '某位同学')}到老师这里`,
@@ -281,7 +293,11 @@ export async function interpret(user: TokenPayload, input: AiInterpretInput): Pr
throw new ApiError(400, 'AI_NOT_CONFIGURED', '助手功能还没开启,请管理员在「大模型设置」里打开');
}
if (settings.provider === 'openai' && (!settings.hasApiKey || !settings.baseUrl)) {
- throw new ApiError(400, 'AI_NOT_CONFIGURED', '还没配置大模型服务地址或密钥,请管理员在「大模型设置」里填写');
+ throw new ApiError(
+ 400,
+ 'AI_NOT_CONFIGURED',
+ '还没配置大模型服务地址或密钥,请管理员在「大模型设置」里填写',
+ );
}
const context = await resolveContext(user, input.classId);
@@ -301,7 +317,10 @@ export async function interpret(user: TokenPayload, input: AiInterpretInput): Pr
const warnings: string[] = [];
try {
if (reply.action === 'create_homework') {
- const parsed = homeworkParamsSchema.parse({ ...reply.params, classId: reply.params.classId ?? input.classId });
+ 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') {
@@ -312,7 +331,10 @@ export async function interpret(user: TokenPayload, input: AiInterpretInput): Pr
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 });
+ const parsed = callParamsSchema.parse({
+ ...reply.params,
+ classId: reply.params.classId ?? input.classId,
+ });
action = 'call_student';
params = parsed as unknown as Record;
}
@@ -321,6 +343,16 @@ export async function interpret(user: TokenPayload, input: AiInterpretInput): Pr
logger.warn(`助手参数校验失败:${error instanceof Error ? error.message : String(error)}`);
}
+ if (typeof params.classId === 'string') {
+ await assertClassAccess(user, params.classId);
+ if (typeof params.courseId === 'string') {
+ const course = await prisma.course.findFirst({
+ where: { id: params.courseId, classId: params.classId },
+ select: { id: true },
+ });
+ if (!course) throw ApiError.badRequest('科目不属于所选班级');
+ }
+ }
const preview = await buildPreview(action, params);
preview.warnings = [...preview.warnings, ...warnings];
if (settings.provider === 'mock') preview.warnings.push('当前是离线示例解析(未配置真实模型),请仔细核对');
@@ -330,7 +362,8 @@ export async function interpret(user: TokenPayload, input: AiInterpretInput): Pr
userId: user.sub,
prompt: input.text,
intent: action,
- status: 'pending',
+ // 审计只记录解析,不代表前端确认后的业务操作已执行。
+ status: action === 'unknown' ? 'unrecognized' : 'interpreted',
message: preview.summary,
rawReply: JSON.stringify(reply).slice(0, 2000),
},
diff --git a/packages/server/src/modules/auth/auth.module.ts b/packages/server/src/modules/auth/auth.module.ts
index f34bd3b..b79c861 100644
--- a/packages/server/src/modules/auth/auth.module.ts
+++ b/packages/server/src/modules/auth/auth.module.ts
@@ -16,7 +16,7 @@ import * as authService from './auth.service.js';
const router = Router();
-/** POST /api/auth/login - 登录(公开):教师 / 管理员 / 个人学生账号 */
+/** POST /api/auth/login - 登录(公开):教师 / 管理员 */
router.post('/login', validate({ body: loginSchema }), async (req, res) => {
const result: LoginResponse = await authService.login(req.body as LoginInput);
sendOk(res, result, '登录成功');
@@ -26,7 +26,7 @@ router.post('/login', validate({ body: loginSchema }), async (req, res) => {
* POST /api/auth/class-login - 班级账号登录(公开,ClassHelper 班级端主入口)
*
* 班级码 + 班级密码 → 以「班级」为主体的 JWT(个人数据按全班读写)。
- * 个人学生账号仍可用 /auth/login 登录(向后兼容,客户端已不再展示该入口)。
+ * 学生仅为名单记录,没有个人登录账号。
*/
router.post('/class-login', validate({ body: classLoginSchema }), async (req, res) => {
const result: LoginResponse = await authService.classLogin(req.body as ClassLoginInput);
diff --git a/packages/server/src/modules/calls/calls.service.ts b/packages/server/src/modules/calls/calls.service.ts
index c2d3718..3faeeb2 100644
--- a/packages/server/src/modules/calls/calls.service.ts
+++ b/packages/server/src/modules/calls/calls.service.ts
@@ -1,4 +1,4 @@
-import { SOCKET_EVENTS, buildCallTitle, type NotificationDto } from '@classhelper/shared';
+import { SOCKET_EVENTS, buildCallTitle, dayKeyLocal, type NotificationDto } from '@classhelper/shared';
import { assertCanPublishNotification } from '../../lib/access.js';
import { prisma } from '../../lib/db.js';
import { ApiError } from '../../lib/http.js';
@@ -6,7 +6,6 @@ import type { TokenPayload } from '../../lib/jwt.js';
import { logger } from '../../lib/logger.js';
import { toNotificationDto } from '../../lib/mappers.js';
import { emitToClass, emitToSession } from '../../realtime/bus.js';
-import { pushToClassIsland, shouldPushToClassIsland } from '../integrations/integrations.service.js';
import type { CreateCallInput } from './calls.schemas.js';
const creatorSelect = { select: { id: true, name: true, username: true } } as const;
@@ -55,6 +54,7 @@ export async function createCall(user: TokenPayload, input: CreateCallInput): Pr
// (不会触发上课时段 409 拦截,但客户端按普通通知排队处理)
priority: input.urgent ? 'URGENT' : 'HIGH',
createdBy: user.sub,
+ assignDate: dayKeyLocal(new Date()),
},
include: { creator: creatorSelect, reads: true },
});
@@ -65,26 +65,6 @@ export async function createCall(user: TokenPayload, input: CreateCallInput): Pr
// 定向广播给教室机器:会话房间以**班级 id** 为键,叫人消息因此立即展开
emitToSession(created.classId, SOCKET_EVENTS.callNew, dto);
- // 叫人同样推到教室的 ClassIsland:老师点名时,教室大屏也弹一条(学生姓名在标题里)。
- // 显示位置由班级端决定;推送失败不影响叫人本身。
- try {
- if (await shouldPushToClassIsland(input.classId)) {
- await pushToClassIsland({
- classId: input.classId,
- title: created.title,
- content: created.content,
- urgent: input.urgent === true,
- // 叫人属于「主动通知」:教室的 ClassIsland 上课时段也要立刻弹
- kind: 'call',
- createdBy: user.sub,
- notificationId: created.id,
- teacherName,
- });
- }
- } catch (error) {
- logger.warn(`叫人推送 ClassIsland 失败(叫人本身已发出):${String(error)}`);
- }
-
logger.info(`${input.urgent ? '紧急叫人' : '叫人'}:${teacherName} → ${studentName}(${message})`);
return dto;
}
diff --git a/packages/server/src/modules/classes/classes.module.ts b/packages/server/src/modules/classes/classes.module.ts
index 8b2e5c0..35e3e03 100644
--- a/packages/server/src/modules/classes/classes.module.ts
+++ b/packages/server/src/modules/classes/classes.module.ts
@@ -2,12 +2,7 @@ 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 { validate, validatedBody, validatedParams, validatedQuery } from '../../middleware/validate.js';
import { defineModule } from '../module.types.js';
import {
addStudentSchema,
@@ -19,14 +14,12 @@ import {
promoteClassesSchema,
updateClassAccountSchema,
updateClassSchema,
- updateNotificationChannelSchema,
updateStudentGradeQuerySchema,
type AddStudentInput,
type AssignSubjectTeachersInput,
type CreateClassInput,
type UpdateClassAccountInput,
type UpdateClassInput,
- type UpdateNotificationChannelInput,
type UpdateStudentGradeQueryInput,
} from './classes.schemas.js';
import * as classService from './classes.service.js';
@@ -73,10 +66,15 @@ router.get('/:id', validate({ params: idParamSchema }), async (req, res) => {
});
/** POST /api/classes - 创建班级(称呼由入学年份 + 班号生成,如「2026级1班」) */
-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), '班级创建成功');
-});
+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), '班级创建成功');
+ },
+);
/** PATCH /api/classes/:id - 编辑班级(年级 / 届别 / 教学周数),仅管理员 */
router.patch(
@@ -160,55 +158,9 @@ router.put(
},
);
-/**
- * GET /api/classes/:id/classisland-status - 本班 ClassHelper 班级端 / ClassIsland 联动状态
- *
- * 教室机器(ClassHelper 班级端)也能读:客户端「设置 → ClassIsland 联动」据此显示状态。
- */
-router.get('/:id/classisland-status', validate({ params: idParamSchema }), async (req, res) => {
- const user = getAuthUser(req);
- const { id } = validatedParams<{ id: string }>(req);
- sendOk(res, await classService.getClassIslandStatus(user, id), '获取联动状态成功');
-});
-
-/**
- * GET /api/classes/:id/notification-channel - 读取"通知显示到哪个端"
- *
- * 教室的 ClassHelper 班级端登录后会拉这个值,据此决定自己是弹窗/上岛还是静默;
- * 教师/管理员也能读(Web 端展示用)。
- */
-router.get('/:id/notification-channel', validate({ params: idParamSchema }), async (req, res) => {
- const user = getAuthUser(req);
- const { id } = validatedParams<{ id: string }>(req);
- sendOk(res, await classService.getNotificationChannel(user, id), '获取通知显示位置成功');
-});
-
-/**
- * PATCH /api/classes/:id/notification-channel - 设置"通知显示到哪个端"
- *
- * **由教室的 ClassHelper 班级端自己选**,教师/管理员也可改。
- */
-router.patch(
- '/:id/notification-channel',
- validate({ params: idParamSchema, body: updateNotificationChannelSchema }),
- async (req, res) => {
- const user = getAuthUser(req);
- const { id } = validatedParams<{ id: string }>(req);
- sendOk(
- res,
- await classService.updateNotificationChannel(
- user,
- id,
- validatedBody(req),
- ),
- '通知显示位置已更新',
- );
- },
-);
-
/**
* GET /api/classes/:id/student-grade-query - 本班是否允许班级端按学号查成绩明细
- * PATCH 同路径 - 开关它(班级端自己可改,教师/管理员也可改)
+ * PATCH 同路径 - 教师/管理员按班级管理权限设置,班级端只读
*/
router.get('/:id/student-grade-query', validate({ params: idParamSchema }), async (req, res) => {
const user = getAuthUser(req);
@@ -224,11 +176,7 @@ router.patch(
const { id } = validatedParams<{ id: string }>(req);
sendOk(
res,
- await classService.updateStudentGradeQuery(
- user,
- id,
- validatedBody(req),
- ),
+ await classService.updateStudentGradeQuery(user, id, validatedBody(req)),
'成绩查询开关已更新',
);
},
diff --git a/packages/server/src/modules/classes/classes.schemas.ts b/packages/server/src/modules/classes/classes.schemas.ts
index 0fc6f66..e461ca9 100644
--- a/packages/server/src/modules/classes/classes.schemas.ts
+++ b/packages/server/src/modules/classes/classes.schemas.ts
@@ -1,5 +1,5 @@
import { z } from 'zod';
-import { CLASSISLAND_NOTIFICATION_CHANNELS, MAX_TERM_WEEK, SUBJECT_CATALOG, isDayKey } from '@classhelper/shared';
+import { MAX_TERM_WEEK, SUBJECT_CATALOG, isDayKey } from '@classhelper/shared';
import {
classIndexSchema,
enrollmentYearSchema,
@@ -99,10 +99,7 @@ export const assignSubjectTeachersSchema = z.object({
.trim()
.min(1, '请选择或填写科目')
.max(32)
- .refine(
- (value) => value.length > 0,
- { message: '科目名不能为空' },
- ),
+ .refine((value) => value.length > 0, { message: '科目名不能为空' }),
teacherId: z.string().min(1).nullish(),
}),
)
@@ -114,17 +111,6 @@ export const assignHeadTeacherSchema = z.object({
teacherId: z.string().min(1, '请选择班主任'),
});
-/**
- * 通知显示位置(both / client / classisland)。
- * 由 **ClassHelper 班级端**在设置页里改,教师/管理员也可以在 Web 端改。
- */
-export const updateNotificationChannelSchema = z.object({
- notificationChannel: z.enum(
- CLASSISLAND_NOTIFICATION_CHANNELS as unknown as [string, ...string[]],
- { message: '显示位置只能是 both / client / classisland' },
- ),
-});
-
/** 班级端按学号查询成绩明细的开关 */
export const updateStudentGradeQuerySchema = z.object({
enabled: z.boolean(),
@@ -138,7 +124,6 @@ export const classStudentParamSchema = z.object({
export type CreateClassInput = z.infer;
export type UpdateClassInput = z.infer;
export type UpdateClassAccountInput = z.infer;
-export type UpdateNotificationChannelInput = z.infer;
export type UpdateStudentGradeQueryInput = z.infer;
export type AddStudentInput = z.infer;
export type AssignSubjectTeachersInput = z.infer;
diff --git a/packages/server/src/modules/classes/classes.service.ts b/packages/server/src/modules/classes/classes.service.ts
index 7dad586..f7525ba 100644
--- a/packages/server/src/modules/classes/classes.service.ts
+++ b/packages/server/src/modules/classes/classes.service.ts
@@ -1,10 +1,4 @@
-import type {
- ClassDetailDto,
- ClassDto,
- ClassIslandNotificationChannel,
- StudentDto,
- SubjectTeacherDto,
-} from '@classhelper/shared';
+import type { ClassDetailDto, ClassDto, StudentDto, SubjectTeacherDto } from '@classhelper/shared';
import { SOCKET_EVENTS, formatClassName } from '@classhelper/shared';
import { env } from '../../config/env.js';
import {
@@ -13,7 +7,6 @@ import {
assertCanManageRoster,
assertClassAccess,
classScopeIdFilter,
- isClassDevice,
isStaff,
resolveClassScope,
} from '../../lib/access.js';
@@ -24,23 +17,15 @@ import {
import { prisma } from '../../lib/db.js';
import { ApiError } from '../../lib/http.js';
import type { TokenPayload } from '../../lib/jwt.js';
-import {
- toClassDto,
- toCourseDto,
- toNotificationChannel,
- toStudentDto,
- toTeacherBrief,
-} from '../../lib/mappers.js';
+import { toClassDto, toCourseDto, toStudentDto, toTeacherBrief } from '../../lib/mappers.js';
import { emitToClass } from '../../realtime/bus.js';
import type {
AddStudentInput,
AssignSubjectTeachersInput,
CreateClassInput,
UpdateClassInput,
- UpdateNotificationChannelInput,
UpdateStudentGradeQueryInput,
} from './classes.schemas.js';
-import { attachIntegrationFlag } from '../integrations/integrations.service.js';
import { logger } from '../../lib/logger.js';
const countSelect = {
@@ -106,8 +91,7 @@ export async function listClasses(user: TokenPayload, keyword?: string): Promise
orderBy: [{ enrollmentYear: 'desc' }, { classIndex: 'asc' }, { grade: 'asc' }],
});
- // 附上「是否已接入 ClassIsland」徽标:Web 端班级列表/课表页据此提示
- return attachIntegrationFlag(classes.map((item) => toClassDtoWithAccount(user, item)));
+ return classes.map((item) => toClassDtoWithAccount(user, item));
}
/** 班级详情:学生名单 + 课程 + 班主任 + 各科任课老师 */
@@ -261,7 +245,10 @@ export async function assignSubjectTeachers(
.teacherId;
const existing = assignment.courseId
- ? await prisma.course.findUnique({ where: { id: assignment.courseId }, select: { id: true, classId: true } })
+ ? await prisma.course.findUnique({
+ where: { id: assignment.courseId },
+ select: { id: true, classId: true },
+ })
: await prisma.course.findUnique({
where: { classId_name: { classId, name: assignment.subjectName } },
select: { id: true, classId: true },
@@ -481,98 +468,6 @@ export async function removeStudent(user: TokenPayload, classId: string, student
emitToClass(classId, SOCKET_EVENTS.classUpdated, { classId, action: 'updated' });
}
-/* ---------------------------------------------------------------- ClassHelper 班级端状态 */
-
-/**
- * 本班的 ClassHelper 联动状态(教室机器上的「联动」面板展示)。
- *
- * 为什么单独开一个接口:设备列表(/integrations/devices)是管理面,只对教师/管理员开放;
- * 而教室机器(ClassHelper 班级端)需要知道"本班接没接 ClassIsland、最后什么时候上报的",
- * 才能给出准确的提示(例如没接就别选"只在 ClassIsland 上弹")。
- */
-export async function getClassIslandStatus(
- user: TokenPayload,
- classId: string,
-): Promise<{
- connected: boolean;
- deviceName: string | null;
- deviceCount: number;
- onlineCount: number;
- lastSeenAt: string | null;
- lastHeartbeatAt: string | null;
- pluginVersion: string | null;
- classIslandVersion: string | null;
-}> {
- await assertClassAccess(user, classId);
- const devices = await prisma.integrationDevice.findMany({
- where: { classId, enabled: true, mode: 'plugin' },
- select: {
- name: true,
- lastSeenAt: true,
- lastHeartbeatAt: true,
- pluginVersion: true,
- classIslandVersion: true,
- },
- orderBy: { lastHeartbeatAt: 'desc' },
- });
-
- const latest = devices[0];
- const window = 60_000;
- return {
- connected: devices.length > 0,
- deviceName: latest?.name ?? null,
- deviceCount: devices.length,
- onlineCount: devices.filter(
- (item) => item.lastHeartbeatAt && Date.now() - item.lastHeartbeatAt.getTime() < window,
- ).length,
- lastSeenAt: latest?.lastSeenAt ? latest.lastSeenAt.toISOString() : null,
- lastHeartbeatAt: latest?.lastHeartbeatAt ? latest.lastHeartbeatAt.toISOString() : null,
- pluginVersion: latest?.pluginVersion ?? null,
- classIslandVersion: latest?.classIslandVersion ?? null,
- };
-}
-
-/* ---------------------------------------------------------------- 班级端设置 */
-
-/**
- * 读取班级的通知显示位置(both / client / classisland)。
- * 这是**教室机器**要读的值:登录后拉一次,把自己的弹窗行为对齐到同一个选择。
- */
-export async function getNotificationChannel(
- user: TokenPayload,
- classId: string,
-): Promise<{ notificationChannel: ClassIslandNotificationChannel }> {
- await assertClassAccess(user, classId);
- const record = await prisma.class.findUnique({
- where: { id: classId },
- select: { notificationChannel: true },
- });
- return { notificationChannel: toNotificationChannel(record?.notificationChannel) };
-}
-
-/**
- * 设置班级的通知显示位置。
- *
- * 允许两类人改:**该班的 ClassHelper 班级端**(需求就是"由教室机器自己选")
- * 与教师 / 管理员(Web 端也能改,便于老师统一要求)。
- */
-export async function updateNotificationChannel(
- user: TokenPayload,
- classId: string,
- input: UpdateNotificationChannelInput,
-): Promise<{ notificationChannel: ClassIslandNotificationChannel }> {
- await assertClassAccess(user, classId);
- if (!isClassDevice(user) && !isStaff(user)) {
- throw ApiError.forbidden('只有 ClassHelper 班级端或教师/管理员可以修改通知显示位置');
- }
-
- const channel = toNotificationChannel(input.notificationChannel);
- await prisma.class.update({ where: { id: classId }, data: { notificationChannel: channel } });
- emitToClass(classId, SOCKET_EVENTS.classUpdated, { classId, action: 'updated' });
- logger.info(`班级通知显示位置已更新:classId=${classId} channel=${channel} by=${user.sub}`);
- return { notificationChannel: channel };
-}
-
/**
* 读取「是否允许 ClassHelper 班级端按学号查询本班学生成绩明细」。
* 班级端设置页用它渲染开关的当前状态。
@@ -595,10 +490,7 @@ export async function getStudentGradeQuery(
* 设置「是否允许 ClassHelper 班级端查看本班成绩与排名」。
*
* **只有教师 / 管理员能改**(需求:「成绩查询由服务端控制,客户端只读」)。
- * 原先班级端自己也能改这个开关 —— 教室机器能给自己开成绩查询,等于把
- * "学校说不给看"变成了一台机器说了算;而与它同一套模式的「通知显示位置」
- * (`Class.notificationChannel`)**仍然由班级端自己改**,那个确实是现场偏好,
- * 两者别再按"同一个模式"去理解。关闭后班级端查成绩一律 403,教师端不受影响。
+ * 关闭后班级端查成绩一律 403,教师端不受影响。
*/
export async function updateStudentGradeQuery(
user: TokenPayload,
diff --git a/packages/server/src/modules/clients/clients.module.ts b/packages/server/src/modules/clients/clients.module.ts
index ed84ad1..dc14a60 100644
--- a/packages/server/src/modules/clients/clients.module.ts
+++ b/packages/server/src/modules/clients/clients.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, requirePermission } from '../../middleware/auth.js';
+import { authenticate, getAuthUser, requireAnyPermission, requirePermission } from '../../middleware/auth.js';
import { validate, validatedBody, validatedParams, validatedQuery } from '../../middleware/validate.js';
import { defineModule } from '../module.types.js';
import {
@@ -30,7 +30,9 @@ router.use(authenticate());
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 ?? ''), '心跳已上报');
+ const result = await clientService.heartbeat(user, input, req.ip ?? '');
+ // 1.3.x 旧客户端误读顶层字段,提供兼容别名让它们仍能接收修复版更新。
+ sendOk(res, result, '心跳已上报', 200, input.agentProtocol === 2 ? {} : result);
});
/** POST /api/clients/commands/:id/ack - 执行回执(同样不挂权限键,靠 deviceKey 归属校验) */
@@ -56,18 +58,26 @@ router.post(
async (req, res) => {
const user = getAuthUser(req);
const input = validatedBody(req);
- sendCreated(res, await clientService.createCommands(user, input), '命令已下发,将在这台电脑下次心跳时送达');
+ sendCreated(
+ res,
+ await clientService.createCommands(user, input),
+ '命令已下发,将在这台电脑下次心跳时送达',
+ );
},
);
/** GET /api/clients/devices?classId= - Web 端设备列表(版本 / 在线 / 待执行命令) */
router.get(
'/devices',
- requirePermission('client:manage'),
+ requireAnyPermission('client:read', 'client:manage', 'remote:control', 'client:update-push'),
validate({ query: listClientDevicesQuerySchema }),
async (req, res) => {
const query = validatedQuery(req);
- sendOk(res, await clientService.listDevices({ classId: query.classId }), '获取设备列表成功');
+ sendOk(
+ res,
+ await clientService.listDevices(getAuthUser(req), { classId: query.classId }),
+ '获取设备列表成功',
+ );
},
);
@@ -90,7 +100,7 @@ router.delete(
validate({ params: idParamSchema }),
async (req, res) => {
const { id } = validatedParams<{ id: string }>(req);
- sendOk(res, await clientService.deleteDevice(id), '设备已移除');
+ sendOk(res, await clientService.deleteDevice(getAuthUser(req), id), '设备已移除');
},
);
diff --git a/packages/server/src/modules/clients/clients.schemas.ts b/packages/server/src/modules/clients/clients.schemas.ts
index 0bea4b3..1fcf19b 100644
--- a/packages/server/src/modules/clients/clients.schemas.ts
+++ b/packages/server/src/modules/clients/clients.schemas.ts
@@ -14,10 +14,12 @@ const deviceKeySchema = z.string().trim().min(8, '设备标识不合法').max(80
/** `POST /api/clients/heartbeat`:客户端每 30 秒一次的上报 */
export const clientHeartbeatSchema = z.object({
+ agentProtocol: z.number().int().min(1).max(2).optional(),
deviceKey: deviceKeySchema,
name: z.string().trim().max(60).optional(),
clientVersion: z.string().trim().max(40).optional(),
platform: z.string().trim().max(40).optional(),
+ autoUpdate: z.boolean().optional(),
pendingVersion: z.string().trim().max(40).nullable().optional(),
stateNote: z.string().trim().max(120).optional(),
});
@@ -53,6 +55,11 @@ export const pushClientUpdateSchema = z.object({
all: z.boolean().optional(),
version: z.string().trim().max(40).nullable().optional(),
clear: z.boolean().optional(),
+ mode: z.enum(['idle', 'immediate', 'after-school']).default('idle'),
+ afterSchoolTime: z
+ .string()
+ .regex(/^(?:[01]\d|2[0-3]):[0-5]\d$/, '放学时间应为 HH:mm')
+ .optional(),
});
export type ClientHeartbeatInput = z.infer;
diff --git a/packages/server/src/modules/clients/clients.service.ts b/packages/server/src/modules/clients/clients.service.ts
index 13da383..155f909 100644
--- a/packages/server/src/modules/clients/clients.service.ts
+++ b/packages/server/src/modules/clients/clients.service.ts
@@ -2,6 +2,8 @@ import {
CLIENT_COMMAND_EXPIRE_MS,
CLIENT_COMMAND_LABELS,
CLIENT_ONLINE_WINDOW_MS,
+ weekParityOf,
+ type ClientUpdateMode,
type ClientCommandAckRequest,
type ClientCommandDto,
type ClientCommandKind,
@@ -10,18 +12,23 @@ import {
type ClientPushResult,
type ClientTargetResult,
} from '@classhelper/shared';
+import { loadTermContext, resolveCurrentWeek } from '../../lib/term.js';
+import { resolveClassScope, classScopeWhere } 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 * as clientReleases from '../update/client-releases.service.js';
-import type { ClientHeartbeatInput, CreateClientCommandInput, PushClientUpdateInput } from './clients.schemas.js';
+import type {
+ ClientHeartbeatInput,
+ CreateClientCommandInput,
+ PushClientUpdateInput,
+} from './clients.schemas.js';
/**
* ClassHelper 桌面客户端设备的心跳 / 远控命令 / 更新下发。
*
* 范式(AGENTS.md §5 第 67 条):**DB 记状态 + 客户端定时拉取** ——
- * 与 `IntegrationDevice.requestScheduleReport` 同一套路,不新增长连接握手。
* 因此本文件里没有任何"推送"动作:所有下发都只是改 `ClientDevice` / `ClientCommand` 的字段,
* 由客户端下一次心跳(30 秒内)带走。
*/
@@ -35,6 +42,8 @@ type DeviceRow = {
platform: string;
autoUpdate: boolean;
updateTargetVersion: string;
+ updateMode: ClientUpdateMode;
+ updateNotBefore: Date | null;
pendingVersion: string;
lastHeartbeatAt: Date | null;
lastIp: string;
@@ -131,6 +140,7 @@ export async function heartbeat(
name: input.name?.trim() || '未命名设备',
clientVersion: input.clientVersion?.trim() ?? '',
platform: input.platform?.trim() ?? '',
+ autoUpdate: input.autoUpdate !== false,
pendingVersion: input.pendingVersion?.trim() ?? '',
stateNote: input.stateNote?.trim() ?? '',
lastHeartbeatAt: now,
@@ -153,7 +163,16 @@ export async function heartbeat(
deviceId: device.id,
serverTime: now.toISOString(),
updateTargetVersion: targetVersion || null,
- update,
+ updateMode: device.updateMode,
+ updateNotBefore: device.updateNotBefore?.toISOString() ?? null,
+ // 旧客户端没有时机判断:到放学时间前不提供包,避免它提前安装。
+ update:
+ input.agentProtocol !== 2 &&
+ device.updateMode === 'after-school' &&
+ device.updateNotBefore &&
+ now < device.updateNotBefore
+ ? null
+ : update,
command: command ? toCommandDto(command) : null,
};
}
@@ -187,9 +206,13 @@ export async function ackCommand(
}
/** GET /api/clients/devices - Web 端「客户端设备」列表(含待执行命令) */
-export async function listDevices(options: { classId?: string } = {}): Promise {
+export async function listDevices(
+ user: TokenPayload,
+ options: { classId?: string } = {},
+): Promise {
+ const scope = await resolveClassScope(user, options.classId);
const rows = (await prisma.clientDevice.findMany({
- where: options.classId ? { classId: options.classId } : {},
+ where: classScopeWhere(scope),
include: { class: { select: { id: true, name: true } } },
orderBy: [{ lastHeartbeatAt: 'desc' }, { createdAt: 'desc' }],
})) as unknown as DeviceRow[];
@@ -235,6 +258,8 @@ export async function listDevices(options: { classId?: string } = {}): Promise> {
+async function findTargetDevices(user: TokenPayload, deviceIds: string[]): Promise