ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

C# WinForms医院挂号系统:号源管理、退号回收与并发控制实战

C# WinForms医院挂号系统:号源管理、退号回收与并发控制实战

简介:面向学习C# WinForm与C/S架构开发的读者,这是一套可直接参考的医院挂号管理系统完整项目源码,适合课程设计、毕业设计或桌面管理软件入门练习。系统覆盖用户、科室、医生管理,门急诊挂号、挂号查询、修改口令、挂号单打印及帮助文档等模块,采用MVC分层组织表示层、业务层与数据访问层,数据库操作以存储过程实现,用户密码使用MD5加密,统计信息通过图文报表呈现,并支持医生照片上传。压缩包共185个文件,约10.03MB,包含64个cs源码、42个jpg界面素材、19个resources与19个resx资源文件,另有3个sql数据库脚本、2个rpt报表及chm帮助文档等,便于对照梳理项目结构与部署配置。已有1267人学习,可作为理解WinForm界面布局、分层代码组织、存储过程调用和报表打印流程的实践样例。

1. 为什么选 WinForms 做医院挂号系统:一个桌面方案的定位与边界

医院门诊大厅放一台自助机,或者给收费窗口配一台稳定不出错的电脑,医院挂号管理系统这类项目最怕的从来不是界面不够花哨,而是号源错乱、重复挂号、退号不回收。C# WinForms 做这个场景其实是个挺务实的选择:开发周期短、控件拖拽快、跟 SQL Server 配合紧,局域网内部署不需要折腾浏览器兼容。这套方案面向两类人:一类是拿它当毕设或课程设计的同学,需要一套能跑通科室、医生、排班、挂号、退号全链路的完整代码;另一类是要在小型门诊或者社区诊所落一套桌面挂号工具的开发人员。系统核心就四件事——科室、医生、排班、号源,把这四层关系理清,剩下的界面都只是壳子。下面按需求、建模、架构、模块、避坑、验收的顺序走。

2. 需求梳理与数据模型:从科室到号源的四层结构

2.1 挂号业务的核心实体与字段设计

先想清楚一个患者从进门到拿到号,中间经过哪些数据。科室(Department)是一级分类,下面挂医生(Doctor),医生按天排班(Schedule),每个排班按时间段切出号源(RegistrationSlot)。患者挂号时在号源上落一条挂号记录(Registration),这条记录要能追溯:谁挂的、挂的哪个医生、哪个时段、多少钱、什么状态。这五个实体串起来就是整个系统的骨架,后面所有界面都是在这些实体上做增删改查。

字段设计上,几个容易漏的点:号源表必须有TotalQuota和RemainingQuota两个字段,不能只存一个剩余数,否则排班调整时没法回算;挂号记录要有Status字段,枚举值至少包括已挂号、已就诊、已退号、已作废;金额字段用decimal(10,2),不要用 float,门诊收费对精度敏感;时间字段统一用datetime2,不要混 DATE 和 DATETIME,否则跨天查询时会出现边界判断的坑。

建表 SQL 示例:

CREATE TABLE RegistrationSlot ( SlotId INT IDENTITY(1,1) PRIMARY KEY, ScheduleId INT NOT NULL, SlotDate DATE NOT NULL, StartTime TIME NOT NULL, EndTime TIME NOT NULL, TotalQuota INT NOT NULL DEFAULT 0, RemainingQuota INT NOT NULL DEFAULT 0, Fee DECIMAL(10,2) NOT NULL DEFAULT 0, Status TINYINT NOT NULL DEFAULT 0 -- 0 可用 1 停诊 );

这段建表语句里RemainingQuota单独存的意义在于:每次挂号做的是RemainingQuota = RemainingQuota - 1,而不是COUNT(挂号记录),前者在并发场景下可以直接拿行锁控制,后者每次都要扫记录表,门诊高峰期性能会明显吃紧。TotalQuota保留是为了退号和排班调整时能重新计算剩余数。Status字段的注释要写清楚,否则接手的人不知道 0 和 1 分别代表什么,这种低级问题在答辩时最容易被追问。

2.2 数据库表结构与关系约束

完整的关系大概是:

