462 lines
16 KiB
PHP
462 lines
16 KiB
PHP
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace app\common\service;
|
||
|
||
use think\facade\Config;
|
||
use think\facade\Log;
|
||
|
||
/**
|
||
* 京东官方物流轨迹查询(京东物流开放平台 LOP,https://api.jdl.com)
|
||
*
|
||
* 作用:作为快递100 对「京东自营运单(JDVE…)」轨迹陈旧/缺失时的兜底数据源,
|
||
* 同时供后台「京东接口更新」按钮直接拉取并落库。
|
||
* 仅在 config/logistics.php 的 jd.enable=true(填好 app_key/app_secret/access_token)时生效;
|
||
* 未配置时 isConfigured()=false,ExpressTrackService 完全沿用快递100 逻辑,互不影响。
|
||
*
|
||
* 接口:京东物流标准轨迹服务 /jd/tracking/query(2025-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 公私钥仅用于报文加解密,与本网关签名无关。
|
||
*
|
||
* 网关/path/对接方案编码/单号类型 走 .env(JD_LOGISTICS_GATEWAY / METHOD / LOP_DN / REFERENCE_TYPE)。
|
||
* 响应解析采用「递归找轨迹行」的宽松策略,兼容多种返回结构。
|
||
*/
|
||
final class JdLogisticsService
|
||
{
|
||
/** 轨迹行「时间」候选字段(按优先级) */
|
||
private const TIME_FIELDS = [
|
||
'operationTime', 'operatorTime', 'opeTime', 'operateTime', 'msgTime', 'scanTime',
|
||
'time', 'createTime', 'waybillStateTime', 'orderTime',
|
||
];
|
||
|
||
/** 轨迹行「描述」候选字段(按优先级) */
|
||
private const CONTEXT_FIELDS = [
|
||
'remark', 'operateRemark', 'opeRemark', 'content', 'opeTitle',
|
||
'operationCodeName', 'operationTypeName', 'scanTypeName', 'waybillStateName', 'message', 'desc', 'msg',
|
||
];
|
||
|
||
public static function isConfigured(): bool
|
||
{
|
||
$cfg = Config::get('logistics.jd', []);
|
||
|
||
// LOP 网关签名用 app_secret(md5-salt / HMAC),不需要 RSA 私钥(私钥仅用于报文加解密)
|
||
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, 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<string,mixed> $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<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;
|
||
}
|
||
|
||
/**
|
||
* @param list<string> $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;
|
||
}
|
||
}
|