Files
zyt/server/app/common/service/ExpressTrackService.php
T
2026-06-05 17:15:15 +08:00

498 lines
18 KiB
PHP
Executable File

<?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'; // 极兔速递
/**
* @param string $phoneTailOverride 手工填写的收件电话(仅数字;完整 11 位或与面单一致的后四位等),优先于订单收货手机
*
* @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 = '', string $phoneTailOverride = ''): array
{
$num = trim($trackingNumber);
$overrideDigits = preg_replace('/\D/', '', $phoneTailOverride) ?? '';
$recipientDigits = preg_replace('/\D/', '', $recipientPhone) ?? '';
// 快递100 文档:phone 为收/寄件人电话;顺丰等必填。示例为完整 11 位手机号,仅传后四位易触发 408「验证码错误」
$phoneForKuaidi = self::buildKuaidiPhoneParam($overrideDigits, $recipientDigits);
$resolved = self::resolveCarrier($expressCompany, $num);
$carrier = $resolved['carrier'];
$kuaidiCom = $resolved['kuaidi_com'];
$label = $resolved['label'];
$comCandidates = self::kuaidiComCandidates($kuaidiCom, $num);
$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']);
$result = $out;
if (! $enable) {
$result['hint'] = '未配置快递100查询密钥或已关闭(LOGISTICS_KUAIDI100_DISABLE),仅可打开官网查件。请在 .env 中配置 LOGISTICS_KUAIDI100_CUSTOMER、LOGISTICS_KUAIDI100_KEY';
} elseif ($phoneForKuaidi === '' && self::kuaidiPhoneRequired($kuaidiCom)) {
// 顺丰(及快递100 要求电话的承运商)无 phone 时不请求接口,避免无效调用
$result['hint'] = '顺丰查询需在快递100 中同时提交单号与收/寄件人电话(可与面单一致的完整手机号或后四位)。请填写收件电话后点「刷新轨迹」。';
} else {
$matched = null;
$lastFail = null;
foreach ($comCandidates as $tryCom) {
$tryOut = self::queryKuaidiOnce($cfg, $tryCom, $num, $phoneForKuaidi, $carrier, $label);
if (!empty($tryOut['traces']) || ($tryOut['state'] ?? '') !== '') {
$matched = $tryOut;
break;
}
$lastFail = $tryOut;
}
$result = $matched ?? $lastFail ?? $out;
}
// 京东自营单(JDVE…)兜底:快递100 无轨迹/陈旧时,用京东官方接口(更新或更全才采用)。
// 未配置京东官方接口时 isConfigured()=false,本段跳过,行为与原先一致。
if ($carrier === 'jd' && JdLogisticsService::isConfigured()) {
try {
$jdPhone = $overrideDigits !== '' ? $overrideDigits : $recipientDigits;
$jd = JdLogisticsService::queryTrace($num, $jdPhone);
if ($jd !== null && !empty($jd['traces']) && self::jdResultPreferred($jd, $result)) {
$result['traces'] = $jd['traces'];
$result['state'] = (string) $jd['state'];
$result['state_text'] = (string) $jd['state_text'];
$result['source'] = 'jd_official';
$result['hint'] = '';
// carrier / carrier_label / official_url / kuaidi_com 保留原值
}
} catch (\Throwable $e) {
Log::warning('ExpressTrackService jd official fallback failed', [
'num' => $num,
'error' => $e->getMessage(),
]);
}
}
return $result;
}
/**
* 京东官方轨迹是否应优先于快递100 结果采用:
* 快递100 无轨迹 → 直接用;否则京东更「新」(最新轨迹时间更晚)或同样新但条目更多 → 用。
*
* @param array{traces?:array,newest_unix?:int} $jd
* @param array{traces?:array} $kuaidi
*/
private static function jdResultPreferred(array $jd, array $kuaidi): bool
{
$kuaidiTraces = is_array($kuaidi['traces'] ?? null) ? $kuaidi['traces'] : [];
if ($kuaidiTraces === []) {
return true;
}
$jdNewest = (int) ($jd['newest_unix'] ?? 0);
$kuaidiNewest = self::newestUnixFromTraces($kuaidiTraces);
if ($jdNewest > $kuaidiNewest) {
return true;
}
if ($jdNewest === $kuaidiNewest && $jdNewest > 0) {
return count($jd['traces'] ?? []) > count($kuaidiTraces);
}
return false;
}
/**
* @param array<int, array{time?:string}> $traces
*/
private static function newestUnixFromTraces(array $traces): int
{
$best = 0;
foreach ($traces as $t) {
if (!is_array($t)) {
continue;
}
$p = strtotime((string) ($t['time'] ?? ''));
if ($p !== false && (int) $p > $best) {
$best = (int) $p;
}
}
return $best;
}
/**
* 根据运单号形态纠正承运商(避免 express_tracking 误存 sf 导致京东单查不出)
*/
public static function normalizeExpressCompanyCode(string $trackingNumber, string $storedCompany = 'auto'): string
{
$byNumber = self::detectCarrierFromNumber($trackingNumber);
if ($byNumber === null) {
$ec = strtolower(trim($storedCompany));
return in_array($ec, ['sf', 'jd', 'jt', 'jtexpress', 'auto'], true) ? $ec : 'auto';
}
$ec = strtolower(trim($storedCompany));
$byEc = self::carrierFromExpressCode($ec);
if ($byEc !== null && $byEc['carrier'] !== $byNumber['carrier']) {
return $byNumber['carrier'];
}
return $byNumber['carrier'];
}
/**
* @param array<string, mixed> $cfg
* @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
* }
*/
private static function queryKuaidiOnce(
array $cfg,
string $kuaidiCom,
string $num,
string $phoneForKuaidi,
string $carrier,
string $label
): array {
$officialUrl = self::buildOfficialUrl($carrier, $num);
$out = [
'carrier' => $carrier,
'carrier_label' => $label,
'kuaidi_com' => $kuaidiCom,
'traces' => [],
'state' => '',
'state_text' => '',
'source' => 'kuaidi100',
'hint' => '',
'official_url' => $officialUrl,
];
$paramArr = [
'com' => $kuaidiCom,
'num' => $num,
'resultv2' => '1',
];
if ($phoneForKuaidi !== '') {
$paramArr['phone'] = $phoneForKuaidi;
}
$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, 'com' => $kuaidiCom]);
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), 'com' => $kuaidiCom]);
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, 'com' => $kuaidiCom]);
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['hint'] = $traces === [] ? '暂无轨迹节点,单号可能尚未揽收' : '';
return $out;
}
/**
* @return list<string>
*/
private static function kuaidiComCandidates(string $primaryCom, string $num): array
{
$list = [$primaryCom];
$byNumber = self::detectCarrierFromNumber($num);
if ($byNumber !== null && !in_array($byNumber['kuaidi_com'], $list, true)) {
$list[] = $byNumber['kuaidi_com'];
}
if (preg_match('/^JDVE/i', strtoupper($num)) && !in_array('jd', $list, true)) {
$list[] = 'jd';
}
if (preg_match('/^(JD|JDV|JDK|JDEX)/i', strtoupper($num))) {
foreach (['jingdong', 'jd'] as $c) {
if (!in_array($c, $list, true)) {
$list[] = $c;
}
}
}
return array_values(array_unique(array_filter($list, static fn ($c) => $c !== '' && $c !== 'auto')));
}
/**
* 快递100「phone」入参:有手动覆盖且不少于 4 位时用覆盖;否则用订单收货号码。
* 对 11 位及以上数字取后 11 位作为手机号(去掉可能的前缀符号位)。
*/
private static function buildKuaidiPhoneParam(string $overrideDigits, string $recipientDigits): string
{
$d = strlen($overrideDigits) >= 4 ? $overrideDigits : $recipientDigits;
if ($d === '') {
return '';
}
if (strlen($d) >= 11) {
return substr($d, -11);
}
return $d;
}
/** 实时查询文档:顺丰速运、中通快递等 phone 必填 */
private static function kuaidiPhoneRequired(string $kuaidiCom): bool
{
$c = strtolower($kuaidiCom);
return $c === self::KUAIDI_COM_SF || $c === 'zhongtong';
}
/**
* @return array{carrier: string, kuaidi_com: string, label: string}|null
*/
private static function carrierFromExpressCode(string $expressCompany): ?array
{
$ec = strtolower(trim($expressCompany));
if ($ec === 'sf' || $ec === 'shunfeng') {
return ['carrier' => 'sf', 'kuaidi_com' => self::KUAIDI_COM_SF, 'label' => '顺丰速运'];
}
if ($ec === 'jd' || $ec === 'jingdong') {
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' => '极兔速递'];
}
return null;
}
/**
* @return array{carrier: string, kuaidi_com: string, label: string}|null
*/
private static function detectCarrierFromNumber(string $num): ?array
{
$n = trim($num);
if ($n === '') {
return null;
}
$u = strtoupper($n);
if (preg_match('/^SF\d/i', $n)) {
return ['carrier' => 'sf', 'kuaidi_com' => self::KUAIDI_COM_SF, 'label' => '顺丰速运(单号识别)'];
}
if (preg_match('/^JDVE/i', $u)) {
return ['carrier' => 'jd', 'kuaidi_com' => self::KUAIDI_COM_JD, 'label' => '京东快递(单号识别)'];
}
if (preg_match('/^(JDK|JDV|JDEX)/i', $u) || preg_match('/^JD[A-Z0-9]{10,}/i', $u)) {
return ['carrier' => 'jd', 'kuaidi_com' => self::KUAIDI_COM_JD, 'label' => '京东物流(单号识别)'];
}
if (preg_match('/^JT\d{13}$/i', $n)) {
return ['carrier' => 'jt', 'kuaidi_com' => self::KUAIDI_COM_JT, 'label' => '极兔速递(单号识别)'];
}
return null;
}
/**
* @return array{carrier: string, kuaidi_com: string, label: string}
*/
private static function resolveCarrier(string $expressCompany, string $num): array
{
$byNumber = self::detectCarrierFromNumber($num);
$byEc = self::carrierFromExpressCode($expressCompany);
if ($byNumber !== null && $byEc !== null && $byEc['carrier'] !== $byNumber['carrier']) {
Log::info('ExpressTrackService carrier mismatch, prefer tracking number', [
'express_company' => $expressCompany,
'tracking_number' => $num,
'stored_carrier' => $byEc['carrier'],
'detected_carrier' => $byNumber['carrier'],
]);
return $byNumber;
}
if ($byEc !== null) {
return $byEc;
}
if ($byNumber !== null) {
return $byNumber;
}
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;
}
}