Files
NextEdu/src/modules/elective/data-access-operations.ts
SpecialX d7017f0e30 feat(modules-classes): update classes, course-plans, dashboard, diagnostic, elective, error-book
- 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
2026-07-07 16:19:08 +08:00

437 lines
14 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 { 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.tspartitionLottery
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))
}
}
})
}