表名主要字段关联上级
DepartmentDeptId, DeptName, Location无
DoctorDoctorId, DoctorName, DeptId, TitleDepartment
ScheduleScheduleId, DoctorId, WorkDate, ShiftTypeDoctor
RegistrationSlotSlotId, ScheduleId, SlotDate, StartTime, EndTime, TotalQuota, RemainingQuotaSchedule
RegistrationRegId, PatientId, SlotId, RegTime, Status, FeeRegistrationSlot
PatientPatientId, Name, IdCard, Phone无

外键约束我在实际项目里通常只加两级:Registration 对 RegistrationSlot 加 FK,RegistrationSlot 对 Schedule 加 FK。Department 和 Doctor 之间的 FK 加上没问题,但 Schedule 对 Doctor 那一层如果也强约束,后期医生调动科室时删改会变得麻烦,所以我一般留给业务层校验。这不是偷懒,而是桌面小系统的现实取舍——约束太死,科室合并或者医生调岗时要先删一堆关联数据,运维成本反而高。

索引方面,Registration 表在SlotId和PatientId上各建一个非聚集索引。挂号查询按号源和时间段过滤最频繁,这两个索引能兜住大部分列表页。RegistrationSlot 表在(ScheduleId, SlotDate)上建一个复合索引,排班界面查某医生某天的号源时直接命中。不要给每个字段都加索引,小系统里索引过多反而拖慢写入,门诊高峰期挂号插入会变成瓶颈。

提示:不要给 RemainingQuota 加 CHECK 约束大于等于 0 然后就不管了。约束能挡住负数,但挡不住逻辑错误——比如退号时对已经退过的记录再退一次,RemainingQuota 会多出来,约束并不能发现这种正向溢出。真正管用的是退号语句上的状态条件,这在 4.2 节会写到。

2.3 业务规则先写清楚,再动手画界面

这是血泪经验:WinForms 项目最容易犯的错是先拖控件、后想规则。挂号窗口那个界面拖起来太快了,半小时就能摆出一堆 TextBox 和 DataGridView,然后发现号源扣减逻辑没地方放,最后全写进按钮事件里,改一个需求要翻五个文件。我一般会在动手前把业务规则列成一张表,这张表后面就是测试用例的清单。

规则编号规则描述影响模块
R01同一患者同一天同一科室只能挂一个号挂号校验
R02号源剩余数为 0 时禁止挂号号源扣减
R03退号后号源剩余数加 1,挂号记录状态改为已退号退号
R04已就诊的记录不能退号退号校验
R05排班停诊时已挂出的号需要批量退号或改约排班管理
R06开诊前 2 小时内不允许退号退号校验

这张表还有一层价值:验收的时候一条一条过,比口头说“我测过了”要有说服力得多。R06 这种规则最好在需求阶段就跟导师或客户确认,不然后面加进去要改动退号界面和 BLL 两处。

2.4 患者信息与状态流转的设计

患者表看着简单,但有两个设计点容易翻车。第一是身份证号:很多同学直接把IdCard设成主键,结果患者没带身份证时挂号就卡住了。正确做法是自增PatientId当主键,IdCard建唯一索引但允许传空,前端用手机号做备用识别。第二是就诊卡号:如果系统对接了院内就诊卡,患者表要预留CardNo字段,不要等到验收前才想起来加。

挂号记录的状态流转也要画清楚:

已挂号(0) ──就诊──> 已就诊(1) │ └──退号──> 已退号(2) 已挂号(0) ──超时未就诊──> 已作废(3)

状态只能单向流转,已退号的记录不能再改成已就诊。BLL 层写状态变更时用带条件的 UPDATE,比如WHERE RegId = @RegId AND Status = 0,受影响行数为 0 就说明当前状态不允许这次变更,直接返回失败。这种写法比在 C# 里先查再判要可靠,因为查和改之间没有时间窗口。

3. 总体架构与项目分层:UI、业务、数据三层怎么切

3.1 解决方案目录与项目引用关系

WinForms 项目如果只建一个工程,所有代码堆在 Form1.cs 里,到第三周就没人能看懂。我一般按三层拆,另外加一个公共层:

HospitalRegister.sln ├── HospitalRegister.UI // WinForms 窗体 ├── HospitalRegister.BLL // 业务逻辑 ├── HospitalRegister.DAL // 数据访问 ├── HospitalRegister.Model // 实体类 └── HospitalRegister.Common // 工具、枚举、常量

