C#短信猫群发源码:PDU编码、队列控速与断点续发 简介这份C#短信群发源码基于短信猫硬件面向需要快速实现批量短信发送的开发者及企业应用学习者可用于营销通知、业务提醒等场景的二次开发与教学实践。压缩包为rar格式共114个文件约721KB以38个cs源码文件为核心配合17个resx与17个resources界面资源、14个gif及6个jpg图片素材、7个dll依赖库和3个exe程序构成较完整的Visual Studio解决方案。已有120人学习下载适合作为C#与GSM短信通信的入门到进阶参考。源码围绕短信猫串口通信、AT指令控制、多线程并发发送、号码与模板管理、Access数据库记录及WinForms界面交互展开并包含用户管理、通讯录、发送记录等业务模块涉及串口参数设置、发送状态跟踪与基础异常处理读者可据此理解企业级短信群发系统的目录组织与关键实现快速裁剪或扩展为自身项目。1. 三千个会员的召回单这套 C# 短信群发源码解决的是什么门店做会员召回三千多个老号码要按消费等级分三批推活动通知走云端短信通道按条计费算下来预算直接顶不住。后来换了个朴素方案机器上插一台短信猫本地跑一套 C# 短信群发程序串口下发 AT 指令成本只剩 SIM 卡的套餐钱。这套源码就是围绕这个场景写的——设备接入、PDU 组包、队列控速、回执对账四块都能跑通适合有本地化部署要求、号码量在几千到几万、能接受单卡每小时几十条上限的场景。2. 工程分层串口设备层、PDU 编码层、任务调度层怎么拆拿到这套代码最容易犯的错是把它当成一个循环里调 Send的小工具。真要跑到几千条的量级分层不清楚就会出各种玄学问题串口被两条指令同时写、短信内容截断、发到一半程序崩了不知道从哪续。项目本身的分层逻辑是清晰的理解它比读某一段具体代码更重要。2.1 目录结构与三层职责划分工程按职责切成了三层设备层只管和串口打交道编码层只管把号码 文本变成一串十六进制调度层管批次、控速、状态落库。UI 层几乎是空壳只负责导入号码和看进度。这种切法的好处是换设备厂商时只动设备层换编码格式时只动编码层。目录 / 文件职责换掉它的代价Device/SerialDevice.cs串口打开、AT 指令收发、超时与重连换 USB 转串口芯片时只需调波特率Device/AtCommand.cs指令常量与状态码判断换厂商要补私有指令Coding/PduEncoder.csPDU 组包、7bit 打包、BCD 号码转换换协议CMPP/SGIP时整块替换Coding/PduDecoder.cs状态报告、上行短信解析同上Service/SmsDispatcher.cs队列、并发闸门、速率控制改并发模型时动这里Service/BatchService.cs批次创建、断点续发、进度统计业务字段变更Repository/SmsTaskRepository.cs任务表读写、状态流转换 ORMUI/WinForms 界面导入 Excel、看日志换成 Web 端可以直接丢掉三层之间靠接口隔离。设备层暴露的是Send(string pdu) - Response编码层暴露的是BuildSubmitPdu(...) - string调度层不知道串口长什么样只认发出去能拿到一个参考号。我一般会把调度层写成不依赖 UI 的形式这样后面加个 HTTP 接口或者控制台守护进程直接把调度层搬过去就行。2.2 接入选型TEXT 模式和 PDU 模式到底选哪个短信猫接进来之后用 AT 指令集的两种模式均可用。TEXT 模式是ATCMGF1然后ATCMGS13800000000直接写正文PDU 模式是ATCMGF0ATCMGS长度后面跟一串十六进制。很多人图省事选 TEXT写两行就能发中文但项目里用的是 PDU原因有三条。第一是字符集不可控。TEXT 模式下编码由设备的固件决定同一段中文在不同批次模块上可能是 GBK、可能是 UCS2甚至有的模块直接截断。血泪经验发出去显示成问号的八成是这一步没统一。PDU 模式里 DCS 字段明确写08UCS2或007bit编码权在自己手里。第二是长短信拼接。超过 70 个汉字的短信必须拆段拆段要在用户数据头UDH里写参考号和段序号TEXT 模式没有地方放这个头。第三是状态报告回执报文本质上也是 PDU统一用 PDU 模式可以让收发走同一套解析器。提示如果设备只支持 TEXT 模式先发ATCSCSUCS2再发ATCMGF1至少把字符集固定下来。但仍然做不了长短信。另外要确认串口是真实串口还是 USB 转串口的虚拟串口。虚拟串口在设备管理器的端口下能看到名字通常是 COM3 以上拔插之后编号可能变代码里最好不要硬编码 COM 号做成下拉框选择选中后立刻发一条AT验证。2.3 数据表设计与发送状态机批次表和任务表是这套代码的骨架。所有发到哪了这条成没成的问题本质都是状态字段设计得够不够细。表里最关键的是把已提交和已送达分开——ATCMGS返回 OK 只代表设备收下了运营商那边可能还失败。CREATE TABLE sms_task ( id BIGINT PRIMARY KEY AUTO_INCREMENT, batch_id CHAR(36) NOT NULL, -- 批次号一次导入一个 phone VARCHAR(20) NOT NULL, content VARCHAR(1000) NOT NULL, msg_ref INT NULL, -- PDU 里的 TP-MR回执靠它对账 seg_index TINYINT NOT NULL DEFAULT 1,-- 长短信的第几段 seg_total TINYINT NOT NULL DEFAULT 1, status TINYINT NOT NULL DEFAULT 0, retry TINYINT NOT NULL DEFAULT 0, device_id VARCHAR(32) NULL, -- 多设备时记录哪台发的 error_code VARCHAR(64) NULL, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, sent_at DATETIME NULL, UNIQUE KEY uk_batch_phone (batch_id, phone, seg_index) );status的取值不是随手定的它对应一条单向为主的状态机0 待发送 → 1 已提交 → 2 已送达 / 3 失败 / 4 待确认。4 待确认是给断点续发留的后路——程序崩溃时那些msg_ref已经写进表、但还没收到回执的行既不能当成失败重发会重复打扰客户也不能当成功只能标记成待确认等下一次启动时用ATCMGL去卡里翻。uk_batch_phone这个唯一索引是防止重复发送的第一道闸门导入时用INSERT IGNORE或ON DUPLICATE KEY UPDATE就能挡掉同一个 Excel 里出现两次的号码。3. 核心代码拆解一条 AT 指令从串口写到回执的完整链路这一章是整套源码最值得看的部分。很多同类项目卡在能发但是不稳问题基本都在这几段串口读写没做互斥、PDU 打包漏了长度字段、长短信参考号重复、队列没有速率控制。3.1 串口打开与 AT 握手先确认口和设备是活的public void Initialize(string portName, int baudRate 115200) { _port new SerialPort(portName, baudRate, Parity.None, 8, StopBits.One) { ReadTimeout 3000, WriteTimeout 3000, NewLine \r\n, DtrEnable true, RtsEnable true }; _port.Open(); SendCommand(ATE0); // 关回显返回里不再混入指令原文 SendCommand(ATCMEE1); // 打开详细错误码失败时能拿到 CMS ERROR: 500 SendCommand(ATCMGF0); // 切 PDU 模式 SendCommand(ATCPMS\SM\,\SM\,\SM\); // 收发都走 SIM 卡存储容量小但通用 SendCommand(ATCNMI2,1,0,1,0); // 新短信主动上报 CMTI SendCommand(ATCSQ); // 顺手读个信号低于 10 就别指望能发了 }DtrEnable和RtsEnable两个开关必须打开不少 USB 模块靠 DTR 电平判断主机是否就绪不开就是连着但不响应。ATE0放在第一条是习惯关掉回显后每条指令的返回里就只有OK和真正的数据解析逻辑能省掉一半正则。ATCMEE1属于排查神器。默认情况下出错只返回一个ERROR你根本不知道是号码格式不对、卡欠费还是模块不支持打开详细错误码之后会返回CMS ERROR: 500这类具体码500 是未知错误515 是设备忙331 是内存满。握手成功的判据不是某条指令返回 OK而是ATCSQ能读到值。信号质量低于 10 时取值 0~31后面发十条能失败三条这种情况我一般直接提示用户换个位置或者接根外置天线。3.2 PDU 组包中英文混排时 DCS 与 7bit 打包怎么写PDU 组包是整个项目最容易写错的一段。规则是这样的开头的 SMSC 长度、第一字节的短信类型、被叫号码的 BCD 倒序、协议标识、编码方式、有效期、用户数据长度、用户数据。少一个字节整条短信就是废的。public static string BuildSubmitPdu(string phone, string text, string udhHex null, byte msgRef 0) { var sb new StringBuilder(); sb.Append(00); // SMSC 长度 0用卡里存的短信中心 // 11 SMS-SUBMIT 不带状态报告31 带状态报告51 带 UDH 头 sb.Append(udhHex null ? 11 : 51); sb.Append(msgRef.ToString(X2)); // TP-MR回执靠它与任务行对上 var number phone.TrimStart(); sb.Append(number.Length.ToString(X2)); // 号码位数注意是十进制转十六进制 sb.Append(phone.StartsWith() ? 91 : 81); // 91 国际格式 / 81 国内格式 sb.Append(ReverseBcd(number)); // TP-DA半字节倒序 sb.Append(00); // TP-PID普通点对点短信 bool ucs2 udhHex ! null || text.Any(c c 0x7F); sb.Append(ucs2 ? 08 : 00); // TP-DCS08UCS2007bit 压缩 sb.Append(00); // TP-VP占位配合上面的 11/31 if (ucs2) { var body Encoding.BigEndianUnicode.GetBytes(text); var udh udhHex null ? Array.Emptybyte() : HexToBytes(udhHex); sb.Append((udh.Length body.Length).ToString(X2)); // TP-UDL 是字节数含 UDH sb.Append(udhHex ?? string.Empty); sb.Append(ToHex(body)); } else { sb.Append(text.Length.ToString(X2)); // 7bit 下 UDL 是字符数不打包后字节数 sb.Append(Pack7Bit(text)); } return sb.ToString(); }号码倒序这一步是新手最容易翻车的13800000000要变成31080000000000F0再拼进去偶数位要补F补齐半字节。写个ReverseBcd就够private static string ReverseBcd(string digits) { if (digits.Length % 2 ! 0) digits F; // 奇数位补 F var sb new StringBuilder(digits.Length); for (int i 0; i digits.Length; i 2) sb.Append(digits[i 1]).Append(digits[i]); return sb.ToString(); }7bit 打包是无状态的那部分代码里唯一有点算法味道的每 7 位一个字符塞进 8 位的字节流跨字节的位要错位合并。private static string Pack7Bit(string text) { var bytes new Listbyte(); int buffer 0, bits 0; foreach (var ch in text) { buffer | (ch 0x7F) bits; // 当前字符低位对齐到 buffer 的 bits 位 bits 7; while (bits 8) { bytes.Add((byte)(buffer 0xFF)); buffer 8; bits - 8; } } if (bits 0) bytes.Add((byte)(buffer 0xFF)); // 收尾不足一字节也要吐出来 return ToHex(bytes.ToArray()); }buffer是移位累加器bits记录当前攒了几位够 8 位就吐一个字节。这段代码的正确性验证方式是拿 hello 手工算一遍期望得到E8329BFD06对不上就说明位序搞反了。注意TP-UDL 在 UCS2 下是字节数在 7bit 下是字符数。这两个单位不一样混着写会得到一条长度对但内容错位的短信接收端看到的是乱码。3.3 长短信拆分UDH 头、参考号与段序号超过 70 个汉字的短信必须拆成多段每段带一个 6 字节的 UDH05 00 03 参考号 总段数 当前段号。05是后面还有 5 字节00 03表示 16 位参考号后面跟 8 位总数和序号——这套写法几乎所有安卓机都认。public static Liststring BuildLongSms(string phone, string text, byte refNo) { const int perSeg 67; // UCS2 下 140 - 6(UDH) 134 字节 67 字符 var segments new Liststring(); for (int i 0; i text.Length; i perSeg) segments.Add(text.Substring(i, Math.Min(perSeg, text.Length - i))); if (segments.Count 255) throw new ArgumentException(单条超过 255 段建议在业务层拆成两条任务); var result new Liststring(segments.Count); for (int i 0; i segments.Count; i) { var udh $050003{refNo:X2}{segments.Count:X2}{(i 1):X2}; result.Add(BuildSubmitPdu(phone, segments[i], udh)); } return result; }refNo在这里是关键。同一个手机号的两条长短信如果参考号撞了接收端会把它们拼到一起用户看到的就是两段毫不相干的话粘成一坨。项目里用msgRef自增再加设备序号做低位叠加保证一分钟内不重复。拆分粒度也踩过坑不是所有模块都支持满 67 字符个别老固件在 UCS2 UDH 组合下会多算两字节导致最后一段发送失败。稳妥做法是把perSeg设成 64牺牲一点利用率换稳定。3.4 发送队列串行化、控速与失败重试串口是独占资源两条指令同时往里写返回就彻底乱了。项目的做法是单设备单消费者用信号量把发送动作串起来同时用一个间隔控制器压住速率。public sealed class SmsDispatcher { private readonly IDevice _device; private readonly ISmsTaskRepository _repo; private readonly SemaphoreSlim _gate new(1, 1); // 串口互斥 private readonly TimeSpan _interval; // 每条之间的最小间隔 public async Task SendAsync(SmsTask task, CancellationToken ct) { await _gate.WaitAsync(ct); try { if (task.Status ! SmsStatus.Pending task.Status ! SmsStatus.Retrying) return; var pdus task.SegTotal 1 ? PduEncoder.BuildLongSms(task.Phone, task.Content, (byte)task.MsgRef) : new Liststring { PduEncoder.BuildSubmitPdu(task.Phone, task.Content, null, (byte)task.MsgRef) }; foreach (var pdu in pdus) { var rsp await _device.SendPduAsync(pdu, ct); // 内部发 ATCMGSlen if (!rsp.IsOk) throw new SmsSendException(rsp.Raw); await Task.Delay(_interval, ct); // 段与段之间也要间隔 } task.Status SmsStatus.Submitted; task.SentAt DateTime.Now; } catch (Exception ex) { task.Retry; task.ErrorCode ex.Message; // 重试三次以内退回待发超过就判死避免一条烂号码卡住整批 task.Status task.Retry 3 ? SmsStatus.Retrying : SmsStatus.Failed; } finally { await _repo.UpdateAsync(task); _gate.Release(); } } }_interval的取值是这套系统里最需要实测的参数。运营商对同一张卡有隐性频控间隔低于 6 秒时前几十条看着正常后面开始成片返回CMS ERROR: 500日志里看不出所以然。项目默认 8000 毫秒也就是一小时 450 条左右实际跑下来比较稳。想再快只能加卡加设备。重试策略上有个细节段与段之间失败不能只重发失败的那一段因为已经发出去的前段在接收端已经缓存了重发会导致拼接错位。所以整条任务重来参考号换新的。4. 群发任务落地号码清洗、批次断点续发与回执对账代码能发出去只是第一步真正上生产要解决的是脏数据崩溃恢复怎么知道到底送达了多少。这三块做不好运营那边永远说不清楚效果。4.1 号码导入与清洗把 Excel 里的脏号码洗干净真实场景里拿到的号码表什么样都有带区号的座机号、带86的、中间夹空格和横杠的、Excel 把长数字转成科学计数法的。不清洗直接发一半的额度浪费在格式错误上。public static IEnumerablestring Normalize(IEnumerablestring raw) { var seen new HashSetstring(StringComparer.Ordinal); foreach (var line in raw) { if (string.IsNullOrWhiteSpace(line)) continue; // 只留数字顺手干掉空格、横杠、括号 var digits new string(line.Where(char.IsDigit).ToArray()); if (digits.StartsWith(0086)) digits digits.Substring(4); else if (digits.Length 13 digits.StartsWith(86)) digits digits.Substring(2); else if (digits.Length 15 digits.StartsWith(001)) // 部分表里带外线前缀 digits digits.Substring(3); if (digits.Length ! 11) continue; // 只保留标准手机号 if (!digits.StartsWith(1)) continue; // 座机、400 一律跳过 if (seen.Add(digits)) yield return digits; // 同一批次内去重 } }seen用HashSet而不是List.Contains十万级的数据量下差别是几十秒和几毫秒。真正的全局去重还要靠前面那张表的唯一索引两层都留着比较保险。导入完之后不要立刻开始发。项目里有个预检步骤统计合法号码数、非法号码数、跨批次重复数把结果摆给操作的人看。这一步看着多余但能挡掉Excel 列选错导致整列都是日期这类事故。4.2 批次任务与断点续发程序崩了之后从哪继续群发跑到一半断电或者进程被杀是常态。断点续发的核心是把已提交但未确认的行单独拎出来处理而不是简单按status 0重跑。public async Task ResumeAsync(Guid batchId, CancellationToken ct) { // 第一步把上一轮遗留的已提交改成待确认它们可能已经发出去了 await _db.SmsTasks .Where(t t.BatchId batchId t.Status SmsStatus.Submitted) .UpdateAsync(t new SmsTask { Status SmsStatus.Uncertain }, ct); // 第二步拉起待确认的和本来就待发的 var pending await _db.SmsTasks .Where(t t.BatchId batchId (t.Status SmsStatus.Pending || t.Status SmsStatus.Retrying || t.Status SmsStatus.Uncertain)) .OrderBy(t t.Id) .Take(1000) // 分页拉别一次性把几万行塞内存 .ToListAsync(ct); foreach (var task in pending) { if (task.Status SmsStatus.Uncertain) task.Status await CheckInBoxAsync(task) ? SmsStatus.Submitted : SmsStatus.Pending; } // 剩下的交给 SmsDispatcher 一批批消费 }CheckInBoxAsync的逻辑是发ATCMGL4把 SIM 卡里所有短信拉出来用msg_ref去匹配能匹配上说明发过了。SIM 卡存储只有 30~50 条容量长期跑必须在收到回执后立刻ATCMGD删除否则会满。这里有个容量陷阱ATCPMSSM,SM,SM用的是卡存储几十条就满。如果要支撑更大的量得看模块是否支持ATCPMSME,ME,ME模块内存或者干脆把回执处理做成即时消费不留存量。4.3 状态回执解析把 DELIVER 报文还原成成功/失败状态报告本身就是一条 PDU只是第一字节的类型不同。项目的解析器先跳过 SMSC 长度字段再看类型是不是0x02STATUS-REPORT最后取末字节当状态位。public static (int MsgRef, bool Delivered)? ParseStatusReport(string pdu) { if (string.IsNullOrEmpty(pdu) || pdu.Length 20) return null; // SMSC 字段首字节是后续字节数整段跳过 int smscLen Convert.ToInt32(pdu.Substring(0, 2), 16); int offset 2 smscLen * 2; if (offset 4 pdu.Length) return null; int firstOctet Convert.ToInt32(pdu.Substring(offset, 2), 16); if ((firstOctet 0x03) ! 0x02) return null; // 不是状态报告 int msgRef Convert.ToInt32(pdu.Substring(offset 2, 2), 16); int status Convert.ToInt32(pdu.Substring(pdu.Length - 2, 2), 16); // 00 表示对方已收到01/02/03 分别是暂时失败、永久失败、尝试中 return (msgRef, status 0x00); }msgRef是十六进制字符串解析出来的要注意msg_ref在数据库里得按同样规则生成——项目里是msgRef (int)(DateTime.Now.Ticks % 200) 1范围卡在 1~200 之间循环配合device_id做联合唯一冲突概率很低。回执的到达有延迟从几秒到几分钟不等。项目里起了一个定时补偿任务每 5 分钟扫一次 30 分钟前提交、状态还是Submitted的行把它们归到Uncertain运营导出时明确标注未确认而不是笼统算成功。这一点比技术实现更重要——把不确定的东西说清楚比给一个好看的送达率数字有价值。5. 避坑与排查串口占用、乱码、限速这五类翻车现场这几条基本都是我在实际部署里撞过的写在这里省得后来的人重复交学费。5.1 打开串口就抛 UnauthorizedAccessException现象程序启动、SerialPort.Open()直接异常提示拒绝访问。原因三种可能——不是三种里的一种上一个进程没退干净还占着句柄厂商自带的测试工具或者手机助手在后台扫描端口USB 转串口驱动装了但没装对版本端口锁不住。解决先在设备管理器把虚拟端口删掉重新扫描再用handle.exe或资源监视器查一下是哪个进程占着。代码里一定要把SerialPort实现IDisposable并在finally里Close()Dispose()别指望 GC 帮你回收。另外重新插拔之后 COM 编号会变做端口列表时用 WMI 查DeviceID里的 VID/PID 更稳。5.2 指令都返回 OK但短信一条没发出去现象日志里全是ATCMGS返回提示符写进去之后返回CMGS: 12 OK看上去全成功手机就是收不到。原因最常见的三个——短信中心号码SMSC没设或设错SIM 卡欠费/未激活短信功能TP-DCS 写成了00但内容是中文接收端按 7bit 解出来是乱码部分运营商会直接丢弃。解决发ATCSCA?读当前中心号读不到就去营业厅或者手动ATCSCA861380XXXX500,145写进去。然后拿一个自己的手机号单发一条纯英文一条纯中文对比能定位到是编码问题还是通道问题。真发不出去的时候把完整 PDU 贴到一个在线解码器里逐字段核对比反复改代码快得多。5.3 中文变问号或者只收到前半句现象对方收到一长串问号或者一条 100 字的通知只到了前 70 个字后面没了。原因前者是 DCS 与内容不匹配UCS2 内容配了 7bit 标识或者 TP-UDL 用了字符数而不是字节数后者是没做长短信拆分超过单段容量之后模块直接截断ATCMGS依然返回 OK。解决中文内容一律走DCS 08UDL 字节数UDH拆分按 67 字符一段。发送前加一道断言if (body.Length 140) throw让问题在代码里炸而不是在用户手机上炸。5.4 跑两三个小时后设备假死现象前两小时一切正常之后所有指令都超时重启程序也不行必须拔插 USB。原因模块固件在长时间高频收发后进入异常状态或者串口接收缓冲区溢出没清理ReadExisting每次读到的都是积压的老数据也可能是 USB 供电不足模块在发射瞬间掉压重启。解决加心跳——每 60 秒发一条AT连续三次无响应就执行软复位ATCFUN1,1再不行就标记设备不可用并告警把任务切到备用设备。串口读之前DiscardInBuffer()清一下。供电问题上带独立电源的 USB HUB别用主板前置口。5.5 同一批客户收到两条一样的短信现象投诉说收到重复通知查表中同一手机号有两条status 2的记录。原因断点续发时把已提交未确认的行当成了待发重新发了一遍或者导入 Excel 时同一批次里有重复行唯一索引建在(batch_id, phone)上但长短信拆分后seg_index不同导致绕过了约束。解决唯一索引要加上seg_index这一列同时ResumeAsync里对Submitted的行必须先Uncertain再核对卡内存储确认没发过才放回Pending。上线前拿 200 个号码跑一遍发到一半 kill 进程再启动的演练这个场景不复现个两三次是不敢上生产的。6. 二次开发把单机群发改造成设备池 接口服务原版是个单机 WinForms 程序加设备只能开多个实例每个实例占一个串口、连一个数据库运营那边完全没法统一看进度。改造方向有两步设备池化、任务接口化。设备池的核心是把IDevice列表包一层健康检查和轮询public sealed class DevicePool { private readonly ListDeviceWorker _workers; private int _cursor; public DevicePool(IEnumerableIDevice devices) _workers devices.Select(d new DeviceWorker(d)).ToList(); public DeviceWorker? Acquire() { // 跳过被熔断的、正在发送中的设备避免排队等同一个串口 for (int i 0; i _workers.Count; i) { var idx Math.Abs(Interlocked.Increment(ref _cursor)) % _workers.Count; var w _workers[idx]; if (w.IsHealthy w.TryEnter()) return w; } return null; // 全忙让调用方退避重试 } }TryEnter内部是SemaphoreSlim(1,1).Wait(0)非阻塞拿锁拿不到就换下一台。这样 8 台设备能真正并行而不是排成一队。健康检查做成独立的定时任务连续失败 3 次就IsHealthy false10 分钟后自动恢复探测避免一台坏设备拖垮整批。第二步是把BatchService抽成 HTTP 接口WinForms 只当一个客户端。核心端点就三个POST /api/batch建批次并导入号码、GET /api/batch/{id}查进度和分段统计、POST /api/batch/{id}/pause暂停。用 ASP.NET Core 最小 API 半天能搭完好处是运营在浏览器里就能看到已发送 / 已送达 / 失败 / 待确认四个数不用远程登录到那台机器上。改造完之后有个验证方法我一直在用造 500 条数据其中故意混入 50 个非法号码、20 个重复号码、10 条超长内容然后跑完整流程。合格的标准是——非法号码全部被预检拦下、重复号码只发一次、超长内容拆分后能拼回原文、中途 kill 进程重启后总数不变。这个用例集每次改完编码层或调度层都要跑一遍比任何单元测试都直接。提示改造前先把msg_ref的生成规则和外键关系理清楚。设备池化之后msg_ref只在单台设备内唯一对账时必须带上device_id联合查询否则回执会匹配到别人的任务上。最后说一句实在的这套代码我前后改过三版前两版都是因为觉得断点续发不重要而在真实批次上翻车。从那以后我每次接类似项目都强制先写进程被 kill 之后怎么恢复这一段写完再写发送逻辑。顺序颠倒过来后面全是后悔药。希望帮到你。本文还有配套的精品资源点击获取