Files
NextEdu/src/modules/invitation-codes/data-access.ts
SpecialX 2adf61faa8 feat(modules-assessment): update exams, files, grades, homework, invitation-codes, leave-requests
- 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
2026-07-07 16:19:46 +08:00

261 lines
7.2 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 affectedRowsdrizzle-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
}