C/S架构医院管理系统源码拆解:从三层架构到SQL Server事务与权限设计
2026/9/14 13:51:01 网站建设 项目流程

简介:这是一套基于C#与.NET平台、采用客户端/服务器(CS)架构的医院管理系统完整源码,适合C#中高级学习者、医疗信息化开发者及毕业设计参考。系统覆盖挂号、门诊、住院、药房、财务等典型业务模块,代码中包含ADO.NET数据库操作、角色权限控制、报表统计、异常日志处理等关键实现,能帮助读者理解桌面端业务系统的分层设计。压缩包共350个文件,以125个cs源码文件、81个png界面图、38个resx资源文件为主,附带DLL、PDB、EXE及数据库MDF/LDF文件,包体仅6.76MB,目录按BLL、DAL、UI清晰分层,便于按模块查阅。目前已有340人学习下载,是非常适合用于CS架构医院管理系统快速入门与二次开发的基础资源。

1. 为什么 C/S 架构的医院管理系统仍然是刚需

很多人在选型时会想:现在 Web 版管理系统遍地都是,为什么还要去拆一套 C# 写的 C/S 架构医院管理系统源码?答案藏在医院的实际使用环境里。门诊挂号、药房发药、收费结算这些场景有一个共同特点:操作频率高、数据敏感、网络环境未必稳定。Web 系统一旦断网或者服务器响应变慢,整个窗口就卡死了。而 C/S 架构下,客户端本机就承担了界面渲染和一部分业务校验,服务器只负责数据存取和核心逻辑分发,局域网的稳定性远高于公网,配合 SQL Server 的事务机制,能把“挂号一半断网丢数据”这种事压到最低。

这套源码的价值不在于“能跑”,而在于它把医院管理的完整业务流程切成了模块:挂号、门诊、住院、药房、收费、报表。每一个模块都对应真实世界里的一个岗位和一组操作权限。对于做 .NET 开发的工程师来说,读这套代码能同时看到三层架构怎么落地、权限怎么按角色切分、报表怎么从业务表里聚合数据。对于刚转行或者做毕业设计的人来说,它更是一个可以直接跑起来、能对着界面讲清楚每个按钮背后逻辑的完整项目。

这篇文章不会只给源码清单,我会按“架构拆分 → 数据库设计 → 核心模块实现 → 权限与报表 → 部署与排错”这条线带你把它吃透。中间涉及的 SQL、C# 代码都可以直接抄下来改着用。

2. 从 csproj 缓存文件反推三层架构:BLL、DAL 与 UI 的边界

打开这套源码的根目录,最先吸引眼球的是那一堆ResolveAssemblyReference.cacheDesignTimeResolveAssemblyReferencesInput.cache文件。这些是 Visual Studio 在编译期间自动生成的缓存文件,记录了程序集引用解析的结果。它们本身没有任何业务价值,但它们的分布暴露了整个解决方案的项目结构:UI.csprojBLL.csprojDAL.csproj分别对应表现层、业务逻辑层、数据访问层。这是一个非常经典的三层架构,不是把代码按文件夹分一分就完事,而是在工程层面就做了隔离。

三层架构的核心规则是依赖方向:UI 引用 BLL,BLL 引用 DAL,UI 不直接碰数据库。这样做的好处是,如果某天要从 SQL Server 换成 MySQL,只需要替换 DAL 层的实现,UI 和 BLL 完全不用动。在医院的真实场景里,这种可替换性非常重要,因为不少医院的信息科会要求数据库选型必须符合卫健委的国产化或者安全审计要求。

以登录模块为例,UI 层只做一件事:收集用户名和密码,调用 BLL 层的方法,然后根据返回值决定跳转到哪个窗体。BLL 层负责校验用户状态、调用 DAL 查询数据库、对密码做哈希比对。DAL 层只执行 SQL 或调用存储过程,返回实体对象或 DataTable。

// DAL/UserDAL.cs public class UserDAL { private string _connStr = ConfigurationManager.ConnectionStrings["HospitalDB"].ConnectionString; public UserEntity GetUserByName(string userName) { // 参数化查询,防止 SQL 注入 string sql = "SELECT UserId, UserName, PasswordHash, RoleId, IsActive FROM Sys_User WHERE UserName = @name"; using (SqlConnection conn = new SqlConnection(_connStr)) { SqlCommand cmd = new SqlCommand(sql, conn); cmd.Parameters.AddWithValue("@name", userName); conn.Open(); SqlDataReader reader = cmd.ExecuteReader(); if (reader.Read()) { return new UserEntity { UserId = reader.GetInt32(0), UserName = reader.GetString(1), PasswordHash = reader.GetString(2), RoleId = reader.GetInt32(3), IsActive = reader.GetBoolean(4) }; } } return null; } }

