企业微信消息群发别靠人一台台点发送:Spring Boot 逐个 SendTextMsg,默认间隔 120 秒落地群发
SEO 摘要:本文讲解企业微信消息群发的落地实现,基于 Spring Boot + Vue 构建群发控制台,任务入库后逐个 vid 调用网关单条接口(SendTextMsg 等),默认间隔 120 秒控频,支持定时/周期调度、失败落库重试与工作台回显。文章覆盖产品三个 Tab 设计、技术架构、SOP 与一次性群发的区别、现网边界及落地清单,适合私域运营与企微二次开发团队参考。
目录
- 1. 产品里三个 Tab,别搞成四个
- 2. 技术架构:任务入库,逐个 vid 打单条接口
- 3. SOP 不是另一套发送器
- 4. 现网边界
- 5. 落地清单
- 6. 总结
私域运营的真实痛点不是「没有群发按钮」,而是这三件事叠在一起:
很多团队的做法是:企微官方群发助手 + 人手点发送。这解决不了多号选机器人、按群/好友筛选、定时/周期调度、失败落库重试、工作台回显这些问题。
我们在 极客互动(企业微信聚合平台) 里把这条链路做成了现网功能:侧边栏 群发控制台 三个 Tab,新建任务默认间隔 120 秒,后端逐个 vid 打网关单条接口。下面全部是真实菜单名和源码,没有「官方群发助手」「几千人同时并发」「超级群发已经挂上菜单」这种能力。
技术栈说明 本教程全文使用的底层API调用地址:https://wechatapi.apifox.cn/ 代码调用示例参考官网:https://www.jikehudong.com/ 开发语言:c + java 开发框架:Spring Boot + Vue
1. 产品里三个 Tab,别搞成四个
菜单路径是 群发控制台(路由 /message,页面标题就是这三个字)。首页快捷卡文案是「消息群发」,点进去还是这一页。
三个 Tab:
| 消息群发 | MassMsgTab.vue | 一次性任务列表:新增、详情、失败记录、重试、删除 |
| SOP计划 | SopTab.vue | 卡片:执行 / 停止 / 修改 / 删除;新建走同一套 CreateMassMsg,taskCategory=sop |
| SOP记录 | MassTaskTab.vue | 执行日志;会把「执行中 / 暂停」的 SOP 计划也并进来 |
仓库里还有 SuperMassMsgTab.vue / CreateSuperMassMsg.vue,现网三个 Tab 没有挂上。不要按第四个 Tab 去演示。
本机「消息群发」列表里有一条任务「客户群发」:状态成功,成功 1 / 失败 0 / 总数 1,创建人 admin,时间 2026-08-22 23:56:22。失败数为 0,「重试」是灰的——按钮绑的是 failCount,没有失败就不能点。

点「详情」弹窗:总目标 1、成功 1、失败 0。执行机器人 uuid 是 3e69a8138f83ca109fd7abd126bcafc2,群发对象「客户」,调度「单次定时任务」,开始 23:57:00、结束 23:58:00。这条历史任务的发送频率写的是 1 秒——创建表单默认是 120,已经落库的任务按当时填的数走,页面不会替你改回去。

点「新增群发」默认对象是 群聊。没选机器人时,左侧选人区写「请先选择机器人」,不会假装拉出一堆群。群内身份:不限 / 群主 / 成员;群类型:全部 / 内部群 / 外部群。

切到 好友:多出「联系人类型」和「客户标签筛选」。列表同样要先选机器人;好友数据走联系人缓存,号离线、缓存空,选人就会空。

内容是五个 Tab:文字 / 图片 / 文件 / 小程序 / 名片。当前 Tab 是哪一种,提交就只带那一种——不是图文混排一条任务。群聊文字里有「@所有人」,文案写「机器人是群主/管理员才生效」;字段 form.atAll 没有写进 msg_list,点「是」也不会变成真正的 @所有人。
表单下半截才是调度。任务类型默认定时;发送频率滑条 1~300 秒,默认 120,提示「近期风控规则有变化,建议不小于120S」。自动重试默认关。结束时间的说明是:到点未发完也立刻停。底部按钮叫「立即发送」,周期任务会改成「保存任务」。截图到这里为止,没有点提交。

