昨晚在更新星云API   www.xingyapi.com 的底层实战开发笔记时,有个做供应链 SaaS 的全栈老哥找我大吐苦水,说他们系统惹出大祸了。

他们上周搞了个“自动化对账单派发”功能:客户在群里发一句“查账”,系统自动生成一份 10MB 的 PDF 对账单发回群里,并等待客户回复“确认”。为了赶进度,这老哥直接在 Webhook 接收端写了个极其暴力的同步方法:收到消息 -> 查库生成 PDF -> 调企微接口上传文件 -> 拿到 media_id 发送消息 -> 更新数据库状态。

结果到了月底结账高峰期,几百个群同时喊“查账”。生成并上传 10MB 的 PDF 耗时将近 8 秒,企微网关只等 5 秒,没收到响应立刻发起夺命连环重试。重试了 3 次,这老哥的服务器因为把几百个 10MB 的 PDF 全读进了 JVM 内存,当场 OOM 宕机。更惨的是,等服务重启后,客户群里连续收到了 3 份一模一样的对账单,客户一头雾水,销售总监直接在钉钉上骂街。

很多兄弟做企微机器人,以为“一问一答”就是调个接口的事。但在涉及大文件 IO 和状态流转的真实业务里,把耗时操作和 Webhook 主线程绑死,就是在给服务器埋雷。 今天咱们直接手撕一套“异步透传上传 + 发送落库 + 回调状态机”的工业级闭环管线。

第一关:斩断同步,构建流式上传管线(绝不爆内存)

遇到“上传素材并发送”这种重 IO 动作,第一铁律是:立刻让 Webhook 极速返回 success,把脏活累活扔给 MQ 异步处理!

当异步 Worker 拿到任务,准备将中台的 PDF 传给企微时,如果你去翻底层的 开发文档,你会看到临时素材上传接口要求的是 multipart/form-data。很多新手在这里会犯致命错误:把文件全量读成 byte[]

实战防 OOM 流式打法: 你的服务器只做“水管”,不做“水桶”。直接把自建 OSS 的输入流,怼到 HTTP 客户端的输出流上。

Java

public String uploadMediaToWeCom(String ossFileUrl, String accessToken) {
    String url = "https://qyapi.weixin.qq.com/cgi-bin/media/upload?access_token=" + accessToken + "&type=file";
    
    // 1. 打开中台文件的输入流(绝不全量读入内存!)
    InputStream inStream = new URL(ossFileUrl).openStream();
    
    // 2. 利用 OkHttp 构建流式上传体,内存中只维持几 KB 的 Buffer
    RequestBody streamBody = new RequestBody() {
        @Override
        public MediaType contentType() { return MediaType.parse("application/pdf"); }
        @Override
        public void writeTo(BufferedSink sink) throws IOException {
            sink.writeAll(Okio.source(inStream)); // 管道直连
        }
    };
    
    MultipartBody multipartBody = new MultipartBody.Builder()
        .setType(MultipartBody.FORM)
        .addFormDataPart("media", "对账单.pdf", streamBody)
        .build();

    // 3. 执行请求,榨取 media_id
    String response = okHttpClient.newCall(new Request.Builder().url(url).post(multipartBody).build()).execute().body().string();
    return JSONObject.parseObject(response).getString("media_id");
}

第二关:发送并捕获“黄金线索” MsgId

拿着 media_id 调接口把文件发到群里,这只算完成了动作的一半。你怎么知道客户到底确认了没有?

企微的“发送应用消息”接口,在成功执行后,并不是只回你一个 {errcode:0},它还会返回一个极其关键的字段:msgid这个 msgid 就是贯穿整个状态机的黄金线索!

Java