这段代码里有两个值得注意的地方。第一是using块,它保证SqlConnectionSqlDataReader在方法结束时一定会被释放,避免连接池被占满——在医院这种高并发挂号场景下,连接泄漏是系统变慢的第一大元凶。第二是AddWithValue("@name", userName),它把用户输入当成参数传给 SQL Server,而不是拼进 SQL 字符串里,这是防止 SQL 注入的标准做法。

BLL 层在调用 DAL 之前,还要做一层业务判断。比如用户被停用了、密码错误次数超过五次,这些逻辑不会写进 DAL,因为 DAL 的职责是“取数据”,不是“做判断”。

// BLL/UserBLL.cs public LoginResult VerifyLogin(string userName, string password) { UserDAL dal = new UserDAL(); UserEntity user = dal.GetUserByName(userName); if (user == null) return LoginResult.UserNotFound; if (!user.IsActive) return LoginResult.UserLocked; // PasswordHash 在入库时用 BCrypt 或 SHA256 加盐处理 if (!PasswordHelper.Verify(password, user.PasswordHash)) return LoginResult.WrongPassword; return LoginResult.Success; }

这里把“用户是否存在”和“用户是否被锁定”分开返回,是因为 UI 层需要据此给护士站或挂号窗口不同的提示语。如果全部返回“用户名或密码错误”,用户没法判断自己是输错了还是账号被停了,反而会增加信息科的工作量。

提示:Visual Studio 打开项目时如果提示ResolveAssemblyReference.cache读取失败,直接删除这些 cache 文件再重新生成解决方案即可。它们是编译中间产物,不属于源代码版本管理范围,建议在 .gitignore 里加上*.cache

3. 数据库设计:从患者主索引到药品库存的事务边界

医院管理系统的核心不是界面,是数据模型。这套源码里最值得读的就是建表脚本和存储过程。一个合格的医院数据库,至少要包含这几类表:用户与角色表、患者信息表、挂号记录表、门诊处方表、住院医嘱表、药品字典与库存表、收费流水表。以药品库存为例,它的设计直接决定了药房发药时会不会出现超卖。

药品表通常分为两层:药品字典表Drug_Info保存药品的通用信息(通用名、规格、厂家、零售价),药品库存表Drug_Stock保存某个药房中该药品的当前数量、批次号和有效期。发药操作不能只 UPDATE 库存表,这个动作必须和收费记录在同一个数据库事务里完成。如果先扣库存再写收费流水,第二步失败就导致账实不符;如果不扣库存只写流水,药房就会按错误的账面库存发药。

下面是一段典型的发药扣库存存储过程,我在原项目思路上做了精简,保留了核心的事务和锁机制:

CREATE PROCEDURE [dbo].[usp_DispenseDrug] @PrescriptionId INT, @OperatorId INT, @DispenseTime DATETIME AS BEGIN SET NOCOUNT ON; BEGIN TRANSACTION; BEGIN TRY -- 使用 UPDLOCK 锁定处方行,避免同一张处方被两个窗口同时发药 DECLARE @DetailCount INT; SELECT @DetailCount = COUNT(1) FROM Prescription_Detail WITH (UPDLOCK, HOLDLOCK) WHERE PrescriptionId = @PrescriptionId; IF @DetailCount = 0 BEGIN ROLLBACK TRANSACTION; RAISERROR('处方明细为空,无法发药', 16, 1); RETURN; END; -- 逐行扣减库存,并检查是否够扣 UPDATE d SET StockQty = StockQty - pd.Quantity FROM Drug_Stock d INNER JOIN Prescription_Detail pd ON d.DrugId = pd.DrugId WHERE pd.PrescriptionId = @PrescriptionId; IF @@ROWCOUNT <> @DetailCount BEGIN ROLLBACK TRANSACTION; RAISERROR('库存匹配失败,可能存在批次过期', 16, 1); RETURN; END; -- 写发药记录 INSERT INTO Dispense_Log(PrescriptionId, OperatorId, DispenseTime) VALUES (@PrescriptionId, @OperatorId, @DispenseTime); COMMIT TRANSACTION; END TRY BEGIN CATCH ROLLBACK TRANSACTION; EXEC sp_RaiseError; -- 记录错误日志并重新抛出 END CATCH; END

