Some checks failed
Security / deep-security-scan (push) Failing after 20m5s
DR Drill / dr-drill (push) Failing after 1m31s
CI / scheduled-backup (push) Failing after 1m31s
CI / backup-verify (push) Has been skipped
CI / weekly-dr-drill (push) Failing after 0s
CI / build-deploy (push) Has been cancelled
CI / security-scan (push) Has been cancelled
主要变更: - 新增 lesson-preparation 模块: 备课编辑器、节点编辑、AI 建议、知识点选择、版本历史、作业发布 - 新增 shared 通用组件: charts/question-bank-filters/schedule-list/ui (chip-nav/filter-bar/page-header/stat-card/stat-item) - 新增 student/admin 端 loading.tsx 与 error.tsx, 优化加载与错误态体验 - 新增 teacher/lesson-plans 页面 (列表/新建/编辑) - 新增 drizzle 迁移 0002_tiny_lionheart 及 snapshot - 新增 textbooks/schema.ts 与 exams/utils/normalize-structure.ts - 修复 Tiptap v3 SSR hydration 崩溃 (rich-text-block immediatelyRender: false) - 重构多模块 data-access/actions/组件, 修复权限校验与类型规范 - 同步架构文档 004/005 反映新增模块、导出、依赖关系 - 归档 bugs/* 测试报告与 e2e 测试脚本 (admin/parent/student/teacher web_test)
25 KiB
25 KiB
首次登录引导(Onboarding)重大问题讨论 · v2
版本:v2(替代 v1,2026-06-18) 状态:讨论中,待决策 关联架构图:
docs/architecture/004_architecture_impact_map.md§2.1 shared 层 / §3 已知问题 P2-4 关联代码:
- src/shared/components/onboarding-gate.tsx(312 行,未变)
- src/app/api/onboarding/status/route.ts(未变)
- src/app/api/onboarding/complete/route.ts(未变)
- src/app/layout.tsx#L41(全局挂载点,未变)
- src/auth.ts(jwt/session 回调,未注入 onboarded)
- src/proxy.ts(middleware,无 onboarding 拦截)
〇、v2 与 v1 的差异说明
经 git 核实(git log + git status + git diff),onboarding 相关代码自 v1 审查以来零改动:
onboarding-gate.tsx、api/onboarding/*/route.ts、layout.tsx、auth.ts均无修改- 工作区改动集中在
proxy.ts(权限常量替换)、schema.ts(新增 lesson_plans 表)等与 onboarding 无关的文件
v2 在 v1 基础上新增 9 项 v1 遗漏的问题(标为「v2 新增」),其中含 2 项 P0 级越权漏洞。问题编号沿用 v1,新增项顺延。
一、背景与定位
按项目规则"先图后码",从架构影响地图定位 Onboarding 节点:
- shared 层:
components/onboarding-gate.tsx(312 行)已被架构图标记 ⚠️ P2-4「业务逻辑泄漏到 shared」 - app 层:
/api/onboarding/status、/api/onboarding/complete两条路由 - 数据层:
users.onboardedAt(schema.ts:41) - 被调用模块:
modules/classes/data-access.ts的enrollStudentByInvitationCode(学生路径);教师路径绕过enrollTeacherByInvitationCode直接写表
当前实现:全局 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 行)用权限点反推角色。
2.2 API 层
GET /api/onboarding/status:查users.onboardedAt+ 查usersToRoles推断角色POST /api/onboarding/complete:update users → insert usersToRoles → 学生调enrollStudentByInvitationCode→ 教师直接 insertclassSubjectTeachers→ 写onboardedAt
2.3 关键表结构(v2 补充)
| 表 | 主键 | 影响 |
|---|---|---|
usersToRoles |
(userId, roleId) 联合主键(schema.ts:118) |
onDuplicateKeyUpdate 无法"替换"角色,只会新增行 → 追加角色 |
classSubjectTeachers |
(classId, subjectId) 联合主键(schema.ts:364) |
一个班级一个科目只有一位教师 → onDuplicateKeyUpdate 会覆盖现有教师 |
三、重大问题清单(按风险分级)
🔴 P0 级:安全/合规/越权
P0-1 用户可自选角色(严重越权)
- 位置:onboarding-gate.tsx:192-201、complete/route.ts:32-35
- 问题:Step 0 允许任意登录用户自选 student/teacher/parent;
complete/route.ts直接信任前端body.role。 - 后果:任何注册用户可自封 teacher 获得
exam:create、homework:grade等权限。 - 违反:K12 行业铁律「角色由管理员预分配」、项目规则「Server Action 必须用
requirePermission()」。
P0-2 教师可绑定任意班级+科目
- 位置:complete/route.ts:95-130
- 问题:教师通过
classCodes(6 位邀请码)可把自己写入任意班级的classSubjectTeachers,teacherSubjects由前端任意提交,服务端仅做"名称存在性"校验。 - 后果:教师可越权查看任意班级学生名单、成绩。
P0-3 无权限校验、无 Zod、无事务
- 位置:complete/route.ts 整文件
- 问题:仅检查
auth()登录态,无requirePermission();用String(body.role ?? "")手动解析无 Zod(架构图 005 声称"validation: Zod schema"与实际不符);5 次独立 DB 写入无db.transaction();运行时db.insert(roles)创建角色记录(第 66-68 行)属异常路径。
P0-4 教师可覆盖现有任课教师(v2 新增,严重破坏)
- 位置:complete/route.ts:124-127
- 问题:
classSubjectTeachers主键为(classId, subjectId)(schema.ts:364),一个班级一个科目只有一位教师。onboarding 用onDuplicateKeyUpdate({ set: { teacherId: userId, ... } }),会直接覆盖该班级该科目已有的任课教师。 - 对比:
modules/classes/data-access.ts的enrollTeacherByInvitationCode(第 637 行)有完整校验if (mapping?.teacherId && mapping.teacherId !== tid) throw new Error("Subject already assigned"),且只认领teacherId IS NULL的空缺位置(第 657 行)。onboarding 绕过了该函数,直接 insert。 - 后果:任何自封教师的人可抢占全校任意班级的任课位置,踢掉真实任课教师,篡改任课关系。
- 违反:项目规则「modules 之间通过对方 data-access 通信,不直接查询对方 DB 表」。
P0-5 角色追加越权(v2 新增)
- 位置:complete/route.ts:82-87
- 问题:
usersToRoles主键为(userId, roleId)联合主键(schema.ts:118)。db.insert(usersToRoles).values({ userId, roleId }).onDuplicateKeyUpdate({ set: { roleId } })中,set roleId无意义(roleId 已是要插入的值)。当用户已有其他 roleId 时,此操作新增一行而非替换——即追加角色记录。 - 后果:学生自选 teacher 角色后,给自己追加一条 teacher 角色行;
auth.ts的resolvePermissions(allRoles)会合并所有角色权限(auth.ts:131),学生因此获得 teacher 全部权限。结合 P0-1,这是完整的权限提升链。 - 修复方向:onboarding 不应写
usersToRoles,角色分配由管理员后台处理。
🟠 P1 级:架构违规
P1-1 shared 层反向承载领域逻辑
- 位置:onboarding-gate.tsx 整文件
- 问题:位于
shared/components/,含角色判断、班级代码、教师科目配置等强领域逻辑,通过 fetch 调用业务 API。 - 违反:项目规则「shared 不得反向依赖 @/auth、@/proxy 或任何 modules/*」。
- 架构图标记:004 文档 §2.1 已标记 P2-4。
P1-2 app 层 API 直接跨模块写表
- 位置:complete/route.ts:6
- 问题:直接 import 并写入
classes、classSubjectTeachers、subjects表,绕过modules/classes的 data-access 与权限校验。 - 违反:项目规则「app 只能调用 modules 的 Server Actions 和 data-access」「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归一化),组件用权限点重新推断,两套逻辑可能不一致。
P1-4 auth.ts 未注入 onboarded 状态(v2 新增)
- 位置:auth.ts:122-177 jwt/session 回调
- 问题:jwt 回调每次刷新都查
users.name+usersToRoles+roles三张表(第 143-153 行),但只读name,未读onboardedAt,token 里永远没有 onboarding 状态。 - 后果链:
proxy.ts(middleware)用getToken读 token,无法判断 onboarded → 无法做重定向拦截status/route.ts必须每次查库判断required→ 性能损耗- 客户端无法从
session.user读取 onboarded → 必须额外 fetch onFinish调update()后,token 刷新但 onboarded 仍未注入 → 即便有 middleware 也拦不住
- 修复方向:jwt 回调
columns: { name: true, onboardedAt: true },注入token.onboarded = !!fresh.onboardedAt;session 回调暴露session.user.onboarded。
P1-5 onboarding 绕过 classes 模块封装(v2 新增)
- 位置:complete/route.ts:95-130
- 问题:
modules/classes/data-access.ts已提供enrollTeacherByInvitationCode(第 589 行),含「教师身份校验」「科目已分配校验」「只认领空缺位置」等安全逻辑。onboarding 未调用它,而是直接 insertclassSubjectTeachers,绕过全部校验。 - 后果:与 P0-4 叠加,形成完整越权路径。
- 违反:项目规则「modules 之间通过对方 data-access 通信」。
🟡 P2 级:用户体验与可访问性
P2-1 全局 Dialog 模式缺陷
- 不可关闭(
canClose = !required);刷新丢步;无独立 URL;首屏无骨架屏;useEffect拉取期间闪烁。 - 对比:业界主流(Auth.js 官方、Clerk、Vercel 模板)均采用独立路由
/onboarding+ middleware 重定向。
P2-2 表单校验粗糙
- 电话仅校验非空(无手机号格式);姓名/地址无长度限制;班级代码无格式预校验。
P2-3 国际化与可访问性
- 中英文混合("Role"、"Select role" 英文);Dialog 缺
aria-describedby;进度条无aria-valuenow。
P2-4 进度条与步骤不一致
- admin 跳过 Step 2,但进度条仍渲染 4 段,Step 2 永远亮起。
P2-5 完成跳转硬编码 /dashboard(v2 新增)
- 位置:onboarding-gate.tsx:154
- 问题:
router.push("/dashboard")硬编码,但 proxy.ts:23-30 的resolveDefaultPath按角色返回/admin/dashboard、/teacher/dashboard、/student/dashboard、/parent/dashboard。 - 后果:非 admin 用户完成 onboarding 后跳
/dashboard(不存在),被 proxy 权限检查拦截后重定向,体验为"完成→闪跳→再跳"。
P2-6 家长角色推断死锁(v2 新增)
- 位置:onboarding-gate.tsx:90-94
- 问题:
const isTeacher = permissions.includes(EXAM_CREATE) const isStudent = permissions.includes(HOMEWORK_SUBMIT) && !permissions.includes(EXAM_CREATE) const isParent = !EXAM_CREATE && !HOMEWORK_SUBMIT && permissions.includes(EXAM_READ)isTeacher先判断且包含EXAM_READ(teacher 有 EXAM_READ),家长条件!EXAM_CREATE && EXAM_READ与 teacher 重叠- 实际角色权限映射中,parent 是否有
EXAM_READ存疑;若 parent 无EXAM_READ,则isParent永远为 false → 家长在 Step 2 看到"暂不需要配置"的分支永远不触发,可能落到空白页
- 后果:家长角色无法被正确识别,Step 2 渲染异常。
P2-7 学生注册无错误处理(v2 新增)
- 位置:complete/route.ts:89-93
- 问题:
enrollStudentByInvitationCode会 throw(如无效邀请码),但无 try/catch。一个无效码导致整个请求 500,而前面的update users已执行(无事务)→ 用户 name/phone 已更新但onboardedAt仍为 null → 下次登录反复弹窗且数据不一致。
P2-8 useEffect 依赖导致重复弹窗(v2 新增)
- 位置:onboarding-gate.tsx:45-68
- 问题:useEffect 依赖
[status, session?.user?.name]。auth.tsjwt 回调每次刷新会重读users.name并写入 token(auth.ts:158),若 name 变化(如管理员改了用户名),session.user.name 变化触发 useEffect 重新拉取 status → 可能重复弹窗。
P2-9 不可关闭 Dialog 的冗余 effect(v2 新增)
- 位置:onboarding-gate.tsx:70-74
- 问题:
此 effect 在 open 被 Dialog 的
useEffect(() => { if (!open) return if (!required) return setOpen(true) // 冗余:open 已为 true }, [open, required])onOpenChange关闭时强制重开,实现"不可关闭"。但逻辑脆弱:若 required 在异步中变化,可能产生状态竞态。应改为在onOpenChange中直接判断if (!canClose) return。
四、业界大仓(Monorepo)解决方案引用
4.1 Auth.js v5 官方推荐
- 状态标记:
users.onboardedAt+jwt/session回调注入;完成时调update()刷新 token。 - 强制方式:middleware 重定向到独立
/onboarding路由。在proxy.ts(Next.js 16 的 middleware)用getToken读取onboarded,未完成且非白名单路径 →NextResponse.redirect('/onboarding')。 - 结论:客户端 Dialog 仅适合"非阻塞偏好补全";强制 onboarding 应等同未登录处理。
4.2 商业方案(Clerk / Supabase / Auth0)共性
三段式:metadata 标记 + 强制重定向独立路由 + 服务端 Action 校验。
- 角色等敏感字段放服务端可写的 metadata,禁止前端自写。
- onboarding 完成回调必须由服务端 Action 写入,前端不能直接改。
4.3 shadcn/ui 生态
- 官方无内置 Stepper,但
examples/forms与blocks范式明确:独立路由页面 +<Form>(react-hook-form + zod)+ 父组件持 step state。 - 每步独立 zod schema 渐进式校验,最后一步汇总写入。
4.4 企业级 K12 教务系统(PowerSchool / Veracross / 国内智慧校园)
铁律:角色由管理员预分配,用户不可自选。
| 角色 | 首次登录采集字段 | 角色来源 |
|---|---|---|
| 学生 | 学号(预分配不可改)、姓名、性别、出生日期、家长联系方式、紧急联系人 | 管理员批量导入 |
| 教师 | 工号(预分配)、姓名、所教科目、任教班级、办公室、联系电话、学历资质 | 教务处预分配 |
| 家长 | 与学生关系、学生学号(通过 Access ID + Access Password 绑定)、本人姓名、电话、邮箱 | 学校发放凭证,家长绑定子女 |
| 管理员 | 工号、姓名、职务、管理范围 | 学校 IT 创建 |
4.5 Monorepo(turborepo / nx)惯例
- 跨模块"流程型"功能(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/ # 登录页(proxy 白名单)
├─ (onboarding)/onboarding/ # 新增独立路由
│ └─ page.tsx # 服务端组件,读 session.onboarded 决定渲染
└─ proxy.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
├─ RoleConfirmStep.tsx # 只读展示管理员分配的角色
├─ ProfileStep.tsx # 姓名/电话/住址
└─ BindingStep.tsx # 学生:确认班级;教师:确认任课;家长:绑定子女
shared/
└─ components/onboarding-gate.tsx # 删除
5.2 关键改动点
- auth.ts 回调注入 onboarded(P1-4):jwt 回调
columns: { name: true, onboardedAt: true },token.onboarded = !!fresh.onboardedAt;session 回调暴露session.user.onboarded。 - proxy.ts 增加 onboarding 拦截:读
token.onboarded,未完成且路径不在白名单(/login、/api/auth、/onboarding、静态资源)→ 重定向/onboarding。 - 删除
shared/components/onboarding-gate.tsx,从app/layout.tsx移除挂载。 - 新建
modules/onboarding/,承载所有领域逻辑。 - 新建
app/(onboarding)/onboarding/page.tsx独立路由。 - 删除
app/api/onboarding/*/route.ts,改为modules/onboarding/actions.ts的 Server Action。 - 角色只读化(P0-1/P0-5):Step 0 改为"角色确认"——只读展示
usersToRoles中的角色,用户不可改;onboarding 不写usersToRoles。 - 班级绑定改造(P0-2/P0-4/P1-5):
- 学生:仅"确认"管理员预分配的班级,或输入邀请码(调
enrollStudentByInvitationCode) - 教师:必须调
enrollTeacherByInvitationCode(含"Subject already assigned"校验),禁止直接 insert;理想方案是仅"确认"管理员预分配 - 家长:输入"子女学号 + 绑定码"绑定子女(参考 PowerSchool Access ID 模式)
- 学生:仅"确认"管理员预分配的班级,或输入邀请码(调
- 事务化(P0-3/P2-7):
completeOnboardingAction用db.transaction()包裹所有写入,onboardedAt在事务最后写入。 - Zod 校验(P0-3):
onboardingSchema,phone 用z.string().regex(/^1\d{10}$/),namez.string().min(1).max(50),addressz.string().max(200).optional()。 - 完成跳转修正(P2-5):用
resolveDefaultPath(roles)替代硬编码/dashboard。 - 角色推断统一(P1-3/P2-6):删除组件内的权限点反推逻辑,统一从
session.user.roles(auth.ts 已注入)读取。
5.3 迁移兼容
- 已 onboarded 用户(
onboardedAt非空)不受影响,proxy 直接放行。 - 未 onboarded 用户下次登录被重定向到
/onboarding(而非弹 Dialog)。 - 无需数据迁移,
users.onboardedAt字段保留。
六、待决策的开放问题
Q1:角色分配策略
- 方案 A(推荐,符合 K12 铁律):onboarding 中角色完全只读,由管理员后台预分配;用户无法改变角色。
- 方案 B:保留角色选择,但服务端校验"用户已有该角色"才允许(即只能从已有角色中选主角色)。
- 方案 C:暂不改动角色选择,仅修复其他问题。
Q2:教师任课关系绑定
- 方案 A(推荐):onboarding 中教师仅确认管理员预分配的任课关系,不自填班级代码。
- 方案 B:保留自填邀请码,但必须调
enrollTeacherByInvitationCode(含"Subject already assigned"校验),禁止直接 insert。 - 方案 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。
Q6:auth.ts jwt 回调性能(v2 新增)
jwt 回调每次刷新查 3 张表(auth.ts:143-153)。注入 onboarded 可复用此次查库,但是否同步优化为「仅在登录时全量查、刷新时轻量查」?
- 方案 A:复用现有查库,只加
onboardedAt字段(最小改动)。 - 方案 B:重构为登录时全量、刷新时只查
onboardedAt(优化性能)。
七、附录:问题与代码位置速查
| 编号 | 问题 | 代码位置 | 风险 | v2 新增 |
|---|---|---|---|---|
| P0-1 | 用户自选角色 | onboarding-gate.tsx:192-201 | 🔴 | |
| P0-2 | 教师绑任意班级 | complete/route.ts:95-130 | 🔴 | |
| P0-3 | 无权限校验/Zod/事务 | complete/route.ts 整文件 | 🔴 | |
| P0-4 | 教师覆盖现有任课教师 | complete/route.ts:124-127 | 🔴 | ✅ |
| P0-5 | 角色追加越权 | complete/route.ts:82-87 | 🔴 | ✅ |
| P1-1 | shared 反向承载领域逻辑 | onboarding-gate.tsx 整文件 | 🟠 | |
| P1-2 | app 层跨模块写表 | complete/route.ts:6 | 🟠 | |
| P1-3 | 角色推断双源不一致 | status/route.ts:29-41 vs onboarding-gate.tsx:90-94 | 🟠 | |
| P1-4 | auth 未注入 onboarded | auth.ts:143-153 | 🟠 | ✅ |
| P1-5 | 绕过 classes 模块封装 | complete/route.ts:95-130 | 🟠 | ✅ |
| P2-1 | 全局 Dialog 缺陷 | app/layout.tsx:41 | 🟡 | |
| P2-2 | 表单校验粗糙 | onboarding-gate.tsx:88 | 🟡 | |
| P2-3 | i18n/a11y | onboarding-gate.tsx:188-194 | 🟡 | |
| P2-4 | 进度条与步骤不一致 | onboarding-gate.tsx:179-184 | 🟡 | |
| P2-5 | 完成跳转硬编码 /dashboard | onboarding-gate.tsx:154 | 🟡 | ✅ |
| P2-6 | 家长角色推断死锁 | onboarding-gate.tsx:90-94 | 🟡 | ✅ |
| P2-7 | 学生注册无错误处理 | complete/route.ts:89-93 | 🟡 | ✅ |
| P2-8 | useEffect 重复弹窗 | onboarding-gate.tsx:45-68 | 🟡 | ✅ |
| P2-9 | 冗余不可关闭 effect | onboarding-gate.tsx:70-74 | 🟡 | ✅ |