☰
C#医院HIS系统开发实战:从业务建模到医保接口的避坑指南
2026/10/14 16:12:07 网站建设 项目流程

简介:这是一套基于C#与.NET框架开发的医院HIS系统源码,面向医疗信息化开发者、计算机专业学生及需要二次开发的技术人员,可用于学习大型企业级管理系统的架构设计与业务实现。系统覆盖患者管理、挂号诊断、电子处方、财务收费、物资库存、报表统计、权限控制及数据库设计等核心模块,并预留与EMR、RIS、PACS等系统的集成接口,适合作为课程设计、毕业设计或实际项目的参考蓝本。资源包共186个文件,约2.8MB,以cs源码、exe可执行程序、resx与resources资源文件、bmp图标素材、dll类库及sql脚本、mdf数据库文件为主,另含sln解决方案与csproj工程文件,便于直接编译运行和调试。目前已有640人学习下载。通过研读源码,读者可掌握ADO.NET或Entity Framework数据访问、WinForms界面构建、RBAC权限模型以及关系型数据库表结构设计等实用技能,快速理解HIS各业务模块的协作方式,为定制化开发打下基础。

1. 从一张住院结算单说起:C#医院HIS系统到底在解决什么问题

如果你在医疗信息化行业待过,大概率见过这样的场景:护士站一台老终端上,住院结算单打印到一半卡住了,患者家属在窗口排队,收费员反复刷新界面,后台日志里一堆超时异常。这类问题十有八九不是打印机坏了,而是HIS系统在高并发写入时的事务锁没处理好。C#医院HIS系统,说的就是用C#/.NET技术栈去构建医院信息系统(Hospital Information System),覆盖门诊挂号、医生站、护士站、收费、药房、住院管理、医保结算这些核心业务模块。它不是一个单机软件,而是一套要7×24小时跑在院内局域网、对接医保接口、连接检验检查设备、还要扛住早高峰门诊并发的中后台系统。

这篇文章面向两类人:一类是刚接手HIS模块开发、想知道从哪下手的C#工程师;另一类是在做医疗类项目选型、想搞清楚技术边界和落地成本的技术负责人。我会按「业务建模怎么拆 → 数据库怎么设计 → 核心模块怎么写 → 接口怎么对接 → 坑在哪」的顺序讲,每个环节给出可复现的代码和参数配置。不吹架构,只讲能跑起来的东西。

2. 业务建模与技术选型:为什么HIS不适合微服务一刀切

2.1 先拆清楚HIS的领域边界

HIS的业务复杂度不在算法,而在领域模型的耦合关系。一个患者从挂号到出院,数据流经挂号表、就诊记录、医嘱、处方、收费明细、发药记录、住院费用、医保结算,这些实体之间是强一致的。比如医生开了一条医嘱,护士执行后产生执行记录,收费系统要根据执行记录计费,医保结算又要根据费用明细拆分自付和统筹。如果把这些拆成独立微服务,每个跨服务调用都要处理分布式事务,在院内网络抖动时就是灾难。

我一般的做法是按「业务闭环」划分模块边界,而不是按技术分层。门诊一个闭环、住院一个闭环、药房一个闭环、医保结算一个闭环,闭环内部用单体或模块化单体,闭环之间通过消息队列做最终一致。这样既保留了事务完整性,又不会让一个模块的故障拖垮全院。

常见的模块划分是这样的:

模块核心职责事务要求
门诊管理挂号、分诊、退号、排班强一致
医生站医嘱、处方、诊断、病历强一致
护士站执行、体征、护理记录强一致
收费结算划价、收费、退费、发票强一致
药房管理发药、退药、库存、盘点最终一致
住院管理入出转、床位、费用强一致
医保接口上传、下载、对账最终一致

2.2 C#技术栈的选型理由

为什么用C#而不是Java或Go?在HIS这个场景里,C#有几个实际优势。第一,院内很多老系统是WinForm或WPF写的,C#能直接复用这些客户端资产,不用重写。第二,.NET Core之后的跨平台能力让服务端可以跑在Linux上,部署成本降下来了。第三,Entity Framework Core在处理复杂关系映射时比MyBatis省事,尤其是HIS这种表关系密集的场景。第四,SignalR做护士站实时提醒、叫号推送非常顺手。

