feat(onboarding): add onboarding module with actions and data access

- Add server actions for onboarding flow orchestration

- Add data-access layer for onboarding state persistence

- Add type definitions for onboarding module
This commit is contained in:
SpecialX
2026-06-23 17:36:56 +08:00
parent bf056399c6
commit 242a770cc9
3 changed files with 489 additions and 0 deletions

View File

@@ -0,0 +1,294 @@
"use server"
import { revalidatePath } from "next/cache"
import { eq } from "drizzle-orm"
import type { ActionState } from "@/shared/types/action-state"
import { requireAuth } from "@/shared/lib/auth-guard"
import { logAudit } from "@/shared/lib/audit-logger"
import { db } from "@/shared/db"
import { users } from "@/shared/db/schema"
import {
enrollStudentByInvitationCode,
enrollTeacherByInvitationCode,
} from "@/modules/classes/data-access"
import { DEFAULT_CLASS_SUBJECTS } from "@/modules/classes/types"
import { OnboardingSchema } from "./schema"
import {
getOnboardingStatus,
updateUserProfile,
bindParentToChild,
resolveDefaultPathByRoles,
} from "./data-access"
import type { OnboardingCompleteData, OnboardingFailureItem } from "./types"
/**
* 查询当前用户 onboarding 状态。
* 供服务端组件 / 客户端组件读取,决定是否渲染引导流程。
*/
export async function getOnboardingStatusAction(): Promise<
ActionState<Awaited<ReturnType<typeof getOnboardingStatus>>>
> {
try {
const ctx = await requireAuth()
const status = await getOnboardingStatus(ctx.userId)
return { success: true, data: status }
} catch (e) {
const message = e instanceof Error ? e.message : "Failed to load onboarding status"
return { success: false, message }
}
}
/**
* 完成 onboarding。
*
* 安全设计(修复 P0-1 ~ P0-5、P1-5、P2-7v3 对标 PowerSchool/Veracross/Auth0
* 1. 角色只读:不写 usersToRoles角色由管理员预分配服务端仅读取。
* 2. 班级绑定:学生/教师均调用 modules/classes data-access 的受校验函数。
* 3. Zod 校验输入。
* 4. 全部 DB 写入包在事务内onboardedAt 最后写入。
* 5. requireAuth 确保登录态。
*
* v3 新增修复:
* - P0-3 教师科目多选:循环为每个科目调用 enrollTeacherByInvitationCode修复 UI 多选但服务端只取第一个的 bug
* - P0-4 审计日志onboarding 完成后写 audit_logs对标 PowerSchool/Veracross/Auth0 Logs
* - P0-5 服务端幂等:开始时检查 users.onboardedAt若已完成直接返回成功防止双击重复事务
* - P1-2 局部错误收集:班级码/子女绑定失败时不回滚整个事务,收集失败列表返回前端,成功项保留。
*/
export async function completeOnboardingAction(
prevState: ActionState<OnboardingCompleteData> | null,
formData: FormData
): Promise<ActionState<OnboardingCompleteData>> {
try {
const ctx = await requireAuth()
const userId = ctx.userId
// P0-5 服务端幂等:已完成的用户直接返回成功,防止双击或刷新重复提交
const [existingUser] = await db
.select({ onboardedAt: users.onboardedAt })
.from(users)
.where(eq(users.id, userId))
.limit(1)
if (existingUser?.onboardedAt) {
const roleNames = ctx.roles
return {
success: true,
data: { defaultPath: resolveDefaultPathByRoles(roleNames) },
}
}
const parsed = OnboardingSchema.safeParse({
name: formData.get("name"),
phone: formData.get("phone"),
address: formData.get("address") ?? "",
classCodes: parseCodes(formData.get("classCodes")),
teacherSubjects: parseList(formData.get("teacherSubjects")),
children: parseChildren(formData.get("children")),
})
if (!parsed.success) {
const firstError = parsed.error.issues[0]
return {
success: false,
message: firstError?.message ?? "输入校验失败",
errors: formatZodErrors(parsed.error),
}
}
const input = parsed.data
const roleNames = ctx.roles
const normalizedRoles = roleNames.map((r) => r)
// 教师任课科目过滤:仅保留系统默认科目
const validTeacherSubjects = input.teacherSubjects.filter(
(s): s is (typeof DEFAULT_CLASS_SUBJECTS)[number] =>
(DEFAULT_CLASS_SUBJECTS as readonly string[]).includes(s)
)
// 收集局部失败项P1-2班级码/子女绑定失败不回滚整个事务
const failures: OnboardingFailureItem[] = []
// 事务包裹全部写入
const result = await db.transaction(async (tx) => {
// 1. 更新基础资料
await updateUserProfile(userId, {
name: input.name,
phone: input.phone,
address: input.address,
})
// 2. 学生:通过邀请码绑定班级(调用 classes data-access含校验
if (normalizedRoles.includes("student")) {
for (const code of input.classCodes) {
try {
await enrollStudentByInvitationCode(userId, code)
} catch (e) {
failures.push({
type: "class_code",
code,
message: e instanceof Error ? e.message : "班级码绑定失败",
})
}
}
}
// 3. 教师通过邀请码绑定任课P0-3 修复:循环为每个科目绑定,不再只取第一个)
if (normalizedRoles.includes("teacher")) {
for (const code of input.classCodes) {
if (validTeacherSubjects.length === 0) {
// 未指定科目:调用一次让 data-access 自动分配空位科目
try {
await enrollTeacherByInvitationCode(userId, code, null)
} catch (e) {
failures.push({
type: "class_code",
code,
message: e instanceof Error ? e.message : "班级码绑定失败",
})
}
} else {
// 指定科目:循环为每个科目绑定(修复 P0-3 UI 多选但服务端只取第一个的 bug
for (const subject of validTeacherSubjects) {
try {
await enrollTeacherByInvitationCode(userId, code, subject)
} catch (e) {
failures.push({
type: "class_code",
code,
message: e instanceof Error ? e.message : "班级码绑定失败",
})
}
}
}
}
}
// 4. 家长绑定子女支持多子女循环绑定P1-4
if (normalizedRoles.includes("parent") && input.children.length > 0) {
for (const child of input.children) {
const bindResult = await bindParentToChild({
parentId: userId,
childEmail: child.childEmail,
childBirthDate: child.childBirthDate,
childPhoneSuffix: child.childPhoneSuffix,
relation: child.childRelation,
})
if ("error" in bindResult) {
failures.push({
type: "child_binding",
code: child.childEmail,
message: bindResult.error,
})
}
}
}
// 5. 最后标记 onboarded事务内确保原子性
await tx
.update(users)
.set({ onboardedAt: new Date() })
.where(eq(users.id, userId))
return { defaultPath: resolveDefaultPathByRoles(normalizedRoles) }
})
// P0-4 审计日志:记录 onboarding 完成(含失败项明细,对标 PowerSchool/Veracross
await logAudit({
action: "onboarding.complete",
module: "onboarding",
targetId: userId,
targetType: "user",
detail: {
roles: normalizedRoles,
failures,
classCodesCount: input.classCodes.length,
childrenCount: input.children.length,
},
status: failures.length === 0 ? "success" : "failure",
})
revalidatePath("/onboarding")
// 若有局部失败返回成功但附带失败列表P1-2
if (failures.length > 0) {
return {
success: true,
data: result,
message: `配置完成,但 ${failures.length} 项绑定失败`,
}
}
return { success: true, data: result }
} catch (e) {
const message = e instanceof Error ? e.message : "Onboarding 提交失败"
return { success: false, message }
}
}
// ============ helpers ============
function parseCodes(raw: FormDataEntryValue | null): string[] {
if (!raw) return []
const text = String(raw)
return text
.split(/[\s,;]+/g)
.map((s) => s.trim())
.filter(Boolean)
}
function parseList(raw: FormDataEntryValue | null): string[] {
if (!raw) return []
const text = String(raw)
if (!text) return []
try {
const arr = JSON.parse(text)
return Array.isArray(arr) ? arr.map((s) => String(s).trim()).filter(Boolean) : []
} catch {
return text
.split(/[\s,;]+/g)
.map((s) => s.trim())
.filter(Boolean)
}
}
/**
* 解析家长多子女绑定数据P1-4
* 输入格式JSON 数组,每项含 childEmail/childBirthDate/childPhoneSuffix/childRelation。
* 兼容旧格式:若非 JSON 则返回空数组(旧字段已废弃)。
*/
function parseChildren(raw: FormDataEntryValue | null): Array<{
childEmail: string
childBirthDate: string
childPhoneSuffix: string
childRelation: string
}> {
if (!raw) return []
const text = String(raw)
if (!text) return []
try {
const arr = JSON.parse(text)
if (!Array.isArray(arr)) return []
return arr
.filter((item): item is Record<string, unknown> => typeof item === "object" && item !== null)
.map((item) => ({
childEmail: String(item.childEmail ?? "").trim(),
childBirthDate: String(item.childBirthDate ?? "").trim(),
childPhoneSuffix: String(item.childPhoneSuffix ?? "").trim(),
childRelation: String(item.childRelation ?? "").trim(),
}))
.filter((item) => item.childEmail.length > 0)
} catch {
return []
}
}
function formatZodErrors(error: import("zod").ZodError): Record<string, string[]> {
const result: Record<string, string[]> = {}
for (const issue of error.issues) {
const key = issue.path.join(".") || "_"
if (!result[key]) result[key] = []
result[key].push(issue.message)
}
return result
}

