- classes: update actions-shared, admin-classes-view, class-detail, class-invitation-manager, grade-classes-view, my-classes-grid, schedule dialogs, students-table, data-access-admin, data-access-students, data-access-teacher, data-access - course-plans: update course-plan-detail, course-plan-form, course-plan-item-editor, template-picker-dialog, data-access - dashboard: update dashboard-time-range-filter, use-dashboard-preferences, use-dashboard-realtime - diagnostic: update class-diagnostic-view, report-list, data-access - elective: update elective-course-form, elective-course-list, student-selection-view, data-access-operations, data-access-selections, data-access - error-book: update add-error-book-dialog, error-book-detail-dialog, review-buttons
437 lines
14 KiB
TypeScript
437 lines
14 KiB
TypeScript
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<string, string | number>
|
||
) {
|
||
super(`elective.errors.${code}`, code)
|
||
this.name = "ElectiveBusinessError"
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 检测学生选课时间冲突(P2 建议:选课时间冲突检测)。
|
||
* 查询学生已选/已录取的课程,与新课程 schedule 比对。
|
||
*
|
||
* A-02 修复:时间段解析与冲突判定纯函数已抽离至 lib/schedule-conflict.ts,
|
||
* 本函数仅保留 DB 查询(读取新课程 schedule + 学生已选课程 schedule)。
|
||
*/
|
||
async function checkScheduleConflict(
|
||
tx: Parameters<Parameters<typeof db.transaction>[0]>[0],
|
||
studentId: string,
|
||
newCourseId: string
|
||
): Promise<boolean> {
|
||
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:<studentGradeId>`(按年级配置)
|
||
* 2. `creditLimit:default`(全局配置)
|
||
* 3. 默认值 10
|
||
*/
|
||
async function checkCreditLimit(
|
||
tx: Parameters<Parameters<typeof db.transaction>[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<void> {
|
||
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<void> {
|
||
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))
|
||
}
|
||
}
|
||
})
|
||
}
|