This commit is contained in:
Your Name
2026-06-05 17:15:15 +08:00
parent b9d36d8d04
commit 5a235280a1
10 changed files with 328 additions and 54 deletions
+118 -44
View File
@@ -8,36 +8,42 @@ use think\facade\Config;
use think\facade\Log;
/**
* 京东官方物流轨迹查询(JOS 开放平台 https://api.jd.com/routerjson
* 京东官方物流轨迹查询(京东物流开放平台 LOPhttps://api.jdl.com
*
* 作用:作为快递100 对「京东自营运单(JDVE…)」轨迹陈旧/缺失时的兜底数据源
* 仅在 config/logistics.php 的 jd.enable=true(即填好 app_key/app_secret/access_token)时生效;
* 作用:作为快递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
* 无需改代码(响应解析采用「递归找轨迹行」的宽松策略,兼容多种返回结构)。
* 接口:京东物流标准轨迹服务 /jd/tracking/query2025-04-29 改版,对接方案编码 Tracking_JD)。
* 调用走 LOP 统一网关,鉴权/签名规则与官方 SDK(IsvFilter) 完全一致:
* - 公共参数(app_key/access_token/timestamp/v/sign/algorithm/LOP-DN)以 query string 拼到 URL
* - 业务参数 JSON 字符串作为请求体;待签串固定顺序拼接并首尾包 app_secret。
* 加签算法由 .env JD_LOGISTICS_ALGORITHM 控制(默认 md5-salt=md5(content),另支持 HMacMD5/SHA1/SHA256/SHA512)。
* 注意:后台「报文加解密密钥」的 RSA 公私钥仅用于报文加解密,与本网关签名无关。
*
* 签名(JOS 标准):sign = strtoupper(md5(secret + 拼接(ksort 后的 key.value) + secret))
* 网关/path/对接方案编码/单号类型 走 .envJD_LOGISTICS_GATEWAY / METHOD / LOP_DN / REFERENCE_TYPE)。
* 响应解析采用「递归找轨迹行」的宽松策略,兼容多种返回结构。
*/
final class JdLogisticsService
{
/** 轨迹行「时间」候选字段(按优先级) */
private const TIME_FIELDS = [
'operatorTime', 'opeTime', 'operateTime', 'msgTime', 'scanTime',
'operationTime', 'operatorTime', 'opeTime', 'operateTime', 'msgTime', 'scanTime',
'time', 'createTime', 'waybillStateTime', 'orderTime',
];
/** 轨迹行「描述」候选字段(按优先级) */
private const CONTEXT_FIELDS = [
'operateRemark', 'opeRemark', 'content', 'opeTitle', 'remark',
'scanTypeName', 'waybillStateName', 'message', 'desc', 'msg',
'remark', 'operateRemark', 'opeRemark', 'content', 'opeTitle',
'operationCodeName', 'operationTypeName', 'scanTypeName', 'waybillStateName', 'message', 'desc', 'msg',
];
public static function isConfigured(): bool
{
$cfg = Config::get('logistics.jd', []);
// LOP 网关签名用 app_secretmd5-salt / HMAC),不需要 RSA 私钥(私钥仅用于报文加解密)
return !empty($cfg['enable'])
&& trim((string) ($cfg['app_key'] ?? '')) !== ''
&& trim((string) ($cfg['app_secret'] ?? '')) !== ''
@@ -56,7 +62,7 @@ final class JdLogisticsService
* newest_unix: int
* }|null
*/
public static function queryTrace(string $waybillCode): ?array
public static function queryTrace(string $waybillCode, string $phoneTail = ''): ?array
{
$num = trim($waybillCode);
if ($num === '' || !self::isConfigured()) {
@@ -64,15 +70,23 @@ final class JdLogisticsService
}
$cfg = Config::get('logistics.jd', []);
$bizParam = [
(string) ($cfg['waybill_field'] ?? 'waybillCode') => $num,
// 京东物流标准轨迹服务 /jd/tracking/querybody 为 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 !== '') {
$bizParam['customerCode'] = $customerCode;
$row['customerCode'] = $customerCode;
}
$body = json_encode([$row], JSON_UNESCAPED_UNICODE);
$raw = self::request($cfg, $bizParam);
$raw = self::request($cfg, (string) $body);
if ($raw === null) {
return null;
}
@@ -84,7 +98,19 @@ final class JdLogisticsService
return null;
}
// JOS 网关层错误(如 access_token 失效)在顶层返回 error_response
// 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,
@@ -142,43 +168,90 @@ final class JdLogisticsService
}
/**
* 调用 LOP 网关(统一鉴权/签名,与官方 SDK IsvFilter 一致)。
*
* 公共参数以 query string 拼到 URL;业务参数(JSON 字符串)作为请求体;
* 网关靠 LOP-DN(对接方案编码) 路由到对应服务。
*
* @param array<string,mixed> $cfg
* @param array<string,mixed> $bizParam
* @param string $body 业务参数 JSON 字符串(param_json
*/
private static function request(array $cfg, array $bizParam): ?string
private static function request(array $cfg, string $body): ?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),
$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');
// 待签串:固定顺序拼接,首尾包 appSecretmethod=接口pathparam_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,
];
$params['sign'] = self::sign($params, (string) ($cfg['app_secret'] ?? ''));
$base = rtrim((string) ($cfg['gateway'] ?? 'https://api.jdl.com'), '/');
$url = $base . $path . '?' . http_build_query($query);
$gateway = (string) ($cfg['gateway'] ?? 'https://api.jd.com/routerjson');
// lop-tz:与 timestamp 同源(北京时间 = 东八区 = 8)
$offsetHours = (int) ($now->getOffset() / 3600);
return self::httpPostForm($gateway, http_build_query($params));
return self::httpPostJson($url, $body, [
'Content-Type: application/json;charset=utf-8',
'User-Agent: lop-http/php',
'lop-tz: ' . $offsetHours,
]);
}
/**
* JOS 签名:strtoupper(md5(secret + 拼接(ksort 后的 key.value) + secret))
*
* @param array<string,string> $params
* LOP 网关签名(与官方 SDK Utils::sign 一致):
* - md5-salt md5(content) 的小写十六进制
* - HMacMD5 / HMacSHA1 / HMacSHA256 / HMacSHA512base64(hmac(算法, content, appSecret))
* 不支持的算法返回 null。
*/
private static function sign(array $params, string $secret): string
private static function sign(string $algorithm, string $content, string $secret): ?string
{
ksort($params);
$concat = '';
foreach ($params as $k => $v) {
$concat .= $k . $v;
}
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 strtoupper(md5($secret . $concat . $secret));
return null;
}
}
/**
@@ -356,7 +429,10 @@ final class JdLogisticsService
return false;
}
private static function httpPostForm(string $url, string $body): ?string
/**
* @param list<string> $headers
*/
private static function httpPostJson(string $url, string $body, array $headers): ?string
{
if (!function_exists('curl_init')) {
return null;
@@ -367,9 +443,7 @@ final class JdLogisticsService
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_HTTPHEADER, $headers);
curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false);
curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, false);
$resp = curl_exec($ch);