264 lines
8.8 KiB
PHP
264 lines
8.8 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace app\common\service;
|
|
|
|
use think\facade\Config;
|
|
use think\facade\Log;
|
|
|
|
/**
|
|
* 快递轨迹:顺丰(shunfeng)、京东(jd),优先走快递100;未配置时返回官网查询链接
|
|
*/
|
|
class ExpressTrackService
|
|
{
|
|
private const KUAIDI_COM_SF = 'shunfeng';
|
|
|
|
private const KUAIDI_COM_JD = 'jingdong'; // 京东快递(快递100编码)
|
|
|
|
private const KUAIDI_COM_JT = 'jtexpress'; // 极兔速递
|
|
|
|
/**
|
|
* @return array{
|
|
* carrier: string,
|
|
* carrier_label: string,
|
|
* kuaidi_com: string,
|
|
* traces: list<array{time:string,context:string}>,
|
|
* state: string,
|
|
* state_text: string,
|
|
* source: string,
|
|
* hint: string,
|
|
* official_url: string
|
|
* }
|
|
*/
|
|
public static function query(string $expressCompany, string $trackingNumber, string $recipientPhone = ''): array
|
|
{
|
|
$num = trim($trackingNumber);
|
|
$phoneDigits = preg_replace('/\D/', '', $recipientPhone) ?? '';
|
|
$phoneLast4 = strlen($phoneDigits) >= 4 ? substr($phoneDigits, -4) : '';
|
|
|
|
$resolved = self::resolveCarrier($expressCompany, $num);
|
|
$carrier = $resolved['carrier'];
|
|
$kuaidiCom = $resolved['kuaidi_com'];
|
|
$label = $resolved['label'];
|
|
|
|
$officialUrl = self::buildOfficialUrl($carrier, $num);
|
|
|
|
$out = [
|
|
'carrier' => $carrier,
|
|
'carrier_label' => $label,
|
|
'kuaidi_com' => $kuaidiCom,
|
|
'traces' => [],
|
|
'state' => '',
|
|
'state_text' => '',
|
|
'source' => 'official_only',
|
|
'hint' => '',
|
|
'official_url' => $officialUrl,
|
|
];
|
|
|
|
$cfg = Config::get('logistics.kuaidi100', []);
|
|
$enable = !empty($cfg['enable']);
|
|
if (! $enable) {
|
|
$out['hint'] = '未配置快递100查询密钥或已关闭(LOGISTICS_KUAIDI100_DISABLE),仅可打开官网查件。请在 .env 中配置 LOGISTICS_KUAIDI100_CUSTOMER、LOGISTICS_KUAIDI100_KEY';
|
|
|
|
return $out;
|
|
}
|
|
|
|
$paramArr = [
|
|
'com' => $kuaidiCom,
|
|
'num' => $num,
|
|
'resultv2' => '1',
|
|
];
|
|
if ($phoneLast4 !== '') {
|
|
$paramArr['phone'] = $phoneLast4;
|
|
}
|
|
|
|
$paramJson = json_encode($paramArr, JSON_UNESCAPED_UNICODE);
|
|
$customer = (string) $cfg['customer'];
|
|
$key = (string) $cfg['key'];
|
|
$sign = strtoupper(md5($paramJson . $key . $customer));
|
|
$postBody = http_build_query([
|
|
'customer' => $customer,
|
|
'param' => $paramJson,
|
|
'sign' => $sign,
|
|
]);
|
|
|
|
$url = (string) ($cfg['query_url'] ?? 'https://poll.kuaidi100.com/poll/query.do');
|
|
$raw = self::httpPostForm($url, $postBody);
|
|
if ($raw === null || $raw === '') {
|
|
$out['hint'] = '快递100接口无响应,请稍后重试或使用官网查询';
|
|
Log::warning('ExpressTrackService kuaidi100 empty response', ['num' => $num]);
|
|
|
|
return $out;
|
|
}
|
|
|
|
$json = json_decode($raw, true);
|
|
if (!is_array($json)) {
|
|
$out['hint'] = '快递100返回异常,请使用官网查询';
|
|
Log::warning('ExpressTrackService kuaidi100 invalid json', ['raw' => mb_substr($raw, 0, 500)]);
|
|
|
|
return $out;
|
|
}
|
|
|
|
if (isset($json['result']) && $json['result'] === false) {
|
|
$msg = (string) ($json['message'] ?? '查询失败');
|
|
$returnCode = (string) ($json['returnCode'] ?? '');
|
|
|
|
// 特殊处理"找不到对应公司"错误
|
|
if ($msg === '找不到对应公司' || $returnCode === '400') {
|
|
$out['hint'] = '快递100暂不支持该快递公司或编码错误,请使用下方官网链接查询';
|
|
} else {
|
|
$out['hint'] = $msg;
|
|
}
|
|
|
|
Log::info('ExpressTrackService kuaidi100 business fail', [
|
|
'message' => $msg,
|
|
'returnCode' => $returnCode,
|
|
'num' => $num,
|
|
'com' => $kuaidiCom
|
|
]);
|
|
|
|
return $out;
|
|
}
|
|
|
|
$data = $json['data'] ?? null;
|
|
if (!is_array($data)) {
|
|
$data = [];
|
|
}
|
|
if (($json['message'] ?? '') !== 'ok' && $data === []) {
|
|
$out['hint'] = (string) ($json['message'] ?? '未查到轨迹');
|
|
Log::info('ExpressTrackService kuaidi100 no data', ['json' => $json]);
|
|
|
|
return $out;
|
|
}
|
|
|
|
$traces = [];
|
|
foreach ($data as $row) {
|
|
if (!is_array($row)) {
|
|
continue;
|
|
}
|
|
$t = (string) ($row['ftime'] ?? $row['time'] ?? '');
|
|
$c = (string) ($row['context'] ?? '');
|
|
if ($t === '' && $c === '') {
|
|
continue;
|
|
}
|
|
$traces[] = ['time' => $t, 'context' => $c];
|
|
}
|
|
|
|
$out['traces'] = $traces;
|
|
$out['state'] = (string) ($json['state'] ?? '');
|
|
$out['state_text'] = self::stateText($out['state']);
|
|
$out['source'] = 'kuaidi100';
|
|
$out['hint'] = $traces === [] ? '暂无轨迹节点,单号可能尚未揽收' : '';
|
|
|
|
return $out;
|
|
}
|
|
|
|
/**
|
|
* @return array{carrier: string, kuaidi_com: string, label: string}
|
|
*/
|
|
private static function resolveCarrier(string $expressCompany, string $num): array
|
|
{
|
|
$ec = strtolower(trim($expressCompany));
|
|
if ($ec === 'sf') {
|
|
return ['carrier' => 'sf', 'kuaidi_com' => self::KUAIDI_COM_SF, 'label' => '顺丰速运'];
|
|
}
|
|
if ($ec === 'jd') {
|
|
return ['carrier' => 'jd', 'kuaidi_com' => self::KUAIDI_COM_JD, 'label' => '京东快递'];
|
|
}
|
|
if ($ec === 'jt' || $ec === 'jtexpress') {
|
|
return ['carrier' => 'jt', 'kuaidi_com' => self::KUAIDI_COM_JT, 'label' => '极兔速递'];
|
|
}
|
|
|
|
$u = strtoupper($num);
|
|
if (preg_match('/^SF\d/i', $num)) {
|
|
return ['carrier' => 'sf', 'kuaidi_com' => self::KUAIDI_COM_SF, 'label' => '顺丰速运(单号识别)'];
|
|
}
|
|
if (preg_match('/^(JD|JDV|JDK|JDEX|JD[A-Z0-9])/i', $u)) {
|
|
return ['carrier' => 'jd', 'kuaidi_com' => self::KUAIDI_COM_JD, 'label' => '京东快递(单号识别)'];
|
|
}
|
|
if (preg_match('/^JT\d{13}$/i', $num)) {
|
|
return ['carrier' => 'jt', 'kuaidi_com' => self::KUAIDI_COM_JT, 'label' => '极兔速递(单号识别)'];
|
|
}
|
|
|
|
return ['carrier' => 'auto', 'kuaidi_com' => 'auto', 'label' => '自动识别'];
|
|
}
|
|
|
|
private static function stateText(string $state): string
|
|
{
|
|
$m = [
|
|
'0' => '在途',
|
|
'1' => '揽收',
|
|
'2' => '疑难',
|
|
'3' => '已签收',
|
|
'4' => '退签',
|
|
'5' => '派件中',
|
|
'6' => '退回',
|
|
'7' => '转投',
|
|
'10' => '待清关',
|
|
'11' => '清关中',
|
|
'12' => '已清关',
|
|
'13' => '清关异常',
|
|
'14' => '收件人拒签',
|
|
];
|
|
|
|
return $m[$state] ?? '';
|
|
}
|
|
|
|
/**
|
|
* @return array{sf: string, jd: string, jt: string}
|
|
*/
|
|
public static function officialUrls(string $trackingNumber): array
|
|
{
|
|
$n = trim($trackingNumber);
|
|
$enc = rawurlencode($n);
|
|
|
|
return [
|
|
// 顺丰速运官网查询(新版)
|
|
'sf' => 'https://www.sf-express.com/cn/sc/dynamic_function/waybill/#search/bill-number/' . $enc,
|
|
// 京东物流官网查询
|
|
'jd' => 'https://www.jdl.com/#/trackQuery?waybillCode=' . $enc,
|
|
// 极兔速递官网查询
|
|
'jt' => 'https://www.jtexpress.com.cn/index/query/gzquery.html?bills=' . $enc,
|
|
];
|
|
}
|
|
|
|
private static function buildOfficialUrl(string $carrier, string $num): string
|
|
{
|
|
$urls = self::officialUrls($num);
|
|
if ($carrier === 'sf') {
|
|
return $urls['sf'];
|
|
}
|
|
if ($carrier === 'jd') {
|
|
return $urls['jd'];
|
|
}
|
|
if ($carrier === 'jt') {
|
|
return $urls['jt'];
|
|
}
|
|
|
|
return $urls['jt']; // 默认返回极兔
|
|
}
|
|
|
|
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);
|
|
curl_close($ch);
|
|
|
|
return $resp === false ? null : (string) $resp;
|
|
}
|
|
}
|