☰
C#短信猫群发源码实战:AT指令、UCS2编码与断点续发
2026/10/24 4:40:40 网站建设 项目流程

简介:面向需要快速实现短信群发功能的C#开发者,这份源码以短信猫为硬件基础,围绕号码与模板管理、批量发送、发送记录留存等环节提供可运行项目,适合学习串口通信、AT指令与GSM短信收发的入门及进阶实践。压缩包共114个文件、约721KB,以cs源码和resx/resources资源文件为主体,配合WinForms界面素材、dll与exe程序集、mdb数据库及项目配置文档,覆盖主程序、用户与号码维护、短信发送等模块,项目结构较完整。已有120人学习下载。读者可从中理解C#与.NET框架下的串口参数设置、多线程并发发送、异常处理及数据库读写思路,并参考企业短信群发系统的界面组织、权限管理雏形与模块划分,用于课程设计、毕业项目原型验证或二次开发。

1. 短信群发这套活儿,为什么还有人在用短信猫加 C# 硬扛

前阵子一位做物业系统的朋友找我,说他们每天要给业主发停水停电通知,用云短信接口按条计费,一年账单看着肉疼,问能不能自己发。我翻出压箱底的一套 C# 短信群发源码:插一张 SIM 卡的短信猫,串口线一连,一个 WinForms 界面就能把几千条通知推出去。这套东西适合谁?园区、学校、工厂、门店这类发送量稳定、内容固定、机器就摆在办公室的场景。不适合谁?需要秒级下发验证码、需要上行回复、需要多通道冗余的高并发业务,那就老老实实走云通道。这套 C# 短信群发源码的价值不在于“先进”,而在于链路完全可控:号码在自己手里,日志在自己硬盘上,接口挂了也不影响你发通知。

2. 项目骨架:从目录结构看清这套源码的分层

拿到一份源码,我第一件事不是点运行,而是先看目录。目录骗不了人:分层乱的代码,跑起来也会在第三天给你惊喜。这套短信群发源码的整体风格是典型的“三层半”——UI 只负责收集参数和显示进度,Core 不引用任何 WinForms 类型,Data 单独管 SQLite,剩下半层是给二次开发预留的接口。

2.1 目录结构与模块划分

SmsBulkSender/ ├── SmsBulkSender.sln ├── src/ │ ├── SmsBulkSender.UI/ WinForms 界面层 │ │ ├── Forms/ │ │ │ ├── MainForm.cs 主窗口:任务列表、启停、进度 │ │ │ ├── ContactImportForm.cs 通讯录导入(CSV/TXT) │ │ │ └── SettingsForm.cs 串口参数、发送间隔、重试次数 │ │ └── Program.cs │ ├── SmsBulkSender.Core/ 纯业务,不引用 UI │ │ ├── Modem/ │ │ │ ├── SerialPortManager.cs 串口生命周期与热插拔检测 │ │ │ ├── AtChannel.cs AT 指令收发、超时、缓冲区 │ │ │ └── ModemInfo.cs 信号强度、短信中心号、卡状态 │ │ ├── Encoding/ │ │ │ ├── Ucs2Encoder.cs 中文与号码的 UCS2 转换 │ │ │ └── LongSmsSplitter.cs 长短信拆分与 UDH 拼装 │ │ ├── Sending/ │ │ │ ├── SmsSendEngine.cs 队列、限速、重试、断点续发 │ │ │ └── SendResult.cs 统一返回结构 │ │ └── Contracts/ISmsGateway.cs 发送器抽象,方便换通道 │ └── SmsBulkSender.Data/ SQLite 访问 │ ├── SendRecordRepository.cs │ └── ContactRepository.cs └── tests/ └── SmsBulkSender.Tests/ 编码与拆分逻辑的单元测试

