'text/plain; charset=utf-8']); } try { $app = new Application([ 'corp_id' => $corpId, 'secret' => $secret, 'token' => $token, 'aes_key' => $aesKey, ]); $server = $app->getServer(); $server->addEventListener('change_external_contact', function (Message $message, \Closure $next) { try { $this->handleChangeExternalContact($message); } catch (\Throwable $e) { Log::error('qywx external contact callback: ' . $e->getMessage(), [ 'exception' => $e, ]); } return $next($message); }); $psr = $server->serve(); $body = $psr->getBody(); $body->rewind(); $content = $body->getContents(); $headers = []; $contentType = $psr->getHeaderLine('Content-Type'); if ($contentType !== '') { $headers['Content-Type'] = $contentType; } return response($content, 200, $headers); } catch (BadRequestException $e) { Log::warning('qywx external contact callback: bad request ' . $e->getMessage()); return response('bad request', 400, ['Content-Type' => 'text/plain; charset=utf-8']); } catch (\Throwable $e) { Log::error('qywx external contact callback: serve failed ' . $e->getMessage(), ['exception' => $e]); return response('error', 500, ['Content-Type' => 'text/plain; charset=utf-8']); } } /** * @param Message $message */ private function handleChangeExternalContact($message): void { $changeType = (string) ($message['ChangeType'] ?? ''); $extId = trim((string) ($message['ExternalUserID'] ?? $message['ExternalUserId'] ?? '')); $userId = trim((string) ($message['UserID'] ?? $message['UserId'] ?? '')); $state = (string) ($message['State'] ?? ''); $welcomeCode = (string) ($message['WelcomeCode'] ?? ''); $failReason = (string) ($message['FailReason'] ?? ''); $eventTime = (int) ($message['CreateTime'] ?? 0); // 事件流水:一进来就落库(幂等),用于"今天进来多少人"等零误差统计; // 独立于业务 UPSERT,即便后续 DB 逻辑抛错也不影响计数。 CustomerLogic::recordExternalContactEvent([ 'change_type' => $changeType, 'user_id' => $userId, 'external_userid' => $extId, 'state' => $state, 'fail_reason' => $failReason, 'welcome_code' => $welcomeCode !== '' ? 1 : 0, 'event_time' => $eventTime, 'raw' => $message, ]); if ($extId === '') { Log::info(sprintf('qywx external contact callback: 无 ExternalUserID type=%s user=%s', $changeType, $userId)); return; } // 关键字段直接拼到消息里,便于在 ThinkPHP file 日志格式下一眼定位 ChangeType 分布 Log::info(sprintf( 'qywx external contact callback type=%s user=%s ext=%s state=%s welcome=%s fail=%s', $changeType !== '' ? $changeType : '-', $userId !== '' ? $userId : '-', $extId, $state !== '' ? $state : '-', $welcomeCode !== '' ? '***' : '-', $failReason !== '' ? $failReason : '-' )); if ($changeType === 'add_half_external_contact') { // 半客户:客户尚未通过验证,/externalcontact/get 通常返回 84061「客户尚未通过」之类, // 这里只 log 不写库,避免产生 noise;客户通过后会再触发 add_external_contact 事件再走 UPSERT。 Log::info(sprintf('qywx external contact callback: half add,跳过落库 ext=%s user=%s', $extId, $userId)); return; } if ($changeType === 'transfer_fail') { // 转接失败(customer_refused 等):客户并未成功归属新员工,旧跟进人保持不变; // 此时 /externalcontact/get 多半返回 84061「not external contact」,继续拉详情只会刷无意义 warning。 return; } // 其余全部变更(添加/编辑/删除/被删/转接/标签变化等):一律以 /externalcontact/get 的真实状态为准。 // - 仍是企业客户(含"客户单方删除员工",企微会保留客户与跟进关系)→ UPSERT 最新数据; // - 已彻底不是企业客户(84061)→ 本地软删。 // 实测验证:del_follow_user(客户删员工)后 get 依然成功返回且 follow_user 保留, // 本地自行推断移除跟进人/软删会导致客户数持续少于企微,故删除类事件不再走本地推断。 // 添加事件若命中已有客户(含软删行):不动原有行,新增一行 readd_flag=1(以前加过)的记录。 CustomerLogic::upsertSingleExternalContactFromApi( $extId, $changeType === 'add_external_contact', $userId, $eventTime ); } }