refactor(data-access): 组 C 模块迁移至 cacheFn(lesson-preparation/elective/announcements/messaging/notifications/audit/onboarding/invitation-codes/scheduling/settings)
This commit is contained in:
@@ -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"],
|
||||
})
|
||||
|
||||
@@ -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.9(90%)。
|
||||
*/
|
||||
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 = {
|
||||
|
||||
@@ -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"],
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user