refactor(data-access): 组 A 模块迁移至 cacheFn(users/school/rbac/textbooks/questions/course-plans/standards/files/dashboard/parent/proctoring)

This commit is contained in:
SpecialX
2026-07-05 21:56:18 +08:00
parent 6dee6b6299
commit 27374d1b2c
13 changed files with 765 additions and 388 deletions

View File

@@ -1,6 +1,6 @@
import "server-only" import "server-only"
import { cache } from "react" import { cacheFn } from "@/shared/lib/cache"
import { createId } from "@paralleldrive/cuid2" import { createId } from "@paralleldrive/cuid2"
import { and, asc, desc, eq, inArray, type SQL } from "drizzle-orm" import { and, asc, desc, eq, inArray, type SQL } from "drizzle-orm"
@@ -147,8 +147,7 @@ const buildScopeCondition = (
return conditions return conditions
} }
export const getCoursePlans = cache( export const getCoursePlansRaw = async (params?: GetCoursePlansParams, scope?: CoursePlanQueryScope): Promise<CoursePlanListItem[]> => {
async (params?: GetCoursePlansParams, scope?: CoursePlanQueryScope): Promise<CoursePlanListItem[]> => {
try { try {
const conditions: SQL[] = [...buildScopeCondition(scope)] const conditions: SQL[] = [...buildScopeCondition(scope)]
if (params?.classId) conditions.push(eq(coursePlans.classId, params.classId)) if (params?.classId) conditions.push(eq(coursePlans.classId, params.classId))
@@ -169,10 +168,14 @@ export const getCoursePlans = cache(
return [] return []
} }
} }
)
export const getCoursePlanById = cache( export const getCoursePlans = cacheFn(getCoursePlansRaw, {
async (id: string, scope?: CoursePlanQueryScope): Promise<CoursePlanWithItems | null> => { tags: ["course-plans"],
ttl: 300,
keyParts: ["course-plans", "getCoursePlans"],
})
export const getCoursePlanByIdRaw = async (id: string, scope?: CoursePlanQueryScope): Promise<CoursePlanWithItems | null> => {
try { try {
const conditions: SQL[] = [eq(coursePlans.id, id), ...buildScopeCondition(scope)] const conditions: SQL[] = [eq(coursePlans.id, id), ...buildScopeCondition(scope)]
const [planRow] = await db const [planRow] = await db
@@ -200,7 +203,12 @@ export const getCoursePlanById = cache(
return null return null
} }
} }
)
export const getCoursePlanById = cacheFn(getCoursePlanByIdRaw, {
tags: ["course-plans"],
ttl: 300,
keyParts: ["course-plans", "getCoursePlanById"],
})
export async function createCoursePlan( export async function createCoursePlan(
data: CreateCoursePlanInput, data: CreateCoursePlanInput,
@@ -343,6 +351,10 @@ export async function bulkUpdateItemCompleted(
/** /**
* 复制课程计划到其他班级P2-4 批量操作)。 * 复制课程计划到其他班级P2-4 批量操作)。
* 复制基本信息(不含 completedHours和所有周计划条目。 * 复制基本信息(不含 completedHours和所有周计划条目。
*
* 性能优化:用单次事务 + 批量 INSERT 替代 N+1 循环写入。
* 原实现为 O(N*M) 次 INSERT(N=班级数,M=条目数),
* 现改为 2 次批量 INSERT(plans + items),复杂度降为 O(1) 次 INSERT。
*/ */
export async function copyCoursePlanToClasses( export async function copyCoursePlanToClasses(
sourcePlanId: string, sourcePlanId: string,
@@ -363,44 +375,51 @@ export async function copyCoursePlanToClasses(
.from(coursePlanItems) .from(coursePlanItems)
.where(eq(coursePlanItems.planId, sourcePlanId)) .where(eq(coursePlanItems.planId, sourcePlanId))
const createdIds: string[] = [] // 预先生成所有 newPlanId,便于构造 items 的批量插入
const createdIds = targetClassIds.map(() => createId())
for (const classId of targetClassIds) { const newPlans = targetClassIds.map((classId, index) => ({
const newPlanId = createId() id: createdIds[index]!,
await db.insert(coursePlans).values({ classId,
id: newPlanId, subjectId: source.subjectId,
classId, teacherId: source.teacherId,
subjectId: source.subjectId, academicYearId: source.academicYearId,
teacherId: source.teacherId, semester: source.semester,
academicYearId: source.academicYearId, totalHours: source.totalHours,
semester: source.semester, completedHours: 0,
totalHours: source.totalHours, weeklyHours: source.weeklyHours,
completedHours: 0, startDate: source.startDate,
weeklyHours: source.weeklyHours, endDate: source.endDate,
startDate: source.startDate, syllabus: source.syllabus,
endDate: source.endDate, objectives: source.objectives,
syllabus: source.syllabus, status: "planning" as const,
objectives: source.objectives, createdBy: source.createdBy,
status: "planning", }))
createdBy: source.createdBy,
})
for (const item of sourceItems) { // 批量构造所有 items:每个 plan 复制一份 sourceItems
const newItemId = createId() const newItems = targetClassIds.length > 0 && sourceItems.length > 0
await db.insert(coursePlanItems).values({ ? targetClassIds.flatMap((_, planIndex) =>
id: newItemId, sourceItems.map((item) => ({
planId: newPlanId, id: createId(),
week: item.week, planId: createdIds[planIndex]!,
topic: item.topic, week: item.week,
content: item.content, topic: item.topic,
hours: item.hours, content: item.content,
textbookChapter: item.textbookChapter, hours: item.hours,
notes: item.notes, textbookChapter: item.textbookChapter,
}) notes: item.notes,
})),
)
: []
await db.transaction(async (tx) => {
if (newPlans.length > 0) {
await tx.insert(coursePlans).values(newPlans)
} }
if (newItems.length > 0) {
createdIds.push(newPlanId) await tx.insert(coursePlanItems).values(newItems)
} }
})
return createdIds return createdIds
} }
@@ -413,8 +432,7 @@ export type { CoursePlan, CoursePlanItem, CoursePlanWithItems }
* 关联 course_plan_items 统计条目完成情况。 * 关联 course_plan_items 统计条目完成情况。
* 名称解析通过 data-access 批量查询,不 JOIN 其他模块表。 * 名称解析通过 data-access 批量查询,不 JOIN 其他模块表。
*/ */
export const getGradeCoursePlanProgress = cache( export const getGradeCoursePlanProgressRaw = async (params: { gradeId: string }): Promise<GradeCoursePlanProgressResult> => {
async (params: { gradeId: string }): Promise<GradeCoursePlanProgressResult> => {
const { getClassesByGradeId } = await import("@/modules/classes/data-access") const { getClassesByGradeId } = await import("@/modules/classes/data-access")
const classRows = await getClassesByGradeId(params.gradeId) const classRows = await getClassesByGradeId(params.gradeId)
@@ -504,4 +522,9 @@ export const getGradeCoursePlanProgress = cache(
items, items,
} }
} }
)
export const getGradeCoursePlanProgress = cacheFn(getGradeCoursePlanProgressRaw, {
tags: ["course-plans"],
ttl: 300,
keyParts: ["course-plans", "getGradeCoursePlanProgress"],
})

View File

@@ -1,6 +1,6 @@
import "server-only" import "server-only"
import { cache } from "react" import { cacheFn } from "@/shared/lib/cache"
import { getClassesDashboardStats } from "@/modules/classes/data-access" import { getClassesDashboardStats } from "@/modules/classes/data-access"
import { getExamsDashboardStats } from "@/modules/exams/data-access" import { getExamsDashboardStats } from "@/modules/exams/data-access"
@@ -12,7 +12,7 @@ import type { DataScope } from "@/shared/types/permissions"
import type { AdminDashboardData } from "./types" import type { AdminDashboardData } from "./types"
export const getAdminDashboardData = cache(async (scope?: DataScope): Promise<AdminDashboardData> => { export const getAdminDashboardDataRaw = async (scope?: DataScope): Promise<AdminDashboardData> => {
const [ const [
usersStats, usersStats,
classesStats, classesStats,
@@ -48,4 +48,10 @@ export const getAdminDashboardData = cache(async (scope?: DataScope): Promise<Ad
userGrowth: [], userGrowth: [],
homeworkTrend: [], homeworkTrend: [],
} }
}
export const getAdminDashboardData = cacheFn(getAdminDashboardDataRaw, {
tags: ["dashboard"],
ttl: 60,
keyParts: ["dashboard", "getAdminDashboardData"],
}) })

View File

@@ -1,7 +1,7 @@
import "server-only" import "server-only"
import { and, count, desc, eq, inArray, like, or, sql, type SQL } from "drizzle-orm" import { and, count, desc, eq, inArray, like, or, sql, type SQL } from "drizzle-orm"
import { cache } from "react" import { cacheFn } from "@/shared/lib/cache"
import { db } from "@/shared/db" import { db } from "@/shared/db"
import { fileAttachments } from "@/shared/db/schema" import { fileAttachments } from "@/shared/db/schema"
@@ -60,8 +60,7 @@ export async function createFileAttachment(
/** /**
* 按 ID 查询文件附件 * 按 ID 查询文件附件
*/ */
export const getFileAttachment = cache( export const getFileAttachmentRaw = async (id: string): Promise<FileAttachment | null> => {
async (id: string): Promise<FileAttachment | null> => {
try { try {
const [row] = await db const [row] = await db
.select() .select()
@@ -74,14 +73,18 @@ export const getFileAttachment = cache(
console.error("getFileAttachment failed:", error) console.error("getFileAttachment failed:", error)
return null return null
} }
}, }
)
export const getFileAttachment = cacheFn(getFileAttachmentRaw, {
tags: ["files"],
ttl: 300,
keyParts: ["files", "getFileAttachment"],
})
/** /**
* 按关联资源查询文件列表 * 按关联资源查询文件列表
*/ */
export const getFileAttachmentsByTarget = cache( export const getFileAttachmentsByTargetRaw = async (targetType: string, targetId: string): Promise<FileAttachment[]> => {
async (targetType: string, targetId: string): Promise<FileAttachment[]> => {
try { try {
const rows = await db const rows = await db
.select() .select()
@@ -99,14 +102,18 @@ export const getFileAttachmentsByTarget = cache(
console.error("getFileAttachmentsByTarget failed:", error) console.error("getFileAttachmentsByTarget failed:", error)
return [] return []
} }
}, }
)
export const getFileAttachmentsByTarget = cacheFn(getFileAttachmentsByTargetRaw, {
tags: ["files"],
ttl: 300,
keyParts: ["files", "getFileAttachmentsByTarget"],
})
/** /**
* 按上传者查询文件列表 * 按上传者查询文件列表
*/ */
export const getFileAttachmentsByUploader = cache( export const getFileAttachmentsByUploaderRaw = async (uploaderId: string): Promise<FileAttachment[]> => {
async (uploaderId: string): Promise<FileAttachment[]> => {
try { try {
const rows = await db const rows = await db
.select() .select()
@@ -119,14 +126,18 @@ export const getFileAttachmentsByUploader = cache(
console.error("getFileAttachmentsByUploader failed:", error) console.error("getFileAttachmentsByUploader failed:", error)
return [] return []
} }
}, }
)
export const getFileAttachmentsByUploader = cacheFn(getFileAttachmentsByUploaderRaw, {
tags: ["files"],
ttl: 300,
keyParts: ["files", "getFileAttachmentsByUploader"],
})
/** /**
* 查询所有文件(用于管理员文件管理页面) * 查询所有文件(用于管理员文件管理页面)
*/ */
export const getAllFileAttachments = cache( export const getAllFileAttachmentsRaw = async (limit = 100): Promise<FileAttachment[]> => {
async (limit = 100): Promise<FileAttachment[]> => {
try { try {
const rows = await db const rows = await db
.select() .select()
@@ -139,8 +150,13 @@ export const getAllFileAttachments = cache(
console.error("getAllFileAttachments failed:", error) console.error("getAllFileAttachments failed:", error)
return [] return []
} }
}, }
)
export const getAllFileAttachments = cacheFn(getAllFileAttachmentsRaw, {
tags: ["files"],
ttl: 300,
keyParts: ["files", "getAllFileAttachments"],
})
/** /**
* 删除文件附件记录 * 删除文件附件记录
@@ -193,8 +209,7 @@ export async function deleteFileAttachments(ids: string[]): Promise<BatchDeleteR
* - mimeType: 精确匹配或前缀匹配(如 "image/" * - mimeType: 精确匹配或前缀匹配(如 "image/"
* - search: 在 originalName / filename 中模糊匹配 * - search: 在 originalName / filename 中模糊匹配
*/ */
export const getFileAttachmentsWithFilters = cache( export const getFileAttachmentsWithFiltersRaw = async (params: FileAttachmentQueryParams): Promise<FileAttachment[]> => {
async (params: FileAttachmentQueryParams): Promise<FileAttachment[]> => {
try { try {
const { mimeType, search, limit = 100, offset = 0 } = params const { mimeType, search, limit = 100, offset = 0 } = params
@@ -230,14 +245,18 @@ export const getFileAttachmentsWithFilters = cache(
console.error("getFileAttachmentsWithFilters failed:", error) console.error("getFileAttachmentsWithFilters failed:", error)
return [] return []
} }
}, }
)
export const getFileAttachmentsWithFilters = cacheFn(getFileAttachmentsWithFiltersRaw, {
tags: ["files"],
ttl: 300,
keyParts: ["files", "getFileAttachmentsWithFilters"],
})
/** /**
* 获取文件统计信息(总数、总大小、按类型分组) * 获取文件统计信息(总数、总大小、按类型分组)
*/ */
export const getFileStats = cache( export const getFileStatsRaw = async (): Promise<FileStats> => {
async (): Promise<FileStats> => {
try { try {
const rows = await db const rows = await db
.select({ .select({
@@ -262,14 +281,18 @@ export const getFileStats = cache(
console.error("getFileStats failed:", error) console.error("getFileStats failed:", error)
return { totalCount: 0, totalSize: 0, byType: [] } return { totalCount: 0, totalSize: 0, byType: [] }
} }
}, }
)
export const getFileStats = cacheFn(getFileStatsRaw, {
tags: ["files"],
ttl: 300,
keyParts: ["files", "getFileStats"],
})
/** /**
* 按 URL 查询文件附件(用于头像等场景的旧文件清理) * 按 URL 查询文件附件(用于头像等场景的旧文件清理)
*/ */
export const getFileByUrl = cache( export const getFileByUrlRaw = async (url: string): Promise<FileAttachment | null> => {
async (url: string): Promise<FileAttachment | null> => {
try { try {
const [row] = await db const [row] = await db
.select() .select()
@@ -282,14 +305,18 @@ export const getFileByUrl = cache(
console.error("getFileByUrl failed:", error) console.error("getFileByUrl failed:", error)
return null return null
} }
}, }
)
export const getFileByUrl = cacheFn(getFileByUrlRaw, {
tags: ["files"],
ttl: 300,
keyParts: ["files", "getFileByUrl"],
})
/** /**
* 按 ID 列表批量查询文件(用于批量删除前获取磁盘路径) * 按 ID 列表批量查询文件(用于批量删除前获取磁盘路径)
*/ */
export const getFileAttachmentsByIds = cache( export const getFileAttachmentsByIdsRaw = async (ids: string[]): Promise<FileAttachment[]> => {
async (ids: string[]): Promise<FileAttachment[]> => {
if (ids.length === 0) return [] if (ids.length === 0) return []
try { try {
const rows = await db const rows = await db
@@ -301,5 +328,10 @@ export const getFileAttachmentsByIds = cache(
console.error("getFileAttachmentsByIds failed:", error) console.error("getFileAttachmentsByIds failed:", error)
return [] return []
} }
}, }
)
export const getFileAttachmentsByIds = cacheFn(getFileAttachmentsByIdsRaw, {
tags: ["files"],
ttl: 300,
keyParts: ["files", "getFileAttachmentsByIds"],
})

View File

@@ -1,6 +1,6 @@
import "server-only" import "server-only"
import { cache } from "react" import { cacheFn } from "@/shared/lib/cache"
import { and, asc, eq, inArray } from "drizzle-orm" import { and, asc, eq, inArray } from "drizzle-orm"
import { db } from "@/shared/db" import { db } from "@/shared/db"
@@ -39,7 +39,7 @@ const toWeekday = (d: Date): Weekday => {
return isWeekday(normalized) ? normalized : 1 return isWeekday(normalized) ? normalized : 1
} }
export const getChildren = cache(async (parentId: string): Promise<ParentChildRelation[]> => { export const getChildrenRaw = async (parentId: string): Promise<ParentChildRelation[]> => {
const id = parentId.trim() const id = parentId.trim()
if (!id) return [] if (!id) return []
@@ -62,14 +62,19 @@ export const getChildren = cache(async (parentId: string): Promise<ParentChildRe
relation: r.relation, relation: r.relation,
createdAt: r.createdAt.toISOString(), createdAt: r.createdAt.toISOString(),
})) }))
}
export const getChildren = cacheFn(getChildrenRaw, {
tags: ["parent"],
ttl: 300,
keyParts: ["parent", "getChildren"],
}) })
/** /**
* 校验家长与子女的关系是否存在,并返回关系类型。 * 校验家长与子女的关系是否存在,并返回关系类型。
* 同时按 parentId 与 studentId 过滤,防止跨家庭信息泄露。 * 同时按 parentId 与 studentId 过滤,防止跨家庭信息泄露。
*/ */
export const verifyParentChildRelation = cache( export const verifyParentChildRelationRaw = async (studentId: string, parentId: string): Promise<string | null> => {
async (studentId: string, parentId: string): Promise<string | null> => {
const [row] = await db const [row] = await db
.select({ relation: parentStudentRelations.relation }) .select({ relation: parentStudentRelations.relation })
.from(parentStudentRelations) .from(parentStudentRelations)
@@ -81,11 +86,15 @@ export const verifyParentChildRelation = cache(
) )
.limit(1) .limit(1)
return row?.relation ?? null return row?.relation ?? null
}, }
)
export const getChildBasicInfo = cache( export const verifyParentChildRelation = cacheFn(verifyParentChildRelationRaw, {
async ( tags: ["parent"],
ttl: 300,
keyParts: ["parent", "verifyParentChildRelation"],
})
export const getChildBasicInfoRaw = async (
studentId: string, studentId: string,
relation: string | null = null, relation: string | null = null,
): Promise<ChildBasicInfo | null> => { ): Promise<ChildBasicInfo | null> => {
@@ -109,8 +118,13 @@ export const getChildBasicInfo = cache(
classId: activeClass?.classId ?? null, classId: activeClass?.classId ?? null,
relation, relation,
} }
}, }
)
export const getChildBasicInfo = cacheFn(getChildBasicInfoRaw, {
tags: ["parent"],
ttl: 300,
keyParts: ["parent", "getChildBasicInfo"],
})
const buildHomeworkSummary = ( const buildHomeworkSummary = (
assignments: Awaited<ReturnType<typeof getStudentHomeworkAssignments>>, assignments: Awaited<ReturnType<typeof getStudentHomeworkAssignments>>,
@@ -197,8 +211,7 @@ const buildWeeklySchedule = (
) )
} }
export const getChildDashboardData = cache( export const getChildDashboardDataRaw = async (studentId: string, relation: string | null = null): Promise<ChildDashboardData | null> => {
async (studentId: string, relation: string | null = null): Promise<ChildDashboardData | null> => {
const basicInfo = await getChildBasicInfo(studentId, relation) const basicInfo = await getChildBasicInfo(studentId, relation)
if (!basicInfo) return null if (!basicInfo) return null
@@ -221,11 +234,15 @@ export const getChildDashboardData = cache(
gradeSummary, gradeSummary,
examResults, examResults,
} }
}, }
)
export const getParentDashboardData = cache( export const getChildDashboardData = cacheFn(getChildDashboardDataRaw, {
async (parentId: string): Promise<ParentDashboardData> => { tags: ["parent"],
ttl: 300,
keyParts: ["parent", "getChildDashboardData"],
})
export const getParentDashboardDataRaw = async (parentId: string): Promise<ParentDashboardData> => {
const id = parentId.trim() const id = parentId.trim()
if (!id) return { parentName: null, children: [] } if (!id) return { parentName: null, children: [] }
@@ -248,15 +265,19 @@ export const getParentDashboardData = cache(
parentName, parentName,
children: children.filter((c): c is ChildDashboardData => c !== null), children: children.filter((c): c is ChildDashboardData => c !== null),
} }
}, }
)
export const getParentDashboardData = cacheFn(getParentDashboardDataRaw, {
tags: ["parent"],
ttl: 300,
keyParts: ["parent", "getParentDashboardData"],
})
/** /**
* 获取家长所有子女的轻量列表id + name用于详情页头部多子女切换器。 * 获取家长所有子女的轻量列表id + name用于详情页头部多子女切换器。
* 一次批量查询,避免 N+1。 * 一次批量查询,避免 N+1。
*/ */
export const getChildNameList = cache( export const getChildNameListRaw = async (parentId: string): Promise<Array<{ id: string; name: string | null }>> => {
async (parentId: string): Promise<Array<{ id: string; name: string | null }>> => {
const relations = await getChildren(parentId) const relations = await getChildren(parentId)
if (relations.length === 0) return [] if (relations.length === 0) return []
@@ -265,32 +286,40 @@ export const getChildNameList = cache(
id: r.studentId, id: r.studentId,
name: nameMap.get(r.studentId)?.name ?? null, name: nameMap.get(r.studentId)?.name ?? null,
})) }))
}, }
)
export const getChildNameList = cacheFn(getChildNameListRaw, {
tags: ["parent"],
ttl: 300,
keyParts: ["parent", "getChildNameList"],
})
/** /**
* v4-P1-5: 批量查询多个学生的家长 userId 列表。 * v4-P1-5: 批量查询多个学生的家长 userId 列表。
* 用于通知场景:报告/成绩发布时同步通知所有相关家长。 * 用于通知场景:报告/成绩发布时同步通知所有相关家长。
* 返回去重后的 parentId 数组。 * 返回去重后的 parentId 数组。
*/ */
export const getParentIdsByStudentIds = cache( export const getParentIdsByStudentIdsRaw = async (studentIds: string[]): Promise<string[]> => {
async (studentIds: string[]): Promise<string[]> => {
if (studentIds.length === 0) return [] if (studentIds.length === 0) return []
const rows = await db const rows = await db
.select({ parentId: parentStudentRelations.parentId }) .select({ parentId: parentStudentRelations.parentId })
.from(parentStudentRelations) .from(parentStudentRelations)
.where(inArray(parentStudentRelations.studentId, studentIds)) .where(inArray(parentStudentRelations.studentId, studentIds))
return Array.from(new Set(rows.map((r) => r.parentId))) return Array.from(new Set(rows.map((r) => r.parentId)))
}, }
)
export const getParentIdsByStudentIds = cacheFn(getParentIdsByStudentIdsRaw, {
tags: ["parent"],
ttl: 300,
keyParts: ["parent", "getParentIdsByStudentIds"],
})
/** /**
* L-2: 批量查询学生→家长映射(保留关联关系)。 * L-2: 批量查询学生→家长映射(保留关联关系)。
* 用于考勤通知等需要按家长-学生分组发送的场景,避免 N+1 查询。 * 用于考勤通知等需要按家长-学生分组发送的场景,避免 N+1 查询。
* 返回数组形如 [{ parentId, studentId }],同一家长多个学生会出现多条。 * 返回数组形如 [{ parentId, studentId }],同一家长多个学生会出现多条。
*/ */
export const getParentStudentMapByStudentIds = cache( export const getParentStudentMapByStudentIdsRaw = async (studentIds: string[]): Promise<Array<{ parentId: string; studentId: string }>> => {
async (studentIds: string[]): Promise<Array<{ parentId: string; studentId: string }>> => {
if (studentIds.length === 0) return [] if (studentIds.length === 0) return []
const rows = await db const rows = await db
.select({ .select({
@@ -300,5 +329,10 @@ export const getParentStudentMapByStudentIds = cache(
.from(parentStudentRelations) .from(parentStudentRelations)
.where(inArray(parentStudentRelations.studentId, studentIds)) .where(inArray(parentStudentRelations.studentId, studentIds))
return rows.map((r) => ({ parentId: r.parentId, studentId: r.studentId })) return rows.map((r) => ({ parentId: r.parentId, studentId: r.studentId }))
}, }
)
export const getParentStudentMapByStudentIds = cacheFn(getParentStudentMapByStudentIdsRaw, {
tags: ["parent"],
ttl: 300,
keyParts: ["parent", "getParentStudentMapByStudentIds"],
})

View File

@@ -3,7 +3,7 @@ import "server-only"
import { db } from "@/shared/db" import { db } from "@/shared/db"
import { examProctoringEvents } from "@/shared/db/schema" import { examProctoringEvents } from "@/shared/db/schema"
import { and, desc, eq, gte, lte, sql, inArray } from "drizzle-orm" import { and, desc, eq, gte, lte, sql, inArray } from "drizzle-orm"
import { cache } from "react" import { cacheFn } from "@/shared/lib/cache"
import { createId } from "@paralleldrive/cuid2" import { createId } from "@paralleldrive/cuid2"
import { import {
@@ -110,8 +110,7 @@ export async function recordProctoringEvent(
/** /**
* 查询某场考试的监考事件(含学生姓名、考试标题) * 查询某场考试的监考事件(含学生姓名、考试标题)
*/ */
export const getProctoringEvents = cache( export const getProctoringEventsRaw = async (
async (
examId: string, examId: string,
filters?: GetProctoringEventsFilters, filters?: GetProctoringEventsFilters,
): Promise<ProctoringEventWithDetails[]> => { ): Promise<ProctoringEventWithDetails[]> => {
@@ -169,14 +168,18 @@ export const getProctoringEvents = cache(
studentName: userMap.get(row.event.studentId)?.name ?? "未知学生", studentName: userMap.get(row.event.studentId)?.name ?? "未知学生",
examTitle: resolvedExamTitle, examTitle: resolvedExamTitle,
})) }))
}, }
)
export const getProctoringEvents = cacheFn(getProctoringEventsRaw, {
tags: ["proctoring"],
ttl: 300,
keyParts: ["proctoring", "getProctoringEvents"],
})
/** /**
* 查询某次提交的监考事件 * 查询某次提交的监考事件
*/ */
export const getProctoringEventsBySubmission = cache( export const getProctoringEventsBySubmissionRaw = async (submissionId: string): Promise<ProctoringEvent[]> => {
async (submissionId: string): Promise<ProctoringEvent[]> => {
const rows = await db.query.examProctoringEvents.findMany({ const rows = await db.query.examProctoringEvents.findMany({
where: eq(examProctoringEvents.submissionId, submissionId), where: eq(examProctoringEvents.submissionId, submissionId),
orderBy: [desc(examProctoringEvents.occurredAt)], orderBy: [desc(examProctoringEvents.occurredAt)],
@@ -192,14 +195,18 @@ export const getProctoringEventsBySubmission = cache(
occurredAt: row.occurredAt.toISOString(), occurredAt: row.occurredAt.toISOString(),
createdAt: row.createdAt.toISOString(), createdAt: row.createdAt.toISOString(),
})) }))
}, }
)
export const getProctoringEventsBySubmission = cacheFn(getProctoringEventsBySubmissionRaw, {
tags: ["proctoring"],
ttl: 300,
keyParts: ["proctoring", "getProctoringEventsBySubmission"],
})
/** /**
* 获取考试监考摘要 * 获取考试监考摘要
*/ */
export const getExamProctoringSummary = cache( export const getExamProctoringSummaryRaw = async (examId: string): Promise<ExamProctoringSummary> => {
async (examId: string): Promise<ExamProctoringSummary> => {
// 考试信息与提交记录相互独立,并行拉取 // 考试信息与提交记录相互独立,并行拉取
const [exam, submissions] = await Promise.all([ const [exam, submissions] = await Promise.all([
getExamForProctoringCrossModule(examId), getExamForProctoringCrossModule(examId),
@@ -263,14 +270,18 @@ export const getExamProctoringSummary = cache(
abnormalStudents, abnormalStudents,
eventsByType, eventsByType,
} }
}, }
)
export const getExamProctoringSummary = cacheFn(getExamProctoringSummaryRaw, {
tags: ["proctoring"],
ttl: 300,
keyParts: ["proctoring", "getExamProctoringSummary"],
})
/** /**
* 获取所有学生监考状态 * 获取所有学生监考状态
*/ */
export const getStudentProctoringStatuses = cache( export const getStudentProctoringStatusesRaw = async (examId: string): Promise<StudentProctoringStatus[]> => {
async (examId: string): Promise<StudentProctoringStatus[]> => {
// 1. 拉取所有提交记录 // 1. 拉取所有提交记录
const submissions = await getExamSubmissionsForExam(examId) const submissions = await getExamSubmissionsForExam(examId)
@@ -339,14 +350,18 @@ export const getStudentProctoringStatuses = cache(
eventsByType: stats?.byType ?? emptyEventsByType(), eventsByType: stats?.byType ?? emptyEventsByType(),
} }
}) })
}, }
)
export const getStudentProctoringStatuses = cacheFn(getStudentProctoringStatusesRaw, {
tags: ["proctoring"],
ttl: 300,
keyParts: ["proctoring", "getStudentProctoringStatuses"],
})
/** /**
* 获取考试信息(含监考模式相关字段) * 获取考试信息(含监考模式相关字段)
*/ */
export const getExamForProctoring = cache( export const getExamForProctoringRaw = async (examId: string): Promise<{
async (examId: string): Promise<{
id: string id: string
title: string title: string
examMode: ExamMode examMode: ExamMode
@@ -369,14 +384,18 @@ export const getExamForProctoring = cache(
antiCheatEnabled: exam.antiCheatEnabled ?? false, antiCheatEnabled: exam.antiCheatEnabled ?? false,
}, },
} }
}, }
)
export const getExamForProctoring = cacheFn(getExamForProctoringRaw, {
tags: ["proctoring"],
ttl: 300,
keyParts: ["proctoring", "getExamForProctoring"],
})
/** /**
* 获取最近 N 条监考事件(用于面板实时展示) * 获取最近 N 条监考事件(用于面板实时展示)
*/ */
export const getRecentProctoringEvents = cache( export const getRecentProctoringEventsRaw = async (examId: string, limit = 20): Promise<ProctoringEventWithDetails[]> => {
async (examId: string, limit = 20): Promise<ProctoringEventWithDetails[]> => {
const rows = await db const rows = await db
.select({ .select({
event: examProctoringEvents, event: examProctoringEvents,
@@ -407,7 +426,12 @@ export const getRecentProctoringEvents = cache(
studentName: userMap.get(row.event.studentId)?.name ?? "未知学生", studentName: userMap.get(row.event.studentId)?.name ?? "未知学生",
examTitle: resolvedExamTitle, examTitle: resolvedExamTitle,
})) }))
}, }
)
export const getRecentProctoringEvents = cacheFn(getRecentProctoringEventsRaw, {
tags: ["proctoring"],
ttl: 300,
keyParts: ["proctoring", "getRecentProctoringEvents"],
})
export { ALL_EVENT_TYPES } export { ALL_EVENT_TYPES }

