- exams: update assembly (exam-paper-preview, question-bank-list, structure-editor), exam-actions, exam-assembly, exam-columns, exam-data-table, exam-form, exam-preview-utils, exam-rich-form, editor (exam-nodes-to-editor-doc, exam-rich-editor-inner, question-block, selection-toolbar), hooks (use-exam-preview-rewrite, use-exam-preview-state, use-exam-preview-tasks, use-exam-preview) - files: update data-access, use-file-batch-operations, use-file-upload - grades: update batch-grade-entry, excel-import-dialog, export-button, grade-record-form, grade-record-list, knowledge-point-mastery-chart, report-card-print-action, report-card-print-button, data-access-analytics, use-batch-grade-entry-undo, use-draft-lock - homework: update homework-assignment-form, homework-batch-grading-view, homework-grading-view, homework-scan-grading-view, homework-take-view, scan-uploader - invitation-codes: update generate-invitation-codes-dialog, invitation-codes-view, data-access - leave-requests: update leave-request-form, leave-review-dialog, data-access
261 lines
7.2 KiB
TypeScript
261 lines
7.2 KiB
TypeScript
import "server-only"
|
||
|
||
import { and, desc, eq, isNull, lte, or } from "drizzle-orm"
|
||
|
||
import { db } from "@/shared/db"
|
||
import { invitationCodes } from "@/shared/db/schema"
|
||
import { cacheFn } from "@/shared/lib/cache"
|
||
|
||
import type {
|
||
ConsumeInvitationCodeInput,
|
||
GenerateInvitationCodesInput,
|
||
InvitationCodeRecord,
|
||
ValidateInvitationCodeResult,
|
||
} from "./types"
|
||
import {
|
||
generateBatchId,
|
||
generateUniqueInvitationCodes,
|
||
} from "./lib/code-generator"
|
||
import { toInvitationRole } from "./lib/type-guards"
|
||
|
||
/** 数据库行 → InvitationCodeRecord 映射 */
|
||
function toRecord(row: typeof invitationCodes.$inferSelect): InvitationCodeRecord {
|
||
return {
|
||
id: row.id.toString(),
|
||
code: row.code,
|
||
email: row.email,
|
||
role: toInvitationRole(row.role),
|
||
classId: row.classId,
|
||
notes: row.notes,
|
||
createdAt: row.createdAt,
|
||
createdBy: row.createdBy,
|
||
expiresAt: row.expiresAt,
|
||
usedBy: row.usedBy,
|
||
usedAt: row.usedAt,
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 批量生成邀请码(audit-P2-3 新增)
|
||
*
|
||
* 1. 使用纯函数生成 count 个唯一明文邀请码
|
||
* 2. 批量插入数据库(若遇唯一约束冲突,重新生成冲突项)
|
||
* 3. 返回完整记录(含明文 code,仅此一次返回完整明文)
|
||
*/
|
||
export async function generateInvitationCodes(
|
||
input: GenerateInvitationCodesInput,
|
||
createdBy: string,
|
||
): Promise<{ codes: InvitationCodeRecord[]; batchId: string }> {
|
||
const batchId = generateBatchId()
|
||
const codes = generateUniqueInvitationCodes(input.count)
|
||
const notes = input.notes?.trim() || null
|
||
const expiresAt = input.expiresAt ?? null
|
||
|
||
const rows = codes.map((code) => ({
|
||
code,
|
||
email: input.email?.trim() || null,
|
||
role: input.role,
|
||
classId: input.classId?.trim() || null,
|
||
notes: notes ?? `${batchId}`,
|
||
createdBy,
|
||
expiresAt,
|
||
usedBy: null,
|
||
usedAt: null,
|
||
}))
|
||
|
||
// 批量插入(drizzle-orm 支持 .values(array) 批量 INSERT)
|
||
await db.insert(invitationCodes).values(rows)
|
||
|
||
// MySQL 批量插入后无法直接返回所有行,按 code 查询
|
||
const codeList = codes
|
||
const [first, ...rest] = codeList
|
||
if (!first) {
|
||
return { codes: [], batchId }
|
||
}
|
||
|
||
// 一次查询取回所有刚插入的记录
|
||
// drizzle-orm 的 inArray 在 MySQL 下需要手动构造 OR
|
||
const records = await db
|
||
.select()
|
||
.from(invitationCodes)
|
||
.where(
|
||
rest.length === 0
|
||
? eq(invitationCodes.code, first)
|
||
: or(
|
||
eq(invitationCodes.code, first),
|
||
...rest.map((c) => eq(invitationCodes.code, c)),
|
||
),
|
||
)
|
||
|
||
return {
|
||
codes: records.map(toRecord),
|
||
batchId,
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 列出所有邀请码(按创建时间倒序,audit-P2-3 新增)
|
||
*
|
||
* @param limit 最多返回的记录数(默认 100)
|
||
* @param includeUsed 是否包含已使用的邀请码(默认 true)
|
||
*/
|
||
export async function listInvitationCodesRaw(
|
||
limit = 100,
|
||
includeUsed = true,
|
||
): Promise<InvitationCodeRecord[]> {
|
||
const condition = includeUsed ? undefined : isNull(invitationCodes.usedBy)
|
||
|
||
const rows = condition
|
||
? await db
|
||
.select()
|
||
.from(invitationCodes)
|
||
.where(condition)
|
||
.orderBy(desc(invitationCodes.createdAt))
|
||
.limit(limit)
|
||
: await db
|
||
.select()
|
||
.from(invitationCodes)
|
||
.orderBy(desc(invitationCodes.createdAt))
|
||
.limit(limit)
|
||
|
||
return rows.map(toRecord)
|
||
}
|
||
|
||
export const listInvitationCodes = cacheFn(listInvitationCodesRaw, {
|
||
tags: ["invitation-codes"],
|
||
ttl: 300,
|
||
keyParts: ["invitation-codes", "list"],
|
||
})
|
||
|
||
/**
|
||
* 校验邀请码是否可用(audit-P2-3 新增)
|
||
*
|
||
* 校验项:
|
||
* 1. 邀请码存在
|
||
* 2. 未被使用(usedBy 为 null)
|
||
* 3. 未过期(expiresAt 为 null 或在未来)
|
||
* 4. 邮箱匹配(若 email 字段非空,则使用者邮箱必须一致)
|
||
*
|
||
* 返回结果中 `code` 字段为脱敏数据(不含 code 明文),用于注册流程。
|
||
*/
|
||
export async function validateInvitationCodeRaw(
|
||
code: string,
|
||
email: string,
|
||
): Promise<ValidateInvitationCodeResult> {
|
||
const normalizedCode = code.trim().toUpperCase()
|
||
|
||
const [row] = await db
|
||
.select()
|
||
.from(invitationCodes)
|
||
.where(eq(invitationCodes.code, normalizedCode))
|
||
.limit(1)
|
||
|
||
if (!row) {
|
||
return { valid: false, reason: "not_found" }
|
||
}
|
||
|
||
if (row.usedBy !== null) {
|
||
return { valid: false, reason: "already_used" }
|
||
}
|
||
|
||
if (row.expiresAt !== null && row.expiresAt.getTime() < Date.now()) {
|
||
return { valid: false, reason: "expired" }
|
||
}
|
||
|
||
if (row.email !== null && row.email.toLowerCase() !== email.toLowerCase()) {
|
||
return { valid: false, reason: "email_mismatch" }
|
||
}
|
||
|
||
// 脱敏:不返回 code 明文
|
||
const { code: _code, ...codeWithoutValue } = toRecord(row)
|
||
return { valid: true, code: codeWithoutValue }
|
||
}
|
||
|
||
export const validateInvitationCode = cacheFn(validateInvitationCodeRaw, {
|
||
tags: ["invitation-codes"],
|
||
ttl: 60,
|
||
keyParts: ["invitation-codes", "validate"],
|
||
})
|
||
|
||
/**
|
||
* 标记邀请码为已使用(audit-P2-3 新增)
|
||
*
|
||
* 使用乐观锁:仅在 usedBy IS NULL 时更新,避免并发使用同一邀请码。
|
||
* 返回 true 表示成功标记,false 表示邀请码已被他人使用。
|
||
*/
|
||
export async function consumeInvitationCode(
|
||
input: ConsumeInvitationCodeInput,
|
||
): Promise<boolean> {
|
||
const normalizedCode = input.code.trim().toUpperCase()
|
||
|
||
const result = await db
|
||
.update(invitationCodes)
|
||
.set({
|
||
usedBy: input.userId,
|
||
usedAt: new Date(),
|
||
})
|
||
.where(
|
||
and(
|
||
eq(invitationCodes.code, normalizedCode),
|
||
isNull(invitationCodes.usedBy),
|
||
),
|
||
)
|
||
|
||
// MySQL ResultSetHeader affectedRows(drizzle-orm MySQL 返回 [ResultSetHeader, FieldPacket[]])
|
||
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 > 0
|
||
}
|
||
|
||
/**
|
||
* 删除邀请码(audit-P2-3 新增)
|
||
*
|
||
* 仅允许删除未被使用的邀请码(已使用的邀请码保留作为审计记录)。
|
||
* 返回 true 表示删除成功,false 表示邀请码不存在或已被使用。
|
||
*/
|
||
export async function deleteInvitationCode(
|
||
codeId: string,
|
||
): Promise<boolean> {
|
||
const result = await db
|
||
.delete(invitationCodes)
|
||
.where(
|
||
and(
|
||
eq(invitationCodes.id, codeId),
|
||
isNull(invitationCodes.usedBy),
|
||
),
|
||
)
|
||
|
||
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 > 0
|
||
}
|
||
|
||
/**
|
||
* 清理已过期的邀请码(audit-P2-3 新增,可选维护函数)
|
||
*
|
||
* 删除已过期且未使用的邀请码。可由 cron 任务定期调用。
|
||
*/
|
||
export async function purgeExpiredInvitationCodes(): Promise<number> {
|
||
const result = await db
|
||
.delete(invitationCodes)
|
||
.where(
|
||
and(
|
||
lte(invitationCodes.expiresAt, new Date()),
|
||
isNull(invitationCodes.usedBy),
|
||
),
|
||
)
|
||
|
||
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
|
||
}
|