Commit c05d5c3c by luoqi

feat(ops): 每日健康报告推企微(09:00 沪)+ 手动预览端点

新增 DailyHealthReportService:每日 09:00(Asia/Shanghai)把线上状态收口成一条简洁日报推企微
(复用 AlertService webhook,整体级别取最差→图标自动 🟢/🟡/🔴)。指标含近24h diff:
摄入+DW延迟、画像/召回重算、数据量(患者/事实/计划增量)、召回池待办、客服执行(按outcome)、
画像覆盖、AI用量。手动触发端点 /internal/health-report/{preview,run}(env PAC_HEALTH_REPORT_DEMO 门控)。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
parent 49ff784a
import { Controller, Post, Get } from '@nestjs/common';
import { ApiOperation, ApiTags } from '@nestjs/swagger';
import { Public } from '../common/decorators/public.decorator';
import { DailyHealthReportService } from './daily-health-report.service';
/**
* 手动触发/预览每日健康报告(便于上线前验一次企微效果)。
* 用 env PAC_HEALTH_REPORT_DEMO=1 门控(默认关);日常靠 09:00 cron 自动推。
*/
@ApiTags('internal')
@Controller('internal/health-report')
export class DailyHealthReportController {
constructor(private readonly svc: DailyHealthReportService) {}
/** 预览报告文本(不推送),env 门控。 */
@Public()
@Get('preview')
@ApiOperation({ summary: '预览每日健康报告文本(不推企微)' })
async preview(): Promise<unknown> {
if (process.env.PAC_HEALTH_REPORT_DEMO !== '1') {
return { ok: false, msg: '未启用(设 PAC_HEALTH_REPORT_DEMO=1 后重启)' };
}
const r = await this.svc.build();
return { ok: true, level: r.level, title: r.title, body: r.body };
}
/** 立即生成并推送到企微,env 门控。 */
@Public()
@Post('run')
@ApiOperation({ summary: '立即生成并推送每日健康报告到企微' })
async run(): Promise<unknown> {
if (process.env.PAC_HEALTH_REPORT_DEMO !== '1') {
return { ok: false, msg: '未启用(设 PAC_HEALTH_REPORT_DEMO=1 后重启)' };
}
await this.svc.run();
return { ok: true, msg: '已推送(看企微群)' };
}
}
import { Injectable, Logger } from '@nestjs/common';
import { Cron } from '@nestjs/schedule';
import { PrismaService } from '../prisma/prisma.service';
import { AlertService, type AlertLevel } from '../common/alerting/alert.service';
/**
* DailyHealthReportService — 每日 09:00(沪)给企微推一份**简洁的线上健康日报**。
*
* 目的:把分散的监控(DW 延迟 / 画像覆盖 / 摄入 / 重算 / 召回 / AI 用量)收口成**一条**
* 每日可对比的指标快照。每条都带"近 24h 增量(diff)",一眼看出昨天系统动了多少、有没有异常。
*
* 通道:复用 AlertService → 企微群机器人 webhook(ALERT_WEBHOOK_URL)。
* 报告整体级别(info/warning/critical)取最差项,企微图标自动 🟢/🟡/🔴。
*
* cron:PAC_HEALTH_REPORT_CRON(默认 '0 9 * * *',Asia/Shanghai);DW 08:00 落库后跑。
*/
@Injectable()
export class DailyHealthReportService {
private readonly logger = new Logger(DailyHealthReportService.name);
constructor(
private readonly prisma: PrismaService,
private readonly alerter: AlertService,
) {}
@Cron(process.env.PAC_HEALTH_REPORT_CRON || '0 9 * * *', {
name: 'daily-health-report',
timeZone: 'Asia/Shanghai',
})
async run(): Promise<void> {
try {
const { level, title, body } = await this.build();
await this.alerter.send({ level, title, body });
this.logger.log(`每日健康报告已推送(${level})`);
} catch (err) {
this.logger.error(`每日健康报告生成/推送失败: ${err instanceof Error ? err.message : String(err)}`);
}
}
/** 生成报告(也供手动触发端点预览)。 */
async build(): Promise<{ level: AlertLevel; title: string; body: string }> {
const now = Date.now();
const since24h = new Date(now - 24 * 3_600_000);
const since7d = new Date(now - 7 * 24 * 3_600_000);
const warnH = Number(process.env.PAC_LAG_WARN_HOURS ?? '24');
const errorH = Number(process.env.PAC_LAG_ERROR_HOURS ?? '48');
// ── 数据量 + 近 24h 增量 ──
const [patientsTotal, patients24h, factsTotal, facts24h, plansActive, plansAssigned, plans24h] =
await Promise.all([
this.prisma.patient.count(),
this.prisma.patient.count({ where: { createdAt: { gte: since24h } } }),
this.prisma.patientFact.count(),
this.prisma.patientFact.count({ where: { createdAt: { gte: since24h } } }),
this.prisma.followupPlan.count({ where: { status: 'active', supersededAt: null } }),
this.prisma.followupPlan.count({ where: { status: 'assigned', supersededAt: null } }),
this.prisma.followupPlan.count({ where: { createdAt: { gte: since24h } } }),
]);
// ── 摄入:最近一次成功增量 + DW 延迟 + 近 7 日失败 ──
const lastSync = await this.prisma.syncLog.findFirst({
where: { resource: 'incremental_bundle', status: 'success', cursorAfter: { not: null } },
orderBy: { startedAt: 'desc' },
select: { startedAt: true, transactionsWritten: true, factsEmitted: true, cursorAfter: true },
});
const fail7d = await this.prisma.syncLog.count({
where: { resource: 'incremental_bundle', status: { not: 'success' }, startedAt: { gte: since7d } },
});
const lagHours = lastSync?.cursorAfter ? cursorLagHours(lastSync.cursorAfter, now) : null;
const lagTag = lagHours == null ? '⚪' : lagHours > errorH ? '🔴' : lagHours > warnH ? '🟡' : '🟢';
// ── 重算:画像 / 召回 最近一次 ──
const [lastPersona, lastPlanGen] = await Promise.all([
this.prisma.personaRecomputeLog.findFirst({ orderBy: { startedAt: 'desc' }, select: { startedAt: true, status: true } }),
this.prisma.planGenerationLog.findFirst({ orderBy: { startedAt: 'desc' }, select: { startedAt: true, plansCreated: true } }),
]);
// ── 客服执行(近 24h)按 outcome ──
const execGroups = await this.prisma.planExecution.groupBy({
by: ['outcome'],
where: { createdAt: { gte: since24h } },
_count: { _all: true },
});
const execTotal = execGroups.reduce((s, g) => s + g._count._all, 0);
const execBreakdown = execGroups
.sort((a, b) => b._count._all - a._count._all)
.map((g) => `${OUTCOME_ZH[g.outcome] ?? g.outcome} ${g._count._all}`)
.join(' / ');
// ── 画像覆盖 ──
const personaPatients = await this.prisma.persona.count({ where: { supersededAt: null } });
const coveragePct = patientsTotal > 0 ? Math.round((personaPatients / patientsTotal) * 100) : 0;
// ── AI 用量(近 24h)──
const [inv24h, costAgg] = await Promise.all([
this.prisma.agentInvocation.count({ where: { startedAt: { gte: since24h } } }),
this.prisma.agentInvocation.aggregate({ _sum: { costYuan: true }, where: { startedAt: { gte: since24h } } }),
]);
const cost24h = Number(costAgg._sum.costYuan ?? 0);
// ── 整体级别:取最差(摄入失败/DW 严重滞后 → critical;滞后/重算异常 → warning)──
let level: AlertLevel = 'info';
if (fail7d > 0 && lastSync == null) level = 'critical';
if (lagHours != null && lagHours > warnH) level = 'warning';
if (lastPersona?.status === 'failed' || (lastPlanGen == null)) level = 'warning';
if (lagHours != null && lagHours > errorH) level = 'critical';
const dateStr = new Date(now).toLocaleDateString('zh-CN', { timeZone: 'Asia/Shanghai' }).replace(/\//g, '-');
const title = `每日健康报告 · ${dateStr}`;
const body = [
`📥 摄入 ${lagTag} 上次 ${fmtTime(lastSync?.startedAt)} · DW延迟 ${lagHours == null ? '—' : lagHours.toFixed(1) + 'h'} · 7日失败 ${fail7d}`,
`🔄 重算 画像 ${fmtTime(lastPersona?.startedAt)}${lastPersona?.status && lastPersona.status !== 'success' ? `(${lastPersona.status})` : ''} · 召回 ${fmtTime(lastPlanGen?.startedAt)}(+${lastPlanGen?.plansCreated ?? 0}计划)`,
`📈 数据(24h) 患者 ${fmtNum(patientsTotal)}(+${patients24h}) · 事实 ${fmtWan(factsTotal)}(+${fmtNum(facts24h)}) · 新计划 +${plans24h}`,
`📋 召回池 待办 ${fmtNum(plansActive)} · 进行中 ${fmtNum(plansAssigned)}`,
`📞 客服执行(24h) ${execTotal} ${execBreakdown ? ` · ${execBreakdown}` : ''}`,
`🖼 画像覆盖 ${fmtNum(personaPatients)} / ${fmtNum(patientsTotal)}(${coveragePct}%)`,
`🤖 AI(24h) ${inv24h} 次 · ¥${cost24h.toFixed(2)}`,
].join('\n');
return { level, title, body };
}
}
/** cursor_after JSON({resource: "2026-06-21 19:30:00", ...})→ 距今小时数(取最大游标)。 */
function cursorLagHours(cursorAfter: string, now: number): number | null {
try {
const cursors = JSON.parse(cursorAfter) as Record<string, string>;
const maxTs = Math.max(...Object.values(cursors).map((v) => new Date(v).getTime()).filter((t) => !isNaN(t)));
if (!isFinite(maxTs)) return null;
return (now - maxTs) / 3_600_000;
} catch {
return null;
}
}
const OUTCOME_ZH: Record<string, string> = {
success_appointed: '约到诊',
scheduled_next: '约下次',
no_answer: '未接通',
abandoned: '放弃',
refused: '拒绝',
rescheduled: '改约',
considering: '考虑中',
declined_recent: '近期不考虑',
needs_doctor: '需医生',
external_treatment: '外院已治',
pending_info: '待确认',
sms_sent: '已发短信',
proactive_sms: '主动短信',
marked_invalid: '标记无效',
};
const fmtNum = (n: number): string => n.toLocaleString('en-US');
const fmtWan = (n: number): string => (n >= 10000 ? `${(n / 10000).toFixed(1)}万` : `${n}`);
function fmtTime(d: Date | null | undefined): string {
if (!d) return '—';
return new Date(d).toLocaleString('zh-CN', { timeZone: 'Asia/Shanghai', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', hour12: false });
}
...@@ -10,6 +10,8 @@ import { QueueProducer } from './queue-producer.service'; ...@@ -10,6 +10,8 @@ import { QueueProducer } from './queue-producer.service';
import { StaleScanService } from './stale-scan.service'; import { StaleScanService } from './stale-scan.service';
import { SyncIncrementalSchedulerService } from './sync-incremental.scheduler'; import { SyncIncrementalSchedulerService } from './sync-incremental.scheduler';
import { DwLagMonitorService } from './dw-lag-monitor.service'; import { DwLagMonitorService } from './dw-lag-monitor.service';
import { DailyHealthReportService } from './daily-health-report.service';
import { DailyHealthReportController } from './daily-health-report.controller';
import { PersonaRecomputeProcessor } from './processors/persona-recompute.processor'; import { PersonaRecomputeProcessor } from './processors/persona-recompute.processor';
import { PlanRecomputeProcessor } from './processors/plan-recompute.processor'; import { PlanRecomputeProcessor } from './processors/plan-recompute.processor';
import { PlanAssetGenerateProcessor } from './processors/plan-asset-generate.processor'; import { PlanAssetGenerateProcessor } from './processors/plan-asset-generate.processor';
...@@ -56,11 +58,13 @@ import { PlanAssetGenerateProcessor } from './processors/plan-asset-generate.pro ...@@ -56,11 +58,13 @@ import { PlanAssetGenerateProcessor } from './processors/plan-asset-generate.pro
PlanModule, PlanModule,
SyncModule, // 给 SyncIncrementalSchedulerService 注入 ColdImportService SyncModule, // 给 SyncIncrementalSchedulerService 注入 ColdImportService
], ],
controllers: [DailyHealthReportController],
providers: [ providers: [
QueueProducer, QueueProducer,
StaleScanService, StaleScanService,
SyncIncrementalSchedulerService, SyncIncrementalSchedulerService,
DwLagMonitorService, DwLagMonitorService,
DailyHealthReportService,
PersonaRecomputeProcessor, PersonaRecomputeProcessor,
PlanRecomputeProcessor, PlanRecomputeProcessor,
PlanAssetGenerateProcessor, PlanAssetGenerateProcessor,
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment