上周一个做电商私域的朋友找我,说他的微信机器人服务上线第一周就出问题了——4 个客服号接同一个回调接口,A 号的消息被 B 号的处理器当成自己的消息处理了一次,CRM 里客户标签全乱了。
这个问题的根源很简单:多实例(多个 appId)共用一个回调入口时,必须按 appId 把消息路由到对应的处理器。如果直接在回调里写一堆 if (appId.equals("xxx")),代码会很快变成一坨 if-else,而且不同实例的业务逻辑(CRM 对接、标签管理、话术模板)互相串台。
我用的接口平台是 WTAPI——它基于微信 iPad/Mac 协议 + RPA 技术,把复杂的微信操作封装成了标准 HTTP API,Java/Python/C++/Go/PHP 都能接,不用自己逆向协议。它的控制台「访问控制」页配置回调地址是全局生效的:一个 Token 下登录的所有微信账号,消息都会推到同一个回调地址。所以多实例路由是企业集成的必经之路。
全景对比:多实例 vs 单实例
| 维度 | 单实例(一个 appId) | 多实例(多个 appId) |
|---|---|---|
| 回调入口 | 一个 URL 就够 | 也是一个 URL,但消息必须按 appId 路由 |
| 业务逻辑 | 一套 handler | 每个 appId 一套 handler(CRM 对接、话术模板、标签规则都可能不一样) |
| 消息发送 | 同一个 Token | 同一个 Token,但 postText 的 appId 参数必须是消息来源的那个实例 |
| 标签管理 | 一套标签 | 每个实例的标签是独立的,不能跨实例引用 |
整体架构
微信平台(多个实例的消息推到同一个回调地址)
↓
Spring Boot 回调入口(唯一)
↓
1. Redis SETNX 去重(Appid + NewMsgId)
2. 按 payload.Appid 路由到对应 handler
↓
handler #1(A 号的 CRM 对接逻辑)/ handler #2(B 号的话术模板)/ ...
↓
各自处理:写 CRM、发欢迎语、打标签
Step 1:回调入口——3 秒内只做去重 + 路由
// ---------------------------------------------------------------------------
// 接口路径、鉴权头以 weiti.apifox.cn 实时文档为准
// Base URL: https://wx.chuapi.com
// X-finder-TOKEN(必需)/ Authorization(部分环境需要,官方标为可选)
// 控制台「微信实例」页扫码后生成 appId,「访问控制」页配置回调地址
// ---------------------------------------------------------------------------
@RestController
@Slf4j
public class WxCallbackController {
private final StringRedisTemplate redis;
private final ThreadPoolTaskExecutor executor;
// 按 appId 路由的 handler 映射:key = appId 前缀,value = 对应 handler
private final Map<String, WxMessageHandler> handlers;
/** 回调入口:3 秒内只做去重 + 入队 */
@PostMapping("/wx/callback")
public ResponseEntity<String> callback(@RequestBody Map<String, Object> payload) {
String appid = (String) payload.get("Appid");
Map<String, Object> data = (Map<String, Object>) payload.get("Data");
long newMsgId = ((Number) data.get("NewMsgId")).longValue();
// 去重键:官方指定 Appid + NewMsgId
String dedupKey = appid + ":" + newMsgId;
Boolean acquired = redis.opsForValue()
.setIfAbsent("wx:dedup:" + dedupKey, "1", 24, TimeUnit.HOURS);
if (!Boolean.TRUE.equals(acquired)) {
return ResponseEntity.ok("");
}
// 入异步队列,立即 ack
executor.submit(() -> {
try {
routeAndHandle(payload);
} catch (Exception e) {
log.error("async handle failed, appid={}, msgId={}", appid, newMsgId, e);
}
});
return ResponseEntity.ok("");
}
/** 按 Appid 路由到对应 handler */
private void routeAndHandle(Map<String, Object> payload) {
String appid = (String) payload.get("Appid");
// appId 前缀约定:"wx_crm_" 开头的走 CRM handler,"wx_support_" 走客服 handler
WxMessageHandler handler = handlers.entrySet().stream()
.filter(e -> appid.startsWith(e.getKey()))
.map(Map.Entry::getValue)
.findFirst()
.orElse(null);
if (handler != null) {
handler.handle(payload);
} else {
log.warn("no handler for appid={}", appid);
}
}
}
复现步骤:控制台「微信实例」页扫码登录多个号 → 「访问控制」页配置同一个回调地址 → 分别用不同号发消息 → 观察回调 JSON 里的 Appid 字段区分不同实例。
Step 2:每个实例一套 Handler
用 Spring 的 @Component + 自定义前缀注解实现自动注册,不用手动配 Map:
public interface WxMessageHandler {
void handle(Map<String, Object> payload);
}
/** 标记一个 handler 处理哪个前缀的 appId */
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
public @interface AppIdPrefix {
String value();
}
@Component
@AppIdPrefix("wx_crm_")
@Slf4j
public class CrmMessageHandler implements WxMessageHandler {
@Override
public void handle(Map<String, Object> payload) {
String appid = (String) payload.get("Appid");
String wxid = (String) payload.get("Wxid");
Map<String, Object> data = (Map<String, Object>) payload.get("Data");
int msgType = ((Number) data.get("MsgType")).intValue();
String fromUser = (String) ((Map<String, Object>) data.get("FromUserName")).get("string");
// 过滤自己发的
if (wxid.equals(fromUser)) return;
if (msgType == 1) {
// CRM 对接逻辑:写客户消息记录、触发关键词规则、打标签
String content = (String) ((Map<String, Object>) data.get("Content")).get("string");
crmService.saveMessage(fromUser, content, appid);
labelService.applyKeywordLabels(fromUser, content, appid);
}
}
}
@Component
@AppIdPrefix("wx_support_")
@Slf4j
public class SupportMessageHandler implements WxMessageHandler {
@Override
public void handle(Map<String, Object> payload) {
// 另一个实例的话术模板、人工转派逻辑
// 和 CrmMessageHandler 完全独立,互不影响
...
}
}
Step 3:多实例 postText 发送——appId 参数不能串
发消息时,WTAPI 的 postText 接口要求 appId 参数必须是消息来源实例的 appId。不能用 A 号的实例去发 B 号收到的消息,接口会报错或者被风控。
@Component
public class WxApiClient {
@Value("${wx.base-url:https://wx.chuapi.com}")
private String baseUrl;
@Value("${wx.token}")
private String token;
@Value("${wx.jwt:}")
private String jwt;
private final RestTemplate restTemplate = new RestTemplate();
public void postText(String appId, String toWxid, String content) {
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_JSON);
headers.set("X-finder-TOKEN", token);
if (jwt != null && !jwt.isBlank()) {
headers.set("Authorization", "Bearer " + jwt);
}
Map<String, Object> body = Map.of(
"appId", appId, // 必须是消息来源的那个实例
"toWxid", toWxid,
"content", content
);
HttpEntity<Map<String, Object>> req = new HttpEntity<>(body, headers);
ResponseEntity<Map> resp = restTemplate.postForEntity(
baseUrl + "/finder/v2/api/message/postText", req, Map.class);
if (resp.getBody() == null || !Integer.valueOf(200).equals(resp.getBody().get("ret"))) {
throw new RuntimeException("postText failed: " + resp.getBody());
}
}
}
Handler 里调 postText 时,appId 直接从 payload.get("Appid") 拿,不要自己配置——这样路由到哪个 handler,用的就是哪个实例的 appId,不会串。
Step 4:线程池配置
多实例共用一个线程池,核心线程数按实例数量 × 2 配,队列容量给 2000,拒绝策略丢弃 + 日志:
@Configuration
public class ThreadPoolConfig {
@Bean("wxCallbackExecutor")
public ThreadPoolTaskExecutor wxCallbackExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(8); // 4 个实例 × 2
executor.setMaxPoolSize(16);
executor.setQueueCapacity(2000);
executor.setThreadNamePrefix("wx-cb-");
// 队列满直接丢弃并记日志,绝不阻塞回调主线程
executor.setRejectedExecutionHandler((r, e) ->
log.warn("callback queue full, active={}, pool={}", e.getActiveCount(), e.getPoolSize()));
executor.initialize();
return executor;
}
}
两个最容易犯的坑
1. appId 和 Wxid 搞混
回调里有两个容易搞混的字段:payload.Appid 是设备 ID(实例标识,比如 wx_crm_a1b2c3d4e5f6),payload.Wxid 是这个设备对应的微信账号 wxid(比如 wxid_abc123)。路由用 Appid,过滤自己发的消息用 Wxid。
2. 标签跨实例引用
WTAPI 的标签是实例级别的——A 号的标签 ID=15 不代表 B 号也有这个 ID。每个实例必须单独调 /finder/v2/api/label/list 拉自己的标签,不能把 A 号的标签 ID 直接传给 B 号。
上线前 checklist
- 回调入口 3 秒内返回空字符串
- 去重键 =
Appid + NewMsgId(官方指定),TTL ≥ 24 小时 - 路由按
payload.Appid的前缀匹配 handler - postText 的
appId参数直接从 payload 里取,不自己配置 - 自己发的消息已过滤(
FromUserName == Wxid) - 每个实例单独维护自己的标签 ID 映射
- 线程池队列有界,拒绝策略不阻塞主线程