SEO 摘要 :本文基于极客互动(企业微信聚合平台)的现网实现,完整拆解企业微信批量加好友的工程化方案。核心思路是「导入只建任务,分配才点火」:Excel 名单先进任务,运营把客户分给在线托管号,Spring Boot 账号级分发器按 5~10 分钟随机间隔、工作时段、每日上限等风控闸执行
SearchContact → AddSearch,通过后再按模板延迟发送欢迎语。文章覆盖控制台三层入口设计、客户状态机、分发器四道闸、Redis 欢迎语记账、开发设计边界与功能落地清单,全部为真实菜单名与源码,无「无限并发」式夸大。
目录
- [1. 控制台入口设计](#1. 控制台入口设计)
- [2. 技术架构:导入只建任务,分配才点火](#2. 技术架构:导入只建任务,分配才点火)
- [3. 一条手机号,服务端怎么走](#3. 一条手机号,服务端怎么走)
- [3.1 导入上限写在代码里,不是只写在 UI 上](#3.1 导入上限写在代码里,不是只写在 UI 上)
- [3.2 分配成功才点火](#3.2 分配成功才点火)
- [3.3 分发器里的四道闸](#3.3 分发器里的四道闸)
- [3.4 SearchContact → AddSearch](#3.4 SearchContact → AddSearch)
- [4. 通过之后:欢迎语和加好友请求是两套东西](#4. 通过之后:欢迎语和加好友请求是两套东西)
- [5. 开发设计边界](#5. 开发设计边界)
- [6. 功能落地清单](#6. 功能落地清单)
- [7. 开发总结](#7. 开发总结)
私域获客的真实痛点不是「没有手机号」,而是这三件事叠在一起:
- 名单在 Excel 里,人在企微里:客服一个个搜、一个个点添加,两千条号加一周都加不完。
- 脚本一冲就封:固定 3 秒一条、全天不停,比真人还整齐,风控一眼能看出来。
- 加通过了没人跟:对方点了同意,第一条消息还得客服自己打,转化断在门口。
很多团队的做法是:电脑上挂个按键精灵,对着搜索框狂点。这解决不了多账号分发、每日时段、随机间隔、打招呼语轮换、通过后欢迎语这些问题。
我们在 极客互动(企业微信聚合平台) 里把这条链路做成了现网功能:Excel 导入任务,名单分配给在线托管号,服务端按规则先 SearchContact 再 AddSearch,通过后再按模板发欢迎语。下面全部是真实菜单名和源码,没有「无限并发」「一秒加完」这种会把号送走的能力。
技术栈说明
本教程全文使用的底层API调用地址:https://wechatapi.apifox.cn/
代码调用示例参考官网:https://www.jikehudong.com/
开发语言:c + java
开发框架:Spring Boot + Vue
1. 控制台入口设计
批量加好友不是「一个导入按钮」,而是三层配置:
| 入口 | 菜单路径 | 干什么 |
|---|---|---|
| 任务与名单 | 好友添加中心 → 批量添加 | Excel 导入、看进度、进详情分配 |
| 单条补漏 | 好友添加中心 → 手动添加 | 选在线号 + 手机号,走同一套 SearchContact |
| 风控与话术 | 好友添加中心 → 规则设置 | 时段 / 间隔 / 上限、打招呼语、通过后欢迎语 |
页面上的使用提示写得很直白:先导入,再进详情分配,然后等系统执行。导入本身不会立刻加好友 ,必须把客户分给某个在线托管账号。

点「查看规则」能直接看到当前通用加粉闸,不用先跳到设置页:

「新增任务」是一个三步向导。蓝条写死了上限:单次导入不可超过 2000 个好友;首列手机号,第二列可选客户名称。

导入完成后进「任务详情」,名单落在 待分配 / 已分配 / 已添加 三个页签。只有待分配记录可以点「分配」或「批量分配」:

分配对话框要求 在线 企微账号。号没登录时确认按钮是灰的,文案是「暂无在线托管账号,请先确认账号已登录」------不会把请求丢给一个离线的 uuid 空转。

规则页才是真正的油门。默认值不是拍脑袋写在文案里,而是前端表单和后端 defaultRule() 对齐的:

打招呼语和欢迎语是两套模板,不要混:
- 添加话术 :发好友请求时带的验证语,执行时随机抽一条,可插入
#托管账号昵称#。 - 欢迎语 :对方通过之后的第一条私聊,可延迟秒数,可插入
#客户昵称#/#托管账号昵称#。


临时补一条号,不用走 Excel,切到 手动添加 :选托管账号、填手机号、搜索。底层还是 SearchContact。页面上写明:本系统手动添加也会把当日剩余可加粉人数减 1,避免「规则设了 20 条、人手又加了 80 条」。

2. 技术架构:导入只建任务,分配才点火
整体不是「前端拿着手机号直接调网关」。Vue 只负责导入、分配、改规则。真正搜人、发请求、算间隔,全在 Spring Boot 的账号级分发器里。

用 mermaid 把同一张图画成可维护版本:
#mermaid-svg-FwdNQSxRgRsTom1h{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-FwdNQSxRgRsTom1h .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-FwdNQSxRgRsTom1h .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-FwdNQSxRgRsTom1h .error-icon{fill:#552222;}#mermaid-svg-FwdNQSxRgRsTom1h .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-FwdNQSxRgRsTom1h .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-FwdNQSxRgRsTom1h .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-FwdNQSxRgRsTom1h .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-FwdNQSxRgRsTom1h .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-FwdNQSxRgRsTom1h .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-FwdNQSxRgRsTom1h .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-FwdNQSxRgRsTom1h .marker{fill:#333333;stroke:#333333;}#mermaid-svg-FwdNQSxRgRsTom1h .marker.cross{stroke:#333333;}#mermaid-svg-FwdNQSxRgRsTom1h svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-FwdNQSxRgRsTom1h p{margin:0;}#mermaid-svg-FwdNQSxRgRsTom1h .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-FwdNQSxRgRsTom1h .cluster-label text{fill:#333;}#mermaid-svg-FwdNQSxRgRsTom1h .cluster-label span{color:#333;}#mermaid-svg-FwdNQSxRgRsTom1h .cluster-label span p{background-color:transparent;}#mermaid-svg-FwdNQSxRgRsTom1h .label text,#mermaid-svg-FwdNQSxRgRsTom1h span{fill:#333;color:#333;}#mermaid-svg-FwdNQSxRgRsTom1h .node rect,#mermaid-svg-FwdNQSxRgRsTom1h .node circle,#mermaid-svg-FwdNQSxRgRsTom1h .node ellipse,#mermaid-svg-FwdNQSxRgRsTom1h .node polygon,#mermaid-svg-FwdNQSxRgRsTom1h .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-FwdNQSxRgRsTom1h .rough-node .label text,#mermaid-svg-FwdNQSxRgRsTom1h .node .label text,#mermaid-svg-FwdNQSxRgRsTom1h .image-shape .label,#mermaid-svg-FwdNQSxRgRsTom1h .icon-shape .label{text-anchor:middle;}#mermaid-svg-FwdNQSxRgRsTom1h .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-FwdNQSxRgRsTom1h .rough-node .label,#mermaid-svg-FwdNQSxRgRsTom1h .node .label,#mermaid-svg-FwdNQSxRgRsTom1h .image-shape .label,#mermaid-svg-FwdNQSxRgRsTom1h .icon-shape .label{text-align:center;}#mermaid-svg-FwdNQSxRgRsTom1h .node.clickable{cursor:pointer;}#mermaid-svg-FwdNQSxRgRsTom1h .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-FwdNQSxRgRsTom1h .arrowheadPath{fill:#333333;}#mermaid-svg-FwdNQSxRgRsTom1h .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-FwdNQSxRgRsTom1h .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-FwdNQSxRgRsTom1h .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-FwdNQSxRgRsTom1h .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-FwdNQSxRgRsTom1h .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-FwdNQSxRgRsTom1h .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-FwdNQSxRgRsTom1h .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-FwdNQSxRgRsTom1h .cluster text{fill:#333;}#mermaid-svg-FwdNQSxRgRsTom1h .cluster span{color:#333;}#mermaid-svg-FwdNQSxRgRsTom1h 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-FwdNQSxRgRsTom1h .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-FwdNQSxRgRsTom1h rect.text{fill:none;stroke-width:0;}#mermaid-svg-FwdNQSxRgRsTom1h .icon-shape,#mermaid-svg-FwdNQSxRgRsTom1h .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-FwdNQSxRgRsTom1h .icon-shape p,#mermaid-svg-FwdNQSxRgRsTom1h .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-FwdNQSxRgRsTom1h .icon-shape .label rect,#mermaid-svg-FwdNQSxRgRsTom1h .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-FwdNQSxRgRsTom1h .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-FwdNQSxRgRsTom1h .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-FwdNQSxRgRsTom1h :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 存储
企微网关
Spring Boot
Vue 好友添加中心
批量添加 Excel导入
任务详情 分配托管号
规则设置 间隔与上限
手动添加
SysMassAddController
dispatchByRobot
每 uuid 一个异步任务
风控闸
时段 / 随机间隔 / 每日上限 / 连续N条后等待
SearchContact → AddSearch
WxworkMassAddWelcomeHandler
SearchContact
AddSearch
SendTextMsg
MySQL 任务+客户名单
Redis 欢迎语 pending/sent
关键设计就四条:
- 导入和执行解耦。 Excel 进来
status=0待分配;分配成功才status=1,事务提交后才startRobotDispatcher。 - 一号一个分发器。
RUNNING_ROBOT_DISPATCHERS是ConcurrentHashMap.newKeySet(),同一个 uuid 不会并行跑两轮,避免间隔被打穿。 - 先搜后加,两步网关。
SearchContact要 ticket / optionid,对方开了手机号隐私保护会明确失败,不会拿空 ticket 去撞AddSearch。 - 欢迎语靠 Redis 记账。 AddSearch 成功写
welcome_pending,回调里「已通过好友验证」或对方首条私聊再发,去重 key 保证只发一次。
3. 一条手机号,服务端怎么走
3.1 导入上限写在代码里,不是只写在 UI 上
java
private static final int IMPORT_MAX = 2000;
if (rows.size() > IMPORT_MAX) {
throw new ServiceException("单次导入不可超过" + IMPORT_MAX + "条");
}
模板只有两列:手机号、客户名称。解析时会做手机号归一化,空行跳过。任务表记下总数 / 已分配 / 已发起 / 已成功,列表页那四列不是前端自己加的。
客户状态机:
| status | 含义 |
|---|---|
| 0 | 待分配 |
| 1 | 已分配,等分发器取走 |
| 2 | 已发起(正在 Search/Add) |
| 3 | 网关返回成功 |
| 4 | 失败(搜不到、无 ticket、AddSearch 报错等) |
3.2 分配成功才点火
java
int n = customerMapper.assignCustomers(customerIds, robotUuid.trim());
if (n > 0) {
TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
@Override
public void afterCommit() {
startRobotDispatcher(uuid);
}
});
}
必须 afterCommit:否则分发器跑起来时行还没提交,会把刚分配的人当成「当前无待发起客户」直接结束。
3.3 分发器里的四道闸
默认规则和页面一致:
java
o.put("timeStart", "09:00:00");
o.put("timeEnd", "18:00:00");
o.put("intervalMin", "00:05:00");
o.put("intervalMax", "00:10:00");
o.put("dailyLimit", 5000);
o.put("autoRecover", false);
o.put("continuousLimit", 10);
o.put("waitMinutes", 2);
每日上限 5000 是天花板,不是建议值。 真正把速度按住人的是 5~10 分钟随机间隔:工作时段按 9 小时算,一个号一天大概 50~100 次请求。再叠加「连续 10 条后等 2 分钟」。把上限调到 20,才是很多团队实际在用的数。
循环里顺序是固定的:
java
if (cfg.dailyLimit > 0 && sentToday >= cfg.dailyLimit) return;
if (!first) sleepSeconds(randomRange(cfg.intervalMinSec, cfg.intervalMaxSec));
ensureInTimeWindow(cfg); // 不在 09:00--18:00 就睡到下一段
customerMapper.updateCustomerStatusById(c.getCustomerId(), 2);
String script = pickRandomScript();
JSONObject resp = invokeAddFriend(robotUuid, c.getMobile(), script);
间隔用 ThreadLocalRandom 在 min~max 闭区间取值,不是固定 5 分钟。不在工作时段就 ensureInTimeWindow 睡过去,不会下班还在加。
命中「频繁 / 限制 / too many」且打开了「加粉频繁自动恢复」,分发器会 sleepUntilTomorrowZero(),第二天 00:00 再继续;没开这个开关,任务会被停住,避免硬刚。
3.4 SearchContact → AddSearch
java
// 1) SearchContact
searchReq.put("uuid", uuid);
searchReq.put("phoneNumber", mobile);
// 2) 必须有 ticket + optionid,否则直接失败
// 常见失败:未搜索到联系人 / 对方开启手机号隐私保护
// 3) AddSearch
addReq.put("uuid", uuid);
addReq.put("vid", vidLong);
addReq.put("optionid", optionid);
addReq.put("phone", mobile);
addReq.put("content", content); // 随机打招呼语,替换 #托管账号昵称#
addReq.put("ticket", ticket);
话术为空时兜底「你好」,避免空验证语被网关拒。手动添加页会单独提示:搜到人但昵称打成 *****、没有 ticket,就不要点添加。
4. 通过之后:欢迎语和加好友请求是两套东西
AddSearch 成功后写 Redis:
text
wxwork:massadd:welcome_pending:{uuid}:{extVid} TTL 7 天
wxwork:massadd:welcome:{uuid}:{peerVid} 去重,只发一次
回调里 WxworkMassAddWelcomeHandler 认两种触发:
- 系统提示文案像「已通过好友验证」;
- pending 还在,对方发来任意首条私聊文本(有些场景网关不推系统提示)。
然后随机抽一条欢迎语,按 delaySec 睡(上限 300 秒),SendTextMsg 打回私聊。没配欢迎语模板就跳过,不会用打招呼语凑数。
占位符替换就两行:
java
return template.replace("#托管账号昵称#", nick).replace("#客户昵称#", cust);
5. 开发设计边界
能做的:
- Excel 导入最多 2000 条,任务维度看已分配 / 已发起 / 已成功。
- 必须分配给 在线 托管号才执行;一号一个分发器。
- 时段、随机间隔、每日上限、连续 N 条后等待、加粉频繁次日恢复。
- 通用规则 + 按账号个性规则(可关
autoAdd让某号完全不跑批量)。 - 打招呼语随机;通过后欢迎语延迟;手动添加计入当日额度。
- 搜不到号、没 ticket,会把客户打成失败,而不是假成功。
明确没做、避免售前被问穿:
- 不是并发几千号一起加。 每个托管号串行,间隔睡在线程里。
- 不会绕过手机号隐私保护。 没 ticket 就是加不了。
- 导入不会自动均分到所有号。 要运营自己点分配------这是有意的,避免把一份敏感名单洒到不该加的号上。
- 每日上限默认 5000 不等于能加 5000。 间隔才是真实吞吐。
- 欢迎语只走文本。 没有「通过后自动发海报 / 小程序」。
- 第三方外呼「空闲机器人自动加」那套页面目前不是好友添加中心的主路径,本文按现网三个 Tab 写。
6. 功能落地清单
- 菜单做 好友添加中心,三个 Tab:批量添加 / 手动添加 / 规则设置。
- 导入校验 2000 条;客户
status用 0~4,不要只用「成功/失败」。 assignCustomers必须事务提交后再启动分发器。- 分发器按 uuid 互斥;间隔随机;工作时段外 sleep,不要直接 return 把队列扔掉(否则第二天不会自动续)。
- SearchContact 失败要写清楚 errmsg,方便运营区分「号不存在」和「隐私保护」。
- 欢迎语用 pending + sent 两把 Redis key,回调和兜底异步只许成功一次。
- 手动添加走同一网关,并且扣当日额度。
7. 开发总结
批量加好友的技术难度不在「调两个 HTTP 接口」,而在 把速度按成人的节奏,还要把失败原因暴露给运营。极客聚合这条链路已经按这个节奏在跑:名单先进任务,人决定分给哪个号,机器只负责按 5~10 分钟随机去搜、去加、通过后再打招呼。
如果你也在管一批企微号,被「脚本加粉封号」「两千条 Excel 没人认领」「加通过了没人回」折磨过,欢迎在评论区留言场景(几号、一天打算加多少、要不要欢迎语延迟)。