, * state: string, * state_text: string, * source: string, * hint: string, * official_url: string * } */ public static function query(string $expressCompany, string $trackingNumber, string $recipientPhone = '', string $phoneTailOverride = ''): array { $num = trim($trackingNumber); $overrideDigits = preg_replace('/\D/', '', $phoneTailOverride) ?? ''; $recipientDigits = preg_replace('/\D/', '', $recipientPhone) ?? ''; // 快递100 文档:phone 为收/寄件人电话;顺丰等必填。示例为完整 11 位手机号,仅传后四位易触发 408「验证码错误」 $phoneForKuaidi = self::buildKuaidiPhoneParam($overrideDigits, $recipientDigits); $resolved = self::resolveCarrier($expressCompany, $num); $carrier = $resolved['carrier']; $kuaidiCom = $resolved['kuaidi_com']; $label = $resolved['label']; $comCandidates = self::kuaidiComCandidates($kuaidiCom, $num); $officialUrl = self::buildOfficialUrl($carrier, $num); $out = [ 'carrier' => $carrier, 'carrier_label' => $label, 'kuaidi_com' => $kuaidiCom, 'traces' => [], 'state' => '', 'state_text' => '', 'source' => 'official_only', 'hint' => '', 'official_url' => $officialUrl, ]; $cfg = Config::get('logistics.kuaidi100', []); $enable = !empty($cfg['enable']); $result = $out; if (! $enable) { $result['hint'] = '未配置快递100查询密钥或已关闭(LOGISTICS_KUAIDI100_DISABLE),仅可打开官网查件。请在 .env 中配置 LOGISTICS_KUAIDI100_CUSTOMER、LOGISTICS_KUAIDI100_KEY'; } elseif ($phoneForKuaidi === '' && self::kuaidiPhoneRequired($kuaidiCom)) { // 顺丰(及快递100 要求电话的承运商)无 phone 时不请求接口,避免无效调用 $result['hint'] = '顺丰查询需在快递100 中同时提交单号与收/寄件人电话(可与面单一致的完整手机号或后四位)。请填写收件电话后点「刷新轨迹」。'; } else { $matched = null; $lastFail = null; foreach ($comCandidates as $tryCom) { $tryOut = self::queryKuaidiOnce($cfg, $tryCom, $num, $phoneForKuaidi, $carrier, $label); if (!empty($tryOut['traces']) || ($tryOut['state'] ?? '') !== '') { $matched = $tryOut; break; } $lastFail = $tryOut; } $result = $matched ?? $lastFail ?? $out; } // 京东自营单(JDVE…)兜底:快递100 无轨迹/陈旧时,用京东官方接口(更新或更全才采用)。 // 未配置京东官方接口时 isConfigured()=false,本段跳过,行为与原先一致。 if ($carrier === 'jd' && JdLogisticsService::isConfigured()) { try { $jd = JdLogisticsService::queryTrace($num); if ($jd !== null && !empty($jd['traces']) && self::jdResultPreferred($jd, $result)) { $result['traces'] = $jd['traces']; $result['state'] = (string) $jd['state']; $result['state_text'] = (string) $jd['state_text']; $result['source'] = 'jd_official'; $result['hint'] = ''; // carrier / carrier_label / official_url / kuaidi_com 保留原值 } } catch (\Throwable $e) { Log::warning('ExpressTrackService jd official fallback failed', [ 'num' => $num, 'error' => $e->getMessage(), ]); } } return $result; } /** * 京东官方轨迹是否应优先于快递100 结果采用: * 快递100 无轨迹 → 直接用;否则京东更「新」(最新轨迹时间更晚)或同样新但条目更多 → 用。 * * @param array{traces?:array,newest_unix?:int} $jd * @param array{traces?:array} $kuaidi */ private static function jdResultPreferred(array $jd, array $kuaidi): bool { $kuaidiTraces = is_array($kuaidi['traces'] ?? null) ? $kuaidi['traces'] : []; if ($kuaidiTraces === []) { return true; } $jdNewest = (int) ($jd['newest_unix'] ?? 0); $kuaidiNewest = self::newestUnixFromTraces($kuaidiTraces); if ($jdNewest > $kuaidiNewest) { return true; } if ($jdNewest === $kuaidiNewest && $jdNewest > 0) { return count($jd['traces'] ?? []) > count($kuaidiTraces); } return false; } /** * @param array $traces */ private static function newestUnixFromTraces(array $traces): int { $best = 0; foreach ($traces as $t) { if (!is_array($t)) { continue; } $p = strtotime((string) ($t['time'] ?? '')); if ($p !== false && (int) $p > $best) { $best = (int) $p; } } return $best; } /** * 根据运单号形态纠正承运商(避免 express_tracking 误存 sf 导致京东单查不出) */ public static function normalizeExpressCompanyCode(string $trackingNumber, string $storedCompany = 'auto'): string { $byNumber = self::detectCarrierFromNumber($trackingNumber); if ($byNumber === null) { $ec = strtolower(trim($storedCompany)); return in_array($ec, ['sf', 'jd', 'jt', 'jtexpress', 'auto'], true) ? $ec : 'auto'; } $ec = strtolower(trim($storedCompany)); $byEc = self::carrierFromExpressCode($ec); if ($byEc !== null && $byEc['carrier'] !== $byNumber['carrier']) { return $byNumber['carrier']; } return $byNumber['carrier']; } /** * @param array $cfg * @return array{ * carrier: string, * carrier_label: string, * kuaidi_com: string, * traces: list, * state: string, * state_text: string, * source: string, * hint: string, * official_url: string * } */ private static function queryKuaidiOnce( array $cfg, string $kuaidiCom, string $num, string $phoneForKuaidi, string $carrier, string $label ): array { $officialUrl = self::buildOfficialUrl($carrier, $num); $out = [ 'carrier' => $carrier, 'carrier_label' => $label, 'kuaidi_com' => $kuaidiCom, 'traces' => [], 'state' => '', 'state_text' => '', 'source' => 'kuaidi100', 'hint' => '', 'official_url' => $officialUrl, ]; $paramArr = [ 'com' => $kuaidiCom, 'num' => $num, 'resultv2' => '1', ]; if ($phoneForKuaidi !== '') { $paramArr['phone'] = $phoneForKuaidi; } $paramJson = json_encode($paramArr, JSON_UNESCAPED_UNICODE); $customer = (string) $cfg['customer']; $key = (string) $cfg['key']; $sign = strtoupper(md5($paramJson . $key . $customer)); $postBody = http_build_query([ 'customer' => $customer, 'param' => $paramJson, 'sign' => $sign, ]); $url = (string) ($cfg['query_url'] ?? 'https://poll.kuaidi100.com/poll/query.do'); $raw = self::httpPostForm($url, $postBody); if ($raw === null || $raw === '') { $out['hint'] = '快递100接口无响应,请稍后重试或使用官网查询'; Log::warning('ExpressTrackService kuaidi100 empty response', ['num' => $num, 'com' => $kuaidiCom]); return $out; } $json = json_decode($raw, true); if (!is_array($json)) { $out['hint'] = '快递100返回异常,请使用官网查询'; Log::warning('ExpressTrackService kuaidi100 invalid json', ['raw' => mb_substr($raw, 0, 500), 'com' => $kuaidiCom]); return $out; } if (isset($json['result']) && $json['result'] === false) { $msg = (string) ($json['message'] ?? '查询失败'); $returnCode = (string) ($json['returnCode'] ?? ''); if ($msg === '找不到对应公司' || $returnCode === '400') { $out['hint'] = '快递100暂不支持该快递公司或编码错误,请使用下方官网链接查询'; } else { $out['hint'] = $msg; } Log::info('ExpressTrackService kuaidi100 business fail', [ 'message' => $msg, 'returnCode' => $returnCode, 'num' => $num, 'com' => $kuaidiCom, ]); return $out; } $data = $json['data'] ?? null; if (!is_array($data)) { $data = []; } if (($json['message'] ?? '') !== 'ok' && $data === []) { $out['hint'] = (string) ($json['message'] ?? '未查到轨迹'); Log::info('ExpressTrackService kuaidi100 no data', ['json' => $json, 'com' => $kuaidiCom]); return $out; } $traces = []; foreach ($data as $row) { if (!is_array($row)) { continue; } $t = (string) ($row['ftime'] ?? $row['time'] ?? ''); $c = (string) ($row['context'] ?? ''); if ($t === '' && $c === '') { continue; } $traces[] = ['time' => $t, 'context' => $c]; } $out['traces'] = $traces; $out['state'] = (string) ($json['state'] ?? ''); $out['state_text'] = self::stateText($out['state']); $out['hint'] = $traces === [] ? '暂无轨迹节点,单号可能尚未揽收' : ''; return $out; } /** * @return list */ private static function kuaidiComCandidates(string $primaryCom, string $num): array { $list = [$primaryCom]; $byNumber = self::detectCarrierFromNumber($num); if ($byNumber !== null && !in_array($byNumber['kuaidi_com'], $list, true)) { $list[] = $byNumber['kuaidi_com']; } if (preg_match('/^JDVE/i', strtoupper($num)) && !in_array('jd', $list, true)) { $list[] = 'jd'; } if (preg_match('/^(JD|JDV|JDK|JDEX)/i', strtoupper($num))) { foreach (['jingdong', 'jd'] as $c) { if (!in_array($c, $list, true)) { $list[] = $c; } } } return array_values(array_unique(array_filter($list, static fn ($c) => $c !== '' && $c !== 'auto'))); } /** * 快递100「phone」入参:有手动覆盖且不少于 4 位时用覆盖;否则用订单收货号码。 * 对 11 位及以上数字取后 11 位作为手机号(去掉可能的前缀符号位)。 */ private static function buildKuaidiPhoneParam(string $overrideDigits, string $recipientDigits): string { $d = strlen($overrideDigits) >= 4 ? $overrideDigits : $recipientDigits; if ($d === '') { return ''; } if (strlen($d) >= 11) { return substr($d, -11); } return $d; } /** 实时查询文档:顺丰速运、中通快递等 phone 必填 */ private static function kuaidiPhoneRequired(string $kuaidiCom): bool { $c = strtolower($kuaidiCom); return $c === self::KUAIDI_COM_SF || $c === 'zhongtong'; } /** * @return array{carrier: string, kuaidi_com: string, label: string}|null */ private static function carrierFromExpressCode(string $expressCompany): ?array { $ec = strtolower(trim($expressCompany)); if ($ec === 'sf' || $ec === 'shunfeng') { return ['carrier' => 'sf', 'kuaidi_com' => self::KUAIDI_COM_SF, 'label' => '顺丰速运']; } if ($ec === 'jd' || $ec === 'jingdong') { return ['carrier' => 'jd', 'kuaidi_com' => self::KUAIDI_COM_JD, 'label' => '京东快递']; } if ($ec === 'jt' || $ec === 'jtexpress') { return ['carrier' => 'jt', 'kuaidi_com' => self::KUAIDI_COM_JT, 'label' => '极兔速递']; } return null; } /** * @return array{carrier: string, kuaidi_com: string, label: string}|null */ private static function detectCarrierFromNumber(string $num): ?array { $n = trim($num); if ($n === '') { return null; } $u = strtoupper($n); if (preg_match('/^SF\d/i', $n)) { return ['carrier' => 'sf', 'kuaidi_com' => self::KUAIDI_COM_SF, 'label' => '顺丰速运(单号识别)']; } if (preg_match('/^JDVE/i', $u)) { return ['carrier' => 'jd', 'kuaidi_com' => self::KUAIDI_COM_JD, 'label' => '京东快递(单号识别)']; } if (preg_match('/^(JDK|JDV|JDEX)/i', $u) || preg_match('/^JD[A-Z0-9]{10,}/i', $u)) { return ['carrier' => 'jd', 'kuaidi_com' => self::KUAIDI_COM_JD, 'label' => '京东物流(单号识别)']; } if (preg_match('/^JT\d{13}$/i', $n)) { return ['carrier' => 'jt', 'kuaidi_com' => self::KUAIDI_COM_JT, 'label' => '极兔速递(单号识别)']; } return null; } /** * @return array{carrier: string, kuaidi_com: string, label: string} */ private static function resolveCarrier(string $expressCompany, string $num): array { $byNumber = self::detectCarrierFromNumber($num); $byEc = self::carrierFromExpressCode($expressCompany); if ($byNumber !== null && $byEc !== null && $byEc['carrier'] !== $byNumber['carrier']) { Log::info('ExpressTrackService carrier mismatch, prefer tracking number', [ 'express_company' => $expressCompany, 'tracking_number' => $num, 'stored_carrier' => $byEc['carrier'], 'detected_carrier' => $byNumber['carrier'], ]); return $byNumber; } if ($byEc !== null) { return $byEc; } if ($byNumber !== null) { return $byNumber; } return ['carrier' => 'auto', 'kuaidi_com' => 'auto', 'label' => '自动识别']; } private static function stateText(string $state): string { $m = [ '0' => '在途', '1' => '揽收', '2' => '疑难', '3' => '已签收', '4' => '退签', '5' => '派件中', '6' => '退回', '7' => '转投', '10' => '待清关', '11' => '清关中', '12' => '已清关', '13' => '清关异常', '14' => '收件人拒签', ]; return $m[$state] ?? ''; } /** * @return array{sf: string, jd: string, jt: string} */ public static function officialUrls(string $trackingNumber): array { $n = trim($trackingNumber); $enc = rawurlencode($n); return [ // 顺丰速运官网查询(新版) 'sf' => 'https://www.sf-express.com/cn/sc/dynamic_function/waybill/#search/bill-number/' . $enc, // 京东物流官网查询 'jd' => 'https://www.jdl.com/#/trackQuery?waybillCode=' . $enc, // 极兔速递官网查询 'jt' => 'https://www.jtexpress.com.cn/index/query/gzquery.html?bills=' . $enc, ]; } private static function buildOfficialUrl(string $carrier, string $num): string { $urls = self::officialUrls($num); if ($carrier === 'sf') { return $urls['sf']; } if ($carrier === 'jd') { return $urls['jd']; } if ($carrier === 'jt') { return $urls['jt']; } return $urls['jt']; // 默认返回极兔 } private static function httpPostForm(string $url, string $body): ?string { if (!function_exists('curl_init')) { return null; } $ch = curl_init(); curl_setopt($ch, CURLOPT_URL, $url); curl_setopt($ch, CURLOPT_POST, true); curl_setopt($ch, CURLOPT_POSTFIELDS, $body); curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); curl_setopt($ch, CURLOPT_TIMEOUT, 15); curl_setopt($ch, CURLOPT_HTTPHEADER, [ 'Content-Type: application/x-www-form-urlencoded', ]); curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false); curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, false); $resp = curl_exec($ch); curl_close($ch); return $resp === false ? null : (string) $resp; } }