☰
C#医院门诊管理系统源码解析:从挂号事务到高并发避坑
2026/10/14 15:32:08 网站建设 项目流程

简介:这份资源是基于C#开发的医院门诊管理系统完整源码包,面向计算机专业学生、课程设计开发者及需要学习三层架构与数据库编程的C#开发者,可帮助解决门诊业务流程信息化与实战项目练手的需求。压缩包共245个文件,约6.55MB,以84个cs源码文件、28个resx与resources资源文件、17个dll依赖库、6个csproj工程文件及5个config配置文件为主,另含mdf与ldf数据库文件,结构完整可直接编译运行。系统采用三层架构结合存储过程实现,代码注释详细,功能覆盖权限管理、员工管理、部门管理、挂号管理、收费管理、药品管理及门诊处方等核心模块。使用时将数据库附加到SQL Server,修改app.config中的用户名与密码即可运行。目前已有1128人学习下载,适合作为课程设计、毕业设计参考或C#分层架构与数据库交互的实战学习案例。

1. 门诊系统为什么总在“上午十点”崩:从一份 C# 源码包说起

如果你在医疗信息化行业待过,大概率听过这句吐槽:门诊系统平时好好的,一到上午十点就卡成 PPT。挂号、收费、医生站、药房四个窗口同时压上来,数据库连接池瞬间打满,前端还在傻等接口返回。这不是段子,是很多中小型门诊系统的真实写照。今天要拆的这套“基于 C# 的医院门诊管理系统(源码 + 数据库)”,本质上就是解决这类场景的典型方案:用 WinForms 或 WPF 做客户端,SQL Server 存业务数据,中间夹一层 ADO.NET 或轻量 ORM,把挂号、就诊、收费、发药串成一条闭环。它适合谁?适合刚接手院内小系统维护的 .NET 开发、需要交课程设计但不想只做增删改查的学生,以及想用一套能跑通的代码理解门诊业务流的转行者。源码包里通常包含建库脚本、表结构和几个核心窗体的实现,但真正值钱的不是代码本身,而是它怎么把“挂号扣号源”和“收费写流水”这两件事做成一个事务。

2. 先看库表再谈代码:门诊业务的数据底座长什么样

2.1 五张核心表撑起一条就诊链路

拿到源码包,别急着双击 .sln。先找 .sql 文件,把数据库建起来,再回头看代码,否则你会在窗体跳转里迷路。门诊系统不管界面多花哨,数据层永远绕不开这几张表:患者信息表、科室医生表、排班号源表、挂号记录表、收费明细表。它们的关系是一条直线:患者挂某个医生的号,占用一条排班号源,生成一条挂号记录,就诊后产生收费明细。很多新手一上来就去看主窗体怎么写的,结果看到一半发现字段对不上,又回头翻 SQL,来回折腾。我的习惯是先把建库脚本跑一遍,用 SSMS 把表关系图拉出来,心里有数了再开 Visual Studio。

-- 核心表结构简化示例,实际源码包中字段更全 CREATE TABLE Patient ( PatientId INT PRIMARY KEY IDENTITY(1,1), Name NVARCHAR(50) NOT NULL, IdCard NVARCHAR(18) UNIQUE, -- 身份证号做唯一约束,防止重复建档 Phone NVARCHAR(20), CreateTime DATETIME DEFAULT GETDATE() ); CREATE TABLE Schedule ( ScheduleId INT PRIMARY KEY IDENTITY(1,1), DoctorId INT NOT NULL, WorkDate DATE NOT NULL, TimeSlot NVARCHAR(20), -- 如“上午”“下午” TotalQuota INT NOT NULL, -- 总号源 UsedQuota INT DEFAULT 0, -- 已用号源,挂号时 +1 RowVersion ROWVERSION -- 乐观并发标记,防止超挂 );

上面这段 SQL 里有两个点值得注意。第一,IdCard加了唯一约束,这是门诊建档去重最省事的做法,比在代码里查一遍再插入可靠得多。第二,Schedule表用了ROWVERSION,这是 SQL Server 的行版本戳,每次更新自动变化。挂号扣号源时带上这个字段做乐观锁,能避免两个窗口同时读到UsedQuota=9然后都改成 10 的情况。参数上,TotalQuota和UsedQuota建议用 INT 而不是 BIT,因为有些科室支持加号,号源不是简单的“有/无”。建完表后,记得给Schedule的WorkDate + DoctorId建联合索引,否则上午挂号高峰一查排班就是全表扫描。

2.2 连接字符串与事务边界:别让挂号扣号和写记录分家

数据库建好之后,第二步是确认连接字符串。源码包里通常在App.config或appsettings.json里,WinForms 项目偏爱前者。这里有个血泪经验:不要用sa账号跑业务,建一个只有db_datareader和db_datawriter权限的登录名,否则一旦源码里某条 SQL 拼接出问题,整个库都可能被拖走。连接字符串改完,先别跑主程序,写一个小的控制台测试,确认能连上、能查到排班数据,再继续。