// 异步 Worker 中的发送与落库逻辑
public void sendInvoiceAndRecord(String chatId, String mediaId, String orderNo) {
    // 1. 组装发消息的 JSON
    JSONObject sendJson = new JSONObject();
    sendJson.put("chatid", chatId);
    sendJson.put("msgtype", "file");
    sendJson.put("file", new JSONObject().fluentPut("media_id", mediaId));
    
    // 2. 发起调用
    String respStr = wecomClient.sendMsg(sendJson);
    String msgId = JSONObject.parseObject(respStr).getString("msgid");
    
    // 3. 核心:建立业务与消息的映射记录,等待闭环!
    // 往 t_message_confirm_log 表写一条记录,状态为 WAIT_CONFIRM
    jdbcTemplate.update(
        "INSERT INTO t_message_confirm_log (msg_id, chat_id, order_no, status) VALUES (?, ?, ?, 'WAIT_CONFIRM')",
        msgId, chatId, orderNo
    );
}

第三关:Webhook 回调闭环,终结状态机

当文件发进群里,客户下载看完后,在群里回了一句“确认无误”。这个时候,你的 Webhook 会再次收到一条文本消息的回调。

我们怎么把这句“确认无误”,和刚才发出去的那个 PDF 对账单关联起来?

这就需要我们的分发引擎去查刚才留下的“案底”:

Java

// Webhook 文本消息处理器
public void handleCustomerReply(StandardMsgDTO msg) {
    String chatId = msg.getChatId();
    String content = msg.getTextContent();
    
    if (content.contains("确认")) {
        // 去查这个群里,有没有还在等待确认的订单记录
        List<ConfirmLog> pendingLogs = logRepository.findPendingByChatId(chatId);
        
        if (!pendingLogs.isEmpty()) {
            ConfirmLog log = pendingLogs.get(0);
            
            // 将状态机流转为“已确认”,并通知下游财务系统打款!
            logRepository.updateStatus(log.getMsgId(), "CONFIRMED");
            financeService.processOrder(log.getOrderNo());
            
            // 机器人给个反馈
            wecomClient.sendText(chatId, "✅ 收到确认,财务已开始打款流程。");
        }
    }
}

通过 media_id 分离大文件 IO,通过 msgid 记录下发状态,再通过 Webhook 接收客户反馈去扭转状态机。这套逻辑打通,你的机器人就不再是个无脑的“传话筒”,而是拥有了完整业务流程接管能力的 SaaS 引擎。

联调刺客:用工具编排“异步拉锯战”压测

这种长链路、跨线程的异步状态机,绝不能凭想象写完就上生产。你必须模拟出网关重试、耗时拉长等极端恶劣的公网环境。

上线前,掏出咱们搞 API 压测必备的 Apifox 或者 Apipost

  1. 构建双重 Mock 场景:在测试工具里建两个请求。请求 A 模拟客户发送“查账”触发 Webhook;请求 B 模拟客户回复“确认”。

  2. 极限并发重试轰炸:将请求 A 设置为 10 并发,在一秒内全部打向你的本地接口(模拟企微网关由于超时引发的疯狂重试)。

  3. 精准盯盘

    • 盯你的控制台,看是不是只有第 1 个请求成功触发了异步流式上传,剩下的 9 个被防重机制拦截吞掉了?

    • 盯你的数据库 t_message_confirm_log,是不是只有 1 条干净的 WAIT_CONFIRM 记录,而不是 10 条?

    • 紧接着手动发出请求 B,看数据库的状态是不是瞬间丝滑地扭转为了 CONFIRMED

别被企微那堆繁杂的回调结构乱了阵脚,把耗时的 IO 彻底丢进异步管道,死死咬住唯一的标识 ID 做状态比对,这才是高级 API 实战开发者该有的工程底盘。

Logo

DAMO开发者矩阵,由阿里巴巴达摩院和中国互联网协会联合发起,致力于探讨最前沿的技术趋势与应用成果,搭建高质量的交流与分享平台,推动技术创新与产业应用链接,围绕“人工智能与新型计算”构建开放共享的开发者生态。

更多推荐