diff --git a/src/modules/onboarding/actions.ts b/src/modules/onboarding/actions.ts new file mode 100644 index 0000000..29d1baf --- /dev/null +++ b/src/modules/onboarding/actions.ts @@ -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>> +> { + 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 | null, + formData: FormData +): Promise> { + 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 => 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 { + const result: Record = {} + for (const issue of error.issues) { + const key = issue.path.join(".") || "_" + if (!result[key]) result[key] = [] + result[key].push(issue.message) + } + return result +} diff --git a/src/modules/onboarding/data-access.ts b/src/modules/onboarding/data-access.ts new file mode 100644 index 0000000..353c82a --- /dev/null +++ b/src/modules/onboarding/data-access.ts @@ -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 { + 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 { + 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" +} diff --git a/src/modules/onboarding/types.ts b/src/modules/onboarding/types.ts new file mode 100644 index 0000000..af44615 --- /dev/null +++ b/src/modules/onboarding/types.ts @@ -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 +}