跳转到内容

KEC 课程管理平台 · 架构级代码审查报告 ​

  • 审查日期:2026-09-14
  • 代码版本:v1.23.1(分支 main,HEAD 2d045c6)
  • 审查范围:server/(Express 5 + Prisma + SQLite)、client/(Vue 3 + Element Plus)、miniprogram/、部署配置
  • 审查方式:全部结论基于实际代码阅读 + 生产 schema 比对 + 开发库 SQL 实测(Node node:sqlite 只读查询)+ 完整测试套件执行(82 文件 / 1979 用例全绿,8.99s)。推测性结论已剔除;两处一度怀疑的问题经查证不是 bug,见「已核实无问题」。

执行摘要 ​

这是一个工程质量明显高于同类内部系统的项目:安全基线(认证 / CSRF / 上传 / 脱敏)扎实,学期与合班口径做过系统性收敛,排课算法有事务内二次校验与并发锁。

主要风险不在"写得差",而在三类静默失效:

  1. 一段跨表同步逻辑因学期字段格式错配而永久空转(C-2);
  2. 合班一致性在 auto-arrange 路径存在缺口,且本应兜底的检测器生产未接线(H-1);
  3. 部署配置里的占位密钥能通过全部生产强度校验(C-1)。

这三类都不会抛错、不会被测试发现,只会让数据悄悄不对。


健康度评分 ​

维度评分说明
正确性风险6/10排课主链路稳健;扣分项为 2 处静默失效 + 1 处潜伏跨学期合并
类型 / 不变量安全6/10semester 双格式(Int 学期号 vs String 学年串)无类型区分,是 C-2 的根因
可观测性8/10日志分级、脱敏、审计齐全;但"同步 0 条"被当成正常结果记录
测试覆盖7/101979 用例、核心算法覆盖好;缺口在 mock 掩盖真实 where 条件、合班 × 锁定交叉场景
可维护性7/10修复注释带编号可追溯;扣分项为 4 个 2000+ 行文件
安全8/10无 CRITICAL 级代码漏洞;唯一高危来自部署配置回退值

CRITICAL — 建议本周处理 ​

C-1 生产部署可用仓库内公开占位串作为 JWT 密钥 ​

位置:docker-compose.yml:38-40 + server/src/config/auth.config.js:39-88(已逐项验算)

三个密钥全部回退到同一字面量:

yaml
- JWT_SECRET=${JWT_SECRET:-please_change_this_to_random_string}
- JWT_REFRESH_SECRET=${JWT_REFRESH_SECRET:-please_change_this_to_random_string}
- JWT_DOWNLOAD_SECRET=${JWT_DOWNLOAD_SECRET:-please_change_this_to_random_string}

按 auth.config.js 的校验逻辑手工验算,该串:

  • 长度 35 ≥ 32 → 通过 validateSecretStrength
  • 唯一字符 16 个 → 熵 35 × log₂16 = 140 bits ≥ 128 → 通过 checkSecretEntropy
  • 不在 PLACEHOLDER_SECRETS 集合内(该集合只含 change-me / secret 等 5 项)

而 docker-compose.yml:34 已设 NODE_ENV=production。

后果:.env 存在但漏配这三个变量时,容器通过全部生产强度校验正常启动,而密钥是公开仓库里的明文 → 任何人可离线签发 role=super_admin 的 JWT,完全接管系统(含用户管理、数据重置)。三个密钥同值还意味着拿到 access 密钥即可推导 refresh / download 语义。

同类:server/.env.production.example:18-20 的 your_generated_hex_string_here_minimum_32_characters 同样能过检。

修复:

  1. PLACEHOLDER_SECRETS 改为正则黑名单,纳入 please_change_this、your_generated_hex_string_here、change_me 等模式;
  2. compose 去掉 :- 回退,改为 ${JWT_SECRET:?JWT_SECRET must be set},让缺配直接启动失败。

影响面:仅影响部署启动路径,不触碰任何业务代码。风险是现存漏配环境会从"静默运行"变为"启动失败"——这正是期望行为,但上线前需先确认生产 .env 已配齐,否则会中断服务。建议先在服务器执行 docker compose config | grep JWT 核对。


C-2 培养方案改周课时后,排课快照同步永久空转 ​

位置:server/src/controllers/plan/plan-matrix.controller.js:543-561(已复核 + 确认端点可达 + 确认测试无法发现)

