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 { 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 { 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 => { 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() 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 => { 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 { await db .update(learningDiagnosticReports) .set({ status: "published", updatedAt: new Date() }) .where(eq(learningDiagnosticReports.id, id)) } /** 删除诊断报告 */ export async function deleteDiagnosticReport(id: string): Promise { await db.delete(learningDiagnosticReports).where(eq(learningDiagnosticReports.id, id)) }