SOP 计划是卡片,不是表格。本机有一张「重点客户每日关怀」:周期任务、每天 09:00、好友 · 1 个、创建人 admin、状态 已暂停。底下四个字:执行 / 停止 / 修改 / 删除。暂停时「停止」是灰的。不要点执行——暂停态会走 resume,非暂停会再 sendMassMsg 真正发一轮。

SOP 记录表里也能看到同一条计划:类型周期任务、状态暂停、0/0/1。这个 Tab 会把 sop_record 和「执行中 / 暂停」的 SOP 计划拼在一张表里,不是纯执行日志。

2. 技术架构:任务入库,逐个 vid 打单条接口
整体不是「点一次调用企微官方群发助手」。Vue 把对象列表和一条 msg_list 交给 Spring Boot,POST /system/massMsg/send 先写入 sys_mass_msg_task。定时任务丢给调度器;周期任务状态 periodic_active,每分钟 tick 一次。真正发送时,for 循环每个 vid,中间 Thread.sleep(frequencySec * 1000)。单条内容走 SendTextMsg 这类接口;msg_list 多于一条才兜底 SendGroupsMsg。成功后再写 Redis,工作台能回显。

用 mermaid 把同一张图画成可维护版本:
#mermaid-svg-OSCatzwN5BTDqdfu{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-OSCatzwN5BTDqdfu .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-OSCatzwN5BTDqdfu .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-OSCatzwN5BTDqdfu .error-icon{fill:#552222;}#mermaid-svg-OSCatzwN5BTDqdfu .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-OSCatzwN5BTDqdfu .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-OSCatzwN5BTDqdfu .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-OSCatzwN5BTDqdfu .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-OSCatzwN5BTDqdfu .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-OSCatzwN5BTDqdfu .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-OSCatzwN5BTDqdfu .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-OSCatzwN5BTDqdfu .marker{fill:#333333;stroke:#333333;}#mermaid-svg-OSCatzwN5BTDqdfu .marker.cross{stroke:#333333;}#mermaid-svg-OSCatzwN5BTDqdfu svg{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-OSCatzwN5BTDqdfu p{margin:0;}#mermaid-svg-OSCatzwN5BTDqdfu .label{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;color:#333;}#mermaid-svg-OSCatzwN5BTDqdfu .cluster-label text{fill:#333;}#mermaid-svg-OSCatzwN5BTDqdfu .cluster-label span{color:#333;}#mermaid-svg-OSCatzwN5BTDqdfu .cluster-label span p{background-color:transparent;}#mermaid-svg-OSCatzwN5BTDqdfu .label text,#mermaid-svg-OSCatzwN5BTDqdfu span{fill:#333;color:#333;}#mermaid-svg-OSCatzwN5BTDqdfu .node rect,#mermaid-svg-OSCatzwN5BTDqdfu .node circle,#mermaid-svg-OSCatzwN5BTDqdfu .node ellipse,#mermaid-svg-OSCatzwN5BTDqdfu .node polygon,#mermaid-svg-OSCatzwN5BTDqdfu .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-OSCatzwN5BTDqdfu .rough-node .label text,#mermaid-svg-OSCatzwN5BTDqdfu .node .label text,#mermaid-svg-OSCatzwN5BTDqdfu .image-shape .label,#mermaid-svg-OSCatzwN5BTDqdfu .icon-shape .label{text-anchor:middle;}#mermaid-svg-OSCatzwN5BTDqdfu .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-OSCatzwN5BTDqdfu .rough-node .label,#mermaid-svg-OSCatzwN5BTDqdfu .node .label,#mermaid-svg-OSCatzwN5BTDqdfu .image-shape .label,#mermaid-svg-OSCatzwN5BTDqdfu .icon-shape .label{text-align:center;}#mermaid-svg-OSCatzwN5BTDqdfu .node.clickable{cursor:pointer;}#mermaid-svg-OSCatzwN5BTDqdfu .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-OSCatzwN5BTDqdfu .arrowheadPath{fill:#333333;}#mermaid-svg-OSCatzwN5BTDqdfu .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-OSCatzwN5BTDqdfu .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-OSCatzwN5BTDqdfu .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-OSCatzwN5BTDqdfu .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-OSCatzwN5BTDqdfu .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-OSCatzwN5BTDqdfu .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-OSCatzwN5BTDqdfu .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-OSCatzwN5BTDqdfu .cluster text{fill:#333;}#mermaid-svg-OSCatzwN5BTDqdfu .cluster span{color:#333;}#mermaid-svg-OSCatzwN5BTDqdfu div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-OSCatzwN5BTDqdfu .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-OSCatzwN5BTDqdfu rect.text{fill:none;stroke-width:0;}#mermaid-svg-OSCatzwN5BTDqdfu .icon-shape,#mermaid-svg-OSCatzwN5BTDqdfu .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-OSCatzwN5BTDqdfu .icon-shape p,#mermaid-svg-OSCatzwN5BTDqdfu .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-OSCatzwN5BTDqdfu .icon-shape .label rect,#mermaid-svg-OSCatzwN5BTDqdfu .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-OSCatzwN5BTDqdfu .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-OSCatzwN5BTDqdfu .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-OSCatzwN5BTDqdfu :root{–mermaid-font-family:\”trebuchet ms\”,verdana,arial,sans-serif;}
回写
网关单条接口
Spring Boot
Vue 群发控制台
消息群发
SOP计划
SOP记录
POST /system/massMsg/send
MassMsgScheduler
每分钟 tick 周期任务
逐个 vid Thread.sleep
SendTextMsg
SendCDNImgMsg
SendGroupsMsg 仅多条
Redis 工作台回显
失败记录可重试
创建入口:
@PostMapping("/send")
public AjaxResult send(@RequestBody Map<String, Object> params) {
SysMassMsgTask task = massMsgService.sendMassMsg(params);
return AjaxResult.success(task);
}
前端默认间隔和提交字段:
frequency: 120,
// …
const payload = {
taskName: this.form.taskName || '群发-' + new Date().toLocaleString(),
taskCategory: this.taskCategory || 'normal',
uuid,
vids,
isroom,
msg_list: msgList,
taskType: this.form.taskType,
startTime: this.form.startTime,
endTime: this.form.endTime,
frequency: this.form.frequency,
autoRetry: this.form.autoRetry
};
await sendMassMsg(payload);
后端没带 frequency 时同样落成 120。会先丢掉机器人自己的 vid,避免本端消息挂到机器人会话。没填开始时间就丢线程池立刻发;填了就 massMsgScheduler.schedule。周期任务不在这一步发,只把状态写成 periodic_active。
Integer frequency = params.get("frequency") != null
? Integer.valueOf(params.get("frequency").toString()) : 120;
vids = filterOutRobotSelfVid(uuid, vids);
task.setStatus(isPeriodic ? "periodic_active" : "waiting");
if (!isPeriodic) {
dispatchTimedTask(taskId, startTime);
}
周期调度器每分钟扫一次:
@Scheduled(cron = "0 * * * * ?")
public void tick() {
massMsgService.tickPeriodicMassMessages();
}
发送循环里,间隔、结束时间、暂停是三道闸。暂停会把 resumeFromIndex 写回,下次从停下的那个对象继续。
if (i > 0 && frequencySec > 0) {
Thread.sleep(frequencySec * 1000L);
}
JSONObject result = sendByMsgList(uuid, vid, isroom, msgList);
if (errcode != null && errcode == 0) {
persistMassSentMessage(uuid, vid, isroom, msgList, result);
}
单条按类型分流。文字带 [] 走 SendTextAndExpMsg,避免误走 SendGroupsMsg 变成「这是一条群发消息」:
if (msgList != null && msgList.size() == 1) {
// type 0 -> SendTextMsg / SendTextAndExpMsg
// type 14 -> SendCDNImgMsg
// type 15 -> SendCDNFileMsg
// type 78 -> SendAppMsg
// type 41 -> SendBusinessCardMsg
}
req.put("vids", Collections.singletonList(vid));
return JSON.parseObject(HttpClientSslUtils.doPost(
getSendApiBase() + "/wxwork/SendGroupsMsg", req.toJSONString(), ...));
现网表单 buildMsgList() 一次只返回一个元素,所以日常走上面那些单条接口。图片、文件是点「立即发送」时才 cdnUploadImg / cdnUploadFile,前端卡图片 10MB、文件 20MB。
后端还认 type=10001 打 SendNotice(群公告,仅群聊)。创建表单没有这个 Tab,不要按现网能力吹。
3. SOP 不是另一套发送器
SOP 计划和消息群发共用 CreateMassMsg.vue,只是 taskCategory 从 normal 换成 sop。周期选项是每天 / 每周 / 每月,再加一个触发时分。卡片上的「执行规则」就是这段配置拼出来的文案。
点「执行」有两条路:
第二条会真正往网关打消息。截图里那张「已暂停」的卡片,点执行就是 resume,所以演示时不要点。
SOP 记录 Tab 会并行拉 sop_record 和 sop。无筛选时,计划只保留 running / periodic_active / paused。这就是为什么暂停中的「重点客户每日关怀」会出现在记录表——它还没变成一条独立的执行日志。
4. 现网边界
能做的:
- 三个 Tab:消息群发、SOP 计划、SOP 记录。
- 对象群聊或好友;群可筛身份和内外部;好友可筛内外部 + 企业标签。
- 内容五种之一:文字 / 图片 / 文件 / 小程序 / 名片。
- 定时或周期;默认间隔 120 秒;到结束时间停;可暂停后续传。
- 失败写入失败表,列表「重试」在 failCount>0 时才可点;创建时「自动重试」默认关,打开后当次失败会立刻再打一次。
- 发送成功写 Redis,客服工作台能看到这条群发。
明确没做、避免售前被问穿:
- 不是企微官方群发助手。 不会走企业微信后台那套群发额度,也不会在手机端出现「群发」记录样式。
- 120 秒只是页面建议。 滑条能拖到 1 秒,本机这条「客户群发」详情就是 1 秒。调度器不会替你拦风控。
- 一次一种内容。 五个 Tab 不会拼成图文混排一条 msg_list。
- 「@所有人」是摆设。 单选绑定了 form.atAll,提交 payload 没用它。
- 超级群发代码在仓库,菜单没挂。 不要演示第四个 Tab。
- 群公告只在后端。 SendNotice 有实现,表单没有入口。
- SOP「执行」会真发。 暂停是 resume,否则新建一条 sop_record 再跑发送循环。
- 好友列表依赖号在线 + 客户缓存。 没选机器人是「请先选择机器人」;选了离线号,缓存空就「暂无数据」。
- 不是几千人同时并发。 一个任务一条线程,对象之间 sleep。多开几个任务会叠线程,没有全局限流面板。
- 和「好友添加中心」批量加好友不是同一功能。 群发只对已经能选到的 vid 发消息。
5. 落地清单
6. 总结
消息群发的技术难度不在「再包一层 SendGroupsMsg」,而在 选人、控频、定时/周期、失败续传、工作台回显要落在同一个菜单里。极客聚合这条链路已经按这个节奏在跑:任务入库,逐个 vid 打单条接口,默认间隔 120 秒。官方群发助手没接,超级群发没挂菜单,@所有人也还没写进 payload——这些现网就长这样。
如果你也在管一批企微号,被「一台台点发送」「间隔靠手感」「SOP 和一次性群发对不上账」折磨过,欢迎在评论区留言场景(大概多少个群/好友、要不要周期任务、间隔准备卡在多少秒)。需要看演示或交流私域自动化的,直接私信,我们按现网功能给你对一下是否匹配,不会拿还没做的能力画饼。

