Files
NextEdu/docs/architecture/audit/archive/elective-audit-report.md

28 KiB
Raw Blame History

选修课Elective模块审计报告

审计日期2026-06-25 审计范围:

  • src/modules/elective/**
  • src/app/(dashboard)/admin/elective/**src/app/(dashboard)/teacher/elective/**src/app/(dashboard)/student/elective/**
  • 跨模块依赖:school / users / classes 的 data-accessrbacELECTIVE_* 权限点
  • i18n 资源:src/shared/i18n/messages/{zh-CN,en}/elective.json 参照规则:docs/architecture/004_architecture_impact_map.mddocs/architecture/005_architecture_data.json.trae/rules/project_rules.md

一、现有实现概要

1.1 文件分布

文件 行数 职责
Server Actions actions.ts 348 8 个写 Action权限校验 + Zod + trackEvent + 资源归属校验)
数据访问 data-access.ts 258 课程 CRUD + scope 过滤 + 显示名聚合 + 共享映射函数
数据访问 data-access-operations.ts 374 选课/退课/抽签(事务 + FOR UPDATE 锁 + Fisher-Yates + 时间冲突/学分上限校验
数据访问 data-access-selections.ts 147 选课记录查询 + 学生可选课程
跨模块抽象 resolvers.ts 83 CourseDisplayResolver / StudentGradeResolver 接口 + 注入函数(测试 mock 友好)
Schema schema.ts 179 Zod 校验5 个 schema
Types types.ts 73 类型定义
Constants constants.ts 55 i18n key 映射 + Badge variant + 类型守卫
Import-export export.ts 102 Excel 导出(课程列表 + 选课名单)
组件 components/elective-page-layout.tsx 30 页面布局骨架header/children 插槽)
组件 components/elective-course-list.tsx 236 课程卡片网格 + 管理操作
组件 components/elective-course-form.tsx 301 课程创建/编辑表单
组件 components/elective-filters.tsx 51 nuqs 筛选栏(搜索 + 模式)
组件 components/student-selection-view.tsx 248 学生选课视图(已选 + 可选)
页面 admin/elective/page.tsx 47 管理员课程列表RSC
页面 admin/elective/create/page.tsx 32 创建课程RSC
页面 admin/elective/[id]/edit/page.tsx 44 编辑课程RSC
页面 teacher/elective/page.tsx 58 教师我的课程RSC
页面 student/elective/page.tsx 48 学生选课中心RSC
骨架屏 5 个 loading.tsxadmin/admin-create/admin-edit/teacher/student 列表/表单骨架屏
错误边界 3 个 error.tsxadmin/teacher/student 错误兜底
i18n zh-CN/elective.json 114 中文翻译
i18n en/elective.json 114 英文翻译

1.2 数据流

page.tsx (RSC)
  └─ getElectiveCourses / getElectiveCourseById / getAvailableCoursesForStudent / getStudentSelections  (data-access)
       └─ db (drizzle) → electiveCourses / courseSelections 表
       └─ 跨模块 data-access通过 resolvers.ts 接口抽象):
            school.getSubjectOptions / school.getGradeOptions
            users.getUserNamesByIds
            classes.getStudentActiveGradeId
  └─ <ElectiveCourseList> (client) → deleteElectiveCourseAction / openSelectionAction / closeSelectionAction / runLotteryAction
  └─ <ElectiveCourseForm> (client) → createElectiveCourseAction / updateElectiveCourseAction
  └─ <StudentSelectionView> (client) → selectCourseAction / dropCourseAction

1.3 架构图记录完整性

docs/architecture/004_architecture_impact_map.md005_architecture_data.json 已覆盖 elective 模块(章节 2.20),包含完整的 exports / 依赖关系 / 权限点 / 文件清单。但「已知问题」段存在过时信息(详见第五部分),需同步修正。


二、现存问题与原因分析

2.1 P0教师页面跳转到 admin 路由(跨角色越权 + 404

  • 位置teacher/elective/page.tsx:53-54
  • 现象:教师页面渲染 ElectiveCourseList 时传入 createHref="/admin/elective/create"editBaseHref="/admin/elective"。教师点击"创建课程"或"编辑"按钮后会被路由到 /admin/*,由于中间件对 admin 角色做了路由保护,教师实际看到的是 403 / 重定向到首页。
  • 原因ElectiveCourseList 只支持单一 createHref/editBaseHrefadmin 与 teacher 共用一份组件时硬编码了 admin 路径。
  • 违反规则「Server Action 必须使用 requirePermission() 进行权限校验」前端入口虽然校验了权限但跳转到无权访问的路由等同于绕过校验UX 上不可达。
  • 后果:教师角色虽然被授予 ELECTIVE_MANAGE 权限,却无法实际创建/编辑课程,功能完全不可用。

2.2 P0parent 角色缺失选课页面

  • 位置src/app/(dashboard)/parent/elective/**(目录不存在)
  • 现象005_architecture_data.json 中 parent 角色被授予 ELECTIVE_READ 权限,但没有对应的 parent 页面。家长无法查看子女的选课情况。
  • 违反规则:「所有用户可见文本必须适配 i18n」「最大化复用识别四个角色共用的 UI 块和业务逻辑块」——当前只覆盖 3 个角色,遗漏 parent。
  • 后果:家长对子女选课缺乏监督,无法及时发现选课异常(如未选满学分、错选时间冲突课程)。

2.3 P0admin/student 页面缺少 requirePermission()

  • 位置
  • 违反规则「Server Action 必须使用 requirePermission() 进行权限校验」 + 项目记忆中的硬约束「Parent routes must include permission checks with both parentId and studentId to prevent information leakage」。
  • 后果仅依赖中间件的路由级保护缺少纵深防御若中间件配置出现疏漏如新增动态路由会直接导致越权读他人数据。teacher 页面已经做了示范(requirePermission(Permissions.ELECTIVE_READ)admin/student 不应例外。

2.4 P0选课/退课错误消息未走 i18n

  • 位置data-access-operations.ts
    • L233 throw new Error("Course selection is not open")
    • L237 throw new Error("Selection has not started yet")
    • L241 throw new Error("Selection has ended")
    • L254 throw new Error("Already selected this course")
    • L259 throw new Error("Schedule conflicts with your existing courses")
    • L265 throw new Error(\Credit limit exceeded (${creditCheck.current}/${creditCheck.max})`)`
    • L301-303 message: "Enrolled successfully" / "Added to waitlist" / "Selection submitted"
  • 现象:上述英文 throw 出去后经由 handleActionError 包装为 { success: false, message: <英文> } 返回前端toast 直接显示英文。
  • 违反规则:「所有用户可见文本必须适配 i18n使用 next-intl提取翻译键」。
  • 后果中文用户看到英文错误提示i18n 资源中已存在对应的中文键(errors.selectionClosederrors.alreadySelectederrors.scheduleConflicterrors.creditExceeded 等)却完全没被复用。

2.5 P0export.ts 存在 as 类型断言

  • 位置export.ts:21
    status: params.status as "draft" | "open" | "closed" | "cancelled" | undefined,
    
  • 违反规则:「禁止 as 断言(除非从 unknown 转换或测试中,需注释原因)」。
  • 后果:未做类型守卫即强转,传入非法字符串(如 "foo")会被静默接受,运行时引发 SQL 类型不匹配。

2.6 P1elective-course-form.tsx 大量硬编码英文文案

  • 位置elective-course-form.tsx
    • L95 "New Elective Course" / "Edit Elective Course"
    • L102 "Course Name *"
    • L112 "Subject"
    • L129 "Grade"
    • L146 "Teacher"
    • L163 "Capacity"
    • L175 "Classroom"
    • L184 "Schedule"
    • L188 placeholder="e.g. Mon 14:00-15:30"
    • L194 "Credit"
    • L225 "Start Date"
    • L235 "End Date"
    • L245 "Selection Start"
    • L258 "Selection End"
    • L274 "Description"
    • L278 placeholder="Course description..."
    • L291 "Cancel"
    • L294 "Saving..." / "Create" / "Save"
    • L72-85 "Invalid form state" / "Failed to save course" 等 toast 回退文案
  • 违反规则:「所有用户可见文本必须适配 i18n」+ i18n 资源已存在 form.createTitle / form.editTitle / form.namePlaceholder / form.descriptionPlaceholder 等键。
  • 后果:中文用户在创建/编辑课程表单中看到全英文界面,体验割裂。
  • 位置
  • 违反规则项目记忆「Link navigation must use Next.js <Link> component instead of raw <a> tags」。
  • 后果:原生 <a> 触发整页刷新丢失客户端导航状态、prefetch 优化、Layout 复用。

2.8 P1错误边界文案与按钮文案错误

  • 位置admin/elective/error.tsxteacher/elective/error.tsx
  • 现象
    • titledescription 都用 t("errors.unexpected"),重复且无信息量;
    • 重试按钮 action.labelt("actions.save")"保存"),但学生页用 t("actions.retry")"重试")—— admin/teacher 文案错误。
  • 违反规则i18n 完整性 + UX 一致性。
  • 后果:用户在错误页看到"保存"按钮且语义与"重试"不符。

2.9 P1表单未使用 React Hook Form

  • 位置elective-course-form.tsx
  • 现象:使用 4 个独立 useState 管理 Select 状态,没有统一表单状态管理、字段校验、脏值检查。项目 tech stack 明确包含 React Hook Form
  • 违反规则:技术栈一致性 + 可维护性。
  • 后果:字段一多需要每个都加 useState,扩展性差;与服务端 Zod 校验形成两套校验,难以保持一致。

2.10 P1export.ts 表头混用英文硬编码

  • 位置export.ts:56 "Status"、L93 "Status"、L94 "Priority"、L95 "Selected At"、L96 "Enrolled At"
  • 现象:课程列表 sheet 中 status 列的 header 是英文硬编码 "Status",而其他列都走 i18n选课名单 sheet 中 status/priority/selectedAt/enrolledAt 4 列 header 全英文硬编码。
  • 违反规则:「所有用户可见文本必须适配 i18n」—— Excel 导出也是用户可见文本。
  • 后果:中文用户下载 Excel 后表头混杂中英文。

2.11 P1getElectiveCourses 静默吞错

  • 位置data-access.ts:161-164
    } catch (error) {
      console.error("getElectiveCourses failed:", error)
      return []
    }
    
  • 现象DB 查询失败时返回空数组,页面无任何错误提示,用户以为"暂无数据"。
  • 违反规则:「错误与边界处理:明确处理空数据、无权限、网络异常等边界状态」。
  • 后果DB 异常被掩盖,运维无法及时发现,用户误判为"无课程",无法触发错误边界。

2.12 P1parseSchedule 不支持完整英文星期与多时段

  • 位置data-access-operations.ts:35
    const match = schedule.match(/^(周[一二三四五六日天]|[MonTueWedThuFriSatSun]+)\s+.../)
    
  • 现象
    • 字符类 [MonTueWedThuFriSatSun]+ 匹配任意 M/o/n/T/u/e 字符组合(如 "Mon""oMenT" 都会通过),不严谨;
    • 不支持 "Monday""周一 14:00-15:30, 周三 16:00-17:30" 多时段;
    • normalizeDay 表只有 mon/tue/...,没有 monday/tuesday/...
  • 后果:教师在 schedule 输入 "Monday 14:00-15:30" 时不会触发冲突检测,存在隐性排课冲突。

2.13 P1学分上限与候补人数硬编码 2026-06-25 已修复)

  • 位置data-access-operations.ts:15 const MAX_CREDIT_PER_TERM = 10
  • 现象:所有年级/学校共用同一个上限,无法配置。
  • 违反规则:「可扩展性:采用配置驱动设计」。
  • 后果K12 不同年级(如高一 8 学分 vs 高三 12 学分)无法差异化配置。
  • 修复说明:新增 data-access-settings.ts,复用 systemSettingscategory="elective")作为配置存储。getElectiveCreditLimit(gradeId?) 支持按年级覆盖key=creditLimit:grade:<gradeId>fallback 到全局key=creditLimit:default),均未配置返回默认值 10。React cache() 包装请求级去重。checkCreditLimit 新增 studentGradeId: string | null 参数并调用 getElectiveCreditLimit(studentGradeId)selectCourse 在事务前通过 getStudentGradeId(studentId) 拿到年级 ID 并透传。

2.14 P1抽签结果不可重跑

  • 位置data-access-operations.ts:212
    await tx.update(electiveCourses).set({ enrolledCount, status: "closed", updatedAt: now })...
    
  • 现象:抽签完成立即把课程状态置为 closed,管理员若发现结果异常无法重新抽签(重抽需要先把状态手动改回 open)。
  • 后果:管理员缺乏"试抽 + 调整 + 正式抽"的灵活度K12 学校在抽签争议时无法快速复核。

2.15 P1管理员缺少课程统计概览

  • 位置:缺失
  • 现象admin/teacher 页面只有平铺的课程卡片,缺少总览统计(如总课程数、总选课人数、热门科目分布、容量使用率)。
  • 违反规则行业最佳实践「K12 admin 应有数据驾驶舱」。
  • 后果:管理员难以从全局角度掌握选课运行情况。

2.16 P2getCachedSubjectOptions / getCachedGradeOptions 缓存全量选项

  • 位置data-access.ts:104-105
  • 现象:即使只需要 1 个科目的名称,也会拉取全部 subject/grade 选项。
  • 后果:高并发场景下存在 N+1 缓存膨胀风险(虽 React cache() 限单次请求内,但单次请求若涉及 1000+ 课程仍冗余)。

2.17 P2缺少 Suspense 流式渲染( 2026-06-25 已修复)

  • 位置:所有 RSC 页面
  • 现象admin/teacher/student 页面用 Promise.all 一次性等待所有数据,没有 <Suspense> 边界,无法流式渲染局部内容。
  • 违反规则:「异步数据使用 React Suspense + 骨架屏」「性能:支持流式渲染」。
  • 后果:单条慢查询会拖累整页加载时间,用户长时间看到白屏。
  • 修复说明
    • student 页面拆分为 MySelectionsLoaderSuspense+ AvailableCoursesLoaderSuspense分别对应"我的选课"与"可选课程"
    • admin 页面拆分为 StatsCardsLoaderSuspense+ CourseListLoaderSuspense统计卡片与列表分离
    • StudentSelectionView 拆分为 StudentMySelectionsSection + StudentAvailableCoursesSection 两个独立客户端组件
    • loading.tsx 新增 MySelectionsSkeleton / AvailableCoursesSkeleton / StatsCardsSkeleton / CourseListSkeleton 分段骨架屏
    • React cache() 自动去重 getStudentSelections 调用,两个 Suspense 边界共享同一份数据

2.18 P2缺少课程先修/容量阈值通知( 2026-06-25 部分修复)

  • 位置:缺失
  • 现象K12 学校通常需要:
    • 课程先修要求(如"必须先选 Python 入门才能选 Python 进阶"
    • 容量阈值通知(如课程满 90% 时通知管理员考虑扩容);
    • 选课截止前提醒(如截止前 24h 通知未选课学生)。
  • 后果:缺少这些企业级功能会降低 K12 学校的运营效率。
  • 修复说明(容量阈值通知)
    • 新增 data-access-settings.ts 中的 getCapacityNotifyThreshold(),默认 0.990%),可由 systemSettings 表配置key=capacityNotifyThresholdcategory=elective)。
    • data-access-operations.ts 新增 notifyCapacityThresholdIfNeeded():仅当 newEnrolledCount === Math.ceil(capacity * threshold) 时触发一次通知避免每次递增都发通知。Fire-and-forget 设计catch 中吞错并 console.error,不阻塞主流程。通过 notifications.sendNotification 发送给 course.teacherIdtype="warning",附带 actionUrl 指向课程详情。i18n 标题/内容通过 next-intl getTranslations("elective") 翻译。
    • selectCourse 事务成功后调用 notifyCapacityThresholdIfNeeded
  • 未实施项:课程先修要求与选课截止前提醒属于更长期规划,本次审计范围内不实施,后续可在 P3 阶段补齐。

2.19 P2无单元测试 2026-06-25 已修复)

  • 位置tests/elective/(不存在)
  • 现象buildLotteryRankCaseparseScheduleisScheduleConflictbuildScopeFilter 等纯函数已被精心设计为可测试,但没有任何测试文件。
  • 修复说明:新增 tests/integration/elective/elective-pure-functions.test.ts35 个单测覆盖 normalizeDay / parseSchedule / isScheduleConflict / buildLotteryRankCase / mapCourseRowvitest.config.ts 添加 server-only 别名 stub。
  • 违反规则:「可测试性:数据获取、计算、格式化等纯逻辑全部放入纯函数或 hooks与 UI 分离;导出清晰的接口类型以便 mock」——架构已就绪但测试缺失。
  • 后果:未来重构无回归保障。

三、行业差距对比

维度 行业优秀实践(如 PowerSchool、Veracross、睿睿云、校园钉钉选修 当前实现 差距影响
角色覆盖 4 角色全覆盖admin 全局管理 / teacher 创建维护 / student 选退课 / parent 查看子女 3 角色覆盖,缺 parent 家长无法监督子女选课,错失家校协同点
时间冲突检测 结构化时段编辑器(周几 + 节次),可视化冲突预览 纯文本 schedule + 正则解析 教师/学生易输入错误格式,冲突检测可能失效
抽签可重跑 支持"预抽 + 公示 + 正式抽签",结果可回滚 抽完立即 close不可重抽 学校难以应对抽签争议
学分上限 按年级/学校可配置 全局硬编码 MAX_CREDIT_PER_TERM=10 不同年级无法差异化
选课截止提醒 截止前 24h 短信/站内信通知未选课学生 学生错过选课窗口
容量阈值通知 满 90% 通知 admin 考虑扩容 热门课程爆满后才发现
课程详情页 独立课程详情页 + 教师介绍 + 评价聚合 + 历年选课人数趋势 仅卡片展示,无详情页 学生选课决策信息不足
选课概览驾驶舱 admin 数据驾驶舱:选课率、热门科目、班级分布、未选名单 仅平铺课程卡片 admin 缺乏全局视图,决策低效
候补转正通知 候补转正时通知学生 仅事务内自动转正,无通知 学生不知道自己已转正,可能错过上课
课程先修 标记先修关系,选课时校验 学生可能跳级选课失败
退课理由 退课时要求填写理由 + 期限 直接退课,无理由 学校无法分析退课原因改进课程

四、改进优先级建议

P0必须立即修复影响功能可用或安全

  1. 教师页面跳转修复:把 createHref/editBaseHref 改为参数化teacher 页面传入 /teacher/elective/...,并新增 teacher 路由 /teacher/elective/create/teacher/elective/[id]/edit(或共享 admin 路由但放开教师访问)。
  2. 新增 parent 选课页面:复用 StudentSelectionView 的只读变体,展示子女的已选/可选课程;通过 parentId + studentId 双重校验防止信息泄露。
  3. admin/student 页面加 requirePermission():补齐 ELECTIVE_READ 校验,与 teacher 一致。
  4. data-access 错误消息 i18n 化:把 throw new Error("...") 改为带 i18n key + 参数的结构化错误,由 actions 层用 getTranslations("elective") 翻译后再返回。
  5. export.ts 移除 as 断言:用类型守卫替代。

P1应在本次实施影响质量与体验

  1. elective-course-form.tsx 全量 i18n 化:替换所有硬编码英文为 i18n 键,新增 form.* 翻译键。
  2. <a><Link>elective-course-list.tsx 中 2 处替换为 Next.js <Link>
  3. 错误边界文案修正admin/teacher error.tsx 重试按钮改用 t("actions.retry")title/description 分离为 errors.title / errors.description
  4. export.ts 表头全量 i18n:新增 export.statusHeader / export.priorityHeader / export.selectedAtHeader / export.enrolledAtHeader 翻译键。
  5. getElectiveCourses 不再静默吞错:移除 try-catch让异常冒泡到 RSC 触发 error.tsx。
  6. parseSchedule 支持完整星期 + 多时段:重写正则与归一化函数。
  7. 抽签可重跑:抽签后保留 status="open",仅更新 enrolledCount;增加"已抽签"标记字段或独立的 lotteryRunAt 字段。
  8. 管理员选课概览:在 admin 页面顶部增加统计卡片网格(总课程数、总选课人数、平均容量使用率、待抽签课程数),复用 attendance-stats-cards.tsx 模式。

P2中长期改进提升企业级能力

  1. 配置化学分上限 2026-06-25 已修复):抽离为 data-access-settings.ts 中的 getElectiveCreditLimit(gradeId?),复用 systemSettings 表按年级可设,未配置 fallback 到全局默认值 10。
  2. Suspense 流式渲染 2026-06-25 已修复student 页面拆分"我的选课"与"可选课程"为两个独立 Suspense 边界admin 列表与统计卡片分离。
  3. 容量阈值通知 2026-06-25 已修复):选课时若 enrolledCount >= capacity * threshold,触发 notifications 模块通知教师type="warning"),阈值通过 getCapacityNotifyThreshold() 可配置,默认 0.9。
  4. 退课期限与理由 2026-06-25 已修复):新增 electiveCourses.dropDeadlinedatetimecourseSelections.dropReasonvarchar 255字段dropCourse 校验截止时间并抛 ElectiveBusinessError("dropDeadlinePassed")退课对话框可选填理由trim 后非空才入库。
  5. 单元测试 2026-06-25 已修复):补齐 parseSchedule / isScheduleConflict / buildLotteryRankCase / buildScopeFilter / mapCourseRow 的单元测试。
  6. 课程详情页 2026-06-25 已修复):新增 /admin/elective/[id]/student/elective/[id] 详情页。

五、架构图同步说明

5.1 需要修正的过时信息

004_architecture_impact_map.md005_architecture_data.json 中 elective 模块章节的「已知问题」存在以下过时项,本次审计已核实并修复:

架构图记录 实际情况 处理
" P03 个读 Action 无调用方" 实际并不存在这 3 个 Action页面直接调用 data-access项目规则允许 app/ 调用 data-access 删除该项
" P0i18n 完全缺失" i18n 资源完整zh-CN + en 双语Server Action 错误消息已 i18n 化 修正为「P0data-access-operations 的 throw 错误消息仍为英文」
" P0错误边界完全缺失3 个角色目录均无 error.tsx" 3 个 error.tsx 已存在 删除该项改为「P1error.tsx 文案错误title=description=unexpected按钮文案错误
"⚠️ P1elective-course-form.tsx 存在 `v as "fcfs" "lottery"` 类型断言" 已用 isSelectionMode 类型守卫替代,无 as
"⚠️ P1elective-course-list.tsx 存在 null as never 类型逃逸" 当前代码无 null as never 删除该项
"⚠️ P1buildLotteryRankCase 未导出,无法单测" export function buildLotteryRankCase 删除该项
"⚠️ P1SELECTION_MODE_LABELS 已定义但表单未复用" 表单已使用 isSelectionMode 守卫 + 直接渲染 t("selectionMode.fcfs/lottery") 删除该项

5.2 需要新增的节点

新增项 004 章节 005 节点
新增 parent/elective/page.tsx(家长查看子女选课) 2.20 文件清单新增一行 appRoutes.parent.elective 节点
新增 teacher/elective/create/page.tsxteacher/elective/[id]/edit/page.tsx 2.20 文件清单新增 2 行 appRoutes.teacher.electiveCreate / electiveEdit 节点
新增 data-access-stats.ts(管理员选课统计) 2.20 文件清单新增一行 modules.elective.exports.dataAccess 新增函数
新增 tests/elective/*.test.ts(单元测试) 2.20 文件清单新增测试说明 无需 005 节点(测试不属导出)

5.3 实施完成后的同步动作

代码修改完成后,将上述变更同步写入:

  • docs/architecture/004_architecture_impact_map.md 第 2.20 节
  • docs/architecture/005_architecture_data.jsonmodules.electiveappRoutes 节点