☰
C#医院HIS系统开发实战:从表结构设计到并发控制与审计日志
2026/10/12 4:02:19 网站建设 项目流程

简介:这是一套基于C#与.NET框架开发的医院HIS系统源码,面向医疗信息化开发者、软件工程专业学生及需要二次开发的技术人员,可用于学习大型企业级信息系统的架构设计与业务实现。系统覆盖患者管理、医疗业务、财务管理、物资管理、报表统计、权限控制、系统集成与数据库设计等核心模块,采用SQL Server存储结构化医疗数据,并通过ADO.NET或Entity Framework完成数据交互,界面层可借助Windows Forms或WPF构建。压缩包共186个文件,约2.8MB,以cs源码、exe可执行程序、resx与resources资源文件、bmp与png图标素材、dll类库、xsd数据集及sql脚本为主,另含mdf与ldf数据库文件,便于快速部署运行。目前已有640人学习下载。读者可借此深入理解HIS各业务模块的代码组织方式、数据库表结构设计与权限控制思路,并在此基础上进行定制化开发与功能扩展。

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

一张住院结算单背后,藏着几十次数据库写入和状态流转。患者办入院时,床位状态从空闲变占用;医生开医嘱时,医嘱表新增记录并触发费用预记;护士执行医嘱时,执行状态回写;出院结算时,费用明细汇总、医保拆分、发票号生成。这些动作分散在不同科室、不同终端、不同时间点,却必须保证同一笔费用不被重复计、同一个床位不被两人同时占用。C#医院HIS系统要解决的,就是把这套高频、强一致、多角色协作的业务流程,用一套可维护的桌面或Web应用稳定跑起来。它适合两类人:一是刚接手院内信息系统维护、需要快速理解表结构和状态机的开发者;二是准备从零搭建中小型院内业务系统的技术负责人。下面按「先立住模型、再动手建表、最后排坑」的顺序展开。

2. 先定边界:HIS不是一个大模块,而是几条主线的组合

2.1 门诊、住院、收费三条主线怎么切

很多人第一次接触院内系统,容易把它想成一个巨大的单体。实际落地时,我一般按业务闭环切成三条主线:门诊主线负责挂号、就诊、开方、收费、发药;住院主线负责入院登记、床位分配、医嘱、执行、出院结算;收费主线则贯穿前两者,处理费用明细、支付方式、退费重结。三条主线共享患者主索引、科室字典、人员字典、收费项目字典这四张基础表。

切分的关键不是按科室切,而是按「一次业务动作产生哪些状态变更」切。比如挂号成功,患者主索引不变,但挂号表新增一条记录,排班表剩余号源减一。住院入院登记,患者主索引可能新增或更新,床位表状态变更,住院主表新增记录。把这些状态变更点列清楚,表结构自然就出来了。

常见做法是先用一张「业务动作-状态变更」对照表把需求过一遍,再决定哪些表需要加乐观锁字段,哪些操作必须放在同一个事务里。这一步偷懒,后面就会遇到「挂号成功了但号源没减」这类玄学问题。

2.2 为什么选C#而不是其他技术栈

院内系统对界面响应、打印控件、串口设备对接有硬需求。C#配合WPF或WinForms,在本地打印、读卡器、医保接口动态库调用上生态成熟,这是很多团队继续用它的现实原因。Web端可以用ASP.NET Core,桌面端用WPF,两者共享业务逻辑层和实体模型,减少重复代码。

选型时重点看三点:一是团队是否熟悉.NET生态,二是是否需要对接大量本地外设,三是部署环境是否允许安装运行时。如果这三条都满足,C#是稳妥选择。如果纯Web轻量场景,且没有本地设备对接需求,那另当别论。我一般会建议中小型院内系统用ASP.NET Core Web API做服务端,WPF做收费和护士站客户端,这样既保留本地打印能力,又方便后续扩展。

2.3 最小可用表结构:患者、挂号、医嘱、费用四张核心表

先给一个能跑通的最小表结构,字段只保留最关键的,方便理解关系。

