# 首次登录引导(Onboarding)重大问题讨论 > 创建日期:2026-06-18 > 状态:**讨论中,待决策** > 关联架构图:`docs/architecture/004_architecture_impact_map.md` §2.1 shared 层 / §3 已知问题 P2-4 > 关联代码: > - [src/shared/components/onboarding-gate.tsx](file:///e:/Desktop/CICD/src/shared/components/onboarding-gate.tsx)(312 行) > - [src/app/api/onboarding/status/route.ts](file:///e:/Desktop/CICD/src/app/api/onboarding/status/route.ts) > - [src/app/api/onboarding/complete/route.ts](file:///e:/Desktop/CICD/src/app/api/onboarding/complete/route.ts) > - [src/app/layout.tsx](file:///e:/Desktop/CICD/src/app/layout.tsx#L41)(全局挂载点) --- ## 一、背景与定位 按项目规则"先图后码",先从架构影响地图定位 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](file:///e:/Desktop/CICD/src/shared/db/schema.ts#L41)) - **被调用模块**:`modules/classes/data-access.ts` 的 `enrollStudentByInvitationCode` 当前 Onboarding 是一个**全局 Dialog**:在 `app/layout.tsx` 第 41 行无条件挂载 ``,组件内通过 `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 行)——用权限点反推角色: ```ts 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` → 教师直接 insert `classSubjectTeachers` → 写 `onboardedAt` --- ## 三、重大问题清单(按风险分级) ### 🔴 P0 级:安全/合规/越权 #### P0-1 用户可自选角色(严重越权) - **位置**:[onboarding-gate.tsx:192-201](file:///e:/Desktop/CICD/src/shared/components/onboarding-gate.tsx#L192-L201) - **问题**: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](file:///e:/Desktop/CICD/src/app/api/onboarding/complete/route.ts#L95-L130) - **问题**:教师通过 `classCodes`(6 位邀请码)可把自己写入任意班级的 `classSubjectTeachers`,且 `teacherSubjects` 由前端任意提交,服务端仅做"名称存在性"校验,不校验该教师是否被管理员分配到该班。 - **后果**:教师可越权查看任意班级学生名单、成绩;可篡改他人班级的任课关系。 - **违反规则**:项目规则「modules 之间通过对方 data-access 通信,不直接查询对方 DB 表」——此处 app 层 API 直接 insert `classSubjectTeachers`。 #### P0-3 无权限校验、无 Zod、无事务 - **位置**:[complete/route.ts](file:///e:/Desktop/CICD/src/app/api/onboarding/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](file:///e:/Desktop/CICD/src/shared/components/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](file:///e:/Desktop/CICD/src/app/api/onboarding/complete/route.ts#L6) - **问题**:`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](file:///e:/Desktop/CICD/src/app/api/onboarding/status/route.ts#L29-L41) vs [onboarding-gate.tsx:90-94](file:///e:/Desktop/CICD/src/shared/components/onboarding-gate.tsx#L90-L94) - **问题**: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` 触发重复请求 - **对比**:业界主流(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` / Auth0 `appMetadata` / Supabase RLS-protected `profiles.role`),**禁止前端自写**。 - onboarding 完成回调必须由服务端 Action 写入 metadata,前端不能直接改。 - 未完成 onboarding 时 middleware/Action 层强制重定向。 ### 4.3 shadcn/ui 生态 - 官方无内置 Stepper,但 `examples/forms` 与 `blocks` 范式明确:**独立路由页面 + `
`(react-hook-form + zod)+ 父组件持 step state**。 - 每步独立 zod schema 做渐进式校验,最后一步汇总写入。 - 官方 `blocks/login-04` 等登录块均采用独立路由页面,而非全局 Dialog。 ### 4.4 企业级 K12 教务系统(PowerSchool / Veracross / 国内智慧校园) **铁律:角色由管理员预分配,用户不可自选。** | 角色 | 首次登录采集字段 | 角色来源 | |------|------------------|----------| | 学生 | 学号(预分配不可改)、姓名、性别、出生日期、家长联系方式、紧急联系人 | 管理员批量导入 | | 教师 | 工号(预分配)、姓名、所教科目、任教班级、办公室、联系电话、学历资质 | 教务处预分配 | | 家长 | 与学生关系、学生学号(通过学校发放的 **Access ID + Access Password** 绑定)、本人姓名、电话、邮箱 | 学校发放凭证,家长绑定子女 | | 管理员 | 工号、姓名、职务、管理范围 | 学校 IT 创建 | **原因**: 1. **合规**:K12 数据受《个人信息保护法》《未成年人保护法》约束,学生身份必须由学校权威确认。 2. **安全**:允许自选教师角色 = 任何人可创建考试、查看全班成绩。 3. **数据一致性**:班级、学号、任课关系是教务核心数据,必须由教务处维护。 ### 4.5 Monorepo(turborepo / nx)惯例 - **turborepo 官方模板**:跨模块"流程型"功能(onboarding、setup-wizard)作为**独立 module**,而非塞进 shared。 - **nx feature-shell 模式**:onboarding 作为 `feature-onboarding` library,依赖 `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 关键改动点 1. **删除 `shared/components/onboarding-gate.tsx`**,从 `app/layout.tsx` 移除挂载。 2. **新建 `modules/onboarding/`**,承载所有领域逻辑。 3. **新建 `app/(onboarding)/onboarding/page.tsx`** 独立路由。 4. **增强 `middleware.ts`**:读取 session.onboarded,未完成且非白名单路径 → 重定向到 `/onboarding`。 5. **Auth.js 回调**:在 `jwt`/`session` 回调注入 `onboardedAt`,供 middleware 读取。 6. **删除 `app/api/onboarding/*/route.ts`**,改为 `modules/onboarding/actions.ts` 的 Server Action。 7. **角色只读化**:Step 0 改为"角色确认"——只读展示 `usersToRoles` 中的角色,用户不可改。 8. **班级绑定改造**: - 学生:仅"确认"管理员预分配的班级,或输入邀请码(服务端校验有效性 + 用途) - 教师:仅"确认"管理员预分配的任课关系,**移除自填班级代码** - 家长:输入"子女学号 + 绑定码"绑定子女(参考 PowerSchool Access ID 模式) 9. **事务化**:`completeOnboardingAction` 用 `db.transaction()` 包裹所有写入。 10. **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](file:///e:/Desktop/CICD/src/shared/components/onboarding-gate.tsx#L192-L201) | 🔴 P0 | | 信任前端 role 写入 | [complete/route.ts:32-35](file:///e:/Desktop/CICD/src/app/api/onboarding/complete/route.ts#L32-L35) | 🔴 P0 | | 教师绑任意班级 | [complete/route.ts:95-130](file:///e:/Desktop/CICD/src/app/api/onboarding/complete/route.ts#L95-L130) | 🔴 P0 | | 无权限校验/Zod/事务 | [complete/route.ts](file:///e:/Desktop/CICD/src/app/api/onboarding/complete/route.ts) 整文件 | 🔴 P0 | | shared 反向承载领域逻辑 | [onboarding-gate.tsx](file:///e:/Desktop/CICD/src/shared/components/onboarding-gate.tsx) 整文件 | 🟠 P1 | | app 层跨模块写表 | [complete/route.ts:6](file:///e:/Desktop/CICD/src/app/api/onboarding/complete/route.ts#L6) | 🟠 P1 | | 角色推断双源不一致 | [status/route.ts:29-41](file:///e:/Desktop/CICD/src/app/api/onboarding/status/route.ts#L29-L41) vs [onboarding-gate.tsx:90-94](file:///e:/Desktop/CICD/src/shared/components/onboarding-gate.tsx#L90-L94) | 🟠 P1 | | 全局 Dialog 缺陷 | [app/layout.tsx:41](file:///e:/Desktop/CICD/src/app/layout.tsx#L41) | 🟡 P2 | | 表单校验粗糙 | [onboarding-gate.tsx:88](file:///e:/Desktop/CICD/src/shared/components/onboarding-gate.tsx#L88) | 🟡 P2 |