- Add export module for diagnostic report data export - Add stats-service for diagnostic analytics aggregation - Add confidence-utils for diagnostic confidence score calculations
257 lines
9.4 KiB
TypeScript
257 lines
9.4 KiB
TypeScript
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))
|
||
}
|