// 挂号核心逻辑:扣号源 + 写挂号记录,必须在一个事务里 using (var conn = new SqlConnection(connStr)) { conn.Open(); using (var tran = conn.BeginTransaction()) { try { // 1. 乐观锁扣号源:只有 UsedQuota < TotalQuota 才更新 var updateSql = @"UPDATE Schedule SET UsedQuota = UsedQuota + 1 WHERE ScheduleId = @sid AND UsedQuota < TotalQuota"; using (var cmd = new SqlCommand(updateSql, conn, tran)) { cmd.Parameters.AddWithValue("@sid", scheduleId); int affected = cmd.ExecuteNonQuery(); if (affected == 0) throw new Exception("号源已满,请换一个时段"); } // 2. 插入挂号记录 var insertSql = @"INSERT INTO Registration (PatientId, ScheduleId, RegTime, Status) VALUES (@pid, @sid, GETDATE(), '已挂号')"; using (var cmd = new SqlCommand(insertSql, conn, tran)) { cmd.Parameters.AddWithValue("@pid", patientId); cmd.Parameters.AddWithValue("@sid", scheduleId); cmd.ExecuteNonQuery(); } tran.Commit(); } catch { tran.Rollback(); throw; } } }

这段代码的关键在于UPDATE语句里的AND UsedQuota < TotalQuota。它把并发检查下推到了数据库,而不是在 C# 里先查再判断。ExecuteNonQuery返回受影响行数,如果是 0,说明号已经被别人抢走了,直接抛异常回滚。参数@sid和@pid用AddWithValue传,注意PatientId是 INT,别传成字符串。事务边界覆盖了扣号和写记录两步,任何一步失败都回滚,不会出现“号扣了但没挂号记录”的脏数据。这套写法在门诊量每天几百的小系统里足够用,但如果日挂号量上万,建议把扣号源改成存储过程,减少网络往返。

3. 从登录到发药:把窗体跳转和业务校验串起来

3.1 登录窗与主窗体的权限控制

源码包里的登录逻辑通常很简单:查User表,比对密码哈希,然后根据角色跳不同主窗体。这里容易翻车的地方是密码存储。如果源码里是明文存密码,别犹豫,改成SHA256加盐。C# 里用Rfc2898DeriveBytes做 PBKDF2 也不复杂,几十行代码的事。登录成功后,把当前用户对象存到一个静态类或Program的全局变量里,后续收费、退费都要靠它判断权限。我一般会在主窗体加载时,根据角色隐藏或禁用某些按钮,而不是在点击事件里才去查权限,这样界面更干净,也少一层数据库查询。

// 密码哈希与校验,替换源码中的明文比对 public static string HashPassword(string password, string salt) { using (var pbkdf2 = new Rfc2898DeriveBytes(password, Encoding.UTF8.GetBytes(salt), 10000)) { return Convert.ToBase64String(pbkdf2.GetBytes(32)); } } // 登录校验 public bool ValidateUser(string userName, string inputPwd) { var sql = "SELECT PasswordHash, Salt, Role FROM [User] WHERE UserName=@name"; // ... 执行查询,拿到 hash 和 salt var computed = HashPassword(inputPwd, salt); return computed == dbHash; // 实际应用用固定时间比较,防时序攻击 }

参数上,迭代次数 10000 是 2020 年代中期的常见下限,再低就不太安全了。盐值每个用户独立,存在User表的Salt字段里,长度至少 16 字节。登录窗体里还有一个细节:连续失败几次后锁定账号,这个逻辑源码包不一定有,但值得加,用FailedCount和LockUntil两个字段就能实现。

3.2 收费窗体的金额计算与退费回滚

收费是门诊系统里最不能出错的一环。源码包里的收费窗体通常有一个DataGridView列出待收费项目,下面一个“收费”按钮。核心逻辑是遍历明细,累加金额,写Charge主表和ChargeDetail子表,同时更新挂号状态为“已收费”。退费则是反向操作:插一条负数的退费记录,或者把原记录状态改成“已退费”,再把号源释放回去。这里必须用事务,否则退费时号源释放了但钱没退,或者反过来,都是事故。

// 退费:更新收费记录状态 + 释放号源,同一事务 using (var conn = new SqlConnection(connStr)) { conn.Open(); using (var tran = conn.BeginTransaction()) { // 1. 标记收费记录为已退费 var sql1 = @"UPDATE Charge SET Status='已退费', RefundTime=GETDATE() WHERE ChargeId=@cid AND Status='已收费'"; // 2. 释放号源 var sql2 = @"UPDATE Schedule SET UsedQuota = UsedQuota - 1 WHERE ScheduleId = (SELECT ScheduleId FROM Registration WHERE RegId=@rid)"; // 执行并提交 } }

注意sql1里的AND Status='已收费',这是防止重复退费的关键。如果受影响行数为 0,说明这笔收费已经退过了,直接提示用户,不要继续执行sql2。参数@cid和@rid从当前选中的行里取,别用DataGridView的显示值,要用DataBoundItem里的实体对象,否则排序后行号错位,退错钱就是大事故。

