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,
]);
}
}

View File

@@ -72,6 +72,7 @@ class AdminAuthMiddleware implements MiddlewareInterface
str_starts_with($path, '/api/admin/customer/') => ['customers.manage'],
str_starts_with($path, '/api/admin/warehouse-workbench/') => ['warehouse_workbench.manage'],
str_starts_with($path, '/api/admin/express-companies') && strtoupper($method) === 'GET' => ['warehouse_workbench.manage', 'orders.manage', 'warehouses.manage'],
str_starts_with($path, '/api/admin/express-company/recognize') => ['warehouse_workbench.manage', 'orders.manage', 'warehouses.manage'],
str_starts_with($path, '/api/admin/express-company/') => ['warehouses.manage'],
str_starts_with($path, '/api/admin/warehouses'),
str_starts_with($path, '/api/admin/warehouse/') => ['warehouses.manage'],

View File

@@ -0,0 +1,26 @@
<?php
namespace app\process;
use app\support\OrderLogisticsSyncService;
use support\Log;
use Workerman\Timer;
class Kuaidi100LogisticsSyncProcess
{
public function __construct()
{
Timer::add(600, [$this, 'syncDue'], [], true);
}
public function syncDue(): void
{
try {
(new OrderLogisticsSyncService())->syncDue(50);
} catch (\Throwable $e) {
Log::warning('kuaidi100 logistics sync process failed', [
'message' => $e->getMessage(),
]);
}
}
}

View File

