From e49f806c3a0d6ad7bef89f79d1f33431d80011dd Mon Sep 17 00:00:00 2001 From: Your Name Date: Thu, 4 Jun 2026 16:51:46 +0800 Subject: [PATCH] =?UTF-8?q?=E6=9B=B4=E6=96=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../app/common/service/JdLogisticsService.php | 387 ++++++++++++++++++ 1 file changed, 387 insertions(+) create mode 100644 server/app/common/service/JdLogisticsService.php diff --git a/server/app/common/service/JdLogisticsService.php b/server/app/common/service/JdLogisticsService.php new file mode 100644 index 00000000..bc98d2c9 --- /dev/null +++ b/server/app/common/service/JdLogisticsService.php @@ -0,0 +1,387 @@ +, + * state: string, + * state_text: string, + * source: string, + * hint: string, + * newest_unix: int + * }|null + */ + public static function queryTrace(string $waybillCode): ?array + { + $num = trim($waybillCode); + if ($num === '' || !self::isConfigured()) { + return null; + } + + $cfg = Config::get('logistics.jd', []); + $bizParam = [ + (string) ($cfg['waybill_field'] ?? 'waybillCode') => $num, + ]; + $customerCode = trim((string) ($cfg['customer_code'] ?? '')); + if ($customerCode !== '') { + $bizParam['customerCode'] = $customerCode; + } + + $raw = self::request($cfg, $bizParam); + 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; + } + + // JOS 网关层错误(如 access_token 失效)在顶层返回 error_response + 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, + ]; + } + + /** + * @param array $cfg + * @param array $bizParam + */ + private static function request(array $cfg, array $bizParam): ?string + { + $paramKey = (string) ($cfg['param_key'] ?? '360buy_param_json'); + $params = [ + 'method' => (string) ($cfg['method'] ?? 'jingdong.ldop.receive.trace.get'), + 'app_key' => (string) ($cfg['app_key'] ?? ''), + 'access_token' => (string) ($cfg['access_token'] ?? ''), + 'timestamp' => date('Y-m-d H:i:s'), + 'format' => 'json', + 'v' => (string) ($cfg['api_version'] ?? '2.0'), + $paramKey => json_encode($bizParam, JSON_UNESCAPED_UNICODE), + ]; + + $params['sign'] = self::sign($params, (string) ($cfg['app_secret'] ?? '')); + + $gateway = (string) ($cfg['gateway'] ?? 'https://api.jd.com/routerjson'); + + return self::httpPostForm($gateway, http_build_query($params)); + } + + /** + * JOS 签名:strtoupper(md5(secret + 拼接(ksort 后的 key.value) + secret)) + * + * @param array $params + */ + private static function sign(array $params, string $secret): string + { + ksort($params); + $concat = ''; + foreach ($params as $k => $v) { + $concat .= $k . $v; + } + + return strtoupper(md5($secret . $concat . $secret)); + } + + /** + * 递归在响应 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; + } + + 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); + $err = curl_error($ch); + curl_close($ch); + + if ($resp === false) { + Log::warning('JdLogisticsService http error', ['url' => $url, 'error' => $err]); + + return null; + } + + return (string) $resp; + } +}