, * state: string, * state_text: string, * source: string, * hint: string, * newest_unix: int * }|null */ public static function queryTrace(string $waybillCode, string $phoneTail = ''): ?array { $num = trim($waybillCode); if ($num === '' || !self::isConfigured()) { return null; } $cfg = Config::get('logistics.jd', []); // 京东物流标准轨迹服务 /jd/tracking/query:body 为 JSON 数组 [{referenceNumber, referenceType, phone}] $row = [ (string) ($cfg['reference_field'] ?? 'referenceNumber') => $num, 'referenceType' => (string) ($cfg['reference_type'] ?? '20000'), ]; $tail = substr(preg_replace('/\D/', '', $phoneTail) ?? '', -4); if ($tail !== '') { $row['phone'] = $tail; } $customerCode = trim((string) ($cfg['customer_code'] ?? '')); if ($customerCode !== '') { $row['customerCode'] = $customerCode; } $body = json_encode([$row], JSON_UNESCAPED_UNICODE); $raw = self::request($cfg, (string) $body); if ($raw === null) { return null; } $json = json_decode($raw, true); if (!is_array($json)) { Log::warning('JdLogisticsService invalid json', ['raw' => mb_substr($raw, 0, 500), 'num' => $num]); return null; } // LOP 网关/业务错误:code 非 1000(成功)时记录原始报文,便于排查鉴权/单号/权限问题 $code = (string) ($json['code'] ?? $json['resultCode'] ?? ''); if ($code !== '' && !in_array($code, ['1000', '0000', '0'], true)) { Log::warning('JdLogisticsService lop error', [ 'num' => $num, 'code' => $code, 'message' => (string) ($json['msg'] ?? $json['message'] ?? $json['resultMessage'] ?? ''), 'raw' => mb_substr($raw, 0, 500), ]); return null; } // 兼容 JOS 网关层错误结构 if (isset($json['error_response'])) { Log::warning('JdLogisticsService gateway error', [ 'num' => $num, 'error' => $json['error_response'], ]); return null; } $rows = self::extractTraceRows($json); if ($rows === []) { Log::info('JdLogisticsService no trace rows', ['num' => $num, 'json' => mb_substr($raw, 0, 800)]); return null; } $traces = self::normalizeRows($rows); if ($traces === []) { return null; } // 时间倒序(最新在前),与快递100 输出一致 usort($traces, static function (array $a, array $b): int { return ($b['_unix'] ?? 0) <=> ($a['_unix'] ?? 0); }); $newestUnix = (int) ($traces[0]['_unix'] ?? 0); $signed = false; foreach ($traces as $t) { if (self::looksSigned((string) $t['context'])) { $signed = true; break; } } $state = $signed ? '3' : '0'; // 去掉内部辅助字段 $clean = []; foreach ($traces as $t) { $clean[] = [ 'time' => (string) $t['time'], 'context' => (string) $t['context'], 'status' => (string) ($t['status'] ?? ''), ]; } return [ 'traces' => $clean, 'state' => $state, 'state_text' => $signed ? '已签收' : '在途', 'source' => 'jd_official', 'hint' => '', 'newest_unix' => $newestUnix, ]; } /** * 调用 LOP 网关(统一鉴权/签名,与官方 SDK IsvFilter 一致)。 * * 公共参数以 query string 拼到 URL;业务参数(JSON 字符串)作为请求体; * 网关靠 LOP-DN(对接方案编码) 路由到对应服务。 * * @param array $cfg * @param string $body 业务参数 JSON 字符串(param_json) */ private static function request(array $cfg, string $body): ?string { $appKey = (string) ($cfg['app_key'] ?? ''); $appSecret = (string) ($cfg['app_secret'] ?? ''); $accessToken = (string) ($cfg['access_token'] ?? ''); $path = (string) ($cfg['method'] ?? '/jd/tracking/query'); $version = (string) ($cfg['api_version'] ?? '2.0'); $algorithm = trim((string) ($cfg['algorithm'] ?? 'md5-salt')) ?: 'md5-salt'; $lopDn = (string) ($cfg['lop_dn'] ?? 'Tracking_JD'); // 时间戳与时区必须自洽(否则网关报 471 时间戳已失效):统一用北京时间 + lop-tz=8 $now = new \DateTime('now', new \DateTimeZone('Asia/Shanghai')); $timestamp = $now->format('Y-m-d H:i:s'); // 待签串:固定顺序拼接,首尾包 appSecret(method=接口path,param_json=业务体) $content = implode('', [ $appSecret, 'access_token', $accessToken, 'app_key', $appKey, 'method', $path, 'param_json', $body, 'timestamp', $timestamp, 'v', $version, $appSecret, ]); $sign = self::sign($algorithm, $content, $appSecret); if ($sign === null) { return null; } $query = [ 'LOP-DN' => $lopDn, 'app_key' => $appKey, 'access_token' => $accessToken, 'timestamp' => $timestamp, 'v' => $version, 'sign' => $sign, 'algorithm' => $algorithm, ]; $base = rtrim((string) ($cfg['gateway'] ?? 'https://api.jdl.com'), '/'); $url = $base . $path . '?' . http_build_query($query); // lop-tz:与 timestamp 同源(北京时间 = 东八区 = 8) $offsetHours = (int) ($now->getOffset() / 3600); return self::httpPostJson($url, $body, [ 'Content-Type: application/json;charset=utf-8', 'User-Agent: lop-http/php', 'lop-tz: ' . $offsetHours, ]); } /** * LOP 网关签名(与官方 SDK Utils::sign 一致): * - md5-salt :md5(content) 的小写十六进制 * - HMacMD5 / HMacSHA1 / HMacSHA256 / HMacSHA512:base64(hmac(算法, content, appSecret)) * 不支持的算法返回 null。 */ private static function sign(string $algorithm, string $content, string $secret): ?string { switch (trim($algorithm)) { case 'md5-salt': return md5($content); case 'HMacMD5': return base64_encode(hash_hmac('md5', $content, $secret, true)); case 'HMacSHA1': return base64_encode(hash_hmac('sha1', $content, $secret, true)); case 'HMacSHA256': return base64_encode(hash_hmac('sha256', $content, $secret, true)); case 'HMacSHA512': return base64_encode(hash_hmac('sha512', $content, $secret, true)); default: Log::warning('JdLogisticsService unsupported algorithm', ['algorithm' => $algorithm]); return null; } } /** * 递归在响应 JSON 中找出「轨迹行数组」:取出现轨迹行最多的一组。 * 兼容字段被序列化成 JSON 字符串(如 querytrace_result 为 string)的情况。 * * @param mixed $node * @return list> */ private static function extractTraceRows($node): array { $best = []; $walk = function ($n) use (&$walk, &$best): void { if (is_string($n)) { $trimmed = trim($n); if ($trimmed !== '' && ($trimmed[0] === '{' || $trimmed[0] === '[')) { $decoded = json_decode($trimmed, true); if (is_array($decoded)) { $walk($decoded); } } return; } if (!is_array($n)) { return; } // 是否为「轨迹行的列表」:连续数字键、且元素是带时间/描述字段的关联数组 if (self::isList($n)) { $rows = []; foreach ($n as $item) { if (is_array($item) && self::rowHasTraceFields($item)) { $rows[] = $item; } } if (count($rows) > count($best)) { $best = $rows; } } foreach ($n as $v) { $walk($v); } }; $walk($node); return $best; } /** * @param array $row */ private static function rowHasTraceFields(array $row): bool { $hasTime = false; foreach (self::TIME_FIELDS as $f) { if (isset($row[$f]) && trim((string) $row[$f]) !== '') { $hasTime = true; break; } } if (!$hasTime) { return false; } foreach (self::CONTEXT_FIELDS as $f) { if (isset($row[$f]) && trim((string) $row[$f]) !== '') { return true; } } return false; } /** * @param array> $rows * @return list */ private static function normalizeRows(array $rows): array { $out = []; foreach ($rows as $row) { $time = ''; foreach (self::TIME_FIELDS as $f) { if (isset($row[$f]) && trim((string) $row[$f]) !== '') { $time = trim((string) $row[$f]); break; } } $context = ''; foreach (self::CONTEXT_FIELDS as $f) { if (isset($row[$f]) && trim((string) $row[$f]) !== '') { $context = trim((string) $row[$f]); break; } } if ($time === '' && $context === '') { continue; } $unix = self::parseTimeToUnix($time); $out[] = [ 'time' => $time !== '' ? self::formatTime($time, $unix) : '', 'context' => $context, 'status' => '', '_unix' => $unix, ]; } return $out; } private static function parseTimeToUnix(string $time): int { $t = trim($time); if ($t === '') { return 0; } // 毫秒时间戳 if (preg_match('/^\d{13}$/', $t)) { return (int) ((int) $t / 1000); } // 秒时间戳 if (preg_match('/^\d{10}$/', $t)) { return (int) $t; } $p = strtotime($t); return $p !== false ? (int) $p : 0; } private static function formatTime(string $raw, int $unix): string { // 纯时间戳统一格式化成可读时间,便于落库/前端展示 if ($unix > 0 && preg_match('/^\d{10,13}$/', trim($raw))) { return date('Y-m-d H:i:s', $unix); } return $raw; } /** * @param array $arr */ private static function isList(array $arr): bool { if ($arr === []) { return false; } if (function_exists('array_is_list')) { return array_is_list($arr); } return array_keys($arr) === range(0, count($arr) - 1); } private static function looksSigned(string $hay): bool { if ($hay === '') { return false; } foreach (['准备签收', '待签收', '等待签收', '预计', '即将送达'] as $neg) { if (mb_stripos($hay, $neg) !== false) { return false; } } foreach (['签收', '妥投', '送达', '已放在'] as $k) { if (mb_stripos($hay, $k) !== false) { return true; } } return false; } /** * @param list $headers */ private static function httpPostJson(string $url, string $body, array $headers): ?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, $headers); curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false); curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, false); $resp = curl_exec($ch); $err = curl_error($ch); curl_close($ch); if ($resp === false) { Log::warning('JdLogisticsService http error', ['url' => $url, 'error' => $err]); return null; } return (string) $resp; } }