wechatbot888头像
关注
企业微信消息群发别再人工手动执行了:第三方API+Spring Boot + Vue 打造带 SOP 的群发控制台(附架构与源码)封面图

企业微信消息群发别再人工手动执行了:第三方API+Spring Boot + Vue 打造带 SOP 的群发控制台(附架构与源码)

基于极客互动企微 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

TabtaskCategory职责
消息群发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 == 0success
  • successCount == 0failed
  • 其余 → 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

文章来源转载

评论

赞0

评论列表

微信小程序
QQ小程序

关于作者

点赞数:0
关注数:0
粉丝:0
文章:0
关注标签:0
加入于:--