# ClassHelper 代码审查报告 **审查日期**:2026-10-17 **审查基线**:`master` @ `b7cdc7c`(版本号 1.3.2),含工作区未提交改动(ClassIsland 联动退役那一批,116 个文件 / -10171 行) **审查范围**:`packages/{server,web-admin,desktop-client,shared}` 全量源码 **审查方式**:逐文件读代码 + 静态门禁。**未修改任何代码。** --- ## 0. 门禁结果 | 检查 | 结果 | 备注 | | --- | --- | --- | | `pnpm lint` | ✅ 通过 | 无输出 | | `pnpm typecheck` | ✅ 通过 | shared / server / web-admin / desktop-client 四个包全 Done(含 `vue-tsc`) | | `pnpm version:check` | ✅ 通过 | 12 处落点全为 1.3.2 | | `pnpm check:icons` | ✅ 通过 | 两端各 33 个图标全部注册 | | `pnpm exec prettier --check --end-of-line auto`(92 个改动文件) | ⚠️ 17 个 `.md` 未过 | 代码文件全部干净;md 是这轮改动新写脏的 | 基线本身是绿的:**以下全部是"读代码 + 对照契约"找出的逻辑/一致性问题,不是编译或类型错误。** 未跑的验收:`verify:e2e` / `verify:web` / `verify:desktop` / `verify:packaged`(会真实写库、发通知、开 Electron 窗口,本次为只读审查故未执行)。 ### 0.1 环境备注:AGENTS.md 记的 pnpm 绕过办法在 Git Bash 下不完整 AGENTS.md §3 的「AI 会话里跑 pnpm」一节给的是 PowerShell 写法(设 `DSH_NODE` + `DSH_PNPM_BIN` 后照常写 `pnpm xxx`)。**在 Git Bash 里这么写会报 `pnpm: command not found`**(PATH 上没有 `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 -r` 落进转发壳后用真实 node**,**直接调 `pnpm.cjs` 负责解决最外层没有 `pnpm` 命令**。建议把这一条补进 AGENTS.md。 --- ## 1. 严重(静默出错,用户看不出来) ### 1.1 侧栏少了三个管理页的入口,只能手输 URL 进入 **位置** - 菜单数组:`packages/web-admin/src/layouts/AdminLayout.vue:99-135`(当前 12 项) - 缺失的三条路由:`packages/web-admin/src/router/index.ts:41-47`(学期周次)、`:48-54`(毕业归档)、`:101-108`(权限组管理) | 页面 | 路由 | 现状 | | --- | --- | --- | | 学期周次 | `/term-weeks` | **从未**接过菜单项(1.2.0 起) | | 毕业归档 | `/archives` | **从未**接过菜单项(1.2.0 起) | | 权限组管理 | `/permissions` | 1.3.0 有菜单项,**1.3.1 误删** | **证据链** - `git show 227117c -- packages/web-admin/src/layouts/AdminLayout.vue` 的 diff 里有 `- path: '/permissions'` / `- title: '权限组'`,而该提交的信息只讲「客户端页合并」,**一个字没提删权限组**。 - 菜单项计数:1.2.0 = 10 项 → 1.3.0 = 14 项 → 1.3.1 及现在 = **12 项**(少的正是 ClassIsland 联动那一项〔有意合并〕+ 权限组这一项〔误伤〕)。 - `git log -S"term-weeks"` / `-S"/archives"` 在 `AdminLayout.vue` 的历史里**零命中** ⇒ 这两页从 1.2.0 上线起就没接线。 - 代码自己还留着念想:`AdminLayout.vue:294` 注释「菜单里的『权限组』要等它回来才显示」,但数组里已经没有它;`src/styles/index.css:223` 注释仍写「Web 端目前 **14** 个菜单项」。 - 文档三处都按「有侧栏入口」写:`docs/reference.md:750 / 777 / 807`。 - 全前端 grep:这三条路径**没有任何其他链接**指向它们(顶栏下拉只有改密码、使用引导、检查更新、退出)。 **后果**:管理员只能靠记 URL 进入这三个页面。`currentTitle`(`AdminLayout.vue:156`)对它们也回退成 APP_TITLE,小屏顶栏标题不对。 **为什么没被发现**:同文件 `:353-361` 的开发期自检只校验「菜单里的路径必须有对应路由」,**不校验反向**(有路由但没菜单)。建议顺手补上反向断言。 **修法**:`menuItems` 数组补三行即可(权限组那条带 `permission: 'permission:manage'`,另两条 `roles: ['ADMIN']`),并在 `:354-361` 的自检里加上反向检查。 --- ### 1.2 「叫人」的记录不写 `assignDate`,按天查会被整个吞掉 **位置**:`packages/server/src/modules/calls/calls.service.ts:48-59` ```ts const created = await prisma.notification.create({ data: { classId: input.classId, title: buildCallTitle(studentName, teacherName), content: message, priority: input.urgent ? 'URGENT' : 'HIGH', createdBy: user.sub, // ← 缺 assignDate }, ``` **根因**:`Notification.assignDate` 是 `String @default("")`(`prisma/schema.prisma:356`),不显式赋值就是空串。 **连环后果** - `notifications.service.ts:118-121` 的 `.filter((item) => item.date !== '')` ⇒ **日期栏里永远不会出现有「叫人」的那一天**; - `notifications.service.ts:49-67` 的 `archiveWhere` 按 `assignDate` 过滤 ⇒ **通知页选中任何日期/区间,叫人记录全部消失**(不选日期时才看得到)。 **旁证**:`lib/mappers.ts:399-400` 特意写了「assignDate 为空时按 createdAt 兜底」——那条兜底只修了 DTO 的展示字段,**修不了查询与分组**(查询走的是库里的原始空串)。同模块 `createNotification`(`notifications.service.ts:231`)老老实实写了 `dayKeyLocal(new Date())`,这里漏了。 **修法**:加一行 `assignDate: dayKeyLocal(new Date())`。 **存量数据**:库里已有的叫人记录 `assignDate` 仍是空串,需要一条一次性回填(按 `createdAt` 的本地日期),否则它们永远进不了日期栏。 --- ### 1.3 主窗口重建后「关闭即隐藏到托盘」失效,点 X 会真的销毁窗口 **位置**:`packages/desktop-client/src/main/index.ts:232-237` ```ts // 只在 app.whenReady() 里给第一个窗口挂 mainWindow?.on('close', (event) => { if (isQuitting) return; event.preventDefault(); mainWindow?.hide(); }); ``` 而 `createWindow()`(`index.ts:63-108`)本身**从不挂这个处理器**,`ensureMainWindow()`(`index.ts:116-126`)会被三条路径调用重建窗口: 1. 崩溃自愈:`index.ts:189-196` 的 `recoverMainWindow` → `ensureMainWindow()`; 2. 托盘「显示主窗口」→ `showMainWindow()`(`index.ts:266-267`); 3. `app.on('activate')`(`index.ts:329-334`)。 **触发链**:主窗口渲染进程崩溃 → 静默重建(用户无明显感知)→ 用户点右上角 X → `isQuitting` 为 false 但**没有 close 监听** → 窗口被真销毁。而 `app.on('window-all-closed')`(`index.ts:314-317`)是空实现(只打日志不退出)⇒ **托盘进程还在跑、通知还在弹,但屏幕上没有任何窗口,点托盘图标才能找回来**。 **修法**:把这段 `close` 处理器移进 `createWindow()`。 --- ### 1.4 客户端离线缓存键不含班级,换班登录会串上一个班的数据 **关键前提**:`auth.logout()`(`renderer/stores/auth.ts:115-125`)**只清 token,不清缓存**(`ClientLayout.vue:357` 的提示自己也写着「离线缓存会保留」)。 **只有作业页做了班级分键**(`HomeworkView.vue:289` 的 `${classId}@${day}`),其余全是全局键: | 数据 | 缓存键 | 位置 | | --- | --- | --- | | 课表 | `week:${week}` | `island/bridge.ts:52`、`views/ScheduleView.vue:191` | | 班级/学期 | `'term'` | `views/ScheduleView.vue:175` | | 成绩 | `'my'` | `views/GradeView.vue:109` | | 通知 | `'self'` | `stores/notifications.ts:25` | | 个人信息 | `'me'` | — | **触发**:A 班登出 → B 班用班级码登录 → 首屏(离线,或首个请求失败)**直接显示 A 班的课表 / 成绩 / 通知 / 周次**。更麻烦的是这些数据还会喂给灵动岛的上下课判定(见 1.6)。 **修法**:缓存键统一带上 `classId`(`classId:xxx@week:n`),或 `clearConfig()` 时按前缀清缓存。 --- ### 1.5 IndexedDB 打开失败会被永久缓存,本次会话离线回退彻底失效 **位置**:`packages/desktop-client/src/renderer/cache/db.ts:35-58` `databasePromise` 被赋值为**可能 reject 的 Promise**,且 `onerror` / `onblocked`(`:53-54`)之后**不清空该变量**。 **后果**:只要失败一次(被别的标签页占用 / 磁盘受限 / 首次升级失败),之后**所有** `cacheGet` / `cacheSet` 都返回同一个已 reject 的 Promise ⇒ `fetchWithCache`(`cache/index.ts:32-36`)永远拿不到缓存、直接落到 fallback,`persist()` 静默失败。界面表现为「永远是空数据 / 离线什么都没有」,**而且不报错**。 **与 1.4 叠加**:这是「离线优先」这条设计在教室机上的两个独立致命洞——一个让数据串班,一个让缓存整个失效。 **修法**:`onerror` / `onblocked` / reject 时把 `databasePromise` 置回 `null`,下次调用重新尝试打开。 --- ### 1.6 灵动岛的「是否在上课」有三处独立破口 上下课判定是灵动岛的立身之本(决定通知立即弹还是暂存到下课),现在有三条互不相关的失败路径。 #### (a) 可能拿「第 1 周」的课表去算当前周 `appStore.currentWeek` 全仓库**只有 `views/ScheduleView.vue:179` 一处写入**,而读者有四处:`island/bridge.ts:49`(取课表的周次 + 缓存键)、`:62`(`resolveClassStatus` 的周次过滤)、`:72`(上报给主进程)、`layouts/ClientLayout.vue:475`(侧栏「第 X 周」)。 而 bridge 的课表只在 `bridge.ts:26,323` 的 **5 分钟**定时器或 `schedule:updated` 时重取。 **触发**:启动/登录后 bridge 先跑(`App.vue:111`),此时 `currentWeek` 还是 1,于是取回并按 `week:1` 缓存;课表页随后才把 `currentWeek` 改成真实周次(如 10)。在这 5 分钟内,`schedules` 里**没有 `weekStart > 1` 才开始的课**,却按第 10 周过滤 ⇒ 上课期间 `inClass` 恒为 `false` ⇒ **灵动岛照常弹普通通知**,正是该功能要拦的事。 `bridge.ts:22-24` 注释承诺的「最多 10 秒内自动弹出」在这种状态下也不成立。 #### (b) 课表刷新失败会把已加载的课表清空成 `[]` `bridge.ts:50-57`:网络抖动 + 该周次无缓存时 `fetchWithCache` 回退成 `[]`,代码直接 `schedules = result.data`,紧接着 **force=true** 把 `inClass: false` 推给主进程。 周次翻页后第一次刷新正好是「新 key 无缓存」的时刻 ⇒ 每次翻周都有一个窗口期,且要等 5 分钟才自愈。 #### (c) 主窗口隐藏到托盘后,10 秒复算被 Chromium 后台节流 主窗口 `webPreferences`(`main/index.ts:78-84`)没有 `backgroundThrottling: false`,而**灵动岛窗口显式关了它**(`main/island.ts:301`)——对比说明作者知道这件事。而「是否在上课」的定时器跑在**主窗口**渲染进程里(`bridge.ts:322` 的 10s tick + `stores/app.ts:121` 的 20s 健康轮询)。 教室机的常态就是「关到托盘后台运行」⇒ 长时间隐藏后 Chromium 降到约 1 次/分钟 ⇒ 上课开始/结束时灵动岛最多延迟约一分钟才隐藏/补弹。课间只有 10 分钟,体感明显。 **修法**:(a) 让 `currentWeek` 有单一数据源并在变化时触发 bridge 重取,或让 bridge 自己算当前周;(b) 失败时保留旧 `schedules`(`result.error` 非空且 `data.length === 0` 就不覆盖);(c) 给主窗口加 `backgroundThrottling: false`,或把上课判定挪到灵动岛渲染进程。 --- ### 1.7 渲染进程能通过配置让主进程执行任意 exe **位置** - 写入:`packages/desktop-client/src/main/config.ts:476-482` —— 无条件接受渲染进程传来的 `pendingUpdateFile` 字符串(注释还写着「渲染进程传进来也照收」); - 执行:`packages/desktop-client/src/main/auto-update.ts:270-341` —— 只校验「文件存在、比当前版本新、策略匹配、不在上课、窗口隐藏够久」,然后就把它当安装器启动。 **对比**:真正下载的路径有文件名白名单(`auto-update.ts:63` 的 `SAFE_FILE_NAME`)**和** sha256 校验(`:205-219`),而这条配置路径**一条校验都没有**。 **同一个文件里的正面例子**:`config.ts:485-487` 明确**拒绝**渲染进程改写 `deviceKey`(「它是这台机器在服务端的身份,不允许变更」)——同一个威胁模型,`pendingUpdateFile` 漏了。 **触发**:渲染进程出现一次注入(未来的 `v-html`、被投毒依赖、XSS)→ 写 `pendingUpdateFile=C:\...\x.exe` + `autoUpdate` 开着 → 机器空闲时被静默安装。 **修法**:`config.ts` 里只接受位于 `pendingUpdateDir()` 之下的路径(与 `deviceKey` 同样的收口方式)。 --- ## 2. 中等(功能异常或越权,但触发条件较窄) ### 2.1 教师名单导入会把管理员静默降级成教师 **位置**:`packages/server/src/modules/imports/table-import.service.ts:895-900` ```ts /** 角色列解析:只认「管理员」类词;其余(含空)返回 null 交给调用方取默认值 */ function parseTeacherRole(cell: string): 'TEACHER' | 'ADMIN' | null { const value = cell.trim().toUpperCase(); if (!value) return null; if (value === 'ADMIN' || cell.includes('管理员') || value === '管理员') return 'ADMIN'; return 'TEACHER'; // ← 与注释矛盾 } ``` 文档说「其余(含空)返回 null」,实现却是 `return 'TEACHER'`。而调用处(`:653`)是 `parseTeacherRole(roleCell) ?? existing.role` —— `null` 永远不会出现 ⇒ **任何不认识的角色单元格(包括空单元格)都会把已有账号改成 TEACHER**。 **触发**:默认 `mode` 就是 `'upsert'`(`imports.schemas.ts:25`)⇒ 管理员照模板导一份教师名单、角色列没填 → 自己(或其他管理员)变成普通教师。 **修法**:`return null`(把非空但不认识的值也归为 null,或在调用处区分「空」与「不认识」)。 ### 2.2 六个权限键在界面上可分配,服务端从不校验 6 个键写在 `packages/shared/src/permissions.ts:184-208`,也出现在权限组勾选树(`:284 / :295 / :300`),但把服务端所有 `requirePermission` / `requireAnyPermission` / `hasAuthPermission` 调用点扫一遍后,**这 6 个键一次都没出现**: `student:grade-query`、`dashboard:read`、`update:check`、`update:force`、`log:read`、`developer:mode` **后果**:权限组的语义被破坏。给某位老师分配「只含 `dashboard:read` 的组」后,他照样能调 `/api/grades/student/:studentNo`(`grades.module.ts:44` 刻意不挂键)、`/api/dashboard/*`、`/api/update/check`。管理员在界面上勾的框不生效——比「没有这个功能」更难发现。 `update:force` 尤其明确:意图是「只有勾了它的人能绕过缓存」,实现却是 `update.module.ts:33` 的 `user.role === 'ADMIN'` 硬判。 **修法**:要么把键挂到对应路由上,要么从 `PERMISSION_KEYS` / 勾选树里下线(现在这样是"看起来能管、实际管不了")。 ### 2.3 socket 侧重建班级端身份时丢了 `classSession` **位置**:`packages/server/src/realtime/socket.ts:57-63`(过滤 token)→ `:76-82`(拼回 `TokenPayload`) ```ts socket.data.user = { id: payload.classSession ? (payload.classId ?? payload.sub) : payload.sub, username: payload.classSession ? (payload.classCode ?? payload.username) : payload.username, name: payload.name, role: payload.role, classId: payload.classId, // ← 丢了 classSession }; ``` `getAccessibleClassIds`(`lib/access.ts:74-90`)判班级端时走 `classDeviceClassId()` → `isClassDevice()`(看 `role === 'CLASS_DEVICE'`)与 `classSessionClassId()`(看 `user.classSession`)。第二条判据恒假 ⇒ 返回 `[]`。 **两个后果** 1. 教室机器显式 `class:join` 本班房间会被拒(`socket.ts:111` 的 `allowed.includes(classId)` 恒假,报「无权订阅该班级」); 2. **教师/管理员每次建立 WS 连接都会执行 `listClassStudentIds`**(`lib/session.ts:27-33`),把整班学生 id 全拉出来,只为了算出一个空集合。 `:55-56` 的注释直接指着本文件说「房间据此加入」,实际那段逻辑不参与判断。 **修法**:`socket.data.user` 里带上 `classSession: payload.classSession === true`,或在 `getAccessibleClassIds` 改用 `role` 单一判据。 ### 2.4 导入课表把学期长度写死成 20 周 **位置**:`packages/server/src/modules/imports/class-plan.service.ts:301-303` ```ts // 时间配置(导入的时间表)里如果只有部分节次,weekEnd 用默认 20 周 const weekStart = input.termStartWeek ?? 1; const weekEnd = 20; ``` 学期周数现在是可配的(`Class.termWeeks`,上限 30)。班级设 30 周时,导进去的课表到第 20 周就没了,**第 21–30 周在教室大屏上是空白**。 **修法**:读 `Class.termWeeks`(与 `schedules.service.ts:108-114` 的 `createSchedule` 同一口径)。 ### 2.5 建课表的交叉校验有缺口(schema 按 20 兜底、服务端按班级配置落库) **位置**:`packages/server/src/modules/schedules/schedules.schemas.ts:50` ```ts .refine((data) => (data.weekStart ?? 1) <= (data.weekEnd ?? 20), { ... }) ``` 而 `createSchedule`(`schedules.service.ts:126-127`)真正落库用的是 `input.weekEnd ?? termWeeks`。 **触发**:`termWeeks = 10` 的班级可创建 `weekStart = 15, weekEnd = 10` —— schema 判它合规(15 ≤ 20 通过),落库得到一条**永远不生效**的课表行。`updateSchedule` 那条路径有合并后校验(`:158-160`)兜住,创建这条没有。 **修法**:schema 里去掉 20 这个魔数(只校验上下界),把交叉校验交给 service —— 与 `updateSchedule` 同一做法。 ### 2.6 成绩导入按姓名匹配时,同名学生的成绩会张冠李戴 **位置**:`packages/server/src/modules/imports/table-import.service.ts:337` ```ts const byName = new Map(students.map((item) => [item.name.toLowerCase(), item])); ``` 同班同名学生的后一条会**覆盖**前一条。`byStudentNo` 查不到时落到 `byName`(`:373-375`),于是成绩会绑到其中一个人身上,**且没有任何提示**。 **修法**:命中重复姓名时不入 map,在用到时给出「该班有 N 个同名同学,请填写学号」的行级错误。 ### 2.7 通知删除不广播,教室机器上会一直留着已删的通知 **位置**:`packages/server/src/modules/notifications/notifications.service.ts:242-247` ```ts export async function deleteNotification(user, notificationId): Promise { const current = await prisma.notification.findUnique({ where: { id: notificationId } }); if (!current) throw ApiError.notFound('通知不存在'); await assertCanPublishNotification(user, current.classId); await prisma.notification.delete({ where: { id: notificationId } }); // ← 没有 emitToClass } ``` **后果**:教师在 Web 端删掉通知后,教室大屏的通知列表不会刷新,会一直显示这条已删除的记录(直到用户手动下拉或重连)。 **修法**:删完 `emitToClass(current.classId, SOCKET_EVENTS.notificationDeleted, { id })`,并同步给两端订阅方(新增事件要同时改服务端 + Web 端 + 客户端,见 AGENTS.md §5 第 63 条)。 ### 2.8 批量录成绩只让第一个班的客户端知道 **位置**:`packages/server/src/modules/grades/grades.service.ts:190-196` ```ts if (results[0]) { emitToClass(input.classId, SOCKET_EVENTS.gradeUpdated, { ...results[0], bulk: true, count: results.length }); } ``` 一次批量跨多个班级时,**只有第一个班**房间收到事件,其他班教室机器的成绩页不刷新。 **修法**:按 `classId` 分组广播(或逐个 `emitToClass(dto.classId, ...)`)。 ### 2.9 读接口挂在写权限键上 - `packages/server/src/modules/students/students.module.ts:36`:`GET /api/students` 挂 `requirePermission('roster:manage')` - `packages/server/src/modules/teachers/teachers.module.ts:30`:`GET /api/teachers` 挂 `requirePermission('teacher:manage')` 教师默认有这两个键,所以日常不暴露;但自定义权限组一多就会撞上——按权限键的命名语义申请「只读名单」的组会直接 403。 **修法**:拆出 `roster:read`,或像 grades 的两条读接口那样把键摘掉(边界交给 service 的 `resolveClassScope`)。 ### 2.10 调班历史没有校验学生与班级的关系 **位置**:`packages/server/src/modules/students/students.service.ts:247-249` ```ts if (!isAdmin(user) && !options.classId && !options.studentId) { throw ApiError.forbidden('查看全部调班历史需要管理员权限'); } ``` **触发**:教师只要**提供某个 studentId**(而不是 classId)就能读它的调班历史,学生属于哪个班并不校验。教师之间本来互不越界,这里是个口子。 **修法**:走 `studentId` 分支时补一次 `assertClassAccess(user, student.classId)`。 ### 2.11 深色模式下引导正文用了从未定义的 CSS 变量 **位置**:`packages/desktop-client/src/renderer/components/OnboardingWelcome.vue:253` ```css color: var(--ch-text-regular, #606266); ``` 全客户端 grep:只定义了 `--ch-text` / `--ch-text-secondary` / `--ch-text-tertiary`,**`--ch-text-regular` 一次都没有定义**(浅色 `styles/index.css:41` 区块与深色 `:126` 区块都没有)。 **后果**:深色主题下正文恒定落到 `#606266`,而卡片底是 `--ch-layer-solid`(深色为 `#1e1f23`)⇒ **六步引导的文字几乎看不清**。 **修法**:改用 `--ch-text-secondary` 或 `--ch-text-tertiary`。 ### 2.12 随便按一下 Esc 就把「已看过首次引导」写掉了 **位置**:`packages/desktop-client/src/renderer/components/OnboardingWelcome.vue:104-109` ```ts function onKeydown(event: KeyboardEvent): void { if (event.key === 'Escape') finish(); // ← 没判引导是否可见 } if (typeof window !== 'undefined') window.addEventListener('keydown', onKeydown); ``` 组件随 `App.vue:165` **常驻整个应用生命周期**,而 `finish()`(`:91-102`)会 `saveConfig({ onboardingDone: true })`。 **触发**:新机器首次启动,用户在登录页随手按 Esc(或关掉任意弹窗)⇒ 引导标记被写掉 ⇒ 首次启动引导**再也不自动弹出**(`App.vue:128-135` 的判定失效)。 **修法**:`if (!onboarding.visible) return;` ### 2.13 成绩明细表的「等级」列把老师手改/自定义等级算回去 **位置**:`packages/desktop-client/src/renderer/views/GradeView.vue:385-389` ```vue