Java 微信二次开发2个坑:多实例回调路由 + 消息分发策略(附 Spring Boot 代码)

1 阅读5分钟

上周一个做电商私域的朋友找我,说他的微信机器人服务上线第一周就出问题了——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 映射
  • 线程池队列有界,拒绝策略不阻塞主线程