/** * QcReportService - 质控报告生成服务 * * 功能: * - 聚合质控统计数据 * - 生成 LLM 友好的 XML 报告 * - 缓存报告以提高查询效率 * * 设计原则: * - 报告驱动:预计算报告,LLM 只需阅读 * - 双模输出:人类可读 + LLM 友好格式 * - 智能缓存:报告有效期内直接返回缓存 */ import { PrismaClient } from '@prisma/client'; import { logger } from '../../../common/logging/index.js'; const prisma = new PrismaClient(); // ============================================================ // 类型定义 // ============================================================ /** * 报告摘要 */ export interface ReportSummary { totalRecords: number; completedRecords: number; criticalIssues: number; warningIssues: number; pendingQueries: number; passRate: number; lastQcTime: string | null; healthScore?: number; healthGrade?: string; dimensionBreakdown?: DimensionPassRate[]; } export interface DimensionPassRate { code: string; label: string; passRate: number; total: number; failed: number; } /** * 问题项 — V3.1 五级坐标 */ export interface ReportIssue { recordId: string; eventId?: string; formName?: string; instanceId?: number; fieldName?: string; dimensionCode?: string; ruleId: string; ruleName: string; severity: 'critical' | 'warning' | 'info'; message: string; field?: string; actualValue?: any; expectedValue?: any; evidence?: Record; semanticLabel?: string; detectedAt: string; } /** * 表单统计 */ export interface FormStats { formName: string; formLabel: string; totalChecks: number; passed: number; failed: number; passRate: number; } /** * V3.1: 事件级概览 */ export interface EventOverview { eventId: string; eventLabel?: string; status: string; fieldsTotal: number; fieldsPassed: number; fieldsFailed: number; fieldsWarning: number; d1Issues: number; d2Issues: number; d3Issues: number; d5Issues: number; d6Issues: number; d7Issues: number; } /** * 质控报告 */ export interface QcReport { projectId: string; reportType: 'daily' | 'weekly' | 'on_demand'; generatedAt: string; expiresAt: string | null; summary: ReportSummary; criticalIssues: ReportIssue[]; warningIssues: ReportIssue[]; formStats: FormStats[]; eventOverview: EventOverview[]; topIssues: TopIssue[]; groupedIssues: GroupedIssues[]; llmFriendlyXml: string; legacyXml?: string; } /** * 报告选项 */ export interface ReportOptions { forceRefresh?: boolean; // 强制刷新,忽略缓存 reportType?: 'daily' | 'weekly' | 'on_demand'; expirationHours?: number; // 报告有效期(小时) format?: 'xml' | 'llm-friendly'; // V2.1: 输出格式 } /** * V2.1: 按受试者分组的问题 */ export interface GroupedIssues { recordId: string; issueCount: number; issues: ReportIssue[]; } /** * V2.1: Top 问题统计 */ export interface TopIssue { ruleName: string; ruleId: string; count: number; affectedRecords: string[]; } // ============================================================ // QcReportService 实现 // ============================================================ class QcReportServiceClass { /** * 获取项目质控报告 * * @param projectId 项目 ID * @param options 报告选项 * @returns 质控报告 */ async getReport(projectId: string, options?: ReportOptions): Promise { const reportType = options?.reportType || 'on_demand'; const expirationHours = options?.expirationHours || 24; // 1. 检查缓存 if (!options?.forceRefresh) { const cached = await this.getCachedReport(projectId, reportType); if (cached) { logger.debug('[QcReportService] Returning cached report', { projectId, reportType, generatedAt: cached.generatedAt, }); return cached; } } // 2. 生成新报告 logger.info('[QcReportService] Generating new report', { projectId, reportType, }); const report = await this.generateReport(projectId, reportType, expirationHours); // 3. 缓存报告 await this.cacheReport(report); return report; } /** * 获取缓存的报告 */ private async getCachedReport( projectId: string, reportType: string ): Promise { try { const cached = await prisma.iitQcReport.findFirst({ where: { projectId, reportType, OR: [ { expiresAt: null }, { expiresAt: { gt: new Date() } }, ], }, orderBy: { generatedAt: 'desc' }, }); if (!cached) { return null; } // 若缓存早于最新质控时间,则视为过期,触发重算 const latestQcRows = await prisma.$queryRaw>` SELECT MAX(last_qc_at) AS last_qc_at FROM iit_schema.qc_field_status WHERE project_id = ${projectId} `; const latestQcAt = latestQcRows?.[0]?.last_qc_at; if (latestQcAt && cached.generatedAt < latestQcAt) { return null; } const issuesData = cached.issues as any || {}; return { projectId: cached.projectId, reportType: cached.reportType as 'daily' | 'weekly' | 'on_demand', generatedAt: this.toBeijingTime(cached.generatedAt), expiresAt: cached.expiresAt?.toISOString() || null, summary: cached.summary as unknown as ReportSummary, criticalIssues: (issuesData.critical || []) as ReportIssue[], warningIssues: (issuesData.warning || []) as ReportIssue[], formStats: (issuesData.formStats || []) as FormStats[], eventOverview: (issuesData.eventOverview || []) as EventOverview[], topIssues: (issuesData.topIssues || []) as TopIssue[], groupedIssues: (issuesData.groupedIssues || []) as GroupedIssues[], llmFriendlyXml: cached.llmReport, legacyXml: issuesData.legacyXml, }; } catch (error: any) { logger.warn('[QcReportService] Failed to get cached report', { projectId, error: error.message, }); return null; } } /** * 生成新报告 * * V2.1 优化:支持双格式输出 */ private async generateReport( projectId: string, reportType: 'daily' | 'weekly' | 'on_demand', expirationHours: number ): Promise { const startTime = Date.now(); // 1. 获取项目信息 const project = await prisma.iitProject.findUnique({ where: { id: projectId }, select: { id: true, name: true, fieldMappings: true, }, }); if (!project) { throw new Error(`项目不存在: ${projectId}`); } // 2. 聚合质控统计 const summary = await this.aggregateStats(projectId); // 3. 获取问题列表 const { criticalIssues, warningIssues } = await this.getIssues(projectId); // 4. 获取表单统计 + 事件概览 const [formStats, eventOverview] = await Promise.all([ this.getFormStats(projectId), this.getEventOverview(projectId), ]); // 5. 计算 Top Issues 和分组 const allIssues = [...criticalIssues, ...warningIssues]; const topIssues = this.calculateTopIssues(allIssues); const groupedIssues = this.groupIssuesByRecord(criticalIssues); // 6. 生成双格式 XML 报告 const llmFriendlyXml = this.buildLlmXmlReport( projectId, project.name || projectId, summary, criticalIssues, warningIssues, formStats, eventOverview, ); const legacyXml = this.buildLegacyXmlReport( projectId, project.name || projectId, summary, criticalIssues, warningIssues, formStats, ); const generatedAt = new Date(); const expiresAt = new Date(generatedAt.getTime() + expirationHours * 60 * 60 * 1000); const duration = Date.now() - startTime; logger.info('[QcReportService] Report generated', { projectId, reportType, duration: `${duration}ms`, criticalCount: criticalIssues.length, warningCount: warningIssues.length, topIssuesCount: topIssues.length, groupedRecordCount: groupedIssues.length, }); return { projectId, reportType, generatedAt: this.toBeijingTime(generatedAt), expiresAt: expiresAt.toISOString(), summary, criticalIssues, warningIssues, formStats, eventOverview, topIssues, groupedIssues, llmFriendlyXml, legacyXml, }; } /** * V3.1: 从 qc_project_stats + qc_field_status 聚合统计 */ private async aggregateStats(projectId: string): Promise { const [projectStats, recordSummaries, fieldCounts, pendingEqs, lastField] = await Promise.all([ prisma.iitQcProjectStats.findUnique({ where: { projectId } }), prisma.iitRecordSummary.findMany({ where: { projectId } }), prisma.$queryRaw>` SELECT CASE WHEN severity = 'critical' THEN 'critical' ELSE 'warning' END AS severity, COUNT(*) AS cnt FROM iit_schema.qc_field_status WHERE project_id = ${projectId} AND status IN ('FAIL', 'WARNING') GROUP BY 1 `, prisma.iitEquery.count({ where: { projectId, status: { in: ['pending', 'reopened'] } } }), prisma.$queryRaw>` SELECT last_qc_at FROM iit_schema.qc_field_status WHERE project_id = ${projectId} ORDER BY last_qc_at DESC LIMIT 1 `, ]); const totalRecords = projectStats?.totalRecords ?? recordSummaries.length; const completedRecords = recordSummaries.filter( (r: any) => r.completionRate && r.completionRate >= 100, ).length; let criticalIssues = 0; let warningIssues = 0; for (const fc of fieldCounts) { const n = Number(fc.cnt); if (fc.severity === 'critical') criticalIssues = n; else warningIssues = n; } const passedRecords = projectStats?.passedRecords ?? 0; const passRate = totalRecords > 0 ? Math.round((passedRecords / totalRecords) * 1000) / 10 : 0; const DIMENSION_LABELS: Record = { D1: '数据一致性', D2: '数据完整性', D3: '数据准确性', D5: '时效性', D6: '方案依从', D7: '安全性', }; const dimensionBreakdown: DimensionPassRate[] = []; if (projectStats) { const ps = projectStats as any; for (const [code, label] of Object.entries(DIMENSION_LABELS)) { const rateField = `${code.toLowerCase()}PassRate`; const rate = (ps[rateField] as number) ?? 100; dimensionBreakdown.push({ code, label, passRate: rate, total: 0, failed: 0 }); } } return { totalRecords, completedRecords, criticalIssues, warningIssues, pendingQueries: pendingEqs, passRate, lastQcTime: (lastField as any)?.[0]?.last_qc_at ? this.toBeijingTime(new Date((lastField as any)[0].last_qc_at)) : null, healthScore: ((projectStats as any)?.healthScore as number) ?? undefined, healthGrade: ((projectStats as any)?.healthGrade as string) ?? undefined, dimensionBreakdown, }; } /** * V3.1: 从 qc_field_status 获取问题列表(五级坐标) */ private async getIssues(projectId: string): Promise<{ criticalIssues: ReportIssue[]; warningIssues: ReportIssue[]; }> { const fieldRows = await prisma.$queryRaw>` SELECT record_id, event_id, form_name, instance_id, field_name, status, rule_id, rule_name, rule_category, severity, message, actual_value, expected_value, last_qc_at FROM iit_schema.qc_field_status WHERE project_id = ${projectId} AND status IN ('FAIL', 'WARNING') ORDER BY last_qc_at DESC `; const semanticMap = await this.getSemanticLabelMap(projectId); const criticalIssues: ReportIssue[] = []; const warningIssues: ReportIssue[] = []; for (const row of fieldRows) { const reportIssue: ReportIssue = { recordId: row.record_id, eventId: row.event_id, formName: row.form_name, instanceId: row.instance_id, fieldName: row.field_name, dimensionCode: row.rule_category || undefined, ruleId: row.rule_id || 'unknown', ruleName: row.rule_name || 'Unknown', severity: this.normalizeSeverity(row.severity || row.status), message: row.message || '', field: row.field_name, actualValue: row.actual_value, expectedValue: row.expected_value, semanticLabel: semanticMap.get(row.field_name), detectedAt: row.last_qc_at?.toISOString() || new Date().toISOString(), }; if (reportIssue.severity === 'critical') { criticalIssues.push(reportIssue); } else { warningIssues.push(reportIssue); } } return { criticalIssues, warningIssues }; } /** * V3.1: 事件级概览(从 qc_event_status) */ private async getEventOverview(projectId: string): Promise { const rows = await prisma.$queryRaw>` SELECT event_id, MAX(event_label) AS event_label, CASE WHEN SUM(CASE WHEN status = 'FAIL' THEN 1 ELSE 0 END) > 0 THEN 'FAIL' WHEN SUM(CASE WHEN status = 'WARNING' THEN 1 ELSE 0 END) > 0 THEN 'WARNING' ELSE 'PASS' END AS status, COALESCE(SUM(fields_total), 0)::int AS fields_total, COALESCE(SUM(fields_passed), 0)::int AS fields_passed, COALESCE(SUM(fields_failed), 0)::int AS fields_failed, COALESCE(SUM(fields_warning), 0)::int AS fields_warning, COALESCE(SUM(d1_issues), 0)::int AS d1_issues, COALESCE(SUM(d2_issues), 0)::int AS d2_issues, COALESCE(SUM(d3_issues), 0)::int AS d3_issues, COALESCE(SUM(d5_issues), 0)::int AS d5_issues, COALESCE(SUM(d6_issues), 0)::int AS d6_issues, COALESCE(SUM(d7_issues), 0)::int AS d7_issues FROM iit_schema.qc_event_status WHERE project_id = ${projectId} GROUP BY event_id ORDER BY event_id `; return rows.map(r => ({ eventId: r.event_id, eventLabel: r.event_label || undefined, status: r.status, fieldsTotal: r.fields_total, fieldsPassed: r.fields_passed, fieldsFailed: r.fields_failed, fieldsWarning: r.fields_warning, d1Issues: r.d1_issues, d2Issues: r.d2_issues, d3Issues: r.d3_issues, d5Issues: r.d5_issues, d6Issues: r.d6_issues, d7Issues: r.d7_issues, })); } /** * V3.1: 从 IitFieldMapping 获取语义标签映射 */ private async getSemanticLabelMap(projectId: string): Promise> { const map = new Map(); try { const mappings = await prisma.$queryRaw>` SELECT actual_name, semantic_label FROM iit_schema.field_mapping WHERE project_id = ${projectId} AND semantic_label IS NOT NULL `; for (const m of mappings) { if (m.semantic_label) map.set(m.actual_name, m.semantic_label); } } catch { // non-fatal } return map; } /** * V2.1: 构建自包含的 LLM 友好消息 */ private buildSelfContainedMessage(issue: any): string { const ruleName = issue.ruleName || issue.message || 'Unknown'; const actualValue = issue.actualValue; const expectedValue = issue.expectedValue || this.extractExpectedFromMessage(issue.message); // 如果已经有自包含格式,直接返回 if (issue.llmMessage) { return issue.llmMessage; } // 构建自包含格式 const actualDisplay = actualValue !== undefined && actualValue !== null && actualValue !== '' ? `**${actualValue}**` : '**空**'; if (expectedValue) { return `**${ruleName}**: 当前值 ${actualDisplay} (标准: ${expectedValue})`; } return `**${ruleName}**: 当前值 ${actualDisplay}`; } /** * V2.1: 从原始消息中提取期望值 * 例如:"年龄不在 25-35 岁范围内" -> "25-35 岁" */ private extractExpectedFromMessage(message: string): string | undefined { if (!message) return undefined; // 尝试提取数字范围:如 "25-35" const rangeMatch = message.match(/(\d+)\s*[-~至到]\s*(\d+)/); if (rangeMatch) { return `${rangeMatch[1]}-${rangeMatch[2]}`; } // 尝试提取日期范围 const dateRangeMatch = message.match(/(\d{4}-\d{2}-\d{2})\s*至\s*(\d{4}-\d{2}-\d{2})/); if (dateRangeMatch) { return `${dateRangeMatch[1]} 至 ${dateRangeMatch[2]}`; } return undefined; } /** * 标准化严重程度 */ private normalizeSeverity(severity: string): 'critical' | 'warning' | 'info' { const lower = (severity || '').toLowerCase(); if (lower === 'critical' || lower === 'red' || lower === 'error') { return 'critical'; } else if (lower === 'warning' || lower === 'yellow') { return 'warning'; } return 'info'; } /** * V3.1: 从 qc_field_status 获取表单统计 */ private async getFormStats(projectId: string): Promise { const project = await prisma.iitProject.findUnique({ where: { id: projectId }, select: { fieldMappings: true }, }); const formLabels: Record = ((project?.fieldMappings as any)?.formLabels) || {}; const formStatsRaw = await prisma.$queryRaw>` SELECT form_name, COUNT(*) AS total, COUNT(*) FILTER (WHERE status = 'PASS') AS passed, COUNT(*) FILTER (WHERE status IN ('FAIL', 'WARNING')) AS failed FROM iit_schema.qc_field_status WHERE project_id = ${projectId} GROUP BY form_name ORDER BY form_name `; return formStatsRaw.map(row => { const total = Number(row.total); const passed = Number(row.passed); const failed = Number(row.failed); return { formName: row.form_name, formLabel: formLabels[row.form_name] || this.formatFormName(row.form_name), totalChecks: total, passed, failed, passRate: total > 0 ? Math.round((passed / total) * 1000) / 10 : 0, }; }); } /** * 格式化表单名称 */ private formatFormName(formName: string): string { return formName .replace(/_/g, ' ') .replace(/\b\w/g, c => c.toUpperCase()); } /** * V2.1: 按受试者分组问题 */ private groupIssuesByRecord(issues: ReportIssue[]): GroupedIssues[] { const grouped = new Map(); for (const issue of issues) { const existing = grouped.get(issue.recordId) || []; existing.push(issue); grouped.set(issue.recordId, existing); } return Array.from(grouped.entries()) .map(([recordId, issues]) => ({ recordId, issueCount: issues.length, issues, })) .sort((a, b) => a.recordId.localeCompare(b.recordId, undefined, { numeric: true })); } /** * V2.1: 计算 Top Issues 统计 */ private calculateTopIssues(issues: ReportIssue[], limit: number = 5): TopIssue[] { const ruleStats = new Map }>(); for (const issue of issues) { const key = issue.ruleId || issue.ruleName; const existing = ruleStats.get(key) || { ruleName: issue.ruleName, ruleId: issue.ruleId, records: new Set(), }; existing.records.add(issue.recordId); ruleStats.set(key, existing); } return Array.from(ruleStats.values()) .map(stat => ({ ruleName: stat.ruleName, ruleId: stat.ruleId, count: stat.records.size, affectedRecords: Array.from(stat.records), })) .sort((a, b) => b.count - a.count) .slice(0, limit); } /** * 构建 LLM 友好的 XML 报告 * * V2.1 优化: * - 按受试者分组 * - 添加 Top Issues 统计 * - 自包含的 message 格式 */ private buildLlmXmlReport( projectId: string, projectName: string, summary: ReportSummary, criticalIssues: ReportIssue[], warningIssues: ReportIssue[], formStats: FormStats[], eventOverview?: EventOverview[], ): string { const now = new Date().toLocaleString('zh-CN', { timeZone: 'Asia/Shanghai', hour12: false }); const allIssues = [...criticalIssues, ...warningIssues]; const topIssues = this.calculateTopIssues(allIssues); const groupedCritical = this.groupIssuesByRecord(criticalIssues); const failedRecordCount = groupedCritical.length; const hs = summary.healthScore != null ? ` health_score="${summary.healthScore}" health_grade="${summary.healthGrade}"` : ''; let xml = ` - 健康度: ${summary.healthScore ?? 'N/A'} (${summary.healthGrade ?? '-'}) - 状态: ${failedRecordCount}/${summary.totalRecords} 记录存在严重违规 - 通过率: ${summary.passRate}% - 严重问题: ${summary.criticalIssues} | 警告: ${summary.warningIssues} ${topIssues.length > 0 ? ` - Top ${Math.min(3, topIssues.length)} 问题:\n${topIssues.slice(0, 3).map((t, i) => ` ${i + 1}. ${t.ruleName} (${t.count}人)`).join('\n')}` : ''} `; // dimension_summary if (summary.dimensionBreakdown && summary.dimensionBreakdown.length > 0) { xml += ` \n`; xml += ` \n`; for (const d of summary.dimensionBreakdown) { xml += ` \n`; } xml += ` \n\n`; } // event_overview if (eventOverview && eventOverview.length > 0) { xml += ` \n`; xml += ` \n`; for (const ev of eventOverview) { xml += ` \n`; } xml += ` \n\n`; } // critical issues (grouped by record, five-level coordinates) if (groupedCritical.length > 0) { xml += ` \n`; xml += ` \n\n`; for (const group of groupedCritical) { xml += ` \n`; for (let i = 0; i < group.issues.length; i++) { const issue = group.issues[i]; xml += this.buildV31IssueLine(issue, i + 1, ' '); } xml += ` \n\n`; } xml += ` \n\n`; } else { xml += ` \n\n`; } // warning issues const groupedWarning = this.groupIssuesByRecord(warningIssues); if (groupedWarning.length > 0) { xml += ` \n`; xml += ` \n`; for (const group of groupedWarning) { xml += ` \n`; for (let i = 0; i < group.issues.length; i++) { xml += this.buildV31IssueLine(group.issues[i], i + 1, ' '); } xml += ` \n`; } xml += ` \n\n`; } xml += ``; return xml; } /** * V3.1: 五级坐标 + 维度标注的问题行 */ private buildV31IssueLine(issue: ReportIssue, index: number, indent: string): string { const dim = issue.dimensionCode ? `[${issue.dimensionCode}] ` : ''; const label = issue.semanticLabel || issue.fieldName || issue.field || ''; const loc = [issue.eventId, issue.formName, issue.fieldName].filter(Boolean).join(' > '); const actual = issue.actualValue != null ? ` 当前值=${issue.actualValue}` : ''; const expected = issue.expectedValue ? ` (标准: ${issue.expectedValue})` : ''; return `${indent}${index}. ${dim}${loc ? `[${loc}] ` : ''}${label}: ${issue.message}${actual}${expected}\n`; } /** * V2.1: 构建 LLM 友好的单行问题描述 * * 格式: [规则ID] **问题类型**: 当前 **值** (标准: xxx) */ private buildLlmIssueLine(issue: ReportIssue, index: number): string { const ruleId = issue.ruleId !== 'unknown' ? `[${issue.ruleId}]` : ''; // 尝试使用 llmMessage(如果 HardRuleEngine 已经生成) if (issue.message && issue.message.includes('**')) { // 已经是自包含格式 return `${index}. ${ruleId} ${issue.message}`; } // 回退:手动构建 const actualDisplay = issue.actualValue !== undefined && issue.actualValue !== null && issue.actualValue !== '' ? `**${issue.actualValue}**` : '**空**'; const expectedDisplay = issue.expectedValue ? ` (标准: ${issue.expectedValue})` : ''; return `${index}. ${ruleId} **${issue.ruleName}**: 当前值 ${actualDisplay}${expectedDisplay}`; } /** * 构建原始 XML 报告(兼容旧格式) */ private buildLegacyXmlReport( projectId: string, projectName: string, summary: ReportSummary, criticalIssues: ReportIssue[], warningIssues: ReportIssue[], formStats: FormStats[] ): string { const now = new Date().toLocaleString('zh-CN', { timeZone: 'Asia/Shanghai', hour12: false }); let xml = ` ${summary.totalRecords} ${summary.completedRecords} ${summary.criticalIssues} ${summary.warningIssues} ${summary.pendingQueries} ${summary.passRate}% ${summary.lastQcTime || 'N/A'} `; // V3.1: 严重问题列表(显示所有) if (criticalIssues.length > 0) { xml += ` \n`; for (const issue of criticalIssues) { xml += this.buildIssueXml(issue, ' '); } xml += ` \n\n`; } else { xml += ` \n\n`; } // V3.1: 警告问题列表(显示所有) if (warningIssues.length > 0) { xml += ` \n`; for (const issue of warningIssues) { xml += this.buildIssueXml(issue, ' '); } xml += ` \n\n`; } else { xml += ` \n\n`; } // 表单统计 if (formStats.length > 0) { xml += ` \n`; for (const form of formStats) { xml += `
\n`; xml += ` ${form.totalChecks}\n`; xml += ` ${form.passed}\n`; xml += ` ${form.failed}\n`; xml += ` ${form.passRate}%\n`; xml += `
\n`; } xml += `
\n\n`; } xml += `
`; return xml; } /** * 构建单个问题的 XML */ private buildIssueXml(issue: ReportIssue, indent: string): string { let xml = `${indent}\n`; xml += `${indent} ${this.escapeXml(issue.ruleName)}\n`; xml += `${indent} ${this.escapeXml(issue.message)}\n`; if (issue.field) { xml += `${indent} ${this.escapeXml(String(issue.field))}\n`; } if (issue.actualValue !== undefined) { xml += `${indent} ${this.escapeXml(String(issue.actualValue))}\n`; } if (issue.expectedValue !== undefined) { xml += `${indent} ${this.escapeXml(String(issue.expectedValue))}\n`; } if (issue.evidence && Object.keys(issue.evidence).length > 0) { xml += `${indent} \n`; for (const [key, value] of Object.entries(issue.evidence)) { xml += `${indent} <${this.escapeXml(key)}>${this.escapeXml(String(value))}\n`; } xml += `${indent} \n`; } xml += `${indent} ${issue.detectedAt}\n`; xml += `${indent}\n`; return xml; } /** * UTC Date → 北京时间字符串(yyyy/M/d HH:mm:ss 格式,可被 new Date() 解析回来) */ private toBeijingTime(date: Date): string { return date.toLocaleString('sv-SE', { timeZone: 'Asia/Shanghai', hour12: false }).replace(' ', 'T') + '+08:00'; } /** * 从 toBeijingTime 输出反解回 Date 对象 */ private parseBeijingTime(str: string): Date { return new Date(str); } /** * XML 转义 */ private escapeXml(str: string): string { return str .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"') .replace(/'/g, '''); } /** * 缓存报告 */ private async cacheReport(report: QcReport): Promise { try { await prisma.iitQcReport.create({ data: { projectId: report.projectId, reportType: report.reportType, summary: report.summary as any, issues: { critical: report.criticalIssues, warning: report.warningIssues, formStats: report.formStats, eventOverview: report.eventOverview, topIssues: report.topIssues, groupedIssues: report.groupedIssues, legacyXml: report.legacyXml, } as any, llmReport: report.llmFriendlyXml, generatedAt: this.parseBeijingTime(report.generatedAt), expiresAt: report.expiresAt ? new Date(report.expiresAt) : null, }, }); logger.debug('[QcReportService] Report cached', { projectId: report.projectId, reportType: report.reportType, }); } catch (error: any) { logger.warn('[QcReportService] Failed to cache report', { projectId: report.projectId, error: error.message, }); } } /** * 获取 LLM 友好的报告(用于问答) * * @param projectId 项目 ID * @param format 格式:'llm-friendly' (默认) 或 'xml' (兼容格式) * @returns XML 报告 */ async getLlmReport(projectId: string, format: 'llm-friendly' | 'xml' = 'llm-friendly'): Promise { const report = await this.getReport(projectId); if (format === 'xml') { return report.legacyXml || report.llmFriendlyXml; } return report.llmFriendlyXml; } /** * V2.1: 获取 Top Issues 统计 */ async getTopIssues(projectId: string, limit: number = 5): Promise { const report = await this.getReport(projectId); return report.topIssues.slice(0, limit); } /** * V2.1: 获取按受试者分组的问题 */ async getGroupedIssues(projectId: string): Promise { const report = await this.getReport(projectId); return report.groupedIssues; } /** * 强制刷新报告 * * @param projectId 项目 ID * @returns 新生成的报告 */ async refreshReport(projectId: string): Promise { return this.getReport(projectId, { forceRefresh: true }); } /** * 清理过期报告 */ async cleanupExpiredReports(): Promise { const result = await prisma.iitQcReport.deleteMany({ where: { expiresAt: { lt: new Date(), }, }, }); logger.info('[QcReportService] Cleaned up expired reports', { count: result.count, }); return result.count; } } // 单例导出 export const QcReportService = new QcReportServiceClass();