服务端我一般选ASP.NET Core 6/8 + EF Core + SQL Server(或PostgreSQL)。如果医院预算有限,PostgreSQL完全够用,而且分区表和并行查询在费用明细这种大表上表现更好。缓存用Redis,消息队列用RabbitMQ或院内已有的MQ。前端如果是医生站,WPF或Blazor都可以;如果是移动查房,用MAUI。

// 典型的HIS服务端启动配置 var builder = WebApplication.CreateBuilder(args); // 注册EF Core,使用SQL Server builder.Services.AddDbContext<HisDbContext>(options => options.UseSqlServer( builder.Configuration.GetConnectionString("HisDb"), sqlOptions => { // 开启重试,应对院内网络抖动 sqlOptions.EnableRetryOnFailure( maxRetryCount: 3, maxRetryDelay: TimeSpan.FromSeconds(5), errorNumbersToAdd: null); // 命令超时设长一点,结算查询可能很慢 sqlOptions.CommandTimeout(120); })); // 注册Redis缓存 builder.Services.AddStackExchangeRedisCache(options => { options.Configuration = builder.Configuration.GetConnectionString("Redis"); options.InstanceName = "His:"; }); // 注册SignalR用于护士站实时提醒 builder.Services.AddSignalR(); var app = builder.Build(); app.MapControllers(); app.MapHub<WardHub>("/hubs/ward"); app.Run();

这段配置里几个参数值得说清楚。EnableRetryOnFailure的maxRetryCount设3次是经验值,设太多会导致重复提交问题,设太少扛不住网络抖动。CommandTimeout设120秒是因为住院费用汇总查询在数据量大时可能跑几十秒,默认30秒会直接超时。Redis的InstanceName前缀是为了避免和其他系统共用Redis时key冲突。

2.3 部署形态的选择

HIS的部署形态直接影响架构决策。院内私有化部署是主流,因为数据不能出院。这意味着你不能依赖云服务,所有中间件都要能本地跑。我一般会建议至少两台应用服务器做负载均衡,数据库主从或AlwaysOn,Redis哨兵模式。如果医院规模小,单机部署也能跑,但一定要做好备份。

注意:HIS系统的停机窗口极其有限,门诊时间绝对不能停。所有升级操作必须安排在夜间或周末,并且要有回滚方案。

3. 数据库设计:HIS的表结构怎么扛住门诊高峰

3.1 核心表设计与索引策略

HIS的数据库设计有个特点:读多写多,而且读写模式差异大。挂号表在早高峰写入密集,费用明细表在结算时查询密集,医嘱表则是读写都频繁。如果索引设计不合理,早高峰就是灾难。

先看患者主索引表,这是整个HIS的根表:

CREATE TABLE PatientMaster ( PatientId BIGINT IDENTITY(1,1) PRIMARY KEY, PatientNo VARCHAR(20) NOT NULL UNIQUE, -- 院内患者编号 Name NVARCHAR(50) NOT NULL, IdCardNo VARCHAR(18) NULL, -- 身份证号,加密存储 Gender TINYINT NOT NULL, -- 0未知 1男 2女 BirthDate DATE NULL, Phone VARCHAR(20) NULL, CreateTime DATETIME2 NOT NULL DEFAULT SYSDATETIME(), UpdateTime DATETIME2 NOT NULL DEFAULT SYSDATETIME() ); -- 身份证号查询是高频操作,建索引 CREATE INDEX IX_PatientMaster_IdCard ON PatientMaster(IdCardNo); -- 姓名+出生日期组合查询用于防止重复建档 CREATE INDEX IX_PatientMaster_Name_Birth ON PatientMaster(Name, BirthDate);

挂号表的设计要考虑号源并发:

