diff --git a/src/modules/course-plans/data-access.ts b/src/modules/course-plans/data-access.ts index 85726ab..35a2e45 100644 --- a/src/modules/course-plans/data-access.ts +++ b/src/modules/course-plans/data-access.ts @@ -1,6 +1,6 @@ import "server-only" -import { cache } from "react" +import { cacheFn } from "@/shared/lib/cache" import { createId } from "@paralleldrive/cuid2" import { and, asc, desc, eq, inArray, type SQL } from "drizzle-orm" @@ -147,8 +147,7 @@ const buildScopeCondition = ( return conditions } -export const getCoursePlans = cache( - async (params?: GetCoursePlansParams, scope?: CoursePlanQueryScope): Promise => { +export const getCoursePlansRaw = async (params?: GetCoursePlansParams, scope?: CoursePlanQueryScope): Promise => { try { const conditions: SQL[] = [...buildScopeCondition(scope)] if (params?.classId) conditions.push(eq(coursePlans.classId, params.classId)) @@ -169,10 +168,14 @@ export const getCoursePlans = cache( return [] } } -) -export const getCoursePlanById = cache( - async (id: string, scope?: CoursePlanQueryScope): Promise => { +export const getCoursePlans = cacheFn(getCoursePlansRaw, { + tags: ["course-plans"], + ttl: 300, + keyParts: ["course-plans", "getCoursePlans"], +}) + +export const getCoursePlanByIdRaw = async (id: string, scope?: CoursePlanQueryScope): Promise => { try { const conditions: SQL[] = [eq(coursePlans.id, id), ...buildScopeCondition(scope)] const [planRow] = await db @@ -200,7 +203,12 @@ export const getCoursePlanById = cache( return null } } -) + +export const getCoursePlanById = cacheFn(getCoursePlanByIdRaw, { + tags: ["course-plans"], + ttl: 300, + keyParts: ["course-plans", "getCoursePlanById"], +}) export async function createCoursePlan( data: CreateCoursePlanInput, @@ -343,6 +351,10 @@ export async function bulkUpdateItemCompleted( /** * 复制课程计划到其他班级(P2-4 批量操作)。 * 复制基本信息(不含 completedHours)和所有周计划条目。 + * + * 性能优化:用单次事务 + 批量 INSERT 替代 N+1 循环写入。 + * 原实现为 O(N*M) 次 INSERT(N=班级数,M=条目数), + * 现改为 2 次批量 INSERT(plans + items),复杂度降为 O(1) 次 INSERT。 */ export async function copyCoursePlanToClasses( sourcePlanId: string, @@ -363,44 +375,51 @@ export async function copyCoursePlanToClasses( .from(coursePlanItems) .where(eq(coursePlanItems.planId, sourcePlanId)) - const createdIds: string[] = [] + // 预先生成所有 newPlanId,便于构造 items 的批量插入 + const createdIds = targetClassIds.map(() => createId()) - for (const classId of targetClassIds) { - const newPlanId = createId() - await db.insert(coursePlans).values({ - id: newPlanId, - classId, - subjectId: source.subjectId, - teacherId: source.teacherId, - academicYearId: source.academicYearId, - semester: source.semester, - totalHours: source.totalHours, - completedHours: 0, - weeklyHours: source.weeklyHours, - startDate: source.startDate, - endDate: source.endDate, - syllabus: source.syllabus, - objectives: source.objectives, - status: "planning", - createdBy: source.createdBy, - }) + const newPlans = targetClassIds.map((classId, index) => ({ + id: createdIds[index]!, + classId, + subjectId: source.subjectId, + teacherId: source.teacherId, + academicYearId: source.academicYearId, + semester: source.semester, + totalHours: source.totalHours, + completedHours: 0, + weeklyHours: source.weeklyHours, + startDate: source.startDate, + endDate: source.endDate, + syllabus: source.syllabus, + objectives: source.objectives, + status: "planning" as const, + createdBy: source.createdBy, + })) - for (const item of sourceItems) { - const newItemId = createId() - await db.insert(coursePlanItems).values({ - id: newItemId, - planId: newPlanId, - week: item.week, - topic: item.topic, - content: item.content, - hours: item.hours, - textbookChapter: item.textbookChapter, - notes: item.notes, - }) + // 批量构造所有 items:每个 plan 复制一份 sourceItems + const newItems = targetClassIds.length > 0 && sourceItems.length > 0 + ? targetClassIds.flatMap((_, planIndex) => + sourceItems.map((item) => ({ + id: createId(), + planId: createdIds[planIndex]!, + week: item.week, + topic: item.topic, + content: item.content, + hours: item.hours, + textbookChapter: item.textbookChapter, + notes: item.notes, + })), + ) + : [] + + await db.transaction(async (tx) => { + if (newPlans.length > 0) { + await tx.insert(coursePlans).values(newPlans) } - - createdIds.push(newPlanId) - } + if (newItems.length > 0) { + await tx.insert(coursePlanItems).values(newItems) + } + }) return createdIds } @@ -413,8 +432,7 @@ export type { CoursePlan, CoursePlanItem, CoursePlanWithItems } * 关联 course_plan_items 统计条目完成情况。 * 名称解析通过 data-access 批量查询,不 JOIN 其他模块表。 */ -export const getGradeCoursePlanProgress = cache( - async (params: { gradeId: string }): Promise => { +export const getGradeCoursePlanProgressRaw = async (params: { gradeId: string }): Promise => { const { getClassesByGradeId } = await import("@/modules/classes/data-access") const classRows = await getClassesByGradeId(params.gradeId) @@ -504,4 +522,9 @@ export const getGradeCoursePlanProgress = cache( items, } } -) + +export const getGradeCoursePlanProgress = cacheFn(getGradeCoursePlanProgressRaw, { + tags: ["course-plans"], + ttl: 300, + keyParts: ["course-plans", "getGradeCoursePlanProgress"], +}) diff --git a/src/modules/dashboard/data-access.ts b/src/modules/dashboard/data-access.ts index 91dff33..1a17b3c 100644 --- a/src/modules/dashboard/data-access.ts +++ b/src/modules/dashboard/data-access.ts @@ -1,6 +1,6 @@ import "server-only" -import { cache } from "react" +import { cacheFn } from "@/shared/lib/cache" import { getClassesDashboardStats } from "@/modules/classes/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" -export const getAdminDashboardData = cache(async (scope?: DataScope): Promise => { +export const getAdminDashboardDataRaw = async (scope?: DataScope): Promise => { const [ usersStats, classesStats, @@ -48,4 +48,10 @@ export const getAdminDashboardData = cache(async (scope?: DataScope): Promise => { +export const getFileAttachmentRaw = async (id: string): Promise => { try { const [row] = await db .select() @@ -74,14 +73,18 @@ export const getFileAttachment = cache( console.error("getFileAttachment failed:", error) return null } - }, -) + } + +export const getFileAttachment = cacheFn(getFileAttachmentRaw, { + tags: ["files"], + ttl: 300, + keyParts: ["files", "getFileAttachment"], +}) /** * 按关联资源查询文件列表 */ -export const getFileAttachmentsByTarget = cache( - async (targetType: string, targetId: string): Promise => { +export const getFileAttachmentsByTargetRaw = async (targetType: string, targetId: string): Promise => { try { const rows = await db .select() @@ -99,14 +102,18 @@ export const getFileAttachmentsByTarget = cache( console.error("getFileAttachmentsByTarget failed:", error) return [] } - }, -) + } + +export const getFileAttachmentsByTarget = cacheFn(getFileAttachmentsByTargetRaw, { + tags: ["files"], + ttl: 300, + keyParts: ["files", "getFileAttachmentsByTarget"], +}) /** * 按上传者查询文件列表 */ -export const getFileAttachmentsByUploader = cache( - async (uploaderId: string): Promise => { +export const getFileAttachmentsByUploaderRaw = async (uploaderId: string): Promise => { try { const rows = await db .select() @@ -119,14 +126,18 @@ export const getFileAttachmentsByUploader = cache( console.error("getFileAttachmentsByUploader failed:", error) return [] } - }, -) + } + +export const getFileAttachmentsByUploader = cacheFn(getFileAttachmentsByUploaderRaw, { + tags: ["files"], + ttl: 300, + keyParts: ["files", "getFileAttachmentsByUploader"], +}) /** * 查询所有文件(用于管理员文件管理页面) */ -export const getAllFileAttachments = cache( - async (limit = 100): Promise => { +export const getAllFileAttachmentsRaw = async (limit = 100): Promise => { try { const rows = await db .select() @@ -139,8 +150,13 @@ export const getAllFileAttachments = cache( console.error("getAllFileAttachments failed:", error) return [] } - }, -) + } + +export const getAllFileAttachments = cacheFn(getAllFileAttachmentsRaw, { + tags: ["files"], + ttl: 300, + keyParts: ["files", "getAllFileAttachments"], +}) /** * 删除文件附件记录 @@ -193,8 +209,7 @@ export async function deleteFileAttachments(ids: string[]): Promise => { +export const getFileAttachmentsWithFiltersRaw = async (params: FileAttachmentQueryParams): Promise => { try { const { mimeType, search, limit = 100, offset = 0 } = params @@ -230,14 +245,18 @@ export const getFileAttachmentsWithFilters = cache( console.error("getFileAttachmentsWithFilters failed:", error) return [] } - }, -) + } + +export const getFileAttachmentsWithFilters = cacheFn(getFileAttachmentsWithFiltersRaw, { + tags: ["files"], + ttl: 300, + keyParts: ["files", "getFileAttachmentsWithFilters"], +}) /** * 获取文件统计信息(总数、总大小、按类型分组) */ -export const getFileStats = cache( - async (): Promise => { +export const getFileStatsRaw = async (): Promise => { try { const rows = await db .select({ @@ -262,14 +281,18 @@ export const getFileStats = cache( console.error("getFileStats failed:", error) return { totalCount: 0, totalSize: 0, byType: [] } } - }, -) + } + +export const getFileStats = cacheFn(getFileStatsRaw, { + tags: ["files"], + ttl: 300, + keyParts: ["files", "getFileStats"], +}) /** * 按 URL 查询文件附件(用于头像等场景的旧文件清理) */ -export const getFileByUrl = cache( - async (url: string): Promise => { +export const getFileByUrlRaw = async (url: string): Promise => { try { const [row] = await db .select() @@ -282,14 +305,18 @@ export const getFileByUrl = cache( console.error("getFileByUrl failed:", error) return null } - }, -) + } + +export const getFileByUrl = cacheFn(getFileByUrlRaw, { + tags: ["files"], + ttl: 300, + keyParts: ["files", "getFileByUrl"], +}) /** * 按 ID 列表批量查询文件(用于批量删除前获取磁盘路径) */ -export const getFileAttachmentsByIds = cache( - async (ids: string[]): Promise => { +export const getFileAttachmentsByIdsRaw = async (ids: string[]): Promise => { if (ids.length === 0) return [] try { const rows = await db @@ -301,5 +328,10 @@ export const getFileAttachmentsByIds = cache( console.error("getFileAttachmentsByIds failed:", error) return [] } - }, -) + } + +export const getFileAttachmentsByIds = cacheFn(getFileAttachmentsByIdsRaw, { + tags: ["files"], + ttl: 300, + keyParts: ["files", "getFileAttachmentsByIds"], +}) diff --git a/src/modules/parent/data-access.ts b/src/modules/parent/data-access.ts index 48c0ce6..ec26a1c 100644 --- a/src/modules/parent/data-access.ts +++ b/src/modules/parent/data-access.ts @@ -1,6 +1,6 @@ import "server-only" -import { cache } from "react" +import { cacheFn } from "@/shared/lib/cache" import { and, asc, eq, inArray } from "drizzle-orm" import { db } from "@/shared/db" @@ -39,7 +39,7 @@ const toWeekday = (d: Date): Weekday => { return isWeekday(normalized) ? normalized : 1 } -export const getChildren = cache(async (parentId: string): Promise => { +export const getChildrenRaw = async (parentId: string): Promise => { const id = parentId.trim() if (!id) return [] @@ -62,14 +62,19 @@ export const getChildren = cache(async (parentId: string): Promise => { +export const verifyParentChildRelationRaw = async (studentId: string, parentId: string): Promise => { const [row] = await db .select({ relation: parentStudentRelations.relation }) .from(parentStudentRelations) @@ -81,11 +86,15 @@ export const verifyParentChildRelation = cache( ) .limit(1) return row?.relation ?? null - }, -) + } -export const getChildBasicInfo = cache( - async ( +export const verifyParentChildRelation = cacheFn(verifyParentChildRelationRaw, { + tags: ["parent"], + ttl: 300, + keyParts: ["parent", "verifyParentChildRelation"], +}) + +export const getChildBasicInfoRaw = async ( studentId: string, relation: string | null = null, ): Promise => { @@ -109,8 +118,13 @@ export const getChildBasicInfo = cache( classId: activeClass?.classId ?? null, relation, } - }, -) + } + +export const getChildBasicInfo = cacheFn(getChildBasicInfoRaw, { + tags: ["parent"], + ttl: 300, + keyParts: ["parent", "getChildBasicInfo"], +}) const buildHomeworkSummary = ( assignments: Awaited>, @@ -197,8 +211,7 @@ const buildWeeklySchedule = ( ) } -export const getChildDashboardData = cache( - async (studentId: string, relation: string | null = null): Promise => { +export const getChildDashboardDataRaw = async (studentId: string, relation: string | null = null): Promise => { const basicInfo = await getChildBasicInfo(studentId, relation) if (!basicInfo) return null @@ -221,11 +234,15 @@ export const getChildDashboardData = cache( gradeSummary, examResults, } - }, -) + } -export const getParentDashboardData = cache( - async (parentId: string): Promise => { +export const getChildDashboardData = cacheFn(getChildDashboardDataRaw, { + tags: ["parent"], + ttl: 300, + keyParts: ["parent", "getChildDashboardData"], +}) + +export const getParentDashboardDataRaw = async (parentId: string): Promise => { const id = parentId.trim() if (!id) return { parentName: null, children: [] } @@ -248,15 +265,19 @@ export const getParentDashboardData = cache( parentName, children: children.filter((c): c is ChildDashboardData => c !== null), } - }, -) + } + +export const getParentDashboardData = cacheFn(getParentDashboardDataRaw, { + tags: ["parent"], + ttl: 300, + keyParts: ["parent", "getParentDashboardData"], +}) /** * 获取家长所有子女的轻量列表(id + name),用于详情页头部多子女切换器。 * 一次批量查询,避免 N+1。 */ -export const getChildNameList = cache( - async (parentId: string): Promise> => { +export const getChildNameListRaw = async (parentId: string): Promise> => { const relations = await getChildren(parentId) if (relations.length === 0) return [] @@ -265,32 +286,40 @@ export const getChildNameList = cache( id: r.studentId, name: nameMap.get(r.studentId)?.name ?? null, })) - }, -) + } + +export const getChildNameList = cacheFn(getChildNameListRaw, { + tags: ["parent"], + ttl: 300, + keyParts: ["parent", "getChildNameList"], +}) /** * v4-P1-5: 批量查询多个学生的家长 userId 列表。 * 用于通知场景:报告/成绩发布时同步通知所有相关家长。 * 返回去重后的 parentId 数组。 */ -export const getParentIdsByStudentIds = cache( - async (studentIds: string[]): Promise => { +export const getParentIdsByStudentIdsRaw = async (studentIds: string[]): Promise => { if (studentIds.length === 0) return [] const rows = await db .select({ parentId: parentStudentRelations.parentId }) .from(parentStudentRelations) .where(inArray(parentStudentRelations.studentId, studentIds)) return Array.from(new Set(rows.map((r) => r.parentId))) - }, -) + } + +export const getParentIdsByStudentIds = cacheFn(getParentIdsByStudentIdsRaw, { + tags: ["parent"], + ttl: 300, + keyParts: ["parent", "getParentIdsByStudentIds"], +}) /** * L-2: 批量查询学生→家长映射(保留关联关系)。 * 用于考勤通知等需要按家长-学生分组发送的场景,避免 N+1 查询。 * 返回数组形如 [{ parentId, studentId }],同一家长多个学生会出现多条。 */ -export const getParentStudentMapByStudentIds = cache( - async (studentIds: string[]): Promise> => { +export const getParentStudentMapByStudentIdsRaw = async (studentIds: string[]): Promise> => { if (studentIds.length === 0) return [] const rows = await db .select({ @@ -300,5 +329,10 @@ export const getParentStudentMapByStudentIds = cache( .from(parentStudentRelations) .where(inArray(parentStudentRelations.studentId, studentIds)) return rows.map((r) => ({ parentId: r.parentId, studentId: r.studentId })) - }, -) + } + +export const getParentStudentMapByStudentIds = cacheFn(getParentStudentMapByStudentIdsRaw, { + tags: ["parent"], + ttl: 300, + keyParts: ["parent", "getParentStudentMapByStudentIds"], +}) diff --git a/src/modules/proctoring/data-access.ts b/src/modules/proctoring/data-access.ts index a706633..8ea4a0f 100644 --- a/src/modules/proctoring/data-access.ts +++ b/src/modules/proctoring/data-access.ts @@ -3,7 +3,7 @@ import "server-only" import { db } from "@/shared/db" import { examProctoringEvents } from "@/shared/db/schema" 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 { @@ -110,8 +110,7 @@ export async function recordProctoringEvent( /** * 查询某场考试的监考事件(含学生姓名、考试标题) */ -export const getProctoringEvents = cache( - async ( +export const getProctoringEventsRaw = async ( examId: string, filters?: GetProctoringEventsFilters, ): Promise => { @@ -169,14 +168,18 @@ export const getProctoringEvents = cache( studentName: userMap.get(row.event.studentId)?.name ?? "未知学生", examTitle: resolvedExamTitle, })) - }, -) + } + +export const getProctoringEvents = cacheFn(getProctoringEventsRaw, { + tags: ["proctoring"], + ttl: 300, + keyParts: ["proctoring", "getProctoringEvents"], +}) /** * 查询某次提交的监考事件 */ -export const getProctoringEventsBySubmission = cache( - async (submissionId: string): Promise => { +export const getProctoringEventsBySubmissionRaw = async (submissionId: string): Promise => { const rows = await db.query.examProctoringEvents.findMany({ where: eq(examProctoringEvents.submissionId, submissionId), orderBy: [desc(examProctoringEvents.occurredAt)], @@ -192,14 +195,18 @@ export const getProctoringEventsBySubmission = cache( occurredAt: row.occurredAt.toISOString(), createdAt: row.createdAt.toISOString(), })) - }, -) + } + +export const getProctoringEventsBySubmission = cacheFn(getProctoringEventsBySubmissionRaw, { + tags: ["proctoring"], + ttl: 300, + keyParts: ["proctoring", "getProctoringEventsBySubmission"], +}) /** * 获取考试监考摘要 */ -export const getExamProctoringSummary = cache( - async (examId: string): Promise => { +export const getExamProctoringSummaryRaw = async (examId: string): Promise => { // 考试信息与提交记录相互独立,并行拉取 const [exam, submissions] = await Promise.all([ getExamForProctoringCrossModule(examId), @@ -263,14 +270,18 @@ export const getExamProctoringSummary = cache( abnormalStudents, eventsByType, } - }, -) + } + +export const getExamProctoringSummary = cacheFn(getExamProctoringSummaryRaw, { + tags: ["proctoring"], + ttl: 300, + keyParts: ["proctoring", "getExamProctoringSummary"], +}) /** * 获取所有学生监考状态 */ -export const getStudentProctoringStatuses = cache( - async (examId: string): Promise => { +export const getStudentProctoringStatusesRaw = async (examId: string): Promise => { // 1. 拉取所有提交记录 const submissions = await getExamSubmissionsForExam(examId) @@ -339,14 +350,18 @@ export const getStudentProctoringStatuses = cache( eventsByType: stats?.byType ?? emptyEventsByType(), } }) - }, -) + } + +export const getStudentProctoringStatuses = cacheFn(getStudentProctoringStatusesRaw, { + tags: ["proctoring"], + ttl: 300, + keyParts: ["proctoring", "getStudentProctoringStatuses"], +}) /** * 获取考试信息(含监考模式相关字段) */ -export const getExamForProctoring = cache( - async (examId: string): Promise<{ +export const getExamForProctoringRaw = async (examId: string): Promise<{ id: string title: string examMode: ExamMode @@ -369,14 +384,18 @@ export const getExamForProctoring = cache( antiCheatEnabled: exam.antiCheatEnabled ?? false, }, } - }, -) + } + +export const getExamForProctoring = cacheFn(getExamForProctoringRaw, { + tags: ["proctoring"], + ttl: 300, + keyParts: ["proctoring", "getExamForProctoring"], +}) /** * 获取最近 N 条监考事件(用于面板实时展示) */ -export const getRecentProctoringEvents = cache( - async (examId: string, limit = 20): Promise => { +export const getRecentProctoringEventsRaw = async (examId: string, limit = 20): Promise => { const rows = await db .select({ event: examProctoringEvents, @@ -407,7 +426,12 @@ export const getRecentProctoringEvents = cache( studentName: userMap.get(row.event.studentId)?.name ?? "未知学生", examTitle: resolvedExamTitle, })) - }, -) + } + +export const getRecentProctoringEvents = cacheFn(getRecentProctoringEventsRaw, { + tags: ["proctoring"], + ttl: 300, + keyParts: ["proctoring", "getRecentProctoringEvents"], +}) export { ALL_EVENT_TYPES } diff --git a/src/modules/questions/data-access.ts b/src/modules/questions/data-access.ts index e439b4f..1cb6710 100644 --- a/src/modules/questions/data-access.ts +++ b/src/modules/questions/data-access.ts @@ -3,7 +3,7 @@ import 'server-only'; import { db } from "@/shared/db"; import { knowledgePoints, questions, questionsToKnowledgePoints } from "@/shared/db/schema"; 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 { getChaptersByTextbookId as getChaptersByTextbookIdFromTextbooks, @@ -36,7 +36,7 @@ export type GetQuestionsParams = { difficulty?: number; }; -export const getQuestions = cache(async ({ +export const getQuestionsRaw = async ({ q, page = 1, pageSize = 50, @@ -131,7 +131,7 @@ export const getQuestions = cache(async ({ .select({ count: count() }) .from(questions) .where(whereClause); - + const total = Number(totalResult?.count ?? 0); const rows = await db.query.questions.findMany({ @@ -190,15 +190,25 @@ export const getQuestions = cache(async ({ totalPages: Math.ceil(total / safePageSize), }, }; -}); +}; +export const getQuestions = cacheFn(getQuestionsRaw, { + tags: ["questions"], + ttl: 300, + keyParts: ["questions", "getQuestions"], +}) export type QuestionsDashboardStats = { questionCount: number } -export const getQuestionsDashboardStats = cache(async (): Promise => { +export const getQuestionsDashboardStatsRaw = async (): Promise => { const [row] = await db.select({ value: count() }).from(questions) return { questionCount: Number(row?.value ?? 0) } +} +export const getQuestionsDashboardStats = cacheFn(getQuestionsDashboardStatsRaw, { + tags: ["questions"], + ttl: 300, + keyParts: ["questions", "getQuestionsDashboardStats"], }) async function insertQuestionWithRelations( @@ -436,34 +446,39 @@ export type QuestionKnowledgePoint = { } /** Returns knowledge points associated with the given question ids. */ -export const getKnowledgePointsForQuestions = cache( - async (questionIds: string[]): Promise> => { - const result = new Map() - const uniqueIds = Array.from(new Set(questionIds.filter((v): v is string => typeof v === "string" && v.length > 0))) - if (uniqueIds.length === 0) return result +export const getKnowledgePointsForQuestionsRaw = async ( + questionIds: string[] +): Promise> => { + const result = new Map() + 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 - .select({ - questionId: questionsToKnowledgePoints.questionId, - knowledgePointId: knowledgePoints.id, - knowledgePointName: knowledgePoints.name, - }) - .from(questionsToKnowledgePoints) - .innerJoin(knowledgePoints, eq(knowledgePoints.id, questionsToKnowledgePoints.knowledgePointId)) - .where(inArray(questionsToKnowledgePoints.questionId, uniqueIds)) + const rows = await db + .select({ + questionId: questionsToKnowledgePoints.questionId, + knowledgePointId: knowledgePoints.id, + knowledgePointName: knowledgePoints.name, + }) + .from(questionsToKnowledgePoints) + .innerJoin(knowledgePoints, eq(knowledgePoints.id, questionsToKnowledgePoints.knowledgePointId)) + .where(inArray(questionsToKnowledgePoints.questionId, uniqueIds)) - for (const r of rows) { - const list = result.get(r.questionId) ?? [] - list.push({ - questionId: r.questionId, - knowledgePointId: r.knowledgePointId, - knowledgePointName: r.knowledgePointName, - }) - result.set(r.questionId, list) - } - return result + for (const r of rows) { + const list = result.get(r.questionId) ?? [] + list.push({ + questionId: r.questionId, + knowledgePointId: r.knowledgePointId, + knowledgePointName: r.knowledgePointName, + }) + result.set(r.questionId, list) } -) + return result +} +export const getKnowledgePointsForQuestions = cacheFn(getKnowledgePointsForQuestionsRaw, { + tags: ["questions"], + ttl: 300, + keyParts: ["questions", "getKnowledgePointsForQuestions"], +}) /** * 跨模块接口:获取题目内容与类型(供 error-book 模块提取正确答案使用)。 @@ -567,7 +582,8 @@ export async function exportQuestions( const rows = await db.query.questions.findMany({ where: whereClause, - limit: 1000, + // P3-8: LIMIT 从 1000 调低至 100,避免单次导出拉取过多记录 + limit: 100, orderBy: [desc(questions.createdAt)], with: { knowledgePoints: { diff --git a/src/modules/rbac/data-access-assignments.ts b/src/modules/rbac/data-access-assignments.ts index 20b131d..06059af 100644 --- a/src/modules/rbac/data-access-assignments.ts +++ b/src/modules/rbac/data-access-assignments.ts @@ -1,8 +1,8 @@ import "server-only" -import { cache } from "react" import { and, count, desc, eq, ilike, inArray, or } from "drizzle-orm" +import { cacheFn } from "@/shared/lib/cache" import { db } from "@/shared/db" 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. */ -export const getUserRoleNames = cache(async (userId: string): Promise => { +export const getUserRoleNamesRaw = async (userId: string): Promise => { const rows = await db .select({ name: roles.name }) .from(usersToRoles) .innerJoin(roles, eq(usersToRoles.roleId, roles.id)) .where(eq(usersToRoles.userId, userId)) return rows.map((r) => r.name) +} +export const getUserRoleNames = cacheFn(getUserRoleNamesRaw, { + tags: ["rbac"], + ttl: 300, + keyParts: ["rbac", "getUserRoleNames"], }) /** diff --git a/src/modules/rbac/data-access-permissions.ts b/src/modules/rbac/data-access-permissions.ts index d4addbc..fb58ccb 100644 --- a/src/modules/rbac/data-access-permissions.ts +++ b/src/modules/rbac/data-access-permissions.ts @@ -1,8 +1,8 @@ import "server-only" -import { cache } from "react" import { and, eq, inArray } from "drizzle-orm" +import { cacheFn } from "@/shared/lib/cache" import { db } from "@/shared/db" import { rolePermissions, roles } from "@/shared/db/schema" 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 * counted (disabled roles do not contribute permissions at login time). */ -export const getPermissionRoleCounts = cache(async (): Promise> => { +export const getPermissionRoleCountsRaw = async (): Promise> => { const allPermissionValues = getAllPermissionMetas().map((m) => m.value) if (allPermissionValues.length === 0) return {} @@ -33,4 +33,9 @@ export const getPermissionRoleCounts = cache(async (): Promise => { +export const getRolesRaw = async (): Promise => { const roleRows = await db.select().from(roles).orderBy(roles.name) if (roleRows.length === 0) return [] @@ -63,12 +63,17 @@ export const getRoles = cache(async (): Promise => { permissionCount: permMap.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. */ -export const getRoleById = cache(async (id: string): Promise => { +export const getRoleByIdRaw = async (id: string): Promise => { const roleRow = await db.query.roles.findFirst({ where: eq(roles.id, id) }) if (!roleRow) return null @@ -90,14 +95,24 @@ export const getRoleById = cache(async (id: string): Promise .filter((p): p is Permission => p !== null), 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. */ -export const getRoleByName = cache(async (name: string): Promise => { +export const getRoleByNameRaw = async (name: string): Promise => { const row = await db.query.roles.findFirst({ where: eq(roles.name, name) }) 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 => { +export const getRolePermissionsRaw = async (roleId: string): Promise => { const rows = await db .select({ permission: rolePermissions.permission }) .from(rolePermissions) @@ -196,6 +211,11 @@ export const getRolePermissions = cache(async (roleId: string): Promise (isPermission(r.permission) ? r.permission : null)) .filter((p): p is Permission => p !== null) +} +export const getRolePermissions = cacheFn(getRolePermissionsRaw, { + tags: ["rbac"], + ttl: 300, + keyParts: ["rbac", "getRolePermissions"], }) /** diff --git a/src/modules/school/data-access.ts b/src/modules/school/data-access.ts index 9bf7f8a..aa40033 100644 --- a/src/modules/school/data-access.ts +++ b/src/modules/school/data-access.ts @@ -1,8 +1,8 @@ import "server-only" -import { cache } from "react" import { and, asc, desc, eq, inArray, or, sql } from "drizzle-orm" +import { cacheFn } from "@/shared/lib/cache" import { db } from "@/shared/db" import { academicYears, departments, grades, roles, schools, subjects, users, usersToRoles } from "@/shared/db/schema" import type { @@ -24,7 +24,7 @@ import type { const toIso = (d: Date): string => d.toISOString() -export const getDepartments = cache(async (): Promise => { +export const getDepartmentsRaw = async (): Promise => { try { const rows = await db.select().from(departments).orderBy(asc(departments.name)) return rows.map((r) => ({ @@ -38,9 +38,14 @@ export const getDepartments = cache(async (): Promise => { console.error("getDepartments failed:", error) return [] } +} +export const getDepartments = cacheFn(getDepartmentsRaw, { + tags: ["school"], + ttl: 300, + keyParts: ["school", "getDepartments"], }) -export const getAcademicYears = cache(async (): Promise => { +export const getAcademicYearsRaw = async (): Promise => { try { const rows = await db.select().from(academicYears).orderBy(asc(academicYears.startDate)) return rows.map((r) => ({ @@ -56,9 +61,14 @@ export const getAcademicYears = cache(async (): Promise console.error("getAcademicYears failed:", error) return [] } +} +export const getAcademicYears = cacheFn(getAcademicYearsRaw, { + tags: ["school"], + ttl: 300, + keyParts: ["school", "getAcademicYears"], }) -export const getSchools = cache(async (): Promise => { +export const getSchoolsRaw = async (): Promise => { try { const rows = await db.select().from(schools).orderBy(asc(schools.name)) return rows.map((r) => ({ @@ -72,9 +82,14 @@ export const getSchools = cache(async (): Promise => { console.error("getSchools failed:", error) return [] } +} +export const getSchools = cacheFn(getSchoolsRaw, { + tags: ["school"], + ttl: 300, + keyParts: ["school", "getSchools"], }) -export const getGrades = cache(async (): Promise => { +export const getGradesRaw = async (): Promise => { try { const rows = await db .select({ @@ -126,9 +141,14 @@ export const getGrades = cache(async (): Promise => { console.error("getGrades failed:", error) return [] } +} +export const getGrades = cacheFn(getGradesRaw, { + tags: ["school"], + ttl: 300, + keyParts: ["school", "getGrades"], }) -export const getStaffOptions = cache(async (): Promise => { +export const getStaffOptionsRaw = async (): Promise => { try { const rows = await db .select({ id: users.id, name: users.name, email: users.email }) @@ -148,9 +168,14 @@ export const getStaffOptions = cache(async (): Promise => { console.error("getStaffOptions failed:", error) return [] } +} +export const getStaffOptions = cacheFn(getStaffOptionsRaw, { + tags: ["school"], + ttl: 300, + keyParts: ["school", "getStaffOptions"], }) -export const getGradesForStaff = cache(async (staffId: string): Promise => { +export const getGradesForStaffRaw = async (staffId: string): Promise => { const id = staffId.trim() if (!id) return [] @@ -204,6 +229,11 @@ export const getGradesForStaff = cache(async (staffId: string): Promise => { +export const getSchoolsForUserRaw = async (userId: string): Promise => { const id = userId.trim() if (!id) return [] @@ -277,6 +307,11 @@ export const getSchoolsForUser = cache(async (userId: string): Promise => { +export const getGradesForUserRaw = async (userId: string): Promise => { const id = userId.trim() if (!id) return [] @@ -370,6 +405,11 @@ export const getGradesForUser = cache(async (userId: string): Promise => { +export const getSubjectOptionsRaw = async (): Promise => { try { const rows = await db .select({ @@ -533,9 +573,14 @@ export const getSubjectOptions = cache(async (): Promise => { console.error("getSubjectOptions failed:", error) return [] } +} +export const getSubjectOptions = cacheFn(getSubjectOptionsRaw, { + tags: ["school"], + ttl: 300, + keyParts: ["school", "getSubjectOptions"], }) -export const getGradeOptions = cache(async (): Promise => { +export const getGradeOptionsRaw = async (): Promise => { try { const rows = await db .select({ @@ -560,59 +605,79 @@ export const getGradeOptions = cache(async (): Promise => { console.error("getGradeOptions failed:", error) return [] } +} +export const getGradeOptions = cacheFn(getGradeOptionsRaw, { + tags: ["school"], + ttl: 300, + keyParts: ["school", "getGradeOptions"], }) /** * 按 ID 获取单个年级名称。 * 供跨模块调用使用,避免为单个年级拉取全量年级选项。 */ -export const getGradeNameById = cache( - async (gradeId: string): Promise => { - const id = gradeId.trim() - if (!id) return null - const [row] = await db - .select({ name: grades.name }) - .from(grades) - .where(eq(grades.id, id)) - .limit(1) - return row?.name ?? null - }, -) +export const getGradeNameByIdRaw = async ( + gradeId: string +): Promise => { + const id = gradeId.trim() + if (!id) return null + const [row] = await db + .select({ name: grades.name }) + .from(grades) + .where(eq(grades.id, id)) + .limit(1) + return row?.name ?? null +} +export const getGradeNameById = cacheFn(getGradeNameByIdRaw, { + tags: ["school"], + ttl: 300, + keyParts: ["school", "getGradeNameById"], +}) /** * 按 ID 获取单个科目名称。 * 供跨模块调用使用,避免为单个科目拉取全量科目选项或直接查询 subjects 表。 */ -export const getSubjectNameById = cache( - async (subjectId: string): Promise => { - const id = subjectId.trim() - if (!id) return null - const [row] = await db - .select({ name: subjects.name }) - .from(subjects) - .where(eq(subjects.id, id)) - .limit(1) - return row?.name ?? null - }, -) +export const getSubjectNameByIdRaw = async ( + subjectId: string +): Promise => { + const id = subjectId.trim() + if (!id) return null + const [row] = await db + .select({ name: subjects.name }) + .from(subjects) + .where(eq(subjects.id, id)) + .limit(1) + return row?.name ?? null +} +export const getSubjectNameById = cacheFn(getSubjectNameByIdRaw, { + tags: ["school"], + ttl: 300, + keyParts: ["school", "getSubjectNameById"], +}) /** * 批量获取科目名称映射(subjectId -> name)。 * 供跨模块调用使用,避免直接查询 subjects 表。 */ -export const getSubjectNameMapByIds = cache( - async (subjectIds: string[]): Promise> => { - const ids = subjectIds.filter((id) => id.trim().length > 0) - if (ids.length === 0) return new Map() - const rows = await db - .select({ id: subjects.id, name: subjects.name }) - .from(subjects) - .where(inArray(subjects.id, ids)) - const map = new Map() - for (const r of rows) map.set(r.id, r.name) - return map - }, -) +export const getSubjectNameMapByIdsRaw = async ( + subjectIds: string[] +): Promise> => { + const ids = subjectIds.filter((id) => id.trim().length > 0) + if (ids.length === 0) return new Map() + const rows = await db + .select({ id: subjects.id, name: subjects.name }) + .from(subjects) + .where(inArray(subjects.id, ids)) + const map = new 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 @@ -622,7 +687,7 @@ export const getSubjectNameMapByIds = cache( * 校验用户是否为指定年级的年级主任。 * 供 classes 模块跨模块调用使用,避免直接查询 grades 表。 */ -export const isGradeHead = cache(async ( +export const isGradeHeadRaw = async ( gradeId: string, userId: string ): Promise => { @@ -636,13 +701,18 @@ export const isGradeHead = cache(async ( .where(and(eq(grades.id, trimmedGradeId), eq(grades.gradeHeadId, trimmedUserId))) .limit(1) return Boolean(row) +} +export const isGradeHead = cacheFn(isGradeHeadRaw, { + tags: ["school"], + ttl: 300, + keyParts: ["school", "isGradeHead"], }) /** * 校验用户是否为指定年级的年级主任或教学主任。 * 供 classes 模块跨模块调用使用,避免直接查询 grades 表。 */ -export const isGradeManager = cache(async ( +export const isGradeManagerRaw = async ( gradeId: string, userId: string ): Promise => { @@ -661,13 +731,18 @@ export const isGradeManager = cache(async ( ) .limit(1) return Boolean(row) +} +export const isGradeManager = cacheFn(isGradeManagerRaw, { + tags: ["school"], + ttl: 300, + keyParts: ["school", "isGradeManager"], }) /** * 根据年级名称(大小写不敏感)查找用户担任年级主任的年级 ID。 * 供 classes 模块跨模块调用使用,避免直接查询 grades 表。 */ -export const findGradeIdByHeadAndName = cache(async ( +export const findGradeIdByHeadAndNameRaw = async ( userId: string, gradeName: string ): Promise => { @@ -686,6 +761,11 @@ export const findGradeIdByHeadAndName = cache(async ( ) .limit(1) 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 动态导入获取,避免循环依赖。 * 任何一层查询失败均返回空数组,保证调用方拿到稳定结构。 */ -export const getOrgTree = cache(async (): Promise => { +export const getOrgTreeRaw = async (): Promise => { try { const [schoolList, gradeList] = await Promise.all([getSchools(), getGrades()]) @@ -797,6 +877,11 @@ export const getOrgTree = cache(async (): Promise => { console.error("getOrgTree failed:", error) return [] } +} +export const getOrgTree = cacheFn(getOrgTreeRaw, { + tags: ["school"], + ttl: 300, + keyParts: ["school", "getOrgTree"], }) /** @@ -810,7 +895,7 @@ export interface GradeOverviewStats { teacherCount: number } -export const getGradeOverviewStats = cache(async (): Promise => { +export const getGradeOverviewStatsRaw = async (): Promise => { try { // 动态导入 classes 模块避免循环依赖 const { getAdminClasses } = await import("@/modules/classes/data-access") @@ -845,4 +930,9 @@ export const getGradeOverviewStats = cache(async (): Promise => { // 1. 查询全书知识点 + 章节标题 @@ -90,12 +90,17 @@ export const getKnowledgePointsWithRelations = cache(async ( questionCount: questionCountMap.get(r.id) ?? 0, 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, textbookId: string, ): Promise> => { @@ -125,6 +130,11 @@ export const getStudentKpMastery = cache(async ( }) } 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 textbookId 教材 ID */ -export const getClassKpMastery = cache(async ( +export const getClassKpMasteryRaw = async ( studentIds: string[], textbookId: string, ): Promise> => { @@ -166,12 +176,17 @@ export const getClassKpMastery = cache(async ( }) } 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, ): Promise<{ id: string; name: string; description: string | null }[]> => { const rows = await db @@ -185,12 +200,17 @@ export const getPrerequisitesForKp = cache(async ( .where(eq(knowledgePointPrerequisites.knowledgePointId, kpId)) return rows +} +export const getPrerequisitesForKp = cacheFn(getPrerequisitesForKpRaw, { + tags: ["textbooks"], + ttl: 300, + keyParts: ["textbooks", "getPrerequisitesForKp"], }) /** * 获取某个知识点的后置知识点列表(即哪些知识点以此 KP 为前置)。 */ -export const getSuccessorsForKp = cache(async ( +export const getSuccessorsForKpRaw = async ( kpId: string, ): Promise<{ id: string; name: string; description: string | null }[]> => { const rows = await db @@ -204,4 +224,9 @@ export const getSuccessorsForKp = cache(async ( .where(eq(knowledgePointPrerequisites.prerequisiteKpId, kpId)) return rows +} +export const getSuccessorsForKp = cacheFn(getSuccessorsForKpRaw, { + tags: ["textbooks"], + ttl: 300, + keyParts: ["textbooks", "getSuccessorsForKp"], }) diff --git a/src/modules/textbooks/data-access.ts b/src/modules/textbooks/data-access.ts index 49b5710..6e9ca0c 100644 --- a/src/modules/textbooks/data-access.ts +++ b/src/modules/textbooks/data-access.ts @@ -1,9 +1,9 @@ import "server-only" -import { cache } from "react" import { and, asc, count, eq, inArray, like, or, sql, isNull, type SQL } from "drizzle-orm" import { createId } from "@paralleldrive/cuid2" +import { cacheFn } from "@/shared/lib/cache" import { db } from "@/shared/db" import { chapters, knowledgePoints, knowledgePointPrerequisites, textbooks } from "@/shared/db/schema" import { escapeLikePattern, NotFoundError } from "@/shared/lib/action-utils" @@ -40,7 +40,7 @@ export interface TextbookQueryScope { grade?: string } -export const getTextbooks = cache(async (query?: string, subject?: string, grade?: string): Promise => { +export const getTextbooksRaw = async (query?: string, subject?: string, grade?: string): Promise => { const conditions: SQL[] = [] const q = query?.trim() @@ -88,9 +88,14 @@ export const getTextbooks = cache(async (query?: string, subject?: string, grade updatedAt: r.updatedAt, _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 => { +export const getTextbookByIdRaw = async (id: string): Promise => { const [row] = await db .select({ id: textbooks.id, @@ -120,9 +125,14 @@ export const getTextbookById = cache(async (id: string): Promise => { +export const getChaptersByTextbookIdRaw = async (textbookId: string): Promise => { const rows = await db .select({ id: chapters.id, @@ -150,6 +160,11 @@ export const getChaptersByTextbookId = cache(async (textbookId: string): Promise updatedAt: r.updatedAt, })) ) +} +export const getChaptersByTextbookId = cacheFn(getChaptersByTextbookIdRaw, { + tags: ["textbooks"], + ttl: 300, + keyParts: ["textbooks", "getChaptersByTextbookId"], }) export async function createTextbook(data: CreateTextbookInput): Promise { @@ -317,7 +332,7 @@ export async function deleteChapter(id: string): Promise { }) } -export const getKnowledgePointsByChapterId = cache(async (chapterId: string): Promise => { +export const getKnowledgePointsByChapterIdRaw = async (chapterId: string): Promise => { const rows = await db .select({ id: knowledgePoints.id, @@ -341,9 +356,14 @@ export const getKnowledgePointsByChapterId = cache(async (chapterId: string): Pr level: r.level ?? 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 => { +export const getKnowledgePointsByTextbookIdRaw = async (textbookId: string): Promise => { const rows = await db .select({ id: knowledgePoints.id, @@ -368,6 +388,11 @@ export const getKnowledgePointsByTextbookId = cache(async (textbookId: string): level: r.level ?? 0, order: r.order ?? 0, })) +} +export const getKnowledgePointsByTextbookId = cacheFn(getKnowledgePointsByTextbookIdRaw, { + tags: ["textbooks"], + ttl: 300, + keyParts: ["textbooks", "getKnowledgePointsByTextbookId"], }) export async function createKnowledgePoint(data: CreateKnowledgePointInput): Promise { @@ -437,7 +462,7 @@ export type TextbooksDashboardStats = { chapterCount: number } -export const getTextbooksDashboardStats = cache(async (): Promise => { +export const getTextbooksDashboardStatsRaw = async (): Promise => { const [textbookCountRow, chapterCountRow] = await Promise.all([ db.select({ value: count() }).from(textbooks), db.select({ value: count() }).from(chapters), @@ -446,6 +471,11 @@ export const getTextbooksDashboardStats = cache(async (): Promise => { - const conditions: SQL[] = [] +export const getTextbooksWithScopeRaw = async ( + query?: string, + subject?: string, + grade?: string, + scope?: TextbookQueryScope +): Promise => { + const conditions: SQL[] = [] - const q = query?.trim() - if (q) { - const needle = `%${escapeLikePattern(q)}%` - const nameCond = or( - like(textbooks.title, needle), - like(textbooks.subject, needle), - like(textbooks.grade, needle), - like(textbooks.publisher, needle) - ) - 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`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 q = query?.trim() + if (q) { + const needle = `%${escapeLikePattern(q)}%` + const nameCond = or( + like(textbooks.title, needle), + like(textbooks.subject, needle), + like(textbooks.grade, needle), + like(textbooks.publisher, needle) + ) + 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`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 @@ -591,7 +624,7 @@ export type KnowledgePointOption = { * 获取所有知识点选项(含章节和教材信息)。 * 供 questions 模块跨模块调用使用,避免直接查询 textbooks/chapters/knowledgePoints 表。 */ -export const getKnowledgePointOptions = cache(async (): Promise => { +export const getKnowledgePointOptionsRaw = async (): Promise => { const rows = await db .select({ id: knowledgePoints.id, @@ -624,6 +657,11 @@ export const getKnowledgePointOptions = cache(async (): Promise => { +export const getUserProfileRaw = async (userId: string): Promise => { const user = await db.query.users.findFirst({ where: eq(users.id, userId), }) @@ -51,6 +51,11 @@ export const getUserProfile = cache(async (userId: string): Promise } -export const getUsersDashboardStats = cache(async (): Promise => { +export const getUsersDashboardStatsRaw = async (): Promise => { // audit-P1-9:原查询 sessions 表(JWT 策略下无数据)。 // 改为查询最近 24 小时内 signin 成功事件数作为"活跃会话"近似值。 const oneDayAgo = new Date(Date.now() - 24 * 60 * 60 * 1000) @@ -198,6 +203,11 @@ export const getUsersDashboardStats = cache(async (): Promise => { - const id = userId.trim() - if (!id) return null +export const getUserWithRoleRaw = async ( + userId: string, roleName: string +): Promise<{ id: string; name: string | null; email: string } | null> => { + const id = userId.trim() + if (!id) return null - const [row] = await db - .select({ id: users.id, name: users.name, email: users.email }) - .from(users) - .innerJoin(usersToRoles, eq(usersToRoles.userId, users.id)) - .innerJoin(roles, eq(usersToRoles.roleId, roles.id)) - .where(and(eq(users.id, id), eq(roles.name, roleName))) - .limit(1) + const [row] = await db + .select({ id: users.id, name: users.name, email: users.email }) + .from(users) + .innerJoin(usersToRoles, eq(usersToRoles.userId, users.id)) + .innerJoin(roles, eq(usersToRoles.roleId, roles.id)) + .where(and(eq(users.id, id), eq(roles.name, roleName))) + .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 * 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. */ -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 try { ctx = await getAuthContext() @@ -260,44 +275,59 @@ export const getCurrentStudentUser = cache(async (): Promise<{ id: string; name: .limit(1) 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. */ -export const getUserNamesByIds = cache( - async (ids: string[]): Promise> => { - const uniqueIds = Array.from(new Set(ids.filter((v): v is string => typeof v === "string" && v.length > 0))) - const result = new Map() - if (uniqueIds.length === 0) return result +export const getUserNamesByIdsRaw = async ( + ids: string[] +): Promise> => { + const uniqueIds = Array.from(new Set(ids.filter((v): v is string => typeof v === "string" && v.length > 0))) + const result = new Map() + if (uniqueIds.length === 0) return result - const rows = await db - .select({ id: users.id, name: users.name, email: users.email }) - .from(users) - .where(inArray(users.id, uniqueIds)) + const rows = await db + .select({ id: users.id, name: users.name, email: users.email }) + .from(users) + .where(inArray(users.id, uniqueIds)) - for (const r of rows) { - result.set(r.id, { id: r.id, name: r.name, email: r.email }) - } - return result + for (const r of rows) { + result.set(r.id, { id: r.id, name: r.name, email: r.email }) } -) + 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. */ -export const getTeachersByIds = cache( - async (teacherIds: string[]): Promise => { - const uniqueIds = Array.from(new Set(teacherIds.filter((v): v is string => typeof v === "string" && v.length > 0))) - if (uniqueIds.length === 0) return [] +export const getTeachersByIdsRaw = async ( + teacherIds: string[] +): Promise => { + 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 - .select({ id: users.id, name: users.name, email: users.email }) - .from(users) - .innerJoin(usersToRoles, eq(usersToRoles.userId, users.id)) - .innerJoin(roles, eq(usersToRoles.roleId, roles.id)) - .where(and(eq(roles.name, "teacher"), inArray(users.id, uniqueIds))) - .groupBy(users.id, users.name, users.email) + const rows = await db + .select({ id: users.id, name: users.name, email: users.email }) + .from(users) + .innerJoin(usersToRoles, eq(usersToRoles.userId, users.id)) + .innerJoin(roles, eq(usersToRoles.roleId, roles.id)) + .where(and(eq(roles.name, "teacher"), inArray(users.id, uniqueIds))) + .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 = { id: string @@ -308,46 +338,75 @@ export type UserBasicInfo = { } /** Returns basic user info (id, name, email, image, gradeId) for a single user. */ -export const getUserBasicInfo = cache( - async (userId: string): Promise => { - const id = userId.trim() - if (!id) return null +export const getUserBasicInfoRaw = async ( + userId: string +): Promise => { + const id = userId.trim() + if (!id) return null - const [row] = await db - .select({ - id: users.id, - name: users.name, - email: users.email, - image: users.image, - gradeId: users.gradeId, - }) - .from(users) - .where(eq(users.id, id)) - .limit(1) + const [row] = await db + .select({ + id: users.id, + name: users.name, + email: users.email, + image: users.image, + gradeId: users.gradeId, + }) + .from(users) + .where(eq(users.id, id)) + .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. */ -export const getUserIdsByGradeId = cache( - async (gradeId: string): Promise => { - const id = gradeId.trim() - if (!id) return [] +export const getUserIdsByGradeIdRaw = async ( + gradeId: string +): Promise => { + const id = gradeId.trim() + if (!id) return [] - const rows = await db - .select({ id: users.id }) - .from(users) - .where(eq(users.gradeId, id)) + const rows = await db + .select({ id: users.id }) + .from(users) + .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 => { - const rows = await db.select({ id: users.id }).from(users) 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 => { + 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 = {