ARTICLE DETAIL

资讯详情

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

用WinForm构建医院挂号系统:架构设计与并发控制实战

用WinForm构建医院挂号系统:架构设计与并发控制实战 简介一套基于C# Winform开发的医院挂号管理系统面向课程设计、毕业设计及C/S架构入门学习者。系统采用MVC模式按表示层、业务层、数据访问层分离设计覆盖用户管理、科室管理、医生管理、门急诊挂号、挂号查询、修改口令、挂号单打印、帮助文档等完整模块数据库访问使用存储过程用户密码经MD5加密统计信息以报表图文展示并支持医生照片上传。压缩包共185个文件主要包含cs源码、resx/resources资源、jpg图片、sql脚本、chm帮助文档和可运行exe等整体仅10.03MB目录结构清晰便于按模块检索整套系统含完整源码与数据库脚本可直接运行演示或修改复用。已有1263人学习下载通过该项目可系统理解Winform窗体设计、MVC分层思想、存储过程封装、MD5加密和报表呈现等关键技术的真实工程落地适合作为综合项目练习与二次开发蓝本。1. 为什么还有人在用 WinForm 做医院挂号系统2025 年的技术选型会上如果有人说“我要用 C# WinForm 做医院挂号管理系统”大概率会被问一句“为什么不上 Web”——但真正在医疗信息化一线待过的人会告诉你门诊大厅那台挂在墙上的自助挂号机、护士站那台运行了三年的签到终端十台里有七台跑的还是 WinForm。原因很直接医院内网环境复杂科室电脑配置参差不齐有的甚至还在用 Windows 7 工控机WinForm 启动快、不依赖浏览器、能直接操作串口和读卡器更重要的是它不需要 Web 前端那一整套工程化体系一个 C# 程序员就能把挂号、退号、排班、统计全盘拿下。这个项目的核心不是“写一个能跑的界面”而是解决医院场景里最硬的两个问题一是号源并发下怎么保证同一个号不被两个人挂走二是科室、医生、号别、排班这些关系数据怎么设计才能不乱。适合谁打算做医疗 MIS 系统毕业设计的学生、要给诊所或中小医院做内部系统的开发者以及想从“会拖控件”进阶到“能处理并发和事务”的 C# 初级工程师。接下来我按自己实际做过的方案把架构设计、数据表、界面交互、并发控制和踩坑记录完整讲一遍。2. 先把地基打好挂号系统的数据模型与分层架构2.1 五个核心表怎么设计从科室到号源医院挂号系统的数据模型说到底是围绕“号源”在转。一个患者要挂上号链路是“科室 → 医生 → 排班 → 号源 → 挂号记录”任何一环缺了系统都会出逻辑漏洞。我一般会设计五张核心表外加两张辅助表。科室表Departments最简单字段就是 Id、名称、父级 Id、排序号。这里有个常见误区很多人用“科室类别”字段区分内科外科但实际医院里科室是有层级的比如“内科”下面还有“心内科”“呼吸内科”所以必须用父级 Id 做自关联界面上才能渲染出树形结构。医生表Doctors要关联科室 Id同时建议存一个“职称”字段用于号别展示。医生表别放排班信息排班是独立业务混在一起后面改班次会非常痛苦。排班表Schedules是关键。字段包括 Id、医生 Id、排班日期、午别上午/下午、号别普通号/专家号、总号数、已约号数、状态。注意“已约号数”这个字段是冗余设计的——它本可以通过统计挂号记录算出来但在高并发下直接 UPDATE 这个字段比 COUNT 一次表快得多代价是必须放在事务里更新否则会脏。号源表NumberSources是我个人习惯加的。它是排班和挂号记录之间的“中间态”一个排班生成 N 条号源记录每条有号序、状态0 未挂 / 1 已挂 / 2 已锁定。这样做的好处后面讲并发时会体现。挂号记录表Registrations存患者信息和挂号信息患者姓名、身份证号、手机号、排班 Id、号源 Id、挂号时间、状态正常/退号。这张表要存患者姓名快照不要 JOIN 患者主数据表——因为患者档案可能后续被合并修改而挂号记录是医疗凭证不能跟着变。2.2 三层架构还是双层我的选择WinForm 项目最常见的做法是 UI 层直接写 SQL 连接对于几百行代码的 demo 没问题但挂号系统一旦涉及排班、退号、统计报表这种写法会让人改到崩溃。我用的是三层UIForm→ BLL业务逻辑→ DAL数据访问。DAL 层我习惯用 Dapper 而不是 EF。原因有三一是 Dapper 性能好医院内网老机器上 EF 的实体追踪是额外开销二是 SQL 写在代码里肉眼可见排班更新和号源锁定的 SQL 可以精确控制三是 Dapper 的用法贴近原生 ADO.NET新手更容易理解连接和事务的生命周期。依赖注入用 Microsoft.Extensions.DependencyInjection 就行NuGet 上直接装WinForm 里手动构建 ServiceCollection 也只要十行代码。// Program.cs 入口构建依赖注入容器 static void Main() { Application.EnableVisualStyles(); Application.SetCompatibleTextRenderingDefault(false); var services new ServiceCollection(); // 注册数据库连接工厂 services.AddSingletonIDbConnectionFactory, SqliteConnectionFactory(); // 注册仓储层 services.AddScopedIDepartmentRepository, DepartmentRepository(); services.AddScopedIScheduleRepository, ScheduleRepository(); services.AddScopedIRegistrationRepository, RegistrationRepository(); // 注册业务层 services.AddScopedRegistrationService(); services.AddScopedScheduleService(); using var provider services.BuildServiceProvider(); Application.Run(new MainForm(provider)); }这段代码做的事是启动时手动构建整个对象图。为什么要用依赖注入而不是直接在 Form 里 new因为排班服务和挂号服务都要操作号源如果各自 new 一个仓储实例事务无法跨对象共享后面处理并发锁号时很难保持一致。数据库我用 SQLite 一个轻量连接工厂。不要小看 SQLite单机版挂号系统比如一个社区诊所三五台终端它的并发表现足够好而且部署时不需要在每台机器上装 SQL Server。真到需要上网络版把 DAL 层里 SQL 方言差异改一改就行业务层不用动。2.3 连接与事务一个被严重低估的参数WinForm 连数据库最常见的坑是“连接未关闭导致数据库被锁”。SQLite 尤其敏感——写操作未提交读操作会直接报 database is locked。我的做法是写一个 IDbConnectionFactory每次用 using 包裹保证连接短生命周期。public class SqliteConnectionFactory : IDbConnectionFactory { private readonly string _connectionString; public SqliteConnectionFactory(string connectionString) { _connectionString connectionString; } public IDbConnection CreateConnection() { var conn new SqliteConnection(_connectionString); conn.Open(); return conn; } }这个工厂类本身不做什么但它把“连接怎么创建”收敛到一个地方。连接字符串里有一个参数必须设置——Poolingtrue。很多人不知道 SQLite 的 ADO.NET 驱动默认是支持连接池的不设这个参数每次 Open 都走全新建连接在高频挂号场景下性能会明显下降。事务方面我统一在 BLL 层开事务把“扣减已约号数 插入挂号记录 更新号源状态”放进同一个事务。Dapper 的事务用法是 conn.BeginTransaction()然后所有 Execute 都传入事务对象。这块代码会在第四章并发控制里完整给出这里先说明一个理念数据一致性靠的不是代码逻辑严谨而是事务边界清晰。3. 界面怎么做才像个正规系统主窗体布局与科室树联动3.1 主界面框架左边科室树右边号源列表医院挂号系统的界面不需要花哨但必须信息密度高、操作快。门诊护士一天挂几百个号多点一下鼠标都是负担。我用的布局是经典的左树右表左侧 TreeView 显示科室层级右侧上方显示选中科室的医生排班列表右侧下方显示详细号源状态。TreeView 的构建要从数据库读出科室表然后递归生成节点。不要在设计器里手动拖节点那只是写死的假数据。每次进入挂号界面时重新加载科室树以保证排班变化能及时反映。树的节点文本用“科室名称”Tag 属性存科室 Id后面点击事件直接取 Tag 用。private void LoadDepartmentTree() { treeDepartments.Nodes.Clear(); var depts _departmentRepository.GetAll(); // 先建一个字典方便通过父级Id找节点 var nodeMap new Dictionaryint, TreeNode(); foreach (var d in depts.OrderBy(x x.SortOrder)) { var node new TreeNode(d.Name) { Tag d.Id }; nodeMap[d.Id] node; if (d.ParentId 0) { treeDepartments.Nodes.Add(node); } else if (nodeMap.ContainsKey(d.ParentId)) { nodeMap[d.ParentId].Nodes.Add(node); } } treeDepartments.ExpandAll(); }代码逻辑不复杂核心是 nodeMap 字典。注意一个细节科室数据必须按 SortOrder 排序后再遍历否则同级的节点会乱序。再有就是 ExpandAll 这个调用在科室多的时候不要用只展开前两层即可不然界面一打开卡顿明显。3.2 号源展示ListView 还是 DataGridView显示号源状态时很多人习惯用 DataGridView因为它能绑定数据源、自动生成列。但我这里强烈建议用 ListView 的 Details 视图自己拼列。原因有两个一是挂号界面需要给不同号源状态上不同颜色已挂灰色、可挂黑色、当前选中蓝色ListView 的项颜色控制比 DataGridView 的单元格样式操作更顺手二是 ListView 的刷新性能明显优于 DataGridView——号源列表可能一次性加载几百条DataGridView 每次刷新都全量重绘视觉上会有闪烁感。private void LoadNumberSources(int scheduleId) { listViewNumbers.BeginUpdate(); listViewNumbers.Items.Clear(); var sources _scheduleRepository.GetNumberSources(scheduleId); foreach (var src in sources) { var item new ListViewItem(src.Seq.ToString(00)); item.SubItems.Add(src.DoctorName); item.SubItems.Add(src.NumberType); if (src.Status 1) // 已挂 { item.ForeColor Color.Gray; } else { item.ForeColor Color.Black; } item.Tag src.Id; listViewNumbers.Items.Add(item); } listViewNumbers.EndUpdate(); }BeginUpdate 和 EndUpdate 是一对关键调用它们在批量添加项时暂停控件重绘全部添加完再画一次几百条数据也只闪一下。ForeColor 按状态着色这是列表界面最直观的“号源状态灯”。Tag 属性上存的是号源记录的主键 Id后续挂号操作里直接从选中项的 Tag 取值不用再查库定位。3.3 状态栏与进度条让长操作不再像死机挂号系统里有一个容易被忽略的操作——批量导入排班。如果医院一次导入一个月的排班生成几千条号源记录界面上没有任何反馈操作者会以为程序崩溃了。这时候就要用上 WinForm 的 StatusStrip 和 ToolStripProgressBar。我在导入排班时开了个 BackgroundWorker后台线程处理数据前台线程更新进度条。关键代码是 BackgroundWorker 的 ReportProgress 回调它负责把后台进度安全地推送到 UI 线程。private void backgroundWorker_DoWork(object sender, DoWorkEventArgs e) { var schedules (ListSchedule)e.Argument; int total schedules.Count; for (int i 0; i total; i) { _scheduleRepository.GenerateNumberSources(schedules[i]); // 每处理一条汇报一次进度 backgroundWorker.ReportProgress((i 1) * 100 / total); } } private void backgroundWorker_ProgressChanged(object sender, ProgressChangedEventArgs e) { toolStripProgressBar.Value e.ProgressPercentage; toolStripStatusLabel.Text $正在生成号源... {e.ProgressPercentage}%; }BackgroundWorker 是 WinForm 处理“耗时操作 UI 反馈”最朴素的方案。它内部封装了线程切换不需要自己写 Invoke 那套模板代码。注意 DoWork 里不要直接操作任何 UI 控件否则会抛线程间非法访问异常。进度条值是按百分比计算的确保 i1 而不是 i否则最后一条数据的进度不会跳满。4. 并发与数据一致性锁号、防重复与退号释放4.1 高并发下的超卖问题为什么不能先查再插入很多初学 WinForm 的人写挂号逻辑是这样的先 SELECT 判断号源是否已挂然后 INSERT 挂号记录。这在单机、单用户下没有问题但门诊高峰期多个护士同时操作时两个事务可能同时查到“未挂”状态然后同时插入记录——超卖就这么发生了。这不是代码逻辑错而是数据库事务隔离级别的问题先查再写需要在 Serializable 级别下才能避免幻读但 Serializable 会显著降低并发性能。正确的做法是把“检查状态 扣减数量 插入记录”放在一个事务里并且把状态检查写成 UPDATE 的条件。比如号源表的核心更新语句可以这样设计只有 status 0未挂时才能把它更新为 1已挂通过“受影响行数”判断是否抢号成功。public bool TryLockNumberSource(int numberSourceId, IDbTransaction tx) { string sql UPDATE NumberSources SET Status 1 WHERE Id Id AND Status 0; var affected _db.Execute(sql, new { Id numberSourceId }, tx); // 如果影响行数为1说明确实从“未挂”改成了“已挂” return affected 1; }这段 SQL 的神奇之处在于数据库的行锁机制保证了同时只有一个事务能把这个 0 改成 1另一个事务的 UPDATE 会一直等待等前一个提交后再执行此时 Status 已经是 1条件不满足affected 0自然返回 false。整个流程不需要 SELECT 判断也不需要乐观锁版本号简洁高效。前提是挂号和更新号源必须在同一个事务里这个事务跨仓储操作所以需要上面说的依赖注入来共享连接事务。4.2 完整挂号流程从锁定号源到写挂号单把上面的思想拼装成完整的挂号服务代码结构应该长这样public bool Register(RegisterCommand cmd) { using var conn _dbFactory.CreateConnection(); conn.Open(); using var tx conn.BeginTransaction(); try { // 1. 先锁定号源失败立刻返回不往下走 bool locked _numberSourceRepo.TryLockNumberSource(cmd.NumberSourceId, tx); if (!locked) { tx.Rollback(); return false; } // 2. 扣减排班的剩余号数冗余字段 int affectedRows _scheduleRepo.DecreaseRemainingCount(cmd.ScheduleId, tx); if (affectedRows 0) { tx.Rollback(); return false; } // 3. 写入挂号记录 _registrationRepo.Insert(new Registration { PatientName cmd.PatientName, IdCard cmd.IdCard, ScheduleId cmd.ScheduleId, NumberSourceId cmd.NumberSourceId, CreateTime DateTime.Now }, tx); tx.Commit(); return true; } catch { tx.Rollback(); throw; } }看到这里你应该明白我为什么在前面强调依赖注入了——没有共享的 IDbConnection事务就传不进仓储方法里。整个流程的顺序是固定的先锁号源、再扣排班计数、最后插入记录。顺序不能颠倒因为号源是行级粒度最细的锁先锁它可以把阻塞时间降到最短如果先扣排班行数那这一行排班会被锁住所有挂这个医生号的请求全排成队列并发能力大打折扣。DecreaseRemainingCount 的 SQL 里也要带条件不能无条件自减UPDATE Schedules SET BookedCount BookedCount 1 WHERE Id Id AND BookedCount TotalCount这样即使号源状态被人为改脏排班数量也不会突破上限。双重校验是必要的号源状态和排班数量是两条独立的数据链任何一条不一致都直接中断挂号。4.3 退号与号源释放数据回滚的两种陷阱退号逻辑看起来是挂号的逆操作但有两个坑。第一个坑退号时必须校验挂号记录的状态。一张挂号单可能已经就诊、已经取药不能无条件退号。我在 Registrations 表里加个 VisitStatus 字段0 未就诊 / 1 已就诊退号时 SQL 条件带上 WHERE VisitStatus 0如果影响行数是 0说明已经就诊直接拒绝退号。第二个坑退号后号源要怎么处理。最错误的做法是把号源 Status 改回 0然后患者可以再次挂这个号。但这里有个现实问题如果退号的是个“专家号”号源释放后新患者挂到的是同一个号序但就诊排序上可能引发纠纷。我在系统里做的是把号源状态改为“历史号”同时保留挂号记录的状态字段为“已退号”这样号序不再复用报表统计也清晰。具体 SQL 是更新 NumberSources 的 Status 3已退更新 Registrations 的 RegStatus 2已退号。这个设计的代价是当天的可挂总数会统计口径不一致——报表同时要看“释放后可挂数”和“原始号数”这一点在最后统计查询时要特别加减。4.4 锁等待超时配置别让线程卡死并发控制的最后一环是超时设置。默认的 SQLite 锁等待时间是 30 秒如果两个事务同时抢同一行后到的线程会阻塞很久。对于挂号这种高频短事务我建议把等待时间缩短到 3 秒超时直接报“当前正忙请重试”的提示避免客户端长时间无响应。连接字符串里的设置是 Default Timeout3。当然这会导致个别情况下用户点击挂号后弹出重试提示但体验上比“程序假死”好得多。另外WinForm 的 UI 线程和数据库操作要分开——不要在按钮点击事件里同步执行长事务用 async/await 或者 BackgroundWorker 包装一下至少保证事务执行期间窗口可以拖动、可以取消。5. 避坑指南WinForm 挂号系统开发中的 6 个血泪教训5.1 界面假死数据库同步查询卡死 UI 线程现象点击“查询号源”按钮后整个窗体无法拖动、无法关闭过几秒才恢复。原因WinForm 的消息循环被阻塞。在按钮点击事件里直接执行数据库查询查询期间 UI 线程无法处理 Windows 消息。号源列表加载慢的时候症状尤其明显。解决所有数据库操作放到异步方法里。最省事的改法是给查询方法加上 async 关键字和 await Task.Run()但注意不要 async void——按钮事件可以 async void但内部逻辑要包 try/catch未处理异常会导致进程崩溃。5.2 SQLite 数据库被锁死连接没有释放现象程序运行一段时间后所有操作报 database is locked。原因某处代码执行了查询但没有 Dispose 连接连接池里全是未释放的打开连接SQLite 的写锁一直持有着。解决Dapper 的 Query 方法不会自动关连接必须在 using 块里使用连接对象。我见过太多人图省事在 DAL 方法里 new 一个 conn 然后忘了 Dispose。建议是全局搜一下 SqliteConnection确认每个 Open 都有对应的 Dispose 或用 using 包裹。5.3 科室树与排班数据不一致现象切换科室后右边显示的医生排班还是上一个科室的数据。原因TreeView 的 AfterSelect 事件触发时机理解错误。在窗体加载时给 TreeView 赋值了数据源赋值的瞬间也会触发 AfterSelect此时排班数据还没加载完选中的是错误科室。解决加载完科室树后再设置 SelectedNode 为根节点并用一个 bool 字段 _isLoading 挡住事件处理逻辑。等所有初始化完成再将 _isLoading 置 false之后用户点击才会触发加载排班。5.4 退号后号源状态混乱现象退掉一个号重新挂号同一个号序系统提示冲突。原因前面说过号源 Status 改回 0 的做法有风险。如果改成 0 之后新挂号成功但排班表里 BookedCount 没有对应减回去报表对不上。解决退号用独立状态 3已退不走 Status 0。报表统计时用“总号数 已挂 可挂 已退”的口径而不是简单数 Status 0 的和。这个改动虽然只差一个常量但能避免大量对账问题。5.5 DataGridView 列宽自适应引发的性能问题现象号源列表数据量几百条时DataGridView 的 AutoSizeColumnsMode 设为 AllCells 后刷新一次卡两秒。原因AllCells 模式会在每次数据变化时重新计算所有列宽数据量越大计算越慢。这是 DataGridView 最常见的性能杀手。解决改用 Fill 模式的列宽分配或者干脆用前面推荐的 ListView。如果确实要用 DataGridView把 AutoSizeColumnsMode 设为 None手动设固定列宽刷新速度能提升一个数量级。5.6 日期时间处理午别判断的边界现象下午 13:00 挂号时系统默认选择了上午的排班。原因用 DateTime.Now.Hour 12 判断午别没考虑医院午别的实际分界——很多医院上午门诊到 12:00 截止但下午号在 11:30 就开始挂了。解决午别判断不要用时间点用排班表里配置的“开始挂号时间”和“截止挂号时间”字段判断。数据库里排班除了日期、午别必须存号源的起止时间挂号时实时判断当前时间落在哪个区间。否则一到节假日或夏令时调整所有午别判断都是错的。6. 收尾技巧用一张验证清单和三个小扩展把系统做扎实系统开发完不要急着交付先跑一遍我常用的验证清单并发挂号测试开两个实例同时挂同一个号源确认只有一个成功。退号再挂号测试退号后原号序不可再挂但排班总剩余数正确。跨天挂号测试把系统时间改到排班日期的前一天 23:59确认界面能正确切换日期。断网测试医院内网偶尔会抖动SQLite 的响应异常后界面必须弹出明确提示而不是白屏。数据备份恢复测试每天下班前自动备份数据库文件次日能正常恢复。如果还愿意多做一步我建议添三个扩展点。第一个是号源池预生成策略。现在每个排班在创建时就生成所有号源记录如果医院一个月排班几百行数据量尚可。但三甲医院一个科室一天几十个号一个月上万条记录也没问题不需要优化。真正要优化的是号源与排班的级联删除——排班改了日期号源怎么办我的做法是在排班表上加一个版本号字段改排班时把旧号源全部作废置状态 4新排班生成新号源保证历史挂号的追溯性。第二个是统计报表。挂号系统最有价值的数据沉淀是“科室-医生-时段”三维度的挂号量分析。用 Dapper 做一次分组查询即可得出哪个医生上午号最抢手、哪个时段的退号率最高。报表建议导出 CSV 或者用 C# 生成 Word 文档不引入额外的报表控件让运营人员直接用 Excel 分析。第三个是 HIS 接口预留。中小医院的挂号系统迟早要对接医保或 HIS我在 DAL 层之上加了一个 IExternalService 接口预留一个假实现的类后续对接时只需要替换实现类业务层不用动。这套系统我用在一个社区医院的中药房预约场景里后来移植到口腔科复诊挂号时只改了排班的号别字段业务层几乎是原样复用。WinForm 确实不是最时髦的技术但它稳定、可控、单机部署方便的特性刚好卡在医院这种“稳定压倒一切”的环境里。如果你按上面的章节从数据表一路做到并发控制交付一个能真实挂号、退号、统计的小系统花不了两周时间。做完之后你会对 C# 的事务、线程、UI 调度有全新的手感——这些经验换到任何其他技术栈都带得走。希望帮到你。本文还有配套的精品资源点击获取
返回列表