简介:基于C#(WinForm)开发的排队叫号系统项目,覆盖智能排队全流程:预约、取号、微信取号、绿色通道、服务评价与数据统计分析,并整合取号端、软件/硬件叫号器、LED条屏端、综合显示屏端、消息服务端及语音端等多类终端。项目采用C/S架构,Remoting通信可发布并兼容WebAPI;数据维护模块以B/S+MVC实现,支持响应式布局;综合显示屏端用安卓原生内嵌B/S方案,便于实时更新和样式维护。资源共1560个文件,以C#源码(cs)为主,搭配界面图片(png)、资源文件(resx)、前端脚本(js)、页面模板(cshtml)及动态库(dll)等,压缩包25.55MB,结构清晰,适合系统学习多端协同与消息服务扩展。已有483人学习,可作为课程设计或企业排队系统开发的完整参考。
1. 排队叫号系统是什么:一张取号小票背后的四个角色
医院门诊、政务大厅、银行网点里那台吐小票的取号机,背后是一套C# WinForm排队叫号系统。它的核心不是界面,而是“号池管理 + 多窗口协同 + 状态流转”:患者取号、窗口叫号、过号重呼、语音播报、副屏显示,每一步都在操作同一个号码状态机。WinForm 在这个场景里依然是合理选择,部署在 Windows 窗口电脑上,开机自启,不依赖浏览器,开发调试效率比 WPF 高,第三方控件生态也成熟。它本质上是一台轻量级“上位机”,管理的不只是本地队列,还要和 LCD 屏、语音盒、多个窗口终端对话。这篇文章会把号池设计、多窗体通信、局域网广播和踩坑记录一次讲透,适合要做课程设计、诊所分诊或政务窗口叫号系统的开发者。
2. 号池与队列设计:用 ConcurrentQueue 管住并发取号
一个叫号系统的地基是号池。取号终端可能同时有 2 到 3 台,窗口端有 5 到 10 个,如果还接了自助机,同一秒内可能发生多次取号和叫号。号池设计得不好,会出现重号、漏号、叫了 A 窗口却来了 B 号的问题。
2.1 为什么用 Queue 而不是数组或 List
很多初学者会用List<int>存号码,取号时Add,叫号时从头部取。这在单线程下能跑,但一旦取号端和窗口端同时操作,List的索引会错乱。C# 中数组和集合的核心区别在这里体现得很直接:数组容量固定,扩容要手动处理;集合类里Queue<T>天生就是先进先出的语义,正好匹配叫号逻辑——先取的号先被叫。而ConcurrentQueue<T>更进一步,内部做了线程安全处理,多线程下入队出队不会破坏数据结构。
我一般会封装一个NumberQueue类,把取号、叫号、状态变更都收拢到这一个类里,不散落在窗体事件中。
public class NumberQueue { // ConcurrentQueue 保证多线程下入队出队安全 private ConcurrentQueue<NumberItem> _waitingQueue = new ConcurrentQueue<NumberItem>(); private readonly object _lock = new object(); private int _currentNumber = 0; // 取号:生成新号并入队 public NumberItem TakeTicket(string groupName) { int number; lock (_lock) { _currentNumber++; number = _currentNumber; } var item = new NumberItem { Number = number, GroupName = groupName, Status = TicketStatus.Waiting, CreateTime = DateTime.Now }; _waitingQueue.Enqueue(item); return item; } // 叫号:从队首取一个号码,置为 Calling public NumberItem CallNext(string windowId) { if (_waitingQueue.TryDequeue(out NumberItem item)) { item.Status = TicketStatus.Calling; item.CallWindow = windowId; item.CallTime = DateTime.Now; return item; } return null; // 队列为空 } }逻辑说明:TakeTicket负责生成号码并入队;CallNext从队首取出号码并标记为正在呼叫。ConcurrentQueue的TryDequeue在队列为空时不会抛异常,而是返回false,所以调用方只需要判空即可。
参数说明:如果业务上需要分科室/分窗口组(比如内科、外科、采血),groupName参数用于区分队列;_currentNumber用lock保护,避免两台自助机同时取号时拿到同一个号码。窗口号windowId会记录在号码上,后续统计“哪个窗口处理了多少号”直接用这个字段。
提示:如果只用一个自增整数做日期前缀,例如“A001”,需要把日期和序号拼接放在同一个方法里,不要在 UI 层做拼接,否则并发下会出现重复前缀。
2.2 状态表:叫号系统的“后悔药”机制
队列只是存储,真正的业务规则在状态流转里。一个号码至少要经过:Waiting(等待)→ Calling(呼叫中)→ Done(已完成)/ Missed(过号)。过号之后还要允许重呼,这相当于给顾客一颗“后悔药”。
状态不建议用枚举直接在内存里标记,因为窗口端重启、取号机断电后,内存状态全部丢失。我一般会维护一张状态表,用数据库或文本文件持久化,每次状态变更都写一条记录。这样做有两个好处:一是窗口端掉线重连后能恢复叫号进度;二是能算“平均等待时长”——这个指标在门诊大厅的大屏上很能说明问题。
public class NumberStateMachine { private NumberItem _item; private readonly object _stateLock = new object(); public NumberStateMachine(NumberItem item) { _item = item; } // 只有特定的状态迁移是被允许的 public bool Transition(TicketAction action) { lock (_stateLock) { switch (_item.Status) { case TicketStatus.Waiting: if (action == TicketAction.Call) { _item.Status = TicketStatus.Calling; return true; } break; case TicketStatus.Calling: if (action == TicketAction.Done) { _item.Status = TicketStatus.Done; return true; } if (action == TicketAction.Miss) { _item.Status = TicketStatus.Missed; return true; } break; case TicketStatus.Missed: // 过号重呼:允许把 Missed 转回 Calling if (action == TicketAction.Recall) { _item.Status = TicketStatus.Calling; return true; } break; } return false; // 非法迁移 } } }逻辑说明:Transition是状态流转的唯一入口,加锁避免多个窗口同时操作同一个号码。非法迁移返回false,调用方可以弹提示或记录日志。比如一个已经 Done 的号码不能再次转回 Calling,防止误操作。
参数说明:TicketAction枚举建议定义为Call / Done / Miss / Recall四项,对应窗口端按钮“呼叫下一个”“完成”“过号”“重呼”。Recall是过号补救,也是实际业务中最高频的操作之一——顾客刚走开,回头又来了,窗口护士要能一键把号码重新置为呼叫中。
2.3 无号可叫时怎么办:空队列策略
实际运营中,窗口端点击“呼叫下一个”时经常遇到队列为空。这时如果不做处理,直接返回null,界面会闪一下没反应,用户以为系统卡了。我一般会让窗口端显示“当前无等待号码”并播放提示音,但不清空屏幕——上一号的信息留在显示屏上,直到新号码呼出。
还有一种情况:所有号码都被叫过且完成,队列里确实没号,但大屏上还显示着上一个号码。这时要把屏幕置灰或显示“请取号”字样,否则患者会一直在那个窗口排队等待。这个逻辑放在窗口端的 UI 刷新方法里,用一个定时器每 1 秒检查队列状态。
3. 多窗体协作叫号:委托与事件串起取号机、窗口端和语音播报
WinForm 项目天然是多窗体的:主窗体管总览,取号窗体管发号,窗口端是 5 到 10 个独立窗口,还可能有一个独立的语音播报窗体。跨窗体通信如果直接持有窗体实例引用,代码会耦合到难以维护。C# 的委托和事件机制在这里是标配解法。
3.1 用事件广播代替窗体间直接引用
窗口端点击“呼叫下一个”时,主窗体要知道、语音播报窗体要知道、LCD 副屏也要知道。如果每个窗体都互相引用,7 个窗口就是 42 条引用关系。常见做法是定义一个事件总线或中间类,所有窗体只订阅事件、不持有对方实例。
public class CallCenter { // 单例:整个进程共享 public static CallCenter Instance { get; } = new CallCenter(); // 事件:窗口端发起呼叫 public event Action<CallMessage> OnCallRequested; // 事件:系统完成叫号,所有订阅者收到通知 public event Action<CallMessage> OnNumberCalled; public void RequestCall(CallMessage msg) { OnCallRequested?.Invoke(msg); } public void NotifyNumberCalled(CallMessage msg) { OnNumberCalled?.Invoke(msg); } }窗口端按钮事件里调用CallCenter.Instance.RequestCall(msg),主窗体订阅OnCallRequested处理取号逻辑,语音窗体订阅OnNumberCalled播放语音,LCD 窗体订阅后刷新显示。这样窗口端完全不知道其他窗体存在,加一个新的显示终端只需要多一个订阅者。
// 窗口端:叫号按钮点击 private void btnCallNext_Click(object sender, EventArgs e) { // 组装消息体,带上窗口ID var msg = new CallMessage { WindowId = _windowId, Action = CallAction.Next, Timestamp = DateTime.Now }; CallCenter.Instance.RequestCall(msg); } // 主窗体:订阅事件,执行真正的叫号逻辑 public MainForm() { InitializeComponent(); CallCenter.Instance.OnCallRequested += (msg) => { if (msg.Action == CallAction.Next) { var next = _numberQueue.CallNext(msg.WindowId); if (next == null) { MessageBox.Show("当前无等待号码"); return; } // 通知所有订阅者:号码已呼出 CallCenter.Instance.NotifyNumberCalled(new CallMessage { WindowId = msg.WindowId, Number = next.Number, Action = CallAction.Next }); } }; }逻辑说明:窗口端只管发请求,主窗体处理队列并广播结果。CallMessage里携带WindowId、Number、Action三个核心字段,订阅者按需取用:语音窗体读Number,LCD 窗体读WindowId决定显示在哪个窗口栏位。
参数说明:CallAction.Next对应呼叫下一个,后续还可以扩展CallAction.Recall和CallAction.Miss,窗口端按钮共用同一个事件通道,主窗体按Action分流处理。
3.2 跨线程更新 UI 的潜在坑
事件往往是在后台线程或定时器线程里触发的,而 WinForm 的控件只能在主线程(UI线程)上更新。直接写label1.Text = ...会抛出“线程间操作无效”异常。我见过不少开发者为了省事在窗体构造函数里写了一行CheckForIllegalCrossThreadCalls = false,这等于关掉了安全检查,偶发崩溃查都查不到。
private void OnNumberReceived(CallMessage msg) { // 判断是否需要跨线程调用 if (this.InvokeRequired) { this.BeginInvoke((Action)(() => UpdateDisplay(msg))); } else { UpdateDisplay(msg); } } private void UpdateDisplay(CallMessage msg) { lblCurrentNumber.Text = msg.Number.ToString(); lblWindow.Text = msg.WindowId; }InvokeRequired判断当前线程是否为 UI 线程,不是就用BeginInvoke把更新操作切回 UI 线程。BeginInvoke是异步的,不会阻塞当前线程;这里用Invoke要小心死锁——如果你在 UI 线程里同步等待一个后台线程的结果,而后台线程又调用了Invoke,就会互相卡死。我习惯一律用BeginInvoke。
提示:定时器建议使用 WinForm 的
System.Windows.Forms.Timer,它运行在 UI 线程上,不会触发跨线程问题。如果用的是System.Threading.Timer,回调跑在线程池线程上,就必须处理Invoke。
3.3 窗口端界面:按钮防误触和快捷键
窗口端界面是护士/柜员一直在用的,每天点击几百次。按钮不一定需要花哨,但防误触很重要。“过号”和“完成”两个按钮靠在一起,护士点错一次就会造成实际业务与系统状态不一致。我一般会把“过号”按钮做成按下需要确认的样式,或者在“完成”按钮上做 1 秒防抖——连续点击只生效一次。
private void btnDone_Click(object sender, EventArgs e) { // 防误触:1秒内的重复点击直接忽略 if ((DateTime.Now - _lastClickTime).TotalSeconds < 1) return; _lastClickTime = DateTime.Now; CallCenter.Instance.RequestCall(new CallMessage { WindowId = _windowId, Action = CallAction.Done }); }逻辑说明:_lastClickTime是窗体的私有字段,每次点击先检查时间差。这个防抖只针对“完成”和“过号”,因为这两个操作不可逆(号已经从队列里取出了)。顺便给按钮加上快捷键Alt+C、Alt+D,熟手护士可以完全不碰鼠标。
另外要说一下 WinForm 界面美化。叫号系统的窗口端界面不需要复杂样式,但字体要够大——窗口端屏幕通常离操作员 50 厘米,字号 16px 起步;LCD 副屏的字号要到 48px 以上。我用过的方案是给 Label 设置AutoSize = false+Font加大加粗 +TextAlign = MiddleCenter,背景色用深色底白字,LED 屏的效果就出来了。
4. 局域网窗口通信:TCPListener 多客户端广播与断线重连
当窗口端和取号机不在同一台电脑上时,就需要走局域网通信。一个典型的部署是:管理端电脑做主控(跑 SQLite 或 SQL Server),窗口端是 5 到 10 台瘦客户机,取号机是触摸屏一体机。通信方案首选 TCP,原因很简单:叫号数据量小、实时性要求高、局域网稳定。HTTP 轮询也可以,但延迟和服务器压力都不如长连接。
4.1 服务端:TCPListener 接收多客户端连接
C# 的TcpListener是标准库自带的,不需要引入第三方包。多客户端的关键是每个连接开一个独立线程或用async/await处理。注意客户端断线是非常常见的:窗口电脑重启、网线松动、远程桌面断开,都会让 TCP 连接悄悄死掉。
public class TcpCallServer { private TcpListener _listener; private ConcurrentDictionary<string, TcpClient> _clients = new ConcurrentDictionary<string, TcpClient>(); public async Task StartAsync(int port) { _listener = new TcpListener(IPAddress.Any, port); _listener.Start(); // 循环接收新客户端 while (true) { TcpClient client = await _listener.AcceptTcpClientAsync(); string clientId = client.Client.RemoteEndPoint.ToString(); _clients[clientId] = client; _ = HandleClientAsync(client, clientId); } } private async Task HandleClientAsync(TcpClient client, string clientId) { var buffer = new byte[1024]; NetworkStream stream = client.GetStream(); try { while (client.Connected) { int read = await stream.ReadAsync(buffer, 0, buffer.Length); if (read == 0) { // 对端关闭连接 break; } // 这里解析客户端请求,比如窗口端上报状态 string request = Encoding.UTF8.GetString(buffer, 0, read); OnClientMessage(clientId, request); } } catch (IOException ex) { // 远程主机强迫关闭了连接,常见于客户端断电/拔网线 Console.WriteLine($"[TCP] 客户端 {clientId} 异常断开: {ex.Message}"); } finally { _clients.TryRemove(clientId, out _); client.Dispose(); } } }逻辑说明:AcceptTcpClientAsync每等到一个连接就注册到字典里,并启动独立的任务处理该连接的读写。ReadAsync返回 0 表示对端正常关闭;抛出IOException说明连接被远程主机强制关闭——这是断电、断网时最常见的现象。
参数说明:port推荐使用 9000 以上的高位端口,避免权限和冲突。ConcurrentDictionary管理客户端列表,后面广播叫号消息时遍历即可。缓冲区 1024 字节足够,叫号消息体很小,无非是“WIN01|A012”这样的短串;如果以后要传图片或语音文件,再扩到 4096 或改分包协议。
4.2 广播叫号消息的两种姿势
服务端收到窗口端的叫号请求后,要把新的号码广播给所有窗口端和副屏。广播方式有两类选择:一是遍历_clients逐个发;二是用一个自定义协议标记消息类型,客户端自己决定是否处理。第一种简单直接,局域网内几十个客户端完全够用;第二种适合消息类型多的场景。
public async Task BroadcastAsync(string message) { var data = Encoding.UTF8.GetBytes(message); foreach (var pair in _clients.ToList()) { TcpClient client = pair.Value; try { await client.GetStream().WriteAsync(data, 0, data.Length); } catch (Exception ex) { // 写入失败说明连接已不可用,移除并关闭 Console.WriteLine($"[TCP] 广播失败,移除 {pair.Key}: {ex.Message}"); _clients.TryRemove(pair.Key, out _); client.Dispose(); } } }逻辑说明:_clients.ToList()是必要的——如果直接在 foreach 循环里TryRemove,会抛“集合已修改”的异常。ToList()生成一个快照,循环期间断开连接不影响遍历。
参数说明:消息格式建议用分隔符而不是 JSON,比如"CALL|WIN01|A012|2024-05-20 10:30:00",发送端和接收端各加十行解析代码就能搞定。等消息格式超过 5 种,再考虑引入Newtonsoft.Json或System.Text.Json。
4.3 客户端心跳与自动重连
TCP 长连接的一个残酷现状是:连接断掉时,客户端和服务端都不会立刻知道。客户端断电了,服务端要等下一次写数据才会发现连接已经死了。心跳机制是必须的:客户端每 3 秒发一个PING,服务端收到后回PONG;服务端如果 10 秒内没收到某个客户端的任何数据,就主动断掉这个连接。
客户端的自动重连逻辑,我一般用一个独立的定时器:
private void ReconnectTimer_Tick(object sender, EventArgs e) { if (_tcpClient == null || !_tcpClient.Connected) { try { _tcpClient = new TcpClient(); _tcpClient.Connect(_serverIp, _serverPort); _ = ReceiveLoopAsync(_tcpClient); // 启动接收 UpdateStatus("已连接"); } catch (SocketException ex) { UpdateStatus($"连接失败: {ex.Message}"); // 等下次定时器触发再重试 } } }逻辑说明:Connected属性只代表上一次 I/O 操作时的连接状态,并不保证当前连接是活的,所以重连尝试失败后不要原地重试,等定时器下次触发就好。重试间隔 3 秒比较合适,太短会刷爆服务端日志,太长窗口端空闲太久。
参数说明:_serverIp和_serverPort建议读取配置文件而不是硬编码。用App.config里的appSettings节点,部署时只要改配置文件,不用重编译程序。如果你要部署到多台窗口端,配置文件里还要再放一个WindowId,让每台窗口端启动时知道自己是谁。
4.4 语音播报的可靠性问题
语音播报是叫号系统的“最后一公里”,也是用户感知最强的部分。很多团队用 Windows 自带的 Speech API,也就是System.Speech.Synthesis.SpeechSynthesizer。它的问题在于:首次调用时初始化很慢(有的机器要 1~2 秒),如果在 UI 线程里直接调用,窗口端界面会卡住。
private SpeechSynthesizer _speech = new SpeechSynthesizer(); private void SpeakNumber(string message) { // 异步播报,不阻塞 UI _speech.SpeakAsync(message); }逻辑说明:SpeakAsync是异步的,调用后立即返回,语音播放发生在后台线程。SpeechSynthesizer实例建议全局单例,不要每次播报都 new 一个——创建和释放很耗时,而且多次创建可能报“语音引擎初始化失败”。
参数说明:语音内容建议格式化为“请 A012 号到 3 号窗口”,否则 TTS 会把“A012”读成“啊零一二”。可以在SpeakNumber里做一次字符串替换:“A” → “A ”,数字前加“号”字间隔。重呼时播报“请 A012 号再次到 3 号窗口”,播报前先停止上一段语音_speech.SpeakAsyncCancelAll(),否则两条语音叠在一起。
5. 避坑与排查:这些让叫号系统翻车的细节
叫号系统本身不难,但生产环境里翻车的几乎都是小问题。下面这 5 条是实际出现过的问题,每条都按“现象 → 原因 → 解决”来说明。
5.1 窗口端长时间运行后界面假死
现象:窗口端界面上按钮点了没反应,鼠标悬停变成“等待”图标,持续 10 秒以上,有时直接白屏。
原因:排查后发现是语音播报的SpeechSynthesizer在每次窗口切换时被重新创建,内部清理旧引擎时阻塞了 UI 线程。另一个高发原因是MessageBox.Show在事件里被连续触发——队列为空时窗口端每点一次就弹一次,护士连点五次弹出五个对话框。
解决:SpeechSynthesizer全局单例,只在程序启动时创建一次;MessageBox弹窗加开关控制,同一个 3 秒内只弹一次。另外给窗口端加一个看门狗定时器,每 30 秒检测一次 UI 线程响应时间,超过 3 秒就重启进程——这种兜底能救回一次卡死的现场。
5.2 同时取号打印小票出现重复号
现象:两台取号机同时取号,打出来的小票号码一样;或者号码跳号(A001 之后直接 A003)。
原因:号码自增逻辑写在了 UI 层。两台取号机各自维护了一个currentNumber字段,没有共享同一个数据源。比如取号窗体构造函数里初始化_currentNumber = GetLastNumberFromDb(),然后每次取号_currentNumber++,两台机器同时启动时读到同一个数字。
解决:号码生成必须收敛到单一服务或单一数据表。单机版可以用静态类加锁;网络版必须让服务端分配号码,客户端取号时调服务端接口获取,禁止本地自增。如果用的是 SQLite 做公共存储,用UPDATE ... SET number = number + 1 OUTPUT这类原子操作为号,而不是查出来再写回去。
5.3 TCP 连接在 Windows 防火墙和杀毒软件下被“静默丢弃”
现象:服务端启动后,同一台电脑上的客户端能连接,局域网其他电脑连接超时;或者连接能建立,但广播消息时对端收不到。
原因:八成是 Windows 防火墙没放行TcpListener监听的端口。Windows Server 和 Windows 10/11 都默认开启防火墙,未放行的端口对外表现为“连接超时”。杀毒软件的网络过滤驱动也可能拦截未知进程的监听。
解决:部署时放行 TCP 端口,命令如下(需要管理员权限运行):
netsh advfirewall firewall add rule name="CallSystem" dir=in action=allow protocol=TCP localport=9000name是规则名称,随便起;localport要和TcpListener监听的端口一致。如果局域网里还有多块网卡,要确认TcpListener监听在IPAddress.Any而非127.0.0.1,否则只能本机访问。排查时先用telnet 服务端IP 9000验证端口是否通,不通再检查防火墙和监听地址。
提示:切到
IPAddress.Any监听时,服务端启动日志会显示监听地址是0.0.0.0:9000;如果显示127.0.0.1:9000,说明代码里绑定了环回地址,别急着查防火墙。
5.4 过号重呼时状态不一致
现象:护士对 A005 点“过号”,几秒后又点“重呼”,业务上 A005 应该回到呼叫中,但 LCD 屏显示的还是 A006;或者重呼成功但语音播报读的是上一个号码。
原因:广播消息的处理顺序乱掉了。CallCenter事件是同步触发的,但多个窗体各自用定时器刷新界面,导致 A005 重呼的广播消息到达 LCD 屏时,A006 的显示刷新先执行了,把 A005 覆盖掉了。
解决:LCD 显示屏不要在收到广播后立即刷新,而是把号码放进一个ConcurrentQueue<CallMessage>,用 500 毫秒的定时器统一取出并渲染,保证显示顺序和消息到达顺序一致。语音播报同理,加一个播报队列,宁可延迟 300 毫秒也不要播错号——患者听到错号会直接去错窗口排队。
5.5 数据库连接并发过高导致取号变慢
现象:高峰期同时 3 台取号机操作,取号小票打印明显变慢,有时从 1 秒变成 5 秒,严重时报“数据库已被锁定”。
原因:用了 SQLite 做取号记录持久化,但每次取号都新建一个连接,写操作并发冲突导致锁等待;SQLite 对写操作是串行化的,并发写入会报database is locked。
解决:把取号写库的连接做成单一实例共享,写操作加全局锁串行执行。或者换个思路:取号和叫号记录先写内存队列,由后台线程批量落库,界面响应时间和数据库写入解耦。批量落库还有一个好处——当天营业结束后的报表统计可以一次性读取,不用逐条查询。
6. 进阶落地:日志回放与异常恢复,让系统不再是黑匣子
系统上线后最怕的是“不知道发生了什么”。叫号系统的核心数据是号码状态流转记录,把每次取号、叫号、过号、完成都写成结构化日志,就是一份完整的业务档案。回放日志可以定位很多问题:某个号码等待了多久、哪个窗口处理了多少号、某段时间是否出现了跳号。
推荐做法是写一个同步的日志写入器,独立于业务逻辑。号码状态每次变化时,往日志文件追加一行:时间戳 | 动作 | 号码 | 窗口ID。关键时刻再往 SQLite 里写一条持久化记录,双写保证安全。启动恢复时,内存队列从数据库加载未完成号码,已完成的号码纯日志留存即可。
public static void LogTransition(NumberItem item, TicketAction action) { string line = $"{DateTime.Now:yyyy-MM-dd HH:mm:ss.fff}|{action}|{item.Number}|{item.GroupName}|{item.CallWindow}"; File.AppendAllText(_logPath, line + Environment.NewLine); }文件日志比数据库可靠得多,磁盘满了、数据库锁了,日志文件依然能写。排查“刚才发生了什么”,第一件事就是翻日志,而不是问操作员。日志文件按天分割,保留 30 天,超过自动清理——这能避免日志文件无限膨胀导致磁盘告警。
异常恢复的核心设计是:状态一律以数据库为准,内存队列只是加速层。窗口端重启、主控端重启、断电重启,启动时都从数据库读取当天未完成的号码重建队列,而不是清空重新取号。这样即使叫号系统崩溃,重新启动后护士还能继续处理没有完成的号码,患者手里的票号不会作废。
我自己的习惯是:给每台窗口端加一个启动自检流程,检查 TCP 连接、检查数据库可达性、检查语音引擎初始化,三个检查项全部通过才显示主界面;任何一个失败,弹出明确错误信息而不是放任系统带病运行。这套自检曾经在客户现场救过我一次——一台窗口端被换了位置,IP 变了,自检提示连接失败,部署人员两分钟就定位到了网络配置问题,而不是在系统里翻半天。
把日志、状态恢复和启动自检做成三个小模块,整个系统就有了最基本的可观测性。再往后,可以在这个基础上加一个简单的监控面板,显示各窗口实时的待办号码数和平均处理时长——这些数据对门诊管理者和政务大厅运营者都很有价值。希望这个方向能给你的项目一个扎实的起点,少走我当年踩过的那些坑。
本文还有配套的精品资源,点击获取