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 { 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<CoursePlanListItem[]> => {
export const getCoursePlansRaw = async (params?: GetCoursePlansParams, scope?: CoursePlanQueryScope): Promise<CoursePlanListItem[]> => {
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<CoursePlanWithItems | null> => {
export const getCoursePlans = cacheFn(getCoursePlansRaw, {
tags: ["course-plans"],
ttl: 300,
keyParts: ["course-plans", "getCoursePlans"],
})
export const getCoursePlanByIdRaw = async (id: string, scope?: CoursePlanQueryScope): Promise<CoursePlanWithItems | null> => {
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<GradeCoursePlanProgressResult> => {
export const getGradeCoursePlanProgressRaw = async (params: { gradeId: string }): Promise<GradeCoursePlanProgressResult> => {
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"],
})

View File

@@ -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<AdminDashboardData> => {
export const getAdminDashboardDataRaw = async (scope?: DataScope): Promise<AdminDashboardData> => {
const [
usersStats,
classesStats,
@@ -48,4 +48,10 @@ export const getAdminDashboardData = cache(async (scope?: DataScope): Promise<Ad
userGrowth: [],
homeworkTrend: [],
}
}
export const getAdminDashboardData = cacheFn(getAdminDashboardDataRaw, {
tags: ["dashboard"],
ttl: 60,
keyParts: ["dashboard", "getAdminDashboardData"],
})

View File

@@ -1,7 +1,7 @@
import "server-only"
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 { fileAttachments } from "@/shared/db/schema"
@@ -60,8 +60,7 @@ export async function createFileAttachment(
/**
* 按 ID 查询文件附件
*/
export const getFileAttachment = cache(
async (id: string): Promise<FileAttachment | null> => {
export const getFileAttachmentRaw = async (id: string): Promise<FileAttachment | null> => {
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<FileAttachment[]> => {
export const getFileAttachmentsByTargetRaw = async (targetType: string, targetId: string): Promise<FileAttachment[]> => {
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<FileAttachment[]> => {
export const getFileAttachmentsByUploaderRaw = async (uploaderId: string): Promise<FileAttachment[]> => {
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<FileAttachment[]> => {
export const getAllFileAttachmentsRaw = async (limit = 100): Promise<FileAttachment[]> => {
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<BatchDeleteR
* - mimeType: 精确匹配或前缀匹配(如 "image/"
* - search: 在 originalName / filename 中模糊匹配
*/
export const getFileAttachmentsWithFilters = cache(
async (params: FileAttachmentQueryParams): Promise<FileAttachment[]> => {
export const getFileAttachmentsWithFiltersRaw = async (params: FileAttachmentQueryParams): Promise<FileAttachment[]> => {
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<FileStats> => {
export const getFileStatsRaw = async (): Promise<FileStats> => {
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<FileAttachment | null> => {
export const getFileByUrlRaw = async (url: string): Promise<FileAttachment | null> => {
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<FileAttachment[]> => {
export const getFileAttachmentsByIdsRaw = async (ids: string[]): Promise<FileAttachment[]> => {
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"],
})

View File

@@ -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<ParentChildRelation[]> => {
export const getChildrenRaw = async (parentId: string): Promise<ParentChildRelation[]> => {
const id = parentId.trim()
if (!id) return []
@@ -62,14 +62,19 @@ export const getChildren = cache(async (parentId: string): Promise<ParentChildRe
relation: r.relation,
createdAt: r.createdAt.toISOString(),
}))
}
export const getChildren = cacheFn(getChildrenRaw, {
tags: ["parent"],
ttl: 300,
keyParts: ["parent", "getChildren"],
})
/**
* 校验家长与子女的关系是否存在,并返回关系类型。
* 同时按 parentId 与 studentId 过滤,防止跨家庭信息泄露。
*/
export const verifyParentChildRelation = cache(
async (studentId: string, parentId: string): Promise<string | null> => {
export const verifyParentChildRelationRaw = async (studentId: string, parentId: string): Promise<string | null> => {
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<ChildBasicInfo | null> => {
@@ -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<ReturnType<typeof getStudentHomeworkAssignments>>,
@@ -197,8 +211,7 @@ const buildWeeklySchedule = (
)
}
export const getChildDashboardData = cache(
async (studentId: string, relation: string | null = null): Promise<ChildDashboardData | null> => {
export const getChildDashboardDataRaw = async (studentId: string, relation: string | null = null): Promise<ChildDashboardData | null> => {
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<ParentDashboardData> => {
export const getChildDashboardData = cacheFn(getChildDashboardDataRaw, {
tags: ["parent"],
ttl: 300,
keyParts: ["parent", "getChildDashboardData"],
})
export const getParentDashboardDataRaw = async (parentId: string): Promise<ParentDashboardData> => {
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<Array<{ id: string; name: string | null }>> => {
export const getChildNameListRaw = async (parentId: string): Promise<Array<{ id: string; name: string | null }>> => {
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<string[]> => {
export const getParentIdsByStudentIdsRaw = async (studentIds: string[]): Promise<string[]> => {
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<Array<{ parentId: string; studentId: string }>> => {
export const getParentStudentMapByStudentIdsRaw = async (studentIds: string[]): Promise<Array<{ parentId: string; studentId: string }>> => {
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"],
})

View File

@@ -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<ProctoringEventWithDetails[]> => {
@@ -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<ProctoringEvent[]> => {
export const getProctoringEventsBySubmissionRaw = async (submissionId: string): Promise<ProctoringEvent[]> => {
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<ExamProctoringSummary> => {
export const getExamProctoringSummaryRaw = async (examId: string): Promise<ExamProctoringSummary> => {
// 考试信息与提交记录相互独立,并行拉取
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<StudentProctoringStatus[]> => {
export const getStudentProctoringStatusesRaw = async (examId: string): Promise<StudentProctoringStatus[]> => {
// 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<ProctoringEventWithDetails[]> => {
export const getRecentProctoringEventsRaw = async (examId: string, limit = 20): Promise<ProctoringEventWithDetails[]> => {
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 }

View File

@@ -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<QuestionsDashboardStats> => {
export const getQuestionsDashboardStatsRaw = async (): Promise<QuestionsDashboardStats> => {
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<Map<string, QuestionKnowledgePoint[]>> => {
const result = new Map<string, QuestionKnowledgePoint[]>()
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<Map<string, QuestionKnowledgePoint[]>> => {
const result = new Map<string, QuestionKnowledgePoint[]>()
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: {

View File

@@ -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<string[]> => {
export const getUserRoleNamesRaw = async (userId: string): Promise<string[]> => {
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"],
})
/**

View File

@@ -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<Record<string, number>> => {
export const getPermissionRoleCountsRaw = async (): Promise<Record<string, number>> => {
const allPermissionValues = getAllPermissionMetas().map((m) => m.value)
if (allPermissionValues.length === 0) return {}
@@ -33,4 +33,9 @@ export const getPermissionRoleCounts = cache(async (): Promise<Record<string, nu
}
}
return counts
}
export const getPermissionRoleCounts = cacheFn(getPermissionRoleCountsRaw, {
tags: ["rbac"],
ttl: 300,
keyParts: ["rbac", "getPermissionRoleCounts"],
})

View File

@@ -1,8 +1,8 @@
import "server-only"
import { cache } from "react"
import { createId } from "@paralleldrive/cuid2"
import { count, eq, inArray } from "drizzle-orm"
import { cacheFn } from "@/shared/lib/cache"
import { db } from "@/shared/db"
import { rolePermissions, roles, usersToRoles } from "@/shared/db/schema"
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.
* 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)
if (roleRows.length === 0) return []
@@ -63,12 +63,17 @@ export const getRoles = cache(async (): Promise<RoleWithStats[]> => {
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<RoleDetail | null> => {
export const getRoleByIdRaw = async (id: string): Promise<RoleDetail | null> => {
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<RoleDetail | null>
.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<RoleRecord | null> => {
export const getRoleByNameRaw = async (name: string): Promise<RoleRecord | null> => {
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<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
.select({ permission: rolePermissions.permission })
.from(rolePermissions)
@@ -196,6 +211,11 @@ export const getRolePermissions = cache(async (roleId: string): Promise<Permissi
return rows
.map((r) => (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"],
})
/**

View File

@@ -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<DepartmentListItem[]> => {
export const getDepartmentsRaw = async (): Promise<DepartmentListItem[]> => {
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<DepartmentListItem[]> => {
console.error("getDepartments failed:", error)
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 {
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<AcademicYearListItem[]>
console.error("getAcademicYears failed:", error)
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 {
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<SchoolListItem[]> => {
console.error("getSchools failed:", error)
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 {
const rows = await db
.select({
@@ -126,9 +141,14 @@ export const getGrades = cache(async (): Promise<GradeListItem[]> => {
console.error("getGrades failed:", error)
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 {
const rows = await db
.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)
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()
if (!id) return []
@@ -204,6 +229,11 @@ export const getGradesForStaff = cache(async (staffId: string): Promise<GradeLis
console.error("getGradesForStaff failed:", error)
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: 返回其任课班级所在学校
* - 其他角色: 返回空数组
*/
export const getSchoolsForUser = cache(async (userId: string): Promise<SchoolListItem[]> => {
export const getSchoolsForUserRaw = async (userId: string): Promise<SchoolListItem[]> => {
const id = userId.trim()
if (!id) return []
@@ -277,6 +307,11 @@ export const getSchoolsForUser = cache(async (userId: string): Promise<SchoolLis
console.error("getSchoolsForUser failed:", error)
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: 返回其任课班级所在年级
* - 其他角色: 返回空数组
*/
export const getGradesForUser = cache(async (userId: string): Promise<GradeListItem[]> => {
export const getGradesForUserRaw = async (userId: string): Promise<GradeListItem[]> => {
const id = userId.trim()
if (!id) return []
@@ -370,6 +405,11 @@ export const getGradesForUser = cache(async (userId: string): Promise<GradeListI
console.error("getGradesForUser failed:", error)
return []
}
}
export const getGradesForUser = cacheFn(getGradesForUserRaw, {
tags: ["school"],
ttl: 300,
keyParts: ["school", "getGradesForUser"],
})
// ---------------------------------------------------------------------------
@@ -511,7 +551,7 @@ export type GradeOption = {
order: number
}
export const getSubjectOptions = cache(async (): Promise<SubjectOption[]> => {
export const getSubjectOptionsRaw = async (): Promise<SubjectOption[]> => {
try {
const rows = await db
.select({
@@ -533,9 +573,14 @@ export const getSubjectOptions = cache(async (): Promise<SubjectOption[]> => {
console.error("getSubjectOptions failed:", error)
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 {
const rows = await db
.select({
@@ -560,59 +605,79 @@ export const getGradeOptions = cache(async (): Promise<GradeOption[]> => {
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<string | null> => {
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<string | null> => {
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<string | null> => {
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<string | null> => {
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<Map<string, string | null>> => {
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<string, string | null>()
for (const r of rows) map.set(r.id, r.name)
return map
},
)
export const getSubjectNameMapByIdsRaw = async (
subjectIds: string[]
): Promise<Map<string, string | null>> => {
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<string, string | null>()
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<boolean> => {
@@ -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<boolean> => {
@@ -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<string | null> => {
@@ -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<OrgTreeNode[]> => {
export const getOrgTreeRaw = async (): Promise<OrgTreeNode[]> => {
try {
const [schoolList, gradeList] = await Promise.all([getSchools(), getGrades()])
@@ -797,6 +877,11 @@ export const getOrgTree = cache(async (): Promise<OrgTreeNode[]> => {
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<GradeOverviewStats[]> => {
export const getGradeOverviewStatsRaw = async (): Promise<GradeOverviewStats[]> => {
try {
// 动态导入 classes 模块避免循环依赖
const { getAdminClasses } = await import("@/modules/classes/data-access")
@@ -845,4 +930,9 @@ export const getGradeOverviewStats = cache(async (): Promise<GradeOverviewStats[
console.error("getGradeOverviewStats failed:", error)
return []
}
}
export const getGradeOverviewStats = cacheFn(getGradeOverviewStatsRaw, {
tags: ["school"],
ttl: 300,
keyParts: ["school", "getGradeOverviewStats"],
})

View File

@@ -1,8 +1,8 @@
import "server-only"
import { cache } from "react"
import { and, asc, eq, inArray, sql, count } from "drizzle-orm"
import { cacheFn } from "@/shared/lib/cache"
import { db } from "@/shared/db"
import {
chapters,
@@ -18,7 +18,7 @@ import type { KpWithRelations, MasteryInfo } from "./types"
*
* 一次查询聚合,避免 N+1。
*/
export const getKnowledgePointsWithRelations = cache(async (
export const getKnowledgePointsWithRelationsRaw = async (
textbookId: string,
): Promise<KpWithRelations[]> => {
// 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<Map<string, MasteryInfo>> => {
@@ -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<Map<string, MasteryInfo>> => {
@@ -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"],
})

View File

@@ -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<Textbook[]> => {
export const getTextbooksRaw = async (query?: string, subject?: string, grade?: string): Promise<Textbook[]> => {
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<Textbook | undefined> => {
export const getTextbookByIdRaw = async (id: string): Promise<Textbook | undefined> => {
const [row] = await db
.select({
id: textbooks.id,
@@ -120,9 +125,14 @@ export const getTextbookById = cache(async (id: string): Promise<Textbook | unde
updatedAt: row.updatedAt,
_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
.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<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
.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<KnowledgePoint[]> => {
export const getKnowledgePointsByTextbookIdRaw = async (textbookId: string): Promise<KnowledgePoint[]> => {
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<void> {
@@ -437,7 +462,7 @@ export type TextbooksDashboardStats = {
chapterCount: number
}
export const getTextbooksDashboardStats = cache(async (): Promise<TextbooksDashboardStats> => {
export const getTextbooksDashboardStatsRaw = async (): Promise<TextbooksDashboardStats> => {
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<TextbooksDashb
textbookCount: Number(textbookCountRow[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` 按年级过滤,避免跨年级越权读取。
*/
export const getTextbooksWithScope = cache(
async (
query?: string,
subject?: string,
grade?: string,
scope?: TextbookQueryScope
): Promise<Textbook[]> => {
const conditions: SQL[] = []
export const getTextbooksWithScopeRaw = async (
query?: string,
subject?: string,
grade?: string,
scope?: TextbookQueryScope
): Promise<Textbook[]> => {
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<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 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<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
@@ -591,7 +624,7 @@ export type KnowledgePointOption = {
* 获取所有知识点选项(含章节和教材信息)。
* 供 questions 模块跨模块调用使用,避免直接查询 textbooks/chapters/knowledgePoints 表。
*/
export const getKnowledgePointOptions = cache(async (): Promise<KnowledgePointOption[]> => {
export const getKnowledgePointOptionsRaw = async (): Promise<KnowledgePointOption[]> => {
const rows = await db
.select({
id: knowledgePoints.id,
@@ -624,6 +657,11 @@ export const getKnowledgePointOptions = cache(async (): Promise<KnowledgePointOp
subject: row.subject ?? null,
grade: row.grade ?? null,
}))
}
export const getKnowledgePointOptions = cacheFn(getKnowledgePointOptionsRaw, {
tags: ["textbooks"],
ttl: 300,
keyParts: ["textbooks", "getKnowledgePointOptions"],
})
// ===== Prerequisite CRUD =====

View File

@@ -1,9 +1,9 @@
import "server-only"
import { cache } from "react"
import { and, count, desc, eq, gte, ilike, inArray, or } from "drizzle-orm"
import { getAuthContext } from "@/shared/lib/auth-guard"
import { cacheFn } from "@/shared/lib/cache"
import { db } from "@/shared/db"
import { loginLogs, roles, users, usersToRoles } from "@/shared/db/schema"
import { resolvePrimaryRole } from "@/shared/lib/role-utils"
@@ -23,7 +23,7 @@ export type UserProfile = {
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({
where: eq(users.id, userId),
})
@@ -51,6 +51,11 @@ export const getUserProfile = cache(async (userId: string): Promise<UserProfile
createdAt: user.createdAt,
updatedAt: user.updatedAt,
}
}
export const getUserProfile = cacheFn(getUserProfileRaw, {
tags: ["users"],
ttl: 300,
keyParts: ["users", "getUserProfile"],
})
/** 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 策略下无数据)。
// 改为查询最近 24 小时内 signin 成功事件数作为"活跃会话"近似值。
const oneDayAgo = new Date(Date.now() - 24 * 60 * 60 * 1000)
@@ -198,6 +203,11 @@ export const getUsersDashboardStats = cache(async (): Promise<UsersDashboardStat
userRoleCounts,
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. */
export const getUserWithRole = cache(
async (userId: string, roleName: string): Promise<{ id: string; name: string | null; email: string } | null> => {
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<Map<string, UserNameOption>> => {
const uniqueIds = Array.from(new Set(ids.filter((v): v is string => typeof v === "string" && v.length > 0)))
const result = new Map<string, UserNameOption>()
if (uniqueIds.length === 0) return result
export const getUserNamesByIdsRaw = async (
ids: string[]
): Promise<Map<string, UserNameOption>> => {
const uniqueIds = Array.from(new Set(ids.filter((v): v is string => typeof v === "string" && v.length > 0)))
const result = new Map<string, UserNameOption>()
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<TeacherOption[]> => {
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<TeacherOption[]> => {
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<UserBasicInfo | null> => {
const id = userId.trim()
if (!id) return null
export const getUserBasicInfoRaw = async (
userId: string
): Promise<UserBasicInfo | null> => {
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<string[]> => {
const id = gradeId.trim()
if (!id) return []
export const getUserIdsByGradeIdRaw = async (
gradeId: string
): Promise<string[]> => {
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<string[]> => {
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<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 = {