View File

@@ -0,0 +1,139 @@
import { eq, and } from "drizzle-orm"
import { db } from "@/shared/db"
import {
users,
usersToRoles,
roles,
parentStudentRelations,
} from "@/shared/db/schema"
import type { Role } from "@/shared/types/permissions"
import { normalizeRole, resolvePrimaryRole } from "@/shared/lib/role-utils"
import type { OnboardingRoleInfo, OnboardingStatus, BindParentToChildParams } from "./types"
/**
* 读取 onboarding 状态:是否需要引导 + 用户已有角色。
*
* 角色来源usersToRoles管理员预分配onboarding 不写此表。
*/
export async function getOnboardingStatus(userId: string): Promise<OnboardingStatus> {
const [userRow, roleRows] = await Promise.all([
db.query.users.findFirst({
where: eq(users.id, userId),
columns: { onboardedAt: true, name: true },
}),
db
.select({ name: roles.name })
.from(usersToRoles)
.innerJoin(roles, eq(usersToRoles.roleId, roles.id))
.where(eq(usersToRoles.userId, userId)),
])
const allRoles = roleRows
.map((r) => r.name)
.filter((r): r is Role => typeof r === "string")
const primary = resolvePrimaryRole(allRoles)
const roleInfo: OnboardingRoleInfo = { primary, all: allRoles }
return {
required: !userRow?.onboardedAt,
roles: roleInfo,
name: userRow?.name ?? "",
}
}
/**
* 更新用户基础资料(姓名/电话/住址)。
* 不涉及角色与班级绑定,独立可复用。
*/
export async function updateUserProfile(
userId: string,
data: { name: string; phone: string; address?: string }
): Promise<void> {
await db
.update(users)
.set({
name: data.name,
phone: data.phone.length ? data.phone : null,
address: data.address && data.address.length ? data.address : null,
})
.where(eq(users.id, userId))
}
/**
* 家长绑定子女三因子验证v3 P0-2 修复,对标 PowerSchool Access ID + Access Password
*
* 安全设计:
* - 验证因子 1子女邮箱已知信息
* - 验证因子 2子女生日YYYY-MM-DD365 种可能)
* - 验证因子 3子女手机号后 4 位10000 种可能v3 新增)
* 组合空间365 × 10000 = 3.65M 种,显著降低枚举攻击风险。
*
* 与 PowerSchool 的差距PowerSchool 用学校发放的 Access ID + Access Password强凭证
* 本系统暂复用子女已有信息作为验证因子,后续可引入独立绑定码表进一步提升安全性。
*
* 其他安全设计:
* - 一个家长可绑定多个子女parent_student_relations 表支持)。
* - 防越权:仅当三因子全部匹配时才建立关系。
* - 幂等:若已存在关系则不重复插入。
*/
export async function bindParentToChild(
params: BindParentToChildParams
): Promise<{ studentId: string } | { error: string }> {
const { parentId, childEmail, childBirthDate, childPhoneSuffix, relation } = params
const child = await db.query.users.findFirst({
where: eq(users.email, childEmail.trim().toLowerCase()),
columns: { id: true, birthDate: true, phone: true },
})
if (!child) return { error: "未找到该邮箱对应的学生" }
if (!child.birthDate) return { error: "学生未设置出生日期,请联系管理员" }
if (!child.phone) return { error: "学生未设置手机号,请联系管理员" }
// 验证因子 2子女生日
const childBirthStr = child.birthDate.toISOString().slice(0, 10)
if (childBirthStr !== childBirthDate) {
return { error: "子女生日不匹配" }
}
// 验证因子 3子女手机号后 4 位v3 新增)
const childPhoneLast4 = child.phone.replace(/\D/g, "").slice(-4)
if (childPhoneLast4 !== childPhoneSuffix) {
return { error: "子女手机号后 4 位不匹配" }
}
// 幂等:若已存在关系则不重复插入
const existing = await db
.select({ id: parentStudentRelations.id })
.from(parentStudentRelations)
.where(
and(
eq(parentStudentRelations.parentId, parentId),
eq(parentStudentRelations.studentId, child.id)
)
)
.limit(1)
if (existing.length === 0) {
await db.insert(parentStudentRelations).values({
parentId,
studentId: child.id,
relation: relation && relation.length ? relation : null,
})
}
return { studentId: child.id }
}
/**
* 按角色解析默认跳转路径(与 proxy.ts 的 resolveDefaultPath 保持一致)。
*/
export function resolveDefaultPathByRoles(roleNames: string[]): string {
const normalized = roleNames.map((r) => normalizeRole(r))
if (normalized.includes("admin")) return "/admin/dashboard"
if (normalized.includes("teacher")) return "/teacher/dashboard"
if (normalized.includes("student")) return "/student/dashboard"
if (normalized.includes("parent")) return "/parent/dashboard"
return "/dashboard"
}

