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:
294
src/modules/onboarding/actions.ts
Normal file
294
src/modules/onboarding/actions.ts
Normal 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-7,v3 对标 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
|
||||
}
|
||||
139
src/modules/onboarding/data-access.ts
Normal file
139
src/modules/onboarding/data-access.ts
Normal 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-DD,365 种可能)
|
||||
* - 验证因子 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"
|
||||
}
|
||||
56
src/modules/onboarding/types.ts
Normal file
56
src/modules/onboarding/types.ts
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user