Bug fixes (from bugs/ directory): - Fix cross-module DB queries in 9 modules (homework, grades, parent, diagnostic, elective, proctoring, notifications, scheduling, classes) by routing through data-access functions - Fix shared/lib <-> auth circular dependency via new session.ts module - Fix divide-by-zero guard in grades data-access - Fix audit export data truncation (paginated fetch for full datasets) - Fix missing transactions in homework grading and elective lottery - Fix missing revalidatePath in course-plans actions - Fix frontend permission checks using requirePermission instead of requireAuth - Fix dashboard role routing using session.user.roles - Fix student auth pattern (migrate getDemoStudentUser to users module) - Fix ActionState return type handling in components Code quality fixes: - Remove 60+ as type assertions (replace with type guards) - Remove non-null assertions (use optional chaining or explicit checks) - Convert dynamic imports to static imports (grades, diagnostic) - Add React.cache() wrapping for read functions - Parallelize independent queries with Promise.all - Add explicit return types to 30+ arrow functions - Replace any with unknown + type guards - Fix import type for type-only imports - Add Zod validation schemas for classes and diagnostic modules - Extract duplicate code (normalizeRoleName, normalizeBcryptHash, logger IP extraction) - Add console.error to silent catch blocks - Fix permission naming consistency (exam:proctor_read -> exam:proctor:read) Architecture doc sync: - Update 004_architecture_impact_map.md and 005_architecture_data.json - Update management-modules-audit.md for P0-7 cross-module fix Moved deleted proctoring event route to deletes/ folder.
16 KiB
16 KiB
首次登录引导(Onboarding)重大问题讨论
创建日期:2026-06-18 状态:讨论中,待决策 关联架构图:
docs/architecture/004_architecture_impact_map.md§2.1 shared 层 / §3 已知问题 P2-4 关联代码:
一、背景与定位
按项目规则"先图后码",先从架构影响地图定位 Onboarding 相关节点:
- shared 层:
components/onboarding-gate.tsx(312 行)已被架构图标记为 ⚠️ P2-4「业务逻辑泄漏到 shared」 - app 层:
/api/onboarding/status、/api/onboarding/complete两条路由 - 数据层:
users.onboardedAt(src/shared/db/schema.ts:41) - 被调用模块:
modules/classes/data-access.ts的enrollStudentByInvitationCode
当前 Onboarding 是一个全局 Dialog:在 app/layout.tsx 第 41 行无条件挂载 <OnboardingGate />,组件内通过 useEffect 拉取 /api/onboarding/status,若 required === true 则弹出不可关闭的 4 步 Dialog。
二、现状代码盘点
2.1 组件层(onboarding-gate.tsx)
| 步骤 | 标题 | 采集字段 | 备注 |
|---|---|---|---|
| Step 0 | 角色选择 | role(student/teacher/parent) | admin 只读展示;其他角色用户可下拉自选 |
| Step 1 | 通用信息 | name / phone / address | 仅校验非空 |
| Step 2 | 角色信息 | classCodes(学生/教师)、teacherSubjects(教师) | 可跳过;家长显示"暂不需要配置" |
| Step 3 | 完成 | — | 调 /api/onboarding/complete 后跳 /dashboard |
角色推断逻辑(第 90-94 行)——用权限点反推角色:
const isAdmin = permissions.includes(Permissions.SETTINGS_ADMIN)
const isTeacher = permissions.includes(Permissions.EXAM_CREATE)
const isStudent = permissions.includes(Permissions.HOMEWORK_SUBMIT) && !permissions.includes(Permissions.EXAM_CREATE)
const isParent = !permissions.includes(Permissions.EXAM_CREATE) && !permissions.includes(Permissions.HOMEWORK_SUBMIT) && permissions.includes(Permissions.EXAM_READ)
2.2 API 层
GET /api/onboarding/status:查users.onboardedAt是否为空 + 查usersToRoles推断角色POST /api/onboarding/complete:更新 users 表 → 写 usersToRoles → 学生调enrollStudentByInvitationCode→ 教师直接 insertclassSubjectTeachers→ 写onboardedAt
三、重大问题清单(按风险分级)
🔴 P0 级:安全/合规/越权
P0-1 用户可自选角色(严重越权)
- 位置:onboarding-gate.tsx:192-201
- 问题:Step 0 允许任意登录用户从下拉框选择
student / teacher / parent角色;complete/route.ts:32-35直接信任前端body.role并写入usersToRoles。 - 后果:任何注册用户可自封为 teacher,从而获得
exam:create、homework:grade等权限;可自封为 parent 查看他人成绩。这是 K12 教务系统的合规红线。 - 违反规则:项目规则「Server Action 必须使用
requirePermission()」、K12 行业铁律「角色由管理员预分配」。
P0-2 教师可绑定任意班级+科目
- 位置:complete/route.ts:95-130
- 问题:教师通过
classCodes(6 位邀请码)可把自己写入任意班级的classSubjectTeachers,且teacherSubjects由前端任意提交,服务端仅做"名称存在性"校验,不校验该教师是否被管理员分配到该班。 - 后果:教师可越权查看任意班级学生名单、成绩;可篡改他人班级的任课关系。
- 违反规则:项目规则「modules 之间通过对方 data-access 通信,不直接查询对方 DB 表」——此处 app 层 API 直接 insert
classSubjectTeachers。
P0-3 无权限校验、无 Zod、无事务
- 位置:complete/route.ts 整文件
- 问题:
- 仅检查
auth()登录态,未调用requirePermission() - 用
String(body.role ?? "")手动解析,无 Zod(架构图 005 声称"validation: Zod schema"与实际不符) - 5 次独立 DB 写入(update users / insert usersToRoles / enrollStudent / insert classSubjectTeachers / update onboardedAt)无
db.transaction() - 运行时
db.insert(roles).values({ name: role })创建角色记录(第 66-68 行)——角色应在 seed 时创建,运行时创建属异常路径
- 仅检查
- 后果:中途失败导致数据不一致(如已绑定角色但
onboardedAt仍为 null,用户被反复弹窗);越权写入。
🟠 P1 级:架构违规
P1-1 shared 层反向承载领域逻辑
- 位置:onboarding-gate.tsx 整文件
- 问题:组件位于
shared/components/,但包含角色判断、班级代码、教师科目配置等强领域逻辑,并通过 fetch 调用业务 API。 - 违反规则:项目规则「shared 不得反向依赖 @/auth、@/proxy 或任何 modules/*」「shared 是被依赖方」。
- 架构图标记:004 文档 §2.1 已标记 P2-4。
P1-2 app 层 API 直接跨模块写表
- 位置:complete/route.ts:6
- 问题:
app/api/onboarding/complete/route.ts直接 import 并写入classes、classSubjectTeachers、subjects表,绕过modules/classes的 data-access 与权限校验。 - 违反规则:项目规则「app 只能调用 modules 的 Server Actions 和 data-access,不直接访问 DB」「modules 之间通过对方 data-access 通信」。
P1-3 角色推断双源不一致
- 位置:status/route.ts:29-41 vs onboarding-gate.tsx:90-94
- 问题:status API 用
roles.name推断角色(含grade_head/teaching_head → teacher归一化),组件又用权限点重新推断,两套逻辑可能不一致(如年级组长既有 EXAM_CREATE 又有其他权限,组件推断可能错位)。
🟡 P2 级:用户体验与可访问性
P2-1 全局 Dialog 模式缺陷
- 问题:
- Dialog 不可关闭(
canClose = !required),用户被强制锁定 - 刷新页面丢失步骤状态(step 重置为 0)
- 无独立 URL,无法分享/书签
- 首屏无骨架屏,
useEffect拉取 status 期间会闪烁 - 依赖
session?.user?.name触发重复请求
- Dialog 不可关闭(
- 对比:业界主流(Auth.js 官方、Clerk、Vercel 模板)均采用独立路由
/onboarding+ middleware 重定向。
P2-2 表单校验粗糙
- 问题:电话仅校验非空(无手机号格式校验);姓名无长度限制;地址无长度限制;班级代码无格式预校验。
P2-3 国际化与可访问性
- 问题:中英文混合("Role"、"Select role" 英文,其余中文);Dialog 缺少
aria-describedby;进度条无aria-valuenow;表单无required标记。
P2-4 进度条与步骤不一致
- 问题:admin 跳过 Step 2,但进度条仍渲染 4 段,视觉上 Step 2 永远亮起,造成困惑。
四、业界大仓(Monorepo)解决方案引用
4.1 Auth.js v5 官方推荐
- 状态标记:
users.onboardedAt字段 +jwt/session回调注入 session;完成时调update()刷新 token。 - 强制方式:middleware 重定向到独立
/onboarding路由,而非客户端 Dialog。- 在
middleware.ts用auth()读取 session,若user.onboardedAt为空且路径不在白名单(/login、/api/auth、/onboarding、静态资源),则NextResponse.redirect(new URL('/onboarding', req.url))。
- 在
- 结论:客户端 Dialog 仅适合"非阻塞的偏好补全"(如头像、通知偏好);强制 onboarding 应等同未登录处理。
4.2 商业方案(Clerk / Supabase / Auth0)共性
三段式:metadata 标记 + 强制重定向独立路由 + 服务端 Action 校验。
- 角色等敏感字段放服务端可写的 metadata(Clerk
privateMetadata/ Auth0appMetadata/ Supabase RLS-protectedprofiles.role),禁止前端自写。 - onboarding 完成回调必须由服务端 Action 写入 metadata,前端不能直接改。
- 未完成 onboarding 时 middleware/Action 层强制重定向。
4.3 shadcn/ui 生态
- 官方无内置 Stepper,但
examples/forms与blocks范式明确:独立路由页面 +<Form>(react-hook-form + zod)+ 父组件持 step state。 - 每步独立 zod schema 做渐进式校验,最后一步汇总写入。
- 官方
blocks/login-04等登录块均采用独立路由页面,而非全局 Dialog。
4.4 企业级 K12 教务系统(PowerSchool / Veracross / 国内智慧校园)
铁律:角色由管理员预分配,用户不可自选。
| 角色 | 首次登录采集字段 | 角色来源 |
|---|---|---|
| 学生 | 学号(预分配不可改)、姓名、性别、出生日期、家长联系方式、紧急联系人 | 管理员批量导入 |
| 教师 | 工号(预分配)、姓名、所教科目、任教班级、办公室、联系电话、学历资质 | 教务处预分配 |
| 家长 | 与学生关系、学生学号(通过学校发放的 Access ID + Access Password 绑定)、本人姓名、电话、邮箱 | 学校发放凭证,家长绑定子女 |
| 管理员 | 工号、姓名、职务、管理范围 | 学校 IT 创建 |
原因:
- 合规:K12 数据受《个人信息保护法》《未成年人保护法》约束,学生身份必须由学校权威确认。
- 安全:允许自选教师角色 = 任何人可创建考试、查看全班成绩。
- 数据一致性:班级、学号、任课关系是教务核心数据,必须由教务处维护。
4.5 Monorepo(turborepo / nx)惯例
- turborepo 官方模板:跨模块"流程型"功能(onboarding、setup-wizard)作为独立 module,而非塞进 shared。
- nx feature-shell 模式:onboarding 作为
feature-onboardinglibrary,依赖data-access-user、data-access-class。 - Vercel 自家项目:
app/(app)/onboarding/[[...step]]/page.tsx路由组 +modules/onboarding/模块。
五、重构方案建议(待讨论)
5.1 目标架构
app/
├─ (auth)/login/ # 登录页(middleware 白名单)
├─ (onboarding)/onboarding/ # 新增独立路由
│ └─ page.tsx # 服务端组件,读取 session.onboarded 决定渲染
└─ middleware.ts # 新增/增强:未 onboarded 时重定向
modules/onboarding/ # 新建模块
├─ actions.ts # completeOnboardingAction(Server Action + requirePermission)
├─ data-access.ts # 仅操作 users.onboardedAt
├─ schema.ts # Zod:name/phone/address/classCodes
├─ types.ts
└─ components/
├─ OnboardingStepper.tsx # 客户端 stepper 容器
├─ RoleConfirmStep.tsx # 只读展示管理员分配的角色
├─ ProfileStep.tsx # 姓名/电话/住址
└─ BindingStep.tsx # 学生:确认班级;教师:确认任课;家长:绑定子女
shared/
└─ components/onboarding-gate.tsx # 删除
5.2 关键改动点
- 删除
shared/components/onboarding-gate.tsx,从app/layout.tsx移除挂载。 - 新建
modules/onboarding/,承载所有领域逻辑。 - 新建
app/(onboarding)/onboarding/page.tsx独立路由。 - 增强
middleware.ts:读取 session.onboarded,未完成且非白名单路径 → 重定向到/onboarding。 - Auth.js 回调:在
jwt/session回调注入onboardedAt,供 middleware 读取。 - 删除
app/api/onboarding/*/route.ts,改为modules/onboarding/actions.ts的 Server Action。 - 角色只读化:Step 0 改为"角色确认"——只读展示
usersToRoles中的角色,用户不可改。 - 班级绑定改造:
- 学生:仅"确认"管理员预分配的班级,或输入邀请码(服务端校验有效性 + 用途)
- 教师:仅"确认"管理员预分配的任课关系,移除自填班级代码
- 家长:输入"子女学号 + 绑定码"绑定子女(参考 PowerSchool Access ID 模式)
- 事务化:
completeOnboardingAction用db.transaction()包裹所有写入。 - Zod 校验:定义
onboardingSchema,phone 用z.string().regex(/^1\d{10}$/)。
5.3 迁移兼容
- 已 onboarded 用户(
onboardedAt非空)不受影响,middleware 直接放行。 - 未 onboarded 用户下次登录会被重定向到
/onboarding(而非弹 Dialog)。 - 无需数据迁移,
users.onboardedAt字段保留。
六、待决策的开放问题
请就以下问题给出决策,以便进入实施阶段:
Q1:角色分配策略
- 方案 A(推荐,符合 K12 铁律):onboarding 中角色完全只读,由管理员通过后台预分配;用户无法在 onboarding 中改变角色。
- 方案 B:保留角色选择,但服务端校验"用户已有该角色"才允许选择(即只能从已有角色中选一个主角色)。
- 方案 C:暂不改动角色选择,仅修复其他问题。
Q2:教师任课关系绑定
- 方案 A(推荐):onboarding 中教师仅确认管理员预分配的任课关系,不自填班级代码。
- 方案 B:保留自填邀请码,但服务端强校验邀请码用途(teacher-assign)、有效期、使用次数。
- 方案 C:完全移除 onboarding 中的班级绑定,统一由管理员后台处理。
Q3:家长绑定子女方式
- 方案 A(推荐,PowerSchool 模式):家长输入"子女学号 + 学校发放的 6 位绑定码"。
- 方案 B:家长输入"子女学号 + 子女生日"作为验证。
- 方案 C:暂不实现家长绑定,由管理员后台预绑定。
Q4:onboarding 路由形态
- 方案 A(推荐):单页
/onboarding+ 客户端 stepper(步骤状态用 query param 持久化)。 - 方案 B:嵌套路由
/onboarding/role、/onboarding/profile、/onboarding/binding(每步独立 Server Action)。 - 方案 C:保留全局 Dialog,仅修复安全与架构问题。
Q5:实施范围
- 方案 A:一次性完成 P0 + P1 + P2 全部整改。
- 方案 B:先做 P0(安全/越权)+ P1(架构),P2(UX)后续迭代。
- 方案 C:仅做 P0 紧急修复,P1/P2 列入 backlog。
七、附录:问题与代码位置速查
| 问题 | 代码位置 | 风险 |
|---|---|---|
| 用户自选角色 | onboarding-gate.tsx:192-201 | 🔴 P0 |
| 信任前端 role 写入 | complete/route.ts:32-35 | 🔴 P0 |
| 教师绑任意班级 | complete/route.ts:95-130 | 🔴 P0 |
| 无权限校验/Zod/事务 | complete/route.ts 整文件 | 🔴 P0 |
| shared 反向承载领域逻辑 | onboarding-gate.tsx 整文件 | 🟠 P1 |
| app 层跨模块写表 | complete/route.ts:6 | 🟠 P1 |
| 角色推断双源不一致 | status/route.ts:29-41 vs onboarding-gate.tsx:90-94 | 🟠 P1 |
| 全局 Dialog 缺陷 | app/layout.tsx:41 | 🟡 P2 |
| 表单校验粗糙 | onboarding-gate.tsx:88 | 🟡 P2 |