# 首次登录引导（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 行无条件挂载 `<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 行）——用权限点反推角色：

```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` 范式明确：**独立路由页面 + `<Form>`（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 |