这个划分里有两点值得说。第一,Core不引用System.Windows.Forms,意味着发送引擎可以原封不动搬进控制台程序或 Windows 服务,后面做二次开发时这层隔离能省掉大量重构。第二,Contracts/ISmsGateway.cs这个接口存在,说明作者一开始就没打算把业务焊死在短信猫上,哪怕当前只有一种实现。

2.2 技术选型:WinForms + SerialPort + SQLite

选型没什么花哨的,都是“能在客户那台老电脑上跑起来”的方案:

层面选型选它的理由
界面WinForms部署简单,单 exe 加几个 dll,运维不用装运行时
框架.NET Framework 4.7.2 或 .NET 6 WinForms前者兼容老机器,后者新项目起步
串口System.IO.Ports官方库,够用;.NET Core 下需另装 NuGet 包
存储SQLite免安装、单文件、几万条记录毫无压力
配置JSON 文件串口号和间隔需要现场改,别写死在代码里

为什么不选 WPF?这类工具界面就一个表格加几个按钮,WPF 的样式能力用不上,反而多一层学习成本。为什么不直接把记录写文本文件?因为你迟早要按状态筛选“哪些号码失败了”,WHERE status = 3比写正则解析日志省事得多。至于串口库,System.IO.Ports在 .NET Framework 下是内置的,但要注意它的DataReceived事件在 USB 转串口设备上偶尔会丢数据,后面的 AT 层因此采用了轮询加缓冲区的写法。

2.3 数据模型与建表语句

任务表的设计决定了后面能不能断点续发。我的习惯是:一条待发短信就是一行记录,状态跟着行走,进程崩了重开还能接着发。

// 一条待发短信的最小描述,字段别贪多,按需扩展 public class SmsTask { public long Id { get; set; } public string Phone { get; set; } // 归一化后的 11 位号码,不带 +86 public string Content { get; set; } public SmsStatus Status { get; set; } // Pending/Sending/Success/Failed public int RetryCount { get; set; } public string LastError { get; set; } // 存 CMS ERROR 原文,排查时是救命稻草 public DateTime CreatedAt { get; set; } public DateTime? SentAt { get; set; } } public enum SmsStatus { Pending = 0, Sending = 1, Success = 2, Failed = 3 }

对应的建表语句:

CREATE TABLE IF NOT EXISTS sms_task ( id INTEGER PRIMARY KEY AUTOINCREMENT, phone TEXT NOT NULL, content TEXT NOT NULL, status INTEGER NOT NULL DEFAULT 0, retry_count INTEGER NOT NULL DEFAULT 0, last_error TEXT, created_at TEXT NOT NULL, sent_at TEXT ); -- 发送引擎每次只捞 Pending,这个索引直接决定启动速度 CREATE INDEX IF NOT EXISTS idx_sms_task_status ON sms_task(status); -- 防重发用:同一号码同一天同内容的记录能快速查到 CREATE INDEX IF NOT EXISTS idx_sms_task_phone ON sms_task(phone, created_at);

status用一个整型枚举而不是字符串,是为了UPDATE ... WHERE status = 0这种批量操作能走索引。last_error存原始错误文本,不要存“发送失败”这种自己编的话——真出问题时,+CMS ERROR: 500比“发送失败”有用一百倍。sent_at单独一列而不是复用created_at,因为你要统计“从入队到发出用了多久”,这两个时间点必须分开。

3. 串口与 AT 指令:一条短信从 C# 写到 SIM 卡的全过程

这一层是整套源码里最容易翻车的地方。云短信接口调不通,看一眼 HTTP 状态码就知道大概;串口不一样,你面对的是一个黑匣子,返回一串OK也可能什么都没发出去。

3.1 串口打开与参数配置

短信猫插上电脑后,系统里会多出一个虚拟串口,一般是 COM3 往后的编号。设备管理器里能看到端口号,但要注意两点:一是换个 USB 口编号会变,二是同型号设备插两台可能互相抢号。所以配置里我建议存“端口友好名”,启动时遍历SerialPort.GetPortNames()去匹配。

