feat: add kuaidi100 logistics sync
This commit is contained in:
557
server-api/app/support/OrderLogisticsSyncService.php
Normal file
557
server-api/app/support/OrderLogisticsSyncService.php
Normal file
@@ -0,0 +1,557 @@
|
||||
<?php
|
||||
|
||||
namespace app\support;
|
||||
|
||||
use support\Log;
|
||||
use support\think\Db;
|
||||
|
||||
class OrderLogisticsSyncService
|
||||
{
|
||||
private const PROVIDER = 'kuaidi100';
|
||||
|
||||
public function __construct(
|
||||
private ?Kuaidi100ConfigService $configService = null,
|
||||
private ?Kuaidi100Client $client = null
|
||||
) {
|
||||
$this->configService ??= new Kuaidi100ConfigService();
|
||||
$this->client ??= new Kuaidi100Client($this->configService);
|
||||
$this->ensureTable();
|
||||
}
|
||||
|
||||
public function subscribeAsync(int $logisticsId): void
|
||||
{
|
||||
try {
|
||||
$this->subscribe($logisticsId);
|
||||
} catch (\Throwable $e) {
|
||||
Log::warning('kuaidi100 subscribe skipped', [
|
||||
'logistics_id' => $logisticsId,
|
||||
'message' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
public function subscribe(int $logisticsId): array
|
||||
{
|
||||
$logistics = $this->findLogistics($logisticsId);
|
||||
if (!$logistics) {
|
||||
throw new \RuntimeException('物流记录不存在');
|
||||
}
|
||||
|
||||
$sync = $this->ensureSyncRow($logistics);
|
||||
if (!$this->configService->getConfig()['enabled']) {
|
||||
$this->updateSync((int)$sync['id'], [
|
||||
'subscription_status' => 'disabled',
|
||||
'last_error' => '',
|
||||
]);
|
||||
return $this->formatSyncStatus((int)$logistics['id']);
|
||||
}
|
||||
|
||||
$companyCode = trim((string)($sync['provider_com'] ?? ''));
|
||||
|
||||
try {
|
||||
$this->updateSync((int)$sync['id'], [
|
||||
'subscription_status' => 'subscribing',
|
||||
'last_error' => '',
|
||||
]);
|
||||
$response = $this->client->subscribe(
|
||||
$companyCode,
|
||||
(string)$logistics['tracking_no'],
|
||||
$this->resolvePhoneForLogistics($logistics)
|
||||
);
|
||||
$status = $this->subscriptionSucceeded($response) ? 'subscribed' : 'failed';
|
||||
$this->updateSync((int)$sync['id'], [
|
||||
'subscription_status' => $status,
|
||||
'raw_status' => (string)($response['result'] ?? $response['returnCode'] ?? ''),
|
||||
'last_error' => $status === 'subscribed' ? '' : $this->errorMessageFromResponse($response),
|
||||
'raw_summary' => $this->encodeRawSummary($response),
|
||||
]);
|
||||
} catch (\Throwable $e) {
|
||||
$this->updateSync((int)$sync['id'], [
|
||||
'subscription_status' => 'failed',
|
||||
'last_error' => $e->getMessage(),
|
||||
]);
|
||||
throw $e;
|
||||
}
|
||||
|
||||
return $this->formatSyncStatus((int)$logistics['id']);
|
||||
}
|
||||
|
||||
public function syncByLogisticsId(int $logisticsId, bool $force = false): array
|
||||
{
|
||||
$logistics = $this->findLogistics($logisticsId);
|
||||
if (!$logistics) {
|
||||
throw new \RuntimeException('物流记录不存在');
|
||||
}
|
||||
|
||||
$sync = $this->ensureSyncRow($logistics);
|
||||
if (!$this->configService->isReadyForQuery()) {
|
||||
$this->markError((int)$sync['id'], '快递100实时查询配置未完成', ['last_query_at' => date('Y-m-d H:i:s')]);
|
||||
return $this->formatSyncStatus((int)$logistics['id']);
|
||||
}
|
||||
|
||||
if (!$force && !$this->shouldQuery($sync)) {
|
||||
return $this->formatSyncStatus((int)$logistics['id']);
|
||||
}
|
||||
|
||||
$companyCode = trim((string)($sync['provider_com'] ?? ''));
|
||||
if ($companyCode === '') {
|
||||
$companyCode = $this->resolveCompanyCode($logistics);
|
||||
if ($companyCode !== '') {
|
||||
$this->updateSync((int)$sync['id'], [
|
||||
'provider_com' => $companyCode,
|
||||
]);
|
||||
} else {
|
||||
$this->updateSync((int)$sync['id'], [
|
||||
'last_query_at' => date('Y-m-d H:i:s'),
|
||||
'last_error' => '',
|
||||
]);
|
||||
return $this->formatSyncStatus((int)$logistics['id']);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
$response = $this->client->query(
|
||||
$companyCode,
|
||||
(string)$logistics['tracking_no'],
|
||||
$this->resolvePhoneForLogistics($logistics)
|
||||
);
|
||||
$this->applyTrackPayload($logistics, $response, 'query');
|
||||
} catch (\Throwable $e) {
|
||||
$this->markError((int)$sync['id'], $e->getMessage(), ['last_query_at' => date('Y-m-d H:i:s')]);
|
||||
throw $e;
|
||||
}
|
||||
|
||||
return $this->formatSyncStatus((int)$logistics['id']);
|
||||
}
|
||||
|
||||
public function syncDue(int $limit = 50): int
|
||||
{
|
||||
if (!$this->configService->isReadyForQuery()) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
$config = $this->configService->getConfig();
|
||||
$before = date('Y-m-d H:i:s', time() - $config['query_min_interval_minutes'] * 60);
|
||||
$rows = Db::name('order_logistics')
|
||||
->alias('l')
|
||||
->leftJoin('order_logistics_syncs s', 's.logistics_id = l.id AND s.provider = "' . self::PROVIDER . '"')
|
||||
->field(['l.id'])
|
||||
->where('l.tracking_no', '<>', '')
|
||||
->where('l.tracking_status', '<>', 'received')
|
||||
->whereRaw('(s.last_query_at IS NULL OR s.last_query_at <= :before)', ['before' => $before])
|
||||
->order('l.id', 'desc')
|
||||
->limit($limit)
|
||||
->select()
|
||||
->toArray();
|
||||
|
||||
$count = 0;
|
||||
foreach ($rows as $row) {
|
||||
try {
|
||||
$this->syncByLogisticsId((int)$row['id'], true);
|
||||
$count++;
|
||||
} catch (\Throwable $e) {
|
||||
Log::warning('kuaidi100 due sync failed', [
|
||||
'logistics_id' => (int)$row['id'],
|
||||
'message' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
return $count;
|
||||
}
|
||||
|
||||
public function handleCallback(array $payload): array
|
||||
{
|
||||
$result = $this->extractTrackResult($payload);
|
||||
$trackingNo = trim((string)($result['nu'] ?? $result['number'] ?? ''));
|
||||
if ($trackingNo === '') {
|
||||
throw new \InvalidArgumentException('快递100回调缺少运单号');
|
||||
}
|
||||
|
||||
$rows = Db::name('order_logistics')
|
||||
->where('tracking_no', $trackingNo)
|
||||
->order('id', 'desc')
|
||||
->select()
|
||||
->toArray();
|
||||
if (!$rows) {
|
||||
throw new \RuntimeException('未找到对应物流记录');
|
||||
}
|
||||
|
||||
$updatedIds = [];
|
||||
foreach ($rows as $logistics) {
|
||||
$this->ensureSyncRow($logistics, trim((string)($result['com'] ?? '')));
|
||||
$this->applyTrackPayload($logistics, $result, 'push');
|
||||
$updatedIds[] = (int)$logistics['id'];
|
||||
}
|
||||
|
||||
return [
|
||||
'tracking_no' => $trackingNo,
|
||||
'updated_ids' => $updatedIds,
|
||||
];
|
||||
}
|
||||
|
||||
public function formatSyncStatus(int $logisticsId): array
|
||||
{
|
||||
$sync = Db::name('order_logistics_syncs')
|
||||
->where('logistics_id', $logisticsId)
|
||||
->where('provider', self::PROVIDER)
|
||||
->find();
|
||||
|
||||
if (!$sync) {
|
||||
return [
|
||||
'provider_status_text' => '',
|
||||
'sync_status_text' => '未同步',
|
||||
'sync_error' => '',
|
||||
];
|
||||
}
|
||||
|
||||
$error = trim((string)($sync['last_error'] ?? ''));
|
||||
$providerCom = trim((string)($sync['provider_com'] ?? ''));
|
||||
$subscriptionStatus = (string)($sync['subscription_status'] ?? '');
|
||||
if ($error !== '') {
|
||||
$syncStatusText = '同步异常';
|
||||
} elseif ($subscriptionStatus === 'subscribing') {
|
||||
$syncStatusText = '同步中';
|
||||
} elseif ($subscriptionStatus === 'subscribed') {
|
||||
$syncStatusText = '已订阅';
|
||||
} elseif ($subscriptionStatus === 'disabled') {
|
||||
$syncStatusText = '未启用';
|
||||
} elseif ($providerCom === '') {
|
||||
$syncStatusText = '未识别';
|
||||
} elseif (!empty($sync['last_push_at']) || !empty($sync['last_query_at'])) {
|
||||
$syncStatusText = '已同步';
|
||||
} else {
|
||||
$syncStatusText = '待同步';
|
||||
}
|
||||
|
||||
return [
|
||||
'provider_status_text' => (string)($sync['provider_status_text'] ?? ''),
|
||||
'sync_status_text' => $syncStatusText,
|
||||
'sync_error' => $error,
|
||||
];
|
||||
}
|
||||
|
||||
public function nodesForLogistics(int $logisticsId): array
|
||||
{
|
||||
if ($logisticsId <= 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return Db::name('order_logistics_nodes')
|
||||
->where('logistics_id', $logisticsId)
|
||||
->order('node_time', 'desc')
|
||||
->select()
|
||||
->toArray();
|
||||
}
|
||||
|
||||
private function applyTrackPayload(array $logistics, array $result, string $source): void
|
||||
{
|
||||
$logisticsId = (int)$logistics['id'];
|
||||
$sync = $this->ensureSyncRow($logistics, trim((string)($result['com'] ?? '')));
|
||||
$nodes = $this->normalizeNodes((array)($result['data'] ?? []));
|
||||
$now = date('Y-m-d H:i:s');
|
||||
|
||||
Db::startTrans();
|
||||
try {
|
||||
foreach ($nodes as $node) {
|
||||
$exists = Db::name('order_logistics_nodes')
|
||||
->where('logistics_id', $logisticsId)
|
||||
->where('node_time', $node['node_time'])
|
||||
->where('node_desc', $node['node_desc'])
|
||||
->where('node_location', $node['node_location'])
|
||||
->find();
|
||||
if ($exists) {
|
||||
continue;
|
||||
}
|
||||
|
||||
Db::name('order_logistics_nodes')->insert([
|
||||
'logistics_id' => $logisticsId,
|
||||
'node_time' => $node['node_time'],
|
||||
'node_desc' => $node['node_desc'],
|
||||
'node_location' => $node['node_location'],
|
||||
'created_at' => $now,
|
||||
]);
|
||||
}
|
||||
|
||||
$latest = $nodes[0] ?? null;
|
||||
if ($latest) {
|
||||
Db::name('order_logistics')->where('id', $logisticsId)->update([
|
||||
'latest_desc' => $latest['node_desc'],
|
||||
'latest_time' => $latest['node_time'],
|
||||
'updated_at' => $now,
|
||||
]);
|
||||
}
|
||||
|
||||
$syncPayload = [
|
||||
'provider_com' => trim((string)($result['com'] ?? $sync['provider_com'] ?? '')),
|
||||
'provider_state' => (string)($result['state'] ?? ''),
|
||||
'provider_status_text' => $this->providerStateText((string)($result['state'] ?? ''), (string)($result['ischeck'] ?? '')),
|
||||
'raw_status' => (string)($result['status'] ?? ''),
|
||||
'last_error' => '',
|
||||
'raw_summary' => $this->encodeRawSummary($result),
|
||||
];
|
||||
if ($source === 'push') {
|
||||
$syncPayload['last_push_at'] = $now;
|
||||
} else {
|
||||
$syncPayload['last_query_at'] = $now;
|
||||
}
|
||||
|
||||
$this->updateSync((int)$sync['id'], $syncPayload);
|
||||
Db::commit();
|
||||
} catch (\Throwable $e) {
|
||||
Db::rollback();
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
|
||||
private function normalizeNodes(array $items): array
|
||||
{
|
||||
$nodes = [];
|
||||
foreach ($items as $item) {
|
||||
if (!is_array($item)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$time = trim((string)($item['time'] ?? $item['ftime'] ?? ''));
|
||||
$desc = trim((string)($item['context'] ?? $item['status'] ?? ''));
|
||||
if ($time === '' || $desc === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$nodes[] = [
|
||||
'node_time' => $time,
|
||||
'node_desc' => $desc,
|
||||
'node_location' => trim((string)($item['areaName'] ?? $item['areaCenter'] ?? $item['location'] ?? '')),
|
||||
];
|
||||
}
|
||||
|
||||
usort($nodes, static fn (array $left, array $right) => strcmp($right['node_time'], $left['node_time']));
|
||||
|
||||
return $nodes;
|
||||
}
|
||||
|
||||
private function extractTrackResult(array $payload): array
|
||||
{
|
||||
if (isset($payload['lastResult']) && is_array($payload['lastResult'])) {
|
||||
return $payload['lastResult'];
|
||||
}
|
||||
if (isset($payload['data']) && is_array($payload['data']) && isset($payload['data']['lastResult']) && is_array($payload['data']['lastResult'])) {
|
||||
return $payload['data']['lastResult'];
|
||||
}
|
||||
|
||||
return $payload;
|
||||
}
|
||||
|
||||
private function ensureSyncRow(array $logistics, string $providerCom = ''): array
|
||||
{
|
||||
$logisticsId = (int)$logistics['id'];
|
||||
$providerCom = $providerCom !== '' ? $providerCom : $this->resolveCompanyCode($logistics);
|
||||
$row = Db::name('order_logistics_syncs')
|
||||
->where('logistics_id', $logisticsId)
|
||||
->where('provider', self::PROVIDER)
|
||||
->find();
|
||||
|
||||
$now = date('Y-m-d H:i:s');
|
||||
if ($row) {
|
||||
if ($providerCom !== '' && (string)($row['provider_com'] ?? '') === '') {
|
||||
Db::name('order_logistics_syncs')->where('id', (int)$row['id'])->update([
|
||||
'provider_com' => $providerCom,
|
||||
'updated_at' => $now,
|
||||
]);
|
||||
$row['provider_com'] = $providerCom;
|
||||
}
|
||||
return $row;
|
||||
}
|
||||
|
||||
$id = (int)Db::name('order_logistics_syncs')->insertGetId([
|
||||
'logistics_id' => $logisticsId,
|
||||
'provider' => self::PROVIDER,
|
||||
'provider_com' => $providerCom,
|
||||
'subscription_status' => '',
|
||||
'provider_state' => '',
|
||||
'provider_status_text' => '',
|
||||
'last_error' => '',
|
||||
'raw_status' => '',
|
||||
'raw_summary' => null,
|
||||
'created_at' => $now,
|
||||
'updated_at' => $now,
|
||||
]);
|
||||
|
||||
return Db::name('order_logistics_syncs')->where('id', $id)->find() ?: [];
|
||||
}
|
||||
|
||||
private function resolveCompanyCode(array $logistics): string
|
||||
{
|
||||
$company = trim((string)($logistics['express_company'] ?? ''));
|
||||
if ($company === '') {
|
||||
return '';
|
||||
}
|
||||
|
||||
$trackingNo = trim((string)($logistics['tracking_no'] ?? ''));
|
||||
$resolved = (new ExpressCompanyService())->resolveCompanyCode($company, $trackingNo);
|
||||
if ($resolved !== '') {
|
||||
return $resolved;
|
||||
}
|
||||
|
||||
$row = Db::name('express_companies')->where('company_name', $company)->find();
|
||||
$code = trim((string)($row['company_code'] ?? ''));
|
||||
$aliasCode = $this->companyAliasCode($company, $code);
|
||||
if ($aliasCode !== '') {
|
||||
return $aliasCode;
|
||||
}
|
||||
|
||||
return preg_match('/^[a-z0-9]+$/', $code) === 1 && !str_starts_with($code, 'express_') ? $code : '';
|
||||
}
|
||||
|
||||
private function companyAliasCode(string $companyName, string $companyCode = ''): string
|
||||
{
|
||||
$aliases = [
|
||||
'顺丰速运' => 'shunfeng',
|
||||
'顺丰' => 'shunfeng',
|
||||
'sf_express' => 'shunfeng',
|
||||
'京东快递' => 'jd',
|
||||
'京东物流' => 'jd',
|
||||
'EMS' => 'ems',
|
||||
'ems' => 'ems',
|
||||
'中通快递' => 'zhongtong',
|
||||
'圆通速递' => 'yuantong',
|
||||
'圆通快递' => 'yuantong',
|
||||
'申通快递' => 'shentong',
|
||||
'韵达快递' => 'yunda',
|
||||
'极兔速递' => 'jtexpress',
|
||||
'极兔快递' => 'jtexpress',
|
||||
'德邦快递' => 'debangwuliu',
|
||||
'邮政快递包裹' => 'youzhengguonei',
|
||||
];
|
||||
|
||||
return $aliases[$companyCode] ?? $aliases[$companyName] ?? '';
|
||||
}
|
||||
|
||||
private function resolvePhoneForLogistics(array $logistics): string
|
||||
{
|
||||
$orderId = (int)($logistics['order_id'] ?? 0);
|
||||
if ($orderId <= 0) {
|
||||
return '';
|
||||
}
|
||||
|
||||
if (($logistics['logistics_type'] ?? '') === 'return_to_user') {
|
||||
$mobile = Db::name('order_return_addresses')->where('order_id', $orderId)->value('mobile');
|
||||
return trim((string)$mobile);
|
||||
}
|
||||
|
||||
$order = Db::name('orders')->where('id', $orderId)->find();
|
||||
if (!$order) {
|
||||
return '';
|
||||
}
|
||||
|
||||
$mobile = Db::name('users')->where('id', (int)$order['user_id'])->value('mobile');
|
||||
return trim((string)$mobile);
|
||||
}
|
||||
|
||||
private function shouldQuery(array $sync): bool
|
||||
{
|
||||
$lastQueryAt = trim((string)($sync['last_query_at'] ?? ''));
|
||||
if ($lastQueryAt === '') {
|
||||
return true;
|
||||
}
|
||||
|
||||
$config = $this->configService->getConfig();
|
||||
return strtotime($lastQueryAt) <= time() - $config['query_min_interval_minutes'] * 60;
|
||||
}
|
||||
|
||||
private function providerStateText(string $state, string $isCheck): string
|
||||
{
|
||||
if ($isCheck === '1') {
|
||||
return '已签收';
|
||||
}
|
||||
|
||||
return match ($state) {
|
||||
'0' => '在途',
|
||||
'1' => '已揽收',
|
||||
'2' => '疑难',
|
||||
'3' => '已签收',
|
||||
'4' => '退签',
|
||||
'5' => '派件中',
|
||||
'6' => '退回中',
|
||||
'7' => '转投',
|
||||
'8' => '清关中',
|
||||
'14' => '拒签',
|
||||
default => $state === '' ? '' : '物流状态 ' . $state,
|
||||
};
|
||||
}
|
||||
|
||||
private function subscriptionSucceeded(array $response): bool
|
||||
{
|
||||
$result = $response['result'] ?? null;
|
||||
$returnCode = (string)($response['returnCode'] ?? $response['status'] ?? '');
|
||||
|
||||
return $result === true || $result === 'true' || $returnCode === '200';
|
||||
}
|
||||
|
||||
private function errorMessageFromResponse(array $response): string
|
||||
{
|
||||
return trim((string)($response['message'] ?? $response['returnMessage'] ?? '快递100返回失败'));
|
||||
}
|
||||
|
||||
private function markError(int $syncId, string $message, array $extra = []): void
|
||||
{
|
||||
$this->updateSync($syncId, array_merge($extra, [
|
||||
'last_error' => mb_substr($message, 0, 500, 'UTF-8'),
|
||||
]));
|
||||
}
|
||||
|
||||
private function updateSync(int $syncId, array $payload): void
|
||||
{
|
||||
if ($syncId <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
$payload['updated_at'] = date('Y-m-d H:i:s');
|
||||
if (isset($payload['last_error'])) {
|
||||
$payload['last_error'] = mb_substr((string)$payload['last_error'], 0, 500, 'UTF-8');
|
||||
}
|
||||
Db::name('order_logistics_syncs')->where('id', $syncId)->update($payload);
|
||||
}
|
||||
|
||||
private function findLogistics(int $logisticsId): ?array
|
||||
{
|
||||
if ($logisticsId <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$row = Db::name('order_logistics')->where('id', $logisticsId)->find();
|
||||
return $row ?: null;
|
||||
}
|
||||
|
||||
private function encodeRawSummary(array $payload): string
|
||||
{
|
||||
$json = json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||
return mb_substr(is_string($json) ? $json : '', 0, 8000, 'UTF-8');
|
||||
}
|
||||
|
||||
private function ensureTable(): void
|
||||
{
|
||||
Db::execute(<<<'SQL'
|
||||
CREATE TABLE IF NOT EXISTS order_logistics_syncs (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
logistics_id BIGINT UNSIGNED NOT NULL,
|
||||
provider VARCHAR(32) NOT NULL DEFAULT 'kuaidi100',
|
||||
provider_com VARCHAR(64) NOT NULL DEFAULT '',
|
||||
subscription_status VARCHAR(32) NOT NULL DEFAULT '',
|
||||
provider_state VARCHAR(32) NOT NULL DEFAULT '',
|
||||
provider_status_text VARCHAR(64) NOT NULL DEFAULT '',
|
||||
last_query_at DATETIME NULL DEFAULT NULL,
|
||||
last_push_at DATETIME NULL DEFAULT NULL,
|
||||
last_error VARCHAR(500) NOT NULL DEFAULT '',
|
||||
raw_status VARCHAR(32) NOT NULL DEFAULT '',
|
||||
raw_summary LONGTEXT NULL,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY uk_order_logistics_syncs_provider (logistics_id, provider),
|
||||
KEY idx_order_logistics_syncs_provider_com (provider, provider_com),
|
||||
KEY idx_order_logistics_syncs_last_query (last_query_at)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='订单物流第三方同步状态'
|
||||
SQL);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user