企业微信多账号托管别靠人盯着扫:Spring Boot + Vue 实现扫码登录、代理城市与回调自动登记
SEO 摘要:本文分享企业微信多账号托管的完整落地实践,基于 Spring Boot + Vue 实现扫码登录、代理城市配置与回调自动登记。内容覆盖产品入口设计、技术架构、上号链路(init → 代理 → 扫码 → 入库 → 登记回调)、在线状态同步与授权槽位管理,并给出真实源码与现网边界说明,适合私域运营、后端开发与架构师参考。
目录
- 1. 产品里三个入口,别搞混
- 2. 技术架构:Vue 只负责扫码,在线和回调在 Spring Boot
- 3. 上号链路:init → 代理 → 扫码 → 入库 → 登记回调
- 3.1 标准准备:先拿 uuid,再决定要不要代理
- 3.2 选「本机」等于拆掉代理
- 3.3 扫完码:先拉资料,再写入库
- 3.4 重新登录不是再扫一遍那么简单
- 3.5 回调地址必须是网关能 POST 到的公网
- 4. 在线状态:每分钟对一次,聊天回调负责纠偏
- 5. 现网边界
- 6. 落地清单
- 7. 总结
技术栈说明 本教程全文使用的底层API调用地址:https://wechatapi.apifox.cn/ 代码调用示例参考官网:https://www.jikehudong.com/ 开发语言:c + java 开发框架:Spring Boot + Vue
私域多号运营的真实痛点不是「没有企微」,而是这三件事叠在一起:
很多团队的做法是:每台电脑开一个企微客户端,人盯着二维码。这解决不了授权槽位、登录城市、消息回调、在线状态、工作台聚合这些问题。
我们在 极客聚合(企业微信聚合平台) 里把这条链路做成了现网功能:后台扫码上号,登录城市走系统里配好的代理,回调地址由服务端自动登记,在线状态每分钟对一次网关。下面全部是真实菜单名和源码,没有「无限并发几千号同时在线扫」这种会把号送走的能力。
1. 产品里三个入口,别搞混
多账号托管不是「一个登录按钮」,而是三层配置:
| 上号与库存 | 企微账号管理 → 账号管理 | 统计卡、账号列表、登录 / 退出 / 二次验证 / 日志 |
| 按号配能力 | 企微账号管理 → 账号设置 | 这个号开不开关键字、口令入群、自动拉人、AI |
| 自动化时段 | 企微账号管理 → 工作时间 | 未设置或已禁用的号,不受工作时间限制 |
旁边还有两个底座,不在这个菜单里,但不上号就跑不起来:
| 登录城市 | 系统设置 → 配置代理 | 城市名会出现在「登录企业微信」的下拉框 |
| 授权上限 | 系统设置 → 成员管理 → 托管账号上限 | 非 admin 按「上限 − 在线数」扣槽位 |
| 会话聚合 | 客服工作台 | 侧边栏按钮打开独立窗口,多号切会话 |
账号管理页顶部四张卡:已购买授权、已登录账号、已到期授权、离线账号。admin 的剩余可用授权显示 「无限」,普通成员才按 account_limit 扣。 
点「登录企业微信」是两步向导:初始化 → 扫码登录。页面写死了两件事:消息回调地址由后台配置;登录城市来自 系统设置 → 配置代理。首次登录后约 30 分钟内可能二次验证,建议尽量等满这段再密集上号。

号上完只是「能在线」。真正按号配自动化,要切到 账号设置,先点账号卡片:

进账号后是四张能力卡,和 AI 客服、加好友不是一个开关包打天下——每个号自己勾:

工作时间是第三层闸。页面提示很直白:未设置或已禁用规则的账号,不受工作时间限制。 没配规则不等于「全天停机」,而是自动化不受这段约束。

登录城市不是手填 IP。运营在 配置代理 里按服务商「固定 IP」列表录入:省市 → 创建机器人时的展示名;IP / 端口 / 账号 / 密码 → 下发到网关 /wxwork/setProxy。代理类型只支持 http / socks5。选「本机」等于 127.0.0.1:1081 http,等同移除代理。

号都在线之后,客服不用在十几台电脑之间切。侧边栏「客服工作台」打开独立窗口:左侧切托管号,中间会话,右侧话术库。号离线时页面会说实话——只展示本地缓存或已落库消息生成的会话,不会假装还在实时收消息。