public sealed class AtChannel : IDisposable { private readonly SerialPort _port; private readonly object _sync = new object(); // 串口是独占资源,一次只允许一个事务 private readonly StringBuilder _buffer = new StringBuilder(); private readonly StringBuilder _pending = new StringBuilder(); public AtChannel(string portName, int baudRate = 9600) { _port = new SerialPort(portName, baudRate, Parity.None, 8, StopBits.One) { ReadTimeout = 500, WriteTimeout = 2000, NewLine = "\r\n", DtrEnable = true, // 部分猫靠 DTR 判断主机在线,置 false 会一直没响应 RtsEnable = true }; _port.DataReceived += (s, e) => { var buf = new byte[_port.BytesToRead]; _port.Read(buf, 0, buf.Length); lock (_buffer) _buffer.Append(Encoding.ASCII.GetString(buf)); // 只做搬运,不在这里解析 }; } public void Open() { if (!_port.IsOpen) _port.Open(); _port.DiscardInBuffer(); // 上电时猫可能吐一堆 URC,先清掉 _port.DiscardOutBuffer(); } public void Dispose() { if (_port.IsOpen) _port.Close(); _port.Dispose(); } }

波特率默认 9600 是这类设备的通用值,部分模块支持 115200,改之前先用AT试通再调。DtrEnable = true这一行是我踩过坑才加上的:有些模块在没有 DTR 信号时处于低功耗状态,你发AT它根本不理你,返回一片空白,看起来像串口坏了。数据接收用事件但只做 Append,不在事件里做字符串匹配,因为DataReceived是在线程池线程上触发的,在里面解析容易和主线程抢锁。

3.2 AT 指令收发引擎:写一条、等一条、超时重试

AT 指令的通信模型很原始:你写一行,它以\r\n结尾回一行或几行,最后以OK或ERROR收尾。麻烦在于返回可能有延迟,也可能永远不来,所以必须带超时。

// 发一条 AT 指令并等待任意一个终结符出现 // 返回 false 表示超时,或返回内容里带 ERROR / CMS ERROR public bool SendCommand(string command, out string response, int timeoutMs = 3000, params string[] terminators) { lock (_sync) { lock (_buffer) _buffer.Clear(); // 丢掉上一轮的残留,否则会误判终结符 _port.Write(command + "\r"); var deadline = Environment.TickCount + timeoutMs; response = string.Empty; while (Environment.TickCount < deadline) { Thread.Sleep(30); // 30ms 一轮,兼顾响应速度和 CPU 占用 lock (_buffer) { response += _buffer.ToString(); _buffer.Clear(); } if (terminators.Length == 0) continue; if (terminators.Any(t => response.Contains(t))) { // 拿到 OK 也要防着后面跟了个 ERROR return !response.Contains("ERROR"); } } return false; // 交给上层决定重试还是报错 } }

几个参数的含义需要说清楚。timeoutMs默认 3000 毫秒,对AT、AT+CMGF这类配置指令够用;但AT+CMGS发正文要等网络侧响应,实际会到 10 秒以上,所以发送正文那一步要单独传 15000。terminators传入的是期望的终结符列表,比如"OK", "ERROR",因为不同模块对同一条指令可能返回OK也可能返回+CMS ERROR: 302。lock (_sync)保证同一时刻只有一条指令在跑——如果两路线程同时往串口写,返回内容会串在一起,你会看到非常诡异的解析结果。

还有一个细节:函数返回bool而不抛异常。原因是发送一条短信失败太常见了(对方关机、号码空号、短信中心忙),异常应该留给真正的程序错误,业务失败用返回值表达,调用方好判断要不要重试。

3.3 一条短信的完整指令序列

下面这段是核心中的核心,把一个手机号和一串文本变成 SIM 卡里的一条待发短信。

public SendResult SendText(string phone, string content) { // 1. 切文本模式,指令短、可读性好,出错时日志也好看 if (!SendCommand("AT+CMGF=1", out var r1, 2000, "OK", "ERROR")) return SendResult.Fail("CMGF", r1); // 2. 字符集必须和后面 Actually 写入的编码一致,中文一律 UCS2 if (!SendCommand("AT+CSCS=\"UCS2\"", out var r2, 2000, "OK", "ERROR")) return SendResult.Fail("CSCS", r2); // 3. 发起会话。注意:CSCS 设成 UCS2 后,号码本身也要按 UCS2 十六进制写 var targetUcs2 = Ucs2Encoder.PhoneToUcs2(phone); if (!SendCommand($"AT+CMGS=\"{targetUcs2}\"", out var r3, 5000, ">", "ERROR")) return SendResult.Fail("CMGS_PROMPT", r3); // 没等到 ">" 说明号码格式不对 // 4. 写正文 + Ctrl+Z(0x1A) 作为结束符,这一步超时给足 lock (_sync) { lock (_buffer) _buffer.Clear(); _port.Write(Ucs2Encoder.ToUcs2Hex(content) + "\x1A"); var deadline = Environment.TickCount + 15000; var resp = string.Empty; while (Environment.TickCount < deadline) { Thread.Sleep(50); lock (_buffer) { resp += _buffer.ToString(); _buffer.Clear(); } if (resp.Contains("+CMGS:")) // 带消息参考号,说明已被模块接受 return SendResult.Ok(resp); if (resp.Contains("+CMS ERROR") || resp.Contains("ERROR")) return SendResult.Fail("CMS_ERROR", resp); } return SendResult.Fail("TIMEOUT", resp); // 超时不算成功,进重试队列 } }

逐段说逻辑。第一步AT+CMGF=1切文本模式,这一步失败通常是模块不支持或者串口根本没通。第二步AT+CSCS="UCS2"决定后续字符集,如果这里设成GSM而正文里有中文,模块会返回一堆问号或者直接报错。第三步最容易被忽略:CSCS 是 UCS2 之后,AT+CMGS="..."引号里的号码也要转成 UCS2 十六进制,直接写13800000000会被拒。第四步写正文,0x1A是 Ctrl+Z,这个控制字符就是“我说完了”的信号,漏掉它模块会一直等你继续输入,表现就是卡死在那不返回。

成功时返回的+CMGS: 23里的 23 是消息参考号,模块内部用它追踪状态。我一般把整个resp原样写进last_error或者日志表,事后排查时能看出到底走到哪一步。返回TIMEOUT的情况要特别小心:短信可能已经发出去了,只是响应没回来,这时候盲目重试会导致对方收到两条。保守做法是超时后先查AT+CMGL看发件箱里有没有这条记录,再决定要不要重发。

4. 编码与队列:中文为什么一条只有 70 个字

界面跑通之后,真正让业务方抓狂的问题都出在这一层:内容被截断、发出去是乱码、发到第 300 条突然卡住。这三个现象背后是三套不同的机制。

4.1 UCS2 编码与 70 字上限

一条短信的载荷是 140 字节。英文用 7 位编码,140 字节能塞 160 个字符;中文走 UCS2,每个字符占 2 字节,140/2 正好 70。这不是代码写得不好,是协议本身就这么定的,所以产品需求上要提前跟业务方讲清楚:一条通知超过 70 个字就要拆成两条计费。

public static class Ucs2Encoder { // 文本转 UCS2 十六进制串,中文英文一律按 2 字节算,长度可预测 public static string ToUcs2Hex(string text) { var sb = new StringBuilder(text.Length * 4); foreach (var ch in text) sb.Append(((int)ch).ToString("X4")); // 大端,4 位十六进制 return sb.ToString(); } // 号码转 UCS2:逐字符转 ASCII 的十六进制,不能整体 ToString public static string PhoneToUcs2(string phone) { var sb = new StringBuilder(phone.Length * 4); foreach (var c in phone) sb.Append(((int)c).ToString("X4")); return sb.ToString(); } }

PhoneToUcs2里的逐字符处理是有原因的。如果图省事写成Encoding.Unicode.GetBytes(phone)再转十六进制,在小端机器上会得到字节序反转的结果,模块认不出来。逐字符(int)c转X4明确指定了大端顺序,不依赖运行环境的字节序。

4.2 长短信拆分与 UDH 拼接头

超过 70 字就得拆。拆完的每一条要带一个 6 字节的用户数据头,告诉接收方“这是第 2 条,共 3 条,编号是 0x1A”,手机才会自动拼成一条显示。

// 6 字节 UDH:05 00 03 <参考号> <总条数> <当前序号> public static byte[] BuildUdh(byte reference, byte total, byte seq) => new byte[] { 0x05, 0x00, 0x03, reference, total, seq }; public static List<string> Split(string content, int maxPerPart = 67, int maxParts = 6) { var parts = new List<string>(); for (int i = 0; i < content.Length; i += maxPerPart) parts.Add(content.Substring(i, Math.Min(maxPerPart, content.Length - i))); if (parts.Count > maxParts) throw new ArgumentException($"内容需要 {parts.Count} 条,超出上限 {maxParts},请业务侧截断"); return parts; }

maxPerPart取 67 而不是 70,是因为每条长短信要额外占 6 字节 UDH,140 - 6 = 134 字节,134 / 2 = 67 字符。maxParts = 6是我自己定的一个业务上限,超过 6 条(约 400 字)的短信基本没人看完,不如拆成两条发或者改成电话通知。这里抛异常而不是静默截断,是为了让业务方知道内容超了,而不是让用户收到半句话。

注意:带 UDH 的长短信在文本模式(CMGF=1)下很多模块支持得并不好,稳妥做法是长内容切到 PDU 模式(CMGF=0)自己拼整个 PDU 包。如果项目里长短信是刚需,建议直接以 PDU 模式为主,别在文本模式上打补丁。

4.3 批量发送队列:限速、重试与断点续发

单条发送调通之后,群发的难点全在“节奏”上。发太快,运营商侧会限速甚至停卡;发太慢,一万条要发一整天。

public async Task RunAsync(IEnumerable<SmsTask> tasks, IProgress<SendProgress> progress, CancellationToken token) { var list = tasks.ToList(); int done = 0, ok = 0, fail = 0; foreach (var task in list) { token.ThrowIfCancellationRequested(); // 每条之间留间隔,600~1200ms 是实测比较稳的区间 await Task.Delay(_options.IntervalMs, token); var result = await SendWithRetryAsync(task, token); if (result.Success) ok++; else fail++; done++; // 进度回主线程更新 UI,避免跨线程操作控件 progress.Report(new SendProgress(done, list.Count, ok, fail, task.Phone, result.Message)); _repo.UpdateStatus(task.Id, result.Success ? SmsStatus.Success : SmsStatus.Failed, result.Message); } } private async Task<SendResult> SendWithRetryAsync(SmsTask task, CancellationToken token) { for (int attempt = 0; attempt <= _options.MaxRetry; attempt++) { var r = _gateway.Send(task.Phone, task.Content); if (r.Success) return r; if (!r.Retryable) return r; // 号码非法、欠费这类重试没意义 await Task.Delay(_options.RetryDelayMs * (attempt + 1), token); // 线性退避 } return SendResult.Fail("RETRY_EXHAUSTED", "重试次数用尽"); }

IntervalMs是整套代码里最需要现场调的参数。我一般从 800ms 起步,观察运营商侧的拦截情况再往下压;压到 300ms 以下,有些地区的卡会被直接限流,表现是前 50 条正常,后面全返回+CMS ERROR: 500。Retryable这个标记很重要,SendResult里根据错误码判断:CMS ERROR: 500是模块侧临时问题,可以重试;CMS ERROR: 302是号码问题,重试一万次也没用,直接标失败。

断点续发靠的就是status字段。程序启动时只捞status = 0的记录,上次崩溃时卡在status = 1(Sending)的记录要单独处理——启动时把它们重置回Pending,因为进程都挂了,那条短信八成没发出去。

4.4 通讯录导入与脏数据清洗

实际项目里,号码表从来不是干净的。从 Excel 复制出来的内容带着+86、空格、横线、括号,甚至还有合并单元格留下的空行。

// 从 CSV 读通讯录:第一列号码,第二列姓名,允许带表头 public static IEnumerable<Contact> ReadCsv(string path) { foreach (var line in File.ReadLines(path, Encoding.UTF8).Skip(1)) { var cols = line.Split(','); if (cols.Length == 0) continue; var phone = Normalize(cols[0]); // 去掉 +86、空格、横线、括号 // 格式不对的直接丢,别带进队列,否则每条都要浪费一次超时 if (!Regex.IsMatch(phone, @"^1[3-9]\d{9}$")) continue; yield return new Contact { Phone = phone, Name = cols.Length > 1 ? cols[1].Trim() : string.Empty }; } } private static string Normalize(string raw) { var s = Regex.Replace(raw ?? string.Empty, @"[\s\-\(\)]", ""); if (s.StartsWith("+86")) s = s.Substring(3); if (s.StartsWith("0086")) s = s.Substring(4); if (s.StartsWith("86") && s.Length == 13) s = s.Substring(2); // 带国家码但没加号 return s; }

这个正则^1[3-9]\d{9}$是国内的号段规则,能挡掉绝大多数脏数据。导入完成后界面上要显示“共读取 3200 行,有效 3148 条,丢弃 52 条”,让操作的人知道有数据被过滤了,不然他会以为程序吃了他五十个客户。这部分看着琐碎,但它是整个流程里用户接触的第一个环节,导入不准,后面发得再稳也白搭。

5. 常见问题排查:串口、信号与运营商限制

这套代码我在不同机器、不同地区、不同批次的设备上跑过很多次,下面五条是复现率最高的。

现象一:点“连接”弹窗提示串口被占用(Access to the port 'COM3' is denied)。原因通常不是串口坏了,而是上一次运行没释放句柄——调试时直接点了停止按钮,或者程序异常退出,SerialPort对象没走Dispose。另一个可能是别的软件正开着同一个口,比如厂商自带的调试工具还在后台。 解决:在Open()前先做一次“试探性打开再关闭”,失败就提示用户拔插;程序退出时用try/finally保证Close()一定执行;实在释放不掉就禁掉设备再启用,或者换个 USB 口让系统重新分配编号。

现象二:AT能返回OK,但发AT+CMGS之后一直卡住,既没有>也没有ERROR。原因有三个方向:CSCS 设成 UCS2 后号码没做十六进制转换,模块认为格式非法直接不响应;或者前面某条指令的响应没读完,残留的字符让状态机错位;或者模块正在处理上一条短信,处于忙状态。 解决:先用AT+CMEE=1打开详细错误提示,再单独发AT+CMGS="00310033..."手工验证;每次发指令前清空接收缓冲;连续两次超时就AT+CFUN=1,1重启模块,等 10 秒再继续。

现象三:日志里全是成功,对方一条没收到。这种情况最玄学。+CMGS返回只代表模块把短信交给了网络,不代表送达。常见原因是短信中心号配错了(换卡后没更新)、卡欠费、或者内容里带了敏感词被运营商侧拦了。 解决:先发AT+CSCA?查短信中心号,和设备说明书核对;用AT+CSQ看信号,低于 12 就先调天线位置;测试阶段先给自己发一条纯英文的test排除内容问题;正式群发前把内容模板拿去运营商那边做报备,这一步不能省。

现象四:中文发出去是乱码或者一串问号。原因基本是编码链路不一致:CSCS 设成了GSM却写了中文,或者代码里用Encoding.GetEncoding("GB2312")转字节再拼十六进制。 解决:中文场景统一AT+CSCS="UCS2"配ToUcs2Hex,两头对齐;如果模块死活不认 UCS2,就整体切到 PDU 模式,用AT+CMGF=0加自己拼 PDU,别再混着用。

现象五:发到两三百条时程序界面卡死,或者串口不再响应。原因是把发送循环写在了 UI 线程上,Thread.Sleep直接把消息泵堵死;另一个原因是串口接收缓冲区累积了太多未读的 URC(比如+CMTI新短信提示),把内部缓冲撑满了。 解决:发送引擎一律放后台线程或Task,UI 通过IProgress<T>更新;每发 50 条主动DiscardInBuffer()清一次;把ReadBufferSize调大到 4096 以上,减少溢出概率。

6. 二次开发:把单机工具改成能对接业务系统的发送服务

这套源码最值钱的地方不是界面,是那层ISmsGateway。我一般按下面的顺序改造,能从“人坐在电脑前点按钮”变成“业务系统调接口”。

第一步,把发送引擎从 WinForms 项目里剥出来,做成独立的类库,宿主换成一个 Windows 服务或者控制台常驻进程。UI 保留,但只作为运维查看日志和手工补发的工具,不再是唯一入口。

第二步,加一层极简的本地 HTTP 接口,让业务系统能把消息推进队列。用HttpListener就够,不必上完整的 Web 框架:

// 极简入队接口:POST /api/sms body: {"phone":"13800000000","content":"..."} // 只做参数校验和写库,立刻返回 taskId,真正的发送交给后台引擎 private async Task HandleAsync(HttpListenerContext ctx) { using var reader = new StreamReader(ctx.Request.InputStream, Encoding.UTF8); var body = await reader.ReadToEndAsync(); var req = JsonSerializer.Deserialize<SmsRequest>(body); if (req == null || !Regex.IsMatch(req.Phone ?? "", @"^1[3-9]\d{9}$")) { ctx.Response.StatusCode = 400; return; } var id = _repo.Enqueue(req.Phone, req.Content); // 落库,status = Pending await WriteJsonAsync(ctx, new { taskId = id, status = "queued" }); }

第三步,把发送状态做成可查询的。业务系统关心的是“我那条到底发出去没有”,所以需要一个GET /api/sms/{id}返回Pending/Success/Failed加错误原因。这一步做完,短信猫就从“一个工具”变成了“一个通道”。

第四步,抽象出HttpSmsGateway作为备用通道,配置里加个开关。短信猫的硬伤是单点:卡欠费、设备被拔、信号变差,整个发送链路就断了。给它配一条云通道兜底,平时走猫,连续失败 5 条自动切云通道,成本可控且不至于业务停摆。

改造点原实现改造后
触发方式人工点按钮HTTP 接口入队
运行形态WinForms 常驻Windows 服务 + 运维界面
状态查询看表格接口按 taskId 查
通道容灾无猫 + 云通道自动切换
长短信文本模式硬发PDU 模式拼 UDH

验证方法也不复杂:先用十个虚拟号码跑通全链路,确认taskId能查到状态;再把间隔压到 300ms 发两百条,看会不会触发限流;最后拔掉设备模拟故障,确认队列里的记录停在Pending而不是变成Failed,插回去能自动续发。

有一回我图省事,直接在MainForm里改了发送循环,忘了它跑在 UI 线程上,结果测试同事点了“开始”之后整个窗口白屏三分钟,他以为程序崩了直接强杀进程,那批任务全卡在Sending状态,第二天我一条条手工重置的。从那以后我每次动发送逻辑,都强制先看一眼它在哪个线程上跑,再决定要不要包一层Task.Run。这套源码的骨架不复杂,真正花时间的是把这些边界情况一条条趟过去,希望帮到你。

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

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

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

立即咨询