import "server-only" import { createId } from "@paralleldrive/cuid2" import { and, asc, eq, inArray, sql, type SQL } from "drizzle-orm" import { db } from "@/shared/db" import { courseSelections, electiveCourses, } from "@/shared/db/schema" import type { CourseSelectionStatus } from "./types" function buildLotteryRankCase(ids: string[], startRank: number): SQL { const branches = ids.map( (id, idx) => sql`WHEN ${id} THEN ${startRank + idx}` ) return sql`CASE ${courseSelections.id} ${sql.join(branches, sql` `)} END` } export async function runLottery(courseId: string): Promise<{ enrolled: number waitlist: number }> { const [courseRows, selections] = await Promise.all([ db .select() .from(electiveCourses) .where(eq(electiveCourses.id, courseId)) .limit(1), db .select() .from(courseSelections) .where( and( eq(courseSelections.courseId, courseId), eq(courseSelections.status, "selected") ) ) .orderBy(asc(courseSelections.priority), asc(courseSelections.selectedAt)), ]) const course = courseRows[0] if (!course) throw new Error("Course not found") if (selections.length === 0) { return { enrolled: 0, waitlist: 0 } } // Fisher-Yates shuffle: 无偏均匀随机排列,避免 sort(() => Math.random() - 0.5) 的分布偏差 const shuffled = [...selections] for (let i = shuffled.length - 1; i > 0; i--) { const j = Math.floor(Math.random() * (i + 1)) ;[shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]] } const capacity = course.capacity const now = new Date() const enrolledIds: string[] = [] const waitlistIds: string[] = [] for (let i = 0; i < shuffled.length; i++) { if (i < capacity) { enrolledIds.push(shuffled[i].id) } else { waitlistIds.push(shuffled[i].id) } } const enrolledCount = enrolledIds.length const waitlistCount = waitlistIds.length await db.transaction(async (tx) => { if (enrolledIds.length > 0) { await tx .update(courseSelections) .set({ status: "enrolled", lotteryRank: buildLotteryRankCase(enrolledIds, 1), enrolledAt: now, updatedAt: now, }) .where(inArray(courseSelections.id, enrolledIds)) } if (waitlistIds.length > 0) { await tx .update(courseSelections) .set({ status: "waitlist", lotteryRank: buildLotteryRankCase(waitlistIds, capacity + 1), updatedAt: now, }) .where(inArray(courseSelections.id, waitlistIds)) } await tx .update(electiveCourses) .set({ enrolledCount, status: "closed", updatedAt: now }) .where(eq(electiveCourses.id, courseId)) }) return { enrolled: enrolledCount, waitlist: waitlistCount } } export async function selectCourse( courseId: string, studentId: string, priority?: number ): Promise<{ status: CourseSelectionStatus; message: string }> { return db.transaction(async (tx) => { // 锁定课程行,防止 FCFS 模式下并发超卖 const [course] = await tx .select() .from(electiveCourses) .where(eq(electiveCourses.id, courseId)) .for("update") .limit(1) if (!course) throw new Error("Course not found") if (course.status !== "open") throw new Error("Course selection is not open") const now = new Date() if (course.selectionStartAt && now < course.selectionStartAt) { throw new Error("Selection has not started yet") } if (course.selectionEndAt && now > course.selectionEndAt) { throw new Error("Selection has ended") } const [existing] = await tx .select() .from(courseSelections) .where( and( eq(courseSelections.courseId, courseId), eq(courseSelections.studentId, studentId), inArray(courseSelections.status, ["selected", "enrolled", "waitlist"]) ) ) .limit(1) if (existing) throw new Error("Already selected this course") const id = createId() let status: CourseSelectionStatus = "selected" let enrolledAt: Date | null = null if (course.selectionMode === "fcfs" && course.enrolledCount < course.capacity) { status = "enrolled" enrolledAt = now await tx .update(electiveCourses) .set({ enrolledCount: course.enrolledCount + 1, updatedAt: now, }) .where(eq(electiveCourses.id, courseId)) } else if (course.selectionMode === "fcfs") { status = "waitlist" } await tx.insert(courseSelections).values({ id, courseId, studentId, status, priority: priority ?? 1, selectedAt: now, enrolledAt, }) return { status, message: status === "enrolled" ? "Enrolled successfully" : status === "waitlist" ? "Added to waitlist" : "Selection submitted", } }) } export async function dropCourse( courseId: string, studentId: string ): Promise { await db.transaction(async (tx) => { const [existing] = await tx .select() .from(courseSelections) .where( and( eq(courseSelections.courseId, courseId), eq(courseSelections.studentId, studentId), inArray(courseSelections.status, ["selected", "enrolled", "waitlist"]) ) ) .limit(1) if (!existing) throw new Error("No active selection found") // 锁定课程行,确保 enrolledCount 更新与候补递补的原子性 const [course] = await tx .select() .from(electiveCourses) .where(eq(electiveCourses.id, courseId)) .for("update") .limit(1) const now = new Date() await tx .update(courseSelections) .set({ status: "dropped", droppedAt: now, updatedAt: now }) .where(eq(courseSelections.id, existing.id)) if (existing.status === "enrolled" && course && course.selectionMode === "fcfs") { const newEnrolledCount = Math.max(0, course.enrolledCount - 1) await tx .update(electiveCourses) .set({ enrolledCount: newEnrolledCount, updatedAt: now }) .where(eq(electiveCourses.id, courseId)) const [nextWait] = await tx .select() .from(courseSelections) .where( and( eq(courseSelections.courseId, courseId), eq(courseSelections.status, "waitlist") ) ) .orderBy(asc(courseSelections.priority), asc(courseSelections.selectedAt)) .limit(1) if (nextWait) { await tx .update(courseSelections) .set({ status: "enrolled", enrolledAt: now, updatedAt: now, }) .where(eq(courseSelections.id, nextWait.id)) await tx .update(electiveCourses) .set({ enrolledCount: newEnrolledCount + 1, updatedAt: now }) .where(eq(electiveCourses.id, courseId)) } } }) }