医院自律端系统------预警处置模块全栈实战(ASP.NET Core + Vue3 + Quartz 定时调度)
一、项目背景
医院自律端系统是面向医疗机构的内控监管平台,核心目标是对药品、耗材、设备 三大领域的异常行为进行实时监控与预警。当某科室/医疗组的药品使用金额出现环比/同比异常增长时,系统自动生成预警记录,管理员进行线上处置,形成"计算 → 预警 → 处置 → 闭环"的管理链路。
本文以预警处置模块(WarnDispositions)为切入点,深入分析其前端交互设计、后端 API 设计、预警计算引擎与 Quartz 定时调度机制的完整实现。
二、技术栈概览
| 层级 | 技术选型 |
|---|---|
| 前端 | Vue 3 (Composition API) + Vite + Element Plus + Vue Router |
| 后端 | ASP.NET Core (.NET 10) + EF Core |
| 数据库 | MySQL(业务库) + PostgreSQL(HIS 视图只读) |
| 定时调度 | Quartz.NET |
| 认证鉴权 | JWT + BCrypt |
| 日志 | Serilog |
| 部署 | IIS + Nginx(前端静态资源) |
三、预警处置模块前端实现
3.1 页面结构
预警处置页面(WarnDispositions.vue(file:///d:/HospitalSelfDiscipline/hospitalselfdiscipline.web/src/views/warn/WarnDispositions.vue))分为四个核心区域:
- 搜索筛选区 --- 按预警指标、处置状态筛选
- 数据列表区 --- 分页展示预警处置记录
- 详情弹窗 --- 查看预警完整信息 + 环比/同比金额明细
- 处置弹窗 --- 管理员填写处置意见并更新状态
3.2 搜索筛选
使用 el-form 行内表单,提供两级筛选:
html
<el-form :model="searchForm" inline>
<el-form-item label="预警指标">
<el-select v-model="searchForm.indicatorId" placeholder="请选择">
<el-option label="全部" :value="0" />
<el-option v-for="item in indicators" :key="item.id"
:label="item.indicatorName" :value="item.id" />
</el-select>
</el-form-item>
<el-form-item label="处置状态">
<el-select v-model="searchForm.status" placeholder="请选择">
<el-option label="全部" :value="-1" />
<el-option label="待处置" :value="0" />
<el-option label="核查中" :value="1" />
<el-option label="持续监测" :value="2" />
<el-option label="专项点评" :value="3" />
<el-option label="已办结" :value="4" />
</el-select>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="handleSearch">查询</el-button>
<el-button @click="handleReset">重置</el-button>
</el-form-item>
</el-form>
设计亮点: 使用 reactive 而非多个 ref 管理表单状态,状态切换时重置分页回到第 1 页,避免"翻到第 5 页后筛选结果为空"的体验问题。
3.3 状态可视化
预警级别和处置状态使用语义化色彩标签(映射函数 + CSS 类名):
javascript
const getLevelClass = (level) => {
const classes = { 1: 'level-serious', 2: 'level-critical' }
return `level-tag ${classes[level] || 'level-info'}`
}
const getLevelText = (level) => {
const texts = { 1: '黄色预警', 2: '红色预警' }
return texts[level] || '未知'
}
CSS 色卡:
scss
.level-critical { background: #fef0f0; color: #dc2626; } // 红色预警
.level-serious { background: #fff7ed; color: #ea580c; } // 黄色预警
.status-pending { background: #f0f9ff; color: #0284c7; } // 待处置
.status-checking { background: #fef3c7; color: #d97706; } // 核查中
.status-monitoring { background: #ede9fe; color: #7c3aed; } // 持续监测
.status-review { background: #fce7f3; color: #db2777; } // 专项点评
.status-completed { background: #d1fae5; color: #059669; } // 已办结
3.4 环比/同比金额明细查询
详情弹窗中,根据预警的对比类型 (MOM 环比 / YOY 同比)动态展示当期与对比期金额,并支持"查看明细"按钮打开费用明细弹窗:
html
<template v-if="detailForm.compareType === 'MOM'">
<el-form-item label="本月金额(元)">
<span>{{ detailForm.currentAmount }}</span>
<el-button size="small" type="primary" @click="openFeeDetail('current', detailForm)">
查看明细
</el-button>
</el-form-item>
<el-form-item label="上月同期(元)">
<span>{{ detailForm.prevAmount }}</span>
<el-button size="small" type="primary" @click="openFeeDetail('prev', detailForm)">
查看明细
</el-button>
</el-form-item>
</template>
费用明细弹窗通过 /drug-usage/his/detail/{warnRecordId} 接口获取 HIS 原始计费记录,表格展示药品名称、科室、医疗组、金额、计费日期,底部汇总合计金额。
3.5 处置提交
管理员在处置弹窗中选择状态、填写意见后提交:
javascript
const submitDisposition = async () => {
if (!dispositionFormRef.value) return
try {
await dispositionFormRef.value.validate()
} catch { return }
dispositionSubmitting.value = true
try {
await request.post('/warn-dispositions', {
warnRecordId: dispositionForm.warnRecordId,
dispositionType: dispositionForm.dispositionStatus,
dispositionAction: dispositionForm.opinion,
dispositionContent: dispositionForm.opinion
})
ElMessage.success('处置成功')
dispositionVisible.value = false
loadData() // 刷新列表
} catch (e) {
ElMessage.error(e?.response?.data?.message || '处置失败')
} finally {
dispositionSubmitting.value = false
}
}
四、后端 API 设计
4.1 Controller 层
WarnDispositionController.cs(file:///d:/HospitalSelfDiscipline/HospitalSelfDiscipline/Controllers/WarnDispositionController.cs#L9-L83) 提供三个端点:
| 方法 | 路由 | 功能 |
|---|---|---|
POST |
/api/warn-dispositions |
创建处置记录 |
GET |
/api/warn-dispositions |
分页查询处置列表 |
PUT |
/api/warn-dispositions/{id} |
更新处置信息 |
创建处置时,通过 JWT 自动获取当前操作人:
csharp
[HttpPost]
public async Task<ActionResult<ApiResponse<WarnDisposition>>> Create([FromBody] WarnDisposition disposition)
{
disposition.OperatorId = GetCurrentUserId();
disposition.DeptCode = GetCurrentUserDeptCode();
var created = await _service.CreateAsync(disposition);
return Ok(ApiResponse<WarnDisposition>.Success(created, "处置成功"));
}
设计要点: 不信任前端传入的操作人信息,从 JWT Token 中提取 userId 和 deptId Claim,防止伪造。
4.2 Service 层 --- 关联查询优化
WarnDispositionService.cs(file:///d:/HospitalSelfDiscipline/HospitalSelfDiscipline/Services/WarnDispositionService.cs#L56-L198) 的 GetListAsync 方法是一个典型的多表关联投影查询:
处置记录本身只存储 WarnRecordId,列表展示需要从 warn_record、warn_indicator、sys_department、sys_medical_group、warn_threshold 等关联表获取完整信息。使用 EF Core 的 Select 投影一次性获取所需字段:
csharp
var items = await query
.OrderByDescending(d => d.CreateTime)
.Skip((page - 1) * pageSize)
.Take(pageSize)
.Select(d => new
{
d.Id,
d.WarnRecordId,
indicatorName = _db.WarnIndicators
.Where(i => i.Id == _db.WarnRecords
.Where(r => r.Id == d.WarnRecordId)
.Select(r => r.IndicatorId).FirstOrDefault())
.Select(i => i.IndicatorName).FirstOrDefault(),
departmentName = _db.SysDepartments
.Where(dp => dp.DeptCode == _db.WarnRecords
.Where(r => r.Id == d.WarnRecordId)
.Select(r => r.DeptCode).FirstOrDefault())
.Select(dp => dp.DeptName).FirstOrDefault(),
// ... 其他字段
})
.ToListAsync();
4.3 Entity 设计
WarnDisposition.cs(file:///d:/HospitalSelfDiscipline/HospitalSelfDiscipline/Entities/WarnDisposition.cs#L6-L53) 实体与数据库表一一映射:
csharp
public class WarnDisposition
{
public long Id { get; set; }
public long WarnRecordId { get; set; } // 关联预警记录
public int DispositionType { get; set; } // 处置类型(0-5)
public string DispositionAction { get; set; } // 处置动作
public string? DispositionContent { get; set; } // 处置意见
public string? DispositionResult { get; set; } // 处置结果
public long OperatorId { get; set; } // 操作人
public string? DeptCode { get; set; } // 归属科室
// 后续跟进相关字段
public string? CheckCause { get; set; }
public sbyte? IsFurtherCheck { get; set; }
public string? FollowUpAction { get; set; }
public DateOnly? FollowUpDeadline { get; set; }
public sbyte? NextMonthStatus { get; set; }
public DateTime? CreateTime { get; set; }
}
WarnRecord.cs(file:///d:/HospitalSelfDiscipline/HospitalSelfDiscipline/Entities/WarnRecord.cs#L6-L93) 作为预警记录核心实体,包含了环比/同比的计算结果、阈值对比、处置状态、查询参数快照等完整信息:
csharp
public class WarnRecord
{
public long Id { get; set; }
public long IndicatorId { get; set; } // 预警指标
public long ScenarioId { get; set; } // 预警场景
public string WarnCode { get; set; } // 预警编号 (如 WARN-DRUG-202606-143022-0001)
public sbyte WarnLevel { get; set; } // 1=黄色 2=红色
public decimal CurrentValue { get; set; } // 当前值(增长率%)
public decimal ThresholdValue { get; set; } // 触发阈值
public decimal? GrowthRate { get; set; } // 增长率
public decimal? CurrentAmount { get; set; } // 当期金额
public decimal? PrevAmount { get; set; } // 上期金额(环比)
public decimal? PrevYearAmount { get; set; } // 去年同期金额(同比)
public string? CompareType { get; set; } // MOM / YOY
public sbyte? DispositionStatus { get; set; } // 处置状态
public DateTime? DispositionDeadline { get; set; } // 处置截止时间(7天)
public string? QueryParams { get; set; } // JSON快照,保存查询条件用于明细回溯
}
五、预警计算引擎 ------ 系统心脏
5.1 整体架构
┌──────────────────────────────────────────┐
│ Quartz Scheduler │
│ WarnCalculationJob (Cron: 0 0 * * * ?) │
│ 每小时执行一次 │
└──────────────────┬───────────────────────┘
│
▼
┌──────────────────────────────────────────┐
│ WarnCalculationService │
│ │
│ CalculateAllWarnings() ─────────────────┤
│ ├─ 遍历所有启用指标 (Status=1) │
│ ├─ 遍历每个指标的阈值配置 │
│ └─ ProcessIndicatorThreshold() │
│ ├─ DRUG_KEY_TOTAL → 重点药品合计 │
│ ├─ DRUG_KEY_NATION → 国家监控 │
│ ├─ DRUG_KEY_PROVINCE → 省监控 │
│ ├─ DRUG_KEY_BATCH1/2 → 批次监控 │
│ ├─ DRUG_KEY_JIANDE → 建德监控 │
│ ├─ DRUG_NEW_APPLY → 新药申请 │
│ ├─ DRUG_TURNOVER → 周转率 │
│ ├─ CONS_TEMP_APPLY → 临采申请 │
│ ├─ EQUIP_PURCHASE_BUDGET → 采购 │
│ └─ ...共 20+ 指标 │
└──────────────────┬───────────────────────┘
│
▼
┌──────────────────────────────────────────┐
│ CheckAndGenerateWarn() │
│ ├─ 阈值评估 (EvaluateThreshold) │
│ │ ├─ GT: currentValue > red → 红色 │
│ │ ├─ GT: currentValue > yellow → 黄色 │
│ │ ├─ LT: currentValue < red → 红色 │
│ │ └─ BETWEEN: 区间判定 │
│ ├─ 主键去重 (indicator + hospital + │
│ │ dept + group + month) │
│ ├─ 存在 → 更新级别和当前值 │
│ └─ 不存在 → 创建新记录 + 生成编号 │
└──────────────────────────────────────────┘
5.2 阈值评估算法
WarnCalculationService.cs(file:///d:/HospitalSelfDiscipline/HospitalSelfDiscipline/Services/WarnCalculationService.cs#L338-L361) 中的阈值评估支持三种比较模式:
csharp
private static sbyte EvaluateThreshold(decimal currentValue, WarnThreshold threshold)
{
var type = threshold.ThresholdType?.ToUpper() ?? "GT";
var yellow = threshold.YellowThreshold;
var red = threshold.RedThreshold;
switch (type)
{
case "GT": // 大于 → 适用于增长率指标
if (currentValue > red) return 2; // 红色预警
if (currentValue > yellow) return 1; // 黄色预警
break;
case "LT": // 小于 → 适用于设备使用率等
if (currentValue < red) return 2;
if (currentValue < yellow) return 1;
break;
case "BETWEEN": // 区间 → 在黄红之间触发
if (currentValue >= yellow && currentValue <= red) return 1;
if (currentValue > red) return 2;
break;
}
return 0; // 正常,不触发预警
}
5.3 预警编号生成
使用 Interlocked.Increment 原子计数器 + 时间戳 生成全局唯一预警编号:
csharp
private static int _warnSeq;
var seq = Interlocked.Increment(ref _warnSeq);
var timestamp = DateTime.Now.ToString("HHmmssfff");
var warnCode = $"WARN-{scenarioCode}-{warnMonth.Replace("-", "")}-{timestamp}-{seq:D4}";
// 示例: WARN-DRUG-202606-143022567-0001
相比传统的 Count() + 1 方案,Interlocked 保证了多线程并发场景下编号绝对不重复。
5.4 HIS 数据查询优化
预警计算的核心数据源是医院 HIS 系统的 v_zlxt_drug_usage_record 视图(PostgreSQL)。查询按医疗组 + 科室 + 药品属性三维聚合:
csharp
private async Task<List<GroupDeptAttrAggregation>> QueryHisDrugUsageByGroupDeptAttr(
DateTime startDate, DateTime endDate, long hospitalId, List<string> attrCodes)
{
_hisDb.Database.SetCommandTimeout(300); // 大数据量查询超时设置
var query = _hisDb.VDrugUsageRecords.AsQueryable();
query = query.Where(v => v.Jifeisj >= startDate && v.Jifeisj <= endDate);
if (attrCodes.Count > 0)
query = query.Where(v => attrCodes.Contains(v.Shuxingdm));
// 医疗组必须有值(过滤掉未分配医疗组的记录)
query = query.Where(v => v.Kaidanylzid != null && v.Kaidanylzid != "");
// 按医疗组+科室+药品属性分组
return await query
.GroupBy(v => new { v.Kaidanylzid, v.Kaidanylzmc,
v.Kaidanksid, v.Kaidanksmc, v.Shuxingdm })
.Select(g => new GroupDeptAttrAggregation
{
GroupHisId = g.Key.Kaidanylzid,
GroupName = g.Key.Kaidanylzmc ?? "",
DeptHisId = g.Key.Kaidanksid?.ToString() ?? "",
DeptName = g.Key.Kaidanksmc ?? "",
AttrCode = g.Key.Shuxingdm,
TotalAmount = g.Sum(v => v.Jine)
})
.Where(a => a.TotalAmount > 0) // HAVING SUM(jine) > 0
.ToListAsync();
}
查询性能要点:
- 设置 300s 超时,避免 HIS 视图查询超时异常
- 数据库端完成聚合(
GROUP BY+SUM),减少网络传输 - 增加
WHERE TotalAmount > 0过滤无效分组
5.5 环比/同比计算
以重点监控药品金额(DrugKeyMonitorGrowth)为例,一次查询获取三个时段的数据(本期、上月同期、去年同期),同时计算环比和同比:
csharp
private async Task CalculateGroupDeptGrowth(...)
{
foreach (var item in currentData)
{
var currentTotal = item.TotalAmount;
var prevTotal = prevData.FirstOrDefault(...)?.TotalAmount ?? 0;
var prevYearTotal = prevYearData.FirstOrDefault(...)?.TotalAmount ?? 0;
// 环比预警
if (prevTotal > 0)
{
var momGrowth = (currentTotal - prevTotal) / prevTotal;
await CheckAndGenerateWarn(indicator, threshold, ...,
currentAmount: currentTotal, prevAmount: prevTotal,
compareType: "MOM",
description: $"{groupLabel}[{groupName}] 环比增长: {momGrowth:P2}");
}
// 同比预警
if (prevYearTotal > 0)
{
var yoyGrowth = (currentTotal - prevYearTotal) / prevYearTotal;
await CheckAndGenerateWarn(indicator, threshold, ...,
currentAmount: currentTotal, prevYearAmount: prevYearTotal,
compareType: "YOY",
description: $"{groupLabel}[{groupName}] 同比增长: {yoyGrowth:P2}");
}
}
}
预警记录去重策略: 按 IndicatorId + HospitalId + DeptCode + MedicalGroupId + CompareType + WarnMonth 六元组作为业务主键。已存在的记录更新预警级别(可能从黄色升级到红色),不存在的创建新记录。
5.6 已办结复现机制
一个巧妙的设计:当某条预警已办结/已归档后,如果下月又触发相同维度的预警,系统自动将处置状态重置为"待处置"并重新设定 7 天处置截止时间:
csharp
if (existingRecord.DispositionStatus == 4 || existingRecord.DispositionStatus == 5)
{
existingRecord.DispositionStatus = 0;
existingRecord.IsOvertime = 0;
existingRecord.DispositionDeadline = DateTime.Now.AddDays(7);
}
六、Quartz 定时调度
6.1 任务注册
在 Program.cs(file:///d:/HospitalSelfDiscipline/HospitalSelfDiscipline/Program.cs#L179-L187) 中注册 Quartz 服务并加载数据库中的任务配置:
csharp
builder.Services.AddQuartz(q =>
{
q.AddJobListener<JobExecutionListener>();
});
builder.Services.AddQuartzHostedService(options =>
{
options.WaitForJobsToComplete = true;
});
启动时从 sys_quartz_job 表读取启用的任务:
csharp
var jobConfigs = db.SysQuartzJobs.Where(j => j.Status == 1).ToList();
foreach (var jobConfig in jobConfigs)
{
var job = JobBuilder.Create<DynamicJob>().WithIdentity(jobKey).Build();
ITrigger trigger = jobConfig.TriggerType == "cron"
? TriggerBuilder.Create().WithCronSchedule(jobConfig.CronExpr).Build()
: TriggerBuilder.Create().WithSimpleSchedule(...).Build();
await scheduler.ScheduleJob(job, trigger);
}
6.2 核心任务
| 任务 | Cron | 说明 |
|---|---|---|
WarnCalculationJob |
0 0 * * * ? |
每小时执行一次预警计算 |
DepartmentSyncJob |
0 0 5 * * ? |
每天 5:00 从 HIS 同步科室 |
MedicalGroupSyncJob |
0 0 5 * * ? |
每天 5:00 从 HIS 同步医疗组 |
6.3 任务执行监听
通过 JobExecutionListener 记录每次任务执行的开始时间、结束时间、耗时、成功/失败状态到 sys_quartz_job_log 表,形成完整的调度审计日志。
七、前端路由与权限控制
router/index.js(file:///d:/HospitalSelfDiscipline/hospitalselfdiscipline.web/src/router/index.js) 使用 Vue Router 懒加载所有页面:
javascript
{
path: 'warn-dispositions',
name: 'WarnDispositions',
component: () => import('@/views/warn/WarnDispositions.vue')
}
路由守卫检查 localStorage 中的 JWT Token,未登录自动跳转登录页。菜单权限通过后端 sys_permission 表控制,前端布局根据接口返回的权限列表动态渲染侧边栏。
八、架构亮点总结
- 全维度预警覆盖 --- 药品(20+ 指标)、耗材(5 指标)、设备(5 指标),支持环比/同比双模式
- 阈值灵活配置 --- 支持 GT/LT/BETWEEN 三种比较类型,黄色/红色双级预警,可按医院、科室、医疗组粒度配置
- 查询参数快照 ---
WarnRecord.QueryParams存储为 JSON,支持回溯查询原始数据明细,无需重新计算日期范围 - 处置闭环管理 --- 待处置 → 核查中 → 已办结,超时自动标记,办结后复发自动重置
- 双数据库架构 --- MySQL 存业务数据,PostgreSQL 只读 HIS 视图,计算时批量聚合减少跨库查询
- 定时计算 + 前端轮询 --- 每小时自动计算,前端按需查询,成本可控
- 并发安全 ---
Interlocked.Increment保证编号唯一性,EF Core 投影查询减少数据传输
九、结语
医院自律端系统通过 ASP.NET Core + Vue 3 + Quartz.NET 的技术组合,实现了一套完整的医院内部监管预警体系。预警处置模块作为整个闭环的"最后一公里",其设计兼顾了操作便捷性与数据完整性。本文的源码分析希望能为类似场景的全栈开发提供参考。