☰
C# WinForm排队叫号系统实战:Remoting与WebAPI双通道通信及多端协同
2026/10/8 16:35:02 网站建设 项目流程

简介:这是一套基于C# WinForm开发的智能排队叫号系统完整源码,面向计算机相关专业课程设计、毕业设计学生及需要搭建叫号业务的中小型项目开发者。系统覆盖预约、取号、微信取号、绿色通道、服务评价与数据统计分析等完整流程,并整合取号端、硬件叫号器、软件叫号器、LED条屏、综合显示屏、消息服务端及语音端等多类终端。主要模块采用C/S架构,数据服务基于Remoting通信并可发布兼容WebAPI,支持通过消息服务自由扩展业务;数据维护模块采用B/S架构与MVC模式,支持响应式布局,可在PC、平板和手机上浏览操作;综合显示屏端为安卓原生应用内嵌B/S方式,支持实时更新与样式维护。资源包共1560个文件,以cs源码、png与jpg界面素材、resx资源、js脚本、dll依赖、csproj工程文件、cshtml视图、css样式及sql脚本等为主,压缩包约25.55MB,目录结构完整。目前已有484人学习,适合作为课程设计参考或二次开发基础。

1. 从取号到语音播报:一套 C# WinForm 排队叫号系统到底能跑通哪些环节

很多人对「排队叫号系统」的印象还停留在医院大厅那块红色 LED 屏,觉得无非是取个号、喊个号。但真正拆过这类项目的人知道,一套能落地的叫号系统,背后是取号端、叫号器、LED 条屏、综合显示屏、消息服务、语音端六七个角色在同时协作,任何一个环节的通信断了,现场就是一片混乱。这份基于 C# WinForm 开发的排队叫号系统(编号 100010339),把预约、取号、微信取号、绿色通道、服务评价、数据统计分析整条链路都做进去了,主模块走 C/S 架构,数据服务用 Remoting 通信并兼容 WebAPI,数据维护模块走 B/S 的 MVC 模式,综合显示屏端则是安卓原生内嵌 B/S。它适合正在做课程设计、需要一套完整可运行案例的在校生,也适合想研究 Remoting 与 WebAPI 双通道通信、多硬件端协同调度的 C# 从业者。下面我按「这套东西怎么搭起来、每个端怎么跑、坑在哪」的顺序,把它拆开讲。

2. 架构选型与通信链路:Remoting 为什么还留着,WebAPI 怎么兼容

2.1 C/S 主链路与 B/S 维护端的分工逻辑

这套系统最值得先搞清楚的是它的分层思路。取号端、软件叫号器、LED 条屏端、综合显示屏端、消息服务端、语音端这些属于实时性要求高的角色,全部走 C/S 架构,用 WinForm 做界面,因为它们要长时间驻留、要跟硬件打交道、要维持长连接。而数据维护模块——也就是管理员配置窗口、业务规则、统计报表这些——走 B/S 的 MVC 模式,支持响应式布局,PC、平板、手机浏览器都能打开。这个分工不是随便定的:叫号这种场景,窗口工作人员点一下「下一位」,语音和屏幕必须在几百毫秒内响应,用浏览器做前端会有明显的延迟和兼容问题;但配置类操作频率低、对实时性没要求,用 B/S 反而方便远程维护,不用每台机器装客户端。

理解了这个分工,再看 Remoting 和 WebAPI 的关系就顺了。Remoting 是 .NET 早期的高性能进程间通信方案,走 TCP 或 HTTP 通道,序列化效率比早期的 WebAPI 高,适合叫号这种高频小数据包的场景。但 Remoting 有个硬伤:跨平台和跨语言能力差,安卓端、微信端没法直接调。所以项目在 Remoting 之上又发布了一套 WebAPI,让 B/S 端和安卓端能通过 HTTP 访问同一套业务逻辑。常见做法是把业务逻辑抽到一个独立的服务层,Remoting 和 WebAPI 都只是这层的「入口适配器」,这样改业务规则时不用动两套代码。