很多初学者会忽略WITH (UPDLOCK, HOLDLOCK)这两个锁提示。UPDLOCK告诉 SQL Server 在读取处方明细时就加更新锁,防止其他事务同时修改同一张处方;HOLDLOCK把锁持有到事务结束,避免在“先查后改”的间隙里出现幻读。没有这两个锁,发药窗口 A 和窗口 B 同时扫同一张处方条码时,就可能重复扣库存。

患者数据的核心设计是主索引表。大型医院会用专门的 EMR(电子病历)系统维护患者主索引,这套源码里的做法是在Patient_Info表上建立身份证号唯一索引,同时允许PatientId作为业务主键对外暴露。为什么不用身份证号直接做主键?因为身份证号属于个人敏感信息,在日志、报表、接口交互中频繁出现会增加泄露风险。用内部自增的PatientId,身份证号只在建档和实名核验时使用。

挂号模块的表设计也需要重点看。Registration_Record表除了基本的时间、科室、医生外,通常会有一个Status字段:0 表示已挂号未就诊,1 表示已就诊,2 表示已退号。收费和退费都依赖这个状态值做判断。退号时如果状态已经是 1,系统必须阻止操作并提示“该号源已就诊,请走退费流程”,这个校验写在 BLL 层而不是前端按钮的点击事件里,因为前端校验可以通过修改内存状态绕过,服务端校验才可信。

4. 挂号与收费模块实战:WinForms 绑定、事件驱动与参数传递

这套源码的 UI 层是基于 WinForms 做的。WinForms 看起来“老”,但它在医院内网环境里有一个 Web 系统比不了的优势:控件响应是真正的事件驱动,没有浏览器渲染层和网络传输的开销。挂号窗口的护士敲完患者病历号,按下回车,候诊队列立刻刷新,这个过程中建 DataTable、绑定 DataGridView、刷新状态栏,所有操作都在本机完成。

挂号窗体的核心逻辑是:加载科室列表 → 选中科室后联动加载医生排班 → 选择号源类型(普通号/专家号)→ 点击挂号按钮生成挂号记录。这个联动过程在代码里表现为两个 ComboBox 的SelectedIndexChanged事件链。

// UI/RegisterForm.cs private void cmbDepartment_SelectedIndexChanged(object sender, EventArgs e) { if (cmbDepartment.SelectedValue == null) return; int deptId = (int)cmbDepartment.SelectedValue; // 根据科室ID加载排班,BLL 层内部会校验当天是否有号 DataTable dt = _scheduleBLL.GetDoctorSchedulesByDept(deptId, DateTime.Today); cmbDoctor.DataSource = dt; cmbDoctor.DisplayMember = "DoctorName"; cmbDoctor.ValueMember = "ScheduleId"; }

这段代码有一个关键细节:cmbDoctor.ValueMember绑定的是ScheduleId而不是DoctorId。因为同一个医生一天可能有上午和下午两个排班,如果绑定DoctorId,后续提交挂号的存储过程就不知道患者挂的是哪个时间段,会导致医生端看不到准确的候诊列表。这也是很多模仿项目容易踩的坑。

收费模块比挂号更复杂,因为它要处理“部分退款”和“医保报销比例”两个概念。医保报销不是简单的“总价 × 比例”,因为不同药品和诊疗项目有不同的自付比例。这套源码里的做法是:收费流水主表只存总金额、实收金额、患者自付金额,明细表Charge_Detail里每一条记录存项目金额和自付比例,最终实收金额 = SUM(项目金额 × 自付比例)

在 UI 层绑定收费明细时,我建议用只读的 DataGridView 展示,不允许直接编辑单元格,所有费用项目通过“添加项目”按钮来插入。这样能避免护士误点表格导致金额错乱。每次添加项目后,用一段独立的计算方法刷新合计栏:

private void RefreshTotalAmount() { decimal total = 0m; decimal selfPay = 0m; foreach (DataGridViewRow row in dgvChargeItems.Rows) { if (row.Cells["Amount"].Value == null) continue; total += Convert.ToDecimal(row.Cells["Amount"].Value); selfPay += Convert.ToDecimal(row.Cells["SelfPayAmount"].Value); } lblTotalAmount.Text = total.ToString("C2"); lblSelfPayAmount.Text = selfPay.ToString("C2"); }

这里用了row.Cells["Amount"].Value而不是row.Cells[3].Value,原因在于列索引在界面调整后很容易错位,而列名是稳定的。ToString("C2")会把数字格式化成带两位小数的货币格式,但要注意:如果当前系统的区域设置不是中文,C2可能输出¥以外的货币符号。在医院项目中我一般建议直接用ToString("F2"),固定输出数字格式,货币符号放在 UI 的 Label 静态文本里。

