configService ??= new Kuaidi100ConfigService(); $this->client ??= new Kuaidi100Client($this->configService); $this->ensureTable(); } public function subscribeAsync(int $logisticsId): void { try { $this->subscribe($logisticsId); } catch (\Throwable $e) { Log::warning('kuaidi100 subscribe skipped', [ 'logistics_id' => $logisticsId, 'message' => $e->getMessage(), ]); } } public function subscribe(int $logisticsId): array { $logistics = $this->findLogistics($logisticsId); if (!$logistics) { throw new \RuntimeException('物流记录不存在'); } $sync = $this->ensureSyncRow($logistics); if (!$this->configService->getConfig()['enabled']) { $this->updateSync((int)$sync['id'], [ 'subscription_status' => 'disabled', 'last_error' => '', ]); return $this->formatSyncStatus((int)$logistics['id']); } $companyCode = trim((string)($sync['provider_com'] ?? '')); try { $this->updateSync((int)$sync['id'], [ 'subscription_status' => 'subscribing', 'last_error' => '', ]); $response = $this->client->subscribe( $companyCode, (string)$logistics['tracking_no'], $this->resolvePhoneForLogistics($logistics) ); $status = $this->subscriptionSucceeded($response) ? 'subscribed' : 'failed'; $this->updateSync((int)$sync['id'], [ 'subscription_status' => $status, 'raw_status' => (string)($response['result'] ?? $response['returnCode'] ?? ''), 'last_error' => $status === 'subscribed' ? '' : $this->errorMessageFromResponse($response), 'raw_summary' => $this->encodeRawSummary($response), ]); } catch (\Throwable $e) { $this->updateSync((int)$sync['id'], [ 'subscription_status' => 'failed', 'last_error' => $e->getMessage(), ]); throw $e; } return $this->formatSyncStatus((int)$logistics['id']); } public function syncByLogisticsId(int $logisticsId, bool $force = false): array { $logistics = $this->findLogistics($logisticsId); if (!$logistics) { throw new \RuntimeException('物流记录不存在'); } $sync = $this->ensureSyncRow($logistics); if (!$this->configService->isReadyForQuery()) { $this->markError((int)$sync['id'], '快递100实时查询配置未完成', ['last_query_at' => date('Y-m-d H:i:s')]); return $this->formatSyncStatus((int)$logistics['id']); } if (!$force && !$this->shouldQuery($sync)) { return $this->formatSyncStatus((int)$logistics['id']); } $companyCode = trim((string)($sync['provider_com'] ?? '')); if ($companyCode === '') { $companyCode = $this->resolveCompanyCode($logistics); if ($companyCode !== '') { $this->updateSync((int)$sync['id'], [ 'provider_com' => $companyCode, ]); } else { $this->updateSync((int)$sync['id'], [ 'last_query_at' => date('Y-m-d H:i:s'), 'last_error' => '', ]); return $this->formatSyncStatus((int)$logistics['id']); } } try { $response = $this->client->query( $companyCode, (string)$logistics['tracking_no'], $this->resolvePhoneForLogistics($logistics) ); $this->applyTrackPayload($logistics, $response, 'query'); } catch (\Throwable $e) { $this->markError((int)$sync['id'], $e->getMessage(), ['last_query_at' => date('Y-m-d H:i:s')]); throw $e; } return $this->formatSyncStatus((int)$logistics['id']); } public function syncDue(int $limit = 50): int { if (!$this->configService->isReadyForQuery()) { return 0; } $config = $this->configService->getConfig(); $before = date('Y-m-d H:i:s', time() - $config['query_min_interval_minutes'] * 60); $rows = Db::name('order_logistics') ->alias('l') ->leftJoin('order_logistics_syncs s', 's.logistics_id = l.id AND s.provider = "' . self::PROVIDER . '"') ->field(['l.id']) ->where('l.tracking_no', '<>', '') ->where('l.tracking_status', '<>', 'received') ->whereRaw('(s.last_query_at IS NULL OR s.last_query_at <= :before)', ['before' => $before]) ->order('l.id', 'desc') ->limit($limit) ->select() ->toArray(); $count = 0; foreach ($rows as $row) { try { $this->syncByLogisticsId((int)$row['id'], true); $count++; } catch (\Throwable $e) { Log::warning('kuaidi100 due sync failed', [ 'logistics_id' => (int)$row['id'], 'message' => $e->getMessage(), ]); } } return $count; } public function handleCallback(array $payload): array { $result = $this->extractTrackResult($payload); $trackingNo = trim((string)($result['nu'] ?? $result['number'] ?? '')); if ($trackingNo === '') { throw new \InvalidArgumentException('快递100回调缺少运单号'); } $rows = Db::name('order_logistics') ->where('tracking_no', $trackingNo) ->order('id', 'desc') ->select() ->toArray(); if (!$rows) { throw new \RuntimeException('未找到对应物流记录'); } $updatedIds = []; foreach ($rows as $logistics) { $this->ensureSyncRow($logistics, trim((string)($result['com'] ?? ''))); $this->applyTrackPayload($logistics, $result, 'push'); $updatedIds[] = (int)$logistics['id']; } return [ 'tracking_no' => $trackingNo, 'updated_ids' => $updatedIds, ]; } public function formatSyncStatus(int $logisticsId): array { $sync = Db::name('order_logistics_syncs') ->where('logistics_id', $logisticsId) ->where('provider', self::PROVIDER) ->find(); if (!$sync) { return [ 'provider_status_text' => '', 'sync_status_text' => '未同步', 'sync_error' => '', ]; } $error = trim((string)($sync['last_error'] ?? '')); $providerCom = trim((string)($sync['provider_com'] ?? '')); $subscriptionStatus = (string)($sync['subscription_status'] ?? ''); if ($error !== '') { $syncStatusText = '同步异常'; } elseif ($subscriptionStatus === 'subscribing') { $syncStatusText = '同步中'; } elseif ($subscriptionStatus === 'subscribed') { $syncStatusText = '已订阅'; } elseif ($subscriptionStatus === 'disabled') { $syncStatusText = '未启用'; } elseif ($providerCom === '') { $syncStatusText = '未识别'; } elseif (!empty($sync['last_push_at']) || !empty($sync['last_query_at'])) { $syncStatusText = '已同步'; } else { $syncStatusText = '待同步'; } return [ 'provider_status_text' => (string)($sync['provider_status_text'] ?? ''), 'sync_status_text' => $syncStatusText, 'sync_error' => $error, ]; } public function nodesForLogistics(int $logisticsId): array { if ($logisticsId <= 0) { return []; } return Db::name('order_logistics_nodes') ->where('logistics_id', $logisticsId) ->order('node_time', 'desc') ->select() ->toArray(); } private function applyTrackPayload(array $logistics, array $result, string $source): void { $logisticsId = (int)$logistics['id']; $sync = $this->ensureSyncRow($logistics, trim((string)($result['com'] ?? ''))); $nodes = $this->normalizeNodes((array)($result['data'] ?? [])); $now = date('Y-m-d H:i:s'); Db::startTrans(); try { foreach ($nodes as $node) { $exists = Db::name('order_logistics_nodes') ->where('logistics_id', $logisticsId) ->where('node_time', $node['node_time']) ->where('node_desc', $node['node_desc']) ->where('node_location', $node['node_location']) ->find(); if ($exists) { continue; } Db::name('order_logistics_nodes')->insert([ 'logistics_id' => $logisticsId, 'node_time' => $node['node_time'], 'node_desc' => $node['node_desc'], 'node_location' => $node['node_location'], 'created_at' => $now, ]); } $latest = $nodes[0] ?? null; if ($latest) { Db::name('order_logistics')->where('id', $logisticsId)->update([ 'latest_desc' => $latest['node_desc'], 'latest_time' => $latest['node_time'], 'updated_at' => $now, ]); } $syncPayload = [ 'provider_com' => trim((string)($result['com'] ?? $sync['provider_com'] ?? '')), 'provider_state' => (string)($result['state'] ?? ''), 'provider_status_text' => $this->providerStateText((string)($result['state'] ?? ''), (string)($result['ischeck'] ?? '')), 'raw_status' => (string)($result['status'] ?? ''), 'last_error' => '', 'raw_summary' => $this->encodeRawSummary($result), ]; if ($source === 'push') { $syncPayload['last_push_at'] = $now; } else { $syncPayload['last_query_at'] = $now; } $this->updateSync((int)$sync['id'], $syncPayload); Db::commit(); } catch (\Throwable $e) { Db::rollback(); throw $e; } } private function normalizeNodes(array $items): array { $nodes = []; foreach ($items as $item) { if (!is_array($item)) { continue; } $time = trim((string)($item['time'] ?? $item['ftime'] ?? '')); $desc = trim((string)($item['context'] ?? $item['status'] ?? '')); if ($time === '' || $desc === '') { continue; } $nodes[] = [ 'node_time' => $time, 'node_desc' => $desc, 'node_location' => trim((string)($item['areaName'] ?? $item['areaCenter'] ?? $item['location'] ?? '')), ]; } usort($nodes, static fn (array $left, array $right) => strcmp($right['node_time'], $left['node_time'])); return $nodes; } private function extractTrackResult(array $payload): array { if (isset($payload['lastResult']) && is_array($payload['lastResult'])) { return $payload['lastResult']; } if (isset($payload['data']) && is_array($payload['data']) && isset($payload['data']['lastResult']) && is_array($payload['data']['lastResult'])) { return $payload['data']['lastResult']; } return $payload; } private function ensureSyncRow(array $logistics, string $providerCom = ''): array { $logisticsId = (int)$logistics['id']; $providerCom = $providerCom !== '' ? $providerCom : $this->resolveCompanyCode($logistics); $row = Db::name('order_logistics_syncs') ->where('logistics_id', $logisticsId) ->where('provider', self::PROVIDER) ->find(); $now = date('Y-m-d H:i:s'); if ($row) { if ($providerCom !== '' && (string)($row['provider_com'] ?? '') === '') { Db::name('order_logistics_syncs')->where('id', (int)$row['id'])->update([ 'provider_com' => $providerCom, 'updated_at' => $now, ]); $row['provider_com'] = $providerCom; } return $row; } $id = (int)Db::name('order_logistics_syncs')->insertGetId([ 'logistics_id' => $logisticsId, 'provider' => self::PROVIDER, 'provider_com' => $providerCom, 'subscription_status' => '', 'provider_state' => '', 'provider_status_text' => '', 'last_error' => '', 'raw_status' => '', 'raw_summary' => null, 'created_at' => $now, 'updated_at' => $now, ]); return Db::name('order_logistics_syncs')->where('id', $id)->find() ?: []; } private function resolveCompanyCode(array $logistics): string { $company = trim((string)($logistics['express_company'] ?? '')); if ($company === '') { return ''; } $trackingNo = trim((string)($logistics['tracking_no'] ?? '')); $resolved = (new ExpressCompanyService())->resolveCompanyCode($company, $trackingNo); if ($resolved !== '') { return $resolved; } $row = Db::name('express_companies')->where('company_name', $company)->find(); $code = trim((string)($row['company_code'] ?? '')); $aliasCode = $this->companyAliasCode($company, $code); if ($aliasCode !== '') { return $aliasCode; } return preg_match('/^[a-z0-9]+$/', $code) === 1 && !str_starts_with($code, 'express_') ? $code : ''; } private function companyAliasCode(string $companyName, string $companyCode = ''): string { $aliases = [ '顺丰速运' => 'shunfeng', '顺丰' => 'shunfeng', 'sf_express' => 'shunfeng', '京东快递' => 'jd', '京东物流' => 'jd', 'EMS' => 'ems', 'ems' => 'ems', '中通快递' => 'zhongtong', '圆通速递' => 'yuantong', '圆通快递' => 'yuantong', '申通快递' => 'shentong', '韵达快递' => 'yunda', '极兔速递' => 'jtexpress', '极兔快递' => 'jtexpress', '德邦快递' => 'debangwuliu', '邮政快递包裹' => 'youzhengguonei', ]; return $aliases[$companyCode] ?? $aliases[$companyName] ?? ''; } private function resolvePhoneForLogistics(array $logistics): string { $orderId = (int)($logistics['order_id'] ?? 0); if ($orderId <= 0) { return ''; } if (($logistics['logistics_type'] ?? '') === 'return_to_user') { $mobile = Db::name('order_return_addresses')->where('order_id', $orderId)->value('mobile'); return trim((string)$mobile); } $order = Db::name('orders')->where('id', $orderId)->find(); if (!$order) { return ''; } $mobile = Db::name('users')->where('id', (int)$order['user_id'])->value('mobile'); return trim((string)$mobile); } private function shouldQuery(array $sync): bool { $lastQueryAt = trim((string)($sync['last_query_at'] ?? '')); if ($lastQueryAt === '') { return true; } $config = $this->configService->getConfig(); return strtotime($lastQueryAt) <= time() - $config['query_min_interval_minutes'] * 60; } private function providerStateText(string $state, string $isCheck): string { if ($isCheck === '1') { return '已签收'; } return match ($state) { '0' => '在途', '1' => '已揽收', '2' => '疑难', '3' => '已签收', '4' => '退签', '5' => '派件中', '6' => '退回中', '7' => '转投', '8' => '清关中', '14' => '拒签', default => $state === '' ? '' : '物流状态 ' . $state, }; } private function subscriptionSucceeded(array $response): bool { $result = $response['result'] ?? null; $returnCode = (string)($response['returnCode'] ?? $response['status'] ?? ''); return $result === true || $result === 'true' || $returnCode === '200'; } private function errorMessageFromResponse(array $response): string { return trim((string)($response['message'] ?? $response['returnMessage'] ?? '快递100返回失败')); } private function markError(int $syncId, string $message, array $extra = []): void { $this->updateSync($syncId, array_merge($extra, [ 'last_error' => mb_substr($message, 0, 500, 'UTF-8'), ])); } private function updateSync(int $syncId, array $payload): void { if ($syncId <= 0) { return; } $payload['updated_at'] = date('Y-m-d H:i:s'); if (isset($payload['last_error'])) { $payload['last_error'] = mb_substr((string)$payload['last_error'], 0, 500, 'UTF-8'); } Db::name('order_logistics_syncs')->where('id', $syncId)->update($payload); } private function findLogistics(int $logisticsId): ?array { if ($logisticsId <= 0) { return null; } $row = Db::name('order_logistics')->where('id', $logisticsId)->find(); return $row ?: null; } private function encodeRawSummary(array $payload): string { $json = json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); return mb_substr(is_string($json) ? $json : '', 0, 8000, 'UTF-8'); } private function ensureTable(): void { Db::execute(<<<'SQL' CREATE TABLE IF NOT EXISTS order_logistics_syncs ( id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, logistics_id BIGINT UNSIGNED NOT NULL, provider VARCHAR(32) NOT NULL DEFAULT 'kuaidi100', provider_com VARCHAR(64) NOT NULL DEFAULT '', subscription_status VARCHAR(32) NOT NULL DEFAULT '', provider_state VARCHAR(32) NOT NULL DEFAULT '', provider_status_text VARCHAR(64) NOT NULL DEFAULT '', last_query_at DATETIME NULL DEFAULT NULL, last_push_at DATETIME NULL DEFAULT NULL, last_error VARCHAR(500) NOT NULL DEFAULT '', raw_status VARCHAR(32) NOT NULL DEFAULT '', raw_summary LONGTEXT NULL, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY uk_order_logistics_syncs_provider (logistics_id, provider), KEY idx_order_logistics_syncs_provider_com (provider, provider_com), KEY idx_order_logistics_syncs_last_query (last_query_at) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='订单物流第三方同步状态' SQL); } }