引用关系是单向的:UI 引用 BLL,BLL 引用 DAL 和 Model,DAL 引用 Model,Common 谁都能引用。UI 层绝对不引用 DAL,这条线守住了,后面换数据库或者加缓存的时候改动面才可控。有同学图省事让 UI 直接调 DAL 查下拉框数据,一开始省了二十行代码,后面做权限控制或者加缓存时就得一个个窗体去改。

实体类用普通 POCO,不继承任何框架基类:

public class RegistrationSlot { public int SlotId { get; set; } public int ScheduleId { get; set; } public DateTime SlotDate { get; set; } public TimeSpan StartTime { get; set; } public TimeSpan EndTime { get; set; } public int TotalQuota { get; set; } public int RemainingQuota { get; set; } public decimal Fee { get; set; } public int Status { get; set; } }

实体类保持“哑”状态有个好处:BLL 层可以脱离数据库做单元测试,传几个构造好的对象进去就能验证挂号规则,不用真连库。TimeSpan 存时段比 string 好,做时间比较和格式化都方便,界面上显示时再ToString(@"hh\:mm")即可。

3.2 数据访问层的封装策略

DAL 层我不用 ORM,直接 ADO.NET 手写。原因很简单:挂号系统 SQL 不复杂,但事务边界要求明确,手写反而好控制。封装一个SqlHelper类,把连接、参数、执行三件事包起来:

