基于极客互动企微 iPad 协议 + Spring Boot + Vue 打造的企业微信群发控制台,一条消息轻松覆盖 80+ 客户:消息类型路由、120 秒风控间隔、暂停续发、失败重试、SOP 计划与执行分离全流程打通,并与客服工作台消息流实时同步。本文附真实运营界面与核心代码,手把手拆解从架构设计到发送闭环的完整实现。
目录
前言
做企业微信私域的同学,一定踩过这些坑:
- 运营要给 80 个客户群发同一条活动文案,客服在手机上一个个点,点到手腕酸;
- 图、文件、小程序、名片混发,协议字段对不上,发出去变成「这是一条群发消息」系统提示;
- 发太快被风控,发太慢又赶不上活动档期;
- 中途有人点暂停,续发时不知道从第几个对象接着走;
- 失败了只能整单重跑,已经成功的客户再收一遍。
我们在 极客聚合(企业微信聚合交互系统) 里把这套能力做成了独立模块——群发控制台。极客互动API+前端 Vue + Element UI,后端 Spring Boot 异步调度,底层对接企微网关的多种 Send* 接口。本文按真实代码拆开讲:架构、消息路由、风控间隔、失败治理、SOP 计划与执行分离。
技术栈说明
本教程全文使用的底层API调用地址:https://wechatapi.apifox.cn/
代码调用示例参考官网:https://www.jikehudong.com/
开发语言:c+java
开发框架:Spring Boot + Vue
一、效果展示:运营同学真正在用的界面
登录后左侧进入「群发控制台」,三个 Tab:消息群发 / SOP计划 / SOP记录。

列表直接给运营看他们最关心的数:成功、失败、总数、状态 Tag。每条任务可以看详情、翻失败记录、对 pending 失败一键重试。
点「新增群发」进入创建页:选机器人、圈群/圈好友、组内容、配调度。

对象不是一把梭。群聊可以按「群主 / 成员」「内部群 / 外部群」过滤,左侧搜索勾选,右侧是已选清单。好友侧还能按企业标签收窄,适合「只打重点客户」这种精细化场景。
内容区支持 文字 / 图片 / 文件 / 小程序 / 名片。文字可以插表情、群主身份下支持 @所有人。
往下是调度和风控——这是这套系统能长期跑、而不是第二天号被限的关键:


频率滑条范围 1~300 秒,默认 120 秒,界面上写清楚:近期风控规则有变化,建议不低于 120s。定时任务可填开始/结束时间,到期硬截止;自动重试打开后,单条失败会立刻再打一次。
任务跑完,详情弹窗把汇总卡、调度配置、消息预览摊开:

日常关怀这类「每天固定点触达」不适合每次手工建任务,所以单独做了 SOP:

计划是模板,执行是快照。点「执行」会克隆一份 sop_record,名称带上「(手动执行)」,记录页可以回溯每一次实发。
二、整体架构:控制台只负责任务,真正发送走企微网关
群发不是「一个 HTTP 请求循环 200 次」。创建任务必须秒回,真正的发送是后台作业。

链路可以压成一句话:
Vue 群发控制台
→ POST /system/massMsg/send
→ MassMsgServiceImpl(落库 + 线程池调度)
→ 按 type 路由到 SendTextMsg / SendCDNImgMsg / SendGroupsMsg ...
→ 成功写入 Redis,工作台聊天记录同步可见
前端三个 Tab 对应三种 taskCategory:
| Tab | taskCategory | 职责 |
|---|---|---|
| 消息群发 | normal | 一次性 / 定时 / 周期群发 |
| SOP计划 | sop | 可复用的自动化模板 |
| SOP记录 | sop_record | 计划的每一次实发快照 |
REST 入口很薄,业务全在 Service:
@RestController
@RequestMapping("/system/massMsg")
public class MassMsgController {
@PostMapping("/send")
public AjaxResult send(@RequestBody Map<String, Object> params) {
return AjaxResult.success(massMsgService.sendMassMsg(params));
}
@GetMapping("/taskList")
public TableDataInfo taskList(SysMassMsgTask query) { /* 分页 */ }
@PostMapping("/retry") // 失败补发
@PostMapping("/pause") // 暂停,记下断点
@PostMapping("/resume") // 从 resumeFromIndex 续发
}
创建任务时就把调度参数打进 scheduleConfig JSON,状态机从 waiting / periodic_active 起步:
boolean isPeriodic = "periodic".equalsIgnoreCase(taskType);
task.setScheduleMode(isPeriodic ? "periodic" : "timed");
task.setStatus(isPeriodic ? "periodic_active" : "waiting");
taskMapper.insert(task);
if (!isPeriodic) {
dispatchTimedTask(taskId, startTime); // 到点再丢进发送线程池
}
定时任务用 ScheduledExecutorService 精确到点,发送循环走 CachedThreadPool。到点任务不会卡在某个正在 sleep 间隔的 worker 后面——这是早期「一个线程睡到开始时间」方案踩过的坑。
周期任务更简单粗暴、也好排查:每分钟 tick 一次。
@Component
public class MassMsgPeriodicScheduler {
@Scheduled(cron = "0 * * * * ?")
public void tick() {
massMsgService.tickPeriodicMassMessages();
}
}
状态流转如下(timed 与 periodic 共用 running / pause / 终态):

