简介:面向C#开发者与医疗信息化学习者的医院HIS系统完整源码包,覆盖患者管理、医疗业务、财务、物资、报表统计等核心模块,并配有数据库文件,可快速部署运行。压缩包共186个文件,约2.8MB,文件以C#源程序(cs)、可执行程序(exe)、界面资源(resx)、图标位图(bmp/ico)为主,另含SQL脚本、MDF数据库备份及XSD数据集定义,结构清晰,便于按模块查阅;其中数据库备份可直接还原,省去环境搭建时间。已有641人学习下载。读者可借此熟悉HIS系统从数据库设计到界面交互的完整链路,理解C#结合Windows Forms开发企业级信息系统的分层思路,学习ADO.NET数据访问、RBAC权限控制、报表导出等实现方式;同时,源码具备二次开发基础,适合作为相关课程设计、毕业设计或中小型医院信息化的改造参考。无论用于教学还是产品原型都足够扎实。
1. C#医院HIS系统:一套能跑通门诊全流程的医疗信息管理源码
一个门诊窗口在高峰期每秒要处理七八次挂号、缴费和退号操作,号源一旦并发写错,患者可能拿着同一张就诊卡出现在两个诊室门口。C#医院HIS系统就是针对这类场景设计的一套完整业务系统:从患者建档、门诊挂号、医生开方、收费结算到药房发药,数据在同一条链路里闭环流转,而不是一堆互相孤立的页面。它适合正在接触医疗信息化、想弄懂医院核心流程如何落地的开发者,也适合接手内网项目但没有完整业务参考的团队。拿到这套源码,你能看到挂号、处方、库存、结算如何通过数据库事务和表约束互相咬合,照着一遍遍调试下来,门诊业务的基本盘基本就有底了。
2. 技术选型与整体架构:.NET + SQL Server 驱动的分层业务闭环
2.1 技术栈选择:为什么医疗内网项目偏爱C#与SQL Server
医院信息系统的业务特点是强事务、高约束、内网部署。拆这套源码时最直接的感受是:C# + .NET Framework + SQL Server 的组合在Windows内网环境下几乎没有额外的运行时成本,ADO.NET天然和SQL Server的类型系统对齐,decimal字段不会在映射过程中被悄悄变成double。
具体到这套系统,Web端用的是ASP.NET MVC,没有引入前后端分离框架,页面是Razor视图加jQuery。这种做法在小规模医院内网里非常务实——不需要Node环境,不需要前端构建,IIS发布后直接能用,老版本浏览器也能正常打开。数据访问层没有用ORM主导,而是以ADO.NET加存储过程为主。这不是技术倒退,而是医疗项目里存储过程方便做权限隔离、方便DBA在数据库端排查性能问题,也方便在变更时只更新数据库而不重新发布整个站点。
还有一点值得注意:这套系统故意把事务控制放在业务层,不在控制器里写事务。控制器只负责参数校验和视图组装,所有开处方、收费、发药这类跨表操作都放进Service方法,内部用SqlTransaction统一管理。这样做的直接好处是数据库连接生命周期可控制,一个方法里多张表的写操作要么全成功要么全回滚,不会出现"处方保存了但药品明细丢了一半"这种尴尬情况。
2.2 三层架构拆解:UI、BLL、DAL的边界与依赖方向
整个解决方案按五个工程拆分:HIS.Models放实体和DTO,HIS.DAL负责SQL执行与结果映射,HIS.BLL承载业务规则和事务,HIS.Web是MVC站点(控制器、视图、脚本),HIS.Common放日志、加密、Session认证等公共能力。依赖方向是单向的:HIS.Web引用BLL,BLL引用DAL,DAL只引用Models,谁也不能反向引用。
这种边界在实际开发里的意义是:改数据库字段时只需要动Models和DAL,BLL的接口签名不变;改业务规则时只动BLL的方法体,Controller完全不用知道。接手这个项目时我优先看BLL层的RegisterService和ChargeService这两个文件,因为门诊核心链路的状态流转都集中在它们里面。
值得一提的是存储过程在这里相当于"第四层"。DAL方法通常只有两类:一类是几行ADO.NET调用存储过程并返回DataTable或实体集合,另一类是直接执行参数化SQL做简单查询。复杂查询(排班列表、医生日报、药品库存报表)统一走存储过程,这样开发人员不在代码里拼字符串SQL,SQL注入和字段拼写错误都被天然挡住了。
2.3 数据库设计:从患者主索引到医嘱明细的关联关系
这套系统数据库名是HISDB,核心表不多,但每张表职责都很明确。最关键的是患者主索引和就诊对象的贯穿:patients表以patient_id为主键,registrations表记录一次就诊,一个患者可以有多条就诊记录,一条就诊记录可以带出多个处方、一个处方对应多条明细。这样门诊全流程的数据都能从registration_id一路串联起来。
| 表名 | 用途 | 关键字段 |
|---|---|---|
| patients | 患者基本信息 | patient_id, name, id_card, phone |
| departments | 科室字典 | dept_id, dept_name |
| doctors | 医生信息 | doctor_id, name, dept_id |
| opd_schedule | 门诊排班号源 | schedule_id, doctor_id, visit_date, used_flag |
| registrations | 挂号记录(一次就诊) | registration_id, patient_id, schedule_id, status |
| prescriptions | 处方头 | prescription_id, registration_id, total_amount |
| prescription_items | 处方明细 | item_id, prescription_id, drug_id, quantity, amount |
| drug_stock | 药品库存 | drug_id, warehouse_id, stock_qty |
| charge_records | 收费流水 | charge_id, registration_id, bill_no, amount |
设计上有几个值得抄的点:金额字段统一decimal(18,2),不允许float;号源表用used_flag标记是否已占用,配合锁来控制并发;所有逻辑删除都带一列del_flag,做业务操作时默认只查del_flag = 0的数据。你会看到这些字段选择直接决定存储过程怎么写。
索引设计上,registrations表在patient_id和schedule_id上建非聚集索引,opd_schedule表在(visit_date, doctor_id)上建复合索引,因为这两类查询是门诊最高频的。外键方面,prescription_items和drug_stock之间没有强外键约束,而是通过drug_id做软关联,理由是药房换仓库、退药时不会因为外键约束卡住事务。这个设计一开始看起来像反模式,实际跑下来反而更灵活。
权限这块用的是Session + 自定义ActionFilter,登录后把用户ID和角色ID写进Session,每个控制器方法打上[AuthRequired]特性。系统管理模块维护用户表、角色表和菜单表,属于比较标准的RBAC模型。改菜单权限时不需要碰数据库结构,往菜单表里插一条记录再赋给角色就行。
3. 核心模块拆解:一次门诊就诊从挂号到药房发药的完整链路
3.1 挂号模块:号源锁与并发控制
挂号是门诊高峰时最容易翻车的环节。这套系统把号源放在opd_schedule表里,每个医生每个时段一行,used_flag = 0表示可挂。并发场景下,两个窗口同时点挂号,如果代码是"先查有没有空号、再判断、再UPDATE",大概率会有一次挂重。所以源码里的挂号操作直接封装成存储过程,用数据库锁来保证同一时刻只有一个事务能拿到这个号源:
CREATE PROCEDURE [dbo].[usp_RegisterTicket] @patientId INT, @scheduleId INT, @registerUserId INT, @registrationId INT OUTPUT AS BEGIN SET NOCOUNT ON; DECLARE @errorMsg NVARCHAR(200); SET @registrationId = 0; BEGIN TRANSACTION; SELECT @registrationId = id FROM dbo.opd_schedule WITH (UPDLOCK, ROWLOCK, HOLDLOCK) WHERE id = @scheduleId AND used_flag = 0 AND del_flag = 0; IF @registrationId = 0 BEGIN SET @errorMsg = N'号源已被占用,请刷新后重试'; ROLLBACK TRANSACTION; RAISERROR(@errorMsg, 16, 1); RETURN; END UPDATE dbo.opd_schedule SET used_flag = 1, register_time = GETDATE(), register_user_id = @registerUserId WHERE id = @scheduleId; INSERT INTO dbo.registrations(patient_id, schedule_id, status, create_time, create_user) VALUES (@patientId, @scheduleId, 1, GETDATE(), @registerUserId); SET @registrationId = SCOPE_IDENTITY(); COMMIT TRANSACTION; END这段存储过程的要点在WITH子句:UPDLOCK告诉SQL Server我要对选中的行加更新锁,HOLDLOCK让锁一直保持到事务结束。两个提示配合起来,相当于把号源行锁住,第二个并发事务会阻塞在这条SELECT上,等第一个提交后,它读到的used_flag已经是1,直接返回0,业务层据此给出"号源占用"提示。如果用的是默认读提交隔离级别,不加锁提示就会出现号源超卖。
参数方面,@patientId和@scheduleId来自挂号页面选择,@registerUserId从当前登录Session里取,@registrationId是输出参数,返回新建挂号记录的自增ID。如果患者档案不存在,插入registrations时外键会直接报错,这种场景不要用RAISERROR掩盖,让SQL Server抛外键错误反而更利于排查。
3.2 医生工作站:处方录入与事务包裹
医生工作站的核心是开处方。一次保存动作会同时写一张处方头和若干条明细,任何一条明细失败都不能留下半张处方。这里直接在BLL层用SqlTransaction把所有DAL调用包住:
public int SavePrescription(int registrationId, List<PrescriptionItem> items) { if (items == null || items.Count == 0) throw new ArgumentException("处方明细不能为空"); using (var conn = new SqlConnection(_connString)) using (var tx = conn.BeginTransaction()) { conn.Open(); try { var headerId = _dal.InsertPrescription(conn, tx, registrationId, DateTime.Now); decimal total = 0m; foreach (var item in items) { if (item.DrugId <= 0 || item.Quantity <= 0) throw new ArgumentException("药品或数量不合法"); if (item.Amount < 0) throw new ArgumentException("金额不能为负"); _dal.InsertPrescriptionItem(conn, tx, headerId, item); total += item.Amount; } // 金额重新回写处方头,不信任前端传过来的总价 _dal.UpdatePrescriptionTotal(conn, tx, headerId, total); tx.Commit(); return headerId; } catch { tx.Rollback(); throw; } } }这段代码有几个值得注意的设计:事务紧跟连接创建,绝不跨方法传递打开状态的连接;所有业务校验放在事务内、写库前,一旦发现非法数据立即抛异常回滚;处方总金额在后端重新累加并回写,前端传的total_price完全不信任。这三个习惯能挡住绝大多数"前台看着正常、后台数据库脏数据"的翻车现场。
如果你准备把这段代码改成支持医保预审,只需要在InsertPrescriptionItem之前调用医保接口,把返回的预审编码写进明细表新增列即可,事务边界不用动。
3.3 收费结算:金额计算与单据号生成
收费模块最大的坑是流水号重复。月底财务对账时发现两条单据号一样,基本都是因为用SELECT MAX(bill_no)+1生成单号。这套系统的做法是维护一张bill_seq序列表,用UPDATE语句原子地取号:
DECLARE @cur INT; UPDATE dbo.bill_seq WITH (ROWLOCK, UPDLOCK) SET @cur = current_no = current_no + 1 WHERE seq_key = 'OPD_CHARGE'; SELECT 'ZF' + CONVERT(VARCHAR(8), GETDATE(), 112) + RIGHT('000000' + CAST(@cur AS VARCHAR(6)), 6);UPDATE语句在设置current_no的同时把自增后的值赋给本地变量,锁粒度精细到一行,并发下两个会话不会拿到相同的号。单据号格式包含日期和六位序列,例如ZF20250613000042,方便财务按天对账。这里有一个细节:序列表的初始值和步长可以配置,如果某天单据量超过999999,需要提前把位数扩到8位或更换序列键。
收费金额计算放在BLL的ChargeService里,先累加明细金额,再根据患者类型打折,最后四舍五入到分。这里必须用Math.Round(value, 2, MidpointRounding.AwayFromZero),而不是默认的银行家舍入,否则某些金额会少一分钱。
3.4 药房发药:库存扣减与条件更新
发药时同样不能先查库存再更新,因为查询和更新之间永远存在时间窗口。源码里的扣减语句直接在UPDATE里带上库存条件,让数据库约束库存永远不为负:
UPDATE dbo.drug_stock WITH (ROWLOCK) SET stock_qty = stock_qty - @quantity WHERE drug_id = @drugId AND warehouse_id = @warehouseId AND stock_qty >= @quantity; IF @@ROWCOUNT = 0 BEGIN RAISERROR(N'库存不足,发药失败', 16, 1); RETURN; END注意UPDATE条件里的stock_qty >= @quantity,这一行是整个语句的灵魂。当库存不足时,UPDATE影响0行,通过@@ROWCOUNT判断就能立即报错。如果有多个药房窗口并发发同一盒药,数据库的行锁会串行化这些UPDATE,后到的那次读到已被扣减的库存,自然失败。这种方式比先查再用程序判断健壮得多。
发药成功后还要回写处方明细表的dispense_flag和发药人、发药时间,这一步要和上面的UPDATE放进同一个事务,否则药出了窗口但电脑上没有记录,月底盘库永远对不上。
4. 项目落地:源码结构、数据库初始化与本地运行
4.1 源码目录结构与模块映射
拿到源码先别急着编译,按目录把工程对应关系理清,后面调试会快很多。这套系统的解决方案结构如下:
HISSolution/ ├── HIS.Models/ # 实体与DTO │ ├── Entities/ │ └── DTO/ ├── HIS.DAL/ # 数据访问,全部参数化SQL │ ├── PatientDAL.cs │ ├── RegisterDAL.cs │ ├── PrescriptionDAL.cs │ ├── ChargeDAL.cs │ └── DrugDAL.cs ├── HIS.BLL/ # 业务规则与事务边界 │ ├── PatientService.cs │ ├── RegisterService.cs │ ├── PrescriptionService.cs │ └── ChargeService.cs ├── HIS.Web/ # MVC站点 │ ├── Controllers/ │ ├── Views/ │ ├── Content/ │ └── Web.config ├── HIS.Common/ # 日志、加密、Session ├── database/ │ ├── 01_init.sql # 建库建表 │ ├── 02_base_data.sql # 科室、医生、药品 │ └── 03_procedures.sql # 存储过程 └── docs/ └── 设计说明.md对照第2章的架构来看,Controllers里只做参数接收和视图返回,Service方法里才是真正的业务规则,DAL只负责和数据库打交道。调试时如果遇到"数据没保存成功",先看Service里有没有包事务;如果遇到"SQL语法报错",直接去database目录看对应脚本,而不是在C#代码里找字符串拼接。
4.2 数据库初始化:脚本顺序与基础数据
数据库脚本的执行顺序是固定的:先用SSMS或sqlcmd执行01_init.sql建库建表,再执行02_base_data.sql写入基础数据,最后执行03_procedures.sql创建存储过程。因为存储过程引用了表结构,顺序颠倒会直接报错,提示找不到对象。
基础数据包括科室字典(内科、外科、儿科)、若干医生账号、药品字典和初始库存、管理员账号。首次登录后第一件事就是改默认密码,这个坑几乎每个人都会踩。02脚本里药品库存既是字典又是库存表,初始库存数据要从零开始逐条插入,不能只插药品字典,否则挂号、开方都能走通,但到发药环节库存为0,全部报"库存不足"。
4.3 连接配置与本地调试
Web.config里的连接字符串是全局的单点配置,所有DAL类都从这里取:
<connectionStrings> <add name="HISDb" connectionString="Server=.;Database=HISDB;User Id=sa;Password=你的密码;MultipleActiveResultSets=true;" providerName="System.Data.SqlClient" /> </connectionStrings>Server=.表示本机默认实例,如果有命名实例写成Server=PCNAME\SQLEXPRESS。MultipleActiveResultSets=true允许一个连接上开多个DataReader,MVC页面加载多个下拉框时避免连接池耗尽。本地调试优先用SQL Server身份验证,不要用Integrated Security,因为IIS应用程序池的账号和Visual Studio调试账号往往不一致,容易出现"不能登录"这类让人误以为是代码问题的错误。
调试技巧:F5跑起来后,先用admin登录系统管理模块,给当前账号分配门诊角色的菜单权限,再进挂号页面。因为这套系统的权限过滤是请求级别的,角色没配菜单时页面能打开但接口会返回401,不是代码坏了。
4.4 部署到服务器:IIS与局域网访问
内网部署的步骤很固定,我一般按这个顺序操作:
- 在HIS.Web上右键发布到文件夹
- IIS新建网站,物理路径指向发布文件夹,应用程序池选集成模式
- 给网站绑定端口(如8081),目录浏览关闭
- 防火墙放行8081端口,内网其他机器用 http://服务器IP:8081 访问
- 把Web.config里的Server=.改为Server=数据库服务器IP,前提是网络和防火墙都打通
如果服务器是Windows Server,装IIS时记得勾选ASP.NET功能,否则站点起来直接报500.19或500.21错误,这是部署阶段最常见的坑。发布前还要检查Views目录是否包含预编译视图,如果启用了Razor预编译,发布时选"预编译此站点"选项,避免首次访问页面时编译超时。
5. HIS系统开发避坑指南:门诊并发、结算误差与数据回滚
5.1 一号多挂:并发挂号导致号源超卖
现象:用并发工具同时发起多个挂号请求,模拟50个线程抢同一个号源,数据库里出现多条used_flag = 1的排班记录。
原因:当时的实现是先查询排班状态,再UPDATE,两步之间没有锁,两个事务同时读到 unused,然后都执行了UPDATE。
解决:改为封装好的存储过程加UPDLOCK/HOLDLOCK提示,让锁粒度精确到行。改动后同样的压测场景只剩一个成功,其余返回"号源已被占用"。
5.2 发药后库存变成负数
现象:药房两个窗口同时给患者发同一种药,盘库时发现库存变成-2。
原因:发药代码先SELECT stock_qty判断大于0,再执行UPDATE减1,判断和更新的缝隙被另一个发药事务插入。
解决:UPDATE带上stock_qty >= @quantity条件,同时判断@@ROWCOUNT,等于0就抛异常回滚。这个写法从根上杜绝了负库存,比在C#代码里加lock更可靠。
5.3 结算金额对不上账
现象:同一个处方在A窗收费是102.35元,在B窗变成102.34元,月底对账怎么都对不上。
原因:数据库金额字段用float,SQL计算时累加顺序不同导致浮点误差。
解决:金额列统一改成decimal(18,2),C#代码里的金额类型全部用decimal,累加用Math.Round(value, 2, MidpointRounding.AwayFromZero)。改完再压测就稳定了。
5.4 收费单据号重复
现象:月底财务对账出现两条bill_no完全相同的收费记录,查日志发现两条记录生成时间差了不到一秒。
原因:单据号用SELECT MAX(bill_no)+1生成,两个并发事务同时查到了同一个最大值。
解决:引入bill_seq序列表,用UPDATE取号原子自增。这个方案不需要额外建SQL Server SEQUENCE,普通版数据库也能用。
5.5 Session超时导致医生开方内容丢失
现象:医生写一处方超过20分钟,点击保存被踢回登录页,之前输入的内容全没了。
原因:系统默认Session超时时间是20分钟,自定义过滤器检测到Session过期就跳登录页。
解决:Web.config里把SessionState超时调到60分钟,同时在处方编辑页加一个每10分钟一次的隐藏请求保持Session续期。有条件的话在服务端草稿箱落库,这样即使超时也不会丢内容。
6. 二次开发思路:从门诊模块扩展到住院与统计报表
6.1 扩展方向:从门诊延伸到住院与报表
这套系统的核心链路止于门诊,二次开发最自然的方向是住院模块。registrations表已经有status字段,可以扩展为门诊和住院两种就诊类型;住院医嘱复用prescription_items结构,增加一个医嘱执行状态列,就能接护士站的三查七对流程。报表系统则建议直接用存储过程聚合,导出Excel,不要在前端做复杂计算,因为医院财务对数据口径的追溯要求很高。
改造权限模型时,把现有用户-角色模型扩展成用户-角色-菜单-科室的四层结构,数据权限按dept_id过滤。这样住院护士只能看本科室病区,门诊医生只能看自己的号源,不需要改Controller代码,只在ActionFilter里加上科室范围判断即可。
6.2 一个具体技巧:用序列表封装挂号锁号操作
如果你要复用这套系统的并发控制思路,最值得带走的是"UPDATE取号"这个技巧。无论是号源、单据号还是库存扣减,只要涉及同一资源的并发写操作,都可以用UPADTE带条件来实现原子操作。
CREATE TABLE dbo.bill_seq ( seq_key VARCHAR(50) PRIMARY KEY, current_no INT NOT NULL );业务层调用时,把取号和业务写入放进同一个事务。取号过程不要单独提交事务,否则单据号会出现空洞,财务对账时会问东问西。
从那以后,我每次拿到任何一套HIS源码,都会先做三件事:打开数据库看锁提示,打开BLL看事务边界,打开Controller看有没有直接拼SQL。这三步走完,代码能不能接得住门诊高峰,基本心里有数。这套系统的存储过程和事务设计做得相对完整,跑一遍挂号到发药全流程,你会对医疗信息系统的数据一致性有更具体的感知。希望帮到你。
本文还有配套的精品资源,点击获取