Files
NextEdu/src/modules/diagnostic/data-access-reports.ts
SpecialX 9ceb2b7b67 feat(diagnostic): add export, stats service, and confidence utils
- Add export module for diagnostic report data export

- Add stats-service for diagnostic analytics aggregation

- Add confidence-utils for diagnostic confidence score calculations
2026-06-23 17:37:58 +08:00

257 lines
9.4 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import "server-only"
import { createId } from "@paralleldrive/cuid2"
import { and, count, desc, eq, inArray, type SQL } from "drizzle-orm"
import { cache } from "react"
import { db } from "@/shared/db"
import { learningDiagnosticReports } from "@/shared/db/schema"
import { getUserNamesByIds } from "@/modules/users/data-access"
import { getStudentIdsByClassIds } from "@/modules/classes/data-access"
import { toNumber } from "@/modules/grades/lib/grade-utils"
import { BusinessError } from "@/shared/lib/action-utils"
import type { DataScope } from "@/shared/types/permissions"
import { getClassMasterySummary, getStudentMasterySummary } from "./data-access"
import { buildClassReportContent, buildStudentReportContent } from "./stats-service"
import type {
DiagnosticReport,
DiagnosticReportListResult,
DiagnosticReportQueryParams,
DiagnosticReportWithDetails,
} from "./types"
/**
* 诊断报告业务错误P3-27 修复:结构化错误码,避免直接暴露内部错误)。
* 继承 BusinessError 以便 handleActionError 安全地将 message 返回给客户端。
*/
export class DiagnosticReportError extends BusinessError {
constructor(
public readonly code:
| "STUDENT_NOT_FOUND"
| "NO_MASTERY_DATA"
| "CLASS_NOT_FOUND"
| "CLASS_NO_MASTERY_DATA",
message: string,
) {
super(message, code)
this.name = "DiagnosticReportError"
}
}
const isStringArray = (v: unknown): v is string[] =>
Array.isArray(v) && v.every((item) => typeof item === "string")
const toStringArrayNullable = (v: unknown): string[] | null =>
isStringArray(v) ? v : null
const serializeReport = (r: typeof learningDiagnosticReports.$inferSelect): DiagnosticReport => ({
id: r.id,
studentId: r.studentId,
classId: r.classId,
generatedBy: r.generatedBy,
reportType: r.reportType,
period: r.period,
summary: r.summary,
strengths: toStringArrayNullable(r.strengths),
weaknesses: toStringArrayNullable(r.weaknesses),
recommendations: toStringArrayNullable(r.recommendations),
overallScore: r.overallScore !== null ? toNumber(r.overallScore) : null,
status: r.status,
createdAt: r.createdAt.toISOString(),
updatedAt: r.updatedAt.toISOString(),
})
/** 生成个人诊断报告 */
export async function generateDiagnosticReport(
studentId: string,
period: string,
generatedBy: string
): Promise<string> {
const summary = await getStudentMasterySummary(studentId)
if (!summary) throw new DiagnosticReportError("STUDENT_NOT_FOUND", "学生不存在")
// P2-6 修复:当学生存在但无任何掌握度数据时,拒绝生成误导性报告
if (summary.totalKnowledgePoints === 0) {
throw new DiagnosticReportError("NO_MASTERY_DATA", "学生暂无掌握度数据,无法生成报告")
}
const { summaryText, strengths, weaknesses, recommendations, overallScore } =
buildStudentReportContent(summary, period)
const id = createId()
await db.insert(learningDiagnosticReports).values({
id,
studentId,
generatedBy,
reportType: "individual",
period,
summary: summaryText,
strengths,
weaknesses,
recommendations,
overallScore: String(overallScore),
status: "draft",
})
return id
}
/** 生成班级诊断报告 */
export async function generateClassDiagnosticReport(
classId: string,
period: string,
generatedBy: string
): Promise<string> {
const summary = await getClassMasterySummary(classId)
if (!summary) throw new DiagnosticReportError("CLASS_NOT_FOUND", "班级不存在")
// P2-6 修复:当班级存在但无任何掌握度数据时,拒绝生成误导性报告
if (summary.studentCount === 0 || summary.knowledgePointStats.length === 0) {
throw new DiagnosticReportError("CLASS_NO_MASTERY_DATA", "班级暂无掌握度数据,无法生成报告")
}
const { summaryText, strengths, weaknesses, recommendations, overallScore } =
buildClassReportContent(summary, period)
const id = createId()
await db.insert(learningDiagnosticReports).values({
id,
studentId: null, // 班级报告无单个学生studentId 置空P2-3 修复:不再存生成者 ID
generatedBy,
reportType: "class",
period,
summary: summaryText,
strengths,
weaknesses,
recommendations,
overallScore: String(overallScore),
status: "draft",
})
return id
}
/** 查询诊断报告列表P3-15 修复:支持分页) */
export const getDiagnosticReports = cache(
async (
filters: DiagnosticReportQueryParams,
scope?: DataScope,
): Promise<DiagnosticReportListResult> => {
const conditions: SQL[] = []
if (filters.studentId) conditions.push(eq(learningDiagnosticReports.studentId, filters.studentId))
if (filters.reportType) conditions.push(eq(learningDiagnosticReports.reportType, filters.reportType))
if (filters.status) conditions.push(eq(learningDiagnosticReports.status, filters.status))
if (filters.period) conditions.push(eq(learningDiagnosticReports.period, filters.period))
// v4-P1-1: 应用 DataScope 行级权限过滤
// - class_taught: 仅返回所教班级学生的个人报告 + 班级报告(班级报告 studentId 为 null需通过 classId 关联)
// 由于当前 schema 班级报告 studentId=null无法直接按 classId 过滤,因此对 class_taught scope
// 个人报告按所教班级学生 ID 过滤班级报告studentId=null保留教师可查看自己生成的班级报告
// - class_members: 学生角色,调用方已在 filters.studentId 中传入 ctx.userId无需在此重复过滤
// - children: 仅返回子女的报告
// - grade_managed: 返回所辖年级所有学生的报告(通过 studentId IN 所辖年级学生)
// - all: 不过滤
if (scope) {
if (scope.type === "children") {
if (scope.childrenIds.length === 0) {
return { reports: [], total: 0 }
}
conditions.push(inArray(learningDiagnosticReports.studentId, scope.childrenIds))
} else if (scope.type === "class_taught") {
if (scope.classIds.length === 0) {
return { reports: [], total: 0 }
}
const studentIds = await getStudentIdsByClassIds(scope.classIds)
if (studentIds.length === 0) {
return { reports: [], total: 0 }
}
// 个人报告按学生 ID 过滤班级报告studentId=null由 generatedBy 限制为当前教师
// 这里简化:仅返回所教班级学生的个人报告
conditions.push(inArray(learningDiagnosticReports.studentId, studentIds))
}
// grade_managed 和 all 不在此过滤grade_managed 需要跨模块查询年级学生,由调用方自行过滤)
}
const whereClause = conditions.length > 0 ? and(...conditions) : undefined
const limit = filters.limit ?? 100
const offset = filters.offset ?? 0
// P3-15 修复:并行查询总数和分页数据
const [totalRows, rows] = await Promise.all([
db
.select({ total: count() })
.from(learningDiagnosticReports)
.where(whereClause),
db
.select({ report: learningDiagnosticReports })
.from(learningDiagnosticReports)
.where(whereClause)
.orderBy(desc(learningDiagnosticReports.createdAt))
.limit(limit)
.offset(offset),
])
const total = totalRows[0]?.total ?? 0
// 收集所有需要查询姓名的用户 ID学生 + 生成者),通过 users data-access 统一获取
const userIds = new Set<string>()
for (const r of rows) {
if (r.report.studentId) userIds.add(r.report.studentId)
if (r.report.generatedBy) userIds.add(r.report.generatedBy)
}
const userMap = await getUserNamesByIds(Array.from(userIds))
const reports: DiagnosticReportWithDetails[] = rows.map((r) => ({
...serializeReport(r.report),
studentName: r.report.studentId
? userMap.get(r.report.studentId)?.name ?? "Unknown"
: null,
generatedByName: r.report.generatedBy
? userMap.get(r.report.generatedBy)?.name ?? "Unknown"
: null,
}))
return { reports, total }
},
)
/** 获取报告详情 */
export const getDiagnosticReportById = cache(
async (id: string): Promise<DiagnosticReportWithDetails | null> => {
const [row] = await db
.select({ report: learningDiagnosticReports })
.from(learningDiagnosticReports)
.where(eq(learningDiagnosticReports.id, id))
.limit(1)
if (!row) return null
// 通过 users data-access 获取学生姓名和生成者姓名
const userIds: string[] = []
if (row.report.studentId) userIds.push(row.report.studentId)
if (row.report.generatedBy) userIds.push(row.report.generatedBy)
const userMap = await getUserNamesByIds(userIds)
return {
...serializeReport(row.report),
studentName: row.report.studentId
? userMap.get(row.report.studentId)?.name ?? "Unknown"
: null,
generatedByName: row.report.generatedBy
? userMap.get(row.report.generatedBy)?.name ?? null
: null,
}
},
)
/** 发布诊断报告 */
export async function publishDiagnosticReport(id: string): Promise<void> {
await db
.update(learningDiagnosticReports)
.set({ status: "published", updatedAt: new Date() })
.where(eq(learningDiagnosticReports.id, id))
}
/** 删除诊断报告 */
export async function deleteDiagnosticReport(id: string): Promise<void> {
await db.delete(learningDiagnosticReports).where(eq(learningDiagnosticReports.id, id))
}