refactor(data-access): 组 A 模块迁移至 cacheFn(users/school/rbac/textbooks/questions/course-plans/standards/files/dashboard/parent/proctoring)
This commit is contained in:
@@ -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"],
|
||||
})
|
||||
|
||||
@@ -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"],
|
||||
})
|
||||
|
||||
@@ -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"],
|
||||
})
|
||||
|
||||
@@ -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"],
|
||||
})
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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"],
|
||||
})
|
||||
|
||||
/**
|
||||
|
||||
@@ -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"],
|
||||
})
|
||||
|
||||
@@ -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"],
|
||||
})
|
||||
|
||||
/**
|
||||
|
||||
@@ -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"],
|
||||
})
|
||||
|
||||
@@ -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"],
|
||||
})
|
||||
|
||||
@@ -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 =====
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
Reference in New Issue
Block a user