View File

@@ -3,7 +3,7 @@ import 'server-only';
import { db } from "@/shared/db"; import { db } from "@/shared/db";
import { knowledgePoints, questions, questionsToKnowledgePoints } from "@/shared/db/schema"; import { knowledgePoints, questions, questionsToKnowledgePoints } from "@/shared/db/schema";
import { and, count, desc, eq, inArray, sql, type SQL } from "drizzle-orm"; import { and, count, desc, eq, inArray, sql, type SQL } from "drizzle-orm";
import { cache } from "react"; import { cacheFn } from "@/shared/lib/cache";
import { createId } from "@paralleldrive/cuid2"; import { createId } from "@paralleldrive/cuid2";
import { import {
getChaptersByTextbookId as getChaptersByTextbookIdFromTextbooks, getChaptersByTextbookId as getChaptersByTextbookIdFromTextbooks,
@@ -36,7 +36,7 @@ export type GetQuestionsParams = {
difficulty?: number; difficulty?: number;
}; };
export const getQuestions = cache(async ({ export const getQuestionsRaw = async ({
q, q,
page = 1, page = 1,
pageSize = 50, pageSize = 50,
@@ -190,15 +190,25 @@ export const getQuestions = cache(async ({
totalPages: Math.ceil(total / safePageSize), totalPages: Math.ceil(total / safePageSize),
}, },
}; };
}); };
export const getQuestions = cacheFn(getQuestionsRaw, {
tags: ["questions"],
ttl: 300,
keyParts: ["questions", "getQuestions"],
})
export type QuestionsDashboardStats = { export type QuestionsDashboardStats = {
questionCount: number questionCount: number
} }
export const getQuestionsDashboardStats = cache(async (): Promise<QuestionsDashboardStats> => { export const getQuestionsDashboardStatsRaw = async (): Promise<QuestionsDashboardStats> => {
const [row] = await db.select({ value: count() }).from(questions) const [row] = await db.select({ value: count() }).from(questions)
return { questionCount: Number(row?.value ?? 0) } return { questionCount: Number(row?.value ?? 0) }
}
export const getQuestionsDashboardStats = cacheFn(getQuestionsDashboardStatsRaw, {
tags: ["questions"],
ttl: 300,
keyParts: ["questions", "getQuestionsDashboardStats"],
}) })
async function insertQuestionWithRelations( async function insertQuestionWithRelations(
@@ -436,34 +446,39 @@ export type QuestionKnowledgePoint = {
} }
/** Returns knowledge points associated with the given question ids. */ /** Returns knowledge points associated with the given question ids. */
export const getKnowledgePointsForQuestions = cache( export const getKnowledgePointsForQuestionsRaw = async (
async (questionIds: string[]): Promise<Map<string, QuestionKnowledgePoint[]>> => { questionIds: string[]
const result = new Map<string, QuestionKnowledgePoint[]>() ): Promise<Map<string, QuestionKnowledgePoint[]>> => {
const uniqueIds = Array.from(new Set(questionIds.filter((v): v is string => typeof v === "string" && v.length > 0))) const result = new Map<string, QuestionKnowledgePoint[]>()
if (uniqueIds.length === 0) return result const uniqueIds = Array.from(new Set(questionIds.filter((v): v is string => typeof v === "string" && v.length > 0)))
if (uniqueIds.length === 0) return result
const rows = await db const rows = await db
.select({ .select({
questionId: questionsToKnowledgePoints.questionId, questionId: questionsToKnowledgePoints.questionId,
knowledgePointId: knowledgePoints.id, knowledgePointId: knowledgePoints.id,
knowledgePointName: knowledgePoints.name, knowledgePointName: knowledgePoints.name,
}) })
.from(questionsToKnowledgePoints) .from(questionsToKnowledgePoints)
.innerJoin(knowledgePoints, eq(knowledgePoints.id, questionsToKnowledgePoints.knowledgePointId)) .innerJoin(knowledgePoints, eq(knowledgePoints.id, questionsToKnowledgePoints.knowledgePointId))
.where(inArray(questionsToKnowledgePoints.questionId, uniqueIds)) .where(inArray(questionsToKnowledgePoints.questionId, uniqueIds))
for (const r of rows) { for (const r of rows) {
const list = result.get(r.questionId) ?? [] const list = result.get(r.questionId) ?? []
list.push({ list.push({
questionId: r.questionId, questionId: r.questionId,
knowledgePointId: r.knowledgePointId, knowledgePointId: r.knowledgePointId,
knowledgePointName: r.knowledgePointName, knowledgePointName: r.knowledgePointName,
}) })
result.set(r.questionId, list) result.set(r.questionId, list)
}
return result
} }
) return result
}
export const getKnowledgePointsForQuestions = cacheFn(getKnowledgePointsForQuestionsRaw, {
tags: ["questions"],
ttl: 300,
keyParts: ["questions", "getKnowledgePointsForQuestions"],
})
/** /**
* 跨模块接口:获取题目内容与类型(供 error-book 模块提取正确答案使用)。 * 跨模块接口:获取题目内容与类型(供 error-book 模块提取正确答案使用)。
@@ -567,7 +582,8 @@ export async function exportQuestions(
const rows = await db.query.questions.findMany({ const rows = await db.query.questions.findMany({
where: whereClause, where: whereClause,
limit: 1000, // P3-8: LIMIT 从 1000 调低至 100避免单次导出拉取过多记录
limit: 100,
orderBy: [desc(questions.createdAt)], orderBy: [desc(questions.createdAt)],
with: { with: {
knowledgePoints: { knowledgePoints: {

View File

@@ -1,8 +1,8 @@
import "server-only" import "server-only"
import { cache } from "react"
import { and, count, desc, eq, ilike, inArray, or } from "drizzle-orm" import { and, count, desc, eq, ilike, inArray, or } from "drizzle-orm"
import { cacheFn } from "@/shared/lib/cache"
import { db } from "@/shared/db" import { db } from "@/shared/db"
import { roles, users, usersToRoles } from "@/shared/db/schema" import { roles, users, usersToRoles } from "@/shared/db/schema"
@@ -24,13 +24,18 @@ function clampPage(page?: number): number {
/** /**
* Get the list of role names assigned to a user. * Get the list of role names assigned to a user.
*/ */
export const getUserRoleNames = cache(async (userId: string): Promise<string[]> => { export const getUserRoleNamesRaw = async (userId: string): Promise<string[]> => {
const rows = await db const rows = await db
.select({ name: roles.name }) .select({ name: roles.name })
.from(usersToRoles) .from(usersToRoles)
.innerJoin(roles, eq(usersToRoles.roleId, roles.id)) .innerJoin(roles, eq(usersToRoles.roleId, roles.id))
.where(eq(usersToRoles.userId, userId)) .where(eq(usersToRoles.userId, userId))
return rows.map((r) => r.name) return rows.map((r) => r.name)
}
export const getUserRoleNames = cacheFn(getUserRoleNamesRaw, {
tags: ["rbac"],
ttl: 300,
keyParts: ["rbac", "getUserRoleNames"],
}) })
/** /**

View File

@@ -1,8 +1,8 @@
import "server-only" import "server-only"
import { cache } from "react"
import { and, eq, inArray } from "drizzle-orm" import { and, eq, inArray } from "drizzle-orm"
import { cacheFn } from "@/shared/lib/cache"
import { db } from "@/shared/db" import { db } from "@/shared/db"
import { rolePermissions, roles } from "@/shared/db/schema" import { rolePermissions, roles } from "@/shared/db/schema"
import { isPermission } from "@/shared/lib/type-guards" import { isPermission } from "@/shared/lib/type-guards"
@@ -15,7 +15,7 @@ import { getAllPermissionMetas } from "./lib/permission-catalog"
* appear in the permission catalog are included; only enabled roles are * appear in the permission catalog are included; only enabled roles are
* counted (disabled roles do not contribute permissions at login time). * counted (disabled roles do not contribute permissions at login time).
*/ */
export const getPermissionRoleCounts = cache(async (): Promise<Record<string, number>> => { export const getPermissionRoleCountsRaw = async (): Promise<Record<string, number>> => {
const allPermissionValues = getAllPermissionMetas().map((m) => m.value) const allPermissionValues = getAllPermissionMetas().map((m) => m.value)
if (allPermissionValues.length === 0) return {} if (allPermissionValues.length === 0) return {}
@@ -33,4 +33,9 @@ export const getPermissionRoleCounts = cache(async (): Promise<Record<string, nu
} }
} }
return counts return counts
}
export const getPermissionRoleCounts = cacheFn(getPermissionRoleCountsRaw, {
tags: ["rbac"],
ttl: 300,
keyParts: ["rbac", "getPermissionRoleCounts"],
}) })