挂号模块的并发问题也要提前考虑。同一个医生上午号源只有 30 个,两个窗口同时挂号时,不能等提交 SQL 才发现超号。常见的做法是在 BLL 层先做一次剩余号数检查,然后调用存储过程,在存储过程内部再次检查并更新号源表。第二次检查是必须的,因为 BLL 层的检查存在时间差,只有数据库层面的行锁能真正保证不超号。

-- 存储过程内部扣号源 UPDATE Doctor_Schedule SET LeftNumber = LeftNumber - 1 WHERE ScheduleId = @ScheduleId AND LeftNumber > 0; IF @@ROWCOUNT = 0 BEGIN RAISERROR('该医生当前号源已满', 16, 1); RETURN; END;

UPDATE语句的WHERE LeftNumber > 0条件确保了扣减操作本身就是原子性的。SQL Server 在更新这一行时会持有排他锁直到事务结束,其他事务的更新操作必须等待,所以不会出现两个窗口同时把 LeftNumber 从 1 扣到 -1 的情况。

5. 权限控制与报表统计:从角色菜单表到自定义查询面板

权限控制是医院管理系统里最容易出事故的部分。这套源码采用 RBAC(基于角色的访问控制)模型,核心是三张表:Role_Info角色表、Menu_Info菜单表、Role_Menu_Mapping角色菜单关联表。用户登录成功后,系统根据用户的 RoleId 查出他能看到哪些菜单,动态生成主窗体的菜单栏。

动态生成菜单不能用硬编码的方式,否则系统上线后每调整一次权限都要改代码重新编译。做法是登录成功后调用一次GetMenusByRoleId,把返回的 DataTable 转成ToolStripMenuItem集合挂到主窗体的菜单容器上。这里有一个细节:不同角色的菜单级数不一样,比如管理员能看到“系统设置”一级菜单下的“用户管理”“数据备份”两个二级菜单,而护士角色只看到“门诊业务”下的“挂号管理”“收费管理”。

// UI/MainForm.cs private void LoadMenusByRole(int roleId) { DataTable dt = _menuBLL.GetMenusByRoleId(roleId); foreach (DataRow row in dt.Rows) { ToolStripMenuItem item = new ToolStripMenuItem(row["MenuName"].ToString()); item.Tag = row["MenuCode"].ToString(); item.Click += MenuItem_Click; msMain.Items.Add(item); } } private void MenuItem_Click(object sender, EventArgs e) { ToolStripMenuItem item = sender as ToolStripMenuItem; if (item == null) return; string menuCode = item.Tag.ToString(); // 根据菜单编码打开对应的窗体 switch (menuCode) { case "REGISTER": new RegisterForm().ShowDialog(); break; case "CHARGE": new ChargeForm().ShowDialog(); break; } }

这里把菜单编码放在Tag属性里,而不是直接用Text做判断,是因为Text可能被改成中文别名,但MenuCode是稳定的业务编码。这样做的好处是,将来要给菜单加国际化或者改显示名,不需要动点击事件的分支逻辑。

报表模块在设计时要考虑两类用户:管理层要看汇总,财务要看明细。这套源码里提供了基础的就诊量统计和收费汇总报表,做法是写一个报表查询窗体,用户选择日期范围和统计维度,系统动态拼接 SQL 后把结果集绑定到 DataGridView,同时调用 PrintDocument 实现打印。动态拼接 SQL 有一个风险点:排序字段和分组字段如果完全由用户输入,存在注入可能。我一般会在服务端维护一个白名单字典,把“科室”“医生”“收费类型”等固定字段映射成真实列名,用户选什么不影响 SQL 结构。

private Dictionary<string, string> _columnMap = new Dictionary<string, string> { { "科室", "DepartmentName" }, { "医生", "DoctorName" }, { "收费类型", "ChargeType" } }; private string BuildGroupSql(string groupBy) { if (!_columnMap.ContainsKey(groupBy)) throw new ArgumentException("不支持的统计维度"); string column = _columnMap[groupBy]; return $"SELECT {column}, COUNT(*) AS VisitCount, SUM(Amount) AS TotalAmount FROM v_ChargeReport WHERE ChargeDate BETWEEN @start AND @end GROUP BY {column}"; }

