This commit is contained in:
Your Name
2026-06-04 16:51:46 +08:00
parent b566e60624
commit e49f806c3a
@@ -0,0 +1,387 @@
<?php
declare(strict_types=1);
namespace app\common\service;
use think\facade\Config;
use think\facade\Log;
/**
* 京东官方物流轨迹查询(JOS 开放平台 https://api.jd.com/routerjson
*
* 作用:作为快递100 对「京东自营运单(JDVE…)」轨迹陈旧/缺失时的兜底数据源。
* 仅在 config/logistics.php 的 jd.enable=true(即填好 app_key/app_secret/access_token)时生效;
* 未配置时 isConfigured()=falseExpressTrackService 完全沿用快递100 逻辑,互不影响。
*
* 默认调用青龙「运单轨迹查询」jingdong.ldop.receive.trace.get
* 若你方用 ECLP 等其它接口,可在 .env 覆盖 JD_LOGISTICS_METHOD / JD_LOGISTICS_PARAM_KEY / JD_LOGISTICS_WAYBILL_FIELD
* 无需改代码(响应解析采用「递归找轨迹行」的宽松策略,兼容多种返回结构)。
*
* 签名(JOS 标准):sign = strtoupper(md5(secret + 拼接(ksort 后的 key.value) + secret))
*/
final class JdLogisticsService
{
/** 轨迹行「时间」候选字段(按优先级) */
private const TIME_FIELDS = [
'operatorTime', 'opeTime', 'operateTime', 'msgTime', 'scanTime',
'time', 'createTime', 'waybillStateTime', 'orderTime',
];
/** 轨迹行「描述」候选字段(按优先级) */
private const CONTEXT_FIELDS = [
'operateRemark', 'opeRemark', 'content', 'opeTitle', 'remark',
'scanTypeName', 'waybillStateName', 'message', 'desc', 'msg',
];
public static function isConfigured(): bool
{
$cfg = Config::get('logistics.jd', []);
return !empty($cfg['enable'])
&& trim((string) ($cfg['app_key'] ?? '')) !== ''
&& trim((string) ($cfg['app_secret'] ?? '')) !== ''
&& trim((string) ($cfg['access_token'] ?? '')) !== '';
}
/**
* 查询京东官方轨迹,返回与 ExpressTrackService::query 兼容的结构(失败/未配置返回 null)。
*
* @return array{
* traces: list<array{time:string,context:string,status:string}>,
* 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<string,mixed> $cfg
* @param array<string,mixed> $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<string,string> $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<array<string,mixed>>
*/
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<string,mixed> $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<int, array<string,mixed>> $rows
* @return list<array{time:string,context:string,status:string,_unix:int}>
*/
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<mixed> $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;
}
}