View File

@@ -1,8 +1,8 @@
import "server-only" import "server-only"
import { cache } from "react"
import { createId } from "@paralleldrive/cuid2" import { createId } from "@paralleldrive/cuid2"
import { count, eq, inArray } from "drizzle-orm" import { count, eq, inArray } from "drizzle-orm"
import { cacheFn } from "@/shared/lib/cache"
import { db } from "@/shared/db" import { db } from "@/shared/db"
import { rolePermissions, roles, usersToRoles } from "@/shared/db/schema" import { rolePermissions, roles, usersToRoles } from "@/shared/db/schema"
import { isPermission } from "@/shared/lib/type-guards" import { isPermission } from "@/shared/lib/type-guards"
@@ -35,7 +35,7 @@ function toRoleRecord(row: typeof roles.$inferSelect): RoleRecord {
* List all roles with permission and user counts. * List all roles with permission and user counts.
* Results are ordered: system roles first (alphabetical), then custom roles (newest first). * Results are ordered: system roles first (alphabetical), then custom roles (newest first).
*/ */
export const getRoles = cache(async (): Promise<RoleWithStats[]> => { export const getRolesRaw = async (): Promise<RoleWithStats[]> => {
const roleRows = await db.select().from(roles).orderBy(roles.name) const roleRows = await db.select().from(roles).orderBy(roles.name)
if (roleRows.length === 0) return [] if (roleRows.length === 0) return []
@@ -63,12 +63,17 @@ export const getRoles = cache(async (): Promise<RoleWithStats[]> => {
permissionCount: permMap.get(row.id) ?? 0, permissionCount: permMap.get(row.id) ?? 0,
userCount: userMap.get(row.id) ?? 0, userCount: userMap.get(row.id) ?? 0,
})) }))
}
export const getRoles = cacheFn(getRolesRaw, {
tags: ["rbac"],
ttl: 300,
keyParts: ["rbac", "getRoles"],
}) })
/** /**
* Get a single role by id, including its full permission list and user count. * Get a single role by id, including its full permission list and user count.
*/ */
export const getRoleById = cache(async (id: string): Promise<RoleDetail | null> => { export const getRoleByIdRaw = async (id: string): Promise<RoleDetail | null> => {
const roleRow = await db.query.roles.findFirst({ where: eq(roles.id, id) }) const roleRow = await db.query.roles.findFirst({ where: eq(roles.id, id) })
if (!roleRow) return null if (!roleRow) return null
@@ -90,14 +95,24 @@ export const getRoleById = cache(async (id: string): Promise<RoleDetail | null>
.filter((p): p is Permission => p !== null), .filter((p): p is Permission => p !== null),
userCount: userCountRows[0] ? Number(userCountRows[0].count) : 0, userCount: userCountRows[0] ? Number(userCountRows[0].count) : 0,
} }
}
export const getRoleById = cacheFn(getRoleByIdRaw, {
tags: ["rbac"],
ttl: 300,
keyParts: ["rbac", "getRoleById"],
}) })
/** /**
* Get a single role by name. * Get a single role by name.
*/ */
export const getRoleByName = cache(async (name: string): Promise<RoleRecord | null> => { export const getRoleByNameRaw = async (name: string): Promise<RoleRecord | null> => {
const row = await db.query.roles.findFirst({ where: eq(roles.name, name) }) const row = await db.query.roles.findFirst({ where: eq(roles.name, name) })
return row ? toRoleRecord(row) : null return row ? toRoleRecord(row) : null
}
export const getRoleByName = cacheFn(getRoleByNameRaw, {
tags: ["rbac"],
ttl: 300,
keyParts: ["rbac", "getRoleByName"],
}) })
/** /**
@@ -188,7 +203,7 @@ export async function setRoleEnabled(id: string, enabled: boolean): Promise<Role
/** /**
* Get the list of permission strings assigned to a role. * Get the list of permission strings assigned to a role.
*/ */
export const getRolePermissions = cache(async (roleId: string): Promise<Permission[]> => { export const getRolePermissionsRaw = async (roleId: string): Promise<Permission[]> => {
const rows = await db const rows = await db
.select({ permission: rolePermissions.permission }) .select({ permission: rolePermissions.permission })
.from(rolePermissions) .from(rolePermissions)
@@ -196,6 +211,11 @@ export const getRolePermissions = cache(async (roleId: string): Promise<Permissi
return rows return rows
.map((r) => (isPermission(r.permission) ? r.permission : null)) .map((r) => (isPermission(r.permission) ? r.permission : null))
.filter((p): p is Permission => p !== null) .filter((p): p is Permission => p !== null)
}
export const getRolePermissions = cacheFn(getRolePermissionsRaw, {
tags: ["rbac"],
ttl: 300,
keyParts: ["rbac", "getRolePermissions"],
}) })
/** /**

View File

@@ -1,8 +1,8 @@
import "server-only" import "server-only"
import { cache } from "react"
import { and, asc, desc, eq, inArray, or, sql } from "drizzle-orm" import { and, asc, desc, eq, inArray, or, sql } from "drizzle-orm"
import { cacheFn } from "@/shared/lib/cache"
import { db } from "@/shared/db" import { db } from "@/shared/db"
import { academicYears, departments, grades, roles, schools, subjects, users, usersToRoles } from "@/shared/db/schema" import { academicYears, departments, grades, roles, schools, subjects, users, usersToRoles } from "@/shared/db/schema"
import type { import type {
@@ -24,7 +24,7 @@ import type {
const toIso = (d: Date): string => d.toISOString() const toIso = (d: Date): string => d.toISOString()
export const getDepartments = cache(async (): Promise<DepartmentListItem[]> => { export const getDepartmentsRaw = async (): Promise<DepartmentListItem[]> => {
try { try {
const rows = await db.select().from(departments).orderBy(asc(departments.name)) const rows = await db.select().from(departments).orderBy(asc(departments.name))
return rows.map((r) => ({ return rows.map((r) => ({
@@ -38,9 +38,14 @@ export const getDepartments = cache(async (): Promise<DepartmentListItem[]> => {
console.error("getDepartments failed:", error) console.error("getDepartments failed:", error)
return [] return []
} }
}
export const getDepartments = cacheFn(getDepartmentsRaw, {
tags: ["school"],
ttl: 300,
keyParts: ["school", "getDepartments"],
}) })
export const getAcademicYears = cache(async (): Promise<AcademicYearListItem[]> => { export const getAcademicYearsRaw = async (): Promise<AcademicYearListItem[]> => {
try { try {
const rows = await db.select().from(academicYears).orderBy(asc(academicYears.startDate)) const rows = await db.select().from(academicYears).orderBy(asc(academicYears.startDate))
return rows.map((r) => ({ return rows.map((r) => ({
@@ -56,9 +61,14 @@ export const getAcademicYears = cache(async (): Promise<AcademicYearListItem[]>
console.error("getAcademicYears failed:", error) console.error("getAcademicYears failed:", error)
return [] return []
} }
}
export const getAcademicYears = cacheFn(getAcademicYearsRaw, {
tags: ["school"],
ttl: 300,
keyParts: ["school", "getAcademicYears"],
}) })
export const getSchools = cache(async (): Promise<SchoolListItem[]> => { export const getSchoolsRaw = async (): Promise<SchoolListItem[]> => {
try { try {
const rows = await db.select().from(schools).orderBy(asc(schools.name)) const rows = await db.select().from(schools).orderBy(asc(schools.name))
return rows.map((r) => ({ return rows.map((r) => ({
@@ -72,9 +82,14 @@ export const getSchools = cache(async (): Promise<SchoolListItem[]> => {
console.error("getSchools failed:", error) console.error("getSchools failed:", error)
return [] return []
} }
}
export const getSchools = cacheFn(getSchoolsRaw, {
tags: ["school"],
ttl: 300,
keyParts: ["school", "getSchools"],
}) })
export const getGrades = cache(async (): Promise<GradeListItem[]> => { export const getGradesRaw = async (): Promise<GradeListItem[]> => {
try { try {
const rows = await db const rows = await db
.select({ .select({
@@ -126,9 +141,14 @@ export const getGrades = cache(async (): Promise<GradeListItem[]> => {
console.error("getGrades failed:", error) console.error("getGrades failed:", error)
return [] return []
} }
}
export const getGrades = cacheFn(getGradesRaw, {
tags: ["school"],
ttl: 300,
keyParts: ["school", "getGrades"],
}) })
export const getStaffOptions = cache(async (): Promise<StaffOption[]> => { export const getStaffOptionsRaw = async (): Promise<StaffOption[]> => {
try { try {
const rows = await db const rows = await db
.select({ id: users.id, name: users.name, email: users.email }) .select({ id: users.id, name: users.name, email: users.email })
@@ -148,9 +168,14 @@ export const getStaffOptions = cache(async (): Promise<StaffOption[]> => {
console.error("getStaffOptions failed:", error) console.error("getStaffOptions failed:", error)
return [] return []
} }
}
export const getStaffOptions = cacheFn(getStaffOptionsRaw, {
tags: ["school"],
ttl: 300,
keyParts: ["school", "getStaffOptions"],
}) })
export const getGradesForStaff = cache(async (staffId: string): Promise<GradeListItem[]> => { export const getGradesForStaffRaw = async (staffId: string): Promise<GradeListItem[]> => {
const id = staffId.trim() const id = staffId.trim()
if (!id) return [] if (!id) return []
@@ -204,6 +229,11 @@ export const getGradesForStaff = cache(async (staffId: string): Promise<GradeLis
console.error("getGradesForStaff failed:", error) console.error("getGradesForStaff failed:", error)
return [] return []
} }
}
export const getGradesForStaff = cacheFn(getGradesForStaffRaw, {
tags: ["school"],
ttl: 300,
keyParts: ["school", "getGradesForStaff"],
}) })
/** /**
@@ -213,7 +243,7 @@ export const getGradesForStaff = cache(async (staffId: string): Promise<GradeLis
* - teacher: 返回其任课班级所在学校 * - teacher: 返回其任课班级所在学校
* - 其他角色: 返回空数组 * - 其他角色: 返回空数组
*/ */
export const getSchoolsForUser = cache(async (userId: string): Promise<SchoolListItem[]> => { export const getSchoolsForUserRaw = async (userId: string): Promise<SchoolListItem[]> => {
const id = userId.trim() const id = userId.trim()
if (!id) return [] if (!id) return []
@@ -277,6 +307,11 @@ export const getSchoolsForUser = cache(async (userId: string): Promise<SchoolLis
console.error("getSchoolsForUser failed:", error) console.error("getSchoolsForUser failed:", error)
return [] return []
} }
}
export const getSchoolsForUser = cacheFn(getSchoolsForUserRaw, {
tags: ["school"],
ttl: 300,
keyParts: ["school", "getSchoolsForUser"],
}) })
/** /**
@@ -286,7 +321,7 @@ export const getSchoolsForUser = cache(async (userId: string): Promise<SchoolLis
* - teacher: 返回其任课班级所在年级 * - teacher: 返回其任课班级所在年级
* - 其他角色: 返回空数组 * - 其他角色: 返回空数组
*/ */
export const getGradesForUser = cache(async (userId: string): Promise<GradeListItem[]> => { export const getGradesForUserRaw = async (userId: string): Promise<GradeListItem[]> => {
const id = userId.trim() const id = userId.trim()
if (!id) return [] if (!id) return []
@@ -370,6 +405,11 @@ export const getGradesForUser = cache(async (userId: string): Promise<GradeListI
console.error("getGradesForUser failed:", error) console.error("getGradesForUser failed:", error)
return [] return []
} }
}
export const getGradesForUser = cacheFn(getGradesForUserRaw, {
tags: ["school"],
ttl: 300,
keyParts: ["school", "getGradesForUser"],
}) })
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -511,7 +551,7 @@ export type GradeOption = {
order: number order: number
} }
export const getSubjectOptions = cache(async (): Promise<SubjectOption[]> => { export const getSubjectOptionsRaw = async (): Promise<SubjectOption[]> => {
try { try {
const rows = await db const rows = await db
.select({ .select({
@@ -533,9 +573,14 @@ export const getSubjectOptions = cache(async (): Promise<SubjectOption[]> => {
console.error("getSubjectOptions failed:", error) console.error("getSubjectOptions failed:", error)
return [] return []
} }
}
export const getSubjectOptions = cacheFn(getSubjectOptionsRaw, {
tags: ["school"],
ttl: 300,
keyParts: ["school", "getSubjectOptions"],
}) })
export const getGradeOptions = cache(async (): Promise<GradeOption[]> => { export const getGradeOptionsRaw = async (): Promise<GradeOption[]> => {
try { try {
const rows = await db const rows = await db
.select({ .select({
@@ -560,59 +605,79 @@ export const getGradeOptions = cache(async (): Promise<GradeOption[]> => {
console.error("getGradeOptions failed:", error) console.error("getGradeOptions failed:", error)
return [] return []
} }
}
export const getGradeOptions = cacheFn(getGradeOptionsRaw, {
tags: ["school"],
ttl: 300,
keyParts: ["school", "getGradeOptions"],
}) })
/** /**
* 按 ID 获取单个年级名称。 * 按 ID 获取单个年级名称。
* 供跨模块调用使用,避免为单个年级拉取全量年级选项。 * 供跨模块调用使用,避免为单个年级拉取全量年级选项。
*/ */
export const getGradeNameById = cache( export const getGradeNameByIdRaw = async (
async (gradeId: string): Promise<string | null> => { gradeId: string
const id = gradeId.trim() ): Promise<string | null> => {
if (!id) return null const id = gradeId.trim()
const [row] = await db if (!id) return null
.select({ name: grades.name }) const [row] = await db
.from(grades) .select({ name: grades.name })
.where(eq(grades.id, id)) .from(grades)
.limit(1) .where(eq(grades.id, id))
return row?.name ?? null .limit(1)
}, return row?.name ?? null
) }
export const getGradeNameById = cacheFn(getGradeNameByIdRaw, {
tags: ["school"],
ttl: 300,
keyParts: ["school", "getGradeNameById"],
})
/** /**
* 按 ID 获取单个科目名称。 * 按 ID 获取单个科目名称。
* 供跨模块调用使用,避免为单个科目拉取全量科目选项或直接查询 subjects 表。 * 供跨模块调用使用,避免为单个科目拉取全量科目选项或直接查询 subjects 表。
*/ */
export const getSubjectNameById = cache( export const getSubjectNameByIdRaw = async (
async (subjectId: string): Promise<string | null> => { subjectId: string
const id = subjectId.trim() ): Promise<string | null> => {
if (!id) return null const id = subjectId.trim()
const [row] = await db if (!id) return null
.select({ name: subjects.name }) const [row] = await db
.from(subjects) .select({ name: subjects.name })
.where(eq(subjects.id, id)) .from(subjects)
.limit(1) .where(eq(subjects.id, id))
return row?.name ?? null .limit(1)
}, return row?.name ?? null
) }
export const getSubjectNameById = cacheFn(getSubjectNameByIdRaw, {
tags: ["school"],
ttl: 300,
keyParts: ["school", "getSubjectNameById"],
})
/** /**
* 批量获取科目名称映射subjectId -> name * 批量获取科目名称映射subjectId -> name
* 供跨模块调用使用,避免直接查询 subjects 表。 * 供跨模块调用使用,避免直接查询 subjects 表。
*/ */
export const getSubjectNameMapByIds = cache( export const getSubjectNameMapByIdsRaw = async (
async (subjectIds: string[]): Promise<Map<string, string | null>> => { subjectIds: string[]
const ids = subjectIds.filter((id) => id.trim().length > 0) ): Promise<Map<string, string | null>> => {
if (ids.length === 0) return new Map() const ids = subjectIds.filter((id) => id.trim().length > 0)
const rows = await db if (ids.length === 0) return new Map()
.select({ id: subjects.id, name: subjects.name }) const rows = await db
.from(subjects) .select({ id: subjects.id, name: subjects.name })
.where(inArray(subjects.id, ids)) .from(subjects)
const map = new Map<string, string | null>() .where(inArray(subjects.id, ids))
for (const r of rows) map.set(r.id, r.name) const map = new Map<string, string | null>()
return map for (const r of rows) map.set(r.id, r.name)
}, return map
) }
export const getSubjectNameMapByIds = cacheFn(getSubjectNameMapByIdsRaw, {
tags: ["school"],
ttl: 300,
keyParts: ["school", "getSubjectNameMapByIds"],
})
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Cross-module query interfaces — grade head/teaching head verification // Cross-module query interfaces — grade head/teaching head verification
@@ -622,7 +687,7 @@ export const getSubjectNameMapByIds = cache(
* 校验用户是否为指定年级的年级主任。 * 校验用户是否为指定年级的年级主任。
* 供 classes 模块跨模块调用使用,避免直接查询 grades 表。 * 供 classes 模块跨模块调用使用,避免直接查询 grades 表。
*/ */
export const isGradeHead = cache(async ( export const isGradeHeadRaw = async (
gradeId: string, gradeId: string,
userId: string userId: string
): Promise<boolean> => { ): Promise<boolean> => {
@@ -636,13 +701,18 @@ export const isGradeHead = cache(async (
.where(and(eq(grades.id, trimmedGradeId), eq(grades.gradeHeadId, trimmedUserId))) .where(and(eq(grades.id, trimmedGradeId), eq(grades.gradeHeadId, trimmedUserId)))
.limit(1) .limit(1)
return Boolean(row) return Boolean(row)
}
export const isGradeHead = cacheFn(isGradeHeadRaw, {
tags: ["school"],
ttl: 300,
keyParts: ["school", "isGradeHead"],
}) })
/** /**
* 校验用户是否为指定年级的年级主任或教学主任。 * 校验用户是否为指定年级的年级主任或教学主任。
* 供 classes 模块跨模块调用使用,避免直接查询 grades 表。 * 供 classes 模块跨模块调用使用,避免直接查询 grades 表。
*/ */
export const isGradeManager = cache(async ( export const isGradeManagerRaw = async (
gradeId: string, gradeId: string,
userId: string userId: string
): Promise<boolean> => { ): Promise<boolean> => {
@@ -661,13 +731,18 @@ export const isGradeManager = cache(async (
) )
.limit(1) .limit(1)
return Boolean(row) return Boolean(row)
}
export const isGradeManager = cacheFn(isGradeManagerRaw, {
tags: ["school"],
ttl: 300,
keyParts: ["school", "isGradeManager"],
}) })
/** /**
* 根据年级名称(大小写不敏感)查找用户担任年级主任的年级 ID。 * 根据年级名称(大小写不敏感)查找用户担任年级主任的年级 ID。
* 供 classes 模块跨模块调用使用,避免直接查询 grades 表。 * 供 classes 模块跨模块调用使用,避免直接查询 grades 表。
*/ */
export const findGradeIdByHeadAndName = cache(async ( export const findGradeIdByHeadAndNameRaw = async (
userId: string, userId: string,
gradeName: string gradeName: string
): Promise<string | null> => { ): Promise<string | null> => {
@@ -686,6 +761,11 @@ export const findGradeIdByHeadAndName = cache(async (
) )
.limit(1) .limit(1)
return row?.id ?? null return row?.id ?? null
}
export const findGradeIdByHeadAndName = cacheFn(findGradeIdByHeadAndNameRaw, {
tags: ["school"],
ttl: 300,
keyParts: ["school", "findGradeIdByHeadAndName"],
}) })
/** /**
@@ -746,7 +826,7 @@ export async function promoteGrades(schoolId: string): Promise<{ promoted: numbe
* 班级数据通过 classes 模块的 data-access 动态导入获取,避免循环依赖。 * 班级数据通过 classes 模块的 data-access 动态导入获取,避免循环依赖。
* 任何一层查询失败均返回空数组,保证调用方拿到稳定结构。 * 任何一层查询失败均返回空数组,保证调用方拿到稳定结构。
*/ */
export const getOrgTree = cache(async (): Promise<OrgTreeNode[]> => { export const getOrgTreeRaw = async (): Promise<OrgTreeNode[]> => {
try { try {
const [schoolList, gradeList] = await Promise.all([getSchools(), getGrades()]) const [schoolList, gradeList] = await Promise.all([getSchools(), getGrades()])
@@ -797,6 +877,11 @@ export const getOrgTree = cache(async (): Promise<OrgTreeNode[]> => {
console.error("getOrgTree failed:", error) console.error("getOrgTree failed:", error)
return [] return []
} }
}
export const getOrgTree = cacheFn(getOrgTreeRaw, {
tags: ["school"],
ttl: 300,
keyParts: ["school", "getOrgTree"],
}) })
/** /**
@@ -810,7 +895,7 @@ export interface GradeOverviewStats {
teacherCount: number teacherCount: number
} }
export const getGradeOverviewStats = cache(async (): Promise<GradeOverviewStats[]> => { export const getGradeOverviewStatsRaw = async (): Promise<GradeOverviewStats[]> => {
try { try {
// 动态导入 classes 模块避免循环依赖 // 动态导入 classes 模块避免循环依赖
const { getAdminClasses } = await import("@/modules/classes/data-access") const { getAdminClasses } = await import("@/modules/classes/data-access")
@@ -845,4 +930,9 @@ export const getGradeOverviewStats = cache(async (): Promise<GradeOverviewStats[
console.error("getGradeOverviewStats failed:", error) console.error("getGradeOverviewStats failed:", error)
return [] return []
} }
}
export const getGradeOverviewStats = cacheFn(getGradeOverviewStatsRaw, {
tags: ["school"],
ttl: 300,
keyParts: ["school", "getGradeOverviewStats"],
}) })

View File

@@ -1,8 +1,8 @@
import "server-only" import "server-only"
import { cache } from "react"
import { and, asc, eq, inArray, sql, count } from "drizzle-orm" import { and, asc, eq, inArray, sql, count } from "drizzle-orm"
import { cacheFn } from "@/shared/lib/cache"
import { db } from "@/shared/db" import { db } from "@/shared/db"
import { import {
chapters, chapters,
@@ -18,7 +18,7 @@ import type { KpWithRelations, MasteryInfo } from "./types"
* *
* 一次查询聚合,避免 N+1。 * 一次查询聚合,避免 N+1。
*/ */
export const getKnowledgePointsWithRelations = cache(async ( export const getKnowledgePointsWithRelationsRaw = async (
textbookId: string, textbookId: string,
): Promise<KpWithRelations[]> => { ): Promise<KpWithRelations[]> => {
// 1. 查询全书知识点 + 章节标题 // 1. 查询全书知识点 + 章节标题
@@ -90,12 +90,17 @@ export const getKnowledgePointsWithRelations = cache(async (
questionCount: questionCountMap.get(r.id) ?? 0, questionCount: questionCountMap.get(r.id) ?? 0,
prerequisiteIds: prereqMap.get(r.id) ?? [], prerequisiteIds: prereqMap.get(r.id) ?? [],
})) }))
}
export const getKnowledgePointsWithRelations = cacheFn(getKnowledgePointsWithRelationsRaw, {
tags: ["textbooks"],
ttl: 300,
keyParts: ["textbooks", "getKnowledgePointsWithRelations"],
}) })
/** /**
* 获取学生在某教材下所有知识点的掌握度。 * 获取学生在某教材下所有知识点的掌握度。
*/ */
export const getStudentKpMastery = cache(async ( export const getStudentKpMasteryRaw = async (
studentId: string, studentId: string,
textbookId: string, textbookId: string,
): Promise<Map<string, MasteryInfo>> => { ): Promise<Map<string, MasteryInfo>> => {
@@ -125,6 +130,11 @@ export const getStudentKpMastery = cache(async (
}) })
} }
return map return map
}
export const getStudentKpMastery = cacheFn(getStudentKpMasteryRaw, {
tags: ["textbooks"],
ttl: 300,
keyParts: ["textbooks", "getStudentKpMastery"],
}) })
/** /**
@@ -133,7 +143,7 @@ export const getStudentKpMastery = cache(async (
* @param studentIds 班级学生 ID 列表 * @param studentIds 班级学生 ID 列表
* @param textbookId 教材 ID * @param textbookId 教材 ID
*/ */
export const getClassKpMastery = cache(async ( export const getClassKpMasteryRaw = async (
studentIds: string[], studentIds: string[],
textbookId: string, textbookId: string,
): Promise<Map<string, MasteryInfo>> => { ): Promise<Map<string, MasteryInfo>> => {
@@ -166,12 +176,17 @@ export const getClassKpMastery = cache(async (
}) })
} }
return map return map
}
export const getClassKpMastery = cacheFn(getClassKpMasteryRaw, {
tags: ["textbooks"],
ttl: 300,
keyParts: ["textbooks", "getClassKpMastery"],
}) })
/** /**
* 获取某个知识点的前置依赖列表(含知识点详情)。 * 获取某个知识点的前置依赖列表(含知识点详情)。
*/ */
export const getPrerequisitesForKp = cache(async ( export const getPrerequisitesForKpRaw = async (
kpId: string, kpId: string,
): Promise<{ id: string; name: string; description: string | null }[]> => { ): Promise<{ id: string; name: string; description: string | null }[]> => {
const rows = await db const rows = await db
@@ -185,12 +200,17 @@ export const getPrerequisitesForKp = cache(async (
.where(eq(knowledgePointPrerequisites.knowledgePointId, kpId)) .where(eq(knowledgePointPrerequisites.knowledgePointId, kpId))
return rows return rows
}
export const getPrerequisitesForKp = cacheFn(getPrerequisitesForKpRaw, {
tags: ["textbooks"],
ttl: 300,
keyParts: ["textbooks", "getPrerequisitesForKp"],
}) })
/** /**
* 获取某个知识点的后置知识点列表(即哪些知识点以此 KP 为前置)。 * 获取某个知识点的后置知识点列表(即哪些知识点以此 KP 为前置)。
*/ */
export const getSuccessorsForKp = cache(async ( export const getSuccessorsForKpRaw = async (
kpId: string, kpId: string,
): Promise<{ id: string; name: string; description: string | null }[]> => { ): Promise<{ id: string; name: string; description: string | null }[]> => {
const rows = await db const rows = await db
@@ -204,4 +224,9 @@ export const getSuccessorsForKp = cache(async (
.where(eq(knowledgePointPrerequisites.prerequisiteKpId, kpId)) .where(eq(knowledgePointPrerequisites.prerequisiteKpId, kpId))
return rows return rows
}
export const getSuccessorsForKp = cacheFn(getSuccessorsForKpRaw, {
tags: ["textbooks"],
ttl: 300,
keyParts: ["textbooks", "getSuccessorsForKp"],
}) })

View File

@@ -1,9 +1,9 @@
import "server-only" import "server-only"
import { cache } from "react"
import { and, asc, count, eq, inArray, like, or, sql, isNull, type SQL } from "drizzle-orm" import { and, asc, count, eq, inArray, like, or, sql, isNull, type SQL } from "drizzle-orm"
import { createId } from "@paralleldrive/cuid2" import { createId } from "@paralleldrive/cuid2"
import { cacheFn } from "@/shared/lib/cache"
import { db } from "@/shared/db" import { db } from "@/shared/db"
import { chapters, knowledgePoints, knowledgePointPrerequisites, textbooks } from "@/shared/db/schema" import { chapters, knowledgePoints, knowledgePointPrerequisites, textbooks } from "@/shared/db/schema"
import { escapeLikePattern, NotFoundError } from "@/shared/lib/action-utils" import { escapeLikePattern, NotFoundError } from "@/shared/lib/action-utils"
@@ -40,7 +40,7 @@ export interface TextbookQueryScope {
grade?: string grade?: string
} }
export const getTextbooks = cache(async (query?: string, subject?: string, grade?: string): Promise<Textbook[]> => { export const getTextbooksRaw = async (query?: string, subject?: string, grade?: string): Promise<Textbook[]> => {
const conditions: SQL[] = [] const conditions: SQL[] = []
const q = query?.trim() const q = query?.trim()
@@ -88,9 +88,14 @@ export const getTextbooks = cache(async (query?: string, subject?: string, grade
updatedAt: r.updatedAt, updatedAt: r.updatedAt,
_count: { chapters: Number(r.chaptersCount ?? 0) }, _count: { chapters: Number(r.chaptersCount ?? 0) },
})) }))
}
export const getTextbooks = cacheFn(getTextbooksRaw, {
tags: ["textbooks"],
ttl: 300,
keyParts: ["textbooks", "getTextbooks"],
}) })
export const getTextbookById = cache(async (id: string): Promise<Textbook | undefined> => { export const getTextbookByIdRaw = async (id: string): Promise<Textbook | undefined> => {
const [row] = await db const [row] = await db
.select({ .select({
id: textbooks.id, id: textbooks.id,
@@ -120,9 +125,14 @@ export const getTextbookById = cache(async (id: string): Promise<Textbook | unde
updatedAt: row.updatedAt, updatedAt: row.updatedAt,
_count: { chapters: Number(row.chaptersCount ?? 0) }, _count: { chapters: Number(row.chaptersCount ?? 0) },
} }
}
export const getTextbookById = cacheFn(getTextbookByIdRaw, {
tags: ["textbooks"],
ttl: 300,
keyParts: ["textbooks", "getTextbookById"],
}) })
export const getChaptersByTextbookId = cache(async (textbookId: string): Promise<Chapter[]> => { export const getChaptersByTextbookIdRaw = async (textbookId: string): Promise<Chapter[]> => {
const rows = await db const rows = await db
.select({ .select({
id: chapters.id, id: chapters.id,
@@ -150,6 +160,11 @@ export const getChaptersByTextbookId = cache(async (textbookId: string): Promise
updatedAt: r.updatedAt, updatedAt: r.updatedAt,
})) }))
) )
}
export const getChaptersByTextbookId = cacheFn(getChaptersByTextbookIdRaw, {
tags: ["textbooks"],
ttl: 300,
keyParts: ["textbooks", "getChaptersByTextbookId"],
}) })
export async function createTextbook(data: CreateTextbookInput): Promise<Textbook> { export async function createTextbook(data: CreateTextbookInput): Promise<Textbook> {
@@ -317,7 +332,7 @@ export async function deleteChapter(id: string): Promise<void> {
}) })
} }
export const getKnowledgePointsByChapterId = cache(async (chapterId: string): Promise<KnowledgePoint[]> => { export const getKnowledgePointsByChapterIdRaw = async (chapterId: string): Promise<KnowledgePoint[]> => {
const rows = await db const rows = await db
.select({ .select({
id: knowledgePoints.id, id: knowledgePoints.id,
@@ -341,9 +356,14 @@ export const getKnowledgePointsByChapterId = cache(async (chapterId: string): Pr
level: r.level ?? 0, level: r.level ?? 0,
order: r.order ?? 0, order: r.order ?? 0,
})) }))
}
export const getKnowledgePointsByChapterId = cacheFn(getKnowledgePointsByChapterIdRaw, {
tags: ["textbooks"],
ttl: 300,
keyParts: ["textbooks", "getKnowledgePointsByChapterId"],
}) })
export const getKnowledgePointsByTextbookId = cache(async (textbookId: string): Promise<KnowledgePoint[]> => { export const getKnowledgePointsByTextbookIdRaw = async (textbookId: string): Promise<KnowledgePoint[]> => {
const rows = await db const rows = await db
.select({ .select({
id: knowledgePoints.id, id: knowledgePoints.id,
@@ -368,6 +388,11 @@ export const getKnowledgePointsByTextbookId = cache(async (textbookId: string):
level: r.level ?? 0, level: r.level ?? 0,
order: r.order ?? 0, order: r.order ?? 0,
})) }))
}
export const getKnowledgePointsByTextbookId = cacheFn(getKnowledgePointsByTextbookIdRaw, {
tags: ["textbooks"],
ttl: 300,
keyParts: ["textbooks", "getKnowledgePointsByTextbookId"],
}) })
export async function createKnowledgePoint(data: CreateKnowledgePointInput): Promise<void> { export async function createKnowledgePoint(data: CreateKnowledgePointInput): Promise<void> {
@@ -437,7 +462,7 @@ export type TextbooksDashboardStats = {
chapterCount: number chapterCount: number
} }
export const getTextbooksDashboardStats = cache(async (): Promise<TextbooksDashboardStats> => { export const getTextbooksDashboardStatsRaw = async (): Promise<TextbooksDashboardStats> => {
const [textbookCountRow, chapterCountRow] = await Promise.all([ const [textbookCountRow, chapterCountRow] = await Promise.all([
db.select({ value: count() }).from(textbooks), db.select({ value: count() }).from(textbooks),
db.select({ value: count() }).from(chapters), db.select({ value: count() }).from(chapters),
@@ -446,6 +471,11 @@ export const getTextbooksDashboardStats = cache(async (): Promise<TextbooksDashb
textbookCount: Number(textbookCountRow[0]?.value ?? 0), textbookCount: Number(textbookCountRow[0]?.value ?? 0),
chapterCount: Number(chapterCountRow[0]?.value ?? 0), chapterCount: Number(chapterCountRow[0]?.value ?? 0),
} }
}
export const getTextbooksDashboardStats = cacheFn(getTextbooksDashboardStatsRaw, {
tags: ["textbooks"],
ttl: 300,
keyParts: ["textbooks", "getTextbooksDashboardStats"],
}) })
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -502,75 +532,78 @@ export async function verifyKnowledgePointBelongsToTextbook(
* *
* 学生端应传入 `scope.grade` 按年级过滤,避免跨年级越权读取。 * 学生端应传入 `scope.grade` 按年级过滤,避免跨年级越权读取。
*/ */
export const getTextbooksWithScope = cache( export const getTextbooksWithScopeRaw = async (
async ( query?: string,
query?: string, subject?: string,
subject?: string, grade?: string,
grade?: string, scope?: TextbookQueryScope
scope?: TextbookQueryScope ): Promise<Textbook[]> => {
): Promise<Textbook[]> => { const conditions: SQL[] = []
const conditions: SQL[] = []
const q = query?.trim() const q = query?.trim()
if (q) { if (q) {
const needle = `%${escapeLikePattern(q)}%` const needle = `%${escapeLikePattern(q)}%`
const nameCond = or( const nameCond = or(
like(textbooks.title, needle), like(textbooks.title, needle),
like(textbooks.subject, needle), like(textbooks.subject, needle),
like(textbooks.grade, needle), like(textbooks.grade, needle),
like(textbooks.publisher, needle) like(textbooks.publisher, needle)
) )
if (nameCond) conditions.push(nameCond) if (nameCond) conditions.push(nameCond)
}
const s = subject?.trim()
if (s && s !== "all") conditions.push(eq(textbooks.subject, s))
// URL 参数 grade用户筛选
const g = grade?.trim()
if (g && g !== "all") conditions.push(eq(textbooks.grade, g))
// scope.grade数据范围过滤学生端强制按年级过滤
const scopeGrade = scope?.grade?.trim()
if (scopeGrade) conditions.push(eq(textbooks.grade, scopeGrade))
const rows = await db
.select({
id: textbooks.id,
title: textbooks.title,
subject: textbooks.subject,
grade: textbooks.grade,
publisher: textbooks.publisher,
createdAt: textbooks.createdAt,
updatedAt: textbooks.updatedAt,
chaptersCount: sql<number>`COUNT(${chapters.id})`,
})
.from(textbooks)
.leftJoin(chapters, eq(chapters.textbookId, textbooks.id))
.where(conditions.length ? and(...conditions) : undefined)
.groupBy(
textbooks.id,
textbooks.title,
textbooks.subject,
textbooks.grade,
textbooks.publisher,
textbooks.createdAt,
textbooks.updatedAt
)
.orderBy(asc(textbooks.title), asc(textbooks.subject), asc(textbooks.grade))
return rows.map((r) => ({
id: r.id,
title: r.title,
subject: r.subject,
grade: r.grade,
publisher: r.publisher,
createdAt: r.createdAt,
updatedAt: r.updatedAt,
_count: { chapters: Number(r.chaptersCount ?? 0) },
}))
} }
)
const s = subject?.trim()
if (s && s !== "all") conditions.push(eq(textbooks.subject, s))
// URL 参数 grade用户筛选
const g = grade?.trim()
if (g && g !== "all") conditions.push(eq(textbooks.grade, g))
// scope.grade数据范围过滤学生端强制按年级过滤
const scopeGrade = scope?.grade?.trim()
if (scopeGrade) conditions.push(eq(textbooks.grade, scopeGrade))
const rows = await db
.select({
id: textbooks.id,
title: textbooks.title,
subject: textbooks.subject,
grade: textbooks.grade,
publisher: textbooks.publisher,
createdAt: textbooks.createdAt,
updatedAt: textbooks.updatedAt,
chaptersCount: sql<number>`COUNT(${chapters.id})`,
})
.from(textbooks)
.leftJoin(chapters, eq(chapters.textbookId, textbooks.id))
.where(conditions.length ? and(...conditions) : undefined)
.groupBy(
textbooks.id,
textbooks.title,
textbooks.subject,
textbooks.grade,
textbooks.publisher,
textbooks.createdAt,
textbooks.updatedAt
)
.orderBy(asc(textbooks.title), asc(textbooks.subject), asc(textbooks.grade))
return rows.map((r) => ({
id: r.id,
title: r.title,
subject: r.subject,
grade: r.grade,
publisher: r.publisher,
createdAt: r.createdAt,
updatedAt: r.updatedAt,
_count: { chapters: Number(r.chaptersCount ?? 0) },
}))
}
export const getTextbooksWithScope = cacheFn(getTextbooksWithScopeRaw, {
tags: ["textbooks"],
ttl: 300,
keyParts: ["textbooks", "getTextbooksWithScope"],
})
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Cross-module query interfaces — read-only access for other modules // Cross-module query interfaces — read-only access for other modules
@@ -591,7 +624,7 @@ export type KnowledgePointOption = {
* 获取所有知识点选项(含章节和教材信息)。 * 获取所有知识点选项(含章节和教材信息)。
* 供 questions 模块跨模块调用使用,避免直接查询 textbooks/chapters/knowledgePoints 表。 * 供 questions 模块跨模块调用使用,避免直接查询 textbooks/chapters/knowledgePoints 表。
*/ */
export const getKnowledgePointOptions = cache(async (): Promise<KnowledgePointOption[]> => { export const getKnowledgePointOptionsRaw = async (): Promise<KnowledgePointOption[]> => {
const rows = await db const rows = await db
.select({ .select({
id: knowledgePoints.id, id: knowledgePoints.id,
@@ -624,6 +657,11 @@ export const getKnowledgePointOptions = cache(async (): Promise<KnowledgePointOp
subject: row.subject ?? null, subject: row.subject ?? null,
grade: row.grade ?? null, grade: row.grade ?? null,
})) }))
}
export const getKnowledgePointOptions = cacheFn(getKnowledgePointOptionsRaw, {
tags: ["textbooks"],
ttl: 300,
keyParts: ["textbooks", "getKnowledgePointOptions"],
}) })
// ===== Prerequisite CRUD ===== // ===== Prerequisite CRUD =====

View File

@@ -1,9 +1,9 @@
import "server-only" import "server-only"
import { cache } from "react"
import { and, count, desc, eq, gte, ilike, inArray, or } from "drizzle-orm" import { and, count, desc, eq, gte, ilike, inArray, or } from "drizzle-orm"
import { getAuthContext } from "@/shared/lib/auth-guard" import { getAuthContext } from "@/shared/lib/auth-guard"
import { cacheFn } from "@/shared/lib/cache"
import { db } from "@/shared/db" import { db } from "@/shared/db"
import { loginLogs, roles, users, usersToRoles } from "@/shared/db/schema" import { loginLogs, roles, users, usersToRoles } from "@/shared/db/schema"
import { resolvePrimaryRole } from "@/shared/lib/role-utils" import { resolvePrimaryRole } from "@/shared/lib/role-utils"
@@ -23,7 +23,7 @@ export type UserProfile = {
updatedAt: Date updatedAt: Date
} }
export const getUserProfile = cache(async (userId: string): Promise<UserProfile | null> => { export const getUserProfileRaw = async (userId: string): Promise<UserProfile | null> => {
const user = await db.query.users.findFirst({ const user = await db.query.users.findFirst({
where: eq(users.id, userId), where: eq(users.id, userId),
}) })
@@ -51,6 +51,11 @@ export const getUserProfile = cache(async (userId: string): Promise<UserProfile
createdAt: user.createdAt, createdAt: user.createdAt,
updatedAt: user.updatedAt, updatedAt: user.updatedAt,
} }
}
export const getUserProfile = cacheFn(getUserProfileRaw, {
tags: ["users"],
ttl: 300,
keyParts: ["users", "getUserProfile"],
}) })
/** Input for updating a user's profile (self-service) */ /** Input for updating a user's profile (self-service) */
@@ -121,7 +126,7 @@ export type UsersDashboardStats = {
}> }>
} }
export const getUsersDashboardStats = cache(async (): Promise<UsersDashboardStats> => { export const getUsersDashboardStatsRaw = async (): Promise<UsersDashboardStats> => {
// audit-P1-9原查询 sessions 表JWT 策略下无数据)。 // audit-P1-9原查询 sessions 表JWT 策略下无数据)。
// 改为查询最近 24 小时内 signin 成功事件数作为"活跃会话"近似值。 // 改为查询最近 24 小时内 signin 成功事件数作为"活跃会话"近似值。
const oneDayAgo = new Date(Date.now() - 24 * 60 * 60 * 1000) const oneDayAgo = new Date(Date.now() - 24 * 60 * 60 * 1000)
@@ -198,6 +203,11 @@ export const getUsersDashboardStats = cache(async (): Promise<UsersDashboardStat
userRoleCounts, userRoleCounts,
recentUsers, recentUsers,
} }
}
export const getUsersDashboardStats = cacheFn(getUsersDashboardStatsRaw, {
tags: ["users"],
ttl: 300,
keyParts: ["users", "getUsersDashboardStats"],
}) })
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -217,29 +227,34 @@ export type TeacherOption = {
} }
/** Returns the user row if the user has the specified role, otherwise null. */ /** Returns the user row if the user has the specified role, otherwise null. */
export const getUserWithRole = cache( export const getUserWithRoleRaw = async (
async (userId: string, roleName: string): Promise<{ id: string; name: string | null; email: string } | null> => { userId: string, roleName: string
const id = userId.trim() ): Promise<{ id: string; name: string | null; email: string } | null> => {
if (!id) return null const id = userId.trim()
if (!id) return null
const [row] = await db const [row] = await db
.select({ id: users.id, name: users.name, email: users.email }) .select({ id: users.id, name: users.name, email: users.email })
.from(users) .from(users)
.innerJoin(usersToRoles, eq(usersToRoles.userId, users.id)) .innerJoin(usersToRoles, eq(usersToRoles.userId, users.id))
.innerJoin(roles, eq(usersToRoles.roleId, roles.id)) .innerJoin(roles, eq(usersToRoles.roleId, roles.id))
.where(and(eq(users.id, id), eq(roles.name, roleName))) .where(and(eq(users.id, id), eq(roles.name, roleName)))
.limit(1) .limit(1)
return row ?? null return row ?? null
} }
) export const getUserWithRole = cacheFn(getUserWithRoleRaw, {
tags: ["users"],
ttl: 300,
keyParts: ["users", "getUserWithRole"],
})
/** /**
* Returns the current authenticated student user (id + name) by reading the * Returns the current authenticated student user (id + name) by reading the
* auth context and verifying the "student" role via JOIN users + usersToRoles + roles. * auth context and verifying the "student" role via JOIN users + usersToRoles + roles.
* Returns null if not authenticated or the user does not have the student role. * Returns null if not authenticated or the user does not have the student role.
*/ */
export const getCurrentStudentUser = cache(async (): Promise<{ id: string; name: string; gradeId: string | null } | null> => { export const getCurrentStudentUserRaw = async (): Promise<{ id: string; name: string; gradeId: string | null } | null> => {
let ctx let ctx
try { try {
ctx = await getAuthContext() ctx = await getAuthContext()
@@ -260,44 +275,59 @@ export const getCurrentStudentUser = cache(async (): Promise<{ id: string; name:
.limit(1) .limit(1)
return { id: student.id, name: student.name || "Student", gradeId: userRow?.gradeId ?? null } return { id: student.id, name: student.name || "Student", gradeId: userRow?.gradeId ?? null }
}
export const getCurrentStudentUser = cacheFn(getCurrentStudentUserRaw, {
tags: ["users"],
ttl: 300,
keyParts: ["users", "getCurrentStudentUser"],
}) })
/** Returns a map of userId -> { name, email } for the given user ids. */ /** Returns a map of userId -> { name, email } for the given user ids. */
export const getUserNamesByIds = cache( export const getUserNamesByIdsRaw = async (
async (ids: string[]): Promise<Map<string, UserNameOption>> => { ids: string[]
const uniqueIds = Array.from(new Set(ids.filter((v): v is string => typeof v === "string" && v.length > 0))) ): Promise<Map<string, UserNameOption>> => {
const result = new Map<string, UserNameOption>() const uniqueIds = Array.from(new Set(ids.filter((v): v is string => typeof v === "string" && v.length > 0)))
if (uniqueIds.length === 0) return result const result = new Map<string, UserNameOption>()
if (uniqueIds.length === 0) return result
const rows = await db const rows = await db
.select({ id: users.id, name: users.name, email: users.email }) .select({ id: users.id, name: users.name, email: users.email })
.from(users) .from(users)
.where(inArray(users.id, uniqueIds)) .where(inArray(users.id, uniqueIds))
for (const r of rows) { for (const r of rows) {
result.set(r.id, { id: r.id, name: r.name, email: r.email }) result.set(r.id, { id: r.id, name: r.name, email: r.email })
}
return result
} }
) return result
}
export const getUserNamesByIds = cacheFn(getUserNamesByIdsRaw, {
tags: ["users"],
ttl: 300,
keyParts: ["users", "getUserNamesByIds"],
})
/** Returns teachers (users with the "teacher" role) for the given user ids. */ /** Returns teachers (users with the "teacher" role) for the given user ids. */
export const getTeachersByIds = cache( export const getTeachersByIdsRaw = async (
async (teacherIds: string[]): Promise<TeacherOption[]> => { teacherIds: string[]
const uniqueIds = Array.from(new Set(teacherIds.filter((v): v is string => typeof v === "string" && v.length > 0))) ): Promise<TeacherOption[]> => {
if (uniqueIds.length === 0) return [] const uniqueIds = Array.from(new Set(teacherIds.filter((v): v is string => typeof v === "string" && v.length > 0)))
if (uniqueIds.length === 0) return []
const rows = await db const rows = await db
.select({ id: users.id, name: users.name, email: users.email }) .select({ id: users.id, name: users.name, email: users.email })
.from(users) .from(users)
.innerJoin(usersToRoles, eq(usersToRoles.userId, users.id)) .innerJoin(usersToRoles, eq(usersToRoles.userId, users.id))
.innerJoin(roles, eq(usersToRoles.roleId, roles.id)) .innerJoin(roles, eq(usersToRoles.roleId, roles.id))
.where(and(eq(roles.name, "teacher"), inArray(users.id, uniqueIds))) .where(and(eq(roles.name, "teacher"), inArray(users.id, uniqueIds)))
.groupBy(users.id, users.name, users.email) .groupBy(users.id, users.name, users.email)
return rows.map((r) => ({ id: r.id, name: r.name, email: r.email })) return rows.map((r) => ({ id: r.id, name: r.name, email: r.email }))
} }
) export const getTeachersByIds = cacheFn(getTeachersByIdsRaw, {
tags: ["users"],
ttl: 300,
keyParts: ["users", "getTeachersByIds"],
})
export type UserBasicInfo = { export type UserBasicInfo = {
id: string id: string
@@ -308,46 +338,75 @@ export type UserBasicInfo = {
} }
/** Returns basic user info (id, name, email, image, gradeId) for a single user. */ /** Returns basic user info (id, name, email, image, gradeId) for a single user. */
export const getUserBasicInfo = cache( export const getUserBasicInfoRaw = async (
async (userId: string): Promise<UserBasicInfo | null> => { userId: string
const id = userId.trim() ): Promise<UserBasicInfo | null> => {
if (!id) return null const id = userId.trim()
if (!id) return null
const [row] = await db const [row] = await db
.select({ .select({
id: users.id, id: users.id,
name: users.name, name: users.name,
email: users.email, email: users.email,
image: users.image, image: users.image,
gradeId: users.gradeId, gradeId: users.gradeId,
}) })
.from(users) .from(users)
.where(eq(users.id, id)) .where(eq(users.id, id))
.limit(1) .limit(1)
return row ?? null return row ?? null
} }
) export const getUserBasicInfo = cacheFn(getUserBasicInfoRaw, {
tags: ["users"],
ttl: 300,
keyParts: ["users", "getUserBasicInfo"],
})
/** Returns user IDs for all users with the given gradeId. */ /** Returns user IDs for all users with the given gradeId. */
export const getUserIdsByGradeId = cache( export const getUserIdsByGradeIdRaw = async (
async (gradeId: string): Promise<string[]> => { gradeId: string
const id = gradeId.trim() ): Promise<string[]> => {
if (!id) return [] const id = gradeId.trim()
if (!id) return []
const rows = await db const rows = await db
.select({ id: users.id }) .select({ id: users.id })
.from(users) .from(users)
.where(eq(users.gradeId, id)) .where(eq(users.gradeId, id))
return rows.map((r) => r.id)
}
)
/** Returns all user IDs (used for school-wide announcement notifications). */
export const getAllUserIds = cache(async (): Promise<string[]> => {
const rows = await db.select({ id: users.id }).from(users)
return rows.map((r) => r.id) return rows.map((r) => r.id)
}
export const getUserIdsByGradeId = cacheFn(getUserIdsByGradeIdRaw, {
tags: ["users"],
ttl: 300,
keyParts: ["users", "getUserIdsByGradeId"],
})
/**
* Returns all user IDs (used for school-wide announcement notifications).
*
* P3-7: 加默认 LIMIT 1000 防止超大学校全量用户导致 OOM
* 调用方可通过 limit/offset 参数实现分页遍历(用于公告 fan-out 批量化)。
*
* @param limit 返回条数上限,默认 1000
* @param offset 偏移量,默认 0
*/
export const getAllUserIdsRaw = async (
limit: number = 1000, offset: number = 0
): Promise<string[]> => {
const rows = await db
.select({ id: users.id })
.from(users)
.limit(limit)
.offset(offset)
return rows.map((r) => r.id)
}
export const getAllUserIds = cacheFn(getAllUserIdsRaw, {
tags: ["users"],
ttl: 300,
keyParts: ["users", "getAllUserIds"],
}) })
export type AdminUserListItem = { export type AdminUserListItem = {