feat: add kuaidi100 logistics sync

This commit is contained in:
wushumin
2026-05-26 17:08:33 +08:00
parent 09d9fcbe69
commit a5f00d7e31
31 changed files with 2596 additions and 67 deletions

View File

@@ -17,6 +17,54 @@ class ExpressCompaniesController
]);
}
public function catalog(Request $request)
{
$keyword = trim((string)$request->input('keyword', ''));
$limit = max(1, min(100, (int)$request->input('limit', 30)));
return api_success([
'list' => $this->service()->catalogList($keyword, $limit),
'total' => $this->service()->catalogTotal(),
'synced_at' => $this->service()->catalogSyncedAt(),
]);
}
public function syncCatalog(Request $request)
{
try {
$result = $this->service()->syncCatalog();
} catch (\RuntimeException $e) {
return api_error($e->getMessage(), 422);
} catch (\Throwable $e) {
return api_error('快递100公司码表同步失败', 500, [
'detail' => $e->getMessage(),
]);
}
return api_success($result, '快递100公司码表已同步');
}
public function recognize(Request $request)
{
$trackingNo = trim((string)$request->input('tracking_no', ''));
$companyName = trim((string)$request->input('company_name', $request->input('company_code', '')));
if ($trackingNo === '') {
return api_error('运单号不能为空', 422);
}
try {
$result = $this->service()->recognizeCompany($companyName, $trackingNo);
} catch (\RuntimeException $e) {
return api_error($e->getMessage(), 422);
} catch (\Throwable $e) {
return api_error('快递公司识别失败', 500, [
'detail' => $e->getMessage(),
]);
}
return api_success($result);
}
public function save(Request $request)
{
$id = (int)$request->input('id', 0);

View File

@@ -3,8 +3,9 @@
namespace app\controller\admin;
use app\support\AppraisalEvidenceService;
use app\support\MessageDispatcher;
use app\support\EnterpriseWebhookService;
use app\support\MessageDispatcher;
use app\support\OrderLogisticsSyncService;
use app\support\WarehouseService;
use support\Request;
use support\think\Db;
@@ -292,6 +293,17 @@ class OrdersController
->select()
->toArray();
}
$syncService = new OrderLogisticsSyncService();
$sendSyncStatus = $sendLogistics ? $syncService->formatSyncStatus((int)$sendLogistics['id']) : [
'provider_status_text' => '',
'sync_status_text' => '未同步',
'sync_error' => '',
];
$returnSyncStatus = $returnLogistics ? $syncService->formatSyncStatus((int)$returnLogistics['id']) : [
'provider_status_text' => '',
'sync_status_text' => '未同步',
'sync_error' => '',
];
return api_success([
'order_info' => [
@@ -376,23 +388,14 @@ class OrdersController
'tracking_no' => $sendLogistics['tracking_no'],
'tracking_status' => $sendLogistics['tracking_status'],
'tracking_status_text' => $this->trackingStatusText($sendLogistics['tracking_status'], 'send_to_center'),
'latest_desc' => $this->formatAdminLogisticsDesc(
'send_to_center',
$sendLogistics['tracking_status'],
$sendLogistics['express_company'],
$sendLogistics['tracking_no'],
$sendLogistics['latest_desc']
),
'provider_status_text' => $sendSyncStatus['provider_status_text'],
'sync_status_text' => $sendSyncStatus['sync_status_text'],
'sync_error' => $sendSyncStatus['sync_error'],
'latest_desc' => (string)($sendLogistics['latest_desc'] ?? ''),
'latest_time' => $sendLogistics['latest_time'],
'nodes' => array_map(fn (array $item) => [
'node_time' => $item['node_time'],
'node_desc' => $this->formatAdminLogisticsDesc(
'send_to_center',
$sendLogistics['tracking_status'],
$sendLogistics['express_company'],
$sendLogistics['tracking_no'],
$item['node_desc']
),
'node_desc' => $item['node_desc'],
'node_location' => $item['node_location'],
], $logisticsNodes),
] : null,
@@ -402,23 +405,14 @@ class OrdersController
'tracking_no' => $returnLogistics['tracking_no'],
'tracking_status' => $returnLogistics['tracking_status'],
'tracking_status_text' => $this->trackingStatusText($returnLogistics['tracking_status'], 'return_to_user'),
'latest_desc' => $this->formatAdminLogisticsDesc(
'return_to_user',
$returnLogistics['tracking_status'],
$returnLogistics['express_company'],
$returnLogistics['tracking_no'],
$returnLogistics['latest_desc']
),
'provider_status_text' => $returnSyncStatus['provider_status_text'],
'sync_status_text' => $returnSyncStatus['sync_status_text'],
'sync_error' => $returnSyncStatus['sync_error'],
'latest_desc' => (string)($returnLogistics['latest_desc'] ?? ''),
'latest_time' => $returnLogistics['latest_time'],
'nodes' => array_map(fn (array $item) => [
'node_time' => $item['node_time'],
'node_desc' => $this->formatAdminLogisticsDesc(
'return_to_user',
$returnLogistics['tracking_status'],
$returnLogistics['express_company'],
$returnLogistics['tracking_no'],
$item['node_desc']
),
'node_desc' => $item['node_desc'],
'node_location' => $item['node_location'],
], $returnLogisticsNodes),
] : null,
@@ -833,6 +827,7 @@ class OrdersController
'tracking_no' => $trackingNo,
'shipped_at' => $now,
]);
(new OrderLogisticsSyncService())->subscribeAsync($logisticsId);
return api_success([
'id' => $id,

View File

@@ -448,6 +448,30 @@ class SystemConfigsController
['config_key' => 'endpoint', 'title' => '短信 Endpoint', 'field_type' => 'text', 'placeholder' => '默认可留空', 'remark' => '如不填写则按 SDK 默认规则解析', 'is_secret' => false],
],
],
'kuaidi100' => [
'group_name' => '快递100',
'group_desc' => '配置快递100实时查询与物流订阅推送用于订单寄送和回寄物流轨迹同步。',
'items' => [
[
'config_key' => 'enabled',
'title' => '同步开关',
'field_type' => 'select',
'placeholder' => '请选择是否启用',
'remark' => '启用后新提交的运单会尝试订阅快递100推送后台进程会定时补查轨迹。',
'is_secret' => false,
'default_value' => 'disabled',
'options' => [
['label' => '停用', 'value' => 'disabled'],
['label' => '启用', 'value' => 'enabled'],
],
],
['config_key' => 'customer', 'title' => 'Customer', 'field_type' => 'text', 'placeholder' => '请输入快递100 Customer', 'remark' => '实时查询接口签名使用的 Customer。', 'is_secret' => false, 'visible_when' => ['config_key' => 'enabled', 'equals' => 'enabled']],
['config_key' => 'key', 'title' => 'Key', 'field_type' => 'password', 'placeholder' => '请输入快递100 Key', 'remark' => '用于实时查询签名和订阅推送。请妥善保管。', 'is_secret' => true, 'visible_when' => ['config_key' => 'enabled', 'equals' => 'enabled']],
['config_key' => 'callback_url', 'title' => '推送回调地址', 'field_type' => 'text', 'placeholder' => '例如 https://api.example.com/api/open/kuaidi100/callback', 'remark' => '需公网可访问;生产建议填本系统 /api/open/kuaidi100/callback 的完整地址。', 'is_secret' => false, 'visible_when' => ['config_key' => 'enabled', 'equals' => 'enabled']],
['config_key' => 'callback_salt', 'title' => '回调 Salt', 'field_type' => 'password', 'placeholder' => '可选需与快递100订阅参数保持一致', 'remark' => '用于快递100推送签名增强如账号未配置可留空。', 'is_secret' => true, 'visible_when' => ['config_key' => 'enabled', 'equals' => 'enabled']],
['config_key' => 'query_min_interval_minutes', 'title' => '最小查询间隔(分钟)', 'field_type' => 'text', 'placeholder' => '默认 30', 'remark' => '定时补查同一运单的最小间隔,允许 5-1440。', 'is_secret' => false, 'default_value' => '30', 'visible_when' => ['config_key' => 'enabled', 'equals' => 'enabled']],
],
],
];
}
@@ -467,6 +491,7 @@ class SystemConfigsController
{
$driver = (new FileStorageConfigService())->normalizeDriver((string)($configValueMap['file_storage.driver'] ?? 'local'));
if ($driver === 'local') {
$this->validateKuaidi100Config($configValueMap);
return;
}
@@ -489,10 +514,12 @@ class SystemConfigsController
throw new \RuntimeException('直传文件大小上限需填写 1-2048 之间的整数');
}
$this->validateKuaidi100Config($configValueMap);
return;
}
if ($driver !== 'qiniu') {
$this->validateKuaidi100Config($configValueMap);
return;
}
@@ -513,6 +540,35 @@ class SystemConfigsController
if ($publicBaseUrl === '' && $bucketDomain === '') {
throw new \RuntimeException('当前已切换为七牛云存储,请至少填写公开访问域名或七牛公网访问域名');
}
$this->validateKuaidi100Config($configValueMap);
}
private function validateKuaidi100Config(array $configValueMap): void
{
$enabled = (string)($configValueMap['kuaidi100.enabled'] ?? 'disabled');
if (!in_array($enabled, ['enabled', 'disabled'], true)) {
throw new \RuntimeException('快递100同步开关配置无效');
}
if ($enabled !== 'enabled') {
return;
}
$required = [
'kuaidi100.customer' => '快递100 Customer',
'kuaidi100.key' => '快递100 Key',
'kuaidi100.callback_url' => '快递100推送回调地址',
];
foreach ($required as $key => $label) {
if (trim((string)($configValueMap[$key] ?? '')) === '') {
throw new \RuntimeException(sprintf('当前已启用快递100请先填写 %s', $label));
}
}
$interval = trim((string)($configValueMap['kuaidi100.query_min_interval_minutes'] ?? '30'));
if ($interval !== '' && (!ctype_digit($interval) || (int)$interval < 5 || (int)$interval > 1440)) {
throw new \RuntimeException('快递100最小查询间隔需填写 5-1440 之间的整数');
}
}
private function applyDerivedConfigValues(array &$configValueMap): void

