返回博客

企业微信 batch/get_by_user 写死 limit:100 不传 cursor,>100 客户必丢

某次例行同步跑完,后台看板上一个老员工的客户数永远停在 100——但在企业微信 APP 里亲眼数过去,他有将近 300 个外部联系人。日志显示 processed=175 new_follows=548 exit=0,绿灯,看起来没问题。

翻代码一分钟,坑就浮出来了。

现象

  • 某些员工在企业微信侧有 200~500 个外部客户,同步落库后始终只有 100 行
  • 同步日志只有汇总行,无 per-member breakdown,看不出哪个人漏了
  • 偶发:某些成员的同步完全静默失败(token 过期、权限不足),日志里没有任何报错

根因

企业微信 externalcontact/batch/get_by_user 是标准分页接口,响应体里有 next_cursor 字段。当 next_cursor 为空字符串时表示已到末页;非空时必须把它塞回下一次请求的 cursor 字段,才能拿到后续页。

问题代码的写法是:

// 每次只传 userid_list + limit,不传 cursor
$body = ['userid_list' => [$userid], 'limit' => 100];
$res = $this->wecomApi('externalcontact/batch/get_by_user', $body);
foreach ($res['external_contact_list'] ?? [] as $c) {
    // 处理 $c
}
// $res['next_cursor'] 直接被丢弃

limit: 100 是合法参数(API 允许 1~100),但不传 cursor 等于永远只请求首页。对于客户数 ≤100 的员工,这个 bug 完全沉默——数据对得上,测试也过;一旦有员工超过 100 个客户,超出部分永久丢失,且没有任何报错。

更糟糕的是,这段代码还叠加了另一个反模式:

if ($res['errcode'] != 0) continue;  // 静默吞掉所有错误

token 过期(errcode 42001)、无权限(60011)、并发限制(45033)——任何接口错误都被 continue 跳过,不记录、不报警,只是少写了几条数据。

坑:分页接口被当一次性接口调用,next_cursor 静默丢弃

企业微信分页 API 不会因为「没传 cursor」而报错,它只会默默返回第一页。如果员工客户数恰好 ≤100,永远不会触发这个 bug;超过 100 时,超出部分悄无声息消失。errcode=0 + 程序 exit=0,所有指标都是绿的。

正解

cursor 驱动翻页循环,并给每个成员单独收集失败信息:

$cursor      = '';       // 首次为空字符串(不是 null)
$maxPages    = 100;      // 防御无限循环(100 * 100 = 1万客户/人足够)
$seenCursors = [];       // 防御 API 返回重复 cursor 死循环
$allClients  = [];
$failureList = [];
 
for ($page = 0; $page < $maxPages; $page++) {
    // 重复 cursor 防御
    if ($cursor !== '' && in_array($cursor, $seenCursors, true)) {
        $failureList[] = ['userid' => $userid, 'reason' => 'cursor_loop'];
        break;
    }
    $seenCursors[] = $cursor;
 
    $body = ['userid_list' => [$userid], 'limit' => 100, 'cursor' => $cursor];
    $res  = $this->wecomApi('externalcontact/batch/get_by_user', $body);
 
    // token 过期:刷新后用同一 cursor 重试,page 不前进
    if (($res['errcode'] ?? -1) === 42001) {
        $this->refreshToken();
        $page--;
        continue;
    }
 
    // 其他错误:记录到 failureList,break 当前成员,不影响其余成员
    if (($res['errcode'] ?? -1) !== 0) {
        $failureList[] = [
            'userid'  => $userid,
            'page'    => $page,
            'errcode' => $res['errcode'] ?? -1,
            'errmsg'  => $res['errmsg']  ?? '',
        ];
        Log::write("[wecom-sync] member failure ...", 'error');
        break;
    }
 
    foreach ($res['external_contact_list'] ?? [] as $c) {
        $allClients[] = $c;
    }
 
    $cursor = $res['next_cursor'] ?? '';
    if ($cursor === '') break;  // 末页退出
}
 
if (!empty($failureList)) {
    Log::write('[wecom-sync] failures: ' . json_encode($failureList, JSON_UNESCAPED_UNICODE), 'warning');
}
return $allClients;

关键防御点汇总:

| 防御 | 做法 | 不做的后果 | |---|---|---| | 首次 cursor 传空字符串 | $cursor = '' | null 可能被某些 API 当参数错误 | | 末页退出 | if ($cursor === '') break | 死循环或重复拉同一页 | | max_pages 上限 | for ($page < 100) | API 异常时永跑不完 | | 重复 cursor 检测 | seenCursors 判重 | API 抖动 bug 触发死循环 | | token 续期不丢 cursor | page--; continue | token 过期后续页全丢 | | 错误不吞 | failureList + Log | 失败沉默,事后无法定位 | | 单成员失败 break,不 die | 外层循环继续下一人 | 1 人坏 → 全员 sync 中断 |

正解:cursor 翻页 + 失败单独收集

每次请求必须把上次响应的 next_cursor 传回去;errcode != 0 不要 continue,要记录到 failureListbreak 当前成员——这样既不丢数据,也不让一个坏苹果拖垮整个同步任务。

一句话外卖

企业微信分页接口不会因「漏传 cursor」报错,它只会静默返回第一页——在员工客户数 ≤100 时完美隐身,超过后必丢;用 limit:100 + 不传 cursor 等于给每个重度用户埋了一颗定时炸弹。