@@ -2,6 +2,7 @@
namespace app\support;
use support\Log;
use support\think\Db;
class ExpressCompanyService
@@ -9,6 +10,7 @@ class ExpressCompanyService
public function __construct()
{
$this->ensureTable();
$this->ensureCatalogTable();
$this->bootstrapDefaults();
}
@@ -118,6 +120,414 @@ class ExpressCompanyService
return trim((string)($row['company_name'] ?? ''));
}
public function catalogList(string $keyword = '', int $limit = 30): array
{
$keyword = trim($keyword);
$limit = max(1, min(100, $limit));
$this->ensureCatalogTable();
$query = Db::name('kuaidi100_express_company_catalog')
->order('sort_order', 'asc')
->order('company_name', 'asc')
->order('id', 'asc');
if ($keyword !== '') {
$like = '%' . $keyword . '%';
$query->whereRaw('(company_name LIKE :keyword OR company_code LIKE :keyword OR company_type LIKE :keyword)', [
'keyword' => $like,
]);
}
$rows = $query->limit($limit)->select()->toArray();
if (!$rows) {
$rows = $this->localCompanyFallbackList($keyword, $limit);
}
return array_map(fn (array $item) => $this->formatCatalog($item), $rows);
}
public function catalogTotal(): int
{
$this->ensureCatalogTable();
return (int)Db::name('kuaidi100_express_company_catalog')->count();
}
public function catalogSyncedAt(): string
{
$this->ensureCatalogTable();
$value = Db::name('kuaidi100_express_company_catalog')
->order('synced_at', 'desc')
->value('synced_at');
$value = trim((string)($value ?? ''));
return $value === '0' ? '' : $value;
}
public function syncCatalog(): array
{
$client = new Kuaidi100Client();
$binary = $client->downloadCompanyCatalogWorkbook();
$tmpFile = tempnam(sys_get_temp_dir(), 'kdbm_');
if ($tmpFile === false) {
throw new \RuntimeException('快递100公司码表临时文件创建失败');
}
file_put_contents($tmpFile, $binary);
$now = date('Y-m-d H:i:s');
Db::startTrans();
try {
$rows = $this->parseCatalogWorkbook($tmpFile);
if (!$rows) {
throw new \RuntimeException('快递100公司码表解析结果为空');
}
$existingCodes = Db::name('kuaidi100_express_company_catalog')
->column('company_code');
$existingCodeMap = array_fill_keys(array_map('strval', $existingCodes), true);
$inserted = 0;
$updated = 0;
$payloadRows = [];
foreach ($rows as $index => $item) {
$payload = [
'company_name' => $item['company_name'],
'company_code' => $item['company_code'],
'company_type' => $item['company_type'],
'sort_order' => (int)($item['sort_order'] ?? $index + 1),
'synced_at' => $now,
'created_at' => $now,
'updated_at' => $now,
];
if (isset($existingCodeMap[$payload['company_code']])) {
$updated++;
} else {
$inserted++;
}
$payloadRows[$payload['company_code']] = $payload;
}
foreach (array_chunk(array_values($payloadRows), 300) as $chunkIndex => $chunk) {
$valuesSql = [];
$bindings = [];
foreach ($chunk as $rowIndex => $payload) {
$prefix = 'r' . $chunkIndex . '_' . $rowIndex;
$valuesSql[] = sprintf(
'(:%s_company_name, :%s_company_code, :%s_company_type, :%s_sort_order, :%s_synced_at, :%s_created_at, :%s_updated_at)',
$prefix,
$prefix,
$prefix,
$prefix,
$prefix,
$prefix,
$prefix
);
foreach ($payload as $field => $value) {
$bindings[$prefix . '_' . $field] = $value;
}
}
Db::execute(
'INSERT INTO kuaidi100_express_company_catalog '
. '(company_name, company_code, company_type, sort_order, synced_at, created_at, updated_at) VALUES '
. implode(',', $valuesSql)
. ' ON DUPLICATE KEY UPDATE '
. 'company_name = VALUES(company_name), '
. 'company_type = VALUES(company_type), '
. 'sort_order = VALUES(sort_order), '
. 'synced_at = VALUES(synced_at), '
. 'updated_at = VALUES(updated_at)',
$bindings
);
}
$backfilled = $this->backfillLocalCompanyCodesFromCatalog($now);
Db::commit();
} catch (\Throwable $e) {
Db::rollback();
throw $e;
} finally {
@unlink($tmpFile);
}
return [
'total' => count($rows),
'inserted' => $inserted ?? 0,
'updated' => $updated ?? 0,
'backfilled' => $backfilled ?? 0,
'synced_at' => $now,
];
}
public function resolveCompanyCode(string $companyNameOrCode, string $trackingNo = ''): string
{
$result = $this->recognizeCompany($companyNameOrCode, $trackingNo);
return (string)($result['company_code'] ?? '');
}
public function recognizeCompany(string $companyNameOrCode, string $trackingNo = ''): array
{
$companyNameOrCode = trim($companyNameOrCode);
$trackingNo = trim($trackingNo);
$recognitionError = '';
$resolved = $this->resolveLocalCompanyMatch($companyNameOrCode);
if (!$resolved && $trackingNo !== '') {
try {
$candidates = $this->recognizeKuaidi100Candidates($trackingNo);
} catch (\Throwable $e) {
$candidates = [];
$recognitionError = $e->getMessage();
}
$resolved = $this->pickRecognizedCandidate($candidates, $companyNameOrCode);
return [
'input' => $companyNameOrCode,
'tracking_no' => $trackingNo,
'company_code' => (string)($resolved['company_code'] ?? ''),
'company_name' => (string)($resolved['company_name'] ?? $companyNameOrCode),
'status' => $resolved ? 'resolved' : ($candidates ? 'multiple' : 'none'),
'status_text' => $resolved ? '已识别' : ($candidates ? '识别到多个候选' : '未识别'),
'error_message' => $recognitionError,
'resolved' => $resolved,
'candidates' => $candidates,
];
}
if ($resolved) {
return [
'input' => $companyNameOrCode,
'tracking_no' => $trackingNo,
'company_code' => (string)($resolved['company_code'] ?? ''),
'company_name' => (string)($resolved['company_name'] ?? $companyNameOrCode),
'status' => 'resolved',
'status_text' => '已识别',
'error_message' => '',
'resolved' => $resolved,
'candidates' => [$resolved],
];
}
return [
'input' => $companyNameOrCode,
'tracking_no' => $trackingNo,
'company_code' => '',
'company_name' => $companyNameOrCode,
'status' => 'none',
'status_text' => '未识别',
'error_message' => $recognitionError,
'resolved' => null,
'candidates' => [],
];
}
public function resolveCompanyNameByCode(string $companyCode): string
{
$companyCode = trim($companyCode);
if ($companyCode === '') {
return '';
}
$row = Db::name('express_companies')
->where('company_code', $companyCode)
->find();
$name = trim((string)($row['company_name'] ?? ''));
if ($name !== '') {
return $name;
}
$row = Db::name('kuaidi100_express_company_catalog')
->where('company_code', $companyCode)
->find();
$name = trim((string)($row['company_name'] ?? ''));
if ($name !== '') {
return $name;
}
return $this->companyAliasName($companyCode);
}
private function resolveLocalCompanyMatch(string $companyNameOrCode): ?array
{
$companyNameOrCode = trim($companyNameOrCode);
if ($companyNameOrCode === '') {
return null;
}
$row = Db::name('express_companies')
->where('company_name', $companyNameOrCode)
->find();
$code = trim((string)($row['company_code'] ?? ''));
if ($this->isValidCompanyCode($code)) {
return [
'company_name' => (string)($row['company_name'] ?? $companyNameOrCode),
'company_code' => $code,
'source' => 'local',
];
}
$row = Db::name('express_companies')
->where('company_code', $companyNameOrCode)
->find();
$code = trim((string)($row['company_code'] ?? ''));
if ($this->isValidCompanyCode($code)) {
$name = trim((string)($row['company_name'] ?? ''));
if ($name === '') {
$name = $this->resolveCompanyNameByCode($code);
}
if ($name === '') {
$name = $companyNameOrCode;
}
return [
'company_name' => $name,
'company_code' => $code,
'source' => 'local',
];
}
$catalogRow = Db::name('kuaidi100_express_company_catalog')
->where('company_name', $companyNameOrCode)
->find();
$code = trim((string)($catalogRow['company_code'] ?? ''));
if ($this->isValidCompanyCode($code)) {
return [
'company_name' => (string)($catalogRow['company_name'] ?? $companyNameOrCode),
'company_code' => $code,
'source' => 'catalog',
];
}
$catalogRow = Db::name('kuaidi100_express_company_catalog')
->where('company_code', $companyNameOrCode)
->find();
$code = trim((string)($catalogRow['company_code'] ?? ''));
if ($this->isValidCompanyCode($code)) {
$name = trim((string)($catalogRow['company_name'] ?? ''));
if ($name === '') {
$name = $this->resolveCompanyNameByCode($code);
}
if ($name === '') {
$name = $companyNameOrCode;
}
return [
'company_name' => $name,
'company_code' => $code,
'source' => 'catalog',
];
}
$aliasCode = $this->companyAliasCode($companyNameOrCode);
if ($aliasCode !== '') {
return [
'company_name' => $this->resolveCompanyNameByCode($aliasCode) ?: $companyNameOrCode,
'company_code' => $aliasCode,
'source' => 'alias',
];
}
return $this->isValidCompanyCode($companyNameOrCode)
? [
'company_name' => $this->resolveCompanyNameByCode($companyNameOrCode) ?: $companyNameOrCode,
'company_code' => $companyNameOrCode,
'source' => 'code',
]
: null;
}
private function recognizeKuaidi100Candidates(string $trackingNo): array
{
$client = new Kuaidi100Client();
$response = $client->recognize($trackingNo);
$items = $this->extractRecognizeItems($response);
if (!$items) {
return [];
}
$candidates = [];
foreach ($items as $item) {
$candidate = $this->normalizeRecognizeCandidate($item);
if ($candidate) {
$candidates[] = $candidate;
}
}
return $candidates;
}
private function extractRecognizeItems(array $response): array
{
if (isset($response['data']) && is_array($response['data'])) {
$items = $response['data'];
} else {
$items = $response;
}
if ($this->isAssocArray($items) && (isset($items['comCode']) || isset($items['company_code']))) {
return [$items];
}
return array_values(array_filter($items, static fn ($item) => is_array($item)));
}
private function normalizeRecognizeCandidate(array $item): ?array
{
$companyCode = trim((string)($item['comCode'] ?? $item['company_code'] ?? $item['code'] ?? ''));
$officialName = trim((string)($item['name'] ?? $item['company_name'] ?? $item['comName'] ?? ''));
if ($companyCode === '' && $officialName === '') {
return null;
}
$localName = $companyCode !== '' ? $this->resolveCompanyNameByCode($companyCode) : '';
$companyName = $localName !== '' ? $localName : ($officialName !== '' ? $officialName : $companyCode);
return [
'company_name' => $companyName,
'company_code' => $companyCode,
'official_name' => $officialName,
'display_text' => $companyName !== '' && $companyCode !== ''
? sprintf('%s / %s', $companyName, $companyCode)
: ($companyName !== '' ? $companyName : $companyCode),
'length_pre' => (int)($item['lengthPre'] ?? $item['length_pre'] ?? 0),
'source' => $localName !== '' ? 'catalog' : 'kuaidi100',
];
}
private function pickRecognizedCandidate(array $candidates, string $companyNameOrCode): ?array
{
if (!$candidates) {
return null;
}
if (count($candidates) === 1) {
return $candidates[0];
}
$companyNameOrCode = trim($companyNameOrCode);
if ($companyNameOrCode === '') {
return null;
}
foreach ($candidates as $candidate) {
if (!is_array($candidate)) {
continue;
}
$candidateName = trim((string)($candidate['company_name'] ?? ''));
$candidateCode = trim((string)($candidate['company_code'] ?? ''));
if ($candidateName === $companyNameOrCode || $candidateCode === $companyNameOrCode) {
return $candidate;
}
}
return null;
}
private function isAssocArray(array $items): bool
{
return array_keys($items) !== range(0, count($items) - 1);
}
private function format(array $item): array
{
$status = (string)($item['status'] ?? 'enabled');
@@ -136,21 +546,63 @@ class ExpressCompanyService
];
}
private function formatCatalog(array $item): array
{
$source = (string)($item['source'] ?? 'kuaidi100');
$companyName = (string)($item['company_name'] ?? '');
$companyCode = (string)($item['company_code'] ?? '');
$companyType = (string)($item['company_type'] ?? '');
return [
'id' => (int)($item['id'] ?? 0),
'company_name' => $companyName,
'company_code' => $companyCode,
'company_type' => $companyType,
'display_text' => $companyType !== ''
? sprintf('%s / %s / %s', $companyName, $companyCode, $companyType)
: sprintf('%s / %s', $companyName, $companyCode),
'source' => $source,
'synced_at' => (string)($item['synced_at'] ?? ''),
];
}
private function bootstrapDefaults(): void
{
$now = date('Y-m-d H:i:s');
$exists = Db::name('express_companies')->where('company_name', '顺丰速运')->find();
if (!$exists) {
Db::name('express_companies')->insert([
'company_name' => '顺丰速运',
'company_code' => 'sf_express',
'status' => 'enabled',
'is_default' => 1,
'sort_order' => 1,
'remark' => '系统默认快递公司',
'created_at' => $now,
'updated_at' => $now,
]);
$defaults = [
['company_name' => '顺丰速运', 'company_code' => 'shunfeng', 'sort_order' => 1, 'is_default' => 1],
['company_name' => '京东快递', 'company_code' => 'jd', 'sort_order' => 2, 'is_default' => 0],
['company_name' => 'EMS', 'company_code' => 'ems', 'sort_order' => 3, 'is_default' => 0],
['company_name' => '中通快递', 'company_code' => 'zhongtong', 'sort_order' => 4, 'is_default' => 0],
['company_name' => '圆通速递', 'company_code' => 'yuantong', 'sort_order' => 5, 'is_default' => 0],
['company_name' => '申通快递', 'company_code' => 'shentong', 'sort_order' => 6, 'is_default' => 0],
['company_name' => '韵达快递', 'company_code' => 'yunda', 'sort_order' => 7, 'is_default' => 0],
['company_name' => '极兔速递', 'company_code' => 'jtexpress', 'sort_order' => 8, 'is_default' => 0],
];
foreach ($defaults as $default) {
$exists = Db::name('express_companies')->where('company_name', $default['company_name'])->find();
if (!$exists) {
Db::name('express_companies')->insert([
'company_name' => $default['company_name'],
'company_code' => $default['company_code'],
'status' => 'enabled',
'is_default' => $default['is_default'],
'sort_order' => $default['sort_order'],
'remark' => '系统默认快递公司',
'created_at' => $now,
'updated_at' => $now,
]);
continue;
}
$currentCode = (string)($exists['company_code'] ?? '');
if ($currentCode === '' || $currentCode === 'sf_express' || str_starts_with($currentCode, 'express_')) {
Db::name('express_companies')->where('id', $exists['id'])->update([
'company_code' => $default['company_code'],
'updated_at' => $now,
]);
}
}
$this->ensureEnabledDefault($now);
@@ -179,9 +631,289 @@ class ExpressCompanyService
}
}
private function generateCompanyCode(string $companyName): string
private function backfillLocalCompanyCodesFromCatalog(string $now): int
{
return 'express_' . substr(hash('sha256', $companyName), 0, 12);
$catalogRows = Db::name('kuaidi100_express_company_catalog')
->select()
->toArray();
if (!$catalogRows) {
return 0;
}
$catalogMap = [];
foreach ($catalogRows as $row) {
$name = trim((string)($row['company_name'] ?? ''));
$code = trim((string)($row['company_code'] ?? ''));
if ($name === '' || !$this->isValidCompanyCode($code)) {
continue;
}
$catalogMap[$name] = $code;
}
if (!$catalogMap) {
return 0;
}
$updated = 0;
$rows = Db::name('express_companies')->select()->toArray();
foreach ($rows as $row) {
$companyName = trim((string)($row['company_name'] ?? ''));
$currentCode = trim((string)($row['company_code'] ?? ''));
if ($companyName === '' || !isset($catalogMap[$companyName])) {
continue;
}
if (!$this->shouldBackfillCompanyCode($currentCode)) {
continue;
}
Db::name('express_companies')->where('id', (int)$row['id'])->update([
'company_code' => $catalogMap[$companyName],
'updated_at' => $now,
]);
$updated++;
}
return $updated;
}
private function shouldBackfillCompanyCode(string $currentCode): bool
{
return $currentCode === '' || $currentCode === 'sf_express' || str_starts_with($currentCode, 'express_');
}
private function localCompanyFallbackList(string $keyword, int $limit): array
{
$query = Db::name('express_companies')
->order('is_default', 'desc')
->order('sort_order', 'asc')
->order('id', 'asc');
if ($keyword !== '') {
$like = '%' . $keyword . '%';
$query->whereRaw('(company_name LIKE :keyword OR company_code LIKE :keyword)', [
'keyword' => $like,
]);
}
$rows = $query->limit($limit)->select()->toArray();
return array_map(static fn (array $item) => [
'id' => (int)$item['id'],
'company_name' => (string)$item['company_name'],
'company_code' => (string)$item['company_code'],
'company_type' => '本地快递公司',
'source' => 'local',
'synced_at' => '',
], $rows);
}
private function parseCatalogWorkbook(string $filePath): array
{
$zip = new \ZipArchive();
if ($zip->open($filePath) !== true) {
throw new \RuntimeException('快递100公司码表压缩包打开失败');
}
$sheetXml = $zip->getFromName('xl/worksheets/sheet1.xml');
if (!is_string($sheetXml) || $sheetXml === '') {
$zip->close();
throw new \RuntimeException('快递100公司码表缺少工作表数据');
}
$sharedStrings = [];
$sharedStringsXml = $zip->getFromName('xl/sharedStrings.xml');
if (is_string($sharedStringsXml) && $sharedStringsXml !== '') {
$sharedStrings = $this->readSharedStrings($sharedStringsXml);
}
$zip->close();
$xml = simplexml_load_string($sheetXml, 'SimpleXMLElement', LIBXML_NOCDATA);
if ($xml === false) {
throw new \RuntimeException('快递100公司码表工作表解析失败');
}
$xml->registerXPathNamespace('x', 'http://schemas.openxmlformats.org/spreadsheetml/2006/main');
$rows = $xml->xpath('//x:sheetData/x:row') ?: [];
$headerMap = [];
$parsedRows = [];
foreach ($rows as $row) {
$values = [];
foreach ($row->c as $cell) {
$columnIndex = $this->columnIndexFromCellRef((string)$cell['r']);
if ($columnIndex <= 0) {
continue;
}
$values[$columnIndex] = $this->readXlsxCellValue($cell, $sharedStrings);
}
if (!$values) {
continue;
}
if (!$headerMap) {
$headerMap = $this->buildCatalogHeaderMap($values);
if ($headerMap) {
continue;
}
}
$companyName = trim((string)($values[$headerMap['company_name'] ?? 1] ?? ''));
$companyCode = trim((string)($values[$headerMap['company_code'] ?? 2] ?? ''));
$companyType = trim((string)($values[$headerMap['company_type'] ?? 3] ?? ''));
if ($companyName === '' || !$this->isValidCompanyCode($companyCode)) {
continue;
}
$parsedRows[] = [
'company_name' => $companyName,
'company_code' => $companyCode,
'company_type' => $companyType,
'sort_order' => count($parsedRows) + 1,
];
}
return $parsedRows;
}
private function buildCatalogHeaderMap(array $values): array
{
$map = [];
foreach ($values as $columnIndex => $value) {
$text = trim((string)$value);
if ($text === '') {
continue;
}
if (in_array($text, ['公司名称', '快递公司名称', '名称'], true)) {
$map['company_name'] = $columnIndex;
} elseif (in_array($text, ['公司编码', '快递公司编码', '编码', 'code'], true)) {
$map['company_code'] = $columnIndex;
} elseif (in_array($text, ['公司类型', '类型'], true)) {
$map['company_type'] = $columnIndex;
}
}
if (!$map) {
return [];
}
$map['company_name'] ??= 1;
$map['company_code'] ??= 2;
$map['company_type'] ??= 3;
return $map;
}
private function readSharedStrings(string $xml): array
{
$document = simplexml_load_string($xml, 'SimpleXMLElement', LIBXML_NOCDATA);
if ($document === false) {
throw new \RuntimeException('快递100公司码表共享字符串解析失败');
}
$document->registerXPathNamespace('x', 'http://schemas.openxmlformats.org/spreadsheetml/2006/main');
$items = $document->xpath('//x:si') ?: [];
$values = [];
foreach ($items as $item) {
$text = '';
foreach ($item->xpath('.//x:t') ?: [] as $textNode) {
$text .= (string)$textNode;
}
$values[] = $text;
}
return $values;
}
private function readXlsxCellValue(\SimpleXMLElement $cell, array $sharedStrings): string
{
$type = (string)$cell['t'];
if ($type === 'inlineStr') {
$text = '';
foreach ($cell->xpath('.//*[local-name()="t"]') ?: [] as $textNode) {
$text .= (string)$textNode;
}
return trim($text);
}
if ($type === 's') {
$index = (int)($cell->v ?? 0);
return trim((string)($sharedStrings[$index] ?? ''));
}
if (isset($cell->v)) {
return trim((string)$cell->v);
}
return trim((string)$cell);
}
private function columnIndexFromCellRef(string $cellRef): int
{
if (!preg_match('/^([A-Z]+)\d+$/i', $cellRef, $matches)) {
return 0;
}
$letters = strtoupper($matches[1]);
$index = 0;
$length = strlen($letters);
for ($i = 0; $i < $length; $i++) {
$index = $index * 26 + (ord($letters[$i]) - 64);
}
return $index;
}
private function isValidCompanyCode(string $code): bool
{
return $code !== '' && preg_match('/^[a-z0-9]+$/', $code) === 1 && !str_starts_with($code, 'express_');
}
private function companyAliasCode(string $companyName): string
{
$aliases = [
'顺丰速运' => 'shunfeng',
'顺丰' => 'shunfeng',
'sf_express' => 'shunfeng',
'京东快递' => 'jd',
'京东物流' => 'jd',
'EMS' => 'ems',
'ems' => 'ems',
'中通快递' => 'zhongtong',
'圆通速递' => 'yuantong',
'圆通快递' => 'yuantong',
'申通快递' => 'shentong',
'韵达快递' => 'yunda',
'极兔速递' => 'jtexpress',
'极兔快递' => 'jtexpress',
'德邦快递' => 'debangwuliu',
'邮政快递包裹' => 'youzhengguonei',
];
return $aliases[$companyName] ?? '';
}
private function companyAliasName(string $companyCode): string
{
$aliases = [
'shunfeng' => '顺丰速运',
'sf_express' => '顺丰速运',
'jd' => '京东快递',
'ems' => 'EMS',
'zhongtong' => '中通快递',
'yuantong' => '圆通速递',
'shentong' => '申通快递',
'yunda' => '韵达快递',
'jtexpress' => '极兔速递',
'debangwuliu' => '德邦快递',
'youzhengguonei' => '邮政快递包裹',
];
return $aliases[$companyCode] ?? '';
}
private function ensureTable(): void
@@ -203,6 +935,27 @@ CREATE TABLE IF NOT EXISTS express_companies (
KEY idx_express_companies_status (status),
KEY idx_express_companies_default (is_default)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='快递公司字典'
SQL);
}
private function ensureCatalogTable(): void
{
Db::execute(<<<'SQL'
CREATE TABLE IF NOT EXISTS kuaidi100_express_company_catalog (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
company_name VARCHAR(64) NOT NULL DEFAULT '',
company_code VARCHAR(64) NOT NULL DEFAULT '',
company_type VARCHAR(64) NOT NULL DEFAULT '',
sort_order INT NOT NULL DEFAULT 0,
synced_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
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_kuaidi100_express_company_catalog_code (company_code),
KEY idx_kuaidi100_express_company_catalog_name (company_name),
KEY idx_kuaidi100_express_company_catalog_type (company_type),
KEY idx_kuaidi100_express_company_catalog_synced_at (synced_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='快递100官方公司码表'
SQL);
}
}

View File

@@ -501,6 +501,7 @@ class FulfillmentFlowService
'tracking_no' => $trackingNo,
'shipped_at' => $now,
]);
(new OrderLogisticsSyncService())->subscribeAsync($logisticsId);
return $this->formatOrderContext($orderId);
}
@@ -535,6 +536,9 @@ class FulfillmentFlowService
$product = Db::name('order_products')->where('order_id', $orderId)->find() ?: [];
$sendLogistics = Db::name('order_logistics')->where('order_id', $orderId)->where('logistics_type', 'send_to_center')->order('id', 'desc')->find();
$returnLogistics = Db::name('order_logistics')->where('order_id', $orderId)->where('logistics_type', 'return_to_user')->order('id', 'desc')->find();
$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' => ''];
$flow = Db::name('order_transfer_flows')->where('order_id', $orderId)->order('id', 'desc')->find();
$report = $this->latestReport($orderId);
$content = $report ? Db::name('report_contents')->where('report_id', (int)$report['id'])->find() : null;
@@ -570,6 +574,17 @@ class FulfillmentFlowService
'express_company' => (string)$sendLogistics['express_company'],
'tracking_no' => (string)$sendLogistics['tracking_no'],
'tracking_status' => (string)$sendLogistics['tracking_status'],
'tracking_status_text' => $this->trackingStatusText((string)$sendLogistics['tracking_status'], 'send_to_center'),
'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' => (string)($sendLogistics['latest_time'] ?? ''),
'nodes' => array_map(fn (array $item) => [
'node_time' => (string)$item['node_time'],
'node_desc' => (string)$item['node_desc'],
'node_location' => (string)$item['node_location'],
], $syncService->nodesForLogistics((int)$sendLogistics['id'])),
] : null,
'return_address' => $returnAddress ? [
'consignee' => (string)($returnAddress['consignee'] ?? ''),
@@ -580,6 +595,17 @@ class FulfillmentFlowService
'express_company' => (string)$returnLogistics['express_company'],
'tracking_no' => (string)$returnLogistics['tracking_no'],
'tracking_status' => (string)$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' => (string)($returnLogistics['latest_desc'] ?? ''),
'latest_time' => (string)($returnLogistics['latest_time'] ?? ''),
'nodes' => array_map(fn (array $item) => [
'node_time' => (string)$item['node_time'],
'node_desc' => (string)$item['node_desc'],
'node_location' => (string)$item['node_location'],
], $syncService->nodesForLogistics((int)$returnLogistics['id'])),
] : null,
'transfer_flow' => $flow ? $this->formatFlow($flow) : null,
'report_info' => $report ? [
@@ -994,6 +1020,25 @@ class FulfillmentFlowService
};
}
private function trackingStatusText(string $status, string $logisticsType): string
{
if ($logisticsType === 'return_to_user') {
return match ($status) {
'submitted' => '已登记回寄运单',
'in_transit' => '回寄途中',
'received' => '用户已签收',
default => $status === '' ? '待回寄' : $status,
};
}
return match ($status) {
'submitted' => '用户已提交运单',
'in_transit' => '用户已寄出,运输中',
'received' => '鉴定中心已签收',
default => $status === '' ? '待提交' : $status,
};
}
private function decodeJsonArray(mixed $value): array
{
if (is_array($value)) {

View File

@@ -0,0 +1,187 @@
<?php
namespace app\support;
class Kuaidi100Client
{
private const QUERY_URL = 'https://poll.kuaidi100.com/poll/query.do';
private const SUBSCRIBE_URL = 'https://poll.kuaidi100.com/poll';
private const AUTONUMBER_URL = 'http://www.kuaidi100.com/autonumber/auto';
private const COMPANY_CATALOG_URL = 'http://api.kuaidi100.com/manager/openapi/download/kdbm.do';
public function __construct(private ?Kuaidi100ConfigService $configService = null)
{
$this->configService ??= new Kuaidi100ConfigService();
}
public function query(string $companyCode, string $trackingNo, string $phone = ''): array
{
$config = $this->configService->getConfig();
if (!$this->configService->isReadyForQuery()) {
throw new \RuntimeException('快递100实时查询配置未完成');
}
$param = [
'com' => $companyCode,
'num' => $trackingNo,
'resultv2' => '1',
];
if ($phone !== '') {
$param['phone'] = $phone;
}
$paramJson = $this->encodeJson($param);
$response = $this->postForm(self::QUERY_URL, [
'customer' => $config['customer'],
'sign' => strtoupper(md5($paramJson . $config['key'] . $config['customer'])),
'param' => $paramJson,
]);
$decoded = json_decode($response, true);
if (!is_array($decoded)) {
throw new \RuntimeException('快递100实时查询返回格式异常');
}
return $decoded;
}
public function recognize(string $trackingNo): array
{
$trackingNo = trim($trackingNo);
if ($trackingNo === '') {
throw new \InvalidArgumentException('快递100单号不能为空');
}
if (!$this->configService->isReadyForRecognition()) {
throw new \RuntimeException('快递100智能识别配置未完成');
}
$config = $this->configService->getConfig();
$response = $this->requestGet(self::AUTONUMBER_URL, [
'num' => $trackingNo,
'key' => $config['key'],
]);
$decoded = json_decode($response, true);
if (!is_array($decoded)) {
throw new \RuntimeException('快递100智能识别返回格式异常');
}
return $decoded;
}
public function downloadCompanyCatalogWorkbook(): string
{
return $this->requestGet(self::COMPANY_CATALOG_URL, [], 30, 15);
}
public function subscribe(string $companyCode, string $trackingNo, string $phone = ''): array
{
$config = $this->configService->getConfig();
if (!$this->configService->isReadyForSubscribe()) {
throw new \RuntimeException('快递100订阅配置未完成');
}
$parameters = [
'callbackurl' => $config['callback_url'],
'resultv2' => '1',
];
if ($config['callback_salt'] !== '') {
$parameters['salt'] = $config['callback_salt'];
}
if ($phone !== '') {
$parameters['phone'] = $phone;
}
$requestPayload = [
'company' => $companyCode,
'number' => $trackingNo,
'key' => $config['key'],
'parameters' => $parameters,
];
if ($companyCode === '') {
unset($requestPayload['company']);
$requestPayload['autoCom'] = '1';
}
$paramJson = $this->encodeJson($requestPayload);
$response = $this->postForm(self::SUBSCRIBE_URL, [
'schema' => 'json',
'param' => $paramJson,
]);
$decoded = json_decode($response, true);
if (!is_array($decoded)) {
throw new \RuntimeException('快递100订阅返回格式异常');
}
return $decoded;
}
private function postForm(string $url, array $fields): string
{
$ch = curl_init($url);
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => http_build_query($fields),
CURLOPT_TIMEOUT => 8,
CURLOPT_CONNECTTIMEOUT => 4,
CURLOPT_HTTPHEADER => [
'Content-Type: application/x-www-form-urlencoded',
],
]);
$response = curl_exec($ch);
$errno = curl_errno($ch);
$error = curl_error($ch);
$httpStatus = (int)curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($errno) {
throw new \RuntimeException('快递100请求失败' . $error);
}
if ($httpStatus < 200 || $httpStatus >= 300) {
throw new \RuntimeException('快递100请求 HTTP 状态异常:' . $httpStatus);
}
return is_string($response) ? $response : '';
}
private function requestGet(string $url, array $query = [], int $timeout = 10, int $connectTimeout = 5): string
{
$fullUrl = $query ? $url . (str_contains($url, '?') ? '&' : '?') . http_build_query($query) : $url;
$ch = curl_init($fullUrl);
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPGET => true,
CURLOPT_TIMEOUT => $timeout,
CURLOPT_CONNECTTIMEOUT => $connectTimeout,
]);
$response = curl_exec($ch);
$errno = curl_errno($ch);
$error = curl_error($ch);
$httpStatus = (int)curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($errno) {
throw new \RuntimeException('快递100请求失败' . $error);
}
if ($httpStatus < 200 || $httpStatus >= 300) {
throw new \RuntimeException('快递100请求 HTTP 状态异常:' . $httpStatus);
}
return is_string($response) ? $response : '';
}
private function encodeJson(array $payload): string
{
$json = json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
if (!is_string($json)) {
throw new \RuntimeException('快递100请求参数编码失败');
}
return $json;
}
}