2.2 消息服务端的扩展点设计

消息服务端是这套系统的中枢。取号端取了一个号,要通知综合显示屏刷新队列、通知语音端准备播报、通知对应窗口的叫号器有新任务。如果每个端之间直接互相调用,端一多就是一张蜘蛛网,加一个端要改一圈代码。项目用消息服务做解耦,各端只跟消息服务打交道,订阅自己关心的消息类型。这样扩展业务时——比如加一个「微信推送叫号提醒」——只需要新增一个订阅者,不用动现有端。

我一般会建议把消息类型定义成枚举或常量类,避免各端用字符串硬编码导致拼写不一致。下面是一个消息订阅的骨架写法,用事件或委托模拟消息分发:

// 消息类型定义,集中管理避免字符串硬编码 public enum MessageType { TakeNumber, // 取号 CallNumber, // 叫号 Evaluate, // 评价 GreenChannel // 绿色通道 } // 消息载体,携带业务数据 public class QueueMessage { public MessageType Type { get; set; } public string TicketNo { get; set; } // 号码 public string WindowNo { get; set; } // 窗口号 public DateTime Timestamp { get; set; } } // 消息服务端:维护订阅者列表并分发 public class MessageService { private readonly Dictionary<MessageType, List<Action<QueueMessage>>> _subscribers = new Dictionary<MessageType, List<Action<QueueMessage>>>(); public void Subscribe(MessageType type, Action<QueueMessage> handler) { if (!_subscribers.ContainsKey(type)) _subscribers[type] = new List<Action<QueueMessage>>(); _subscribers[type].Add(handler); } public void Publish(QueueMessage msg) { if (_subscribers.TryGetValue(msg.Type, out var handlers)) { foreach (var h in handlers) h(msg); // 实际项目里这里应做异常隔离,单个订阅者出错不影响其他 } } }

这段代码的关键点在Publish方法:真实项目里一定要给每个订阅者的调用包 try-catch,否则语音端抛个异常,整个分发循环就断了,后面的显示屏端收不到消息,现场就会出现「叫了号但屏幕没变」的玄学问题。参数上TicketNo和WindowNo是核心,Timestamp用于排查消息延迟。订阅者列表用字典按类型分组,避免每次发布都遍历全部订阅者。

2.3 从 Remoting 迁移到 WebAPI 的兼容做法

如果要把这套系统往现代架构上靠,Remoting 那部分可以逐步替换成 WebAPI,但不要一次性砍掉。常见做法是保留 Remoting 通道给已有的 C/S 端,同时把业务逻辑暴露成 RESTful 接口给新端用。迁移时最容易翻车的是序列化差异:Remoting 默认用 BinaryFormatter,WebAPI 默认用 JSON,同一个对象两边序列化出来的字段名和类型可能对不上。解决办法是给数据契约类显式加[DataContract]和[DataMember]特性,控制字段名,两边共用同一套 DTO。

[DataContract] public class TicketDto { [DataMember(Name = "ticketNo")] public string TicketNo { get; set; } [DataMember(Name = "windowNo")] public string WindowNo { get; set; } [DataMember(Name = "createTime")] public DateTime CreateTime { get; set; } }

Name参数显式指定 JSON 字段名,避免 C# 的 PascalCase 和前端习惯的 camelCase 打架。DateTime类型在跨时区场景要特别注意,建议统一存 UTC 或加时区标记,否则统计报表会出现「今天的号算到昨天」这种血泪问题。

3. 取号端与叫号器:WinForm 界面怎么跟硬件和语音端对接

3.1 取号端的业务流程与状态管理

取号端是整个系统的入口,用户点一下「取号」,背后要走一串动作:判断当前业务类型是否在服务时间、检查是否重复取号、生成号码、写入队列、发布取号消息、打印小票。这一串动作里任何一步失败,用户看到的就是「点了没反应」。所以取号端必须做状态管理,把「空闲、处理中、成功、失败」几个状态明确区分开,按钮在处理中要禁用,避免用户狂点导致重复取号。

