initialize(); $assert = static function (bool $condition, string $message): void { if (!$condition) { throw new RuntimeException($message); } }; $admin = Db::name('admin')->where('root', 1)->whereNull('delete_time')->find(); if (!$admin) { throw new RuntimeException('未找到 root 管理员,无法执行获客客户统计冒烟测试'); } $assert( Db::name('dev_crontab') ->where('command', 'qywx:retry-customer-acquisition-events') ->whereNull('delete_time') ->count() === 1, '获客回调每分钟重试任务未写入数据库' ); $suffix = bin2hex(random_bytes(5)); $linkId = '__smoke_link_' . $suffix; $externalUserId = '__smoke_external_' . $suffix; $userId = '__smoke_user_' . $suffix; $eventTime = time(); $scopedAdmin = null; foreach (Db::name('admin')->where('root', 0)->whereNull('delete_time')->select()->toArray() as $candidate) { $candidateVisibleIds = DataScopeService::getVisibleAdminIds((int) $candidate['id'], $candidate); if (is_array($candidateVisibleIds) && $candidateVisibleIds !== []) { $scopedAdmin = $candidate; break; } } $api = new class($linkId, $externalUserId, $userId) extends QywxCustomerAcquisitionApiService { public function __construct( private string $testLinkId, private string $testExternalUserId, private string $testUserId ) { } public function listCustomers(string $linkId, string $cursor = '', int $limit = 1000): array { return [ 'customer_list' => [[ 'external_userid' => $this->testExternalUserId, 'userid' => $this->testUserId, 'chat_status' => 2, 'state' => '', ]], 'next_cursor' => '', ]; } public function getChatInfo(string $chatKey): array { return [ 'external_userid' => $this->testExternalUserId, 'userid' => $this->testUserId, 'chat_info' => [ 'link_id' => $this->testLinkId, 'state' => 'smoke-chat', 'recv_msg_cnt' => 5, ], ]; } }; $service = new QywxCustomerAcquisitionCustomerService($api); Db::startTrans(); try { $linkOwnerId = (int) ($scopedAdmin['id'] ?? $admin['id']); $localLinkId = (int) Db::name('qywx_promotion_link')->insertGetId([ 'pool_id' => 0, 'account_id' => 0, 'name' => '空来源统计冒烟链接', 'group_name' => '测试', 'wecom_url' => 'https://work.weixin.qq.com/ca/smoke', 'remote_link_id' => $linkId, 'remote_status' => 1, 'owner_admin_id' => $linkOwnerId, 'dept_id' => 0, 'create_time' => $eventTime, 'update_time' => $eventTime, ]); $startMessage = [ 'MsgId' => 'smoke-start-' . $suffix, 'ChangeType' => 'customer_start_chat', 'CreateTime' => $eventTime, 'LinkID' => $linkId, 'ExternalUserID' => $externalUserId, 'UserID' => $userId, 'State' => 'smoke-start', ]; $service->handleCallback($startMessage); $chatMessage = [ 'MsgId' => 'smoke-chat-' . $suffix, 'ChangeType' => 'message_from_customer', 'CreateTime' => $eventTime + 1, 'ChatKey' => 'smoke-chat-key-' . $suffix, ]; $service->handleCallback($chatMessage); $duplicate = $service->handleCallback($chatMessage); $assert(($duplicate['duplicate'] ?? false) === true, '相同 message_from_customer 回调必须幂等'); // 远端客户列表中的 chat_status=2 是“未知”,不能把已由回调确认的状态 1 回退。 $service->syncLink($linkId); $customer = Db::name('qywx_customer_acquisition_customer') ->where('link_id', $linkId) ->where('external_userid', $externalUserId) ->where('userid', $userId) ->find(); $assert((int) ($customer['chat_status'] ?? -1) === 1, '列表同步错误回退了已确认的聊天状态'); $assert((int) ($customer['recv_msg_cnt'] ?? -1) === 5, '累计接收消息数不正确或被重复累加'); $assert((int) ($customer['message_count_known'] ?? 0) === 1, '精确消息次数标识未保存'); $assert((string) ($customer['state'] ?? 'missing') === '', '企业微信返回空来源时客户未按原样保存'); $assert((int) ($customer['promotion_link_id'] ?? 0) === $localLinkId, '客户未关联本地获客链接'); $assert((int) ($customer['owner_admin_id'] ?? -1) === 0, '冒烟客户应保持为未匹配成员'); $stats = WecomAcquisitionCustomerLogic::statistics( ['keyword' => $externalUserId, 'page_size' => 20], (int) $admin['id'], $admin ); $summary = $stats['summary'] ?? []; $assert((int) ($summary['customer_count'] ?? 0) === 1, '获客客户汇总数量不正确'); $assert((int) ($summary['started_chat_count'] ?? 0) === 1, '已发消息客户数不正确'); $assert((int) ($summary['received_message_count'] ?? 0) === 5, '接收消息汇总不正确'); $assert((int) ($summary['message_count_known_count'] ?? 0) === 1, '精确消息统计覆盖数不正确'); $row = $stats['lists'][0] ?? []; $assert(!array_key_exists('external_userid', $row), '接口不应返回原始客户 ExternalUserID'); $assert(str_contains((string) ($row['external_userid_masked'] ?? ''), '*'), '客户标识没有脱敏'); if ($scopedAdmin !== null) { $scopedStats = WecomAcquisitionCustomerLogic::statistics( ['keyword' => $externalUserId, 'page_size' => 20], (int) $scopedAdmin['id'], $scopedAdmin ); $assert( (int) ($scopedStats['summary']['customer_count'] ?? 0) === 1, '空来源且成员未匹配时,链接归属范围内仍应显示企微返回的客户' ); $assert(count($scopedStats['lists'] ?? []) === 1, '空来源客户未进入有限权限账号的统计列表'); } $eventKeys = [ QywxCustomerAcquisitionCustomerService::eventKey($startMessage, 'customer_start_chat', '', $eventTime), QywxCustomerAcquisitionCustomerService::eventKey( $chatMessage, 'message_from_customer', (string) $chatMessage['ChatKey'], $eventTime + 1 ), ]; $events = Db::name('qywx_customer_acquisition_event') ->whereIn('event_key', $eventKeys) ->select()->toArray(); $assert(count($events) === 2, '回调事件审计数量不正确'); foreach ($events as $event) { $assert((int) ($event['status'] ?? 0) === 1, '成功回调未进入成功终态'); $assert((string) ($event['chat_key'] ?? '') === '', '成功后仍保存了敏感 ChatKey'); $assert(($event['raw_json'] ?? null) === null, '成功后仍保存了原始回调'); } echo "WECOM_ACQUISITION_CUSTOMER_STATISTICS_SMOKE_OK messages=5\n"; } finally { Db::rollback(); }