View File

@@ -0,0 +1,61 @@
<?php
namespace app\support;
use support\think\Db;
class Kuaidi100ConfigService
{
private const GROUP = 'kuaidi100';
public function getConfig(): array
{
$rows = Db::name('system_configs')
->where('config_group', self::GROUP)
->column('config_value', 'config_key');
$enabled = $this->normalizeEnabled((string)($rows['enabled'] ?? 'disabled')) === 'enabled';
$minInterval = (int)($rows['query_min_interval_minutes'] ?? 30);
$minInterval = max(5, min(1440, $minInterval > 0 ? $minInterval : 30));
return [
'enabled' => $enabled,
'customer' => trim((string)($rows['customer'] ?? '')),
'key' => trim((string)($rows['key'] ?? '')),
'callback_url' => trim((string)($rows['callback_url'] ?? '')),
'callback_salt' => trim((string)($rows['callback_salt'] ?? '')),
'query_min_interval_minutes' => $minInterval,
];
}
public function isEnabled(): bool
{
return $this->getConfig()['enabled'];
}
public function isReadyForQuery(): bool
{
$config = $this->getConfig();
return $config['enabled'] && $config['customer'] !== '' && $config['key'] !== '';
}
public function isReadyForSubscribe(): bool
{
$config = $this->getConfig();
return $config['enabled'] && $config['key'] !== '' && $config['callback_url'] !== '';
}
public function isReadyForRecognition(): bool
{
$config = $this->getConfig();
return $config['enabled'] && $config['key'] !== '';
}
public function normalizeEnabled(string $value): string
{
return in_array($value, ['enabled', 'disabled'], true) ? $value : 'disabled';
}
}

View 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);
}
}