*/ public static function sync(array $params, int $adminId, array $adminInfo): array { $localLinkId = max(0, (int) ($params['promotion_link_id'] ?? $params['id'] ?? 0)); $query = Db::name('qywx_promotion_link')->alias('l') ->whereNull('l.delete_time') ->where('l.remote_link_id', '<>', '') ->where('l.remote_status', 1); self::applyScope($query, 'l', DataScopeService::getVisibleAdminIds($adminId, $adminInfo)); if ($localLinkId > 0) { $query->where('l.id', $localLinkId); } $links = $query->field('l.id,l.remote_link_id')->order('l.id', 'asc')->limit(200)->select()->toArray(); if ($localLinkId > 0 && $links === []) { throw new RuntimeException('获客链接不存在、已失效,或超出当前权限范围'); } if ($links === []) { throw new RuntimeException('当前数据范围内没有可同步的有效官方获客链接;已删除和历史手工链接不会参与客户同步,请先创建官方获客链接'); } $service = new QywxCustomerAcquisitionCustomerService(); $result = ['links' => count($links), 'scanned' => 0, 'created' => 0, 'updated' => 0, 'failed' => 0, 'errors' => []]; foreach ($links as $link) { try { $one = $service->syncLink((string) $link['remote_link_id']); $result['scanned'] += $one['scanned']; $result['created'] += $one['created']; $result['updated'] += $one['updated']; } catch (\Throwable $e) { $result['failed']++; if (count($result['errors']) < 10) { $result['errors'][] = (string) $link['remote_link_id'] . ':' . $e->getMessage(); } } } return $result; } /** @return array */ public static function statistics(array $params, int $adminId, array $adminInfo): array { $page = max(1, (int) ($params['page_no'] ?? $params['page'] ?? 1)); $pageSize = min(100, max(1, (int) ($params['page_size'] ?? 20))); $visibleIds = DataScopeService::getVisibleAdminIds($adminId, $adminInfo); $base = self::customerQuery($params, $visibleIds); $total = (int) (clone $base)->count(); $rows = $base ->field('c.id,c.promotion_link_id,c.link_id,c.external_userid,c.userid,c.owner_admin_id,c.dept_id,c.state,c.chat_status,c.recv_msg_cnt,c.message_count_known,c.first_acquired_time,c.last_chat_time,c.last_sync_time,c.create_time,c.update_time,a.name as owner_name,d.name as dept_name,l.name as link_name,p.name as pool_name') ->order('c.last_chat_time', 'desc')->order('c.id', 'desc') ->page($page, $pageSize)->select()->toArray(); foreach ($rows as &$row) { $row['external_userid_masked'] = self::maskIdentifier((string) ($row['external_userid'] ?? '')); unset($row['external_userid']); $row['has_messaged'] = (int) ($row['chat_status'] ?? 0) === 1; $row['message_count_known'] = (int) ($row['message_count_known'] ?? 0); $row['received_message_count'] = (int) ($row['recv_msg_cnt'] ?? 0); } unset($row); $summaryQuery = self::customerQuery($params, $visibleIds); $summaryRow = $summaryQuery->fieldRaw( 'COUNT(*) AS customer_count, ' . 'COALESCE(SUM(CASE WHEN c.message_count_known = 1 THEN c.recv_msg_cnt ELSE 0 END),0) AS recv_msg_cnt, ' . 'SUM(CASE WHEN c.chat_status = 1 THEN 1 ELSE 0 END) AS started_chat_count, ' . 'SUM(CASE WHEN c.message_count_known = 1 THEN 1 ELSE 0 END) AS message_count_known_count' )->find() ?: []; return [ 'meta' => [ 'scope_label' => DataScopeService::scopeLabel(DataScopeService::getEffectiveScope($adminInfo)), 'generated_at' => date('Y-m-d H:i:s'), ], 'summary' => [ 'customer_count' => (int) ($summaryRow['customer_count'] ?? 0), 'started_chat_count' => (int) ($summaryRow['started_chat_count'] ?? 0), 'recv_msg_cnt' => (int) ($summaryRow['recv_msg_cnt'] ?? 0), 'received_message_count' => (int) ($summaryRow['recv_msg_cnt'] ?? 0), 'message_count_known_count' => (int) ($summaryRow['message_count_known_count'] ?? 0), ], 'lists' => $rows, 'count' => $total, 'page_no' => $page, 'page_size' => $pageSize, ]; } private static function customerQuery(array $params, ?array $visibleIds) { $query = Db::name('qywx_customer_acquisition_customer')->alias('c') ->leftJoin('admin a', 'a.id = c.owner_admin_id AND a.delete_time IS NULL') ->leftJoin('dept d', 'd.id = c.dept_id') ->leftJoin('qywx_promotion_link l', 'l.id = c.promotion_link_id') ->leftJoin('qywx_promotion_pool p', 'p.id = l.pool_id'); self::applyCustomerScope($query, $visibleIds); $localLinkId = max(0, (int) ($params['promotion_link_id'] ?? 0)); if ($localLinkId > 0) { $query->where('c.promotion_link_id', $localLinkId); } $userId = trim((string) ($params['userid'] ?? '')); if ($userId !== '') { $query->where('c.userid', $userId); } if (isset($params['chat_status']) && $params['chat_status'] !== '') { $query->where('c.chat_status', max(0, (int) $params['chat_status'])); } $keyword = trim((string) ($params['keyword'] ?? '')); if ($keyword !== '') { $query->whereLike('c.external_userid|c.userid|a.name|l.name', '%' . $keyword . '%'); } return $query; } /** * 客户优先按实际承接成员做数据隔离;企微成员尚未绑定后台账号时,改按获客链接归属兜底。 * * 企微 customer_list 的 state/customer_channel 允许为空,不能因此丢掉已返回的客户; * 同时也不能直接放开全部 owner_admin_id=0 的记录,否则会跨部门泄露未映射客户。 */ private static function applyCustomerScope($query, ?array $visibleIds): void { if ($visibleIds === null) { return; } if ($visibleIds === []) { $query->whereRaw('1 = 0'); return; } $ids = array_values(array_unique(array_map('intval', $visibleIds))); $query->where(function ($scope) use ($ids): void { $scope->whereIn('c.owner_admin_id', $ids) ->whereOr(function ($unmapped) use ($ids): void { $unmapped->where('c.owner_admin_id', 0) ->whereIn('l.owner_admin_id', $ids); }); }); } private static function applyScope($query, string $alias, ?array $visibleIds): void { if ($visibleIds === null) { return; } if ($visibleIds === []) { $query->whereRaw('1 = 0'); return; } $query->whereIn($alias . '.owner_admin_id', array_values(array_unique(array_map('intval', $visibleIds)))); } private static function maskIdentifier(string $value): string { $value = trim($value); $length = mb_strlen($value); if ($length <= 0) { return '-'; } if ($length <= 4) { return mb_substr($value, 0, 1) . '***'; } if ($length <= 8) { return mb_substr($value, 0, 2) . '***' . mb_substr($value, -1); } return mb_substr($value, 0, 4) . '****' . mb_substr($value, -4); } }