js
const updateResult = await prisma.teaching_assignments.updateMany({
  where: { course_id: planCourse.course_id, semester: String(sem.semester), is_locked: false },
  ...

sem.semester 来自 plan_course_semesters.semester,schema 中是 Int(相对学期号,如 3);而 teaching_assignments.semester 是 String(学年串,如 "2025-2026-1",schema.prisma:341)。String(3) → "3" 永不匹配任何行。

同一错误在 :557 的 skippedLockedCount 统计上重复一次,因此 :580 的分支恒走 else,用户只看到"更新成功",永远不会看到"已同步 N 条排课"或"N 条锁定记录需手动处理"的提示。BIZ-M2 这个修复标记本身即是失效的。

为什么 1979 个测试没抓到:plan-matrix.controller.test.js 全量 mock Prisma(:21、:40),且只对 plan_course_semesters.updateMany 断言(:341, 368, 450, 481),没有任何用例断言 teaching_assignments.updateMany 的 where 条件。mock 返回 {count: 0} 与真实"永不命中"表现一致。

后果:管理员在方案矩阵把《大学英语》周课时从 4 改为 2 后,已存在的 teaching_assignments 行仍存 weekly_hours=4。而该快照值被以下链路消费:

dedupeTeachingUnits → getTeachersForCourse 的 workloadMap(arrange/queries.js:285-302)→ buildTeacherConstraints 的 effectiveTotal(auto-arrange.js:260)

即教师工作量按过期课时计算,虚占容量,导致后续排课被误判超限而拒绝分配。同时首页 assignedWeeklyHours(快照口径)与安排概览 assignedHours(方案当前值口径,queries.js:210-215 明确不用快照)长期矛盾。

修复:不能直接用学期号匹配。需按班级 enrollment_year + calcClassSemester 反查出"该相对学期号对应的学年串集合",再 semester: { in: [...] };且两步写(update 方案 + updateMany 排课)应并入 $transaction(当前非事务,失败会留下方案已改、快照未改的中间态)。

影响面:仅 PUT /api/plans/semesters/:id 一个端点(前端唯一调用方 client/src/composables/useCourseMatrixEditing.js:98),无其他调用者。修复后行为会从"永远同步 0 条"变为"真实同步 N 条",即历史遗留的过期快照会在下次编辑该学期课时时被批量纠正——建议修复后单独跑一次全量校准脚本,而不是依赖管理员逐个点击。


HIGH — 建议本迭代处理 ​

H-1 合班一致性:auto-arrange 会拆散"部分锁定"的合班组 ​

位置:server/src/services/arrange/auto-arrange.js:1315-1318, 1345-1347(已完整验证触发路径)

排除逻辑是逐班的,不是逐合班组的:

js
const manualClassIds = new Set([
  ...manualAssignments.map(a => a.class_id),
  ...lockedAssignments.map(a => a.class_id),
]);
const classesToAssign = classes.filter((c) => !manualClassIds.has(c.classId))...

而 mergeCombinedClasses:648-650 明确写着"单成员组合退化为独立班"。

触发序列:

  1. 合班组 {A, B} 同开课程 X;首轮自动排课 A 成功、B 因容量不足进 unassigned(补漏轮 的存在正说明这是常态);
  2. 用户锁定 A。toggleLock:1402-1421 确实会联动合班伙伴——但它只查已存在安排行的伙伴(:1411-1419),B 无行 → 只锁了 A;
  3. 再次自动排课课程 X → classesToAssign = [B] → B 单成员退化为独立班 → 被分配给 T2;
  4. 落库:A → T1(锁定) + B → T2(自动)。同一节合班课两个教师。

证据链:

  • optimize.js:593-602 对同一问题有完整防护("含手动/锁定成员的组合整体钉住"),并有测试:optimize-hard-constraints.test.js:149「优化不应拆散合班」、optimize.test.js:1088「变更将拆散合班时应拒绝应用」;
  • 而 auto-arrange 既无防护也无对应测试;
  • 更关键的是,本应兜底的检测器 validate.js:33 validateCombinedClassConsistency 生产代码零调用点(grep 确认:除自身单测外无任何引用)——即这类脏数据产生后系统完全无感。

次生数据损害:dedupeTeachingUnits 的键含 teacher_id,A(T1) 与 B(T2) 会形成两个逻辑单元 → 这节课的周课时在 T1、T2 名下各计一次 → 教师工作量与全校总课时虚高(正是 commit db00153 那次修复想消除的翻倍问题,以另一种形式复发)。

修复(推荐方案 b,与既有语义一致):

  • (a) 最小改动:构建 manualClassIds 时,若某合班组任一成员命中,则把该组全部成员加入排除集。
  • (b) 更正确:把未命中成员继承已锁定 / 手动教师(镜像 toggleLock:1423 与 assignTeacher:423-443 的既有联动语义),因为合班的物理含义就是同组同师同课。
  • 同时把 validateCombinedClassConsistency 接入排课结果的 warnings,让未来的任何分歧显式暴露而非静默。

影响面:方案 (a) 会使 buildResult 的 totalClasses(auto-arrange.js:532,取自 classesToAssign.length)分母变小,前端"已安排 X/Y 个班级"的 Y 随之减小;被整组排除的成员班既不在 assigned 也不在 unassigned,建议单独回传一个 skippedByCombination 列表,否则用户会以为漏排。方案 (b) 不改变分母,但会新增安排行,需确认 manualCount(:2264)统计口径同步调整。不影响 optimize.js、batch.js 的对外契约。


H-2 dedupeTeachingUnits 归并键缺 semester → 教师历年课时跨学期误合并(潜伏) ​

位置:server/src/services/teaching-statistics.service.js:37 + server/src/controllers/teaching-arrange.controller.js:1774(已复核逻辑 + 实测当前数据)

键为 `${combId ?? classId}|${course_id}|${teacher_id}`,不含学期。而 buildHistoricalStatisticsData 的 where(:1736-1748)在不传 start_semester / end_semester 时不加任何学期条件,跨全部学期取数,随后 :1774 对整个多学期集合一次性 dedupe。

同班 + 同课 + 同师跨两学期 → 合并为 1 单元 → semesterMap(:1798-1811)按 representative.semester 归组,而 orderBy: [{semester: 'desc'}](:1770)使 representative 恒为最新学期 → 较早学期的该课程课时被静默丢弃。

现状核实(实事求是):直接查询开发库:

sql
select distinct semester from teaching_assignments;  -- 仅 1 个值:'2026-2027-1'
-- 跨学期碰撞元组数 = 0
-- 536 行在加/不加 semester 两种口径下单元数均为 536

所以当前没有产生错数——这是潜伏缺陷,不是现存事故。

但触发几乎是必然的,两个独立机制都在主动制造这种元组:

  • plan_courses 有 start_semester / end_semester 区间,多学期连排课程(英语 / 语文 / 数学)本就是职业院校常态,schema 的 @@unique([class_id, course_id, semester]) 明确允许同班同课多学期各一行;
  • 「固有班级延续」功能(batch.js:124-161,INHERENT_CLASS.CONTINUITY_WEIGHT = 8,注释称"使延续成为常规首选因素")的设计目标就是让同教师同班级同课程跨学期延续。当前 ENABLED: false 但可经 system_settings 动态开启。

修复:unitKey 加入 a.semester(一行改动)。其余 12 处调用方均为单学期输入,加字段后其结果完全不变——这是零风险修复。若担心影响面,替代方案是仅在 buildHistoricalStatisticsData 内先按学期分组再逐组 dedupe。

顺带同源问题::1792 用 rawAssignments[0].semester(仅最新学期)解析全部历史学期的教材,旧学期教材列必然错误 / 为空。修复需按学期分组各自调用 resolveClassCourseTextbooks。此函数被 data-export.controller.js:1447 exportHistoricalHours 复用,两处一并修好。


H-3 /login、/refresh 响应体明文返回 refreshToken ​

位置:server/src/routes/auth.routes.js:181, 229

与前端"S-01:refresh 仅经 HttpOnly Cookie"的设计意图矛盾。登录窗口期若存在任一 XSS,攻击者可 hook fetch 读取 body 中的 7 天期 refresh token,实现持久驻留(HttpOnly 防护被绕过)。

修复:浏览器渠道仅经 Set-Cookie 下发,body 不含 token;小程序渠道(无 Cookie 能力)保留 body 返回,按 User-Agent 或独立端点区分。

影响面:会破坏小程序登录,必须同步改 miniprogram/utils/request.js:16-33 的取值路径,两端需同版本发布。


H-4 读取系统设置的鉴权绕过吊销校验 ​

位置:server/src/controllers/settings.controller.js:35-72

tryGetAuthUser 只验签名 + is_active,不查 token_blacklist、不比对 token_version → 已登出 / 已重置密码的令牌在剩余有效期内仍能读取设置。而 auth.middleware.js:160-167 的完整校验是能做到的。

修复:复用 authMiddleware 的校验链。

影响面:设置为低敏数据,收紧后仅影响"已吊销令牌"这一非法路径,无正常调用受损。


MEDIUM — 计划处理 ​

#位置问题影响面评估
M-1controllers/import/classes.js:188-300事务内写入 duration_years(:275, 288)后未调 invalidateDurationCache()。grep 确认 class.controller.js 在 518 / 671 / 753 / 853 / 1035 五处都调了,唯独导入路径漏。getActiveClassFilter(semester.service.js:244-254)的 5 分钟 TTL 缓存只存 distinct duration_years,首次导入某新学制的班级(实测库中确有 duration_years=5 的 5 个班)在最长 5 分钟内对开课查询 / 首页统计 / 排课应排列表完全不可见一行修复,零风险。仅缩短陈旧窗口,不改变稳态结果
M-2services/arrange/auto-arrange.js:2293-2299事务内容量二次校验左侧用未合班去重的 groupBy _sum weekly_hours,右侧 cap + t.effectiveTotal 却是去重值(:260 源自 dedupeTeachingUnits)→ 合班教师负载虚增 N 倍 → 误判"并发导致容量已满"而 overloadSkipped 跳过分配(:2377-2384)改用 dedupe 后排课成功率会上升(此前被误跳的班级能排上)。属纠正性变化,需回归 auto-arrange 全部 16 个测试文件
M-3services/arrange/batch.js:220-228注释写"按教师聚合本学期已排课时(跨课程,去重合班)",代码是裸 groupBy _sum,并未去重 → 供需比失真。确认它只驱动 coursePriorities 排序(:279-287),硬上限另由去重路径计算,故 :231-232 的"仅影响排序"结论成立修复只改变批量排课的课程处理顺序,最终分配结果可能因顺序不同而变化(非确定性来源)。建议连同注释一起改,避免误导后续维护者
M-4services/arrange/queries.js:186-218 + dashboard.controller.js:274-277getCourseOverviewAggregate 对每门课程调 getClassesWithCourse(各 4 次查询 + 对 218 个班逐个 findBestMatchPlan,每次内部都 [...plans].sort())。首页并行调两次(当前 + 上学期)且无服务端缓存。实测规模:10 课程 × 218 班 × 2 ≈ 4360 次方案匹配排序 / 请求。当前可接受;课程库扩到 100 门将变约 43600 次,成为首页瓶颈。另::186 不加过滤,无 plan_courses 的课程也付全额代价(:121 先排序匹配,:125 才发现 pc 为空)加"planCourses.length === 0 提前返回"零风险;把 classes / allPlans 查询提到循环外需新增内部批量加载器,不要改 getClassesWithCourse 签名(被 optimize.js、多个 controller 依赖)
M-5services/arrange/batch.js:486, 510补漏轮 F8 守卫用 <:预览结果等于主轮时仍执行真实重排 → 无收益的 DB 重写 + 教师分配无谓变动。且 :510 非预览回退只记 warn,DB 已被更差结果覆盖(:2279 的 deleteMany + createMany 已提交),无回滚把 :486 的 < 改为 <= 是一字符修复、语义安全(等量即无补漏收益)。:510 属单向门,只能靠 F8 前置守卫,建议加强而非事后补救
M-6teaching-arrange.controller.js:889-908deleteAssignment 的合班级联删除不检查伙伴行 is_locked → 可通过删主班绕过锁定,连带删除已锁定的伙伴安排加锁校验后,此前"能删"的合班锁定行会返回失败。需前端配合提示,否则用户感知为功能回退
M-7data-export.controller.js:417 vs query.controller.js:602-603教材使用导出用 isClassMatchPlan(维度匹配即纳入),页面用 bestPlan.id === plan.id(最佳匹配)→ 导出含"幻影班级"、学生数虚高,与页面数字对不上导出改用 bestPlan 判定后导出行数会减少。这是纠正,但会让用户看到与历史导出不同的数字,需在发布说明中告知
M-8services/arrange/queries.js:192-202概览聚合的 assignments 查询未过滤 teacher.status='active' 与 weekly_hours > 0(已核对 select 字段),而 dashboard.controller.js:162-264、teaching-query.service.js:77 都过滤了 → 概览的 teacherCount / assignedCount 含停用教师与 0 课时行。这与该函数注释宣称的"保证两处口径完全一致"(:179-180)自相矛盾补过滤后概览数字会下降。因首页与安排页共用此函数,两处同时变化、保持一致,无新的分歧
M-9services/class-combination.service.js:69-158建立合班时不校验 / 同步既有排课:两成员班"同课不同师"的安排行都保留 → dedupe 键含 teacher_id → 同组合产生 2 个单元 → 工作量双计(与 H-1 同一后果,不同入口)。拆分合班时也不回收安排行与 H-1 应一并设计。加冲突检测后,建立合班会开始报错 / 告警——这是必要的,但需给管理员提供"合并到指定教师"的处置入口,否则会被卡住
M-10app.js:80, 91, 134isDev = NODE_ENV !== 'production' 反向推断:NODE_ENV 漏配时全局限流整体 skip、CORS 放行任意 localhost 源且 credentials: true。与 C-1 同源风险(都依赖 NODE_ENV 正确)改为显式 ENABLE_DEV_MODE 开关。注意与 C-1 的交互:compose 已硬编码 NODE_ENV=production,故容器部署不受影响;受影响的是裸机 / pm2 部署(deploy.sh)

LOW(择机处理) ​

  • utils/excel.js:112-158:解压 xlsx 不限解压后总体积,10MB 上限内可构造解压炸弹(需 admin+ 权限,风险有限)
  • prisma/seed.js:30:未设 ADMIN_INITIAL_PASSWORD 时用公开口令 admin@123456(有 must_change_password 门禁兜底,但首登前存在窗口)
  • optimize.js:459-469, 1044-1054:读 DB 课时配置未调 validateHourSettings(auto-arrange.js:1259 调了,写入侧 :1351 也调了,仅存量脏数据有风险)
  • useAutoArrange.js / useBatchArrange.js:有 cancelArrange 但无 onUnmounted → 排课 SSE 进行中离开页面,流不中止(兜底 7 分钟超时)
  • request.js:93-96:并发 401 刷新失败时每个等待请求各弹一次提示并各调 logout(无去重)
  • stores/classData.js:53:_relationsLoaded 置 true 后永不重置,增删学院 / 专业后筛选联动可能用到陈旧映射直至整页刷新
  • client/src/composables/useSemesters.js:42-54:本地降级在 semesterStartMonth=1 时恒判秋季(后端 class.controller.js:160-182 同类降级已文档化为 B-04)

组件与大文件评估 ​

后端 4 个大文件 ​

文件行数结论理由
services/arrange/auto-arrange.js2437暂不拆autoArrange 单函数跨 1240-2422(约 1180 行)确实是 god function,但它是一条有严格顺序依赖的五阶段流水线,共享 teacherConstraints / globalTextbookMap / classTextbookMap 等大量可变状态。现在拆分会迫使这些状态变成跨模块参数或闭包,复杂度是转移不是消除,且该文件有 16 个测试文件覆盖、修复注释密集,重构风险高于收益。建议只在下次改动该文件时顺带按阶段抽内部函数(不导出、不改契约)
controllers/teaching-arrange.controller.js1919建议拆19 个导出 handler,职责已按 REST 资源自然分块,拆分收益真实。可按 assign / lock / statistics / history 拆为 4 个文件,纯移动无逻辑改动,工作量 M,风险低(teaching-arrange.service.js:5-7 已是再导出层,路由 import 路径不变)
controllers/export/data-export.controller.js1528建议拆16 个导出函数彼此独立,适合按导出对象拆分,工作量 M,风险低
services/arrange/optimize.js / tabu-search.js1257 / 890不拆算法内聚

前端大文件 ​

唯一"拆分真能降复杂度"的:views/teaching/TeachingStatistics.vue(674)——它与 views/query/composables/useTeacherLoadQuery.js 高度同构(姓名防抖筛选 :391-394 vs :84-90、Set 去重选项 :422-458 vs uniqSorted、前端切片分页 + watch 重置页码 :485-494 vs :163-179)。建议抽 useTeachingStatistics.js 并复用既有 composable,工作量 M,风险低(有既有范式可抄)。

不建议拆(行数不来自复杂度):

  • Login.vue(1265):静态 SVG 插画占模板 21-359、样式 520 行,script 仅 190 行且内聚。抽 SVG 组件只搬代码不减复杂度
  • CourseMatrixTable.vue(910):样式 590 行;逻辑已下沉 useMatrixCalculations,教材网格已预计算(:267-296),props / emits 边界干净
  • Layout.vue(819):双 style 块 535 行、script 仅 90 行,菜单已抽 SideMenuContent
  • TeachingArrange.vue(759):已拆 5 个 composable + 13 个子组件(8 个异步加载),本体是纯协调器并已含请求序号防竞态(:498, 594)
  • Dashboard.vue(689):已拆 5 个洞察卡组件 + useCountUp + getWithCache,样式 380 行
  • useClassList.js(687):批量段(:351-530)与列表段共享 load / selectedClasses / invalidateClassOptions,拆分会产生双向耦合
  • TeacherLoadQuery.vue(664)、UnifiedSemesterQuery.vue(642):前者逻辑已在 composable、模板为主;后者已用 useSemesters / useFilterLinkage / FilterBar / getWithCache,内聚合理

真实重复实现(值得收敛) ​

  • MEDIUM:CourseList.vue:343-399、TextbookList.vue:397-449、TeacherList.vue:~414 三处 handleDelete / confirmDelete / cancelDelete 与 useCrudList.js:129-163 逐行同构(silent 删除 + ElNotification 文案),而 useCrudList 目前只被 3 个简单字典页使用——这是既有抽象未被采用,不是缺抽象
  • MEDIUM:小程序 pages/plan-query/plan-query.js:180-229 课程分组 / 小计 / 总计与 web useMatrixCalculations 语义重复(跨端无共享包,属可接受成本,但需同步维护口径)
  • LOW:.combined-group-no / .combined-tag / wrap-cell 样式在 TeachingStatistics:634-666、TeacherLoadQuery:634-663、TeacherHoursArchive:524-543 三处逐字重复,可上提 global.css(项目已有此先例)
  • LOW:textbook-tooltip 非 scoped 样式在 CourseMatrixTable:860-910 与 CourseQuery:512-582 重复(注释已自认)
  • LOW:TextbookList 的 load / silentReload(:336-375)同文件内两份相同 params 构造

已核实到位之处 ​

以下逐项读代码确认,不需要动。

安全基线(无 CRITICAL 级代码漏洞) ​

  • 认证:JWT_SECRET 缺失直接拒绝启动(auth.config.js:9-16);HS256 显式指定算法;access 15m / refresh 7d;token_version 吊销机制(auth.middleware.js:160-167);refresh 轮换 + 黑名单 + 重用检测触发全会话吊销(auth.service.js:237-257);黑名单查询 DB 故障时 fail-close(:335-338);bcrypt 12 轮;用户不存在时走恒定时间比较防枚举(:22, 54);登录三层限流(10/IP + 3/用户名)+ 5 次失败锁 15 分钟
  • CSRF:HMAC 签名 Double Submit + timingSafeEqual(utils/csrf.js:21-50)+ Cookie SameSite=Strict + httpOnly;Bearer 放行逻辑自洽
  • 授权 / IDOR:users / audit / documents = super_admin(app.js:185, 213, 216),import / export / 全部写操作 = admin+(按 18 个路由文件逐条核对无遗漏);数据重置需 confirm='DELETE' + 5 次/小时限流;用户操作含防自改角色 / 防删超管(user.controller.js:140, 214, 277, 349);viewer 角色教师 PII 服务端脱敏(teacher.controller.js:147-153)。业务数据为全局参考数据、无按用户归属资源,IDOR 面已核实不存在
  • 注入:$queryRawUnsafe 仅用于固定 PRAGMA 字符串(lib/prisma.js:11-13);$executeRaw 全模板参数化;仅有的两处动态 orderBy 都有白名单(teacher.controller.js:107-119、textbook.controller.js:42-56)。已核实无问题
  • 上传 / 下载:扩展名白名单 + 魔数校验 + 随机存储名 + path.basename 防穿越(documents.controller.js:38-52, 66-68, 113-135);上传目录未被静态托管(唯一 static 是 client/dist);下载票据 24B 随机 / 30s / 一次性 / 不可复用(download-ticket.service.js)。已核实无问题
  • 其他:错误处理生产不泄堆栈(error.js:41 需 localhost + DEBUG_ERRORS);日志脱敏密码 / 身份证 / 手机号(validation.js:23-33、app.js:108-111、import/teachers.js:205);Excel 公式注入防护(excel.js:10-18);.gitignore 排除 .env 且 git 索引确认未提交真实密钥;deploy.sh:209-211 生成 64B 随机密钥;前端全项目 0 处 v-html、token 仅存内存、client 与 miniprogram 均无硬编码密钥

两处一度怀疑、经查证不是问题(避免误导后续维护者) ​

  1. queries.js:19 isTextbookMatch 对空 textbookIds 返回 false(注释却写"不需要匹配"),与 isTeacherEligible:183 的放行语义相反。追全部调用点后确认它只用于软性评分与诊断(:87 打分、:397 失败诊断、:458 匹配率),从不作为硬门禁;硬门禁是 isTeacherEligible,语义正确。且 calcAllMatchRates:441-443, 456-459 有 F17 修复专门把无教材班级排除出分母——说明作者已意识到这个空集语义并做了补偿。仅注释措辞与代码相反,建议改注释,不改逻辑。

  2. 多处 classes 查询带 status: { not: 'archived' },而 getActiveClassFilter 不带,看似口径分歧。追全部写入点后确认:classes.status 只会被写成 active / graduated / left_school(import/classes.js:132-143、class.controller.js:253-265),从不写 archived,且 class-filter.service.js:62-64 已明确注释"班级 status 状态机不存在 inactive 等取值"。故那些过滤是恒真的防御性死条件,不产生分歧。属清理项,非 bug。

架构层面做对的 ​

  • 学期计算完整收敛到 semester.service.js(10 个文件 14 处调用),服务端无任何日期硬编码推算;parseSemester 严格校验(拒绝小数 / 杂尾 / 跨年 / 越界);calcClassSemester 有 duration_years 守卫;getActiveClassFilter 显式接受查询学期参数,避免"内部读全局学期"导致的学期错位
  • 排课引擎:合班归并有 F16 成员一致性校验(学院 / 层次 / 课时 / 教材签名不符则拆散并 warn,auto-arrange.js:652-669);落库是 $transaction 内 deleteMany + createMany 的全量替换语义(C-3 幂等,:2277-2289)+ 事务内容量与教材上限二次校验,超载项降级跳过并如实归入 unassigned 而非静默丢弃(P1-6 区分两类原因,:2376-2392);并发用进程内 Set + DB 锁双层,DB 锁带 owner 防过期锁误释放(F9,lock.js:61-77);batch.js 的 5 分钟超时短于 lock.js 的 10 分钟锁过期——时序自洽(专门核对过)
  • 方案改版 createPlanNewVersion(:648-804):apply-year 重叠校验 + 源方案收窄 + 深拷贝,单事务
  • 分页一致性:querySemester 与 listClasses(:196-223)的 count 与 data 共用同一 where,JS 侧统一算 valid 集合再分页(query.controller.js:142-250),total 不虚高
  • 口径统一:单学期合班去重在 getStatistics:1118 / exportStatistics:716 / getTeachersForCourse:275 / dashboard:184 / assignTeacher 警告 :462 / optimize.js 全链路一致使用 dedupeTeachingUnits
  • 前端竞态处理质量很高:settings.js:60-104 的 pending-Promise 防重 + 降级重试 + 跨标签页 storage 同步;auth.js:120-147 刷新单点 Promise;request.js:74-87 并发 401 共享刷新;clearAuth 清缓存防公共机器残留
  • 测试基建:composables / utils 几乎全部配套 .spec.js;date.js:30-36 的 getMonth 0-based 处理正确;cache.js 定时器有 pagehide 清理(:114-122);useResponsive / useCountUp / useDebounceFn 均正确 onUnmounted 清理

建议修复顺序 ​

第一批(低风险、高收益,可同批发布) ​

  1. H-2 dedupe 键加 semester — 一行,其余 12 处调用方结果不变
  2. M-1 导入后补 invalidateDurationCache() — 一行,零风险
  3. M-5 补漏轮 < 改 <= — 一字符
  4. C-1 compose 去掉 :- 回退 + 扩充占位串黑名单 — 仅部署路径,上线前先核对生产 .env 已配齐
  5. 修正 batch.js:220、queries.js:19 两处与代码相反的注释

第二批(需回归测试,单独发布) ​

  1. C-2 方案改课时同步排课快照(学期号 → 学年串换算 + 事务化)+ 配套一次性全量校准脚本
  2. H-1 + M-9 合班一致性:整组钉住 / 继承 + 接线 validateCombinedClassConsistency + 补 auto-arrange 的合班 × 锁定交叉测试(当前测试空白)
  3. M-2 + M-3 + M-8 三处合班 / 停用教师口径统一 — 会让排课成功率与概览数字发生变化,需提前告知业务方

第三批(安全加固,需两端协同) ​

  1. H-3(含小程序同版本发布)、H-4、M-10

关于测试策略的建议 ​

C-2 暴露的不是"测试不够多",而是mock 掩盖了真实 where 条件——1979 个用例全绿却漏掉一个永不命中的 updateMany。

建议对"跨表条件匹配"这类逻辑(学期格式、外键关联)补一层打真实 SQLite 的集成测试。项目已有 server/src/__tests__/integration/ 三个先例(auth.routes / documents.routes / textbook.routes),扩展成本很低。这是本次审查中性价比最高的一项流程改进。


总体结论 ​

核心排课算法与业务口径的设计成熟度值得肯定,多数历史坑已有编号修复痕迹和测试兜底。

当前真正需要警惕的是三个不报错的静默失效(C-2 空转同步、H-1 合班拆分、H-2 潜伏跨学期合并),它们的共同特征是"结果看起来正常、数字悄悄不对",且现有以 mock 为主的测试策略结构上无法发现。

建议优先处理这三项并补集成测试,其余按批次推进即可,不必大改。


修复实施记录(2026-09-14 本轮) ​

本节记录本轮已落地的修复。范围遵循指示:不做组件 / 大文件拆分,其余审查报告中列出的问题全部修复;「真实重复实现收敛」不在本轮范围。 验证:服务端全量 vitest run = 83 文件 / 1987 用例全绿;前端套件 554 用例通过。改动均通过 ESLint + Prettier。

CRITICAL ​

#修复内容涉及文件
C-1PLACEHOLDER_SECRETS 由精确匹配改为正则黑名单(覆盖 please_change_this、your_generated_hex_string_here、change_me 等变体);docker-compose.yml 去掉 :- 回退,改为 ${JWT_SECRET:?...} 强制缺配即启动失败;.env.production.example 同步更新为强随机示例config/auth.config.js、docker-compose.yml、.env.production.example
C-2学期号 → 学年串换算:改周课时后按班级 enrollment_year + calcClassSemester 反查该相对学期对应的学年串集合,semester: { in: [...] } 匹配;方案更新与排课 updateMany 并入 $transaction;skippedLockedCount 统计同步修正controllers/plan/plan-matrix.controller.js

HIGH ​

#修复内容涉及文件
H-1排除逻辑由逐班改为逐合班组:合班组任一成员命中手动 / 锁定,则整组钉住(pinnedClassIds);接线 validateCombinedClassConsistency 到排课结果 warnings(此前生产零调用点);新增回传 skippedByCombination,避免整组钉住的成员班被前端误判为漏排services/arrange/auto-arrange.js
H-2dedupeTeachingUnits 归并键加入 semester;buildHistoricalStatisticsData 教材解析按学期分组、各组用各自 semesterInfo,消除「用最新学期信息解析全部历史学期教材」的同源缺陷;教材 idsMap 键以 semester 命名空间隔离,防跨学期覆盖(data-export.controller.js exportHistoricalHours 复用同一函数,一并修好)services/teaching-statistics.service.js、controllers/teaching-arrange.controller.js
H-3/login、/refresh 浏览器通道 refreshToken 仅经 HttpOnly Cookie 下发,响应体不再明文返回;小程序通道按 X-Client-Type: miniprogram 请求头识别并保留 body 返回;小程序 request.js 携带该标识routes/auth.routes.js、miniprogram/utils/request.js、集成测试 auth.routes.test.js
H-4读取系统设置的 tryGetAuthUser 补齐黑名单校验 + token_version 比对,与 authMiddleware 完整验证链对齐controllers/settings.controller.js、测试 getSettings.test.js

MEDIUM ​

#修复内容涉及文件
M-1导入路径事务提交后补调 invalidateDurationCache(),消除新学制班级最长 5 分钟不可见窗口controllers/import/classes.js
M-2事务内容量二次校验左侧改用 dedupe 后口径(与右侧 effectiveTotal 一致),消除合班教师负载虚增导致的误跳过services/arrange/auto-arrange.js
M-3批量排课供需聚合由裸 groupBy _sum 改为 findMany 取带 combination_id 行经 dedupeTeachingUnits 去重后再汇总;注释同步订正services/arrange/batch.js
M-4getClassesWithCourse 在 planCourses.length === 0 时提前返回,避免无方案课程对全部班级付全额匹配排序代价services/arrange/queries.js
M-5补漏轮 F8 守卫由 < 改为 <=(等量即无补漏收益,避免无收益 DB 重写与教师分配无谓变动)services/arrange/batch.js
M-6deleteAssignment 合班级联删除前校验伙伴行 is_locked,命中则抛 ConflictError(409),堵住「删主班绕过锁定」controllers/teaching-arrange.controller.js、测试 teaching-arrange.test.js
M-7教材使用导出改用 findBestMatchPlan(最佳匹配)替代 isClassMatchPlan(维度匹配),与查询页 query.controller.js 同口径,消除「幻影班级」导致的学生数虚高;新增 buildClassPlanMap 预载自定义方案映射,单教材 / 全部教材两模式共用controllers/export/data-export.controller.js、测试 data-export.test.js
M-8getCourseOverviewAggregate 的安排查询补 teacher.status='active' 与 weekly_hours > 0 过滤,与 dashboard / teaching-query 口径一致services/arrange/queries.js
M-9建立 / 更新合班时检测组内既有排课的「同课不同师」冲突,随成功响应回传告警提示人工核对(与 H-1 同源后果,不同入口)services/class-combination.service.js
M-10isDev 由 NODE_ENV !== 'production' 反向推断改为显式 ENABLE_DEV_MODE opt-in 开关app.js

LOW(择机处理,本轮已一并修复) ​

#修复内容涉及文件
LOW-1xlsx 解压前用 JSZip 校验 central directory 解压后总体积,防解压炸弹utils/excel.js
LOW-2seed.js 未设 ADMIN_INITIAL_PASSWORD 时生成随机强密码并打印,替代硬编码 admin@123456prisma/seed.js
LOW(optimize)optimize.js 读取存量 DB 课时配置后补调 validateHourSettings,与 auto-arrange 写入侧一致,脏数据回退默认值services/arrange/optimize.js
LOW-3useAutoArrange / useBatchArrange 补 onUnmounted 中止 SSE 流,避免离开页面后后台继续推送views/teaching/composables/useAutoArrange.js、useBatchArrange.js
LOW-4request.js 并发 401 刷新失败时去重,只弹一次提示 / 只登出一次client/src/utils/request.js
LOW-5classData.js 基础数据(学院 / 专业)变更后重置 _relationsLoaded,避免陈旧映射client/src/stores/classData.js
LOW-6useSemesters.js 本地降级修正 semesterStartMonth=1 恒判秋季的问题(与后端 B-04 同类降级口径对齐)client/src/composables/useSemesters.js

两处注释订正 ​

  • batch.js:220:注释宣称「去重合班」但代码是裸 groupBy,随 M-3 一并订正为与实现一致。
  • queries.js:19(isTextbookMatch):订正「班级无教材返回 false」的语义说明——这是软性评分信号(不参与加分),非硬门禁;硬门禁在 isTeacherEligible,二者语义不同。

配套产物(审查报告建议项) ​

  • C-2 一次性全量校准脚本:server/scripts/calibrate-assignment-hours.js。默认 dry-run,--apply 写库、--verbose 详情;复用 parseSemester + calcClassSemester 将相对学期号映射为学年串,updateMany 未锁定的历史过期快照,报告 skippedLockedCount 与跨方案冲突。已在临时库端到端验证(未锁定 4→2 条纠正,锁定行不动)。建议上线后先跑一次全量校准,而非依赖管理员逐个点击。
  • 真实 SQLite 集成测试(审查报告「测试策略建议」落地):
    • server/src/__tests__/integration/helpers/sqlite-integration.js:createIntegrationDatabase()(每文件独立临时库,prisma db push)、truncateAll、makeRes。
    • server/src/__tests__/integration/plan-semester-sync.integration.test.js:覆盖 C-2 跨表条件匹配 5 个场景(同步命中匹配行、精确映射不误伤其他学期、锁定跳过、事务一致性、空表安全)。
    • 集成测试价值已通过「注入旧缺陷 → 确认变红 → 还原」双向验证(C-2 与 H-2 各一轮)。
    • H-2 亦在 teaching-statistics.service.test.js 补 3 个单元用例(跨学期不合并、同学期合班合并、跨学期合班拆分)。

待办(下次处理,本轮暂缓) ​

  • H-1「合班 × 锁定」交叉测试(auto-arrange):H-1 的代码修复已落地(整组钉住 + 接线 validateCombinedClassConsistency + 回传 skippedByCombination),但审查报告第二批第 7 项建议补的 auto-arrange 合班 × 锁定专项交叉测试尚未编写(当前 auto-arrange.* 测试文件无此覆盖,属测试空白)。本轮按指示暂缓,留待下次补齐。建议用例至少覆盖:
    1. 合班组 {A,B} 首轮 A 成功、B 进 unassigned;锁定 A 后再次 auto-arrange,B 不应被单独分配给其他教师(整组钉住生效)。
    2. 被整组钉住的成员班应出现在 skippedByCombination,且不污染 assigned / unassigned,totalClasses 分母口径正确。
    3. 结果 warnings 应包含 validateCombinedClassConsistency 检出项(若构造分歧场景)。
    4. 与 M-9 建立合班冲突检测联动:既有「同课不同师」脏数据在 auto-arrange 后不应被继续放大(dedupe 单元数不虚增)。