import "server-only" import { createId } from "@paralleldrive/cuid2" import { and, asc, eq, inArray } from "drizzle-orm" import { getTranslations } from "next-intl/server" import { db } from "@/shared/db" import { courseSelections, electiveCourses, } from "@/shared/db/schema" import { BusinessError } from "@/shared/lib/action-utils" import { sendNotification } from "@/modules/notifications" import { getElectiveCreditLimit, getCapacityNotifyThreshold } from "./data-access-settings" import { getStudentGradeId } from "./data-access-selections" import { hasAnyScheduleConflict, parseSchedule, } from "./lib/schedule-conflict" import { buildLotteryRankCase, partitionLottery } from "./lib/lottery" import type { CourseSelectionStatus } from "./types" // A-02 修复:纯函数已抽离至 lib/,此处 re-export 保持向后兼容 // (tests/integration/elective/elective-pure-functions.test.ts 仍从本文件导入) export { normalizeDay, parseSchedule, isScheduleConflict } from "./lib/schedule-conflict" export { buildLotteryRankCase } from "./lib/lottery" /** * 选课模块业务错误码(与 i18n key `errors.*` 对应)。 * 由 actions 层根据 code 通过 getTranslations 翻译为用户可见文案。 */ export type ElectiveErrorCode = | "courseNotFound" | "selectionNotOpen" | "selectionNotStarted" | "selectionEnded" | "alreadySelected" | "scheduleConflict" | "creditExceeded" | "noActiveSelection" | "dropDeadlinePassed" /** * 选课模块业务错误(带 i18n code 与参数)。 * actions 层捕获后用 getTranslations(`elective.errors.${code}`) 翻译。 */ export class ElectiveBusinessError extends BusinessError { constructor( public readonly code: ElectiveErrorCode, public readonly params?: Record ) { super(`elective.errors.${code}`, code) this.name = "ElectiveBusinessError" } } /** * 检测学生选课时间冲突(P2 建议:选课时间冲突检测)。 * 查询学生已选/已录取的课程,与新课程 schedule 比对。 * * A-02 修复:时间段解析与冲突判定纯函数已抽离至 lib/schedule-conflict.ts, * 本函数仅保留 DB 查询(读取新课程 schedule + 学生已选课程 schedule)。 */ async function checkScheduleConflict( tx: Parameters[0]>[0], studentId: string, newCourseId: string ): Promise { const [newCourse] = await tx .select({ schedule: electiveCourses.schedule }) .from(electiveCourses) .where(eq(electiveCourses.id, newCourseId)) .limit(1) const newSlots = parseSchedule(newCourse?.schedule ?? null) if (newSlots.length === 0) return false const existingCourses = await tx .select({ schedule: electiveCourses.schedule, }) .from(courseSelections) .innerJoin(electiveCourses, eq(electiveCourses.id, courseSelections.courseId)) .where( and( eq(courseSelections.studentId, studentId), inArray(courseSelections.status, ["selected", "enrolled", "waitlist"]) ) ) for (const row of existingCourses) { const existingSlots = parseSchedule(row.schedule) if (hasAnyScheduleConflict(newSlots, existingSlots)) { return true } } return false } /** * 检测学生学分是否超限(P2-4 重构:使用 system_settings 配置化上限)。 * 查询学生已选课程的学分总和,加上新课程学分后是否超过上限。 * * 上限来源(按优先级): * 1. `creditLimit:grade:`(按年级配置) * 2. `creditLimit:default`(全局配置) * 3. 默认值 10 */ async function checkCreditLimit( tx: Parameters[0]>[0], studentId: string, newCourseId: string, studentGradeId: string | null ): Promise<{ exceeded: boolean; current: number; max: number }> { const [newCourse] = await tx .select({ credit: electiveCourses.credit }) .from(electiveCourses) .where(eq(electiveCourses.id, newCourseId)) .limit(1) const newCredit = Number(newCourse?.credit ?? 0) const existing = await tx .select({ credit: electiveCourses.credit, }) .from(courseSelections) .innerJoin(electiveCourses, eq(electiveCourses.id, courseSelections.courseId)) .where( and( eq(courseSelections.studentId, studentId), inArray(courseSelections.status, ["selected", "enrolled", "waitlist"]) ) ) const currentCredit = existing.reduce((sum, row) => sum + Number(row.credit ?? 0), 0) const total = currentCredit + newCredit // 配置化上限:按年级或全局,默认 10 const max = await getElectiveCreditLimit(studentGradeId) return { exceeded: total > max, current: total, max, } } 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 ElectiveBusinessError("courseNotFound") if (selections.length === 0) { return { enrolled: 0, waitlist: 0 } } // A-02 修复:洗牌 + 分桶算法已抽离至 lib/lottery.ts(partitionLottery) const capacity = course.capacity const now = new Date() const { enrolledIds, waitlistIds } = partitionLottery(selections, capacity) const enrolledCount = enrolledIds.length const waitlistCount = waitlistIds.length // P1-12 改进:抽签后不强制 close 课程,保留 status="open" 以便管理员重抽。 // 管理员可手动通过 closeSelection 关闭选课。 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)) } // 仅更新 enrolledCount,不自动关闭课程 await tx .update(electiveCourses) .set({ enrolledCount, 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 }> { // P2-4:先查询学生年级 ID(用于按年级配置的学分上限) const studentGradeId = await getStudentGradeId(studentId) 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 ElectiveBusinessError("courseNotFound") if (course.status !== "open") throw new ElectiveBusinessError("selectionNotOpen") const now = new Date() if (course.selectionStartAt && now < course.selectionStartAt) { throw new ElectiveBusinessError("selectionNotStarted") } if (course.selectionEndAt && now > course.selectionEndAt) { throw new ElectiveBusinessError("selectionEnded") } 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 ElectiveBusinessError("alreadySelected") // P2 建议:选课时间冲突检测 const hasConflict = await checkScheduleConflict(tx, studentId, courseId) if (hasConflict) { throw new ElectiveBusinessError("scheduleConflict") } // P2-4:学分上限校验(使用按年级配置的上限) const creditCheck = await checkCreditLimit(tx, studentId, courseId, studentGradeId) if (creditCheck.exceeded) { throw new ElectiveBusinessError("creditExceeded", { current: creditCheck.current, max: creditCheck.max, }) } const id = createId() let status: CourseSelectionStatus = "selected" let enrolledAt: Date | null = null if (course.selectionMode === "fcfs" && course.enrolledCount < course.capacity) { status = "enrolled" enrolledAt = now const newEnrolledCount = course.enrolledCount + 1 await tx .update(electiveCourses) .set({ enrolledCount: newEnrolledCount, updatedAt: now, }) .where(eq(electiveCourses.id, courseId)) // P2-4:容量阈值通知(fire-and-forget,不阻塞事务) // 仅在跨过阈值时触发(避免每次选课都通知) void notifyCapacityThresholdIfNeeded(course, newEnrolledCount) } else if (course.selectionMode === "fcfs") { status = "waitlist" } await tx.insert(courseSelections).values({ id, courseId, studentId, status, priority: priority ?? 1, selectedAt: now, enrolledAt, }) return { status } }) } /** * 容量阈值通知(P2-4 新增)。 * * 触发条件:FCFS 模式下,录取后 `enrolledCount >= capacity * threshold`。 * 阈值来自 system_settings(`capacityNotifyThreshold`,默认 0.9)。 * * 通知接收者:课程创建者/教师(teacherId)。 * 通知为 fire-and-forget,失败不影响选课流程。 * * 防重复策略:仅当 `enrolledCount === Math.ceil(capacity * threshold)` 时触发, * 即只在跨过阈值的瞬间触发一次。 */ async function notifyCapacityThresholdIfNeeded( course: { teacherId: string; id: string; name: string; capacity: number }, newEnrolledCount: number ): Promise { try { const threshold = await getCapacityNotifyThreshold() const triggerPoint = Math.ceil(course.capacity * threshold) // 仅在跨过阈值瞬间触发(避免每次选课都通知) if (newEnrolledCount !== triggerPoint) return // P2-4:通知文案使用 i18n 翻译键 const t = await getTranslations("elective") const title = t("notifications.capacityWarningTitle", { courseName: course.name }) const content = t("notifications.capacityWarningContent", { courseName: course.name, enrolled: newEnrolledCount, capacity: course.capacity, percent: Math.round(threshold * 100), }) await sendNotification({ userId: course.teacherId, title, content, type: "warning", actionUrl: `/admin/elective/${course.id}`, metadata: { courseId: course.id, enrolledCount: newEnrolledCount, capacity: course.capacity, threshold, }, }) } catch { // fire-and-forget:通知失败不影响选课事务 } } export async function dropCourse( courseId: string, studentId: string, dropReason?: 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 ElectiveBusinessError("noActiveSelection") // 锁定课程行,确保 enrolledCount 更新与候补递补的原子性 const [course] = await tx .select() .from(electiveCourses) .where(eq(electiveCourses.id, courseId)) .for("update") .limit(1) const now = new Date() // P2-4:退课截止时间校验 if (course?.dropDeadline && now > course.dropDeadline) { throw new ElectiveBusinessError("dropDeadlinePassed") } await tx .update(courseSelections) .set({ status: "dropped", droppedAt: now, // P2-4:记录退课理由(trim 后存入,空字符串转为 null) dropReason: dropReason && dropReason.trim().length > 0 ? dropReason.trim() : null, 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)) } } }) }