diff --git a/docs/api/api-list.md b/docs/api/api-list.md index ce62573..4114ff7 100644 --- a/docs/api/api-list.md +++ b/docs/api/api-list.md @@ -2,4 +2,4 @@ ## 第三方开放接口 -- [第三方订单对接文档](./third-party-openapi.md):客户推送订单、订单查询、签名鉴权、Webhook 回调说明。 +- [第三方订单对接文档](./third-party-openapi.md):签名鉴权、套餐获取、仓库地址列表、客户推送订单、订单查询、Webhook 回调说明。 diff --git a/docs/api/third-party-openapi.md b/docs/api/third-party-openapi.md index 62f8ca2..d10181d 100644 --- a/docs/api/third-party-openapi.md +++ b/docs/api/third-party-openapi.md @@ -1,7 +1,7 @@ # 第三方订单对接文档 -版本:v1.2 -更新日期:2026-06-16 +版本:v1.3 +更新日期:2026-06-18 ## 1. 对接说明 @@ -177,7 +177,76 @@ GET 请求参与签名的 `raw_body` 为空字符串,`path_with_query` 需要 - `price_package_code` 可直接作为创建订单接口的 `price_package_code` 参数。 - `default_price_package_code` 表示该服务方当前默认套餐;创建订单不传 `price_package_code` 时,平台会使用当前服务方默认启用套餐。 -## 5. 创建订单 +## 5. 仓库地址列表 + +第三方创建订单或通知寄入物流前,可以调用本接口获取当前可寄送的安心验仓库地址列表。 + +```text +GET /api/open/v1/warehouses +``` + +### 5.1 查询参数 + +| 字段 | 类型 | 必填 | 说明 | +| --- | --- | --- | --- | +| `service_provider` | string | 否 | 服务方,可选 `anxinyan`、`zhongjian`;不传返回全部启用仓库 | + +### 5.2 cURL 示例 + +```bash +curl -X GET 'https://{api-domain}/api/open/v1/warehouses?service_provider=anxinyan' \ + -H 'Content-Type: application/json' \ + -H 'X-AXY-App-Key: your_app_key' \ + -H 'X-AXY-Timestamp: 1778227200' \ + -H 'X-AXY-Nonce: random_nonce' \ + -H 'X-AXY-Signature: calculated_signature' +``` + +GET 请求参与签名的 `raw_body` 为空字符串,`path_with_query` 需要包含实际查询字符串。 + +### 5.3 成功响应示例 + +```json +{ + "code": 0, + "message": "ok", + "data": { + "warehouses": [ + { + "id": 1, + "warehouse_name": "安心验鉴定中心", + "warehouse_code": "AXY-WH-DEFAULT", + "service_provider": "anxinyan", + "service_provider_text": "实物鉴定", + "receiver_name": "安心验鉴定中心", + "receiver_mobile": "400-800-1314", + "province": "广东省", + "city": "深圳市", + "district": "南山区", + "detail_address": "科技园鉴定路 88 号 安心验收件中心", + "full_address": "广东省深圳市南山区科技园鉴定路 88 号 安心验收件中心", + "service_time": "周一至周日 09:30-18:30", + "notice": "寄送前请确认订单信息完整,包裹内附上订单号可提升签收后的处理效率。", + "supported_category_ids": [], + "supported_category_names": [], + "service_area_provinces": [], + "service_area_cities": [], + "is_default": true, + "sort_order": 1 + } + ] + } +} +``` + +### 5.4 响应说明 + +- 接口只返回启用仓库,不返回停用仓库。 +- `warehouse_code` 可用于第三方系统识别仓库;寄件时请以接口返回的收件人、手机号和地址为准。 +- `supported_category_ids`、`service_area_provinces`、`service_area_cities` 为空数组时表示不限制对应条件。 +- 响应不包含后台备注、启停状态和创建更新时间等内部管理字段。 + +## 6. 创建订单 ```text POST /api/open/v1/orders @@ -185,7 +254,7 @@ POST /api/open/v1/orders 第三方创建订单时,最小只需要传 `external_order_no`。平台会创建一笔待寄送商品订单;如请求中包含套餐、物品、地址、资料或寄入物流,平台会同步写入订单主表、商品资料、寄回地址、初始鉴定资料和寄入物流记录。 -### 5.1 请求参数 +### 6.1 请求参数 | 字段 | 类型 | 必填 | 说明 | | --- | --- | --- | --- | @@ -200,7 +269,7 @@ POST /api/open/v1/orders | `tracking_no` | string | 否 | 寄入运单号,可替代 `inbound_logistics.tracking_no` | | `extra_info` | object | 否 | 购买、成色、附件、备注等扩展信息 | -### 5.3 单独设置寄回地址 +### 6.2 单独设置寄回地址 ```text POST /api/open/v1/orders/return-address @@ -208,14 +277,14 @@ POST /api/open/v1/orders/return-address 第三方可以在建单后单独补录或更新寄回地址。订单已生成回寄运单后,不允许再修改。 -### 5.4 请求参数 +### 6.3 请求参数 | 字段 | 类型 | 必填 | 说明 | | --- | --- | --- | --- | | `external_order_no` | string | 是 | 第三方订单号 | | `return_address` | object | 是 | 寄回地址,字段要求同创建订单接口 | -### 5.5 请求示例 +### 6.4 请求示例 ```json { @@ -231,7 +300,7 @@ POST /api/open/v1/orders/return-address } ``` -### 5.2 可选对象字段 +### 6.5 可选对象字段 `product_info` 支持: @@ -298,7 +367,7 @@ POST /api/open/v1/orders/return-address 如果希望创建订单时同步写入寄入物流,需要同时提供快递公司和运单号。也可以直接使用顶层 `express_company` 和 `tracking_no`,含义与 `inbound_logistics` 内字段一致。 -### 5.3 最小请求示例 +### 6.6 最小请求示例 ```json { @@ -306,7 +375,7 @@ POST /api/open/v1/orders/return-address } ``` -### 5.4 带可选字段请求示例 +### 6.7 带可选字段请求示例 ```json { @@ -355,7 +424,7 @@ POST /api/open/v1/orders/return-address } ``` -### 5.5 cURL 示例 +### 6.8 cURL 示例 ```bash curl -X POST 'https://{api-domain}/api/open/v1/orders' \ @@ -367,7 +436,7 @@ curl -X POST 'https://{api-domain}/api/open/v1/orders' \ -d '{"external_order_no":"THIRD202605080001"}' ``` -### 5.6 成功响应示例 +### 6.9 成功响应示例 ```json { @@ -413,7 +482,7 @@ curl -X POST 'https://{api-domain}/api/open/v1/orders' \ } ``` -### 5.7 幂等规则 +### 6.10 幂等规则 同一个对接客户下,`external_order_no` 作为幂等键: @@ -424,11 +493,11 @@ curl -X POST 'https://{api-domain}/api/open/v1/orders' \ 建议第三方重试创建订单时保持请求 JSON 内容一致,仅重新生成 `timestamp`、`nonce` 和 `signature`。 -## 6. 查询订单 +## 7. 查询订单 支持按第三方订单号或平台订单号查询订单进度。 -### 6.1 按第三方订单号查询 +### 7.1 按第三方订单号查询 ```text GET /api/open/v1/orders/{external_order_no} @@ -445,14 +514,14 @@ curl -X GET 'https://{api-domain}/api/open/v1/orders/THIRD202605080001' \ -H 'X-AXY-Signature: calculated_signature' ``` -### 6.2 通过查询参数查询 +### 7.2 通过查询参数查询 ```text GET /api/open/v1/orders?external_order_no=THIRD202605080001 GET /api/open/v1/orders?order_no=AXY20260508120000123 ``` -### 6.3 响应示例 +### 7.3 响应示例 ```json { @@ -507,7 +576,7 @@ GET /api/open/v1/orders?order_no=AXY20260508120000123 } ``` -## 7. 取消订单 +## 8. 取消订单 第三方订单尚未寄送前,可以调用本接口取消订单。取消成功后订单状态变为 `cancelled`,后台待处理鉴定任务会同步移除。 @@ -515,14 +584,14 @@ GET /api/open/v1/orders?order_no=AXY20260508120000123 POST /api/open/v1/orders/cancel ``` -### 7.1 请求参数 +### 8.1 请求参数 | 字段 | 类型 | 必填 | 说明 | | --- | --- | --- | --- | | `external_order_no` | string | 是 | 第三方订单号。只能取消当前 `app_key` 所属客户下的订单 | | `cancel_reason` | string | 否 | 取消原因,最长 255 个字符 | -### 7.2 请求示例 +### 8.2 请求示例 ```json { @@ -531,7 +600,7 @@ POST /api/open/v1/orders/cancel } ``` -### 7.3 成功响应示例 +### 8.3 成功响应示例 ```json { @@ -560,7 +629,7 @@ POST /api/open/v1/orders/cancel } ``` -### 7.4 取消规则 +### 8.4 取消规则 - 仅 `pending_shipping` 且尚未提交寄入运单的订单允许取消。 - 创建订单时已传 `inbound_logistics`,或已调用发货通知接口提交 `express_company`、`tracking_no` 的订单不允许取消,返回 `422`。 @@ -569,7 +638,7 @@ POST /api/open/v1/orders/cancel - 重复取消已取消订单会返回成功,`data.cancelled` 为 `false`,不会重复写入取消时间线。 - 取消接口不触发 webhook 回调;调用方以接口响应或订单查询结果为准。 -## 8. 发货通知 +## 9. 发货通知 第三方在商品实际寄出后,可以调用本接口通知平台写入寄入物流。创建订单接口中的 `inbound_logistics` 仍然可用;但如果订单创建和商品寄出不是同一时点,建议创建订单时只建单,实际寄出后再调用本接口提交快递信息。 @@ -577,7 +646,7 @@ POST /api/open/v1/orders/cancel POST /api/open/v1/orders/shipping ``` -### 8.1 请求参数 +### 9.1 请求参数 | 字段 | 类型 | 必填 | 说明 | | --- | --- | --- | --- | @@ -585,7 +654,7 @@ POST /api/open/v1/orders/shipping | `express_company` | string | 是 | 寄入快递公司 | | `tracking_no` | string | 是 | 寄入运单号 | -### 8.2 请求示例 +### 9.2 请求示例 ```json { @@ -595,7 +664,7 @@ POST /api/open/v1/orders/shipping } ``` -### 8.3 成功响应示例 +### 9.3 成功响应示例 ```json { @@ -630,14 +699,14 @@ POST /api/open/v1/orders/shipping } ``` -### 8.4 重复提交规则 +### 9.4 重复提交规则 - 相同 `external_order_no`、`express_company`、`tracking_no` 重复提交时,接口返回成功,`idempotent` 为 `true`,不会重复写物流节点或订单时间线。 - 同一订单在 `pending_shipping` 状态下提交不同快递公司或运单号时,会更新最新一条寄入物流,`updated` 为 `true`,并追加“已更新运单”时间线。 - 非 `pending_shipping` 状态的订单不允许提交或更新寄入运单,返回 `422`。 - 找不到当前客户下的 `external_order_no` 时返回 `404`。 -## 9. 订单状态 +## 10. 订单状态 常见订单状态如下,最终以接口返回的 `order_status` 和 `display_status` 为准。 @@ -653,7 +722,7 @@ POST /api/open/v1/orders/shipping | `pending_supplement` | 需要补充资料 | 需要补充资料 | | `cancelled` | 已取消 | 订单已取消 | -## 10. Webhook 事件回调 +## 11. Webhook 事件回调 如需接收订单状态变化通知,第三方需向平台提供可公网访问的 `webhook_url`,并由平台开启回调。 @@ -674,7 +743,7 @@ POST /api/open/v1/orders/shipping | 总超时 | 6 秒 | | 成功判定 | HTTP 状态码为 2xx 且无网络错误 | -### 10.1 回调报文 +### 11.1 回调报文 ```json { @@ -693,7 +762,7 @@ POST /api/open/v1/orders/shipping } ``` -### 10.2 事件类型 +### 11.2 事件类型 | event_code | event_text | status_code | status_text | | --- | --- | --- | --- | @@ -706,7 +775,7 @@ POST /api/open/v1/orders/shipping | `completed` | 订单已完成 | `completed` | 已完成 | | `supplement_required` | 需要补充资料 | `pending_supplement` | 需要补充资料 | -### 10.3 回调接收建议 +### 11.3 回调接收建议 第三方接收 webhook 时建议: @@ -714,7 +783,7 @@ POST /api/open/v1/orders/shipping - 收到事件后返回 HTTP 2xx。 - 如需强一致的最新状态,可以收到 webhook 后再调用订单查询接口确认。 -## 11. 对接流程建议 +## 12. 对接流程建议 1. 平台分配 `app_key` 和 `app_secret`。 2. 第三方完成签名调试。 diff --git a/server-api/app/controller/open/WarehousesController.php b/server-api/app/controller/open/WarehousesController.php new file mode 100644 index 0000000..f34ddc9 --- /dev/null +++ b/server-api/app/controller/open/WarehousesController.php @@ -0,0 +1,31 @@ +authenticate($request); + } catch (\Throwable $e) { + return api_error($e->getMessage(), 401); + } + + try { + $serviceProvider = trim((string)$request->input('service_provider', '')); + + return api_success([ + 'warehouses' => (new EnterpriseWarehouseService())->list($serviceProvider), + ]); + } catch (\Throwable $e) { + return api_error('仓库地址获取失败', 500, [ + 'detail' => $e->getMessage(), + ]); + } + } +} diff --git a/server-api/app/support/EnterpriseWarehouseService.php b/server-api/app/support/EnterpriseWarehouseService.php new file mode 100644 index 0000000..eca917c --- /dev/null +++ b/server-api/app/support/EnterpriseWarehouseService.php @@ -0,0 +1,48 @@ +list(); + + $warehouses = array_values(array_filter($warehouses, static function (array $item) use ($serviceProvider) { + if ((string)($item['status'] ?? '') !== 'enabled') { + return false; + } + + return $serviceProvider === '' || (string)($item['service_provider'] ?? '') === $serviceProvider; + })); + + return array_map(fn(array $item) => $this->formatOpenWarehouse($item), $warehouses); + } + + private function formatOpenWarehouse(array $item): array + { + return [ + 'id' => (int)$item['id'], + 'warehouse_name' => (string)$item['warehouse_name'], + 'warehouse_code' => (string)$item['warehouse_code'], + 'service_provider' => (string)$item['service_provider'], + 'service_provider_text' => (string)$item['service_provider_text'], + 'receiver_name' => (string)$item['receiver_name'], + 'receiver_mobile' => (string)$item['receiver_mobile'], + 'province' => (string)$item['province'], + 'city' => (string)$item['city'], + 'district' => (string)$item['district'], + 'detail_address' => (string)$item['detail_address'], + 'full_address' => (string)$item['full_address'], + 'service_time' => (string)$item['service_time'], + 'notice' => (string)$item['notice'], + 'supported_category_ids' => array_values((array)($item['supported_category_ids'] ?? [])), + 'supported_category_names' => array_values((array)($item['supported_category_names'] ?? [])), + 'service_area_provinces' => array_values((array)($item['service_area_provinces'] ?? [])), + 'service_area_cities' => array_values((array)($item['service_area_cities'] ?? [])), + 'is_default' => (bool)$item['is_default'], + 'sort_order' => (int)$item['sort_order'], + ]; + } +} diff --git a/server-api/config/route.php b/server-api/config/route.php index a2cbeeb..5273993 100644 --- a/server-api/config/route.php +++ b/server-api/config/route.php @@ -50,6 +50,7 @@ use app\controller\admin\WarehouseWorkbenchController as AdminWarehouseWorkbench use app\controller\admin\ExpressCompaniesController as AdminExpressCompaniesController; use app\controller\admin\FileUploadController as AdminFileUploadController; use app\controller\open\OrdersController as OpenOrdersController; +use app\controller\open\WarehousesController as OpenWarehousesController; use app\controller\open\Kuaidi100Controller as OpenKuaidi100Controller; use app\controller\open\ShouqianbaPaymentController as OpenShouqianbaPaymentController; @@ -218,6 +219,7 @@ Route::post('/api/open/v1/orders/shipping', [OpenOrdersController::class, 'shipp Route::get('/api/open/v1/orders', [OpenOrdersController::class, 'detail']); Route::get('/api/open/v1/orders/{external_order_no}', [OpenOrdersController::class, 'detail']); Route::get('/api/open/v1/service-price-packages', [OpenOrdersController::class, 'servicePricePackages']); +Route::get('/api/open/v1/warehouses', [OpenWarehousesController::class, 'index']); Route::post('/api/open/kuaidi100/callback', [OpenKuaidi100Controller::class, 'callback']); Route::post('/api/open/shouqianba/payment/notify', [OpenShouqianbaPaymentController::class, 'notify']); diff --git a/server-api/tools/enterprise_warehouse_list_mock_test.php b/server-api/tools/enterprise_warehouse_list_mock_test.php new file mode 100644 index 0000000..3836e56 --- /dev/null +++ b/server-api/tools/enterprise_warehouse_list_mock_test.php @@ -0,0 +1,123 @@ +safeLoad(); + +use app\support\EnterpriseWarehouseService; +use app\support\WarehouseService; +use support\think\Db; + +Db::setConfig(require dirname(__DIR__) . '/config/think-orm.php'); + +function assertTrue(bool $condition, string $message): void +{ + if (!$condition) { + throw new RuntimeException($message); + } +} + +function cleanupWarehouseMockData(): void +{ + Db::name('shipping_warehouses')->whereLike('warehouse_code', 'OPENWHMOCK%')->delete(); +} + +function createMockWarehouse(string $code, string $serviceProvider, string $status, int $sortOrder): void +{ + $now = date('Y-m-d H:i:s'); + Db::name('shipping_warehouses')->insert([ + 'warehouse_name' => '开放接口测试仓库 ' . $code, + 'warehouse_code' => $code, + 'warehouse_type' => 'detection_center', + 'service_provider' => $serviceProvider, + 'receiver_name' => '开放接口收件人', + 'receiver_mobile' => '13900001111', + 'province' => '广东省', + 'city' => '深圳市', + 'district' => '南山区', + 'detail_address' => '开放接口测试地址 ' . $code, + 'service_time' => '周一至周五 09:00-18:00', + 'notice' => '开放接口 mock 测试仓库', + 'supported_category_ids_json' => json_encode([101, 102], JSON_UNESCAPED_UNICODE), + 'service_area_provinces_json' => json_encode(['广东省'], JSON_UNESCAPED_UNICODE), + 'service_area_cities_json' => json_encode(['深圳市'], JSON_UNESCAPED_UNICODE), + 'status' => $status, + 'is_default' => $sortOrder === 1 ? 1 : 0, + 'sort_order' => $sortOrder, + 'remark' => '不应暴露到第三方开放接口', + 'created_at' => $now, + 'updated_at' => $now, + ]); +} + +function findWarehouse(array $warehouses, string $code): ?array +{ + foreach ($warehouses as $warehouse) { + if (($warehouse['warehouse_code'] ?? '') === $code) { + return $warehouse; + } + } + + return null; +} + +try { + new WarehouseService(); + cleanupWarehouseMockData(); + + createMockWarehouse('OPENWHMOCK-A', 'open_mock_a', 'enabled', 1); + createMockWarehouse('OPENWHMOCK-B', 'open_mock_b', 'enabled', 2); + createMockWarehouse('OPENWHMOCK-DISABLED', 'open_mock_a', 'disabled', 3); + + $service = new EnterpriseWarehouseService(); + + $all = $service->list(); + assertTrue(findWarehouse($all, 'OPENWHMOCK-A') !== null, '启用仓库 A 应出现在全量列表中'); + assertTrue(findWarehouse($all, 'OPENWHMOCK-B') !== null, '启用仓库 B 应出现在全量列表中'); + assertTrue(findWarehouse($all, 'OPENWHMOCK-DISABLED') === null, '停用仓库不应出现在全量列表中'); + + $filtered = $service->list('open_mock_a'); + assertTrue(count($filtered) === 1, '按 service_provider 过滤后应只返回一个启用仓库'); + assertTrue(($filtered[0]['warehouse_code'] ?? '') === 'OPENWHMOCK-A', '过滤结果应为 OPENWHMOCK-A'); + + foreach (['remark', 'status', 'created_at', 'updated_at'] as $privateField) { + assertTrue(!array_key_exists($privateField, $filtered[0]), $privateField . ' 不应暴露到开放接口'); + } + + foreach ([ + 'id', + 'warehouse_name', + 'warehouse_code', + 'service_provider', + 'service_provider_text', + 'receiver_name', + 'receiver_mobile', + 'province', + 'city', + 'district', + 'detail_address', + 'full_address', + 'service_time', + 'notice', + 'supported_category_ids', + 'supported_category_names', + 'service_area_provinces', + 'service_area_cities', + 'is_default', + 'sort_order', + ] as $publicField) { + assertTrue(array_key_exists($publicField, $filtered[0]), $publicField . ' 应出现在开放接口响应中'); + } + + assertTrue($service->list('missing_provider') === [], '不存在的 service_provider 应返回空列表'); + + cleanupWarehouseMockData(); + echo "ENTERPRISE_WAREHOUSE_LIST_MOCK_TEST_OK\n"; +} catch (Throwable $e) { + cleanupWarehouseMockData(); + fwrite(STDERR, "ENTERPRISE_WAREHOUSE_LIST_MOCK_TEST_FAILED: {$e->getMessage()}\n"); + exit(1); +}