waiting ──到点──► running ──► success / failed / partial
│
└── pause ── resumeFromIndex ──► running
periodic_active ──每分钟命中 slot──► running ──本轮结束──► periodic_active
三、消息协议:一种 msg_list,八种 Send API
企微侧没有「万能群发口」。文字、CDN 图片、文件、链接、小程序、名片、视频号、群公告走的是完全不同的网关。前端统一成 msg_list,后端按 type 拆。
前端约定(节选):
/**
* msg_list:
* [{ type: 0, content }] // 文字
* [{ type: 14, aeskey, cdnkey, fileSize, md5 }] // 图片
* [{ type: 15, ..., file_name }] // 文件
* [{ type: 78, title, appid, pagepath, ... }] // 小程序
* [{ type: 41, username, nickname, headImg }] // 名片
*/
export function sendMassMsg(data) {
return request({ url: '/system/massMsg/send', method: 'post', data })
}
图片不会把二进制塞进任务表。提交前先走机器人 CDN:
if (this.activeContentType === 'image') {
const res = await cdnUploadImg(uuid, this.imageFile)
const cdn = this.extractCdnPayload(res)
return [{
type: 14,
cdnkey: cdn.cdnkey,
aeskey: cdn.aeskey,
md5: cdn.md5,
fileSize: cdn.fileSize
}]
}
后端单条消息走专用接口,多类型组合才走 SendGroupsMsg:
private JSONObject sendByMsgList(String uuid, String vid, boolean isroom,
List<Map<String, Object>> msgList) throws Exception {
JSONObject req = new JSONObject();
req.put("uuid", uuid);
if (msgList != null && msgList.size() == 1) {
Integer type = resolveMsgTypeFromMap(msgList.get(0));
if (type == 14) { /* SendCDNImgMsg */ }
if (type == 15) { /* SendCDNFileMsg */ }
if (type == 78) { /* SendAppMsg */ }
if (type == 41) { /* SendBusinessCardMsg */ }
if (type == 0) { return sendTextOrExpMsg(uuid, vid, isroom, msgList.get(0)); }
}
req.put("vids", Collections.singletonList(vid));
req.put("isroom", isroom);
req.put("msg_list", msgList);
return post("/wxwork/SendGroupsMsg", req);
}
文字里带 [微笑] 这种括号表情时,不能当纯文本丢。正则切开文本节点和表情节点,改打 SendTextAndExpMsg:
if (content.contains("[") && content.contains("]")) {
req.put("content", parseTextToExpArray(content));
return post("/wxwork/SendTextAndExpMsg", req);
}
req.put("content", content);
return post("/wxwork/SendTextMsg", req);
还有一个隐蔽点:纯文本如果 type 标错,网关会当成「群发消息」下发系统提示。实现里对纯文本会强制走 SendTextMsg,避免运营看到「发出去了但客户觉得像广告」。
四、发送循环:间隔、窗口、暂停断点、失败闭环
真正决定能不能规模化的,是这一段循环。逐个 vid 发送,而不是一次丢整个数组——这样才能按对象记成功/失败,才能暂停后续发。
int startIndex = Math.max(0, sc.getIntValue("resumeFromIndex"));
for (int i = startIndex; i < vids.size(); i++) {
if (isTaskPaused(taskId)) {
persistPartialAndPause(taskId, i, success, fail, failList, sendLogList, t);
return;
}
if (endTimeReached()) break; // 硬截止
if (i > 0 && frequencySec > 0) {
Thread.sleep(frequencySec * 1000L); // 默认 120s,给风控留余量
}
JSONObject result = sendByMsgList(uuid, vid, isroom, msgList);
if (errcode == 0) {
success++;
persistMassSentMessage(...); // 写 Redis,工作台能看到
sendLogList.add(log);
} else {
fail++;
if (autoRetry) trySendAgain(...); // 立刻再打一枪
else failList.add(failRecord); // pending,等人工重试 API
}
}
失败记录落 sys_mass_msg_fail,状态 pending。控制台「重试」走 POST /system/massMsg/retry,只补失败的,不会骚扰已经收到的人。
暂停时把当前下标写进 scheduleConfig.resumeFromIndex,任务置 paused。续发不是重头来,是从断点接着 sleep + send。
终态按计数收口:
failCount == 0→successsuccessCount == 0→failed- 其余 →
partial
同时写一条 sys_notice,运营不用守着页面也能知道这单结束了。
五、SOP:计划和执行必须拆开
如果「每天 9 点问候重点客户」和「某一次实发记录」共用一张表、同一个状态,暂停计划会把历史执行搞乱,改文案会污染已经发出去的记录。
所以 SOP 计划只存模板(taskCategory=sop)。手动点「执行」时,前端克隆内容,新建一条 sop_record:
const taskData = {
taskName: row.taskName + ' (手动执行)',
taskCategory: 'sop_record',
uuid: row.uuid,
vids: vidsParsed,
isroom: row.isroom === 1,
msg_list: msgListParsed,
taskType: row.scheduleMode || 'timed',
frequency: 120
}
sendMassMsg(taskData)
记录 Tab 查询 sop_record,并合并仍在跑的 sop 计划,执行中的任务支持轮询刷新。运营的心智很简单:左边改规则,右边看结果。
六、和客服工作台打通:群发过的消息,会话里必须看得到
群发如果只写自己的任务表,客服在工作台打开同一个客户,聊天记录是空的——这会直接把「机器人到底发过什么」问到研发头上。
成功回调后按工作台同一套结构写入 Redis:
/** 群发成功后写入 Redis,供工作台 lastMessages / 聊天记录回显 */
private void persistMassSentMessage(...) {
// 解析 sender / receiver / msgId / sendTime
// 目标 vid 若是机器人自己,直接跳过,避免会话 key 错乱
wxworkMessageRedisService.saveMessage(msg);
}
前后端都会过滤机器人自身 vid。目标列表前端不让选自己,后端 filterOutRobotSelfVid 再挡一层。
联系人加载也按运营体验做了:先读本地缓存再渐进拉 DB/API,切换机器人时用 CancelToken 取消在途请求,避免接口堆积把选人器卡死。
七、为什么这套设计能扛住真实运营
对照文章开头的坑,落地后对应关系是:
| 痛点 | 做法 |
|---|---|
| 人肉点发送 | 任务化 + 异步作业,创建接口秒回 |
| 多消息类型翻车 | msg_list 统一,后端按 type 路由专用 Send API |
| 风控封号 | 默认 120s 间隔,结束时间硬截止 |
| 中途要停 | paused + resumeFromIndex |
| 失败整单重跑 | fail 表 + retry API,只补 pending |
| 每天重复劳动 | SOP 计划 / 记录分离,周期 cron + 手动执行 |
| 客服看不到群发记录 | 成功即写 Redis,工作台会话一致 |
技术上没有用消息中间件,是有意的:单机线程池 + 数据库状态对这个体量足够,出问题能对着 sys_mass_msg_task / fail / send_log 三张表把一单完整复盘。真要上多实例,把 dispatchTimedTask 和周期 tick 换成延迟队列即可,状态机不用改。
如果你也在做企微 SCRM / 私域中台,欢迎评论区交流:你们的群发是同步循环、还是上了 MQ?频率是怎么扛风控的?
转载自 CSDN-专业IT技术社区
原文链接:https://blog.csdn.net/WXID_Mrzhu0107/article/details/164025314




