Files
NextEdu/docs/feature/001_first_login_onboarding.md
SpecialX 978d9a8309
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
feat: 新增备课模块并修复全模块 P0/P1/P2 缺陷
主要变更:

- 新增 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)
2026-06-22 01:06:16 +08:00

25 KiB
Raw Blame History

首次登录引导Onboarding重大问题讨论 · v2

版本:v2(替代 v12026-06-18 状态:讨论中,待决策 关联架构图:docs/architecture/004_architecture_impact_map.md §2.1 shared 层 / §3 已知问题 P2-4 关联代码:


、v2 与 v1 的差异说明

经 git 核实(git log + git status + git diffonboarding 相关代码自 v1 审查以来零改动

  • onboarding-gate.tsxapi/onboarding/*/route.tslayout.tsxauth.ts 均无修改
  • 工作区改动集中在 proxy.ts(权限常量替换)、schema.ts(新增 lesson_plans 表)等与 onboarding 无关的文件

v2 在 v1 基础上新增 9 项 v1 遗漏的问题标为「v2 新增」),其中含 2 项 P0 级越权漏洞。问题编号沿用 v1新增项顺延。


一、背景与定位

按项目规则"先图后码",从架构影响地图定位 Onboarding 节点:

  • shared 层components/onboarding-gate.tsx312 行)已被架构图标记 ⚠️ P2-4「业务逻辑泄漏到 shared」
  • app 层/api/onboarding/status/api/onboarding/complete 两条路由
  • 数据层users.onboardedAtschema.ts:41
  • 被调用模块modules/classes/data-access.tsenrollStudentByInvitationCode(学生路径);教师路径绕过 enrollTeacherByInvitationCode 直接写表

当前实现:全局 Dialog。app/layout.tsx 第 41 行无条件挂载 <OnboardingGate />,组件内 useEffect 拉取 /api/onboarding/statusrequired === true 时弹出不可关闭的 4 步 Dialog。


二、现状代码盘点

2.1 组件层onboarding-gate.tsx

步骤 标题 采集字段 备注
Step 0 角色选择 rolestudent/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/completeupdate users → insert usersToRoles → 学生调 enrollStudentByInvitationCode教师直接 insert classSubjectTeachers → 写 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-201complete/route.ts:32-35
  • 问题Step 0 允许任意登录用户自选 student/teacher/parentcomplete/route.ts 直接信任前端 body.role
  • 后果:任何注册用户可自封 teacher 获得 exam:createhomework:grade 等权限。
  • 违反K12 行业铁律「角色由管理员预分配」、项目规则「Server Action 必须用 requirePermission()」。

P0-2 教师可绑定任意班级+科目

  • 位置complete/route.ts:95-130
  • 问题:教师通过 classCodes6 位邀请码)可把自己写入任意班级的 classSubjectTeachersteacherSubjects 由前端任意提交,服务端仅做"名称存在性"校验。
  • 后果:教师可越权查看任意班级学生名单、成绩。

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.tsenrollTeacherByInvitationCode第 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.tsresolvePermissions(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 并写入 classesclassSubjectTeacherssubjects 表,绕过 modules/classes 的 data-access 与权限校验。
  • 违反项目规则「app 只能调用 modules 的 Server Actions 和 data-access」「modules 之间通过对方 data-access 通信」。

P1-3 角色推断双源不一致

P1-4 auth.ts 未注入 onboarded 状态v2 新增)

  • 位置auth.ts:122-177 jwt/session 回调
  • 问题jwt 回调每次刷新都查 users.name + usersToRoles + roles 三张表(第 143-153 行),但只读 name,未读 onboardedAttoken 里永远没有 onboarding 状态。
  • 后果链
    1. proxy.tsmiddlewaregetToken 读 token无法判断 onboarded → 无法做重定向拦截
    2. status/route.ts 必须每次查库判断 required → 性能损耗
    3. 客户端无法从 session.user 读取 onboarded → 必须额外 fetch
    4. onFinishupdate()token 刷新但 onboarded 仍未注入 → 即便有 middleware 也拦不住
  • 修复方向jwt 回调 columns: { name: true, onboardedAt: true },注入 token.onboarded = !!fresh.onboardedAtsession 回调暴露 session.user.onboarded

P1-5 onboarding 绕过 classes 模块封装v2 新增)

  • 位置complete/route.ts:95-130
  • 问题modules/classes/data-access.ts 已提供 enrollTeacherByInvitationCode第 589 行含「教师身份校验」「科目已分配校验」「只认领空缺位置」等安全逻辑。onboarding 未调用它,而是直接 insert classSubjectTeachers,绕过全部校验。
  • 后果:与 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 完成跳转硬编码 /dashboardv2 新增)

  • 位置onboarding-gate.tsx:154
  • 问题router.push("/dashboard") 硬编码,但 proxy.ts:23-30resolveDefaultPath 按角色返回 /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_READteacher 有 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.ts jwt 回调每次刷新会重读 users.name 并写入 tokenauth.ts:158),若 name 变化如管理员改了用户名session.user.name 变化触发 useEffect 重新拉取 status → 可能重复弹窗。

P2-9 不可关闭 Dialog 的冗余 effectv2 新增)

  • 位置onboarding-gate.tsx:70-74
  • 问题
    useEffect(() => {
      if (!open) return
      if (!required) return
      setOpen(true)  // 冗余open 已为 true
    }, [open, required])
    
    此 effect 在 open 被 Dialog 的 onOpenChange 关闭时强制重开,实现"不可关闭"。但逻辑脆弱:若 required 在异步中变化,可能产生状态竞态。应改为在 onOpenChange 中直接判断 if (!canClose) return

四、业界大仓Monorepo解决方案引用

4.1 Auth.js v5 官方推荐

  • 状态标记users.onboardedAt + jwt/session 回调注入;完成时调 update() 刷新 token。
  • 强制方式middleware 重定向到独立 /onboarding 路由。在 proxy.tsNext.js 16 的 middlewaregetToken 读取 onboarded,未完成且非白名单路径 → NextResponse.redirect('/onboarding')
  • 结论:客户端 Dialog 仅适合"非阻塞偏好补全";强制 onboarding 应等同未登录处理。

4.2 商业方案Clerk / Supabase / Auth0共性

三段式:metadata 标记 + 强制重定向独立路由 + 服务端 Action 校验

  • 角色等敏感字段放服务端可写的 metadata禁止前端自写
  • onboarding 完成回调必须由服务端 Action 写入,前端不能直接改。

4.3 shadcn/ui 生态

  • 官方无内置 Stepperexamples/formsblocks 范式明确:独立路由页面 + <Form>react-hook-form + zod+ 父组件持 step state
  • 每步独立 zod schema 渐进式校验,最后一步汇总写入。

4.4 企业级 K12 教务系统PowerSchool / Veracross / 国内智慧校园)

铁律:角色由管理员预分配,用户不可自选。

角色 首次登录采集字段 角色来源
学生 学号(预分配不可改)、姓名、性别、出生日期、家长联系方式、紧急联系人 管理员批量导入
教师 工号(预分配)、姓名、所教科目、任教班级、办公室、联系电话、学历资质 教务处预分配
家长 与学生关系、学生学号(通过 Access ID + Access Password 绑定)、本人姓名、电话、邮箱 学校发放凭证,家长绑定子女
管理员 工号、姓名、职务、管理范围 学校 IT 创建

4.5 Monorepoturborepo / nx惯例

  • 跨模块"流程型"功能onboarding、setup-wizard作为独立 module,而非塞进 shared。
  • nx feature-shell 模式onboarding 作为 feature-onboarding library依赖 data-access-userdata-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                       # completeOnboardingActionServer Action + requirePermission
├─ data-access.ts                   # 仅操作 users.onboardedAt
├─ schema.ts                        # Zodname/phone/address/classCodes
├─ types.ts
└─ components/
   ├─ OnboardingStepper.tsx
   ├─ RoleConfirmStep.tsx           # 只读展示管理员分配的角色
   ├─ ProfileStep.tsx               # 姓名/电话/住址
   └─ BindingStep.tsx               # 学生:确认班级;教师:确认任课;家长:绑定子女

shared/
└─ components/onboarding-gate.tsx   # 删除

5.2 关键改动点

  1. auth.ts 回调注入 onboardedP1-4jwt 回调 columns: { name: true, onboardedAt: true }token.onboarded = !!fresh.onboardedAtsession 回调暴露 session.user.onboarded
  2. proxy.ts 增加 onboarding 拦截:读 token.onboarded,未完成且路径不在白名单(/login/api/auth/onboarding、静态资源)→ 重定向 /onboarding
  3. 删除 shared/components/onboarding-gate.tsx,从 app/layout.tsx 移除挂载。
  4. 新建 modules/onboarding/,承载所有领域逻辑。
  5. 新建 app/(onboarding)/onboarding/page.tsx 独立路由。
  6. 删除 app/api/onboarding/*/route.ts,改为 modules/onboarding/actions.ts 的 Server Action。
  7. 角色只读化P0-1/P0-5Step 0 改为"角色确认"——只读展示 usersToRoles 中的角色,用户不可改;onboarding 不写 usersToRoles
  8. 班级绑定改造P0-2/P0-4/P1-5
    • 学生:仅"确认"管理员预分配的班级,或输入邀请码(调 enrollStudentByInvitationCode
    • 教师:必须调 enrollTeacherByInvitationCode(含"Subject already assigned"校验),禁止直接 insert理想方案是仅"确认"管理员预分配
    • 家长:输入"子女学号 + 绑定码"绑定子女(参考 PowerSchool Access ID 模式)
  9. 事务化P0-3/P2-7completeOnboardingActiondb.transaction() 包裹所有写入,onboardedAt 在事务最后写入。
  10. Zod 校验P0-3onboardingSchemaphone 用 z.string().regex(/^1\d{10}$/)name z.string().min(1).max(50)address z.string().max(200).optional()
  11. 完成跳转修正P2-5resolveDefaultPath(roles) 替代硬编码 /dashboard
  12. 角色推断统一P1-3/P2-6删除组件内的权限点反推逻辑统一从 session.user.rolesauth.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:暂不实现家长绑定,由管理员后台预绑定。

Q4onboarding 路由形态

  • 方案 A(推荐):单页 /onboarding + 客户端 stepper步骤状态用 query param 持久化)。
  • 方案 B:嵌套路由 /onboarding/role/onboarding/profile/onboarding/binding(每步独立 Server Action
  • 方案 C:保留全局 Dialog仅修复安全与架构问题。

Q5实施范围

  • 方案 A:一次性完成 P0 + P1 + P2 全部整改。
  • 方案 B(推荐):先做 P0安全/越权)+ P1架构P2UX后续迭代。
  • 方案 C:仅做 P0 紧急修复P1/P2 列入 backlog。

Q6auth.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 🟡