2. 技术架构:Vue 只负责扫码,在线和回调在 Spring Boot
整体不是「前端拿着二维码直连网关」。Vue 负责选城市、展示二维码、点登录 / 退出。真正 init、setProxy、SetCallbackUrl、GetRunClientByUuid,全在 Spring Boot。在线状态不是前端轮询硬撑,而是定时任务写回 MySQL,各业务模块只读这张表。
#mermaid-svg-C1kz1LPxQ1ko2SfJ{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-C1kz1LPxQ1ko2SfJ .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-C1kz1LPxQ1ko2SfJ .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-C1kz1LPxQ1ko2SfJ .error-icon{fill:#552222;}#mermaid-svg-C1kz1LPxQ1ko2SfJ .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-C1kz1LPxQ1ko2SfJ .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-C1kz1LPxQ1ko2SfJ .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-C1kz1LPxQ1ko2SfJ .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-C1kz1LPxQ1ko2SfJ .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-C1kz1LPxQ1ko2SfJ .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-C1kz1LPxQ1ko2SfJ .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-C1kz1LPxQ1ko2SfJ .marker{fill:#333333;stroke:#333333;}#mermaid-svg-C1kz1LPxQ1ko2SfJ .marker.cross{stroke:#333333;}#mermaid-svg-C1kz1LPxQ1ko2SfJ svg{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-C1kz1LPxQ1ko2SfJ p{margin:0;}#mermaid-svg-C1kz1LPxQ1ko2SfJ .label{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;color:#333;}#mermaid-svg-C1kz1LPxQ1ko2SfJ .cluster-label text{fill:#333;}#mermaid-svg-C1kz1LPxQ1ko2SfJ .cluster-label span{color:#333;}#mermaid-svg-C1kz1LPxQ1ko2SfJ .cluster-label span p{background-color:transparent;}#mermaid-svg-C1kz1LPxQ1ko2SfJ .label text,#mermaid-svg-C1kz1LPxQ1ko2SfJ span{fill:#333;color:#333;}#mermaid-svg-C1kz1LPxQ1ko2SfJ .node rect,#mermaid-svg-C1kz1LPxQ1ko2SfJ .node circle,#mermaid-svg-C1kz1LPxQ1ko2SfJ .node ellipse,#mermaid-svg-C1kz1LPxQ1ko2SfJ .node polygon,#mermaid-svg-C1kz1LPxQ1ko2SfJ .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-C1kz1LPxQ1ko2SfJ .rough-node .label text,#mermaid-svg-C1kz1LPxQ1ko2SfJ .node .label text,#mermaid-svg-C1kz1LPxQ1ko2SfJ .image-shape .label,#mermaid-svg-C1kz1LPxQ1ko2SfJ .icon-shape .label{text-anchor:middle;}#mermaid-svg-C1kz1LPxQ1ko2SfJ .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-C1kz1LPxQ1ko2SfJ .rough-node .label,#mermaid-svg-C1kz1LPxQ1ko2SfJ .node .label,#mermaid-svg-C1kz1LPxQ1ko2SfJ .image-shape .label,#mermaid-svg-C1kz1LPxQ1ko2SfJ .icon-shape .label{text-align:center;}#mermaid-svg-C1kz1LPxQ1ko2SfJ .node.clickable{cursor:pointer;}#mermaid-svg-C1kz1LPxQ1ko2SfJ .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-C1kz1LPxQ1ko2SfJ .arrowheadPath{fill:#333333;}#mermaid-svg-C1kz1LPxQ1ko2SfJ .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-C1kz1LPxQ1ko2SfJ .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-C1kz1LPxQ1ko2SfJ .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-C1kz1LPxQ1ko2SfJ .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-C1kz1LPxQ1ko2SfJ .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-C1kz1LPxQ1ko2SfJ .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-C1kz1LPxQ1ko2SfJ .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-C1kz1LPxQ1ko2SfJ .cluster text{fill:#333;}#mermaid-svg-C1kz1LPxQ1ko2SfJ .cluster span{color:#333;}#mermaid-svg-C1kz1LPxQ1ko2SfJ 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-C1kz1LPxQ1ko2SfJ .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-C1kz1LPxQ1ko2SfJ rect.text{fill:none;stroke-width:0;}#mermaid-svg-C1kz1LPxQ1ko2SfJ .icon-shape,#mermaid-svg-C1kz1LPxQ1ko2SfJ .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-C1kz1LPxQ1ko2SfJ .icon-shape p,#mermaid-svg-C1kz1LPxQ1ko2SfJ .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-C1kz1LPxQ1ko2SfJ .icon-shape .label rect,#mermaid-svg-C1kz1LPxQ1ko2SfJ .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-C1kz1LPxQ1ko2SfJ .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-C1kz1LPxQ1ko2SfJ .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-C1kz1LPxQ1ko2SfJ :root{–mermaid-font-family:\”trebuchet ms\”,verdana,arial,sans-serif;}
存储
企微网关
Spring Boot
Vue 管理端
企微账号管理 / 账号列表
登录企业微信 / 选登录城市
系统设置 / 配置代理
客服工作台 / 多号会话
SysWxworkControllerinit / save / setCallback
prepareWxworkLoginInstanceinit → setProxy
WxworkCallbackBindService
在线同步 GetRunClientByUuid并行最多 3
授权槽位account_limit − 在线数
init
GetLoginQrcode
automaticLogin
SetCallbackUrl
GetRunClientByUuid
MySQL sys_wxwork_robot
Redis 会话消息
关键约束写在架构图脚注里,也写进代码:
- 登录城市来自配置代理,不是登录弹窗里手填 IP。
- 二次验证:接口 -11025,或回调 type=100012(号会被标离线)。
- 号离线后释放可登录数;槽位按 在线数 扣,不是按列表总行数扣。
3. 上号链路:init → 代理 → 扫码 → 入库 → 登记回调
3.1 标准准备:先拿 uuid,再决定要不要代理
前端 prepareWxworkLoginInstance 把三步收成一次调用。initWaitMs 默认 2 秒,选了城市代理则先 setProxy 再等 6 秒,给网关把出口切过去:
export async function prepareWxworkLoginInstance(params = {}, options = {}) {
const initRes = await initWxworkRobot({ vid }, options)
const uuid = data.uuid || data.UUID
if (proxyId > 0) {
await applyWxworkProxy({ uuid, proxyId }, options)
await sleep(params.proxyWaitMs != null ? params.proxyWaitMs : 6000)
} else {
await sleep(params.initWaitMs != null ? params.initWaitMs : 2000)
}
return { …data, uuid: String(uuid) }
}
后端 POST /wxwork/init 固定 deverType=ipad。uuid 一回来就 bindQuietly 登记回调,不等扫码成功——这样扫码过程中已经能接到网关事件。
3.2 选「本机」等于拆掉代理
proxyId ≤ 0 时服务端不会省略 setProxy,而是显式打本机:
if (proxyId <= 0) {
req.put("ip", "127.0.0.1");
req.put("port", 1081);
req.put("proxyType", "http");
}
城市代理必须带齐 IP / 端口,类型只能是 http 或 socks5,否则网关会直接报 101211。代理表里的「备注」只做本系统备忘,不会塞进 setProxy。
3.3 扫完码:先拉资料,再写入库
POST /wxwork/save 不是把前端填的昵称当真理。在线时会带重试地调 GetRunClientByUuid,从 user_info.object 抽昵称、头像、企微号再入库。离线保存会跳过详情,避免把一个已经掉线的实例写成「在线且资料齐全」。
保存成功且不是离线,会再 bindQuietly 一次——init 时登记过,这里补一次,防止中途 uuid 变了。
3.4 重新登录不是再扫一遍那么简单
列表上的「登录」走 performLogin:有 vid 就 init + setProxy,必要时 automaticLogin,最后按数据库 id save。已经在线的号按钮是灰的,文案是「机器人已在线,无需重复登录」。
失败要按错误码分流,不能一律弹「登录失败」:
| -11025 或回调 100012 | 打开二次验证二维码 |
| SecondaryValidation 返回 -12007(大约超过 2 分钟) | 自动改走常规扫码 |
| automaticLogin 返回 -2007 | 自动登录凭证失效,同样改走常规扫码 |
| 「实例不存在 / uuid 失效」且没有 vid | 把该号标离线,避免假在线占槽位 |
手机操作顺序页面也写死了:先点「是本人使用,去扫码验证」→ 扫网页二维码 → 再点「确定是本人使用」。顺序反了,二次验证会空转。
3.5 回调地址必须是网关能 POST 到的公网
String bindUrl = wxworkCallbackUrl
+ (wxworkCallbackUrl.contains("?") ? "&" : "?")
+ "uuid=" + uuid;
req.put("uuid", uuid);
req.put("url", bindUrl);
// POST {wxwork.apiUrl}/wxwork/SetCallbackUrl
wxwork.callbackUrl 配成 127.0.0.1 / localhost 时,日志会警告:第三方访问不到。网关返回 404,提示隧道没开或域名失效;405 则是隧道 / Nginx 没把 POST 转到 /wxwork/callback。本机开发可以把地址配上,但不要指望局域网 IP 能接到云上的网关回调。
4. 在线状态:每分钟对一次,聊天回调负责纠偏
各业务(加好友分配、工作台轮询、群发选号)都只认库里的 online_status。真相来源是网关 GetRunClientByUuid,不是前端心跳。
定时任务:
@Scheduled(cron = "0 * * * * ?")
public void syncEveryMinute() {
wxworkRobotOnlineSyncService.syncAllRobotsOnlineStatus();
}
启动后再延迟 15 秒补一次,避免刚部署时库表还是旧状态。一次同步会扫全部机器人,但并发被 Semaphore 卡死:
int ONLINE_CHECK_MAX_PARALLEL = 3;
校验异常且当前还标着 online,会改成 offline——宁可少占一个槽位,也不让下游把请求丢给一个已经死掉的 uuid。
反向纠偏在消息回调里:实例其实还活着,定时任务却误标离线时,工作台会停轮询、只读 MySQL。所以收到聊天回调会把该 uuid 改回 online:
if (robot != null && "offline".equalsIgnoreCase(robot.getOnlineStatus())) {
wxworkRobotService.updateOnlineStatusByUuid(uuid, "online");
}
授权槽位跟这套在线状态绑定:
remainingLoginSlots() {
if (this.isAdmin) return null
return Math.max(0, lim – this.weworkOnlineRobotCount)
}
剩余为 0,「登录企业微信」禁用,tooltip 是「当前可登录数量为 0,无法创建企微机器人」。批量登录离线号时,也会先数一遍:离线数量大于剩余槽位,直接拒绝,不会先 init 再报错。
5. 现网边界
能做的:
- 扫码上号、选登录城市、批量登录 / 退出 / 删除。
- 在线号不能再点登录、二次验证、删除;可以账号退出、看日志。
- 回调由 wxwork.callbackUrl 自动登记,工作台可点「重新设置回调」。
- 每分钟同步在线;聊天回调纠正误标离线。
- 非 admin 按「托管账号上限 − 在线数」扣槽;admin 显示无限。
- 按号配关键字 / 口令入群 / 自动拉人 / AI;工作时间未配等于不限制。
明确没做、避免售前被问穿:
- 不是无限并发几千号同时在线扫。 在线检测并行最多 3,登录仍是一号一码。
- 槽位按在线数扣,不是按「曾经登录过的行数」扣。 离线会释放;列表里挂着一堆离线号,不占剩余可登录数。
- 二次验证有时效。 -12007 或自动登录 -2007 会退回常规扫码,不会卡在「二次验证二维码获取失败」。
- 本机 callbackUrl 第三方访问不到。 没有公网 / 隧道,工作台收不到实时消息。
- 工作时间未设置 ≠ 下班停机。 未设置或禁用的号,自动化不受工作时间限制。
- 工作台离线不是空壳装实时。 离线只展示缓存或已落库会话,要同步得先让号在线。
6. 落地清单
7. 总结
多账号托管的技术难度不在「弹出一个二维码」,而在 把城市出口、回调登记、在线真相和授权槽位做成同一套底座。极客聚合这条链路已经按这个节奏在跑:城市在系统设置里配一次,号在账号管理里扫进去,会话在工作台里聚合,掉线由定时任务和回调一起认。
如果你也在管一批企微号,被「十几个号轮流扫」「掉线半天没人发现」「会话散落在多台电脑」折磨过,欢迎在评论区留言场景(几号、要不要分城市出口、客服是否共用一个工作台)





