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:
@@ -404,6 +404,18 @@ export async function joinClassByInvitationCodeAction(
|
||||
return { success: false, message: "Invitation code is required" }
|
||||
}
|
||||
|
||||
// v3:rate 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,
|
||||
|
||||
324
src/modules/classes/components/class-invitation-manager.tsx
Normal file
324
src/modules/classes/components/class-invitation-manager.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
338
src/modules/classes/data-access-invitations.ts
Normal file
338
src/modules/classes/data-access-invitations.ts
Normal 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-access(v3 新增,对标 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,
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user