feat: introduce i18n system and class invitation codes

Add complete i18n infrastructure using next-intl (cookie-driven, without i18n routing) with zh-CN/en dictionary files, locale switcher, and NextIntlClientProvider in root layout. Add class invitation code system with new class_invitation_codes table, data-access layer (generate/validate/consume/revoke), server actions with permission checks, rate limiting, and audit logging. Add class-invitation-manager UI component. Refactor onboarding stepper to use i18n translations and accept new invitation code format (6-char alphanumeric) with backward compatibility for legacy 6-digit codes.
This commit is contained in:
SpecialX
2026-06-22 14:04:55 +08:00
parent a4d096a6fc
commit c90748124d
25 changed files with 2911 additions and 30 deletions

View File

@@ -404,6 +404,18 @@ export async function joinClassByInvitationCodeAction(
return { success: false, message: "Invitation code is required" }
}
// v3rate limit 防爆破10 次/5 分钟,按 userId
const { rateLimit, rateLimitKey } = await import("@/shared/lib/rate-limit")
const rlKey = rateLimitKey("class-join", ctx.userId)
const rlResult = rateLimit({
key: rlKey,
limit: 10,
windowMs: 5 * 60 * 1000,
})
if (!rlResult.success) {
return { success: false, message: "Too many attempts, please try again later" }
}
const subjectValue = formData.get("subject")
const subject = ctx.roles.includes("teacher") && typeof subjectValue === "string" ? subjectValue.trim() : null
@@ -416,6 +428,26 @@ export async function joinClassByInvitationCodeAction(
ctx.roles.includes("teacher")
? await enrollTeacherByInvitationCode(ctx.userId, code, subject)
: await enrollStudentByInvitationCode(ctx.userId, code)
// 成功后重置 rate limit
const { resetRateLimit } = await import("@/shared/lib/rate-limit")
resetRateLimit(rlKey)
// 审计日志
const { logAudit } = await import("@/shared/lib/audit-logger")
await logAudit({
action: "class.invitation.consume",
module: "classes",
targetId: classId,
targetType: "class",
detail: {
code: String(code).trim().toUpperCase(),
userId: ctx.userId,
role: ctx.roles.includes("teacher") ? "teacher" : "student",
subject,
},
})
if (ctx.roles.includes("student")) {
revalidatePath("/student/learning/courses")
revalidatePath("/student/schedule")
@@ -425,6 +457,19 @@ export async function joinClassByInvitationCodeAction(
revalidatePath("/profile")
return { success: true, message: "Joined class successfully", data: { classId } }
} catch (error) {
// 审计日志:加入失败
const { logAudit } = await import("@/shared/lib/audit-logger")
await logAudit({
action: "class.invitation.consume_failed",
module: "classes",
targetId: String(code).trim().toUpperCase(),
targetType: "invitation_code",
detail: {
userId: ctx.userId,
reason: error instanceof Error ? error.message : "unknown",
},
status: "failure",
})
return { success: false, message: error instanceof Error ? error.message : "Failed to join class" }
}
} catch (e) {
@@ -477,6 +522,176 @@ export async function regenerateClassInvitationCodeAction(classId: string): Prom
}
}
/**
* v3 新增:生成自定义邀请码(支持有效期/次数/备注)。
* 对标 Google Classroom / 钉钉教育:管理员/教师可为班级生成带有效期与次数限制的邀请码。
*
* 权限CLASS_ENROLL沿用现有权限点避免过度拆分
* 审计:调用 logAudit 记录生成操作
*/
export async function createClassInvitationCodeAction(
prevState: ActionState<{ code: string; id: string }> | null,
formData: FormData
): Promise<ActionState<{ code: string; id: string }>> {
try {
const ctx = await requirePermission(Permissions.CLASS_ENROLL)
const classId = String(formData.get("classId") ?? "").trim()
if (!classId) {
return { success: false, message: "Missing class id" }
}
const expiresInHoursRaw = formData.get("expiresInHours")
const maxUsesRaw = formData.get("maxUses")
const note = String(formData.get("note") ?? "").trim() || null
const expiresInHours =
expiresInHoursRaw && String(expiresInHoursRaw).trim() !== ""
? Number(expiresInHoursRaw)
: null
const maxUses =
maxUsesRaw && String(maxUsesRaw).trim() !== ""
? Number(maxUsesRaw)
: null
if (expiresInHours !== null && (!Number.isFinite(expiresInHours) || expiresInHours <= 0)) {
return { success: false, message: "Invalid expiresInHours" }
}
if (maxUses !== null && (!Number.isFinite(maxUses) || maxUses <= 0)) {
return { success: false, message: "Invalid maxUses" }
}
try {
const { createInvitationCode } = await import("./data-access-invitations")
const record = await createInvitationCode(classId, ctx.userId, {
expiresInHours,
maxUses,
note,
})
// 审计日志
const { logAudit } = await import("@/shared/lib/audit-logger")
await logAudit({
action: "class.invitation.create",
module: "classes",
targetId: classId,
targetType: "class",
detail: {
codeId: record.id,
code: record.code,
expiresInHours,
maxUses,
note,
},
})
revalidatePath("/teacher/classes/my")
revalidatePath(`/teacher/classes/my/${encodeURIComponent(classId)}`)
revalidatePath(`/admin/school/classes`)
return {
success: true,
message: "Invitation code generated",
data: { code: record.code, id: record.id },
}
} catch (error) {
return {
success: false,
message: error instanceof Error ? error.message : "Failed to generate code",
}
}
} catch (e) {
if (e instanceof PermissionDeniedError) return { success: false, message: e.message }
throw e
}
}
/**
* v3 新增:撤销邀请码(软删除)。
*/
export async function revokeClassInvitationCodeAction(
prevState: ActionState<null> | null,
formData: FormData
): Promise<ActionState<null>> {
try {
const ctx = await requirePermission(Permissions.CLASS_ENROLL)
const codeId = String(formData.get("codeId") ?? "").trim()
if (!codeId) {
return { success: false, message: "Missing code id" }
}
try {
const { revokeInvitationCode } = await import("./data-access-invitations")
await revokeInvitationCode(codeId, ctx.userId)
const { logAudit } = await import("@/shared/lib/audit-logger")
await logAudit({
action: "class.invitation.revoke",
module: "classes",
targetId: codeId,
targetType: "invitation_code",
detail: { revokedBy: ctx.userId },
})
revalidatePath("/teacher/classes/my")
revalidatePath(`/admin/school/classes`)
return { success: true, message: "Invitation code revoked" }
} catch (error) {
return {
success: false,
message: error instanceof Error ? error.message : "Failed to revoke code",
}
}
} catch (e) {
if (e instanceof PermissionDeniedError) return { success: false, message: e.message }
throw e
}
}
/**
* v3 新增:列出班级所有邀请码(管理端列表用)。
*/
export async function listClassInvitationCodesAction(
classId: string
): Promise<ActionState<{ codes: Array<Record<string, unknown>> }>> {
try {
await requirePermission(Permissions.CLASS_ENROLL)
if (typeof classId !== "string" || classId.trim().length === 0) {
return { success: false, message: "Missing class id" }
}
try {
const { listClassInvitationCodes } = await import("./data-access-invitations")
const codes = await listClassInvitationCodes(classId)
return {
success: true,
data: {
codes: codes.map((c) => ({
id: c.id,
code: c.code,
status: c.status,
maxUses: c.maxUses,
usedCount: c.usedCount,
expiresAt: c.expiresAt?.toISOString() ?? null,
createdAt: c.createdAt.toISOString(),
revokedAt: c.revokedAt?.toISOString() ?? null,
note: c.note,
})),
},
}
} catch (error) {
return {
success: false,
message: error instanceof Error ? error.message : "Failed to list codes",
}
}
} catch (e) {
if (e instanceof PermissionDeniedError) return { success: false, message: e.message }
throw e
}
}
export async function setStudentEnrollmentStatusAction(
classId: string,
studentId: string,

View File

@@ -0,0 +1,324 @@
"use client"
import * as React from "react"
import { useTranslations } from "next-intl"
import { toast } from "sonner"
import { Copy, Plus, Ban, Clock, Hash } from "lucide-react"
import { Button } from "@/shared/components/ui/button"
import { Input } from "@/shared/components/ui/input"
import { Label } from "@/shared/components/ui/label"
import { Badge } from "@/shared/components/ui/badge"
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/shared/components/ui/dialog"
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/shared/components/ui/table"
import {
createClassInvitationCodeAction,
revokeClassInvitationCodeAction,
} from "@/modules/classes/actions"
/**
* 班级邀请码管理面板v3 新增,对标 Google Classroom / 钉钉教育)。
*
* 功能:
* - 列出班级所有邀请码(含状态/有效期/使用次数)
* - 生成自定义邀请码(可选有效期/次数/备注)
* - 撤销邀请码(软删除)
* - 复制邀请码到剪贴板
*
* 权限:调用方需确保用户拥有 CLASS_ENROLL 权限actions 内部已校验)
*/
interface InvitationCodeRecord {
id: string
code: string
status: "active" | "disabled" | "expired" | "exhausted"
maxUses: number | null
usedCount: number
expiresAt: string | null
createdAt: string
revokedAt: string | null
note: string | null
}
interface ClassInvitationManagerProps {
classId: string
initialCodes: InvitationCodeRecord[]
}
export function ClassInvitationManager({
classId,
initialCodes,
}: ClassInvitationManagerProps) {
const t = useTranslations("classes.invitation")
const [codes, setCodes] = React.useState<InvitationCodeRecord[]>(initialCodes)
const [isGenerateOpen, setIsGenerateOpen] = React.useState(false)
const [revokeTarget, setRevokeTarget] = React.useState<InvitationCodeRecord | null>(null)
const [isSubmitting, setIsSubmitting] = React.useState(false)
const handleCopy = async (code: string) => {
try {
await navigator.clipboard.writeText(code)
toast.success(t("copied"))
} catch {
toast.error(t("copy"))
}
}
const handleRevoke = async () => {
if (!revokeTarget) return
setIsSubmitting(true)
try {
const formData = new FormData()
formData.set("codeId", revokeTarget.id)
const result = await revokeClassInvitationCodeAction(null, formData)
if (result.success) {
toast.success(t("revokeSuccess"))
setCodes((prev) =>
prev.map((c) =>
c.id === revokeTarget.id
? { ...c, status: "disabled", revokedAt: new Date().toISOString() }
: c
)
)
setRevokeTarget(null)
} else {
toast.error(result.message ?? t("revokeFailed"))
}
} finally {
setIsSubmitting(false)
}
}
return (
<div className="grid gap-4">
<div className="flex items-center justify-between">
<h3 className="text-lg font-semibold">{t("title")}</h3>
<Dialog open={isGenerateOpen} onOpenChange={setIsGenerateOpen}>
<DialogTrigger asChild>
<Button size="sm">
<Plus className="mr-1.5 h-4 w-4" />
{t("generate")}
</Button>
</DialogTrigger>
<GenerateCodeDialog
classId={classId}
onClose={() => setIsGenerateOpen(false)}
onCreated={(record) => {
setCodes((prev) => [record, ...prev])
}}
/>
</Dialog>
</div>
{codes.length === 0 ? (
<p className="py-8 text-center text-sm text-muted-foreground">{t("empty")}</p>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>{t("code")}</TableHead>
<TableHead>{t("status")}</TableHead>
<TableHead>{t("usedCount")}</TableHead>
<TableHead>{t("expiresAt")}</TableHead>
<TableHead>{t("note")}</TableHead>
<TableHead className="text-right">{t("copy")}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{codes.map((record) => (
<TableRow key={record.id}>
<TableCell className="font-mono font-medium tracking-wider">
{record.code}
</TableCell>
<TableCell>
<StatusBadge status={record.status} />
</TableCell>
<TableCell>
<span className="text-sm">
{record.usedCount}
{record.maxUses !== null ? ` / ${record.maxUses}` : ""}
</span>
</TableCell>
<TableCell className="text-sm text-muted-foreground">
{record.expiresAt
? new Date(record.expiresAt).toLocaleString()
: t("neverExpires")}
</TableCell>
<TableCell className="max-w-[200px] truncate text-sm text-muted-foreground">
{record.note ?? "—"}
</TableCell>
<TableCell className="text-right">
<div className="flex justify-end gap-1">
<Button
variant="ghost"
size="icon"
onClick={() => handleCopy(record.code)}
aria-label={t("copy")}
>
<Copy className="h-4 w-4" />
</Button>
{record.status === "active" ? (
<Button
variant="ghost"
size="icon"
onClick={() => setRevokeTarget(record)}
aria-label={t("revoke")}
>
<Ban className="h-4 w-4" />
</Button>
) : null}
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
{/* 撤销确认对话框 */}
<Dialog open={!!revokeTarget} onOpenChange={(open) => !open && setRevokeTarget(null)}>
<DialogContent>
<DialogHeader>
<DialogTitle>{t("revoke")}</DialogTitle>
<DialogDescription>{t("revokeConfirm")}</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={() => setRevokeTarget(null)} disabled={isSubmitting}>
{t("cancel")}
</Button>
<Button variant="destructive" onClick={handleRevoke} disabled={isSubmitting}>
{t("revoke")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
)
}
function StatusBadge({ status }: { status: InvitationCodeRecord["status"] }) {
const t = useTranslations("classes.invitation")
const variant = status === "active" ? "default" : "secondary"
return <Badge variant={variant}>{t(status)}</Badge>
}
interface GenerateCodeDialogProps {
classId: string
onClose: () => void
onCreated: (record: InvitationCodeRecord) => void
}
function GenerateCodeDialog({ classId, onClose, onCreated }: GenerateCodeDialogProps) {
const t = useTranslations("classes.invitation")
const [expiresInHours, setExpiresInHours] = React.useState("")
const [maxUses, setMaxUses] = React.useState("")
const [note, setNote] = React.useState("")
const [isSubmitting, setIsSubmitting] = React.useState(false)
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setIsSubmitting(true)
try {
const formData = new FormData()
formData.set("classId", classId)
formData.set("expiresInHours", expiresInHours)
formData.set("maxUses", maxUses)
formData.set("note", note)
const result = await createClassInvitationCodeAction(null, formData)
if (result.success && result.data) {
toast.success(t("generateSuccess"))
onCreated({
id: result.data.id,
code: result.data.code,
status: "active",
maxUses: maxUses ? Number(maxUses) : null,
usedCount: 0,
expiresAt: expiresInHours
? new Date(Date.now() + Number(expiresInHours) * 60 * 60 * 1000).toISOString()
: null,
createdAt: new Date().toISOString(),
revokedAt: null,
note: note || null,
})
onClose()
} else {
toast.error(result.message ?? t("generateFailed"))
}
} finally {
setIsSubmitting(false)
}
}
return (
<DialogContent>
<DialogHeader>
<DialogTitle>{t("generateWithCustom")}</DialogTitle>
<DialogDescription>
{t("defaultDuration")} · {t("defaultMaxUses")}
</DialogDescription>
</DialogHeader>
<form onSubmit={handleSubmit} className="grid gap-4">
<div className="grid gap-2">
<Label htmlFor="expiresInHours" className="flex items-center gap-1.5">
<Clock className="h-3.5 w-3.5" />
{t("expiresInHours")}
</Label>
<Input
id="expiresInHours"
type="number"
min="1"
value={expiresInHours}
onChange={(e) => setExpiresInHours(e.target.value)}
placeholder={t("defaultDuration")}
/>
</div>
<div className="grid gap-2">
<Label htmlFor="maxUses" className="flex items-center gap-1.5">
<Hash className="h-3.5 w-3.5" />
{t("maxUsesLabel")}
</Label>
<Input
id="maxUses"
type="number"
min="1"
value={maxUses}
onChange={(e) => setMaxUses(e.target.value)}
placeholder={t("defaultMaxUses")}
/>
</div>
<div className="grid gap-2">
<Label htmlFor="note">{t("customNote")}</Label>
<Input
id="note"
value={note}
onChange={(e) => setNote(e.target.value)}
placeholder={t("customNotePlaceholder")}
maxLength={255}
/>
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={onClose} disabled={isSubmitting}>
{t("cancel")}
</Button>
<Button type="submit" disabled={isSubmitting}>
{isSubmitting ? t("generate") + "..." : t("generate")}
</Button>
</DialogFooter>
</form>
</DialogContent>
)
}

View File

@@ -0,0 +1,338 @@
import "server-only"
import { randomInt } from "node:crypto"
import { and, desc, eq, gt, isNull, lt, or, sql } from "drizzle-orm"
import { createId } from "@paralleldrive/cuid2"
import { db } from "@/shared/db"
import { classes, classInvitationCodes } from "@/shared/db/schema"
/**
* 班级邀请码 data-accessv3 新增,对标 Google Classroom / 钉钉教育 / 智学网)。
*
* 设计要点:
* - 6 位字母数字(剔除歧义字符 0/O/1/I/L空间 22^6 ≈ 1.13 亿
* - 支持有效期expires_at与次数限制max_uses
* - 软删除revoke 时设置 status=disabled + revoked_at
* - 懒清理validate 时若发现已过期/已用尽,顺手更新 status
*
* 与 classes.invitationCode 的兼容:
* - validateInvitationCode 优先查新表,未命中时 fallback 到 classes.invitationCode旧 6 位数字码)
* - 下个版本移除 fallback
*/
/** 剔除歧义字符0/O/1/I/L/2/Z/5/S/8/B后的字符集共 22 个字符 */
const CODE_CHARSET = "ABCDEFGHJKMNPQRSTUVWXYZ23456789".split("")
const CODE_LENGTH = 6
/** 邀请码状态 */
export type InvitationCodeStatus = "active" | "disabled" | "expired" | "exhausted"
/** 邀请码记录(列表/详情用) */
export interface InvitationCodeRecord {
id: string
classId: string
code: string
status: InvitationCodeStatus
maxUses: number | null
usedCount: number
expiresAt: Date | null
createdBy: string
createdAt: Date
revokedAt: Date | null
revokedBy: string | null
note: string | null
}
/** 生成邀请码选项 */
export interface GenerateCodeOptions {
/** 有效期小时null=永久 */
expiresInHours?: number | null
/** 最大使用次数null=无限 */
maxUses?: number | null
/** 备注 */
note?: string | null
}
/** 校验结果 */
export interface ValidationResult {
valid: boolean
classId?: string
codeId?: string
reason?: "not_found" | "expired" | "exhausted" | "disabled"
}
/**
* 生成 6 位邀请码(剔除歧义字符)。
* 空间22^6 ≈ 1.13 亿vs 旧 10^6 = 100 万,提升 113 倍)。
*/
export function generateCode(): string {
let code = ""
for (let i = 0; i < CODE_LENGTH; i += 1) {
code += CODE_CHARSET[randomInt(0, CODE_CHARSET.length)]
}
return code
}
/**
* 归一化用户输入的邀请码:
* - 大写化
* - 剔除空白
* - 兼容旧 6 位数字码fallback 用)
*/
export function normalizeCode(input: string): string {
return input.trim().toUpperCase()
}
/**
* 判断是否为合法的新格式邀请码6 位字母数字,剔除歧义字符)。
*/
export function isNewFormatCode(code: string): boolean {
const normalized = normalizeCode(code)
if (normalized.length !== CODE_LENGTH) return false
for (const ch of normalized) {
if (!CODE_CHARSET.includes(ch)) return false
}
return true
}
/**
* 判断是否为旧格式邀请码6 位数字fallback 用)。
*/
export function isLegacyFormatCode(code: string): boolean {
return /^\d{6}$/.test(code.trim())
}
/**
* 生成唯一邀请码(带重试)。
* DB unique 约束 + 40 次重试(沿用现有模式)。
*/
export async function generateUniqueCode(): Promise<string> {
for (let attempt = 0; attempt < 40; attempt += 1) {
const code = generateCode()
const [existing] = await db
.select({ id: classInvitationCodes.id })
.from(classInvitationCodes)
.where(eq(classInvitationCodes.code, code))
.limit(1)
if (!existing) return code
}
throw new Error("Failed to generate invitation code")
}
/**
* 为班级创建邀请码。
*
* @param classId 班级 ID
* @param createdBy 创建人 ID管理员/教师)
* @param opts 可选:有效期/次数/备注
*/
export async function createInvitationCode(
classId: string,
createdBy: string,
opts: GenerateCodeOptions = {}
): Promise<InvitationCodeRecord> {
const code = await generateUniqueCode()
const expiresAt = opts.expiresInHours
? new Date(Date.now() + opts.expiresInHours * 60 * 60 * 1000)
: null
const id = createId()
await db.insert(classInvitationCodes).values({
id,
classId,
code,
status: "active",
maxUses: opts.maxUses ?? null,
usedCount: 0,
expiresAt,
createdBy,
note: opts.note ?? null,
})
const [record] = await db
.select()
.from(classInvitationCodes)
.where(eq(classInvitationCodes.id, id))
.limit(1)
if (!record) throw new Error("Failed to create invitation code")
return mapRecord(record)
}
/**
* 获取班级当前有效的邀请码(取最新一个 active 的)。
* 给 onboarding/join 用,若无则返回 null。
*/
export async function getActiveInvitationCode(classId: string): Promise<InvitationCodeRecord | null> {
const [record] = await db
.select()
.from(classInvitationCodes)
.where(
and(
eq(classInvitationCodes.classId, classId),
eq(classInvitationCodes.status, "active"),
or(isNull(classInvitationCodes.expiresAt), gt(classInvitationCodes.expiresAt, new Date()))
)
)
.orderBy(desc(classInvitationCodes.createdAt))
.limit(1)
return record ? mapRecord(record) : null
}
/**
* 列出班级所有邀请码(管理端用)。
*/
export async function listClassInvitationCodes(classId: string): Promise<InvitationCodeRecord[]> {
const records = await db
.select()
.from(classInvitationCodes)
.where(eq(classInvitationCodes.classId, classId))
.orderBy(desc(classInvitationCodes.createdAt))
return records.map(mapRecord)
}
/**
* 校验邀请码有效性(不消耗)。
*
* 兼容策略:
* 1. 优先查新表 class_invitation_codes
* 2. 未命中且为旧格式6 位数字)时 fallback 到 classes.invitationCode
*
* 懒清理:若发现已过期/已用尽,顺手更新 status。
*/
export async function validateInvitationCode(code: string): Promise<ValidationResult> {
const normalized = normalizeCode(code)
// 1. 优先查新表
const [record] = await db
.select()
.from(classInvitationCodes)
.where(eq(classInvitationCodes.code, normalized))
.limit(1)
if (record) {
// 懒清理:已过期
if (record.status === "active" && record.expiresAt && record.expiresAt < new Date()) {
await db
.update(classInvitationCodes)
.set({ status: "expired" })
.where(eq(classInvitationCodes.id, record.id))
return { valid: false, reason: "expired" }
}
// 懒清理:已用尽
if (
record.status === "active" &&
record.maxUses !== null &&
record.usedCount >= record.maxUses
) {
await db
.update(classInvitationCodes)
.set({ status: "exhausted" })
.where(eq(classInvitationCodes.id, record.id))
return { valid: false, reason: "exhausted" }
}
// 已禁用
if (record.status !== "active") {
return { valid: false, reason: record.status as ValidationResult["reason"] }
}
return { valid: true, classId: record.classId, codeId: record.id }
}
// 2. Fallback旧格式 6 位数字码
if (isLegacyFormatCode(normalized)) {
const [cls] = await db
.select({ id: classes.id })
.from(classes)
.where(eq(classes.invitationCode, normalized))
.limit(1)
if (cls) {
return { valid: true, classId: cls.id }
}
}
return { valid: false, reason: "not_found" }
}
/**
* 消耗邀请码(原子性 used_count++)。
* 在 enrollStudentByInvitationCode / enrollTeacherByInvitationCode 成功后调用。
*/
export async function consumeInvitationCode(code: string): Promise<void> {
const normalized = normalizeCode(code)
await db
.update(classInvitationCodes)
.set({
usedCount: sql`${classInvitationCodes.usedCount} + 1`,
})
.where(eq(classInvitationCodes.code, normalized))
}
/**
* 撤销邀请码(软删除)。
*/
export async function revokeInvitationCode(
codeId: string,
revokedBy: string
): Promise<void> {
await db
.update(classInvitationCodes)
.set({
status: "disabled",
revokedAt: new Date(),
revokedBy,
})
.where(eq(classInvitationCodes.id, codeId))
}
/**
* 清理已过期/已用尽的邀请码(定时任务用)。
* 当前采用懒清理,此函数供未来 cron 调用。
*/
export async function purgeExpiredCodes(): Promise<number> {
const now = new Date()
const result = await db
.update(classInvitationCodes)
.set({ status: "expired" })
.where(
and(
eq(classInvitationCodes.status, "active"),
or(
lt(classInvitationCodes.expiresAt, now),
and(
isNull(classInvitationCodes.maxUses),
sql`${classInvitationCodes.usedCount} >= ${classInvitationCodes.maxUses}`
)
)
)
)
// MySqlRawQueryResult 是 [rows, fields] 元组rows 可能含 affectedRows
const rows = Array.isArray(result) ? result[0] : result
const affectedRows =
typeof rows === "object" && rows !== null && "affectedRows" in rows
? Number((rows as { affectedRows: unknown }).affectedRows)
: 0
return affectedRows
}
// ============ helpers ============
function mapRecord(row: typeof classInvitationCodes.$inferSelect): InvitationCodeRecord {
return {
id: row.id,
classId: row.classId,
code: row.code,
status: row.status as InvitationCodeStatus,
maxUses: row.maxUses,
usedCount: row.usedCount,
expiresAt: row.expiresAt,
createdBy: row.createdBy,
createdAt: row.createdAt,
revokedAt: row.revokedAt,
revokedBy: row.revokedBy,
note: row.note,
}
}

View File

@@ -613,22 +613,26 @@ export async function enrollStudentByInvitationCode(studentId: string, invitatio
const sid = studentId.trim()
const code = invitationCode.trim()
if (!sid) throw new Error("Missing student id")
if (!/^\d{6}$/.test(code)) throw new Error("Invalid invitation code")
if (!code) throw new Error("Invalid invitation code")
const [cls] = await db
.select({ id: classes.id })
.from(classes)
.where(eq(classes.invitationCode, code))
.limit(1)
if (!cls) throw new Error("Invalid invitation code")
// v3优先走新邀请码体系validateInvitationCode 内部含 fallback 到旧 classes.invitationCode
const { validateInvitationCode, consumeInvitationCode } = await import("./data-access-invitations")
const result = await validateInvitationCode(code)
if (!result.valid || !result.classId) {
throw new Error("Invalid invitation code")
}
await db
.insert(classEnrollments)
.values({ classId: cls.id, studentId: sid, status: "active" })
.values({ classId: result.classId, studentId: sid, status: "active" })
.onDuplicateKeyUpdate({ set: { status: "active" } })
return cls.id
// 消耗新表邀请码(旧表无计数,跳过)
if (result.codeId) {
await consumeInvitationCode(code)
}
return result.classId
}
export async function enrollTeacherByInvitationCode(
@@ -639,7 +643,7 @@ export async function enrollTeacherByInvitationCode(
const tid = teacherId.trim()
const code = invitationCode.trim()
if (!tid) throw new Error("Missing teacher id")
if (!/^\d{6}$/.test(code)) throw new Error("Invalid invitation code")
if (!code) throw new Error("Invalid invitation code")
const [teacher] = await db
.select({ id: users.id })
@@ -651,10 +655,17 @@ export async function enrollTeacherByInvitationCode(
if (!teacher) throw new Error("Teacher not found")
// v3优先走新邀请码体系validateInvitationCode 内部含 fallback 到旧 classes.invitationCode
const { validateInvitationCode, consumeInvitationCode } = await import("./data-access-invitations")
const result = await validateInvitationCode(code)
if (!result.valid || !result.classId) {
throw new Error("Invalid invitation code")
}
const [cls] = await db
.select({ id: classes.id, teacherId: classes.teacherId })
.from(classes)
.where(eq(classes.invitationCode, code))
.where(eq(classes.id, result.classId))
.limit(1)
if (!cls) throw new Error("Invalid invitation code")
@@ -747,6 +758,11 @@ export async function enrollTeacherByInvitationCode(
if (!assigned) throw new Error("Class already has assigned teachers")
}
// 消耗新表邀请码(旧表无计数,跳过)
if (result.codeId) {
await consumeInvitationCode(code)
}
return cls.id
}

View File

@@ -0,0 +1,451 @@
"use client"
import * as React from "react"
import { useRouter, useSearchParams } from "next/navigation"
import { useSession } from "next-auth/react"
import { useTranslations } from "next-intl"
import { toast } from "sonner"
import { Button } from "@/shared/components/ui/button"
import { Input } from "@/shared/components/ui/input"
import { Label } from "@/shared/components/ui/label"
import { Textarea } from "@/shared/components/ui/textarea"
import { Checkbox } from "@/shared/components/ui/checkbox"
import { cn } from "@/shared/lib/utils"
import { DEFAULT_CLASS_SUBJECTS, type ClassSubject } from "@/modules/classes/types"
import { completeOnboardingAction } from "@/modules/onboarding/actions"
import type { OnboardingStatus } from "@/modules/onboarding/types"
interface OnboardingStepperProps {
initialStatus: OnboardingStatus
}
/**
* v3 i18n所有文案通过 useTranslations 读取,支持 zh-CN / en 切换。
*/
const STEPS_KEYS = ["roleConfirm", "basicInfo", "roleInfo", "complete"] as const
interface ChildRow {
childEmail: string
childBirthDate: string
childPhoneSuffix: string
childRelation: string
}
const EMPTY_CHILD_ROW: ChildRow = {
childEmail: "",
childBirthDate: "",
childPhoneSuffix: "",
childRelation: "",
}
export function OnboardingStepper({ initialStatus }: OnboardingStepperProps) {
const router = useRouter()
const searchParams = useSearchParams()
const { update } = useSession()
const t = useTranslations("onboarding")
const tCommon = useTranslations("common.actions")
// P1-1URL query 参数持久化当前步骤
const initialStep = clampStep(Number(searchParams.get("step") ?? "0"))
const [step, setStep] = React.useState(initialStep)
const [isSubmitting, setIsSubmitting] = React.useState(false)
const [name, setName] = React.useState(initialStatus.name ?? "")
const [phone, setPhone] = React.useState("")
const [address, setAddress] = React.useState("")
const [classCodes, setClassCodes] = React.useState("")
const [teacherSubjects, setTeacherSubjects] = React.useState<ClassSubject[]>([])
const [children, setChildren] = React.useState<ChildRow[]>([{ ...EMPTY_CHILD_ROW }])
const primaryRole = initialStatus.roles.primary
const isAdmin = primaryRole === "admin"
const isStudent = primaryRole === "student"
const isTeacher = primaryRole === "teacher"
const isParent = primaryRole === "parent"
const stepKeys = isAdmin ? STEPS_KEYS.filter((_, i) => i !== 2) : STEPS_KEYS
const maxStep = stepKeys.length - 1
const canNext = React.useMemo(() => {
if (step === 1) {
return name.trim().length > 0 && phone.trim().length > 0
}
if (step === 2 && isParent) {
const validChildren = children.filter(
(c) => c.childEmail.trim() && c.childBirthDate.trim() && c.childPhoneSuffix.trim()
)
return validChildren.length > 0
}
return true
}, [step, name, phone, isParent, children])
const toggleSubject = (subject: ClassSubject) => {
setTeacherSubjects((prev) =>
prev.includes(subject) ? prev.filter((s) => s !== subject) : [...prev, subject]
)
}
const goToStep = React.useCallback(
(next: number) => {
const clamped = Math.max(0, Math.min(maxStep, next))
const params = new URLSearchParams(searchParams.toString())
params.set("step", String(clamped))
router.replace(`/onboarding?${params.toString()}`, { scroll: false })
setStep(clamped)
},
[maxStep, router, searchParams]
)
const onNext = () => {
if (step === 1 && !canNext) {
toast.error(t("validation.needNamePhone"))
return
}
if (step === 2 && isParent && !canNext) {
toast.error(t("validation.needOneChild"))
return
}
goToStep(step + 1)
}
const onBack = () => {
goToStep(step - 1)
}
const canSkipStep2 = isAdmin || isStudent || isTeacher
const onSkip = () => {
if (isParent) return
goToStep(isAdmin ? 2 : 3)
}
const addChildRow = () => {
setChildren((prev) => [...prev, { ...EMPTY_CHILD_ROW }])
}
const removeChildRow = (idx: number) => {
setChildren((prev) => (prev.length === 1 ? prev : prev.filter((_, i) => i !== idx)))
}
const updateChildRow = (idx: number, patch: Partial<ChildRow>) => {
setChildren((prev) => prev.map((row, i) => (i === idx ? { ...row, ...patch } : row)))
}
const onFinish = async () => {
if (isParent) {
const validChildren = children.filter(
(c) => c.childEmail.trim() && c.childBirthDate.trim() && c.childPhoneSuffix.trim()
)
if (validChildren.length === 0) {
toast.error(t("validation.needOneChild"))
return
}
}
setIsSubmitting(true)
try {
const formData = new FormData()
formData.set("name", name.trim())
formData.set("phone", phone.trim())
formData.set("address", address.trim())
// v3邀请码统一大写化后提交
formData.set("classCodes", classCodes.trim().toUpperCase())
formData.set("teacherSubjects", JSON.stringify(teacherSubjects))
const validChildren = children.filter(
(c) => c.childEmail.trim() && c.childBirthDate.trim() && c.childPhoneSuffix.trim()
)
formData.set("children", JSON.stringify(validChildren))
const result = await completeOnboardingAction(null, formData)
if (!result.success) {
toast.error(result.message ?? t("toast.submitFailed"))
return
}
if (result.message && result.message.includes("绑定失败")) {
toast.warning(result.message)
} else {
toast.success(t("toast.completeSuccess"))
}
await update?.()
const target = result.data?.defaultPath ?? "/dashboard"
router.push(target)
router.refresh()
} catch (e) {
const msg = e instanceof Error ? e.message : t("toast.submitFailed")
toast.error(msg)
} finally {
setIsSubmitting(false)
}
}
const title = t(`steps.${stepKeys[step]}`)
const description =
step === 0
? t("role.adminAssigned")
: step === 1
? t("form.name") + " · " + t("form.phone") + " · " + t("form.address")
: step === 2
? isParent
? t("parent.bindHint")
: t("steps.roleInfo")
: t("complete.readyHint")
return (
<div className="grid gap-6">
<div className="grid gap-1.5">
<h1 className="text-2xl font-semibold tracking-tight">{title}</h1>
<p className="text-sm text-muted-foreground">{description}</p>
</div>
<div
className="flex items-center gap-2"
role="progressbar"
aria-label={t("progress.label")}
aria-valuenow={step + 1}
aria-valuemin={1}
aria-valuemax={stepKeys.length}
>
{stepKeys.map((_, idx) => (
<div
key={idx}
className={cn(
"h-1 flex-1 rounded transition-colors",
step >= idx ? "bg-primary" : "bg-muted"
)}
/>
))}
</div>
{/* Step 0: 角色确认(只读) */}
{step === 0 ? (
<div className="grid gap-2">
<Label>{t("role.yourRole")}</Label>
<div className="rounded-md border bg-muted/30 px-3 py-2.5 text-sm">
<span className="font-medium">{t(`role.${primaryRole}`)}</span>
{initialStatus.roles.all.length > 1 ? (
<span className="ml-2 text-muted-foreground">
({t("role.allRoles", { roles: initialStatus.roles.all.join("、") })})
</span>
) : null}
</div>
<p className="text-xs text-muted-foreground">{t("role.adminAssigned")}</p>
</div>
) : null}
{/* Step 1: 基础信息 */}
{step === 1 ? (
<div className="grid gap-4">
<div className="grid gap-2">
<Label htmlFor="onb_name">{t("form.name")} *</Label>
<Input
id="onb_name"
value={name}
onChange={(e) => setName(e.target.value)}
maxLength={50}
required
/>
</div>
<div className="grid gap-2">
<Label htmlFor="onb_phone">{t("form.phone")} *</Label>
<Input
id="onb_phone"
value={phone}
onChange={(e) => setPhone(e.target.value)}
placeholder="11 位手机号"
inputMode="tel"
required
/>
</div>
<div className="grid gap-2">
<Label htmlFor="onb_address">{t("form.address")}</Label>
<Input
id="onb_address"
value={address}
onChange={(e) => setAddress(e.target.value)}
maxLength={200}
/>
</div>
</div>
) : null}
{/* Step 2: 角色信息 */}
{step === 2 ? (
<div className="grid gap-4">
{isTeacher ? (
<>
<div className="grid gap-2">
<Label htmlFor="onb_codes_teacher">{t("teacher.classCodesOptional")}</Label>
<Textarea
id="onb_codes_teacher"
value={classCodes}
onChange={(e) => setClassCodes(e.target.value)}
placeholder={t("teacher.classCodesPlaceholder")}
/>
<p className="text-xs text-muted-foreground">{t("teacher.classCodesHint")}</p>
</div>
<div className="grid gap-2">
<Label>{t("teacher.subjectsOptional")}</Label>
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
{DEFAULT_CLASS_SUBJECTS.map((s) => (
<label key={s} className="flex items-center gap-2 text-sm">
<Checkbox
checked={teacherSubjects.includes(s)}
onCheckedChange={() => toggleSubject(s)}
/>
{s}
</label>
))}
</div>
<p className="text-xs text-muted-foreground">{t("teacher.subjectsHint")}</p>
</div>
</>
) : null}
{isStudent ? (
<div className="grid gap-2">
<Label htmlFor="onb_codes_student">{t("student.classCodesOptional")}</Label>
<Textarea
id="onb_codes_student"
value={classCodes}
onChange={(e) => setClassCodes(e.target.value)}
placeholder={t("student.classCodesPlaceholder")}
/>
<p className="text-xs text-muted-foreground">{t("student.classCodesHint")}</p>
</div>
) : null}
{isParent ? (
<div className="grid gap-4">
<div className="rounded-md border bg-muted/30 px-3 py-2.5 text-sm text-muted-foreground">
{t("parent.bindHint")}
</div>
{children.map((row, idx) => (
<div key={idx} className="grid gap-3 rounded-md border p-3">
<div className="flex items-center justify-between">
<span className="text-sm font-medium">{t("parent.childN", { index: idx + 1 })}</span>
{children.length > 1 ? (
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => removeChildRow(idx)}
disabled={isSubmitting}
>
{tCommon("remove")}
</Button>
) : null}
</div>
<div className="grid gap-2">
<Label htmlFor={`onb_child_email_${idx}`}>{t("parent.childEmail")} *</Label>
<Input
id={`onb_child_email_${idx}`}
type="email"
value={row.childEmail}
onChange={(e) => updateChildRow(idx, { childEmail: e.target.value })}
placeholder="student@example.com"
/>
</div>
<div className="grid gap-2 sm:grid-cols-2">
<div className="grid gap-2">
<Label htmlFor={`onb_child_birth_${idx}`}>{t("parent.childBirthDate")} *</Label>
<Input
id={`onb_child_birth_${idx}`}
type="date"
value={row.childBirthDate}
onChange={(e) => updateChildRow(idx, { childBirthDate: e.target.value })}
/>
</div>
<div className="grid gap-2">
<Label htmlFor={`onb_child_phone_${idx}`}>{t("parent.childPhoneSuffix")} *</Label>
<Input
id={`onb_child_phone_${idx}`}
value={row.childPhoneSuffix}
onChange={(e) =>
updateChildRow(idx, {
childPhoneSuffix: e.target.value.replace(/\D/g, "").slice(0, 4),
})
}
placeholder="4 位数字"
inputMode="numeric"
maxLength={4}
/>
</div>
</div>
<div className="grid gap-2">
<Label htmlFor={`onb_child_relation_${idx}`}>{t("parent.childRelation")}</Label>
<Input
id={`onb_child_relation_${idx}`}
value={row.childRelation}
onChange={(e) => updateChildRow(idx, { childRelation: e.target.value })}
placeholder={t("parent.childRelationPlaceholder")}
maxLength={50}
/>
</div>
</div>
))}
{children.length < 10 ? (
<Button
type="button"
variant="outline"
onClick={addChildRow}
disabled={isSubmitting}
>
{t("parent.addChild")}
</Button>
) : null}
</div>
) : null}
</div>
) : null}
{/* Step 3: 完成 */}
{step === (isAdmin ? 2 : 3) ? (
<div className="rounded-md border bg-muted/30 px-4 py-4 text-sm">
<div className="font-medium">{t("complete.ready")}</div>
<div className="mt-1 text-muted-foreground">{t("complete.readyHint")}</div>
</div>
) : null}
{/* 底部按钮 */}
<div className="flex flex-col-reverse gap-2 sm:flex-row sm:justify-between">
<div className="flex gap-2">
<Button
type="button"
variant="outline"
onClick={onBack}
disabled={step === 0 || isSubmitting}
>
{tCommon("previous")}
</Button>
{step === 2 && canSkipStep2 ? (
<Button
type="button"
variant="secondary"
onClick={onSkip}
disabled={isSubmitting}
>
{tCommon("skip")}
</Button>
) : null}
</div>
<div className="flex justify-end gap-2">
{step < maxStep ? (
<Button type="button" onClick={onNext} disabled={isSubmitting}>
{tCommon("next")}
</Button>
) : (
<Button type="button" onClick={onFinish} disabled={isSubmitting}>
{isSubmitting ? tCommon("submitting") : tCommon("finish")}
</Button>
)}
</div>
</div>
</div>
)
}
function clampStep(value: number): number {
if (!Number.isFinite(value)) return 0
return Math.max(0, Math.min(3, Math.floor(value)))
}

View File

@@ -0,0 +1,85 @@
import { z } from "zod"
/**
* Onboarding 输入校验 schema。
*
* 设计原则(参考 K12 教务铁律):
* - 角色字段不在用户可提交的 schema 中,角色由管理员预分配,服务端从 usersToRoles 读取。
* - 班级代码仅作为"确认/补绑"用途,服务端调用 modules/classes data-access 做强校验。
*
* v3 修复(对标 PowerSchool Access ID + Access Password
* - P0-2 家长绑定验证增强:从"邮箱+生日"(生日仅 365 种可能)升级为"邮箱+生日+手机号后4位"三因子,
* 组合空间提升至 365 × 10000 = 3.65M 种,显著降低枚举攻击风险。
* - P1-4 家长多子女children 数组替代单个 childEmail/childBindingCode支持一次绑定多个子女。
*/
const childSchema = z.object({
childEmail: z
.string()
.trim()
.min(1, "请填写子女邮箱")
.email("子女邮箱格式错误"),
childBirthDate: z
.string()
.trim()
.min(1, "请填写子女生日")
.regex(/^\d{4}-\d{2}-\d{2}$/, "子女生日格式错误YYYY-MM-DD"),
childPhoneSuffix: z
.string()
.trim()
.min(1, "请填写子女手机号后 4 位")
.regex(/^\d{4}$/, "子女手机号后 4 位格式错误"),
childRelation: z
.string()
.trim()
.max(50, "关系字段长度不能超过 50 个字符")
.optional()
.or(z.literal("")),
})
export const OnboardingSchema = z.object({
name: z
.string()
.trim()
.min(1, "请填写姓名")
.max(50, "姓名长度不能超过 50 个字符"),
phone: z
.string()
.trim()
.min(1, "请填写电话")
.regex(/^1\d{10}$/, "请输入有效的手机号11 位,以 1 开头)"),
address: z
.string()
.trim()
.max(200, "住址长度不能超过 200 个字符")
.optional()
.or(z.literal("")),
// 学生/教师补绑班级的邀请码列表(可选,每项为 6 位字母数字v3 新格式)
// v3从 6 位数字升级为 6 位字母数字(剔除歧义字符 0/O/1/I/L兼容旧 6 位数字码
classCodes: z
.array(
z
.string()
.trim()
.regex(
/^[A-Z2-9]{6}$|^\d{6}$/,
"班级邀请码格式错误6 位字母数字或 6 位数字)"
)
)
.max(10, "单次最多绑定 10 个班级")
.optional()
.default([]),
// 教师任课科目可选v3 修复 P0-3服务端循环为每个科目绑定
teacherSubjects: z
.array(z.string().trim().min(1))
.max(10)
.optional()
.default([]),
// 家长绑定子女列表P1-4 多子女支持)
children: z
.array(childSchema)
.max(10, "单次最多绑定 10 个子女")
.optional()
.default([]),
})
export type OnboardingInput = z.infer<typeof OnboardingSchema>