KEC 课程管理平台 · 架构级代码审查报告
- 审查日期:2026-09-14
- 代码版本:v1.23.1(分支
main,HEAD2d045c6) - 审查范围:
server/(Express 5 + Prisma + SQLite)、client/(Vue 3 + Element Plus)、miniprogram/、部署配置 - 审查方式:全部结论基于实际代码阅读 + 生产 schema 比对 + 开发库 SQL 实测(Node
node:sqlite只读查询)+ 完整测试套件执行(82 文件 / 1979 用例全绿,8.99s)。推测性结论已剔除;两处一度怀疑的问题经查证不是 bug,见「已核实无问题」。
执行摘要
这是一个工程质量明显高于同类内部系统的项目:安全基线(认证 / CSRF / 上传 / 脱敏)扎实,学期与合班口径做过系统性收敛,排课算法有事务内二次校验与并发锁。
主要风险不在"写得差",而在三类静默失效:
- 一段跨表同步逻辑因学期字段格式错配而永久空转(C-2);
- 合班一致性在
auto-arrange路径存在缺口,且本应兜底的检测器生产未接线(H-1); - 部署配置里的占位密钥能通过全部生产强度校验(C-1)。
这三类都不会抛错、不会被测试发现,只会让数据悄悄不对。
健康度评分
| 维度 | 评分 | 说明 |
|---|---|---|
| 正确性风险 | 6/10 | 排课主链路稳健;扣分项为 2 处静默失效 + 1 处潜伏跨学期合并 |
| 类型 / 不变量安全 | 6/10 | semester 双格式(Int 学期号 vs String 学年串)无类型区分,是 C-2 的根因 |
| 可观测性 | 8/10 | 日志分级、脱敏、审计齐全;但"同步 0 条"被当成正常结果记录 |
| 测试覆盖 | 7/10 | 1979 用例、核心算法覆盖好;缺口在 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(已逐项验算)
三个密钥全部回退到同一字面量:
- 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 同样能过检。
修复:
PLACEHOLDER_SECRETS改为正则黑名单,纳入please_change_this、your_generated_hex_string_here、change_me等模式;- 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(已复核 + 确认端点可达 + 确认测试无法发现)
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(已完整验证触发路径)
排除逻辑是逐班的,不是逐合班组的:
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 明确写着"单成员组合退化为独立班"。
触发序列:
- 合班组 {A, B} 同开课程 X;首轮自动排课 A 成功、B 因容量不足进
unassigned(补漏轮的存在正说明这是常态); - 用户锁定 A。
toggleLock:1402-1421确实会联动合班伙伴——但它只查已存在安排行的伙伴(:1411-1419),B 无行 → 只锁了 A; - 再次自动排课课程 X →
classesToAssign = [B]→ B 单成员退化为独立班 → 被分配给 T2; - 落库: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 恒为最新学期 → 较早学期的该课程课时被静默丢弃。
现状核实(实事求是):直接查询开发库:
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-1 | controllers/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-2 | services/arrange/auto-arrange.js:2293-2299 | 事务内容量二次校验左侧用未合班去重的 groupBy _sum weekly_hours,右侧 cap + t.effectiveTotal 却是去重值(:260 源自 dedupeTeachingUnits)→ 合班教师负载虚增 N 倍 → 误判"并发导致容量已满"而 overloadSkipped 跳过分配(:2377-2384) | 改用 dedupe 后排课成功率会上升(此前被误跳的班级能排上)。属纠正性变化,需回归 auto-arrange 全部 16 个测试文件 |
| M-3 | services/arrange/batch.js:220-228 | 注释写"按教师聚合本学期已排课时(跨课程,去重合班)",代码是裸 groupBy _sum,并未去重 → 供需比失真。确认它只驱动 coursePriorities 排序(:279-287),硬上限另由去重路径计算,故 :231-232 的"仅影响排序"结论成立 | 修复只改变批量排课的课程处理顺序,最终分配结果可能因顺序不同而变化(非确定性来源)。建议连同注释一起改,避免误导后续维护者 |
| M-4 | services/arrange/queries.js:186-218 + dashboard.controller.js:274-277 | getCourseOverviewAggregate 对每门课程调 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-5 | services/arrange/batch.js:486, 510 | 补漏轮 F8 守卫用 <:预览结果等于主轮时仍执行真实重排 → 无收益的 DB 重写 + 教师分配无谓变动。且 :510 非预览回退只记 warn,DB 已被更差结果覆盖(:2279 的 deleteMany + createMany 已提交),无回滚 | 把 :486 的 < 改为 <= 是一字符修复、语义安全(等量即无补漏收益)。:510 属单向门,只能靠 F8 前置守卫,建议加强而非事后补救 |
| M-6 | teaching-arrange.controller.js:889-908 | deleteAssignment 的合班级联删除不检查伙伴行 is_locked → 可通过删主班绕过锁定,连带删除已锁定的伙伴安排 | 加锁校验后,此前"能删"的合班锁定行会返回失败。需前端配合提示,否则用户感知为功能回退 |
| M-7 | data-export.controller.js:417 vs query.controller.js:602-603 | 教材使用导出用 isClassMatchPlan(维度匹配即纳入),页面用 bestPlan.id === plan.id(最佳匹配)→ 导出含"幻影班级"、学生数虚高,与页面数字对不上 | 导出改用 bestPlan 判定后导出行数会减少。这是纠正,但会让用户看到与历史导出不同的数字,需在发布说明中告知 |
| M-8 | services/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-9 | services/class-combination.service.js:69-158 | 建立合班时不校验 / 同步既有排课:两成员班"同课不同师"的安排行都保留 → dedupe 键含 teacher_id → 同组合产生 2 个单元 → 工作量双计(与 H-1 同一后果,不同入口)。拆分合班时也不回收安排行 | 与 H-1 应一并设计。加冲突检测后,建立合班会开始报错 / 告警——这是必要的,但需给管理员提供"合并到指定教师"的处置入口,否则会被卡住 |
| M-10 | app.js:80, 91, 134 | isDev = 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.js | 2437 | 暂不拆 | autoArrange 单函数跨 1240-2422(约 1180 行)确实是 god function,但它是一条有严格顺序依赖的五阶段流水线,共享 teacherConstraints / globalTextbookMap / classTextbookMap 等大量可变状态。现在拆分会迫使这些状态变成跨模块参数或闭包,复杂度是转移不是消除,且该文件有 16 个测试文件覆盖、修复注释密集,重构风险高于收益。建议只在下次改动该文件时顺带按阶段抽内部函数(不导出、不改契约) |
controllers/teaching-arrange.controller.js | 1919 | 建议拆 | 19 个导出 handler,职责已按 REST 资源自然分块,拆分收益真实。可按 assign / lock / statistics / history 拆为 4 个文件,纯移动无逻辑改动,工作量 M,风险低(teaching-arrange.service.js:5-7 已是再导出层,路由 import 路径不变) |
controllers/export/data-export.controller.js | 1528 | 建议拆 | 16 个导出函数彼此独立,适合按导出对象拆分,工作量 M,风险低 |
services/arrange/optimize.js / tabu-search.js | 1257 / 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 行,菜单已抽SideMenuContentTeachingArrange.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课程分组 / 小计 / 总计与 webuseMatrixCalculations语义重复(跨端无共享包,属可接受成本,但需同步维护口径) - 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)+ CookieSameSite=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 均无硬编码密钥
两处一度怀疑、经查证不是问题(避免误导后续维护者)
queries.js:19 isTextbookMatch对空textbookIds返回false(注释却写"不需要匹配"),与isTeacherEligible:183的放行语义相反。追全部调用点后确认它只用于软性评分与诊断(:87打分、:397失败诊断、:458匹配率),从不作为硬门禁;硬门禁是isTeacherEligible,语义正确。且calcAllMatchRates:441-443, 456-459有 F17 修复专门把无教材班级排除出分母——说明作者已意识到这个空集语义并做了补偿。仅注释措辞与代码相反,建议改注释,不改逻辑。多处
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的getMonth0-based 处理正确;cache.js定时器有pagehide清理(:114-122);useResponsive/useCountUp/useDebounceFn均正确onUnmounted清理
建议修复顺序
第一批(低风险、高收益,可同批发布)
- H-2 dedupe 键加
semester— 一行,其余 12 处调用方结果不变 - M-1 导入后补
invalidateDurationCache()— 一行,零风险 - M-5 补漏轮
<改<=— 一字符 - C-1 compose 去掉
:-回退 + 扩充占位串黑名单 — 仅部署路径,上线前先核对生产.env已配齐 - 修正
batch.js:220、queries.js:19两处与代码相反的注释
第二批(需回归测试,单独发布)
- C-2 方案改课时同步排课快照(学期号 → 学年串换算 + 事务化)+ 配套一次性全量校准脚本
- H-1 + M-9 合班一致性:整组钉住 / 继承 + 接线
validateCombinedClassConsistency+ 补auto-arrange的合班 × 锁定交叉测试(当前测试空白) - M-2 + M-3 + M-8 三处合班 / 停用教师口径统一 — 会让排课成功率与概览数字发生变化,需提前告知业务方
第三批(安全加固,需两端协同)
- 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-1 | PLACEHOLDER_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-2 | dedupeTeachingUnits 归并键加入 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-4 | getClassesWithCourse 在 planCourses.length === 0 时提前返回,避免无方案课程对全部班级付全额匹配排序代价 | services/arrange/queries.js |
| M-5 | 补漏轮 F8 守卫由 < 改为 <=(等量即无补漏收益,避免无收益 DB 重写与教师分配无谓变动) | services/arrange/batch.js |
| M-6 | deleteAssignment 合班级联删除前校验伙伴行 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-8 | getCourseOverviewAggregate 的安排查询补 teacher.status='active' 与 weekly_hours > 0 过滤,与 dashboard / teaching-query 口径一致 | services/arrange/queries.js |
| M-9 | 建立 / 更新合班时检测组内既有排课的「同课不同师」冲突,随成功响应回传告警提示人工核对(与 H-1 同源后果,不同入口) | services/class-combination.service.js |
| M-10 | isDev 由 NODE_ENV !== 'production' 反向推断改为显式 ENABLE_DEV_MODE opt-in 开关 | app.js |
LOW(择机处理,本轮已一并修复)
| # | 修复内容 | 涉及文件 |
|---|---|---|
| LOW-1 | xlsx 解压前用 JSZip 校验 central directory 解压后总体积,防解压炸弹 | utils/excel.js |
| LOW-2 | seed.js 未设 ADMIN_INITIAL_PASSWORD 时生成随机强密码并打印,替代硬编码 admin@123456 | prisma/seed.js |
| LOW(optimize) | optimize.js 读取存量 DB 课时配置后补调 validateHourSettings,与 auto-arrange 写入侧一致,脏数据回退默认值 | services/arrange/optimize.js |
| LOW-3 | useAutoArrange / useBatchArrange 补 onUnmounted 中止 SSE 流,避免离开页面后后台继续推送 | views/teaching/composables/useAutoArrange.js、useBatchArrange.js |
| LOW-4 | request.js 并发 401 刷新失败时去重,只弹一次提示 / 只登出一次 | client/src/utils/request.js |
| LOW-5 | classData.js 基础数据(学院 / 专业)变更后重置 _relationsLoaded,避免陈旧映射 | client/src/stores/classData.js |
| LOW-6 | useSemesters.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.*测试文件无此覆盖,属测试空白)。本轮按指示暂缓,留待下次补齐。建议用例至少覆盖:- 合班组 {A,B} 首轮 A 成功、B 进 unassigned;锁定 A 后再次 auto-arrange,B 不应被单独分配给其他教师(整组钉住生效)。
- 被整组钉住的成员班应出现在
skippedByCombination,且不污染assigned/unassigned,totalClasses分母口径正确。 - 结果
warnings应包含validateCombinedClassConsistency检出项(若构造分歧场景)。 - 与 M-9 建立合班冲突检测联动:既有「同课不同师」脏数据在 auto-arrange 后不应被继续放大(dedupe 单元数不虚增)。