CREATE TABLE Registration ( RegId BIGINT IDENTITY(1,1) PRIMARY KEY, PatientId BIGINT NOT NULL, DeptId INT NOT NULL, DoctorId INT NOT NULL, RegDate DATE NOT NULL, TimeSlot TINYINT NOT NULL, -- 1上午 2下午 QueueNo INT NOT NULL, -- 排队序号 RegType TINYINT NOT NULL, -- 1普通 2专家 3急诊 Status TINYINT NOT NULL, -- 0已挂号 1已就诊 2已退号 Fee DECIMAL(10,2) NOT NULL, CreateTime DATETIME2 NOT NULL DEFAULT SYSDATETIME(), CONSTRAINT FK_Reg_Patient FOREIGN KEY (PatientId) REFERENCES PatientMaster(PatientId) ); -- 关键索引:按日期+科室+医生查询号源 CREATE INDEX IX_Registration_Date_Dept_Doctor ON Registration(RegDate, DeptId, DoctorId, Status); -- 排队叫号查询 CREATE INDEX IX_Registration_Queue ON Registration(RegDate, DeptId, TimeSlot, QueueNo);

费用明细表是数据量最大的表,一天门诊量5000的医院,一年费用明细能到千万级。必须做分区:

-- 按结算月份做分区(SQL Server示例) CREATE PARTITION FUNCTION PF_ChargeMonth (DATE) AS RANGE RIGHT FOR VALUES ('2024-01-01','2024-02-01','2024-03-01','2024-04-01', '2024-05-01','2024-06-01','2024-07-01','2024-08-01', '2024-09-01','2024-10-01','2024-11-01','2024-12-01'); CREATE PARTITION SCHEME PS_ChargeMonth AS PARTITION PF_ChargeMonth ALL TO ([PRIMARY]); CREATE TABLE ChargeDetail ( ChargeId BIGINT IDENTITY(1,1), PatientId BIGINT NOT NULL, VisitId BIGINT NOT NULL, -- 就诊流水号 ItemId INT NOT NULL, -- 收费项目ID ItemName NVARCHAR(100) NOT NULL, Quantity DECIMAL(10,2) NOT NULL, UnitPrice DECIMAL(10,2) NOT NULL, Amount DECIMAL(10,2) NOT NULL, ChargeTime DATETIME2 NOT NULL, SettleStatus TINYINT NOT NULL DEFAULT 0, -- 0未结算 1已结算 PRIMARY KEY (ChargeId, ChargeTime) ) ON PS_ChargeMonth(ChargeTime);

分区的好处是历史数据查询不会拖慢当前月份的操作,而且可以做分区切换快速归档。

3.2 并发号源控制

门诊早高峰最典型的问题是号源超卖。两个窗口同时挂最后一个号,如果处理不当就会卖出两个。解决方案有两种:数据库悲观锁和Redis原子操作。

我一般用Redis做号源预扣,数据库做最终校验:

