ARTICLE DETAIL

资讯详情

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

C# WinForm 排队叫号系统实战:队列状态机与 Socket 通信

C# WinForm 排队叫号系统实战:队列状态机与 Socket 通信 简介这是一套基于C# WinForm开发的智能排队叫号系统完整源码面向计算机相关专业课程设计、毕业设计学生及需要搭建叫号业务的开发者。系统覆盖预约、取号、微信取号、绿色通道、服务评价与数据统计分析等完整流程并整合取号端、硬件叫号器、软件叫号器、LED条屏、综合显示屏、消息服务端及语音端等多个终端主要模块采用C/S架构数据服务基于Remoting通信并可发布兼容WebAPI数据维护模块则采用B/S与MVC模式支持响应式布局综合显示屏端为安卓原生内嵌B/S实现。资源包共1560个文件以938个cs源码、135个png图片、75个resx资源、59个js脚本、52个dll及43个csproj工程文件为主另含cshtml视图、sql脚本与配置文件压缩包约25.55MB。目前已有484人学习适合作为课程设计参考与二次开发基础。1. 从窗口到队列WinForm 排队叫号系统到底在解决什么银行大厅里三台取号机、六个窗口、一块 LED 屏这套东西背后跑的逻辑并不复杂但真用 C# 加 WinForm 从零搭起来坑比想象中多。排队叫号系统的本质是一个「多端共享的队列状态机」取号端生成票号并写入队列呼叫端从队列取号并广播显示端订阅状态并刷新。WinForm 在这里的角色不是「做个好看的界面」而是承载取号机、呼叫器、窗口屏三种不同形态的客户端共用一套队列内核。适合谁看如果你手上有 C# 基础、做过 WinForm 项目案例想接一个营业厅、医院门诊、政务大厅的叫号需求或者想拿它练手 C# 上位机方向的多线程与 Socket 通信这篇就是按我实际交付过的思路拆的。它不依赖任何第三方商业控件核心用 .NET 自带的System.Net.Sockets、System.Media、System.Data.SQLite就能跑通。下面从数据模型开始一路讲到语音播报和断线重连中间把参数和翻车点都标出来。2. 队列内核与数据模型票号怎么生成、状态怎么流转2.1 为什么用「业务类型 日期 序号」三段式票号票号设计直接决定后面查询和显示的复杂度。我见过用纯自增 ID 的结果跨天重启后号码从 1 开始客户拿着昨天的 087 号来问为什么今天也叫 087。常见做法是业务前缀 yyyyMMdd 4 位序号比如A202405170023。前缀区分业务A 个人、B 对公、C VIP日期段保证跨天不冲突序号段每天归零。序号生成不能靠SELECT MAX 1并发取号会撞号。正确做法是在数据库里建一张SequenceCounter表用事务加行锁自增CREATE TABLE SequenceCounter ( BizType TEXT NOT NULL, BizDate TEXT NOT NULL, LastSeq INTEGER NOT NULL DEFAULT 0, PRIMARY KEY (BizType, BizDate) );取号时在同一个事务里UPDATE ... SET LastSeq LastSeq 1再读回SQLite 用BEGIN IMMEDIATE拿写锁避免两个取号机同时读到同一个值。这一步是血泪经验早期版本没加事务高峰期两台取号机撞了三次号客户投诉到经理那里。2.2 队列状态机五个状态和两条不可逆边一张票从生成到结束只有五个状态Waiting等待、Calling呼叫中、Serving办理中、Done完成、Skipped过号。状态流转有两条铁律Waiting → Calling → Serving → Done是主路径Calling → Skipped是唯一允许的旁路且Skipped之后可以重新入队回到Waiting过号重排。用 C# 枚举加一个状态转移校验方法比散落在各处的 if 判断可靠得多public enum TicketState { Waiting, Calling, Serving, Done, Skipped } public static class TicketStateMachine { // 允许的状态迁移表key 是当前状态value 是可达状态集合 private static readonly DictionaryTicketState, TicketState[] Allowed new DictionaryTicketState, TicketState[] { { TicketState.Waiting, new[] { TicketState.Calling } }, { TicketState.Calling, new[] { TicketState.Serving, TicketState.Skipped } }, { TicketState.Serving, new[] { TicketState.Done } }, { TicketState.Skipped, new[] { TicketState.Waiting } }, { TicketState.Done, new TicketState[0] } }; public static bool CanTransit(TicketState from, TicketState to) Array.IndexOf(Allowed[from], to) 0; }逻辑说明Allowed字典把状态机显式化任何一次状态变更前先调CanTransit不合法就抛异常并记日志。参数上Skipped → Waiting这条边对应「过号重排」重排时序号要重新取不能沿用旧号否则显示端会混乱。Done是终态不允许再变防止误操作把已完成的票改回等待。2.3 用 SQLite 做本地队列存储的取舍为什么不用 SQL Server 或 MySQL营业厅场景经常是单机部署或局域网内两三台机器装数据库服务端是负担。SQLite 单文件、零配置配合 WAL 模式读写并发够用。连接字符串里加CacheShared;Journal ModeWAL;多个 WinForm 客户端连同一个 db 文件时不会互相锁死。// 连接字符串共享缓存 WAL 日志适合多客户端读多写少 string connStr Data Sourcequeue.db;CacheShared;Journal ModeWAL;; using (var conn new SQLiteConnection(connStr)) { conn.Open(); // 取号事务立即拿写锁防止并发撞号 using (var tx conn.BeginTransaction(IsolationLevel.Serializable)) { var cmd conn.CreateCommand(); cmd.CommandText INSERT INTO SequenceCounter(BizType, BizDate, LastSeq) VALUES(b, d, 1) ON CONFLICT(BizType, BizDate) DO UPDATE SET LastSeq LastSeq 1; SELECT LastSeq FROM SequenceCounter WHERE BizTypeb AND BizDated;; cmd.Parameters.AddWithValue(b, bizType); cmd.Parameters.AddWithValue(d, DateTime.Now.ToString(yyyyMMdd)); int seq Convert.ToInt32(cmd.ExecuteScalar()); tx.Commit(); } }参数说明IsolationLevel.Serializable保证事务串行化SQLite 会升级为写锁ON CONFLICT ... DO UPDATE是 SQLite 3.24 以上支持的 upsert 语法省去先查后插的两步操作。注意 WAL 模式下 db 文件旁边会多出-wal和-shm两个文件备份时要一起拷只拷.db会丢最近的数据这个坑我踩过一次恢复时少了半小时的号。3. 多端通信Socket 长连接怎么设计才不丢消息3.1 为什么选 TCP 长连接而不是 HTTP 轮询取号机、呼叫器、窗口屏三端要实时同步。用 HTTP 轮询的话窗口屏每 2 秒拉一次高峰期延迟明显而且服务端压力随客户端数量线性涨。TCP 长连接一次握手后持续推送呼叫器一按「呼叫」窗口屏 200ms 内就能刷新。C# 里用TcpListenerTcpClient就够不需要引入额外框架。服务端维护一个客户端列表每个客户端带一个角色标记取号端 / 呼叫端 / 显示端public class ClientSession { public TcpClient Tcp { get; set; } public string Role { get; set; } // ticket / call / display public string WindowNo { get; set; } // 呼叫端才有如 03 public NetworkStream Stream Tcp.GetStream(); }3.2 消息协议定长头 JSON 体的最小实现裸 TCP 是字节流没有消息边界直接Read会粘包。常见做法是「4 字节长度头 UTF8 JSON 体」。发送时先写BitConverter.GetBytes(body.Length)再写 body接收时先读 4 字节拿到长度再按长度读满。// 发送先写 4 字节长度再写 JSON 体 public static void SendMessage(NetworkStream stream, object payload) { string json JsonConvert.SerializeObject(payload); byte[] body Encoding.UTF8.GetBytes(json); byte[] head BitConverter.GetBytes(body.Length); // 小端 4 字节 stream.Write(head, 0, 4); stream.Write(body, 0, body.Length); stream.Flush(); } // 接收循环读满 4 字节头再读满 body public static string ReadMessage(NetworkStream stream) { byte[] head ReadExact(stream, 4); int len BitConverter.ToInt32(head, 0); byte[] body ReadExact(stream, len); return Encoding.UTF8.GetString(body); } private static byte[] ReadExact(NetworkStream s, int count) { byte[] buf new byte[count]; int offset 0; while (offset count) { int n s.Read(buf, offset, count - offset); if (n 0) throw new IOException(连接已关闭); offset n; } return buf; }逻辑说明ReadExact是关键NetworkStream.Read不保证一次读满请求的字节数必须循环。参数上长度头用BitConverter默认小端两端都是 C# 所以不用管字节序如果以后要和非 .NET 端对接得统一成大端。JSON 用 Newtonsoft 或System.Text.Json都行字段名建议全小写加下划线跨语言友好。3.3 心跳与断线重连别等用户发现屏幕卡死长连接最怕「假死」——TCP 连接还在但对端进程已经崩了这边Read一直阻塞。解决办法是心跳客户端每 15 秒发一个{type:ping}服务端回{type:pong}服务端超过 45 秒没收到任何消息就主动断开并清理会话。客户端重连用指数退避别用固定 1 秒死循环否则服务端重启时几十个客户端同时冲击private async Task ReconnectLoop() { int delay 1000; // 起始 1 秒 while (!_cts.IsCancellationRequested) { try { await ConnectAsync(); delay 1000; // 连上后重置退避 await ReceiveLoop(); } catch (Exception ex) { Log.Warn($连接断开{ex.Message}{delay}ms 后重试); await Task.Delay(delay); delay Math.Min(delay * 2, 30000); // 上限 30 秒 } } }参数说明起始 1 秒、翻倍、上限 30 秒是实测下来既能快速恢复又不会打爆服务端的组合。_cts是CancellationTokenSource窗口关闭时取消循环避免后台线程挂着导致进程退不掉——WinForm 里这个坑很常见用户点了关闭但任务管理器里还有残留进程。4. 界面与语音WinForm 显示端和播报的落地细节4.1 窗口屏用 ListView 还是自绘 Panel显示端要滚动展示「请 A0023 号到 3 号窗口」。用ListView的LargeIcon视图能快速出效果但字号、行高、动画控制很别扭。我一般用自绘PanelGraphics.DrawString配合Timer做滚动。核心是双缓冲否则刷新时闪屏// 在自定义 Panel 的构造函数里开启双缓冲 this.SetStyle(ControlStyles.OptimizedDoubleBuffer | ControlStyles.AllPaintingInWmPaint | ControlStyles.UserPaint, true); this.UpdateStyles();逻辑说明OptimizedDoubleBuffer把绘制先画到内存位图再一次性贴到屏幕消除闪烁AllPaintingInWmPaint禁止擦背景消息减少一次重绘UserPaint表示自己处理OnPaint。三个一起开才有效只开双缓冲在快速滚动时还是会闪。字号建议按屏幕分辨率算1080P 下 48px 起步远距离才看得清。4.2 语音播报用 System.Speech 还是预录音频System.Speech.Synthesis.SpeechSynthesizer能直接读中文但机器音在营业厅环境里显得生硬而且不同机器装的语音包不一样有的读「A0023」会念成「A 零零二十三」。稳妥做法是预录「请」「号到」「号窗口」几个片段数字部分用 0-9 十个音频拼接。用SoundPlayer顺序播放// 拼接播报请 业务字母 数字逐位 号到 窗口号 号窗口 private void PlayCall(string bizLetter, string number, string windowNo) { var files new Liststring { 请.wav, ${bizLetter}.wav }; foreach (char c in number) files.Add(${c}.wav); // 逐位读避免整数字读音问题 files.Add(号到.wav); foreach (char c in windowNo) files.Add(${c}.wav); files.Add(号窗口.wav); foreach (var f in files) { using (var player new SoundPlayer(Path.Combine(_voiceDir, f))) { player.PlaySync(); // 同步播放保证顺序 } } }参数说明PlaySync会阻塞当前线程直到播完所以必须放在后台线程里调不能直接在 UI 线程执行否则界面卡住。音频统一用 16bit PCM WAV、22050Hz 单声道兼容性最好。如果嫌拼接麻烦也可以整句预录但业务类型和窗口号组合多了之后文件数量爆炸逐位拼接更灵活。4.3 呼叫器的防重复点击呼叫按钮被连点两次队列里同一张票会被呼叫两遍窗口屏闪两次。处理办法是按钮点击后立即Enabled false等状态变更确认回来再恢复同时服务端对同一票号的Calling状态做幂等校验——已经是Calling的票再收到呼叫请求直接忽略。private async void btnCall_Click(object sender, EventArgs e) { btnCall.Enabled false; try { var next await _queueService.CallNextAsync(_windowNo); if (next null) MessageBox.Show(当前没有等待的号码); } finally { btnCall.Enabled true; // 无论成败都恢复避免按钮永久禁用 } }逻辑说明finally里恢复按钮是必须的早期版本只在成功分支恢复结果一次网络异常后按钮就再也点不动了现场只能重启程序。服务端幂等校验是第二道防线客户端防抖不能替代服务端校验。5. 避坑与排查上线后最常翻车的五个点5.1 现象窗口屏显示乱码中文变成问号原因Socket 传输时两端编码不一致一端用Encoding.DefaultGBK另一端用 UTF8。解决协议里强制统一 UTF8发送前Encoding.UTF8.GetBytes接收后Encoding.UTF8.GetString不要用Default。数据库连接也检查一遍SQLite 默认 UTF8但如果从 Excel 导入过数据可能混入 GBK。5.2 现象高峰期取号机卡顿两三秒才出票原因每次取号都新建数据库连接SQLite 打开文件有开销。解决用连接池或全局持有一个长连接配合 WAL 模式。另外把SequenceCounter的更新和票号插入放在一个事务里减少磁盘同步次数。实测这样改完取号响应从 2.3 秒降到 80ms。5.3 现象语音播报和窗口屏不同步屏上显示了但没声音原因播报放在 UI 线程PlaySync阻塞导致界面刷新延迟看起来像不同步。解决播报丢到独立线程或Task.Run界面刷新走 UI 线程两者通过消息队列解耦。注意SoundPlayer不是线程安全的多个窗口同时播报要加锁或每个窗口用独立实例。5.4 现象服务端重启后客户端全部连不上要手动重启客户端原因客户端重连逻辑里TcpClient对象复用断开后没Dispose就重连底层 socket 句柄泄漏。解决每次重连前先Dispose旧对象新建TcpClient。配合 5.3 节的指数退避服务端重启后客户端能在 30 秒内自动恢复不用人工干预。5.5 现象过号重排后同一个号出现两次原因重排时直接改了原票的状态回Waiting但没重新取序号显示端按序号排序时出现重复。解决过号重排生成一张新票新票号旧票标记Skipped并归档。查询等待队列时只查Waiting状态天然去重。这个逻辑要在状态机里卡死Skipped → Waiting的迁移实际是「作废旧票 创建新票」两步不是原地改状态。6. 进阶把叫号数据变成可分析的运营报表系统跑起来只是开始真正让甲方觉得值的是数据。每天结束营业后把Ticket表按窗口、业务类型、时段做聚合能算出平均等待时长、各窗口办理量、高峰时段分布。用 C# 直接查 SQLite 导出 CSV或者用System.IO.Packaging生成一个简单的 xlsx。// 按小时统计各业务类型的取号量和平均等待时长 string sql SELECT strftime(%H, CreateTime) AS Hour, BizType, COUNT(*) AS Total, AVG((julianday(CallTime) - julianday(CreateTime)) * 86400) AS AvgWaitSec FROM Ticket WHERE date(CreateTime) today GROUP BY Hour, BizType ORDER BY Hour;;参数说明julianday差值单位是天乘 86400 换成秒strftime(%H)取小时。这个查询在几万条数据下毫秒级返回。导出的报表可以每天定时跑用TaskScheduler或 Windows 计划任务触发。验证方法上我习惯在测试环境用脚本模拟 200 个取号、50 次呼叫跑完后核对等待队列长度是否归零、Done数量是否等于取号数减过号数、平均等待时长是否在合理区间。三个数对不上就说明状态机有漏迁。最后说个习惯每次改完状态机或通信协议我一定先在本地起两个客户端互发 1000 条消息压一遍确认没有粘包和状态错乱再上现场。排队叫号这东西平时不出问题一出就是客户站在窗口前干等返工成本比多测十分钟高得多。希望帮到你。本文还有配套的精品资源点击获取
返回列表