4. 避坑与排查:门诊系统上线后最容易碰到的五件事

4.1 挂号时提示“号源已满”但明明还有号

现象是排班表里UsedQuota小于TotalQuota,但挂号就是失败。原因通常是ROWVERSION或并发判断写错了,比如在 C# 里先SELECT再UPDATE,两个窗口同时读到旧值。解决方法是把判断下推到UPDATE的WHERE里,像第 2 章那样写。另外检查Schedule表的UsedQuota是否有脏数据,比如之前退费没释放号源,手动修一次数据,再补一个定时任务每天凌晨校准。

4.2 收费后打印发票,金额对不上

现象是DataGridView里显示 35.5,打印出来是 35.0。原因是浮点数用了float或double,累加时精度丢失。解决方法是金额字段在数据库里用DECIMAL(10,2),C# 里用decimal类型,别用double。DataGridView的CellFormatting事件里统一格式化为两位小数,打印前再Math.Round一次。

4.3 医生站开处方时卡死,其他窗口正常

现象是只有医生站窗体无响应,挂号收费都正常。原因通常是医生站加载了某个耗时查询,比如一次性拉取患者全部历史就诊记录,数据量大时 UI 线程被阻塞。解决方法是把查询放到BackgroundWorker或Task.Run里,加载完再Invoke回 UI 线程更新控件。如果源码里用的是同步SqlDataAdapter.Fill,改成异步ExecuteReaderAsync。

4.4 数据库连接池耗尽,报“超时时间已到”

现象是上午高峰时频繁报连接超时,重启 IIS 或客户端后好转。原因是每次操作都new SqlConnection但没有using包裹,或者DataReader没关闭。解决方法是全局搜索new SqlConnection,确保每个都在using块里,SqlDataReader用CommandBehavior.CloseConnection或显式Close。连接字符串里加Max Pool Size=100只是缓解,根治还是靠及时释放。

4.5 源码包里的建库脚本执行报错“对象已存在”

现象是在已有数据库上跑脚本,提示表已存在。原因是脚本没有IF NOT EXISTS判断,或者用了CREATE TABLE而不是CREATE TABLE IF NOT EXISTS(SQL Server 不支持后者)。解决方法是手动改脚本,每张表前加IF OBJECT_ID('TableName') IS NULL判断,或者干脆新建一个空库再跑。另外注意脚本里的GO批处理分隔符,在 C# 里执行时要拆开,不能一次性ExecuteNonQuery。

5. 把源码包变成自己的项目:三个进阶改造方向

5.1 用 Dapper 替换裸 ADO.NET,减少样板代码

源码包里大概率是手写SqlCommand和SqlDataReader,读起来累,改起来更累。如果你的 .NET 版本在 4.6.1 以上,引入 Dapper 只要一个 NuGet 包,就能把查询映射成对象。比如查排班:

// Dapper 写法,比裸 ADO.NET 少一半代码 using (var conn = new SqlConnection(connStr)) { var list = conn.Query<Schedule>( "SELECT * FROM Schedule WHERE WorkDate=@date AND DoctorId=@did", new { date = DateTime.Today, did = doctorId }).ToList(); }

参数用匿名对象传,Dapper 自动处理类型映射。注意Schedule类的属性名要和查询列名一致,不一致时用AS别名。事务方面,Dapper 的Execute支持传入transaction对象,和裸 ADO.NET 一样。

5.2 加一层简单的缓存,减少排班查询压力

排班数据一天内变化不大,但挂号时每次都要查。可以在内存里用MemoryCache缓存当天的排班,设置 5 分钟过期。挂号扣号源成功后,手动移除对应缓存项,下次查询重新加载。这样上午高峰时数据库压力能降一半。注意缓存键要包含日期和医生 ID,别用全局键。

5.3 日志与审计:出问题时能查到谁改了数据

门诊系统一旦涉及退费、改号,必须有审计日志。源码包里通常没有,建议加一张AuditLog表,记录操作人、操作时间、操作类型、旧值、新值。在收费和退费的事务里,顺手插一条日志。别用触发器,触发器在批量操作时容易锁表,放在业务代码里更可控。日志表按月分区或定期归档,否则一年后查询会变慢。

5.4 验证改造是否生效:三个可量化的检查点

改完之后怎么知道有没有效果?第一,用 SQL Server Profiler 抓一次挂号高峰的语句,看Schedule表的UPDATE是否走了索引,逻辑读是否下降。第二,在收费窗体连续操作 50 笔,看内存占用是否稳定,没有持续增长说明连接和对象都释放了。第三,模拟两个客户端同时挂最后一个号,只有一个成功,另一个收到“号源已满”,说明并发控制生效。这三个检查点过了,这套源码包才算真正变成你能用的系统。

我自己维护这类系统最大的教训是:别信“先跑起来再说”。数据库表结构、事务边界、金额类型这三样,一开始不较真,后面改起来就是拆房子。每次接手新包,先花半小时把建库脚本和核心事务读一遍,比直接 F5 调试省事得多。希望帮到你。

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

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

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

立即咨询