企业微信 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 跳过,不记录、不报警,只是少写了几条数据。
企业微信分页 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 中断 |
每次请求必须把上次响应的 next_cursor 传回去;errcode != 0 不要 continue,要记录到 failureList 并 break 当前成员——这样既不丢数据,也不让一个坏苹果拖垮整个同步任务。
一句话外卖
企业微信分页接口不会因「漏传 cursor」报错,它只会静默返回第一页——在员工客户数 ≤100 时完美隐身,超过后必丢;用
limit:100+ 不传cursor等于给每个重度用户埋了一颗定时炸弹。