简介:这份资源是一套完整的排队机叫号系统源代码,面向C#初学者以及正在为排队叫号系统设计发愁的开发者,提供可直接参考的工程实现思路。代码涉及数据库开发与多模块协作,涵盖叫号、屏幕显示、公共类库与数据访问等典型环节,适合用来学习WinForm项目分层、界面与业务逻辑分离以及数据库操作封装。压缩包共249个文件,以172个cs源码文件和44个resx资源文件为主,另含9个csproj项目文件、9个ico图标、6个user用户配置及2个rdlc报表文件,整体约1.29MB,模块划分较细,便于按需拆解研究。目前已有1619人学习下载。作者表示该套系统在客户现场稳定运行多年未出问题,代码未作删减保留,读者可从中获取完整项目结构、数据库访问封装思路与界面模块组织方式,对理解实际排队叫号业务和积累C#工程经验有较高参考价值。
1. 排队机叫号系统:从取号到窗口呼号的完整落地链路
银行大厅里那台吐小票的机器,背后其实是一套典型的「取号—排队—呼号—过号」状态机。这份排队机叫号源代码,核心就是把这套状态机用数据库落盘,再配一个叫号终端和一个窗口显示屏。它适合两类人:一类是手上有个大厅、营业厅、诊室分诊场景,想直接改改就能用的开发者;另一类是拿它当数据库开发练手项目,想看清「并发取号不重号」「窗口呼号不串号」这类问题在真实代码里怎么解的人。技术栈上,这类项目常见做法是 C# WinForms 或 Java Swing 做客户端,MySQL 或 SQL Server 存队列,串口或网络协议驱动 LED 屏和语音播报。下面按「能干什么 → 怎么跑 → 怎么改」拆开讲。
2. 系统架构与数据库设计:队列状态怎么落盘
2.1 三层结构:叫号端、窗口端、显示端
排队机叫号系统看着简单,拆开是三个角色在跑。叫号端(取号机)负责生成号码并写入排队表;窗口端(业务员电脑上的呼叫软件)负责从队列里取下一个号、发起呼号;显示端(LED 屏或副屏)负责把当前叫到的号码和窗口号亮出来。三者不直接互相调用,全部通过数据库这张「公告板」通信——窗口端轮询或订阅排队表,取到号后更新状态,显示端再读状态刷新。
这种设计的血泪经验是:不要用内存队列做主存储。一旦叫号机重启,内存里的号全丢,客户手里的小票就成了废纸。所以核心表必须落库,内存只做缓存。常见做法是排队表加一个status字段(0 等待、1 呼叫中、2 已完成、3 已过号),所有端只认这个字段,谁改谁负责,避免多端各存一份状态导致对不上。
2.2 核心表结构:排队表、窗口表、业务类型表
数据库开发是这个项目的重头。三张表撑起整个系统:业务类型表定义「个人业务」「对公业务」这类分类和前缀字母;窗口表记录每个窗口当前服务的业务类型和状态;排队表是主表,每取一个号插一行。下面是我一般会用的建表语句,字段名可按项目实际调整:
-- 业务类型表:定义取号分类 CREATE TABLE biz_type ( id INT PRIMARY KEY AUTO_INCREMENT, type_name VARCHAR(32) NOT NULL, -- 业务名称,如“个人业务” prefix CHAR(1) NOT NULL, -- 号码前缀,如 A daily_seq INT DEFAULT 0, -- 当日已发号数,用于生成流水号 update_date DATE -- 归属日期,跨天重置 ); -- 窗口表:每个业务窗口一行 CREATE TABLE window_info ( id INT PRIMARY KEY AUTO_INCREMENT, win_no VARCHAR(8) NOT NULL, -- 窗口号,如 03 biz_type_id INT, -- 该窗口办理的业务类型 cur_ticket VARCHAR(16), -- 当前正在呼叫的号码 status TINYINT DEFAULT 0 -- 0 空闲 1 忙碌 ); -- 排队表:核心队列表 CREATE TABLE queue_ticket ( id BIGINT PRIMARY KEY AUTO_INCREMENT, ticket_no VARCHAR(16) NOT NULL, -- 完整号码,如 A012 biz_type_id INT NOT NULL, status TINYINT DEFAULT 0, -- 0 等待 1 呼叫中 2 已完成 3 过号 win_id INT, -- 被哪个窗口叫走 create_time DATETIME DEFAULT NOW(), call_time DATETIME, -- 首次呼号时间 finish_time DATETIME, INDEX idx_status_biz (status, biz_type_id) );逻辑说明:biz_type.daily_seq是发号计数器的落点,每次取号先把它加一,再拼上前缀生成ticket_no。queue_ticket上的联合索引(status, biz_type_id)是关键——窗口端每次取「下一个等待号」都是按这两个字段过滤,没索引的话队列一长查询就慢。参数上,status用 TINYINT 而不是字符串,是为了让状态判断走整数比较,也方便后续加状态值。
2.3 号码生成:并发取号不重号的两种做法
取号机可能不止一台,两台同时按「取号」,如果都读daily_seq再写回,就会发出两个相同号码。这是这类项目最经典的翻车点。合格做法有两种:一是用数据库自增或序列保证唯一,号码显示时再拼前缀;二是用行锁把「读—加—写」包在一个事务里。我一般倾向第二种,因为要按业务类型分别计数,自增列做不到按类型分段。示意代码:
START TRANSACTION; -- 行锁住该业务类型的计数器,防止并发读改写 SELECT daily_seq FROM biz_type WHERE id = ? FOR UPDATE; UPDATE biz_type SET daily_seq = daily_seq + 1, update_date = CURDATE() WHERE id = ?; -- 应用层拿到新值后拼号码,再插入排队表 INSERT INTO queue_ticket (ticket_no, biz_type_id, status) VALUES (?, ?, 0); COMMIT;FOR UPDATE在这里是必须的,它把同一业务类型的取号串行化,代价是并发量不高时完全可接受——大厅取号本来就不是高并发场景。注意跨天重置:update_date和当前日期不一致时,先把daily_seq归零再自增,否则第二天号码会接着昨天的流水走,客户看着别扭。这个判断放在事务里做,避免两台机器同时判断跨天。
3. 叫号与呼号链路:窗口端怎么取号、怎么播报
3.1 窗口取号:从等待队列里捞下一个
窗口端按「呼叫下一位」时,要做的事是:找到本窗口业务类型下status=0的最小号,把它改成status=1并绑定win_id,同时更新窗口表的cur_ticket。这里同样有并发问题——两个窗口同时点呼叫,可能捞到同一个号。解决办法还是事务加锁,或者用「先更新后查询」的乐观方式:
START TRANSACTION; -- 锁定一条等待中的号,按先来先服务 SELECT id, ticket_no FROM queue_ticket WHERE status = 0 AND biz_type_id = ? ORDER BY id ASC LIMIT 1 FOR UPDATE; -- 应用层拿到 id 后更新为呼叫中 UPDATE queue_ticket SET status = 1, win_id = ?, call_time = NOW() WHERE id = ?; UPDATE window_info SET cur_ticket = ?, status = 1 WHERE id = ?; COMMIT;逻辑说明:ORDER BY id ASC保证先取的号先被叫,符合排队语义;FOR UPDATE锁住这一行,另一个窗口的事务会阻塞到它提交,然后重新查到下一条,不会重复。参数biz_type_id来自窗口配置,win_id是当前窗口主键。如果查不到记录(队列空),应用层要给出「当前无等待客户」的提示,而不是报错——这是新手常漏的分支。
3.2 呼号动作:语音、LED 屏、状态回写三件事
取到号之后,窗口端要同时做三件事:把号码和窗口号送到显示端、触发语音播报、把排队表状态推进。显示端如果是 LED 屏,常见做法是通过串口发协议帧,或者走网络 socket 发文本指令;语音播报一般调本地 TTS 或播放预录音频拼接。下面是一段示意性的呼号逻辑(C# 风格,其他语言同理):
// 呼号主流程:先落库,再驱动外设 bool CallNext(int winId, int bizTypeId, string winNo) { var ticket = repo.LockNextWaiting(bizTypeId); // 事务内取号 if (ticket == null) return false; // 队列空 repo.MarkCalling(ticket.Id, winId); // status -> 1 repo.UpdateWindow(winId, ticket.TicketNo); // 窗口当前号 // 外设驱动放在落库之后,失败可重试,不影响队列一致性 display.Send($"{ticket.TicketNo} 请到 {winNo} 号窗口"); speaker.Play($"请 {ticket.TicketNo} 号到 {winNo} 号窗口"); return true; }逻辑说明:顺序很关键——先落库再驱动外设。如果先播报后落库,播报成功但数据库写失败,这个号就「消失」了,客户干等。反过来,落库成功但屏没亮,最多是显示延迟,可以靠重试补。参数winNo是给人看的窗口号,winId是数据库主键,两者别混用。外设驱动建议包一层重试和超时,串口设备掉线是常态,不能让一次发送失败卡住整个呼叫流程。
3.3 过号与重呼:状态机的边界处理
客户没来,业务员点「过号」,status从 1 改成 3;客户回来了要求重呼,得允许把 3 改回 1 并重新播报。这里容易出的问题是:过号的号还占着窗口的cur_ticket,下一个号来了覆盖掉,过号记录就查不到了。所以过号时要把window_info.cur_ticket清空或标记,同时queue_ticket保留win_id和call_time作为审计痕迹。重呼次数建议也记一个字段,防止有人反复重呼刷屏。状态机的每个迁移都要在代码里显式判断前置状态,比如只允许 0→1、1→2、1→3、3→1,其他迁移直接拒绝,别让脏数据把队列搞乱。
4. 运行环境与启动步骤:把项目跑起来
4.1 环境准备:数据库、运行时、外设三块
跑这个项目前先确认三块。数据库:按项目实际选 MySQL 5.7+ 或 SQL Server,建库后执行脚本目录里的建表 SQL,再插入几条biz_type和window_info初始数据,否则取号时查不到业务类型会直接空指针。运行时:C# 项目装对应版本的 .NET Framework 或 .NET,Java 项目装 JDK 8 以上,具体看项目里的工程文件。外设:LED 屏和语音设备如果没有,先把驱动层做成「模拟模式」——把发送指令改成写日志,这样不带硬件也能把主流程跑通。
启动顺序有讲究:先起数据库,再起叫号端,最后起窗口端。叫号端启动时一般会做一次「跨天检查」,把昨天的daily_seq归零;窗口端启动时读窗口配置,绑定业务类型。如果窗口端先起、数据库还没好,连接池初始化会失败,界面卡在登录或白屏,这时候看日志里的连接异常就能定位。
4.2 配置项:连接串、窗口号、外设参数
配置文件里通常有三类参数要改。数据库连接串:主机、端口、库名、账号密码,改完先单独用客户端连一次确认通。窗口号:每个窗口端实例对应一个win_no,同一台机器跑多个窗口实例时要区分开,否则两个窗口抢同一个win_id。外设参数:串口号(如 COM3)、波特率、屏的协议格式、语音音量。下面是一个示意配置:
{ "db": "Server=127.0.0.1;Port=3306;Database=queue_db;Uid=root;Pwd=***;", "window": { "winId": 3, "winNo": "03", "bizTypeId": 1 }, "display": { "port": "COM3", "baud": 9600, "protocol": "led_v1" }, "voice": { "enabled": true, "volume": 80 } }参数说明:winId必须和数据库window_info.id一致,winNo是显示用的字符串;protocol决定发送帧格式,换屏时只改这里和对应驱动类。注意密码别明文提交到代码库,本地调试可以,上线前换成配置中心或环境变量。
4.3 冒烟验证:取一个号、叫一个号、看一次屏
跑起来后按这条链路验证:叫号端取一个号,看queue_ticket是否多了一行且status=0;窗口端点呼叫,看这行是否变成status=1且win_id正确;看显示端是否亮出号码、语音是否播报。三步都通,主链路就活了。再补两个边界:队列空时点呼叫是否给提示不崩溃;连续取 5 个号再依次叫,号码顺序是否严格递增。这两条过了,基本可以拿去现场试。
5. 避坑与常见问题:上线前必须排查的几处
5.1 号码重复:并发取号没加锁
现象:两台取号机同时操作,出现两个 A012。原因:读daily_seq和写回之间没有事务或行锁,两个请求读到同一个旧值。解决:把「读—加—写—插队列表」包进一个事务,SELECT ... FOR UPDATE锁住计数器行;或者改用数据库序列/自增保证唯一。上线前用两个客户端并发压一下取号接口,看号码是否连续无重复。
5.2 呼号串窗口:窗口状态没隔离
现象:03 窗口叫的号,显示在 05 窗口的屏上。原因:多个窗口端共用了同一个显示端连接或同一个win_id,或者显示端按「最后更新的窗口」刷新而不是按窗口号寻址。解决:每个窗口端绑定唯一win_id,显示指令里带上目标窗口号,显示端按窗口号分发;同一台机器跑多实例时,配置文件里的winId必须不同。
5.3 跨天号码不重置:日期判断漏了
现象:第二天取号从 A087 开始,客户觉得奇怪。原因:daily_seq只在初始化时归零,取号逻辑没判断update_date是否等于今天。解决:取号事务里先比对日期,不一致就先把daily_seq置 0 再自增,同时更新update_date。这个判断要和自增放在同一事务,否则两台机器同时跨天会各归零一次、发出重复号。
5.4 外设掉线卡死主流程:驱动没做超时
现象:LED 屏断电后,窗口端点呼叫界面卡住十几秒甚至无响应。原因:串口或 socket 发送是同步阻塞的,设备不在线就一直等。解决:外设发送加超时和异步,失败只记日志并提示「显示设备异常」,不阻断落库和后续呼叫;重连逻辑放后台线程,别放在呼叫主链路里。
5.5 队列查询变慢:缺索引或全表扫
现象:用了一两个月后,点呼叫要等两三秒。原因:queue_ticket数据量涨上来,按status和biz_type_id过滤没走索引,全表扫描。解决:建(status, biz_type_id)联合索引;历史数据定期归档到另一张表,主表只留当天和未完成的记录。归档任务放在凌晨低峰跑,别在营业时间删数据。
6. 二次开发改造:把单机版扩成多窗口多业务的实用技巧
拿到这份源代码后,最常见的改造方向是「多业务类型 + 多窗口 + 数据看板」。原项目如果只支持单一队列,第一步是把biz_type用起来:取号界面加业务选择按钮,窗口配置里绑定可办理的业务类型集合(一个窗口可能办多种业务,这时窗口表要拆出关联表)。第二步是呼号策略,从「严格先来先服务」改成「按业务类型分别排队、窗口按优先级取号」,实现上就是取号 SQL 的WHERE条件按窗口可办业务动态拼,ORDER BY可以加优先级字段。
第三步是做统计看板,这是二次开发里最容易被要求的功能。基于现有表就能算:平均等待时长 =call_time - create_time的均值,按业务类型分组;过号率 =status=3的行数除以总取号数;窗口工作量 = 按win_id统计status=2的数量。下面这条 SQL 可以直接拿去改:
-- 按业务类型统计当日平均等待时长与过号率 SELECT b.type_name, COUNT(*) AS total, AVG(TIMESTAMPDIFF(SECOND, q.create_time, q.call_time)) AS avg_wait_sec, SUM(CASE WHEN q.status = 3 THEN 1 ELSE 0 END) / COUNT(*) AS miss_rate FROM queue_ticket q JOIN biz_type b ON b.id = q.biz_type_id WHERE DATE(q.create_time) = CURDATE() GROUP BY b.type_name;参数说明:TIMESTAMPDIFF单位是秒,看板展示时再换算成分钟;miss_rate是小数,前端乘 100 加百分号。注意call_time为空的记录(还没被叫过)会让AVG偏小,统计时加AND q.call_time IS NOT NULL更准。
改造时还有一个习惯值得坚持:所有状态迁移都走一个统一的服务方法,别在界面事件里直接写UPDATE。我早期图快,在按钮回调里散着写 SQL,后来加「重呼次数限制」时改了七八处,漏了一处就出了重复播报的线上问题。从那以后我每次动状态机,都强制走一遍「取号→呼叫→完成→过号→重呼」的完整回归,确认每个迁移的前置判断都在。希望这份拆解帮到你,把队列跑通只是开始,把状态机守干净才是这类项目长期稳定的关键。
本文还有配套的精品资源,点击获取