-- 患者主索引表 CREATE TABLE Patient ( PatientId BIGINT PRIMARY KEY IDENTITY(1,1), PatientNo VARCHAR(32) NOT NULL UNIQUE, -- 院内唯一患者号 Name NVARCHAR(64) NOT NULL, IdCardNo VARCHAR(32) NULL, -- 证件号,脱敏存储 Gender TINYINT NOT NULL DEFAULT 0, -- 0未知 1男 2女 BirthDate DATE NULL, CreateTime DATETIME NOT NULL DEFAULT GETDATE(), RowVersion ROWVERSION -- 乐观锁 ); -- 挂号表 CREATE TABLE Registration ( RegId BIGINT PRIMARY KEY IDENTITY(1,1), PatientId BIGINT NOT NULL, DeptId INT NOT NULL, DoctorId INT NULL, RegType TINYINT NOT NULL, -- 1普通 2专家 3急诊 RegFee DECIMAL(10,2) NOT NULL, RegTime DATETIME NOT NULL DEFAULT GETDATE(), Status TINYINT NOT NULL DEFAULT 0, -- 0待就诊 1已就诊 2已退号 CONSTRAINT FK_Reg_Patient FOREIGN KEY (PatientId) REFERENCES Patient(PatientId) ); -- 医嘱表 CREATE TABLE MedicalOrder ( OrderId BIGINT PRIMARY KEY IDENTITY(1,1), PatientId BIGINT NOT NULL, OrderType TINYINT NOT NULL, -- 1长期 2临时 Content NVARCHAR(512) NOT NULL, DoctorId INT NOT NULL, CreateTime DATETIME NOT NULL DEFAULT GETDATE(), Status TINYINT NOT NULL DEFAULT 0, -- 0新开 1已审核 2已执行 3已停止 RowVersion ROWVERSION ); -- 费用明细表 CREATE TABLE ChargeDetail ( ChargeId BIGINT PRIMARY KEY IDENTITY(1,1), PatientId BIGINT NOT NULL, OrderId BIGINT NULL, ItemId INT NOT NULL, Quantity DECIMAL(10,2) NOT NULL, UnitPrice DECIMAL(10,2) NOT NULL, TotalAmount AS (Quantity * UnitPrice) PERSISTED, ChargeTime DATETIME NOT NULL DEFAULT GETDATE(), SettleStatus TINYINT NOT NULL DEFAULT 0 -- 0未结算 1已结算 2已退费 );

这四张表的关系是:患者是根,挂号、医嘱、费用都挂患者。医嘱可以产生费用,挂号本身也产生费用。RowVersion用于并发控制,TotalAmount用计算列避免应用层算错。实际项目里还会拆出科室字典、人员字典、收费项目字典,但核心关系就这些。

提示:ROWVERSION在SQL Server里是自动递增的二进制,EF Core里对应byte[]并标记IsRowVersion(),更新时会自动带上版本条件。

3. 把业务跑起来:从挂号到结算的C#实现路径

3.1 用EF Core映射表并处理并发冲突

先建实体和DbContext。重点看并发令牌和事务边界。

// 患者实体 public class Patient { public long PatientId { get; set; } public string PatientNo { get; set; } = string.Empty; public string Name { get; set; } = string.Empty; public byte[]? RowVersion { get; set; } } // DbContext配置 public class HisDbContext : DbContext { public DbSet<Patient> Patients => Set<Patient>(); public DbSet<Registration> Registrations => Set<Registration>(); public DbSet<MedicalOrder> MedicalOrders => Set<MedicalOrder>(); public DbSet<ChargeDetail> ChargeDetails => Set<ChargeDetail>(); protected override void OnModelCreating(ModelBuilder modelBuilder) { // 把RowVersion配成并发令牌 modelBuilder.Entity<Patient>() .Property(p => p.RowVersion) .IsRowVersion(); modelBuilder.Entity<MedicalOrder>() .Property(o => o.RowVersion) .IsRowVersion(); // 患者号唯一索引 modelBuilder.Entity<Patient>() .HasIndex(p => p.PatientNo) .IsUnique(); } }

IsRowVersion()告诉EF Core在UPDATE语句的WHERE里带上版本条件。如果两个护士同时改同一个患者信息,后提交的那个会抛DbUpdateConcurrencyException。捕获后一般提示用户刷新重试,而不是强行覆盖。

事务边界要按业务动作定。挂号动作包含「插入挂号记录」和「更新排班号源」两步,必须放同一个事务。医嘱开立包含「插入医嘱」和「插入费用预记」,也要同事务。EF Core里用using var tx = await db.Database.BeginTransactionAsync()包住即可。

