refactor(data-access): 组 C 模块迁移至 cacheFn(lesson-preparation/elective/announcements/messaging/notifications/audit/onboarding/invitation-codes/scheduling/settings)

This commit is contained in:
SpecialX
2026-07-05 21:49:18 +08:00
parent cb5b92160c
commit 6dee6b6299
9 changed files with 520 additions and 336 deletions

View File

@@ -1,6 +1,5 @@
import "server-only"
import { cache } from "react"
import { and, asc, desc, eq, sql, type SQL } from "drizzle-orm"
import { db } from "@/shared/db"
@@ -8,6 +7,7 @@ import {
courseSelections,
electiveCourses,
} from "@/shared/db/schema"
import { cacheFn } from "@/shared/lib/cache"
import {
buildCourseSelect,
@@ -101,50 +101,68 @@ const resolveStudentDisplayNames = async (rows: SelectionCoreRow[]): Promise<Map
return studentNames
}
export const getCourseSelections = cache(
async (
courseId: string
): Promise<CourseSelectionWithDetails[]> => {
const rows = await buildSelectionCoreSelect()
.where(eq(courseSelections.courseId, courseId))
.orderBy(asc(courseSelections.priority), asc(courseSelections.selectedAt))
const studentNames = await resolveStudentDisplayNames(rows)
return rows.map((r) => mapSelectionRow(r, studentNames))
}
)
export const getCourseSelectionsRaw = async (
courseId: string,
): Promise<CourseSelectionWithDetails[]> => {
const rows = await buildSelectionCoreSelect()
.where(eq(courseSelections.courseId, courseId))
.orderBy(asc(courseSelections.priority), asc(courseSelections.selectedAt))
const studentNames = await resolveStudentDisplayNames(rows)
return rows.map((r) => mapSelectionRow(r, studentNames))
}
export const getStudentSelections = cache(
async (
studentId: string
): Promise<CourseSelectionWithDetails[]> => {
const rows = await buildSelectionCoreSelect()
.where(eq(courseSelections.studentId, studentId))
.orderBy(desc(courseSelections.selectedAt))
const studentNames = await resolveStudentDisplayNames(rows)
return rows.map((r) => mapSelectionRow(r, studentNames))
}
)
export const getStudentGradeId = cache(async (studentId: string): Promise<string | null> => {
return getStudentGradeResolver().getStudentActiveGradeId(studentId)
export const getCourseSelections = cacheFn(getCourseSelectionsRaw, {
tags: ["elective"],
ttl: 300,
keyParts: ["elective", "getCourseSelections"],
})
export const getAvailableCoursesForStudent = cache(
async (
studentId: string,
gradeId?: string | null
): Promise<ElectiveCourseWithDetails[]> => {
const resolvedGradeId = gradeId ?? (await getStudentGradeId(studentId))
const conditions: SQL[] = [eq(electiveCourses.status, "open")]
if (resolvedGradeId) {
conditions.push(
sql`(${electiveCourses.gradeId} = ${resolvedGradeId} OR ${electiveCourses.gradeId} IS NULL)`
)
}
const rows: CourseCoreRow[] = await buildCourseSelect()
.where(and(...conditions))
.orderBy(desc(electiveCourses.createdAt))
const displayMaps = await resolveCourseDisplayNames(rows)
return rows.map((r) => mapCourseRow(r, displayMaps.teacherNames, displayMaps.subjectNames, displayMaps.gradeNames))
export const getStudentSelectionsRaw = async (
studentId: string,
): Promise<CourseSelectionWithDetails[]> => {
const rows = await buildSelectionCoreSelect()
.where(eq(courseSelections.studentId, studentId))
.orderBy(desc(courseSelections.selectedAt))
const studentNames = await resolveStudentDisplayNames(rows)
return rows.map((r) => mapSelectionRow(r, studentNames))
}
export const getStudentSelections = cacheFn(getStudentSelectionsRaw, {
tags: ["elective"],
ttl: 300,
keyParts: ["elective", "getStudentSelections"],
})
export const getStudentGradeIdRaw = async (studentId: string): Promise<string | null> => {
return getStudentGradeResolver().getStudentActiveGradeId(studentId)
}
export const getStudentGradeId = cacheFn(getStudentGradeIdRaw, {
tags: ["elective"],
ttl: 300,
keyParts: ["elective", "getStudentGradeId"],
})
export const getAvailableCoursesForStudentRaw = async (
studentId: string,
gradeId?: string | null,
): Promise<ElectiveCourseWithDetails[]> => {
const resolvedGradeId = gradeId ?? (await getStudentGradeId(studentId))
const conditions: SQL[] = [eq(electiveCourses.status, "open")]
if (resolvedGradeId) {
conditions.push(
sql`(${electiveCourses.gradeId} = ${resolvedGradeId} OR ${electiveCourses.gradeId} IS NULL)`
)
}
)
const rows: CourseCoreRow[] = await buildCourseSelect()
.where(and(...conditions))
.orderBy(desc(electiveCourses.createdAt))
const displayMaps = await resolveCourseDisplayNames(rows)
return rows.map((r) => mapCourseRow(r, displayMaps.teacherNames, displayMaps.subjectNames, displayMaps.gradeNames))
}
export const getAvailableCoursesForStudent = cacheFn(getAvailableCoursesForStudentRaw, {
tags: ["elective"],
ttl: 300,
keyParts: ["elective", "getAvailableCoursesForStudent"],
})

View File

@@ -1,10 +1,10 @@
import "server-only"
import { cache } from "react"
import { eq, and } from "drizzle-orm"
import { db } from "@/shared/db"
import { systemSettings } from "@/shared/db/schema"
import { cacheFn } from "@/shared/lib/cache"
/**
* 选课模块配置化设置P2-4 新增)。
@@ -55,23 +55,29 @@ async function readSettingValue(key: string): Promise<string | null> {
*
* @param gradeId 学生所在年级 ID可选
*/
export const getElectiveCreditLimit = cache(
async (gradeId?: string | null): Promise<number> => {
if (gradeId) {
const gradeValue = await readSettingValue(`creditLimit:grade:${gradeId}`)
if (gradeValue !== null) {
const parsed = Number(gradeValue)
if (!Number.isNaN(parsed) && parsed > 0) return parsed
}
}
const defaultValue = await readSettingValue("creditLimit:default")
if (defaultValue !== null) {
const parsed = Number(defaultValue)
export const getElectiveCreditLimitRaw = async (
gradeId?: string | null,
): Promise<number> => {
if (gradeId) {
const gradeValue = await readSettingValue(`creditLimit:grade:${gradeId}`)
if (gradeValue !== null) {
const parsed = Number(gradeValue)
if (!Number.isNaN(parsed) && parsed > 0) return parsed
}
return DEFAULT_MAX_CREDIT_PER_TERM
}
)
const defaultValue = await readSettingValue("creditLimit:default")
if (defaultValue !== null) {
const parsed = Number(defaultValue)
if (!Number.isNaN(parsed) && parsed > 0) return parsed
}
return DEFAULT_MAX_CREDIT_PER_TERM
}
export const getElectiveCreditLimit = cacheFn(getElectiveCreditLimitRaw, {
tags: ["elective"],
ttl: 300,
keyParts: ["elective", "getElectiveCreditLimit"],
})
/**
* 获取容量阈值通知比例P2-4 新增)。
@@ -79,16 +85,20 @@ export const getElectiveCreditLimit = cache(
* 当课程 `enrolledCount >= capacity * threshold` 时触发管理员通知。
* 默认 0.990%)。
*/
export const getCapacityNotifyThreshold = cache(
async (): Promise<number> => {
const value = await readSettingValue("capacityNotifyThreshold")
if (value !== null) {
const parsed = Number(value)
if (!Number.isNaN(parsed) && parsed > 0 && parsed <= 1) return parsed
}
return DEFAULT_CAPACITY_NOTIFY_THRESHOLD
export const getCapacityNotifyThresholdRaw = async (): Promise<number> => {
const value = await readSettingValue("capacityNotifyThreshold")
if (value !== null) {
const parsed = Number(value)
if (!Number.isNaN(parsed) && parsed > 0 && parsed <= 1) return parsed
}
)
return DEFAULT_CAPACITY_NOTIFY_THRESHOLD
}
export const getCapacityNotifyThreshold = cacheFn(getCapacityNotifyThresholdRaw, {
tags: ["elective"],
ttl: 300,
keyParts: ["elective", "getCapacityNotifyThreshold"],
})
/** 导出默认值常量(供测试与文档引用) */
export const ELECTIVE_DEFAULTS = {

View File

@@ -1,10 +1,10 @@
import "server-only"
import { cache } from "react"
import { count, eq, sql } from "drizzle-orm"
import { db } from "@/shared/db"
import { courseSelections, electiveCourses } from "@/shared/db/schema"
import { cacheFn } from "@/shared/lib/cache"
/**
* 选课模块管理员概览统计P1-13 新增)。
@@ -30,59 +30,63 @@ export interface ElectiveOverviewStats {
* - 使用 SQL 聚合而非拉全表后 reduce避免大数据量内存峰值
* - admin 不做 scope 过滤(统计全部课程)
*/
export const getElectiveOverviewStats = cache(
async (): Promise<ElectiveOverviewStats> => {
// 并行执行聚合查询
const [totalRow, enrolledRow, utilizationRow, pendingRow] = await Promise.all([
// 1. 课程总数
db
.select({ total: count() })
.from(electiveCourses),
export const getElectiveOverviewStatsRaw = async (): Promise<ElectiveOverviewStats> => {
// 并行执行聚合查询
const [totalRow, enrolledRow, utilizationRow, pendingRow] = await Promise.all([
// 1. 课程总数
db
.select({ total: count() })
.from(electiveCourses),
// 2. 总选课人数(活跃选课记录数)
db
.select({ total: count() })
.from(courseSelections)
.where(
sql`${courseSelections.status} IN ('selected', 'enrolled', 'waitlist')`
),
// 2. 总选课人数(活跃选课记录数)
db
.select({ total: count() })
.from(courseSelections)
.where(
sql`${courseSelections.status} IN ('selected', 'enrolled', 'waitlist')`
),
// 3. 平均容量使用率capacity > 0 时计算 enrolledCount/capacity 平均值)
db
.select({
avg: sql<number>`COALESCE(
AVG(
CASE
WHEN ${electiveCourses.capacity} > 0
THEN ${electiveCourses.enrolledCount}::float / ${electiveCourses.capacity}
ELSE 0
END
) * 100,
0
)`,
})
.from(electiveCourses),
// 3. 平均容量使用率capacity > 0 时计算 enrolledCount/capacity 平均值)
db
.select({
avg: sql<number>`COALESCE(
AVG(
CASE
WHEN ${electiveCourses.capacity} > 0
THEN ${electiveCourses.enrolledCount}::float / ${electiveCourses.capacity}
ELSE 0
END
) * 100,
0
)`,
})
.from(electiveCourses),
// 4. 待抽签课程数lottery 模式且 status=open 且有 selected 状态的选课记录
db
.select({ total: sql<number>`count(distinct ${electiveCourses.id})` })
.from(electiveCourses)
.innerJoin(
courseSelections,
eq(courseSelections.courseId, electiveCourses.id)
)
.where(
sql`${electiveCourses.selectionMode} = 'lottery'
AND ${electiveCourses.status} = 'open'
AND ${courseSelections.status} = 'selected'`
),
])
// 4. 待抽签课程数lottery 模式且 status=open 且有 selected 状态的选课记录
db
.select({ total: sql<number>`count(distinct ${electiveCourses.id})` })
.from(electiveCourses)
.innerJoin(
courseSelections,
eq(courseSelections.courseId, electiveCourses.id)
)
.where(
sql`${electiveCourses.selectionMode} = 'lottery'
AND ${electiveCourses.status} = 'open'
AND ${courseSelections.status} = 'selected'`
),
])
return {
totalCourses: totalRow[0]?.total ?? 0,
totalEnrolled: enrolledRow[0]?.total ?? 0,
avgUtilization: Math.round(Number(utilizationRow[0]?.avg ?? 0)),
pendingLottery: pendingRow[0]?.total ?? 0,
}
return {
totalCourses: totalRow[0]?.total ?? 0,
totalEnrolled: enrolledRow[0]?.total ?? 0,
avgUtilization: Math.round(Number(utilizationRow[0]?.avg ?? 0)),
pendingLottery: pendingRow[0]?.total ?? 0,
}
)
}
export const getElectiveOverviewStats = cacheFn(getElectiveOverviewStatsRaw, {
tags: ["elective"],
ttl: 300,
keyParts: ["elective", "getElectiveOverviewStats"],
})

View File

@@ -8,6 +8,7 @@ import { db } from "@/shared/db"
import { electiveCourses } from "@/shared/db/schema"
import type { DataScope } from "@/shared/types/permissions"
import { safeParseDate } from "@/shared/lib/action-utils"
import { cacheFn } from "@/shared/lib/cache"
import type {
ElectiveCourseWithDetails,
@@ -131,10 +132,9 @@ export const resolveCourseDisplayNames = async (rows: CourseCoreRow[]): Promise<
return { teacherNames, subjectNames, gradeNames }
}
export const getElectiveCourses = cache(
async (
params?: GetElectiveCoursesParams & { scope?: DataScope; currentUserId?: string }
): Promise<ElectiveCourseWithDetails[]> => {
export const getElectiveCoursesRaw = async (
params?: GetElectiveCoursesParams & { scope?: DataScope; currentUserId?: string }
): Promise<ElectiveCourseWithDetails[]> => {
const conditions: SQL[] = []
if (params?.status)
conditions.push(
@@ -160,18 +160,29 @@ export const getElectiveCourses = cache(
const displayMaps = await resolveCourseDisplayNames(rows)
return rows.map((r) => mapCourseRow(r, displayMaps.teacherNames, displayMaps.subjectNames, displayMaps.gradeNames))
}
)
export const getElectiveCourseById = cache(
async (id: string): Promise<ElectiveCourseWithDetails | null> => {
const [row] = await buildCourseSelect()
.where(eq(electiveCourses.id, id))
.limit(1)
if (!row) return null
const displayMaps = await resolveCourseDisplayNames([row])
return mapCourseRow(row, displayMaps.teacherNames, displayMaps.subjectNames, displayMaps.gradeNames)
}
)
export const getElectiveCourses = cacheFn(getElectiveCoursesRaw, {
tags: ["elective"],
ttl: 300,
keyParts: ["elective", "getElectiveCourses"],
})
export const getElectiveCourseByIdRaw = async (
id: string,
): Promise<ElectiveCourseWithDetails | null> => {
const [row] = await buildCourseSelect()
.where(eq(electiveCourses.id, id))
.limit(1)
if (!row) return null
const displayMaps = await resolveCourseDisplayNames([row])
return mapCourseRow(row, displayMaps.teacherNames, displayMaps.subjectNames, displayMaps.gradeNames)
}
export const getElectiveCourseById = cacheFn(getElectiveCourseByIdRaw, {
tags: ["elective"],
ttl: 300,
keyParts: ["elective", "getElectiveCourseById"],
})
export async function createElectiveCourse(
data: CreateElectiveCourseInput,