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