ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

C/S架构医院管理系统源码拆解:从三层架构到SQL Server事务与权限设计

C/S架构医院管理系统源码拆解:从三层架构到SQL Server事务与权限设计 简介这是一套基于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.cache和DesignTimeResolveAssemblyReferencesInput.cache文件。这些是 Visual Studio 在编译期间自动生成的缓存文件记录了程序集引用解析的结果。它们本身没有任何业务价值但它们的分布暴露了整个解决方案的项目结构UI.csproj、BLL.csproj、DAL.csproj分别对应表现层、业务逻辑层、数据访问层。这是一个非常经典的三层架构不是把代码按文件夹分一分就完事而是在工程层面就做了隔离。三层架构的核心规则是依赖方向UI 引用 BLLBLL 引用 DALUI 不直接碰数据库。这样做的好处是如果某天要从 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块它保证SqlConnection和SqlDataReader在方法结束时一定会被释放避免连接池被占满——在医院这种高并发挂号场景下连接泄漏是系统变慢的第一大元凶。第二是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 Dictionarystring, string _columnMap new Dictionarystring, 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带上DepartmentName、Amount。这样 SQL Server 可以直接从索引中拿数据不需要回表查原始行。CREATE NONCLUSTERED INDEX IX_ChargeReport_ChargeDate ON Charge_Report(ChargeDate) INCLUDE(DepartmentName, DoctorName, ChargeType, Amount);提示报表模块的“导出 Excel”功能不要用 Office COM 组件。医院服务器和客户端大概率没有安装 OfficeCOM 调用会直接抛异常。用 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/ *.userbin和obj目录也是必须忽略的它们是编译输出目录。多人协作时如果把这些目录提交上去项目动辄几百 MB而且每次编译都会产生差异合并代码时会非常痛苦。本文还有配套的精品资源点击获取
返回列表