简介C#医院HIS系统源码包是一套完整的医疗信息系统学习与二次开发示例面向.NET开发者、计算机相关专业学生以及医疗信息化从业者。压缩包内共186个文件以C#源程序、Visual Studio解决方案、窗体资源文件、可执行程序和动态链接库为主同时带有数据库脚本、数据文件和备份文件便于还原数据库后直接运行查看效果。资源整体只有2.8MB部署门槛低适合个人电脑配置环境目前已有641人学习与下载。系统功能覆盖患者信息管理、门诊挂号、医生处方、财务管理、物资库存、报表统计以及用户权限控制等医院核心业务源码中界面按钮、图标、事件处理逻辑等素材齐全能帮助读者理解C#与SQL数据库的交互模式也可为课程设计、毕业设计或HIS项目二次开发提供基础框架。压缩包内还包含位图、图标等界面资源对了解WinForms界面布局和工具栏设计也有直接参考价值。1. C# 医院 HIS 系统从挂号到发药数据是怎么串起来的早上七点半的某医院门诊大厅是 HIS 系统压力最大的战场挂号窗口排队、收费窗口排队、药房窗口也在排队。这三个窗口背后不是三个独立软件而是同一套数据在流转——挂出的号要变成收费账单收费完成要触发药房发药中间任何一个环节断了患者就被卡在流程里。C# 写的医院 HIS 系统核心就是用一套数据库把门诊、收费、药房这些业务串起来让数据而不是人肉跑腿来推动流程。这篇笔记基于我拆过的一套 C# HIS 工程覆盖架构分层、核心表设计、挂号收费药房的事务边界以及实际开发中踩过的并发超卖、连接池耗尽、编码迁移这些坑适合需要用 C# 从零搭 HIS 的开发者或者想重构手头单体医疗系统的团队。2. 架构选型与数据建模先把边界划清代码才不会翻车2.1 C/S 还是 B/SWinForm、WPF 与数据访问层的取舍国内医院 HIS 大量跑在院内局域网终端是接了医保专线、读卡器、小票打印机的老机器所以 C/S 架构至今仍是主流。C/S 的优势是响应快、外设适配成熟、部署可控劣势是升级要逐台推客户端。如果你面对的是一家院区集中、网络环境统一的医院C/S 加三层架构是性价比最高的选择。B/S 适合多院区、需要远程访问的场景但医保接口和硬件外设适配在浏览器里做起来非常折腾一般只在部分查询类功能上用。UI 层的选型上我的习惯是收费、挂号这类高频业务终端用 WinForm开发效率高第三方控件生态成熟自助机、大屏展示这类带触屏需求的场景用 WPF样式和绑定能力更强。但不管 UI 层怎么选业务逻辑必须独立成层否则将来换 UI 框架等于重写整个系统。工程结构上按 DAL数据访问、BLL业务逻辑、UI界面拆三个项目DAL 里只放 SQL 和参数映射。一个典型的查询长这样// DAL/PatientDal.cs —— 数据访问层只负责 SQL 与实体映射 public class PatientDal { private readonly string _connStr; public PatientDal(string connStr) { _connStr connStr; } public PatientEntity GetByPatientId(string patientId) { const string sql SELECT PatientId, Name, Gender, BirthDate, IdCard FROM dbo.Patient WHERE PatientId PatientId;; using var conn new SqlConnection(_connStr); using var cmd new SqlCommand(sql, conn); cmd.Parameters.AddWithValue(PatientId, patientId); conn.Open(); using var reader cmd.ExecuteReader(CommandBehavior.SingleRow); if (!reader.Read()) return null; return new PatientEntity { PatientId reader.GetString(0), Name reader.GetString(1), Gender reader.GetString(2), BirthDate reader.GetDateTime(3), IdCard reader.GetString(4) }; } }这段代码里有几个细节新手容易忽略。第一所有查询参数必须走Parameters.AddWithValue不能拼接字符串。HIS 里患者姓名、身份证号都是高危输入SQL 注入一旦出问题就是医疗数据泄露的事故。第二CommandBehavior.SingleRow告诉驱动只取一行配合短连接策略能降低连接占用时间。第三using保证连接、命令、读取器在异常时也被释放这一步漏了后面就是连接池耗尽第四章会专门讲。数据访问层要不要上 ORM我的看法是EF Core 在这个场景里并不省心医疗业务的 SQL 复杂程度远超 CRUDEF Core 的迁移在带外键的生产库上容易出问题Dapper 或手写 SQL 反而更可控。HIS 的查询模式非常固定几乎都是按患者 ID、按时间范围、按科室查手写 SQL 的可读性和排查效率明显更好。提示连接字符串里建议加上Min Pool Size避免高峰期连接池冷启动导致的前几个请求格外慢。2.2 病人主索引与核心表设计一张 Patient 表撑不起 HIS很多初学者设计 HIS 时只建一张 Patient 表把挂号、收费、处方全塞进去结果就是字段爆炸、状态表达混乱、查询越写越慢。正确做法是分四层主数据患者、医生、科室、业务主表挂号、收费、处方、明细表收费明细、处方明细、流水表操作日志。患者主索引EMPI是 HIS 的核心。同一个患者可能用身份证、医保卡、就诊卡分别挂过号系统必须能把多条记录合并到同一个 PatientId 下。常见做法是单独建一张卡片映射表。下面是精简版建表脚本CREATE TABLE dbo.Patient ( PatientId NVARCHAR(32) NOT NULL PRIMARY KEY, Name NVARCHAR(50) NOT NULL, Gender CHAR(1) NOT NULL, BirthDate DATETIME NULL, IdCard NVARCHAR(18) NULL, Phone NVARCHAR(20) NULL, CreateTime DATETIME NOT NULL DEFAULT (GETDATE()), UpdateTime DATETIME NOT NULL DEFAULT (GETDATE()) ); CREATE TABLE dbo.PatientCard ( CardId NVARCHAR(32) NOT NULL PRIMARY KEY, PatientId NVARCHAR(32) NOT NULL, CardType TINYINT NOT NULL, -- 1就诊卡 2医保卡 3身份证 CardNo NVARCHAR(64) NOT NULL, CreateTime DATETIME NOT NULL DEFAULT (GETDATE()) ); CREATE TABLE dbo.DoctorSchedule ( ScheduleId INT IDENTITY(1,1) NOT NULL PRIMARY KEY, DeptId INT NOT NULL, DoctorId INT NOT NULL, DoctorTime DATETIME NOT NULL, Quota INT NOT NULL, -- 总号数 UsedCount INT NOT NULL DEFAULT (0), -- 已用号数 Status TINYINT NOT NULL DEFAULT (1) -- 1放号中 0停诊 ); CREATE TABLE dbo.Registration ( RegId BIGINT IDENTITY(1,1) NOT NULL PRIMARY KEY, PatientId NVARCHAR(32) NOT NULL, ScheduleId INT NOT NULL, RegNo INT NOT NULL, -- 号序 Status TINYINT NOT NULL DEFAULT (0), -- 0已挂号 1已就诊 2已退号 CreateTime DATETIME NOT NULL DEFAULT (GETDATE()) );表结构里有三个设计点值得说道。一是DoctorSchedule单独成表而不是把号源字段挂在医生表上因为排班是带日期维度的医生表只存静态信息。二是Registration的RegNo是号序由应用层生成配合后文要讲的唯一约束防止同号重复。三是所有状态字段都用TINYINT存数值而不是字符串避免已挂号、未就诊这类文案散落在代码里状态流转统一在 BLL 层判断。索引策略上HIS 的查询模式非常固定每张业务表建两到三个复合索引就够不要贪多。比如Registration上建(PatientId, CreateTime)用于查患者历史挂号(ScheduleId, RegNo)用于号序校验DoctorSchedule上建(DoctorId, DoctorTime)用于排班查询。覆盖索引就够用多余的索引只会拖慢写入。这里我要说一个血泪经验宁可多建两张关联表也不要在 Patient 主表上加冗余标志位。我见过有人为了少写一个 Join在 Patient 表上加IsRegisteredToday字段结果每天早上要跑批量任务清零任务一旦失败全院挂号全乱。业务状态永远放在业务表里主数据表只放稳定属性。3. 挂号、收费与药房库存三个模块的事务边界怎么设计3.1 挂号模块号源扣减与排队叫号的数据流转挂号流程名义上是插入一条挂号记录但真正的难点在号源扣减。上午 8 点到 9 点这个时段几十个窗口同时挂号如果先查UsedCount再更新两个事务会读到同一个值100 个号能挂出 120 个。解决方案是给号源行加锁让查余号、写挂号记录、扣号数三个动作在同一个事务里完成。public (bool ok, string msg) Register(string patientId, int scheduleId) { using var conn new SqlConnection(_connStr); conn.Open(); using var tx conn.BeginTransaction(IsolationLevel.ReadCommitted); try { // 1. 锁定号源行防止并发请求读到同一个余号 var lockSql SELECT Quota, UsedCount FROM dbo.DoctorSchedule WITH (UPDLOCK, ROWLOCK) WHERE ScheduleId ScheduleId;; int quota, used; using (var cmd new SqlCommand(lockSql, conn, tx)) { cmd.Parameters.AddWithValue(ScheduleId, scheduleId); using var reader cmd.ExecuteReader(CommandBehavior.SingleRow); if (!reader.Read()) return (false, 号源不存在); quota reader.GetInt32(0); used reader.GetInt32(1); } if (used quota) return (false, 该时段号源已挂满); // 2. 生成号序并插入挂号记录状态 0 已挂号未就诊 var regNo used 1; var insertSql INSERT INTO dbo.Registration (PatientId, ScheduleId, RegNo, Status, CreateTime) VALUES (PatientId, ScheduleId, RegNo, 0, GETDATE());; using (var cmd new SqlCommand(insertSql, conn, tx)) { cmd.Parameters.AddWithValue(PatientId, patientId); cmd.Parameters.AddWithValue(ScheduleId, scheduleId); cmd.Parameters.AddWithValue(RegNo, regNo); cmd.ExecuteNonQuery(); } // 3. 扣减可用号数 var updateSql UPDATE dbo.DoctorSchedule SET UsedCount UsedCount 1 WHERE ScheduleId ScheduleId;; using (var cmd new SqlCommand(updateSql, conn, tx)) { cmd.Parameters.AddWithValue(ScheduleId, scheduleId); cmd.ExecuteNonQuery(); } tx.Commit(); return (true, $挂号成功号序 {regNo}); } catch (Exception ex) { tx.Rollback(); LogError(Register, ex); return (false, 挂号失败请重试); } }这段代码里最关键的是WITH (UPDLOCK, ROWLOCK)。UPDLOCK 告诉 SQL Server 在读取这一行时直接申请更新锁而不是先读后升级ROWLOCK 把锁粒度限制在单行避免锁住整张排班表。这两个提示缺一不可——只加 UPDLOCK 不加 ROWLOCK高并发下粒度升级可能演变成锁表全院挂号全部排队。注意如果项目用了 ORM务必确认它生成的 SQL 是否真的带 UPDLOCK。有些 ORM 的悲观锁配置在部分驱动上会静默退化成普通查询号源超卖问题依旧存在。事务隔离级别用默认的 ReadCommitted 就够了不要为了安全升级到 Serializable性能代价大且这里用不到。返回给用户的错误信息只是文案真正的异常详情必须写日志否则运维排障时只能看到挂号失败请重试这种黑匣子。排队叫号的数据流转是挂号成功写入Registration后UI 层通过内存队列或 Redis 列表推送叫号事件诊室叫号屏订阅后显示请 3 号到 5 诊室。这部分不需要进事务消息丢了最多是叫号屏晚点刷新患者凭挂号单也能找到诊室所以别把消息推送和数据库事务绑在一起。3.2 门诊收费与退费账单状态机与异常处理收费模块的核心不是插入一条收费记录而是账单状态机的严格流转。我给收费主表定义了四个状态0 待支付、1 已支付、2 已退费、3 已作废。未支付的账单可以作废已支付的只能退费退费后不能再做任何操作。这条规则必须在 BLL 层强制校验不能只靠界面按钮控制。public bool Refund(long chargeId, string operatorId, string reason) { using var conn new SqlConnection(_connStr); conn.Open(); using var tx conn.BeginTransaction(); // 1. 读取当前状态并加行锁防止退费与收费并发 var statusSql SELECT Status FROM dbo.Charge WITH (UPDLOCK, ROWLOCK) WHERE ChargeId ChargeId;; int currentStatus; using (var cmd new SqlCommand(statusSql, conn, tx)) { cmd.Parameters.AddWithValue(ChargeId, chargeId); currentStatus (int)cmd.ExecuteScalar(); } if (currentStatus ! 1) { tx.Rollback(); return false; // 只有已支付状态才能走退费 } // 2. 主表状态改为已退费记录退费操作者和时间 var updateSql UPDATE dbo.Charge SET Status 2, RefundTime GETDATE(), RefundOperatorId OperatorId WHERE ChargeId ChargeId;; // 3. 插入退费流水财务对账靠这张表 var logSql INSERT INTO dbo.ChargeLog (ChargeId, Action, OperatorId, Reason, CreateTime) VALUES (ChargeId, N退费, OperatorId, Reason, GETDATE());; using (var cmd new SqlCommand(updateSql, conn, tx)) { cmd.Parameters.AddWithValue(ChargeId, chargeId); cmd.Parameters.AddWithValue(OperatorId, operatorId); cmd.ExecuteNonQuery(); } using (var cmd new SqlCommand(logSql, conn, tx)) { cmd.Parameters.AddWithValue(ChargeId, chargeId); cmd.Parameters.AddWithValue(OperatorId, operatorId); cmd.Parameters.AddWithValue(Reason, reason); cmd.ExecuteNonQuery(); } tx.Commit(); return true; }退费场景最容易翻车的是逆向流程的处理收费时账单要关联到开单医生和科室退费时这些关联不能删必须在ChargeLog里留痕。财务对账时如果发现退费记录和收费记录对不上多半是有人直接删了原账单而不是新增退费流水。所以我把不允许物理删除业务数据写进了开发规范HIS 里任何业务表只有 UPDATE 改状态没有 DELETE。3.3 药房库存扣减发药时才减库存药房模块最典型的错误是收费即减库存。表面看省事但患者可能交完费不去取药或者药房缺货需要换药这时库存已经减了得做复杂的回补逻辑。我的做法是收费只把处方单标记为待发药药房窗口扫码确认发药的那一刻才扣库存。public bool Dispense(long prescriptionId, int pharmacyId, string operatorId) { using var conn new SqlConnection(_connStr); conn.Open(); using var tx conn.BeginTransaction(); // 1. 校验处方单状态只有 2已收费待发药 才能发药 var checkSql SELECT Status FROM dbo.Prescription WHERE PrescriptionId PrescriptionId AND PharmacyId PharmacyId;; int status; using (var cmd new SqlCommand(checkSql, conn, tx)) { cmd.Parameters.AddWithValue(PrescriptionId, prescriptionId); cmd.Parameters.AddWithValue(PharmacyId, pharmacyId); status (int)cmd.ExecuteScalar(); } if (status ! 2) { tx.Rollback(); return false; } // 2. 加载处方明细逐条扣库存 var prescriptionItems LoadItems(prescriptionId); var sql UPDATE dbo.DrugStock SET StockQty StockQty - Qty WHERE DrugId DrugId AND PharmacyId PharmacyId AND StockQty Qty;; foreach (var item in prescriptionItems) { int affected; using (var cmd new SqlCommand(sql, conn, tx)) { cmd.Parameters.AddWithValue(DrugId, item.DrugId); cmd.Parameters.AddWithValue(PharmacyId, pharmacyId); cmd.Parameters.AddWithValue(Qty, item.Qty); affected cmd.ExecuteNonQuery(); } if (affected 0) { tx.Rollback(); return false; // 任一条库存不足则整体失败 } } // 3. 更新处方单状态为 3已发药 using (var cmd new SqlCommand( UPDATE dbo.Prescription SET Status 3, DispenseTime GETDATE(), DispenseOperatorId OperatorId WHERE PrescriptionId PrescriptionId;, conn, tx)) { cmd.Parameters.AddWithValue(PrescriptionId, prescriptionId); cmd.Parameters.AddWithValue(OperatorId, operatorId); cmd.ExecuteNonQuery(); } tx.Commit(); return true; }库存扣减用了条件更新WHERE StockQty Qty这一行同时承担了并发保护和库存校验两个职责即使两个窗口同时发同一盒药数据库也只会让一个 UPDATE 成功。库存不足时返回 0 行影响事务回滚处方单状态不变患者可以走换药流程而不留下脏数据。把减库存推迟到发药时刻带来的额外好处是收费台完全不需要感知库存。收费时不知道库存也能收费最终在发药窗口才拦截缺货。这看似把矛盾推后实际上符合医院业务——收费台无法实时掌握药房库存而药房窗口面对缺货可以当场改方。4. 常见问题与避坑五条把系统拖垮的真实教训4.1 号源超卖并发挂号让 100 个号变成 120 个现象某医院开通线上预约渠道第一天门诊量最大的专家号放号 100 个实际挂出 120 条记录多出来的 20 个患者到现场被告知没号投诉直接打到信息科。原因初版代码按先查余号再插入实现查询和插入之间没有事务保护。两个并发请求同时读到UsedCount99都认定还有号各自插了一条挂号记录实际号数就超了。解决把查号源、插挂号记录、扣号数包进一个事务读取排班行时加WITH (UPDLOCK, ROWLOCK)后到的请求必须等第一个事务提交后才能读行此时UsedCount已经是 100直接返回号源已挂满。另外补一条数据库层兜底约束ALTER TABLE dbo.Registration ADD CONSTRAINT UQ_Schedule_RegNo UNIQUE (ScheduleId, RegNo);即使应用层漏了锁数据库也会拒绝重复号序超卖变成报错而不是脏数据。4.2 收费成功但库存扣减失败事务边界划错位置现象门诊药房出现多张已收费但出不了药的处方单财务结算时发现收费记录存在但对应药房库存没有扣减记录。原因收费和发药被设计成两个独立事务收费提交成功但发药那边因为网络抖动连接断开库存扣减没执行也没有重试机制。更隐蔽的写法是有人在发药程序里先调收费接口确认、再扣库存两个操作中间没有事务保护。解决把事务边界划清楚——收费事务只做账单状态流转发药事务只做库存扣减和处方单状态更新。中间状态已收费待发药天然允许存在不要试图在代码里保证两个事务必须同时成功。药房后台加一个定时任务扫描超过 24 小时仍未发药的处方单自动提醒核对而不是假设一切正常。4.3 数据库连接池耗尽漏写 using 的代价现象系统上线一个月后每天上午 10 点左右全院收费窗口开始卡顿最慢的窗口转圈超过 10 秒重启服务就恢复第二天又复现。原因某个报表功能的数据访问方法没有写using连接没有及时归还连接池。池上限默认 100这个报表被高频调用后连接全被占满后续请求全部排队等超时。解决全局排查所有SqlConnection统一改成using var conn new SqlConnection(...)让连接在方法结束时自动释放。连接字符串里显式设置池参数Serveryour-server;DatabaseHIS;User Idhis;Passwordxxx; Max Pool Size200;Min Pool Size20;Connection Timeout15;Min Pool Size20让池子里始终保持 20 条热连接避开冷启动Connection Timeout15让卡死请求最多等 15 秒就报错而不是无限排队把故障放大成全院瘫痪。4.4 UI 线程假死数据库查询留在了 Click 事件里现象收费员点打印小票后窗口整个白屏鼠标移到窗体外再回来显示未响应几秒后恢复一天出现好几次。原因按钮的 Click 事件里同步执行收费记录查询和小票打印数据库查询在 UI 线程上运行期间 UI 无法处理消息循环表现就是假死。业务高峰期数据库慢一点假死时间就长一点。解决WinForm 的事件处理器用async void是标准做法但业务方法必须返回Task查询走异步 APIprivate async TaskChargeResult DoChargeAsync(string patientId, long regId) { await using var conn new SqlConnection(_connStr); await conn.OpenAsync(); var cmd new SqlCommand( SELECT Status FROM dbo.Charge WHERE ChargeId ChargeId;, conn); cmd.Parameters.AddWithValue(ChargeId, regId); var status (int)await cmd.ExecuteScalarAsync(); // 后续收费逻辑同样走异步 } private async void btnPrint_Click(object sender, EventArgs e) { await DoChargeAsync(...); // UI 线程在 await 期间不被阻塞 }注意不要在异步方法里用.Result或.Wait()阻塞等待那会重新把 UI 线程卡死。从那以后我要求所有涉及数据库的入口方法默认写成异步签名同步版本只留给后台任务。4.5 数据迁移中文乱码GBK 与 UTF-8 的编码之痛现象从老系统导出的患者姓名、地址在查询界面显示成乱码部分旧病历无法按姓名检索。原因老系统数据库用 GBK 编码导出文件转成 UTF-8 后导入程序却按默认编码读取中文字符全部错位。更隐蔽的是 SQL Server 里字段定义成VARCHAR而不是NVARCHAR中文字符被截断成单字节。解决导入程序用StreamReader显式指定编码读取源文件所有存储中文的字段统一NVARCHAR写入时加N前缀。如果源文件已经乱码唯一的后悔药是回到原始导出文件重新导入别试图在乱码数据上做字符替换那只会越补越乱。迁移完先抽样核对姓名、地址、诊断描述三类的原文和译文再开放线上查询。5. 进阶方向权限模型与审计日志的落地实现HIS 上线后真正让信息科头疼的不是业务功能而是权限和审计。医生能看哪些科室的患者、护士能不能改收费单、离职员工的账号什么时候停用这些如果靠口口相传的管理迟早出事。RBAC基于角色的访问控制是这里最稳妥的方案用户不直接关联权限而是关联角色角色关联权限集合。实操中建议把权限粒度控制到页面 按钮两级菜单级权限管能进哪个窗口按钮级权限管能不能点退费、能不能作废。数据库里三张主表加两张关联表User、Role、Permission各一张用户和角色、角色和权限各一张关联表。权限判断收敛到一个方法里不要在业务代码中到处写当前用户是不是管理员的散装判断。审计日志是另一个容易被拖到以后再说的模块。但在 HIS 里退费、作废、修改患者主索引信息这三类操作必须全量留痕。我的做法是做一个统一的 AuditHelper业务方法在关键节点调用它public class AuditHelper { private readonly string _connStr; public AuditHelper(string connStr) { _connStr connStr; } public void Write(string operatorId, string action, string targetType, string targetId, string detail) { const string sql INSERT INTO dbo.AuditLog (OperatorId, Action, TargetType, TargetId, Detail, CreateIp, CreateTime) VALUES (OperatorId, Action, TargetType, TargetId, Detail, CreateIp, GETDATE());; using var conn new SqlConnection(_connStr); using var cmd new SqlCommand(sql, conn); cmd.Parameters.AddWithValue(OperatorId, operatorId); cmd.Parameters.AddWithValue(Action, action); cmd.Parameters.AddWithValue(TargetType, targetType); cmd.Parameters.AddWithValue(TargetId, targetId); cmd.Parameters.AddWithValue(Detail, detail); cmd.Parameters.AddWithValue(CreateIp, GetClientIp()); conn.Open(); cmd.ExecuteNonQuery(); } }审计日志写入有个原则日志失败不能影响主业务。调用处包一层 try-catch日志方法内部吞掉异常并做本地文件兜底而不是往业务层抛。日志表只追加不修改前台按操作人和时间范围查询但界面不提供删除功能。想进一步提升日志价值可以加操作前后快照退费操作把退费前后的账单状态各记一次对账时直接看出谁改了什么。实现不复杂在写日志前先查一次目标记录把关键字段序列化成 JSON 放进 Detail 字段。上线前我习惯跑一遍权限矩阵核对每个角色、每个页面、每个按钮过一遍用测试账号逐项点。权限配置属于配错一次全院都知道的事尤其在退费和作废这类敏感操作上。从那以后我每次发布前都强制走一遍权限矩阵核对流程把审计日志的抽样检查也放进发布清单。这套 C# HIS 资源里含完整的数据库脚本和核心模块代码下载后照着把模块跑通等于提前把这些坑踩一遍希望帮到你。本文还有配套的精品资源点击获取
阅读完成 · 觉得有帮助?