WinForm 里做这个,我一般用一个状态枚举配合按钮的 Enabled 属性控制。下面是一个简化的取号流程:

private enum TakeState { Idle, Processing, Success, Failed } private TakeState _state = TakeState.Idle; private async void btnTake_Click(object sender, EventArgs e) { if (_state == TakeState.Processing) return; // 防重复点击 _state = TakeState.Processing; btnTake.Enabled = false; lblStatus.Text = "正在取号..."; try { var ticket = await _queueService.TakeNumberAsync(_currentBusinessType); lblStatus.Text = $"您的号码:{ticket.TicketNo}"; _state = TakeState.Success; // 打印小票、发布消息等后续动作 } catch (Exception ex) { lblStatus.Text = "取号失败,请重试"; _state = TakeState.Failed; LogError(ex); // 记录日志便于排查 } finally { btnTake.Enabled = true; _state = TakeState.Idle; } }

async/await在这里很关键:取号要调服务端,如果同步调用会卡住 UI 线程,界面直接假死。_state判断放在最前面,是因为btnTake.Enabled = false到真正禁用之间有个极短的时间窗,快速双击仍可能进来两次。finally里恢复按钮状态,保证异常时界面不会一直卡在禁用状态。

3.2 软件叫号器与硬件叫号器的差异处理

项目里叫号器分两种:软件叫号器和硬件叫号器,而且硬件叫号器标注了「涉密机器不联机」。这个细节很重要——涉密环境的硬件叫号器不能接入网络,只能靠人工或物理方式同步。软件叫号器则是窗口工作人员在电脑上操作,点「下一位」触发叫号。两种叫号器的业务逻辑要分开处理,不能共用一套代码,否则涉密场景下会出问题。

软件叫号器的核心是「叫号」和「重呼」。叫号时要从队列取下一个号,更新窗口状态,发布叫号消息;重呼时号码不变,只重新发布消息。这里有个容易忽略的点:重呼次数要限制,否则用户没来,工作人员一直点重呼,队列就卡住了。常见做法是重呼超过三次自动跳过,把号码标记为「过号」,过号用户回来时可以走绿色通道优先处理。

private int _recallCount = 0; private const int MaxRecall = 3; private void btnCallNext_Click(object sender, EventArgs e) { var next = _queueService.GetNextTicket(_windowNo); if (next == null) { lblTip.Text = "当前无等待号码"; return; } _currentTicket = next; _recallCount = 0; PublishCallMessage(next); } private void btnRecall_Click(object sender, EventArgs e) { if (_currentTicket == null) return; if (_recallCount >= MaxRecall) { _queueService.MarkAsPassed(_currentTicket.TicketNo); // 标记过号 lblTip.Text = "已过号,请叫下一位"; return; } _recallCount++; PublishCallMessage(_currentTicket); }

MaxRecall设成 3 是经验值,设太小用户刚走到窗口就被跳过,设太大后面排队的人等得暴躁。MarkAsPassed要写进数据库,这样统计报表里能看出过号率,方便优化叫号策略。

3.3 语音端与 LED 条屏的触发时机

语音播报和 LED 条屏显示是叫号消息的两个订阅者。它们收到消息后要做的事不一样:语音端要把号码和窗口号合成语音播出来,LED 条屏要把号码滚动显示。这里的关键是触发时机——必须在叫号消息发布后立即触发,不能等数据库写入完成再触发,否则会有明显延迟。

语音合成常见做法是用 Windows 自带的 SAPI,或者第三方 TTS 引擎。播报内容一般是「请 X 号到 X 号窗口」,号码要逐位读还是整体读,取决于业务习惯,医院一般逐位读避免听错。LED 条屏通常通过串口或网络协议通信,不同厂商协议不一样,项目里应该把协议封装成独立的驱动类,换屏时只改驱动不改业务代码。

// 语音播报订阅者 public void OnCallNumber(QueueMessage msg) { string text = $"请 {msg.TicketNo} 号到 {msg.WindowNo} 号窗口"; _speech.Speak(text); // 异步播报,不阻塞消息分发 } // LED 条屏订阅者 public void OnCallNumber(QueueMessage msg) { string display = $"{msg.TicketNo} -> {msg.WindowNo}"; _ledDriver.Send(display); // 驱动内部处理协议差异 }

Speak方法要异步执行,否则语音播报几秒钟,消息分发线程被占住,显示屏端就收不到消息了。_ledDriver把协议细节藏起来,业务层只传字符串,这是换硬件时不翻车的关键。

4. 数据维护端与综合显示屏:B/S 与安卓内嵌怎么协同

4.1 MVC 响应式布局的适配要点

数据维护模块走 B/S 的 MVC 模式,支持 PC、平板、手机浏览。响应式布局的核心是 CSS 媒体查询和弹性栅格,但实际做的时候,表格类页面在手机上体验最差——列太多,横向滚动很难受。常见做法是给表格加一个「卡片模式」,窄屏时每行数据变成一张卡片,字段名和值上下排列。这个切换用 CSS 就能做,不用改后端。

MVC 的 Controller 要区分「页面请求」和「数据请求」。页面请求返回 View,数据请求返回 JSON。如果混在一起,前端 AJAX 调用时容易拿到一坨 HTML。建议给数据接口统一加/api/前缀,路由配置里区分开。

// 页面请求:返回视图 public ActionResult WindowList() { return View(); } // 数据请求:返回 JSON [Route("api/window/list")] public JsonResult GetWindowList() { var list = _windowService.GetAll(); return Json(new { code = 0, data = list }, JsonRequestBehavior.AllowGet); }

JsonRequestBehavior.AllowGet在 GET 请求时必须加,否则 MVC 默认拒绝,前端会收到 500 错误。返回结构统一用{ code, data }格式,前端处理成功失败逻辑时不用每个接口单独判断。

4.2 安卓端内嵌 B/S 的实时更新机制

综合显示屏端是安卓原生应用内嵌 B/S,这个设计的好处是样式可以远程维护,不用重新发 APK。安卓端用 WebView 加载一个本地或远程的 HTML 页面,页面通过 WebSocket 或轮询跟消息服务保持同步。WebSocket 实时性好但断线重连要处理好,轮询简单但有延迟。叫号场景建议用 WebSocket,断线时降级到轮询。

WebView 加载页面时要注意两点:一是开启 JavaScript,二是处理页面加载失败的情况。如果网络断了,WebView 显示空白,现场就尴尬了。常见做法是本地放一个兜底页面,加载远程失败时显示本地页面并提示「网络异常」。

// 安卓端 WebView 配置 WebView webView = findViewById(R.id.webview); WebSettings settings = webView.getSettings(); settings.setJavaScriptEnabled(true); // 必须开启 settings.setDomStorageEnabled(true); // 本地存储 webView.setWebViewClient(new WebViewClient() { @Override public void onReceivedError(WebView view, int errorCode, String description, String failingUrl) { view.loadUrl("file:///android_asset/fallback.html"); // 兜底页面 } }); webView.loadUrl("http://your-server/display");

setDomStorageEnabled不开的话,页面里用 localStorage 存配置会失效。onReceivedError里加载本地兜底页面,保证断网时屏幕不是一片白。

4.3 数据统计分析的实现路径

统计分析模块要出报表,数据来源是取号记录、叫号记录、评价记录。常见做法是在数据库里建汇总表,定时任务每天凌晨跑一次,把明细数据聚合成日报、周报、月报。查询时直接查汇总表,避免每次都对大表做聚合。如果数据量不大,也可以实时查,但要有索引支撑。

评价数据要特别注意:用户可能不评价,评价可能是恶意的。统计时要区分「未评价」和「差评」,不能把未评价算成差评。常见做法是评价表里存一个score字段,未评价存 null,统计时单独算参评率。

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

5.1 现象:叫号后语音播报延迟好几秒

原因通常是语音播报同步执行,阻塞了消息分发线程。消息服务端在Publish时遍历订阅者,如果语音订阅者的Speak方法是同步的,要等语音播完才继续下一个订阅者,显示屏端就跟着延迟。解决是把语音播报放到独立线程或线程池里执行,Publish里只负责触发,不等待结果。

5.2 现象:Remoting 连接偶尔断开,重连后状态丢失

Remoting 的长连接在网络抖动时会断,客户端如果没做重连和状态恢复,断线期间的操作就丢了。解决是给 Remoting 客户端加心跳检测和自动重连,重连后重新订阅消息、重新拉取当前队列状态。状态恢复比单纯重连更重要,否则重连上了但界面显示的还是断线前的旧数据。

5.3 现象:安卓端 WebView 白屏,日志里没有明显报错

白屏最常见的原因是 HTTPS 证书问题或混合内容拦截。如果页面是 HTTPS 但里面请求了 HTTP 接口,安卓默认会拦截。解决是在 WebView 配置里允许混合内容,或者统一用 HTTPS。另一个原因是 WebView 版本太老不支持某些 JS 语法,解决是降级 JS 写法或升级 WebView。

5.4 现象:取号端重复取号,同一个人拿到两个号

原因可能是按钮防重复没做好,也可能是网络超时后用户重试。前者靠状态管理解决,后者要在服务端做幂等——同一个用户、同一个业务类型、短时间内只允许取一个号。服务端幂等判断要基于用户标识和时间窗,不能只靠前端。

5.5 现象:统计报表数字对不上,明细和汇总差几条

原因通常是汇总任务执行时明细还在写入,或者时区处理不一致。解决是汇总任务加时间边界,只统计边界之前的数据;时区统一用服务器时间或 UTC,不要混用。另外,删除或修改明细数据时要同步更新汇总,否则汇总表会越来越偏。

6. 进阶技巧:用消息轨迹和压测把系统稳定性摸清楚

这套系统跑起来不难,难的是在高并发和长时间运行下不出问题。我一般会做两件事:消息轨迹记录和压测。

消息轨迹是在消息服务端给每条消息加一个唯一 ID,记录它的发布时刻、被哪些订阅者消费、消费耗时。这样出问题时能快速定位是哪个环节慢了。实现上可以用一个环形缓冲区存最近 N 条轨迹,避免内存无限增长。

public class MessageTrace { public string MessageId { get; set; } public MessageType Type { get; set; } public DateTime PublishedAt { get; set; } public List<TraceEntry> Entries { get; set; } = new List<TraceEntry>(); } public class TraceEntry { public string SubscriberName { get; set; } public DateTime ConsumedAt { get; set; } public long ElapsedMs { get; set; } }

ElapsedMs是排查性能问题的关键,如果某个订阅者耗时突然从几毫秒涨到几百毫秒,说明它内部出了问题。环形缓冲区大小根据消息频率定,叫号场景一般存最近一千条够用。

压测是另一件必须做的事。用工具模拟几十个取号端同时取号、多个窗口同时叫号,观察消息延迟和数据库压力。压测时重点看三个指标:消息从发布到消费的最大延迟、数据库写入的排队时间、语音和显示屏的响应时间。如果延迟随并发数线性增长,说明有串行瓶颈;如果突然跳变,说明有锁竞争或连接池耗尽。

还有一个容易被忽略的点:日志。这套系统涉及多个端,出问题时如果每个端各自记日志,排查时要一台台机器翻。常见做法是统一日志格式,带上消息 ID 和端标识,集中收集。这样一条消息从取号到播报的完整链路能串起来看,比在多个日志文件里大海捞针强得多。

从那以后我每次接手这类多端协同的项目,都强制先跑一遍消息轨迹和压测,确认每个环节的延迟和容量边界,再谈功能扩展。希望帮到你。

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

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

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

立即咨询