public static class SqlHelper { private static readonly string ConnStr = ConfigurationManager.ConnectionStrings["HospitalDb"].ConnectionString; // 执行增删改,返回受影响行数 public static int ExecuteNonQuery(string sql, params SqlParameter[] parameters) { using (var conn = new SqlConnection(ConnStr)) using (var cmd = new SqlCommand(sql, conn)) { if (parameters != null && parameters.Length > 0) cmd.Parameters.AddRange(parameters); conn.Open(); return cmd.ExecuteNonQuery(); } } // 查询单条记录,返回 DataTable public static DataTable ExecuteQuery(string sql, params SqlParameter[] parameters) { using (var conn = new SqlConnection(ConnStr)) using (var cmd = new SqlCommand(sql, conn)) using (var adapter = new SqlDataAdapter(cmd)) { if (parameters != null && parameters.Length > 0) cmd.Parameters.AddRange(parameters); var dt = new DataTable(); adapter.Fill(dt); return dt; } } }

参数说明:ConnStr从 App.config 读,不硬编码,这是部署时唯一要改的地方;params SqlParameter[]让调用方传参时不用手动拼接 SQL 字符串,防止注入;using包住连接和命令对象,保证异常时也能释放。这里没有做连接池的特殊处理,因为SqlConnection本身自带池化,频繁 Open/Close 反而是推荐用法。

BLL 层调用 DAL 的时候,我一般把每个业务动作包成一个方法,而不是让 UI 直接拼 SQL:

public class RegistrationBLL { public bool Register(int slotId, int patientId, out string message) { message = string.Empty; // 第一步:检查号源余量 var slot = SlotDAL.GetById(slotId); if (slot == null || slot.Status != 0) { message = "号源不存在或已停诊"; return false; } if (slot.RemainingQuota <= 0) { message = "该时段号源已挂满"; return false; } // 第二步:检查重复挂号 if (RegistrationDAL.ExistsSameDaySameDept(patientId, slot.ScheduleId)) { message = "同一患者当天已挂过该科室号"; return false; } // 第三步:写挂号记录并扣减号源,放在同一个事务里 return RegistrationDAL.InsertAndDecreaseQuota(slotId, patientId, slot.Fee); } }

这个方法的写法有一个取舍:校验和写入分开,但写入必须带事务。校验放在事务外面会有理论上的并发窗口,不过门诊挂号的实际并发量不高,加上号源扣减那一步在 DAL 里用UPDATE ... WHERE RemainingQuota > 0做了兜底,所以这个窗口可以接受。如果换成高并发的互联网挂号,那校验也得进事务。答辩时被问到并发问题,这个取舍要能讲清楚,比背概念得分高。

3.3 配置文件与部署形态

App.config 里至少要有三样东西:连接字符串、挂号费默认值、日志开关。

<configuration> <connectionStrings> <add name="HospitalDb" connectionString="Server=.\SQLEXPRESS;Database=HospitalRegister;Integrated Security=True;" providerName="System.Data.SqlClient"/> </connectionStrings> <appSettings> <add key="DefaultFee" value="15.00"/> <add key="LogEnabled" value="true"/> </appSettings> </configuration>

部署的时候把 UI 项目的输出目录整个拷贝到目标机器,前提是目标机器装了 .NET Framework 4.7.2 或以上和 SQL Server 客户端。这一点在答辩或者交付时要说清楚,很多“在我电脑上能跑”的翻车都出在目标机器缺少依赖。我一般会把依赖清单写在部署说明里:.NET Framework 版本、SQL Server 实例名、数据库备份文件、端口是否放行。别小看这张清单,验收现场装环境的时间比演示代码还长。

3.4 窗体导航与主界面组织

WinForms 的多窗体导航有两种常见做法:一种是每个功能开一个新 Form,另一种是主窗体里放一个 Panel 做容器,功能窗体用TopLevel = false嵌进去。挂号系统我推荐后者,原因是主界面顶部要放科室选择和患者信息区,如果用独立窗体,这些共享状态得在窗体之间传参,麻烦且容易出错。

主界面的结构大概是:顶部状态栏显示当前操作员和系统时间,左侧导航栏放挂号、退号、排班、查询、统计五个入口,右侧是内容区 Panel。切换功能时:

private void SwitchPanel(Form targetForm) { panelContent.Controls.Clear(); targetForm.TopLevel = false; targetForm.FormBorderStyle = FormBorderStyle.None; targetForm.Dock = DockStyle.Fill; panelContent.Controls.Add(targetForm); targetForm.Show(); }

TopLevel = false是嵌入的关键,缺了这句会抛“无法将顶级控件添加到控件集合”的异常。Dock = Fill让子窗体随内容区自适应大小。这种结构的好处是共享的科室下拉、患者信息只维护一份,五个功能界面都能直接读主窗体的属性。

4. 核心模块实现:挂号、退号、号源与排班

4.1 号源扣减与并发控制

号源扣减是整个系统里最不能出错的一步。我的写法是把“检查 + 扣减”合成一条 SQL:

UPDATE RegistrationSlot SET RemainingQuota = RemainingQuota - 1 WHERE SlotId = @SlotId AND RemainingQuota > 0 AND Status = 0;

然后判断受影响行数:返回 1 就是扣减成功,返回 0 就是号源已满或者停诊。配合外层事务,把挂号记录的插入和这条更新绑在一起:

public static bool InsertAndDecreaseQuota(int slotId, int patientId, decimal fee) { using (var conn = new SqlConnection(ConnStr)) { conn.Open(); using (var tran = conn.BeginTransaction()) { try { // 1. 扣减号源,带余量条件 var updateSql = @"UPDATE RegistrationSlot SET RemainingQuota = RemainingQuota - 1 WHERE SlotId = @SlotId AND RemainingQuota > 0 AND Status = 0"; var affected = ExecuteNonQuery(conn, tran, updateSql, new SqlParameter("@SlotId", slotId)); if (affected == 0) { tran.Rollback(); return false; } // 2. 插入挂号记录 var insertSql = @"INSERT INTO Registration (PatientId, SlotId, RegTime, Status, Fee) VALUES (@PatientId, @SlotId, @RegTime, 0, @Fee)"; ExecuteNonQuery(conn, tran, insertSql, new SqlParameter("@PatientId", patientId), new SqlParameter("@SlotId", slotId), new SqlParameter("@RegTime", DateTime.Now), new SqlParameter("@Fee", fee)); tran.Commit(); return true; } catch { tran.Rollback(); throw; } } } }

这段代码的关键点在WHERE RemainingQuota > 0。SQL Server 执行这条 UPDATE 时会拿到行锁,在同一瞬间两个请求进来,一个成功一个拿到 affected = 0,不会出现超卖。如果只在 C# 里读余量再判断,两个线程读到同样的余量,就会双卖。这是挂号系统最典型的翻车点,答辩时能讲清这条 SQL 的原理,比说十句“我做了并发控制”管用。

4.2 退号与号源回收

退号看着简单,实际比挂号更容易出问题。基本逻辑是把挂号记录状态改成已退号,然后把号源剩余数加回去:

-- 退号操作,两步必须在同一事务里 UPDATE Registration SET Status = 2, RefundTime = GETDATE() WHERE RegId = @RegId AND Status = 0; -- 只有已挂号状态才能退 UPDATE RegistrationSlot SET RemainingQuota = RemainingQuota + 1 WHERE SlotId = @SlotId AND RemainingQuota < TotalQuota;

第一条 SQL 的AND Status = 0是防重复退号的关键,受影响行数为 0 就说明这条记录已经不是“已挂号”状态了,直接回滚。第二条的RemainingQuota < TotalQuota是防止人为把剩余数加到超过总数,虽然理论上不该发生,但多一道条件多一层保险。

退号还要考虑业务细节:退号时间距离就诊时间太近要不要限制。我一般加一条规则,开诊前 2 小时内不允许退号,把这个判断放在 BLL 层,用排班的StartTime和当前时间比。这条规则在 2.3 节的 R06 里已经列过,实现时对应一个简单的if判断,但要注意把时段和日期拼成完整 DateTime 再比较,只比 TimeSpan 会忽略跨天情况。

4.3 医生排班与号源生成

排班模块负责给医生按周生成出诊计划,然后根据排班批量生成号源。界面一般是左边选科室和医生,右边一个 DataGridView 列出本周的排班行,每行后面带一个“生成号源”按钮。

号源生成的做法是按排班的班次模板切时间段:

// 根据班次类型生成时段 private List<(TimeSpan start, TimeSpan end)> BuildTimeSlots(string shiftType) { var list = new List<(TimeSpan, TimeSpan)>(); if (shiftType == "上午") { var start = new TimeSpan(8, 0, 0); for (int i = 0; i < 4; i++) { list.Add((start.Add(TimeSpan.FromMinutes(i * 30)), start.Add(TimeSpan.FromMinutes((i + 1) * 30)))); } } else if (shiftType == "下午") { var start = new TimeSpan(13, 30, 0); for (int i = 0; i < 4; i++) { list.Add((start.Add(TimeSpan.FromMinutes(i * 30)), start.Add(TimeSpan.FromMinutes((i + 1) * 30)))); } } return list; }

每个时段配一个默认号源数(比如上午 4 个时段各 10 个号),生成写入 RegistrationSlot 表。这里要注意:重新生成号源前必须先检查该医生该日期是否已经有号源,有的话要么跳过要么提示覆盖,不能直接清空重建,否则已经挂出去的号就找不到号源记录了。这个坑我在项目里踩过,清空表之后挂号记录的外键直接报错,数据修复花了半天。

号源生成的数量建议放在 App.config 里做成可配置项,不同科室的号源数不一样,硬编码在代码里后面改一次要重新编译。

4.4 挂号界面的交互设计

挂号窗口通常是整个系统里用得最频繁的界面,布局要经得起收费员盲操作。我会按从左到右的顺序排:科室下拉、医生列表、日期选择、号源时段面板、患者信息区、确认按钮。这个顺序跟患者排队时的对话顺序一致——“挂哪个科、找哪个医生、哪天、上午还是下午”,界面顺着业务流程走,用户不用来回找。

号源时段面板用 FlowLayoutPanel 动态塞按钮,每个按钮显示“08:00-08:30 余 7”,点击后变高亮。这种设计比 DataGridView 好在点击目标大,收费员不容易点错。代码大概是:

private void LoadSlots(int scheduleId) { flowSlots.Controls.Clear(); var slots = SlotBLL.GetAvailableSlots(scheduleId); foreach (var slot in slots) { var btn = new Button { Width = 140, Height = 60, Text = $"{slot.StartTime:hh\\:mm}-{slot.EndTime:hh\\:mm}\n余 {slot.RemainingQuota}", Tag = slot, Enabled = slot.RemainingQuota > 0 && slot.Status == 0 }; btn.Click += SlotButton_Click; flowSlots.Controls.Add(btn); } }

Tag挂实体对象,点击事件里直接强转拿到 SlotId,避免再查一次数据库。Enabled属性根据余量设置,余量为 0 直接灰掉,比点击后再弹提示体验好。这里有个小细节:按钮的Text里用hh\\:mm转义冒号,TimeSpan 的格式化字符串里冒号是特殊字符,不转义会抛异常,这个问题我见过不止一个同学卡住。

4.5 挂号记录查询与日结统计

查询模块是收费员每天都要用的功能,至少支持三个维度的过滤:按患者姓名、按日期范围、按科室。查询结果用 DataGridView 展示,字段包括挂号编号、患者姓名、科室、医生、时段、金额、状态。状态列用 DataGridView 的 CellFormatting 事件转成中文显示:

private void dgvRecords_CellFormatting(object sender, DataGridViewCellFormattingEventArgs e) { if (dgvRecords.Columns[e.ColumnIndex].Name == "Status" && e.Value != null) { e.Value = Convert.ToInt32(e.Value) switch { 0 => "已挂号", 1 => "已就诊", 2 => "已退号", 3 => "已作废", _ => "未知" }; } }

日结统计按日期汇总当天挂号总数、退号数、实收金额,用一条 SQL 就能出来:

SELECT COUNT(CASE WHEN Status IN (0,1) THEN 1 END) AS 有效挂号数, COUNT(CASE WHEN Status = 2 THEN 1 END) AS 退号数, SUM(CASE WHEN Status IN (0,1) THEN Fee ELSE 0 END) AS 实收金额 FROM Registration WHERE CAST(RegTime AS DATE) = @ReportDate;

CAST(RegTime AS DATE)这个写法会让 RegTime 上的索引失效,数据量大的时候改成RegTime >= @StartDate AND RegTime < @EndDate更稳。小系统里两种写法差别不大,但这个习惯值得从一开始就养成。

5. 避坑与排查:WinForms 挂号系统最容易翻车的五个地方

5.1 界面卡死与跨线程访问

现象:挂号提交后界面转圈几秒,有时候直接白屏假死,收费员以为程序崩了。

原因:把数据库操作写在 UI 线程里,或者用Thread后台查询后直接改控件。WinForms 控件有线程亲和性,跨线程访问会抛InvalidOperationException,有人用Control.CheckForIllegalCrossThreadCalls = false把它关掉,结果是控件状态错乱,问题更隐蔽。

解决:耗时的数据库操作一律走async/await,控件更新回到 UI 线程上下文。挂号按钮的点击事件写成:

private async void btnRegister_Click(object sender, EventArgs e) { btnRegister.Enabled = false; try { var result = await Task.Run(() => RegistrationBLL.Register(slotId, patientId, out msg)); // await 之后回到 UI 线程,可以安全更新控件 if (result) { MessageBox.Show("挂号成功"); RefreshSlotPanel(); } else { MessageBox.Show(msg); } } finally { btnRegister.Enabled = true; } }

finally里恢复按钮可用状态很重要,否则一次异常之后按钮永久灰掉,只能重启程序。

5.2 号源超卖

现象:同一时段显示余量还有 3 个,两个窗口同时点确定,都成功了,最后实际挂出去 4 个号。

原因:校验和扣减分两步,中间没有锁或者事务隔离级别不够。C# 里if (slot.RemainingQuota > 0)判断通过之后,另一个线程已经把余量扣到 0,第一个线程再扣就变负数。

解决:把扣减条件写进 UPDATE 的 WHERE 子句里,用受影响行数判断成败,这在 4.1 节已经写了。另外提醒一句,SELECT ... WITH (UPDLOCK)也能做,但它锁的范围和时机需要经验判断,不如带条件的 UPDATE 直观。验收时如果被问到,直接讲 UPDATE 方案就行,别把 UPDLOCK 搬出来绕晕自己。

5.3 数据绑定与控件状态混乱

现象:切换科室后医生列表还显示上一个科室的医生,或者挂号成功后号源面板没刷新。

原因:WinForms 的 ComboBox 和 DataGridView 数据绑定是“半自动”的,DataSource换了但SelectedIndex不会重置,事件里又没做空值判断,就会在切换瞬间拿到旧数据。SelectedIndexChanged在绑定数据源时也会触发一次,这时候SelectedValue可能是 null,不判断就强转会抛异常。

解决:每次重新绑定数据源之后显式清空选中项和下级控件,并且把SelectedIndexChanged事件的入口加一层 null 判断:

private void cboDept_SelectedIndexChanged(object sender, EventArgs e) { if (cboDept.SelectedValue == null) return; int deptId = (int)cboDept.SelectedValue; cboDoctor.DataSource = DoctorBLL.GetByDept(deptId); cboDoctor.DisplayMember = "DoctorName"; cboDoctor.ValueMember = "DoctorId"; cboDoctor.SelectedIndex = -1; flowSlots.Controls.Clear(); // 下两级面板也要清 }

5.4 退号后号源数量对不上

现象:患者退了号,查询余量没变,重启程序后才变回来;或者退号时提示成功但挂号记录状态还是已挂号。

原因:退号只更新了 Registration 表,忘了更新 RegistrationSlot,或者两条 SQL 不在同一个事务里,第二条失败回滚了。还有一种情况是重复退号,患者连点两次退号按钮,余量加了两次。

解决:退号的 DAL 方法必须把两条 UPDATE 包在一个事务里,并且判断两条 SQL 的受影响行数,任何一条为 0 就整体回滚。测试的时候专门造一个“重复退号”的用例,点两次退号按钮,验证余量只加一次。这个用例在验收时演示出来,比说“我考虑了重复提交”有说服力。

5.5 配置文件路径与部署环境不一致

现象:开发机上跑得好好的,拷到同事电脑上启动就报连接字符串为空,或者报“无法连接到 SQL Server”。

原因:App.config 在编译后变成HospitalRegister.UI.exe.config,如果目标机器上这个文件缺失,或者配置里的 SQL Server 实例名对不上,就会报错。还有一种情况是目标机器装了 SQL Server Express 但没开 TCP/IP 协议,客户端连不上。

解决:部署前检查目标机器三件事,.NET Framework 版本、SQL Server 实例名、连接字符串里的数据库是否已建好。我一般会在程序启动时加一个自检:

private static bool CheckConfig() { var connStr = ConfigurationManager.ConnectionStrings["HospitalDb"]?.ConnectionString; if (string.IsNullOrWhiteSpace(connStr)) { MessageBox.Show("未找到数据库连接配置,请检查配置文件。"); return false; } try { using (var conn = new SqlConnection(connStr)) conn.Open(); return true; } catch (Exception ex) { MessageBox.Show($"数据库连接失败:{ex.Message}"); return false; } }

把它放在 Program.cs 的Application.Run之前,连不上库就弹提示退出,比进了主界面点每个按钮都报错要体面得多。

6. 验收与答辩:怎么演示挂号全流程、怎么讲清设计取舍

验收这件事,我通常分两层准备:一层是功能演示路线,另一层是设计问题的应答。两条线都准备好,现场才不会慌。

功能演示我建议按“一条主线 + 两条支线”走。主线是一次完整挂号:打开系统、选科室、选医生、选日期、点一个号源时段、填患者信息、确认挂号、刷新号源面板看到余量减 1。这条线要控制在两分钟以内,顺序固定,避免现场临时找菜单。支线一是重复挂号校验,用同一个患者再挂一次同科室的号,看系统是不是给出拦截提示;支线二是退号,退刚挂的号,回到号源面板看余量加回去。三条线走完,挂号系统的核心闭环就演示清楚了。

设计答辩常被问到的几个点,我整理成表:

问题应答要点
为什么用 WinForms 不用 Web局域网内部署、无需服务器容器、现场收费终端交互密集、开发周期可控
号源超卖怎么防止带余量条件的 UPDATE 加事务,受影响行数判断,不依赖先查后改
三层架构的边界在哪UI 不引用 DAL,BLL 不碰控件,实体类不继承框架基类
重复挂号怎么判断同患者加同日期加同科室组合查询,放在 BLL 校验并在界面提示
退号后余量怎么恢复两条 UPDATE 同事务,状态条件和余量条件双保险
排班停诊怎么处理停诊时批量退号或改约,已挂出的号要有通知路径

有一类问题要提前想好回答:如果被问到“并发量再大十倍怎么办”,别硬撑说现有方案能扛。老实回答“当前是按门诊并发量设计的,更高并发需要把校验也放进事务,或者引入号源预扣层,用消息队列削峰”,这种回答比强行吹牛得分高,也显得你确实想过边界。

最后说一个我的习惯。每做完一个 WinForms 项目,我会在验收前把整个挂号流程手动走三遍:第一遍正常流程,第二遍故意输错数据看提示,第三遍快速连点按钮看会不会出重复记录。这三遍走完,八成的问题都能提前暴露。从那以后我每次交付前都强制走一遍这个流程,比临时抱佛脚管用。希望帮到你。

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

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进