3.2 挂号与退号的完整事务写法

public async Task<RegResult> RegisterAsync(long patientId, int deptId, int doctorId, byte regType) { await using var tx = await _db.Database.BeginTransactionAsync(); try { // 1. 校验患者存在 var patient = await _db.Patients.FindAsync(patientId) ?? throw new InvalidOperationException("患者不存在"); // 2. 校验号源(这里简化为查排班表,实际项目会有Schedule表) var schedule = await _db.Schedules .FirstOrDefaultAsync(s => s.DeptId == deptId && s.DoctorId == doctorId); if (schedule == null || schedule.RemainCount <= 0) throw new InvalidOperationException("号源不足"); // 3. 扣减号源,带并发检查 schedule.RemainCount -= 1; // 4. 插入挂号记录 var reg = new Registration { PatientId = patientId, DeptId = deptId, DoctorId = doctorId, RegType = regType, RegFee = GetRegFee(regType), Status = 0 }; _db.Registrations.Add(reg); // 5. 插入挂号费明细 _db.ChargeDetails.Add(new ChargeDetail { PatientId = patientId, ItemId = 1001, // 挂号费项目编码 Quantity = 1, UnitPrice = reg.RegFee, SettleStatus = 0 }); await _db.SaveChangesAsync(); await tx.CommitAsync(); return new RegResult { RegId = reg.RegId, Success = true }; } catch (DbUpdateConcurrencyException) { await tx.RollbackAsync(); return new RegResult { Success = false, Message = "号源已被占用,请刷新重试" }; } catch (Exception ex) { await tx.RollbackAsync(); return new RegResult { Success = false, Message = ex.Message }; } }

这段代码的关键点:号源扣减和挂号插入在同一个事务;SaveChangesAsync时如果排班记录的RowVersion变了,会抛并发异常;退号就是反向操作,把挂号状态改成2,号源加回,费用明细标记退费。退号必须校验是否已就诊,已就诊不能退。

参数说明:regType决定挂号费金额,实际项目里从收费项目字典查,不要硬编码。ItemId同理,用字典编码而不是写死数字。

3.3 医嘱状态机与费用联动

医嘱有明确的状态流转:新开→已审核→已执行→已停止。每次状态变更都要校验前置状态,不能跳步。

public async Task<bool> ExecuteOrderAsync(long orderId, int nurseId) { await using var tx = await _db.Database.BeginTransactionAsync(); try { var order = await _db.MedicalOrders.FindAsync(orderId) ?? throw new InvalidOperationException("医嘱不存在"); // 只有已审核的医嘱才能执行 if (order.Status != 1) throw new InvalidOperationException($"当前状态{order.Status}不允许执行"); order.Status = 2; // 已执行 // 执行后产生实际费用(如果之前是预记,这里改为正式记账) var charge = await _db.ChargeDetails .FirstOrDefaultAsync(c => c.OrderId == orderId && c.SettleStatus == 0); if (charge != null) { charge.ChargeTime = DateTime.Now; } await _db.SaveChangesAsync(); await tx.CommitAsync(); return true; } catch (DbUpdateConcurrencyException) { await tx.RollbackAsync(); throw new InvalidOperationException("医嘱已被他人修改,请刷新"); } }

状态校验放在业务层而不是数据库约束,是因为状态流转规则可能随科室调整。但数据库层面可以加CHECK约束兜底,防止非法值写入。费用联动要注意:长期医嘱按天计费,临时医嘱一次性计费,执行时才产生正式费用记录,避免预记阶段就结算。

注意:医嘱执行和费用记账必须同事务,否则会出现「医嘱执行了但费用没记」的对账差异,这类问题在月末对账时非常难查。

4. 避坑与排查:HIS开发中最容易翻车的五个点

4.1 并发挂号导致号源超卖

现象:两个窗口同时挂同一个医生的最后一个号,两边都成功,号源变成-1。原因:扣减号源时没有并发控制,两个事务都读到RemainCount=1,各自减一后写回。解决:给排班表加ROWVERSION,EF Core更新时自动带版本条件;或者在UPDATE语句里直接写SET RemainCount = RemainCount - 1 WHERE RemainCount > 0,用数据库原子操作。我一般两者都用,乐观锁兜底,原子更新提性能。

4.2 费用重复记账

现象:同一笔医嘱执行两次,费用明细出现两条。原因:执行接口没有幂等控制,网络重试或护士重复点击都会触发。解决:在费用明细表加OrderId + ItemId + SettleStatus的唯一过滤索引,或者执行前先查是否已有未结算记录。更稳妥的做法是给执行动作加一个业务流水号,用唯一约束防重。

4.3 退费后余额对不上

现象:患者退了一笔费用,但结算时总金额没减。原因:退费只改了费用明细状态,没有同步更新结算主表的汇总金额。解决:结算金额不要冗余存储,每次结算时从费用明细实时汇总;如果必须冗余,退费时用事务同时更新两张表,并加对账任务定期校验。

4.4 打印控件在64位环境失效

现象:收费票据打印在开发机正常,部署到64位服务器后报「ActiveX控件未注册」。原因:很多老打印控件是32位COM组件,64位进程加载不了。解决:把打印相关功能拆到独立的32位进程,通过进程间通信调用;或者改用纯托管打印方案,用PrintDocument自己排版。这个坑在对接旧设备时特别常见,血泪经验是尽早确认控件位数。

4.5 医保接口超时导致事务悬挂

现象:调用医保接口时网络超时,本地事务一直不提交,锁住患者记录。原因:把外部接口调用放在了数据库事务内部,接口不返回事务不结束。解决:先提交本地事务,再调外部接口,用状态字段标记「待确认」,接口返回后异步更新。如果必须同事务,设置合理的命令超时和重试策略,避免长时间持锁。

5. 进阶技巧:用状态快照和审计日志兜住生产问题

生产环境最怕的不是报错,而是「数据不对但不知道谁改的」。我一般会在核心表上加两张辅助表:状态快照表和操作审计表。状态快照表记录每次状态变更前的完整行数据,审计表记录操作人、操作时间、操作类型、变更前后关键字段。这样出问题时能快速定位是哪个环节写错了。

// 审计日志实体 public class AuditLog { public long Id { get; set; } public string TableName { get; set; } = string.Empty; public string RecordId { get; set; } = string.Empty; public string Action { get; set; } = string.Empty; // Insert/Update/Delete public string? BeforeJson { get; set; } public string? AfterJson { get; set; } public int OperatorId { get; set; } public DateTime OperateTime { get; set; } = DateTime.Now; } // 在SaveChanges前拦截变更 public override async Task<int> SaveChangesAsync(CancellationToken ct = default) { var logs = new List<AuditLog>(); foreach (var entry in ChangeTracker.Entries() .Where(e => e.State == EntityState.Modified || e.State == EntityState.Added)) { if (entry.Entity is AuditLog) continue; var log = new AuditLog { TableName = entry.Metadata.GetTableName() ?? "Unknown", Action = entry.State.ToString(), OperateTime = DateTime.Now }; if (entry.State == EntityState.Modified) { log.BeforeJson = JsonSerializer.Serialize( entry.OriginalValues.Properties.ToDictionary(p => p.Name, p => entry.OriginalValues[p])); log.AfterJson = JsonSerializer.Serialize( entry.CurrentValues.Properties.ToDictionary(p => p.Name, p => entry.CurrentValues[p])); } logs.Add(log); } AuditLogs.AddRange(logs); return await base.SaveChangesAsync(ct); }

这段拦截逻辑放在DbContext里,所有通过EF Core的变更都会自动记审计。注意两点:一是审计表本身不要被拦截,否则递归;二是BeforeJson和AfterJson序列化时排除导航属性,只记标量字段,避免循环引用和体积膨胀。

验证方法很简单:随便改一条患者信息,查审计表应该能看到变更前后记录。如果查不到,检查ChangeTracker是否被AsNoTracking影响。另外,审计日志会快速增长,建议按月分表或定期归档,查询时按TableName + RecordId + OperateTime建索引。

还有一个实用技巧:给关键业务表加LastModifiedTime和LastModifiedBy字段,配合审计表使用。日常排查先看这两个字段缩小范围,再查审计表看具体变更内容。这样比直接翻全量日志快得多。

我自己的习惯是,每接手一个院内系统,先花半天把核心表的审计和快照配好,再动业务代码。看起来慢,但后面每次对账差异都能省下大量排查时间。希望帮到你。

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

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

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

立即咨询