返回博客

企业微信回调链路:send 前置撞上隐式 SELECT 依赖,欢迎语渲染漏字永久阻断客户

某次接入企业微信好友添加回调后,欢迎语偶发不发送,errcode 41050(welcome_code 已过期)。排查发现链路同步阶段已超过 20 秒 token 时效,症状明确:把 send_welcome_msg 挪到链路前段就能解决。改完上线,errcode 41050 消失了,却出现了更难发现的问题——发出去的欢迎语内容变成了"Hi~,欢迎...",昵称占位符 %NICKNAME% 被替换成了空字符串。

这不是偶发渲染 bug,而是触发了 41051,客户被永久阻断,之后对这个客户发任何消息都会 fail。

现象

原始链路(平均耗时 30+ 秒):

upsert 联系人(含 nickname)    +4s
upsert 跟进人标签              +2s
3 次冗余 COUNT 查询            +2s
fans_tag 同步                  +5s
取消标签同步                   +3s
AutoTagService::tagMember      +8s
★ send_welcome_msg             +10s  ← 20s 时钟早过了,41050 必然

第一次重构,把 send 直接提到链路头部:

★ send_welcome_msg             ← 立即调用
  ↓
  SELECT name FROM 联系人表   ← 该行还没 INSERT,返回空
  %NICKNAME% 替换为 ""
  发出:"Hi~,欢迎..."        ← 渲染漏字,errcode=0 但内容错误
                               ← 触发 41051,该客户永久阻断
→ upsert 联系人(太晚了)

根因

send_welcome_msg 的 service 封装内部有一条隐式 SELECT 依赖

// WelcomeMsg.php:145(精简版)
public function send($externalUserId, $welcomeCode, $userId)
{
    // 内部悄悄 SELECT 联系人表拿 nickname
    $row = Db::name('wecom_external_contact')
        ->where('external_userid', $externalUserId)
        ->find();
    $nickname = $row['name'] ?? '';
    $content = str_replace('%NICKNAME%', $nickname, $config['text_content']);
    // ...
}

这层依赖没有写在 service 注释里。调用方只看到"调 send",完全不知道 send 内部还要回查数据库。

坑:send 前置后 service 内部 SELECT 读到空行,渲染漏字,errcode=0 但触发永久阻断

41051 不是在 send 失败时触发,而是在 send 成功但内容包含空占位符时由企业微信平台判定触发。errcode=0 不等于一切正常。一旦 41051,该客户被永久阻断,无法通过重试补救,只能让用户重新触发好友添加流程。

正解

minimal upsert 模式:send 之前只写 service 内部 SELECT 必需的字段(如 nickname),其余字段(状态位、跟进人信息等)推迟到 send 之后的副作用阶段。

public function handleExternalChange()
{
    $data           = $this->parseXml(...);
    $welcomeCode    = $data['WelcomeCode']    ?? '';
    $externalUserId = $data['ExternalUserID'] ?? '';
    $userId         = $data['UserID']         ?? '';
 
    // 写回调日志(永远写)
    Db::name('wecom_callback_log')->insert([...]);
 
    // 守卫:配置未启用或缺 welcome_code,立即 ACK
    $config = Db::name('wecom_welcome_config')->where('id', 1)->find();
    if (!$config || !$config['is_active'] || !$welcomeCode) {
        $this->ackImmediately();
        return;
    }
 
    try {
        // ★ minimal upsert:只写 send 内部 SELECT 依赖的字段
        $userDetail = $this->fetchExternalUserDetail($externalUserId);
        Db::name('wecom_external_contact')->insertOrUpdate([
            'external_userid' => $externalUserId,
            'name'            => $userDetail['name']   ?? '',
            'avatar'          => $userDetail['avatar'] ?? '',
            // 不写 welcome_status / follow_user / synced_at,留给后置
        ]);
 
        // ★ 业务 token 时钟内完成 send
        $this->welcomeMsg->send($externalUserId, $welcomeCode, $userId);
    } catch (\Throwable $e) {
        Log::error('critical-path fail: ' . $e->getMessage());
    }
 
    // ACK 给平台,token 时钟义务完成
    $this->ackImmediately();  // echo 'success' + fastcgi_finish_request()
 
    // ===== 副作用,在 token 时钟外 =====
    try {
        Db::name('wecom_external_contact')
            ->where('external_userid', $externalUserId)
            ->update([
                'welcome_status' => 1,
                'follow_user'    => json_encode($userDetail['follow_user'] ?? []),
                'synced_at'      => date('Y-m-d H:i:s'),
            ]);
        $this->syncFansTag($externalUserId);
        AutoTagService::tagMember($externalUserId);
    } catch (\Throwable $e) {
        Log::warning('post-ack side-effect fail: ' . $e->getMessage());
    }
}

重构前必须 grep service 内部所有隐式 SELECT,找出"必须前置的渲染依赖":

grep -nE "Db::name\(.+\)|->find\(|->select\(|->column\(" WelcomeMsg.php
grep -nE "%[A-Z_]+%" WelcomeMsg.php  # 占位符背后通常有 SELECT
正解:minimal upsert 只写 service 内部会 SELECT 的字段,其余全部后置

区分两类字段的判断准则:send service 内部是否会 SELECT 这个字段?是 → 必须前置写入;否 → 可以后置。不要把"完整 upsert"整体挪到 send 前——完整 upsert 自带 N+1 SELECT,反而拖慢链路重新撞 token 时钟。

顺手:在 service 里把隐式契约写进注释
// WelcomeMsg.php::send()
// ⚠️ 隐式契约:调用方必须在调 send 前 minimal upsert 联系人行,
// 至少包含 name 字段(用于 %NICKNAME% 替换)。

这条注释能在下次重构时挡住同类错误。

一句话外卖

把外部 API 调用前置时,先 grep service 内部的隐式 SELECT 依赖,用 minimal upsert 只预写那些字段,副作用一律后置——否则前置不但渲染漏字,还会触发比超时更难修复的永久阻断。