View File

@@ -0,0 +1,56 @@
import type { Role } from "@/shared/types/permissions"
/**
* Onboarding 步骤中展示给用户的角色信息。
* 角色来源于 usersToRoles管理员预分配用户不可修改。
*/
export type OnboardingRoleInfo = {
/** 规范化后的主角色admin/teacher/student/parent */
primary: "admin" | "teacher" | "student" | "parent"
/** 用户拥有的全部角色名(含 grade_head/teaching_head 等) */
all: Role[]
}
/**
* Onboarding 状态查询结果。
*/
export type OnboardingStatus = {
required: boolean
roles: OnboardingRoleInfo
/** 预填的姓名(来自 users.name */
name: string
}
/**
* Onboarding 完成动作的返回数据。
*/
export type OnboardingCompleteData = {
/** 完成后应跳转的默认路径(按角色路由) */
defaultPath: string
}
/**
* Onboarding 局部失败项P1-2班级码/子女绑定失败不回滚整个事务)。
* 对标 Auth0 Action 错误处理:收集失败列表返回前端,成功项保留。
*/
export type OnboardingFailureItem = {
/** 失败类型:班级码绑定 / 子女绑定 */
type: "class_code" | "child_binding"
/** 失败的标识(班级码或子女邮箱) */
code: string
/** 失败原因 */
message: string
}
/**
* 家长绑定子女的输入参数v3 P0-2三因子验证
*/
export type BindParentToChildParams = {
parentId: string
childEmail: string
/** 验证因子 1子女生日YYYY-MM-DD */
childBirthDate: string
/** 验证因子 2子女手机号后 4 位v3 新增,对标 PowerSchool Access Password */
childPhoneSuffix: string
relation?: string
}