# 首次登录引导(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](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#L41](file:///e:/Desktop/CICD/src/app/layout.tsx#L41)(全局挂载点,未变)
> - [src/auth.ts](file:///e:/Desktop/CICD/src/auth.ts)(jwt/session 回调,未注入 onboarded)
> - [src/proxy.ts](file:///e:/Desktop/CICD/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](file:///e:/Desktop/CICD/src/shared/db/schema.ts#L41))
- **被调用模块**:`modules/classes/data-access.ts` 的 `enrollStudentByInvitationCode`(学生路径);教师路径**绕过** `enrollTeacherByInvitationCode` 直接写表
当前实现:全局 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 行](file:///e:/Desktop/CICD/src/shared/components/onboarding-gate.tsx#L90-L94))用权限点反推角色。
### 2.2 API 层
- `GET /api/onboarding/status`:查 `users.onboardedAt` + 查 `usersToRoles` 推断角色
- `POST /api/onboarding/complete`:update users → insert usersToRoles → 学生调 `enrollStudentByInvitationCode` → **教师直接 insert `classSubjectTeachers`** → 写 `onboardedAt`
### 2.3 关键表结构(v2 补充)
| 表 | 主键 | 影响 |
|----|------|------|
| `usersToRoles` | `(userId, roleId)` 联合主键([schema.ts:118](file:///e:/Desktop/CICD/src/shared/db/schema.ts#L118)) | onDuplicateKeyUpdate 无法"替换"角色,只会新增行 → 追加角色 |
| `classSubjectTeachers` | `(classId, subjectId)` 联合主键([schema.ts:364](file:///e:/Desktop/CICD/src/shared/db/schema.ts#L364)) | 一个班级一个科目只有一位教师 → onDuplicateKeyUpdate 会**覆盖现有教师** |
---
## 三、重大问题清单(按风险分级)
### 🔴 P0 级:安全/合规/越权
#### P0-1 用户可自选角色(严重越权)
- **位置**:[onboarding-gate.tsx:192-201](file:///e:/Desktop/CICD/src/shared/components/onboarding-gate.tsx#L192-L201)、[complete/route.ts:32-35](file:///e:/Desktop/CICD/src/app/api/onboarding/complete/route.ts#L32-L35)
- **问题**: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](file:///e:/Desktop/CICD/src/app/api/onboarding/complete/route.ts#L95-L130)
- **问题**:教师通过 `classCodes`(6 位邀请码)可把自己写入任意班级的 `classSubjectTeachers`,`teacherSubjects` 由前端任意提交,服务端仅做"名称存在性"校验。
- **后果**:教师可越权查看任意班级学生名单、成绩。
#### 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 写入无 `db.transaction()`;运行时 `db.insert(roles)` 创建角色记录([第 66-68 行](file:///e:/Desktop/CICD/src/app/api/onboarding/complete/route.ts#L66-L68))属异常路径。
#### P0-4 教师可覆盖现有任课教师(v2 新增,严重破坏)
- **位置**:[complete/route.ts:124-127](file:///e:/Desktop/CICD/src/app/api/onboarding/complete/route.ts#L124-L127)
- **问题**:`classSubjectTeachers` 主键为 `(classId, subjectId)`([schema.ts:364](file:///e:/Desktop/CICD/src/shared/db/schema.ts#L364)),一个班级一个科目只有一位教师。onboarding 用 `onDuplicateKeyUpdate({ set: { teacherId: userId, ... } })`,**会直接覆盖该班级该科目已有的任课教师**。
- **对比**:`modules/classes/data-access.ts` 的 `enrollTeacherByInvitationCode`([第 637 行](file:///e:/Desktop/CICD/src/modules/classes/data-access.ts#L637))有完整校验 `if (mapping?.teacherId && mapping.teacherId !== tid) throw new Error("Subject already assigned")`,且只认领 `teacherId IS NULL` 的空缺位置([第 657 行](file:///e:/Desktop/CICD/src/modules/classes/data-access.ts#L657))。onboarding **绕过了该函数**,直接 insert。
- **后果**:任何自封教师的人可抢占全校任意班级的任课位置,踢掉真实任课教师,篡改任课关系。
- **违反**:项目规则「modules 之间通过对方 data-access 通信,不直接查询对方 DB 表」。
#### P0-5 角色追加越权(v2 新增)
- **位置**:[complete/route.ts:82-87](file:///e:/Desktop/CICD/src/app/api/onboarding/complete/route.ts#L82-L87)
- **问题**:`usersToRoles` 主键为 `(userId, roleId)` 联合主键([schema.ts:118](file:///e:/Desktop/CICD/src/shared/db/schema.ts#L118))。`db.insert(usersToRoles).values({ userId, roleId }).onDuplicateKeyUpdate({ set: { roleId } })` 中,`set roleId` 无意义(roleId 已是要插入的值)。当用户已有其他 roleId 时,此操作**新增一行**而非替换——即**追加角色记录**。
- **后果**:学生自选 teacher 角色后,给自己追加一条 teacher 角色行;`auth.ts` 的 `resolvePermissions(allRoles)` 会合并所有角色权限([auth.ts:131](file:///e:/Desktop/CICD/src/auth.ts#L131)),学生因此获得 teacher 全部权限。结合 P0-1,这是完整的权限提升链。
- **修复方向**:onboarding 不应写 `usersToRoles`,角色分配由管理员后台处理。
### 🟠 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/*」。
- **架构图标记**: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)
- **问题**:直接 import 并写入 `classes`、`classSubjectTeachers`、`subjects` 表,绕过 `modules/classes` 的 data-access 与权限校验。
- **违反**:项目规则「app 只能调用 modules 的 Server Actions 和 data-access」「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` 归一化),组件用权限点重新推断,两套逻辑可能不一致。
#### P1-4 auth.ts 未注入 onboarded 状态(v2 新增)
- **位置**:[auth.ts:122-177](file:///e:/Desktop/CICD/src/auth.ts#L122-L177) jwt/session 回调
- **问题**:jwt 回调每次刷新都查 `users.name` + `usersToRoles` + `roles` 三张表([第 143-153 行](file:///e:/Desktop/CICD/src/auth.ts#L143-L153)),但**只读 `name`,未读 `onboardedAt`**,token 里永远没有 onboarding 状态。
- **后果链**:
1. `proxy.ts`(middleware)用 `getToken` 读 token,无法判断 onboarded → 无法做重定向拦截
2. `status/route.ts` 必须每次查库判断 `required` → 性能损耗
3. 客户端无法从 `session.user` 读取 onboarded → 必须额外 fetch
4. `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](file:///e:/Desktop/CICD/src/app/api/onboarding/complete/route.ts#L95-L130)
- **问题**:`modules/classes/data-access.ts` 已提供 `enrollTeacherByInvitationCode`([第 589 行](file:///e:/Desktop/CICD/src/modules/classes/data-access.ts#L589)),含「教师身份校验」「科目已分配校验」「只认领空缺位置」等安全逻辑。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 完成跳转硬编码 /dashboard(v2 新增)
- **位置**:[onboarding-gate.tsx:154](file:///e:/Desktop/CICD/src/shared/components/onboarding-gate.tsx#L154)
- **问题**:`router.push("/dashboard")` 硬编码,但 [proxy.ts:23-30](file:///e:/Desktop/CICD/src/proxy.ts#L23-L30) 的 `resolveDefaultPath` 按角色返回 `/admin/dashboard`、`/teacher/dashboard`、`/student/dashboard`、`/parent/dashboard`。
- **后果**:非 admin 用户完成 onboarding 后跳 `/dashboard`(不存在),被 proxy 权限检查拦截后重定向,体验为"完成→闪跳→再跳"。
#### P2-6 家长角色推断死锁(v2 新增)
- **位置**:[onboarding-gate.tsx:90-94](file:///e:/Desktop/CICD/src/shared/components/onboarding-gate.tsx#L90-L94)
- **问题**:
```ts
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](file:///e:/Desktop/CICD/src/app/api/onboarding/complete/route.ts#L89-L93)
- **问题**:`enrollStudentByInvitationCode` 会 throw(如无效邀请码),但无 try/catch。一个无效码导致整个请求 500,而前面的 `update users` 已执行(无事务)→ 用户 name/phone 已更新但 `onboardedAt` 仍为 null → 下次登录反复弹窗且数据不一致。
#### P2-8 useEffect 依赖导致重复弹窗(v2 新增)
- **位置**:[onboarding-gate.tsx:45-68](file:///e:/Desktop/CICD/src/shared/components/onboarding-gate.tsx#L45-L68)
- **问题**:useEffect 依赖 `[status, session?.user?.name]`。`auth.ts` jwt 回调每次刷新会重读 `users.name` 并写入 token([auth.ts:158](file:///e:/Desktop/CICD/src/auth.ts#L158)),若 name 变化(如管理员改了用户名),session.user.name 变化触发 useEffect 重新拉取 status → 可能重复弹窗。
#### P2-9 不可关闭 Dialog 的冗余 effect(v2 新增)
- **位置**:[onboarding-gate.tsx:70-74](file:///e:/Desktop/CICD/src/shared/components/onboarding-gate.tsx#L70-L74)
- **问题**:
```ts
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.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` 范式明确:**独立路由页面 + `