public async Task<RegResult> RegisterAsync(RegRequest request) { var slotKey = $"reg:slot:{request.DoctorId}:{request.RegDate}:{request.TimeSlot}"; // 用Redis的DECR原子操作预扣号源 var remaining = await _redis.StringDecrementAsync(slotKey); if (remaining < 0) { // 号源已满,回补并返回失败 await _redis.StringIncrementAsync(slotKey); return RegResult.Fail("号源已满"); } try { // 数据库事务写入挂号记录 using var tx = await _db.Database.BeginTransactionAsync(); // 再次校验数据库中的号源数量(防止Redis数据不一致) var dbCount = await _db.Registrations .CountAsync(r => r.DoctorId == request.DoctorId && r.RegDate == request.RegDate && r.TimeSlot == request.TimeSlot && r.Status != 2); if (dbCount >= request.TotalSlots) { await tx.RollbackAsync(); await _redis.StringIncrementAsync(slotKey); return RegResult.Fail("号源已满"); } var reg = new Registration { PatientId = request.PatientId, DeptId = request.DeptId, DoctorId = request.DoctorId, RegDate = request.RegDate, TimeSlot = request.TimeSlot, QueueNo = dbCount + 1, RegType = request.RegType, Status = 0, Fee = request.Fee }; _db.Registrations.Add(reg); await _db.SaveChangesAsync(); await tx.CommitAsync(); return RegResult.Ok(reg.RegId, reg.QueueNo); } catch (Exception ex) { // 异常时回补Redis号源 await _redis.StringIncrementAsync(slotKey); _logger.LogError(ex, "挂号失败"); throw; } }

这段代码的关键点是Redis预扣 + 数据库二次校验的双保险。Redis的DECR是原子操作,能挡住绝大部分并发;数据库的CountAsync是最终防线,防止Redis重启丢数据导致超卖。QueueNo用dbCount + 1生成,在事务内是安全的,但如果并发极高,可能出现序号重复,这时候需要加唯一约束兜底。

3.3 读写分离与缓存策略

HIS的查询压力主要在医生站调阅历史病历、收费处查询费用明细、医保对账。这些查询可以走只读副本。EF Core支持读写分离,通过拦截器或DbContext工厂实现:

// 读写分离的DbContext工厂 public class HisDbContextFactory { private readonly string _writeConn; private readonly string _readConn; public HisDbContextFactory(IConfiguration config) { _writeConn = config.GetConnectionString("HisDb_Master"); _readConn = config.GetConnectionString("HisDb_Slave"); } // 写操作使用主库 public HisDbContext CreateWriteContext() { var options = new DbContextOptionsBuilder<HisDbContext>() .UseSqlServer(_writeConn) .Options; return new HisDbContext(options); } // 读操作使用从库 public HisDbContext CreateReadContext() { var options = new DbContextOptionsBuilder<HisDbContext>() .UseSqlServer(_readConn) .Options; return new HisDbContext(options); } }

缓存策略上,字典表(科室、医生、收费项目)适合全量缓存到Redis,设置较长的过期时间。患者基本信息可以缓存,但要注意更新时失效。费用明细不建议缓存,因为实时性要求高。

注意:读写分离会带来主从延迟问题。挂号后立即查询可能查不到,因为从库还没同步。这类场景必须强制走主库。

4. 核心模块实现:医嘱、收费、医保接口怎么写

4.1 医嘱模块的状态机设计

医嘱是HIS里最复杂的业务对象,它有明确的状态流转:开立 → 审核 → 执行 → 完成/停止/作废。每个状态转换都有权限和业务规则约束。用状态机模式实现比一堆if-else可靠得多。

// 医嘱状态枚举 public enum OrderStatus { Created = 0, // 已开立 Audited = 1, // 已审核 Executing = 2, // 执行中 Completed = 3, // 已完成 Stopped = 4, // 已停止 Cancelled = 5 // 已作废 } // 医嘱状态机 public class OrderStateMachine { private static readonly Dictionary<OrderStatus, OrderStatus[]> AllowedTransitions = new() { { OrderStatus.Created, new[] { OrderStatus.Audited, OrderStatus.Cancelled } }, { OrderStatus.Audited, new[] { OrderStatus.Executing, OrderStatus.Stopped } }, { OrderStatus.Executing, new[] { OrderStatus.Completed, OrderStatus.Stopped } }, { OrderStatus.Completed, Array.Empty<OrderStatus>() }, { OrderStatus.Stopped, Array.Empty<OrderStatus>() }, { OrderStatus.Cancelled, Array.Empty<OrderStatus>() } }; public static bool CanTransition(OrderStatus from, OrderStatus to) { return AllowedTransitions.TryGetValue(from, out var allowed) && allowed.Contains(to); } public static void Transition(Order order, OrderStatus target, string operatorId) { if (!CanTransition(order.Status, target)) { throw new InvalidOperationException( $"医嘱状态不允许从 {order.Status} 转换到 {target}"); } // 记录状态变更日志,用于追溯 var log = new OrderStatusLog { OrderId = order.OrderId, FromStatus = order.Status, ToStatus = target, OperatorId = operatorId, OperateTime = DateTime.Now }; order.Status = target; order.UpdateTime = DateTime.Now; // log 由调用方加入DbContext } }

状态机的核心价值是把业务规则集中管理。AllowedTransitions字典定义了所有合法转换,新增状态时只改这一处。Transition方法里记录日志是必须的,医疗系统要求所有操作可追溯。

4.2 收费模块的事务处理

收费是HIS里对事务要求最高的模块。一次收费涉及:写收费明细、更新发票号、扣减库存(如果是药品)、更新患者账户余额。这些操作必须在一个事务里完成。

public async Task<ChargeResult> ChargeAsync(ChargeRequest request) { // 开启事务,隔离级别用ReadCommitted using var tx = await _db.Database.BeginTransactionAsync( IsolationLevel.ReadCommitted); try { // 1. 校验患者账户状态 var patient = await _db.PatientMasters .FirstOrDefaultAsync(p => p.PatientId == request.PatientId); if (patient == null) return ChargeResult.Fail("患者不存在"); // 2. 生成收费明细 var chargeDetails = new List<ChargeDetail>(); decimal totalAmount = 0; foreach (var item in request.Items) { // 从收费项目表获取最新价格,防止前端传错 var chargeItem = await _db.ChargeItems .FirstOrDefaultAsync(c => c.ItemId == item.ItemId); if (chargeItem == null) return ChargeResult.Fail($"收费项目 {item.ItemId} 不存在"); var amount = chargeItem.UnitPrice * item.Quantity; totalAmount += amount; chargeDetails.Add(new ChargeDetail { PatientId = request.PatientId, VisitId = request.VisitId, ItemId = item.ItemId, ItemName = chargeItem.ItemName, Quantity = item.Quantity, UnitPrice = chargeItem.UnitPrice, Amount = amount, ChargeTime = DateTime.Now, SettleStatus = 0 }); } // 3. 生成发票记录 var invoice = new Invoice { PatientId = request.PatientId, InvoiceNo = await GenerateInvoiceNoAsync(), TotalAmount = totalAmount, PayMethod = request.PayMethod, ChargeTime = DateTime.Now, Status = 1 // 已收费 }; _db.ChargeDetails.AddRange(chargeDetails); _db.Invoices.Add(invoice); // 4. 如果是药品,扣减库存 foreach (var item in request.Items.Where(i => i.IsDrug)) { var stock = await _db.DrugStocks .FirstOrDefaultAsync(s => s.DrugId == item.ItemId && s.BatchNo == item.BatchNo); if (stock == null || stock.Quantity < item.Quantity) return ChargeResult.Fail($"药品 {item.ItemId} 库存不足"); stock.Quantity -= item.Quantity; stock.UpdateTime = DateTime.Now; } await _db.SaveChangesAsync(); await tx.CommitAsync(); return ChargeResult.Ok(invoice.InvoiceNo, totalAmount); } catch (DbUpdateConcurrencyException) { await tx.RollbackAsync(); return ChargeResult.Fail("并发冲突,请重试"); } catch (Exception ex) { await tx.RollbackAsync; _logger.LogError(ex, "收费失败"); throw; } }

这段代码有几个关键设计。第一,价格从数据库读取而不是信任前端,防止篡改。第二,库存扣减在同一个事务里,用DbUpdateConcurrencyException捕获并发冲突。第三,发票号生成需要保证唯一,通常用数据库序列或Redis原子递增。

4.3 医保接口对接的幂等设计

医保接口是HIS里最让人头疼的部分。医保中心要求每笔交易有唯一交易号,重复上传会被拒绝,但网络超时又必须重试。所以幂等设计是必须的。

public async Task<InsuranceResult> UploadSettlementAsync( long settleId, string insuranceJson) { // 生成幂等键:结算ID + 操作类型 var idempotentKey = $"insurance:upload:{settleId}"; // 检查是否已经上传成功 var cached = await _redis.StringGetAsync(idempotentKey); if (cached.HasValue) { return InsuranceResult.Ok(cached.ToString()); } // 加分布式锁,防止并发重复上传 var lockKey = $"lock:{idempotentKey}"; var lockToken = Guid.NewGuid().ToString(); var acquired = await _redis.LockTakeAsync( lockKey, lockToken, TimeSpan.FromSeconds(30)); if (!acquired) { return InsuranceResult.Fail("操作进行中,请稍后重试"); } try { // 再次检查(双重检查) cached = await _redis.StringGetAsync(idempotentKey); if (cached.HasValue) return InsuranceResult.Ok(cached.ToString()); // 调用医保接口 var response = await _insuranceClient.UploadAsync(insuranceJson); if (response.Success) { // 缓存成功结果,有效期24小时 await _redis.StringSetAsync( idempotentKey, response.TradeNo, TimeSpan.FromHours(24)); // 更新本地结算状态 var settle = await _db.InsuranceSettles .FirstOrDefaultAsync(s => s.SettleId == settleId); if (settle != null) { settle.UploadStatus = 1; settle.InsuranceTradeNo = response.TradeNo; settle.UploadTime = DateTime.Now; await _db.SaveChangesAsync(); } } return response.Success ? InsuranceResult.Ok(response.TradeNo) : InsuranceResult.Fail(response.Message); } finally { await _redis.LockReleaseAsync(lockKey, lockToken); } }

幂等设计的核心是「先查缓存,再加锁,再双重检查」。idempotentKey用结算ID保证唯一性,缓存成功结果24小时是为了覆盖医保对账周期。分布式锁防止并发重复提交,锁超时30秒是因为医保接口响应通常不超过10秒。

注意:医保接口的超时重试必须用相同的交易号,否则医保中心会认为是两笔交易。交易号一般用结算ID生成,不要用时间戳。

5. 避坑指南:HIS开发中最容易翻车的五个地方

5.1 坑一:浮点数算钱导致对账差几分钱

现象:收费系统和医保对账时,总金额差几分钱,查半天查不出原因。

原因:用了float或double存金额,浮点运算有精度损失。比如0.1 + 0.2在浮点数里不等于0.3。

解决:所有金额字段用decimal,C#里用decimal类型,数据库用DECIMAL(10,2)。EF Core配置时显式指定精度:

modelBuilder.Entity<ChargeDetail>() .Property(c => c.Amount) .HasColumnType("decimal(10,2)");

计算时避免中间步骤四舍五入,最后一步再舍入。医保接口传参时用字符串而不是数字,防止序列化精度丢失。

5.2 坑二:号源超卖在压力测试时才暴露

现象:功能测试正常,压测时发现号源卖出超过总数。

原因:只用了数据库Count校验,没有加锁。两个请求同时Count都返回未满,然后都插入。

解决:Redis预扣 + 数据库唯一约束兜底。在Registration表上加唯一索引:

CREATE UNIQUE INDEX UX_Registration_Slot ON Registration(DoctorId, RegDate, TimeSlot, QueueNo);

这样即使并发插入,数据库也会拒绝重复的QueueNo,应用层捕获异常后重试。

5.3 坑三:医嘱执行后收费漏计费

现象:护士执行了医嘱,但收费系统没有生成费用。

原因:医嘱执行和计费是两个独立操作,中间可能失败。比如执行记录写入了,但计费服务调用超时。

解决:用本地消息表 + 定时补偿。执行医嘱时,在同一个事务里写入一条待计费消息,然后异步处理计费。定时任务扫描未处理的消息重试:

// 执行医嘱时写入待计费消息 var executeLog = new OrderExecuteLog { ... }; var chargeMessage = new PendingChargeMessage { OrderId = order.OrderId, PatientId = order.PatientId, Status = 0, // 待处理 CreateTime = DateTime.Now }; _db.OrderExecuteLogs.Add(executeLog); _db.PendingChargeMessages.Add(chargeMessage); await _db.SaveChangesAsync(); // 后台服务定时处理 public class ChargeCompensationService : BackgroundService { protected override async Task ExecuteAsync(CancellationToken ct) { while (!ct.IsCancellationRequested) { using var scope = _scopeFactory.CreateScope(); var db = scope.ServiceProvider.GetRequiredService<HisDbContext>(); var pending = await db.PendingChargeMessages .Where(m => m.Status == 0 && m.CreateTime < DateTime.Now.AddMinutes(-1)) .Take(100) .ToListAsync(ct); foreach (var msg in pending) { try { await _chargeService.GenerateChargeAsync(msg.OrderId); msg.Status = 1; } catch { msg.RetryCount++; if (msg.RetryCount > 5) msg.Status = 2; // 标记失败 } } await db.SaveChangesAsync(ct); await Task.Delay(TimeSpan.FromSeconds(30), ct); } } }

5.4 坑四:医保接口超时后重复上传被拒

现象:医保上传超时,重试时医保中心返回「交易已存在」。

原因:第一次上传实际成功了,只是响应超时。重试时用了新的交易号。

解决:交易号必须幂等,用结算ID生成,不要用时间戳。重试前先查询医保中心的交易状态,确认未成功再重传。缓存成功结果,避免重复调用。

5.5 坑五:数据库连接池耗尽导致全院卡死

现象:早高峰时系统突然变慢,所有操作都超时,重启后恢复。

原因:某个慢查询占用了大量连接,连接池被耗尽。或者代码里有连接泄漏,DbContext没有释放。

解决:第一,所有DbContext用using或依赖注入管理生命周期,不要手动new。第二,设置连接池上限和超时:

Server=.;Database=HisDb;Max Pool Size=200;Connection Timeout=30;

第三,监控慢查询,超过5秒的查询记录日志并优化。第四,关键查询加AsNoTracking(),减少内存占用。

6. 进阶技巧:用EF Core拦截器做审计和性能监控

HIS系统对审计的要求极高,每一笔数据的变更都要能追溯到人。手动在每个SaveChanges里写日志太累,用EF Core的拦截器可以自动完成。

// 审计拦截器:自动记录所有实体的变更 public class AuditInterceptor : SaveChangesInterceptor { private readonly ICurrentUserService _currentUser; public AuditInterceptor(ICurrentUserService currentUser) { _currentUser = currentUser; } public override async ValueTask<InterceptionResult<int>> SavingChangesAsync( DbContextEventData eventData, InterceptionResult<int> result, CancellationToken ct = default) { var context = eventData.Context; if (context == null) return result; var auditLogs = new List<AuditLog>(); foreach (var entry in context.ChangeTracker.Entries()) { // 只记录需要审计的实体 if (entry.Entity is not IAuditable) continue; if (entry.State == EntityState.Unchanged) continue; var log = new AuditLog { TableName = entry.Entity.GetType().Name, RecordId = GetPrimaryKeyValue(entry), ActionType = entry.State.ToString(), OperatorId = _currentUser.UserId, OperatorName = _currentUser.UserName, OperateTime = DateTime.Now, IpAddress = _currentUser.IpAddress }; // 记录变更前后的值 if (entry.State == EntityState.Modified) { var changes = new Dictionary<string, object>(); foreach (var prop in entry.Properties) { if (prop.IsModified) { changes[prop.Metadata.Name] = new { Old = prop.OriginalValue, New = prop.CurrentValue }; } } log.ChangeContent = JsonSerializer.Serialize(changes); } auditLogs.Add(log); } if (auditLogs.Any()) { context.Set<AuditLog>().AddRange(auditLogs); } return result; } private string GetPrimaryKeyValue(EntityEntry entry) { var key = entry.Metadata.FindPrimaryKey(); if (key == null) return string.Empty; var values = key.Properties .Select(p => entry.Property(p.Name).CurrentValue?.ToString()) .Where(v => v != null); return string.Join(",", values); } } // 注册拦截器 builder.Services.AddDbContext<HisDbContext>((sp, options) => { var interceptor = sp.GetRequiredService<AuditInterceptor>(); options.UseSqlServer(connectionString) .AddInterceptors(interceptor); });

这个拦截器的价值在于:所有继承IAuditable接口的实体,任何变更都会自动记录到AuditLog表,包括操作人、时间、IP、变更前后的值。医保飞检、院内审计、问题追溯都靠这个。

性能监控拦截器也很有用,记录每个SQL的执行时间,超过阈值的告警:

public class SlowQueryInterceptor : DbCommandInterceptor { private readonly ILogger<SlowQueryInterceptor> _logger; private readonly int _thresholdMs; public SlowQueryInterceptor( ILogger<SlowQueryInterceptor> logger, int thresholdMs = 1000) { _logger = logger; _thresholdMs = thresholdMs; } public override async ValueTask<DbDataReader> ReaderExecutedAsync( DbCommand command, CommandExecutedEventData eventData, DbDataReader result, CancellationToken ct = default) { if (eventData.Duration.TotalMilliseconds > _thresholdMs) { _logger.LogWarning( "慢查询 {Duration}ms: {Sql}", eventData.Duration.TotalMilliseconds, command.CommandText); } return result; } }

我一般把阈值设在1000毫秒,超过就记警告。上线后观察一周,把频繁出现的慢查询逐个优化。常见的优化手段是加索引、拆分查询、用AsNoTracking、避免Include过多关联。

最后说个血泪经验:HIS系统的测试环境永远模拟不出生产环境的并发量。我一般会在上线前用JMeter或wrk做一轮压测,重点压挂号、收费、医嘱执行这三个接口。压测时把数据库连接池、Redis连接数、线程池都调到生产配置,否则压出来的数据没参考价值。还有,压测数据要定期清理,不然测试库会被垃圾数据撑爆。

希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询