这种白名单映射的方式比直接SELECT * FROM ... WHERE ... GROUP BY " + groupBy安全得多。SQL 注入不只在 WHERE 条件里,GROUP BY 和 ORDER BY 也能被利用,因为这两个位置通常不接受参数化绑定,唯一的防御手段就是列名白名单。

报表性能也要提前考虑。如果v_ChargeReport视图直接关联挂号表、处方表、收费流水表,当数据量超过百万行时,按日期范围查询会非常慢。常见的优化手段是建立覆盖索引:索引键包含ChargeDate,同时用INCLUDE带上DepartmentNameAmount。这样 SQL Server 可以直接从索引中拿数据,不需要回表查原始行。

CREATE NONCLUSTERED INDEX IX_ChargeReport_ChargeDate ON Charge_Report(ChargeDate) INCLUDE(DepartmentName, DoctorName, ChargeType, Amount);

提示:报表模块的“导出 Excel”功能,不要用 Office COM 组件。医院服务器和客户端大概率没有安装 Office,COM 调用会直接抛异常。用 NPOI 或 ClosedXML 这类开源库生成 xlsx 文件,部署时只需拷贝几个 DLL,不需要额外安装软件。

6. 数据库连接字符串加密与异常日志追踪的落地技巧

最后一个章节聊部署和维护阶段最容易卡住人的两个点:连接字符串保护与全局异常日志。很多人在开发机上跑得好好的,部署到医院的服务器上就报“无法连接到数据库”,排查半天发现是连接字符串里的 Data Source 写的是本机localhost,换了机器没改配置。比较好的做法是把连接字符串放到独立的配置节里,部署时只改配置文件不动代码。

但连接字符串存在配置文件里也有问题:文件权限被放宽后,任何能登录 Windows 的人都能看到数据库地址和账号。C/S 架构下客户端数量多,无法保证每台机器都绝对安全。我一般的做法是用 DPAPI 对连接字符串加密,加密后的密文放在配置文件里,运行时在代码中解密。因为 DPAPI 与当前 Windows 用户绑定,即便数据库账号密码泄露,也只是某一台客户端机器的密文,不影响整套系统。

// Utility/ConfigEncryptor.cs public static string DecryptConnectionString(string encrypted) { byte[] cipherBytes = Convert.FromBase64String(encrypted); byte[] plainBytes = ProtectedData.Unprotect(cipherBytes, null, DataProtectionScope.CurrentUser); return Encoding.UTF8.GetString(plainBytes); }

DataProtectionScope.CurrentUser表示解密只能在加密时同一个 Windows 用户下进行。在部署时,先在目标机器上运行一次加密工具生成密文,然后把密文写到配置文件,不要直接把明文放进去。

异常日志这一块,很多小程序的做法是try-catch之后MessageBox.Show(ex.Message)。在医院环境里这样做有两个问题:一是患者隐私数据可能随着异常信息显示在屏幕上;二是错误发生时的完整堆栈没人记录,后续想排查也找不到线索。建议在主窗体的Application.ThreadException事件里做全局捕获,把异常信息写到本地日志文件,同时向用户显示一句友好的提示。

// Program.cs Application.ThreadException += (sender, e) => { string logDir = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "Logs"); Directory.CreateDirectory(logDir); string logFile = Path.Combine(logDir, $"error_{DateTime.Now:yyyyMMdd}.log"); File.AppendAllText(logFile, $"[{DateTime.Now:yyyy-MM-dd HH:mm:ss}] {e.Exception}\r\n"); MessageBox.Show("系统发生错误,请联系信息科查看日志。", "提示", MessageBoxButtons.OK, MessageBoxIcon.Warning); };

日志文件名按天切分,方便信息科按日期找问题。异常对象直接ToString()输出,会包含完整的堆栈信息、内部异常和外层异常消息,比只记录ex.Message有价值得多。这里要注意不要把异常信息e.Exception.ToString()直接显示给用户,因为堆栈里可能包含数据库表名、字段名甚至 SQL 片段,这些信息对普通操作人员没有意义,却可能被有心人利用来构造攻击。

最后说一个与这套源码直接相关的实用技巧:源代码里大量出现的.cache文件,在提交到 Git 或 SVN 时一定要忽略掉,否则每个开发者本地生成一次就能制造十几个冲突。在.gitignore 里加上以下内容就能消停:

*.cache bin/ obj/ *.user

binobj目录也是必须忽略的,它们是编译输出目录。多人协作时如果把这些目录提交上去,项目动辄几百 MB,而且每次编译都会产生差异,合并代码时会非常痛苦。

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

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

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

立即咨询