View File

@@ -7,6 +7,7 @@ use app\model\OrderProduct;
use app\model\OrderSupplementTask;
use app\model\OrderSupplementTaskItem;
use app\model\OrderTimeline;
use app\support\OrderLogisticsSyncService;
use app\support\PublicAssetUrlService;
use support\Request;
use support\think\Db;
@@ -208,6 +209,11 @@ class OrdersController
->select()
->toArray();
}
$returnSyncStatus = $returnLogistics ? (new OrderLogisticsSyncService())->formatSyncStatus((int)$returnLogistics['id']) : [
'provider_status_text' => '',
'sync_status_text' => '未同步',
'sync_error' => '',
];
return api_success([
'order_info' => [
@@ -261,6 +267,9 @@ class OrdersController
'tracking_no' => $returnLogistics['tracking_no'],
'tracking_status' => $returnLogistics['tracking_status'],
'tracking_status_text' => $this->trackingStatusText((string)$returnLogistics['tracking_status'], 'return_to_user'),
'provider_status_text' => $returnSyncStatus['provider_status_text'],
'sync_status_text' => $returnSyncStatus['sync_status_text'],
'sync_error' => $returnSyncStatus['sync_error'],
'latest_desc' => $returnLogistics['latest_desc'],
'latest_time' => $returnLogistics['latest_time'],
'nodes' => array_map(fn (array $item) => [

View File

@@ -2,6 +2,8 @@
namespace app\controller\app;
use app\support\ExpressCompanyService;
use app\support\OrderLogisticsSyncService;
use app\support\WarehouseService;
use support\Request;
use support\think\Db;
@@ -35,6 +37,11 @@ class ShippingController
->select()
->toArray();
}
$syncStatus = $logistics ? (new OrderLogisticsSyncService())->formatSyncStatus((int)$logistics['id']) : [
'provider_status_text' => '',
'sync_status_text' => '未同步',
'sync_error' => '',
];
$warehouseService = new WarehouseService();
$categoryId = (int)($product['category_id'] ?? 0);
@@ -76,6 +83,9 @@ class ShippingController
'tracking_no' => $logistics['tracking_no'] ?? '',
'tracking_status' => $logistics['tracking_status'] ?? '',
'tracking_status_text' => $this->trackingStatusText((string)($logistics['tracking_status'] ?? '')),
'provider_status_text' => $syncStatus['provider_status_text'],
'sync_status_text' => $syncStatus['sync_status_text'],
'sync_error' => $syncStatus['sync_error'],
'latest_desc' => $logistics['latest_desc'] ?? '',
'latest_time' => $logistics['latest_time'] ?? '',
'is_submitted' => $trackingSubmitted,
@@ -89,6 +99,27 @@ class ShippingController
]);
}
public function recognize(Request $request)
{
$trackingNo = trim((string)$request->input('tracking_no', ''));
$companyName = trim((string)$request->input('company_name', $request->input('company_code', '')));
if ($trackingNo === '') {
return api_error('运单号不能为空', 422);
}
try {
$result = (new ExpressCompanyService())->recognizeCompany($companyName, $trackingNo);
} catch (\RuntimeException $e) {
return api_error($e->getMessage(), 422);
} catch (\Throwable $e) {
return api_error('快递公司识别失败', 500, [
'detail' => $e->getMessage(),
]);
}
return api_success($result);
}
public function save(Request $request)
{
$orderId = (int)$request->input('order_id', 0);
@@ -257,6 +288,8 @@ class ShippingController
]);
}
(new OrderLogisticsSyncService())->subscribeAsync($logisticsId);
return api_success([
'order_id' => $orderId,
'express_company' => $expressCompany,

View File

@@ -0,0 +1,43 @@
<?php
namespace app\controller\open;
use app\support\OrderLogisticsSyncService;
use support\Request;
class Kuaidi100Controller
{
public function callback(Request $request)
{
$payload = json_decode($request->rawBody(), true);
if (!is_array($payload)) {
$payload = $request->all();
}
if (isset($payload['param']) && is_string($payload['param'])) {
$paramPayload = json_decode($payload['param'], true);
if (is_array($paramPayload)) {
$payload = $paramPayload;
}
}
if (!is_array($payload)) {
return $this->callbackResponse(false, '400', '请求体格式错误');
}
try {
(new OrderLogisticsSyncService())->handleCallback($payload);
} catch (\Throwable $e) {
return $this->callbackResponse(false, '500', $e->getMessage());
}
return $this->callbackResponse(true, '200', '成功');
}
private function callbackResponse(bool $result, string $returnCode, string $message)
{
return json([
'result' => $result,
'returnCode' => $returnCode,
'message' => $message,
]);
}
}