C#短信群发源码:串口AT指令、PDU编码与长短信群发实战

发布时间:2026/10/24 3:00:53
C#短信群发源码:串口AT指令、PDU编码与长短信群发实战 简介C#短信群发源码基于短信猫面向希望快速搭建批量短信发送功能的开发者依托短信猫硬件与GSM网络完成短信收发适用于企业通知、营销触达等场景的学习与二次开发。压缩包共114个文件、约721KB以38个cs源码、17个resx与17个resources界面资源、7个dll组件及若干gif/jpg素材、mdb数据库和sln解决方案为主完整呈现从主窗体、发送模块到号码与用户管理的项目结构。已有120人学习下载。读者可从中梳理C#串口通信、AT指令控制、多线程并发发送、短信模板与发送记录存储、异常处理及WinForms界面设计等实现思路也可对照解决方案与数据库文件理解企业级短信群发系统的模块划分为同类项目开发提供可复用的参考。1. 一台插着 SIM 卡的盒子怎么就成了内网通知系统的最后一公里去年帮某公司做一套设备告警通知服务器全在内网出不了公网运营商短信接口一个都接不进去最后落地的方案是机柜里插一台短信猫用 C# 短信群发源码直接怼串口发 AT 指令。这套源码解决的不是「发一条短信」这种玩具需求而是批量号码、中文长短信、失败重试、回执落库这一整条链路。它适合两类人一类是做内网通知、值班告警、会员触达的开发者另一类是手上已经有 GSM 模块、但被 PDU 编码和串口超时折磨过的同学。下面按拆包的顺序讲先通信层再编码最后群发引擎和那几个必踩的坑。2. 串口握手与 AT 指令封装把通信层的抖动先摁住短信猫本质上是一个挂在串口上的 GSM 模块主机和它之间只有一件事你发一行以\r结尾的 AT 指令它回一段以\r\n分隔的文本。听起来简单但群发场景下所有稳定性问题几乎都出在这一层——不是编码错是通道本身不干净。源码里第一个要看的类就是串口通道封装它决定了后面 PDU 编码写得再漂亮有没有意义。2.1 TEXT 模式和 PDU 模式为什么源码默认选后者模块一般支持两种短信模式。ATCMGF1是 TEXT 模式发短信时直接ATCMGS13800000000然后跟正文ATCMGF0是 PDU 模式正文要先编码成一串十六进制。TEXT 模式看着省事实际用起来问题很大中文的支持依赖模块固件有的模块要求先ATCSCSUCS2再把正文转成 UCS2 十六进制有的模块干脆只认 GSM 7bit 字符集中文直接变问号而且 TEXT 模式下长短信的拼接基本不可控模块自己拆不拆、怎么拆全看固件版本。PDU 模式虽然要自己编码但所有字段都在你手里——短信中心号、目标号码类型、编码方式、有效期、UDH 头一个字节都不含糊。同一份源码换不同模块只要支持标准ATCMGF0和ATCMGSlen行为就是一致的。这就是选型的核心理由用可控的复杂度换掉不可控的固件差异。所以源码把 PDU 编解码单独抽成一个静态类通道层只管收发字符串两层不耦合。2.2 串口六个参数怎么定波特率、超时和流控的取舍串口参数配错最典型的症状是能打开端口但收到的全是乱码或者AT发出去石沉大海。下面这张表是这类模块最常见的取值也是源码里SerialPort构造的默认值参数典型值说明与踩坑点BaudRate115200 / 9600新模块多为 115200老模块默认 9600发不通先降速试DataBits8基本不用改ParityNone少数工业模块要求 Even接错会整帧乱码StopBitsOne同上HandshakeNone开了硬件流控但线序不对会直接卡死写操作ReadTimeout / WriteTimeout3000 ms别设 -1无限等待模块假死时线程会挂死DtrEnable / RtsEnabletrue部分模块靠 DTR 判断主机在线为 false 时拒绝应答有一点值得单独说NewLine一定要设成\r不能是默认的\n。AT 指令的终结符是回车发\n模块会当成无效字符忽略然后你等OK永远等不到排查半天以为是模块坏了。这类细节在源码里都写死在构造函数中不是我藏私是改错了真的很难查。2.3 一段能直接跑的 AT 通道封装下面这段是源码里最核心的通道类去掉业务逻辑后保留了完整骨架。它解决三个问题AT 指令必须串行、接收要异步、超时要有明确异常。using System; using System.IO.Ports; using System.Text; using System.Threading; public sealed class AtChannel : IDisposable { private readonly SerialPort _port; private readonly object _sync new object(); // AT 指令串行锁模块不支持并发 private readonly StringBuilder _buffer new StringBuilder(); // 异步接收缓冲 public AtChannel(string portName, int baudRate 115200) { _port new SerialPort(portName, baudRate, Parity.None, 8, StopBits.One) { ReadTimeout 3000, WriteTimeout 3000, DtrEnable true, // 告诉模块主机已就绪 RtsEnable true, NewLine \r // AT 终结符是回车不是换行 }; _port.DataReceived OnDataReceived; _port.Open(); } private void OnDataReceived(object sender, SerialDataReceivedEventArgs e) { try { var chunk _port.ReadExisting(); lock (_buffer) { _buffer.Append(chunk); } } catch (Exception) { // 拔线、驱动重载时会抛不在这里处理交给上层超时判定 } } // 发送一条 AT 指令并等待期望的终结符 public string Send(string command, string expect OK, int timeoutMs 5000) { lock (_sync) { lock (_buffer) { _buffer.Clear(); } _port.Write(command \r); var deadline Environment.TickCount timeoutMs; while (Environment.TickCount deadline) { string snapshot; lock (_buffer) { snapshot _buffer.ToString(); } if (snapshot.Contains(\r\nERROR) || snapshot.Contains(\r\nCMS ERROR)) throw new InvalidOperationException($模块报错: {command} {snapshot}); if (snapshot.Contains(\r\n expect)) return snapshot; Thread.Sleep(20); // 轮询间隔太密会吃满 CPU } throw new TimeoutException($AT 超时: {command}); } } public void Dispose() { _port.DataReceived - OnDataReceived; try { if (_port.IsOpen) _port.Close(); } catch { } _port.Dispose(); } }逻辑说明Send里的锁保证同一时刻只有一条指令在飞这是必须的——模块的应答不带指令标识两条指令交叉发送后你根本分不清哪个OK对应哪条。接收端用DataReceived事件把数据攒进缓冲区主流程只轮询缓冲区内容避免同步ReadLine在超时边界上丢数据。参数上timeoutMs默认 5000发短信的ATCMGS要传更长下面会讲因为写短信到 SIM 卡可能耗时两三秒。2.4 上电握手序列与模块状态自检串口打开只是物理层通了模块能不能用还要走一遍握手。源码里Initialize方法的调用顺序是固定的顺序错了会出各种莫名其妙的问题// 1. 探活模块可能还在上电自检重试几次 channel.Send(AT, OK, 2000); // 2. 关回显不关的话每条指令都会被原样回显污染缓冲区判断 channel.Send(ATE0, OK); // 3. 切 PDU 模式 channel.Send(ATCMGF0, OK); // 4. 指定短信存储位置优先 SIM 卡容量小但掉电不丢 channel.Send(ATCPMS\SM\,\SM\,\SM\, OK); // 5. 让新短信主动上报而不是靠轮询 channel.Send(ATCNMI2,1,0,0,0, OK);第 5 步的ATCNMI2,1,0,0,0值得展开第一个参数 2 表示新短信到达时先存起来再上报第二个参数 1 表示用CMTI: SM,index的形式通知。不设这个的话模块默认不主动告诉你来短信了只能定时ATCMGL轮询实时性和资源占用都差。握手完还要做两项自检这两项不做后面群发失败你都不知道从哪查ATCSQ返回信号强度格式是CSQ: 20,99第一个值 0~31低于 10 就别指望稳定发送了先去调天线位置ATCREG?返回CREG: 0,1第二个值 1 表示已注册到网络5 表示漫游。如果是 0 或 3说明卡没插好、欠费或者被锁这时候发什么都是ERROR。还有一个容易被忽略的ATCSCA?查短信中心号。这个号是存在 SIM 卡里的换卡、换批次卡之后可能是空的或者旧的表现就是模块返回OK、CMGS也给了消息参考号但对方永远收不到——这是第 5 章要详细讲的第一个大坑。3. PDU 编解码与长短信拆分一条中文短信为什么卡在 70 个字编码这层是整份源码里最值得逐字节看的部分。很多人拿到源码后能跑通英文一换中文就废或者超过 70 个字就被截断根子都在这里。PDU 不是什么私有协议它是 GSM 03.40 定义的标准格式字段顺序固定理解了结构就能自己改。3.1 一条中文短信在 PDU 里长什么样先看一条实际报文发送方向SMS-SUBMIT00 11 00 0B 81 3108000000F0 00 08 AA 0C 4F60597D拆开对照着看字段值含义SCA 长度00用模块内已设置的短信中心不在这里重复带PDU 类型11SMS-SUBMIT带相对有效期MR00消息参考号模块自己递增DA 长度0B目标号码有 11 位十进制数字DA 类型81国内号码带 的国际号是 91DA 号码3108000000F0半字节交换后的号码奇数位补 FPID00协议标识普通短信填 0DCS08数据编码方案08 UCS2中文必须用它VPAA相对有效期AA 约等于 4 天UD 长度0C用户数据字节数12 字节 6 个汉字UD4F60597DUCS2 大端编码的正文这里有两个必须记住的点。第一ATCMGS的参数不是整条 PDU 的长度而是UD 的字节数也就是上表里的0C对应 12指令要写成ATCMGS12。写错这个数值模块要么报错要么发出乱码。第二DA号码是半字节交换的138...会变成31 08 ...这种形式奇数位末尾补F这个转换写错一位短信就发给了别人或者直接ERROR。3.2 号码字段的半字节交换与国内国际号型号码编码是第一个容易翻车的地方单独抽出来看。转换规则是每两位数字颠倒顺序如果位数是奇数末尾补F// 目标号码 - PDU 的 DA 字段不含长度字节 private static string EncodeAddress(string number, bool international) { var type international ? 91 : 81; // 91 带 81 国内 var digits number.TrimStart(); var sb new StringBuilder(); sb.Append(digits.Length.ToString(X2)); sb.Append(type); for (int i 0; i digits.Length; i 2) { var low digits[i]; var high (i 1 digits.Length) ? digits[i 1] : F; // 奇数位补 F sb.Append(high).Append(low); // 半字节交换 } return sb.ToString(); }逻辑说明循环每次取两位先输出后一位再输出前一位这就是「交换」的字面含义。参数international决定类型字节带86的号码走 91纯 11 位号码走 81。源码里在入库阶段就把号码归一化了——统一去掉空格、横杠、括号然后按是否以开头打标记避免发送时临时判断出错。提示号码库里混着13800000000、8613800000000、013800000000三种写法是常态归一化一定要在入库前做别留到编码阶段。运营商侧对86前缀的处理不完全一致源码默认全按国内号型发。3.3 UCS2 编码与 70 字上限的由来中文走 UCS2每个字符固定 2 字节而一条短信的 UD 字段最大 140 字节所以 140 / 2 70 个字。这个 70 不是产品经理拍的是协议算出来的。对应的编码在 C# 里一行就够但要注意必须是大端// 正文 - UCS2 十六进制串 public static string EncodeUcs2(string text) { var bytes Encoding.BigEndianUnicode.GetBytes(text); // 大端不能用默认的小端 return BitConverter.ToString(bytes).Replace(-, string.Empty); }Encoding.Unicode在 .NET 里是小端直接用它编出来的 PDU 发给模块对方收到的是字节颠倒的乱码。这个坑我见过不止一次症状是中文短信显示成生僻字或者方块。源码里把这个方法单独封装全项目只允许从这里出 UCS2就是为了堵住随手写Encoding.Unicode的口子。另外提醒一句中文标点、全角字符、emoji 都按 UCS2 双字节算。带 emoji 的正文在部分老模块上会被拆成代理对4 字节字数上限还要再降源码里对这类字符做了检测超出就按长短信拆。3.4 长短信 UDH 拆分67 字、参考号和段序超过 70 个字就要拆长短信靠的是 UDH用户数据头。原理是在每段的 UD 前面插入 6 个字节的头05 00 03 ref total seq05是 UDH 长度00是信息元素标识拼接长短信的固定值03是后续字节数ref是这组短信的参考号同一组必须一致1 字节total是总段数seq是当前段序号从 1 开始。因为占了 6 个字节每段可用 UD 变成 140 - 6 134 字节也就是67 个汉字。// 长短信拆分返回每段的 PDU 用户数据含 UDH public static Liststring SplitLongSms(string text, byte reference) { const int perPart 67; // (140 - 6) / 2 var parts new Liststring(); var total (text.Length perPart - 1) / perPart; if (total 255) throw new ArgumentException(超出单条长短信最大段数); for (int i 0; i total; i) { var seg text.Substring(i * perPart, Math.Min(perPart, text.Length - i * perPart)); var udh $050003{reference:X2}{total:X2}{(i 1):X2}; // 段序从 1 开始 parts.Add(udh EncodeUcs2(seg)); } return parts; }逻辑说明参考号用 1 字节源码里按「业务批次 时间戳」哈希生成保证同一批群发的不同接收人之间不撞号。段序从 1 开始而不是 0这一点各模块实现不完全统一但按标准写从 1 开始兼容性最好。这里有个隐藏前提模块必须支持 UDHI用户数据头标识。发长短信时ATCMGS前面的ATCSMP参数里要带上 UDHI 位常用配置是ATCSMP49,167,0,8其中第一个参数 49 0x31包含了 UDHI 标志。不设这个模块会把 UDH 当成正文的一部分发出去对方收到一串奇怪字符加正文碎片。源码在初始化后立刻下发这条指令属于必做项。3.5 回执读取CMGL 与 CDS 两条路发出去不等于送到。回执有两种拿法第一种是主动查ATCMGL4列出所有短信4 表示全部返回的是 PDU 格式需要自己解码状态报告。这种方式的缺点是轮询有延迟而且存储满了会顶掉新短信。第二种是让模块主动推靠ATCNMI的第二个参数把CDS状态报告推上来。源码默认走这条路收到CDS:后解析出消息参考号和状态码回写数据库。状态码里 0 表示已送达其他值对应各种失败原因比如 0x40 以上多为网络侧拒绝。提示短信存储在 SIM 卡上容量很小通常 20~50 条如果只发不删跑到几十条就会CMS ERROR: 322存储满。源码在每次读取回执后立刻执行ATCMGDindex删除这是群发能长时间跑下去的必要动作。4. 群发引擎的队列与回执从逐条 for 循环到可重入发送器前面两层搞定后最容易写错的反而是业务层。我见过太多人拿到源码后把发送逻辑改成foreach (var phone in list) { Send(phone); }跑两百条就出问题——不是模块扛不住是这样写完全没有失败处理、没有节流、没有断点恢复一次卡死就要从头再来。4.1 为什么群发不能写成 for 循环串口是独占资源一条ATCMGS从下发到等回OK大概 1~3 秒一千条就是半小时起步。在这个时间跨度里网络会抖、模块会热、USB 转串口可能掉线、运营商会限速。for 循环的写法有三个致命问题一是失败即中断第 300 条超时了后面 700 条全不发二是没有速率控制短时间内高频发送直接触发风控三是进度不可观测发到哪一条全靠日志肉眼数。所以源码把发送拆成「任务表 队列 消费者」三层。任务表落库队列管调度消费者负责真正怼串口。任何一层挂了其他两层还在重启后能从断点继续。4.2 发送队列的状态机设计每条短信在库里有一个明确的状态流转是单向的状态含义可流转到Pending待发送Sending / CancelledSending已下发 ATCMGS等回执Sent / FailedSent模块确认已提交网络Delivered / UndeliveredFailed发送失败进入重试判断Pending重试/ Dead放弃关键在Sent和Delivered是两个不同状态。Sent只代表模块把短信交给了网络不代表对方收到Delivered要等CDS状态报告回来。源码里这两步分开落库就是为了后面能算出真实送达率——第 6 章会用这份数据。队列实现用BlockingCollection就够了不必上消息中间件public sealed class SmsDispatcher { private readonly BlockingCollectionSmsTask _queue new BlockingCollectionSmsTask(2000); private readonly AtChannel _channel; private readonly int _intervalMs; // 条间隔控制发送速率 private readonly ActionSmsTask _persist; // 状态落库回调 public SmsDispatcher(AtChannel channel, int intervalMs, ActionSmsTask persist) { _channel channel; _intervalMs intervalMs; _persist persist; } public void Enqueue(SmsTask task) { task.State SmsState.Pending; _persist(task); _queue.Add(task); // 有界队列满了会阻塞生产者天然背压 } // 后台消费者单独线程跑别放在 UI 线程 public void Run(CancellationToken token) { foreach (var task in _queue.GetConsumingEnumerable(token)) { try { task.State SmsState.Sending; _persist(task); SendOne(task); // 内部做 PDU 编码和 ATCMGS task.State SmsState.Sent; } catch (Exception ex) { task.LastError ex.Message; task.State SmsState.Failed; } finally { _persist(task); // 无论成败都落库断点恢复靠它 Thread.Sleep(_intervalMs); // 节流也是给模块喘口气 } } } }逻辑说明BlockingCollection设了 2000 的容量上限生产者灌太快会阻塞这是故意的——总比内存里堆十万条然后 OOM 强。finally里的落库是断点恢复的关键进程被杀之前最后一条任务的状态一定是 Persisted。_intervalMs是节流参数取值在下一章有实测对比。4.3 失败重试的边界哪些错该重试哪些不该无脑重试是最常见的错误。号码非法、被拉黑、内容违规这类错误重试一百次也是失败还把队列堵死。源码把失败分成两类private static bool IsRetryable(string error) { if (string.IsNullOrEmpty(error)) return true; // 模块/链路层问题值得重试 if (error.Contains(超时) || error.Contains(Timeout)) return true; if (error.Contains(CMS ERROR: 500)) return true; // 未知错误常见于瞬时拥塞 if (error.Contains(CMS ERROR: 331)) return true; // 网络未响应 // 号码、内容、权限类问题重试无意义 if (error.Contains(CMS ERROR: 321)) return false; // 号码格式非法 if (error.Contains(CMS ERROR: 322)) return false; // 存储满得先删 return false; }重试策略用的是指数退避第一次失败等 5 秒第二次 30 秒第三次 5 分钟最多三次三次都失败标记Dead并告警。这样既不会因为一次网络抖动丢短信也不会让坏号码反复挤占发送窗口。CMS ERROR: 322特别说明一下它不是真正的发送失败是存储满了正确做法是先清存储再重发源码在捕获这个错误时会插一个清理动作。4.4 落库与断点续发群发任务的表结构不复杂但有两列必须加索引CREATE TABLE sms_task ( id BIGINT PRIMARY KEY, batch_id VARCHAR(32) NOT NULL, -- 批次号一次群发一个 phone VARCHAR(20) NOT NULL, content VARCHAR(1000) NOT NULL, state TINYINT NOT NULL DEFAULT 0, -- 0待发 1发送中 2已提交 3已送达 4失败 retry_count TINYINT NOT NULL DEFAULT 0, msg_ref INT NULL, -- 模块返回的消息参考号用于对回执 last_error VARCHAR(255) NULL, created_at DATETIME NOT NULL, updated_at DATETIME NOT NULL, KEY idx_batch_state (batch_id, state), KEY idx_msg_ref (msg_ref) );msg_ref这列是把CDS回执关回任务记录的钥匙。ATCMGS成功后会返回CMGS: mr把这个数字存进去等状态报告上来时按mr反查就能把Sent更新成Delivered。没有这一列回执就是一堆无主数据。断点续发逻辑很简单进程启动时查state IN (0,1)的记录重新入队。state1的那些是发送中途进程被杀的重发有可能造成重复所以源码在重发前先按msg_ref查一次回执表能对上就跳过。这个判断不完美但比无条件重发好得多。5. 避坑与排查串口假死、回执丢包和送达率的 5 个真实翻车点这一章按「现象 → 原因 → 解决」来写都是这套源码在实际部署里真出过的问题。有些坑跟代码无关是硬件和网络侧的光看代码永远查不出来。5.1 发出去显示成功对方永远收不到现象ATCMGS返回OK和CMGS: 12数据库状态是Sent但所有接收方都没收到CDS回执也一直不来。原因短信中心号SMSC为空或指向一个已经停用的旧号。换过 SIM 卡、或者卡是批量采购的物联网卡时特别常见卡里预置的中心号可能压根没写。模块不会因为缺中心号就拒绝提交它会照常收下并给参考号然后短信在模块内部排队等中心号等不到就一直挂着。解决初始化后强制查一次ATCSCA?返回值空或者不是预期号段就ATCSCA号码,145写入145 表示国际格式写完重启模块才生效。源码里把这一步做成了启动自检不通过直接拒绝启动发送服务。5.2 跑两三个小时后串口假死现象前几百条正常之后Send开始超时Write不报错但缓冲区再无新数据进来重启程序又能跑一段时间。原因两个可能叠加。一是 USB 转串口芯片在长时间高频通信下会掉帧甚至驱动重载SerialPort对象看着还 Open实际句柄已经失效二是接收线程只ReadExisting不做超时清理某次半包数据卡在缓冲区里后续判断全被污染。解决加看门狗。连续三次 AT 超时就判定通道失效执行Close() 短延时 Open() 重新握手。同时换用带独立供电的 USB HUB别让模块和键盘鼠标抢一个口。源码里AtChannel暴露了一个Reopen()方法就是干这个的上层消费者捕获TimeoutException累计三次后调用。提示不要用SerialPort.Close()之后立刻Open()Windows 下句柄释放有延迟至少Thread.Sleep(500)。这个 500 毫秒是血泪经验省了它会出现「重新打开成功但发什么都 ERROR」的玄学现象。5.3 中文乱码与问号短信现象英文数字正常中文对方收到一串问号或者干脆变成长度不对的乱码。原因三种可能。一是没切 PDU 模式还在 TEXT 模式下用 GB2312 编二是 UCS2 用了Encoding.Unicode小端而不是BigEndianUnicode三是ATCMGS后面的长度参数填了整条 PDU 的长度而不是 UD 的字节数。解决逐项核对。ATCMGF0必须确认返回OK编码统一走EncodeUcs2一个出口ATCMGS参数严格等于 UD 字段的字节数长短信时含 UDH 那 6 个字节。可以先用一条「测试」两个字的短信单发验证别一上来就群发。5.4 长短信只到一段或顺序颠倒现象160 字的内容对方只收到前 67 字或者收到三段但顺序乱的。原因UDH 里的参考号每段不一致常见于循环里每次重新生成 ref或者段序从 0 开始而模块按 1 开始解析或者ATCSMP没带 UDHI 标志导致 UDH 被当正文发出去。解决同一批长短信必须共用同一个 reftotal和seq严格配对seq从 1 起。发送前先下发ATCSMP49,167,0,8打开 UDHI。验证方法是给自己发一条 200 字的内容看看手机上是不是完整显示成一条——多数手机会自动拼接收到三段独立短信说明网络侧没识别到 UDH。5.5 批量发送中途被限速现象前 200 条很顺之后连续ERROR或者短信状态长期停在Sent不转Delivered。原因发送速率超过了卡所在的套餐或通道阈值触发网络侧流控。物联网卡、行业卡对发送频次限制尤其严格。内容里带明显营销词也会被内容侧拦截表现同样是发出去没回执。解决把_intervalMs调大是最直接的手段具体取值看下一章的实测表。另外号码分批一个批次别超过 500 条批之间留间隔。被限速后不要立即重试让队列暂停 10~30 分钟再恢复硬冲只会让限制时间更长。6. 提速与验证把发送速率从 6 条/分推到模块上限群发跑起来之后所有问题最后都会收敛到一个指标单位时间能送达多少条以及这个数字能不能信。我一般不从「发了多少条」看而是从库里三列算Sent数、Delivered数、Dead数真实送达率 Delivered / (Sent Dead)把还在Sending的排除掉。这个口径比模块日志准因为模块日志里CMGS返回 OK 只代表提交不代表有人收到。速率方面_intervalMs的取值需要实测而不是拍脑袋。同一台模块、同一张卡间隔和成功率的关系大致是这样的每档各发 200 条连续跑三轮取平均间隔理论速率实测提交成功率实测送达率结论300 ms~200 条/分87%71%触发流控不建议800 ms~75 条/分99%93%短时任务可用1500 ms~40 条/分100%96%长期运行的稳妥档3000 ms~20 条/分100%97%送达率提升有限性价比低数据里有意思的地方是间隔从 300ms 提到 800ms送达率涨了 22 个点再从 1500ms 放到 3000ms只涨 1 个点。说明瓶颈在触发阈值不在绝对速度。所以源码默认_intervalMs 1500需要赶时间时临时降到 800但绝不降到 500 以下。另一个进阶用法是模块热备。单台模块跑久了总要重启加第二台做冷备主模块连续三次超时就切到备机切换逻辑就放在AtChannel.Reopen()失败之后// 主备切换主通道重连失败则切备通道 if (!primary.TryReopen()) { logger.Warn(主模块重连失败切换备模块); dispatcher.SwitchChannel(secondary); // 备模块需提前完成 Initialize }切换时要注意备机也在同一张卡池里别让两台同时发同一个批次否则会重复。源码用batch_id加一把分布式锁解决本地单机部署的话一个lock对象就够了。最后说个验证送达率的小技巧在批次里混入 2~3 个自己的号码人工确认收到情况和文案完整性。机器告诉你Delivered但拼接顺序错、字数截断、标点变问号这些只有人眼能看出来。这个习惯我保持了三年——从那以后我每次上线新的模板或换模块都强制先发一轮自测号看实际收到的样子再放量。希望帮到你。本文还有配套的精品资源点击获取

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询