Browse Source

集中封装 -- 聚合配送(自配 + 聚合动力)

shizhongqi 9 months ago
parent
commit
1e319d7ece

+ 139 - 0
app-ghs/controllers/DeliveryController.php

@@ -0,0 +1,139 @@
+<?php
+
+
+namespace ghs\controllers;
+
+use bizGhs\express\classes\ShansAuthTokenClass;
+use common\components\util;
+use common\components\delivery\services\DispatchService;
+use Yii;
+
+/**
+ * 聚合配送(自配 + 聚合动力)
+ * Class DeliveryController
+ * @package ghs\controllers
+ */
+class DeliveryController extends BaseController
+{
+    // ------------------ 授权(账号绑定) ---------------------
+    //授权
+    public function actionMerchantAuth()
+    {
+        //闪送授权(商户授权:授权后能为商户下所有门店发单)
+        $auth = new \common\components\delivery\platform\shanSong\Auth();
+        $redirectUrl = 'https://api.shop.hzghd.com/delivery/callback';
+        $authUrl = $auth->generateMerchantAuthUrl($this->mainId, $redirectUrl);
+
+        // 重定向用户
+        header('Location: ' . $authUrl);
+    }
+
+
+    // 获取多个平台报价
+    public function actionAllPlatformPrice()
+    {
+        $ds = new DispatchService();
+
+        $orderData = [
+              'city_name' => '北京市',
+              'sender' => [
+                  'from_address' => '东升科技国际园',
+                  'from_address_detail' => '2层202',
+                  'from_sender_name' => '小闪',
+                  'from_mobile' => '13800000000',
+                  'from_latitude' => '40.047858',
+                  'from_longitude' => '116.378424',
+              ],
+              'receiver_list' => [
+                  [
+                      'order_no' => 'C1119A000013053981',
+                      'to_address' => '永泰庄地铁站',
+                      'to_address_detail' => '1楼',
+                      'to_receiver_name' => '小送',
+                      'to_mobile' => '13800000001',
+                      'to_latitude' => '40.043612',
+                      'to_longitude' => '116.361199',
+                      'good_type' => 5,           // 物品类型
+                      'weight' => 2,              // 物品重量(kg,整数)
+                      'remarks' => '',            // 备注
+                  ]
+              ],
+              'appoint_type' => 0,              // 0: 立即单,1: 预约单
+              'appointment_date' => '',         // 预约时间 yyyy-MM-dd HH:mm
+              'store_id' => 393549,             // 店铺ID
+              'travel_way' => 0,                // 指定交通工具,0: 不限交通方式
+              'delivery_type' => 1,             // 1: 帮我送,2: 帮我取
+              'expect_start_time' => null,      // 期望送达时间起始(毫秒级时间戳)
+              'expect_end_time' => null,        // 期望送达时间终止(毫秒级时间戳)
+        ];
+        $re = $ds->getBestPlatformByPrice($orderData);
+        return $re;
+    }
+
+    // 创建订单(发单)
+
+    // 查询订单(查单)
+
+    // 第三方回调入口
+    /**
+     * 回调接口
+     */
+    public function actionCallback()
+    {
+        $callbackData = Yii::$app->request->post();
+        if (empty($callbackData)) {
+            $postStr = file_get_contents('php://input');
+            $callbackData = json_decode($postStr, true);
+            if (empty($callbackData)) {
+                util::fail('回调请求的数据为空');
+            }
+        }
+
+        // 从配置中获取安全token
+        // $token = Yii::$app->params['wx_intracity_token'] ?? 'your_token_here';
+        // $result = IntraCityExpress::handleOrderCallback($callbackData, $token);
+        // 记录回调日志
+        Yii::info('聚合配送回调:' . json_encode($callbackData, JSON_UNESCAPED_UNICODE), 'delivery');
+
+        $code = $callbackData['code'];
+        $state = $callbackData['state'];
+        $shopId = $callbackData['shopId'];
+        $isAllStoreAuth = $callbackData['isAllStoreAuth'];
+        $thirdStoreId = $callbackData['thirdStoreId'];
+        $storeId = $callbackData['storeId'];
+
+        // 验证state参数(防止CSRF)
+        //if ($state != $yourUserId) {
+            //throw new \yii\web\BadRequestHttpException('Invalid state parameter');
+        //}
+
+        // 获取AccessToken
+        $auth = new \common\components\delivery\platform\shanSong\Auth();
+        $result = $auth->getAccessToken($code);
+
+        if (!$result['success']) {
+            throw new \yii\web\HttpException(500, '获取授权失败:' . $result['error']);
+        }
+
+        // 保存授权信息到数据库
+        $data['user_id'] = $state;
+        $data['shop_id'] = $shopId;
+        $data['access_token'] = $result['access_token'];
+        $data['refresh_token'] = $result['refresh_token'];
+        $data['expires_at'] = time() + $result['expires_in'];
+        $data['auth_type'] = $isAllStoreAuth ? 'all_store' : 'single_store';
+
+        if (!$isAllStoreAuth) {
+            $data['third_store_id'] = $thirdStoreId;
+            $data['shans_store_id'] = $storeId;
+        }
+
+        ShansAuthTokenClass::add($data);
+        if (false) {
+            throw new \yii\web\HttpException(500, '保存授权信息失败');
+        }
+
+        //return $this->asJson(['return_code' => 0, 'return_msg' => 'OK']);
+        return $this->redirect(['success']);
+    }
+}

+ 11 - 0
biz-ghs/express/classes/ShansAuthTokenClass.php

@@ -0,0 +1,11 @@
+<?php
+
+
+namespace bizGhs\express\classes;
+
+use bizHd\base\classes\BaseClass;
+
+class ShansAuthTokenClass extends BaseClass
+{
+    public static $baseFile = '\bizGhs\express\models\ShansAuthToken';
+}

+ 13 - 0
biz-ghs/express/models/ShansAuthToken.php

@@ -0,0 +1,13 @@
+<?php
+
+
+namespace bizGhs\express\models;
+
+
+class ShansAuthToken
+{
+    public static function tableName()
+    {
+        return 'xhShansAuthToken';
+    }
+}

+ 92 - 0
common/components/delivery/helpers/HttpClient.php

@@ -0,0 +1,92 @@
+<?php
+
+namespace common\components\delivery\helpers;
+
+use GuzzleHttp\Client;
+use GuzzleHttp\Exception\RequestException;
+use Yii;
+
+class HttpClient
+{
+    /**
+     * 发送 POST 请求(支持 JSON 自动解析 + 重试)
+     */
+    public static function post(string $url, array $data = [], array $headers = [], int $retry = 1)
+    {
+        $client = new Client([
+            'timeout' => 8.0,
+            'verify' => false, // 禁用 SSL 证书验证(测试环境可用)
+        ]);
+
+        $headers = array_merge([
+            'Content-Type' => 'application/x-www-form-urlencoded;charset=utf-8',
+        ], $headers);
+
+        $body = http_build_query($data);
+
+        for ($i = 0; $i <= $retry; $i++) {
+            try {
+                $response = $client->post($url, [
+                    'headers' => $headers,
+                    'body' => $body,
+                ]);
+
+                $status = $response->getStatusCode();
+                $respBody = (string) $response->getBody();
+
+                Yii::info("[HttpClient] POST {$url} {$status} {$respBody}");
+
+                $json = json_decode($respBody, true);
+                return $json ?: ['code' => $status, 'body' => $respBody];
+            } catch (RequestException $e) {
+                $message = $e->getMessage();
+                Yii::error("[HttpClient] POST {$url} failed: {$message}");
+
+                if ($i < $retry) {
+                    sleep(1); // 重试间隔
+                    continue;
+                }
+                return ['code' => 500, 'error' => $message];
+            }
+        }
+    }
+
+    /**
+     * 发送 GET 请求
+     */
+    public static function get(string $url, array $params = [], array $headers = [], int $retry = 1)
+    {
+        $client = new Client([
+            'timeout' => 8.0,
+            'verify' => false,
+        ]);
+
+        $headers = array_merge([
+            'Accept' => 'application/json',
+        ], $headers);
+
+        for ($i = 0; $i <= $retry; $i++) {
+            try {
+                $response = $client->get($url, [
+                    'headers' => $headers,
+                    'query' => $params,
+                ]);
+
+                $status = $response->getStatusCode();
+                $respBody = (string) $response->getBody();
+
+                Yii::info("[HttpClient] GET {$url} {$status} {$respBody}");
+
+                $json = json_decode($respBody, true);
+                return $json ?: ['code' => $status, 'body' => $respBody];
+            } catch (RequestException $e) {
+                Yii::error("[HttpClient] GET {$url} failed: {$e->getMessage()}");
+                if ($i < $retry) {
+                    sleep(1);
+                    continue;
+                }
+                return ['code' => 500, 'error' => $e->getMessage()];
+            }
+        }
+    }
+}

+ 53 - 0
common/components/delivery/helpers/SignHelper.php

@@ -0,0 +1,53 @@
+<?php
+
+namespace common\components\delivery\helpers;
+
+class SignHelper
+{
+    /**
+     * 通用签名算法入口
+     * @param array $params 待签名参数
+     * @param string $secret 密钥
+     * @param string $method 签名算法:md5 / hmac_sha256
+     * @param bool $sort 是否需要按 key 排序
+     */
+    public static function makeSign(array $params, string $secret, string $method = 'md5', bool $sort = true)
+    {
+        if ($sort) {
+            ksort($params);
+        }
+
+        // 拼接 query 字符串(过滤空值)
+        $str = '';
+        foreach ($params as $k => $v) {
+            if ($v === '' || $v === null) continue;
+            if (is_array($v)) $v = json_encode($v, JSON_UNESCAPED_UNICODE);
+            $str .= "{$k}={$v}&";
+        }
+        $str = rtrim($str, '&');
+
+        switch (strtolower($method)) {
+            case 'hmac_sha256':
+                $sign = hash_hmac('sha256', $str, $secret);
+                break;
+            case 'md5':
+            default:
+                $sign = md5($str . $secret);
+                break;
+        }
+
+        return strtoupper($sign);
+    }
+
+    /**
+     * 验证签名(用于第三方回调)
+     */
+    public static function verify(array $params, string $secret, string $signKey = 'sign', string $method = 'md5')
+    {
+        if (!isset($params[$signKey])) return false;
+        $sign = $params[$signKey];
+        unset($params[$signKey]);
+        $expect = self::makeSign($params, $secret, $method);
+        return strtoupper($sign) === strtoupper($expect);
+    }
+}

+ 300 - 0
common/components/delivery/platform/shanSong/AUTH_IMPLEMENTATION_GUIDE.md

@@ -0,0 +1,300 @@
+# 闪送商户授权实现指南
+
+## 📋 概述
+
+该授权类 (`Auth`) 完全实现了闪送商户授权功能,支持两种授权方式:
+- **商户授权** - 授权后能为商户下所有门店发单
+- **门店授权** - 授权后只能为授权时选择的门店发单
+
+## 🔧 核心功能
+
+### 1. 生成授权URL
+
+#### 商户授权(推荐)
+```php
+$auth = new \common\components\delivery\platform\shanSong\Auth();
+$redirectUrl = 'http://your-domain.com/callback';
+$authUrl = $auth->generateMerchantAuthUrl($userId, $redirectUrl);
+
+// 重定向用户
+header('Location: ' . $authUrl);
+```
+
+**URL结构示例(生产环境):**
+```
+https://open.ishansong.com/auth?
+  isAllStoreAuth=true&
+  response_type=code&
+  scope=shop_open_api&
+  state=10583&
+  client_id=sseYlPL1Qo3j0lNfT&
+  redirect_uri=http%3a%2f%2fopen.s.bingex.com%2fopenqatest%2findex
+```
+
+#### 门店授权
+```php
+$auth = new \common\components\delivery\platform\shanSong\Auth();
+$thirdStoreId = 312313;  // 平台门店ID
+$userId = 10583;          // 用户标识
+$redirectUrl = 'http://your-domain.com/callback';
+
+$authUrl = $auth->generateStoreAuthUrl($thirdStoreId, $userId, $redirectUrl);
+header('Location: ' . $authUrl);
+```
+
+**URL结构示例:**
+```
+http://open.s.bingex.com/auth?
+  isAllStoreAuth=false&
+  thirdStoreId=312313&
+  response_type=code&
+  scope=shop_open_api&
+  state=10583&
+  client_id=ss2xpXL5NGhXIP7L2&
+  redirect_uri=http%3a%2f%2flanyinbin.cn%2faa%2fcallBack
+```
+
+### 2. 处理授权回调
+
+用户授权成功后,闪送会重定向回你设置的 `redirect_uri`,并携带以下参数:
+
+**商户授权返回参数:**
+```
+?code=520DD
+&state=10583
+&isAllStoreAuth=true
+&shopId=20000000000000348
+```
+
+**门店授权返回参数:**
+```
+?code=520DD
+&state=10583
+&thirdStoreId=312313
+&isAllStoreAuth=false
+&storeId=1331275
+&shopId=20000000000000348
+```
+
+#### 回调处理代码示例:
+
+```php
+public function actionCallback()
+{
+    $code = Yii::$app->request->get('code');
+    $state = Yii::$app->request->get('state');
+    $shopId = Yii::$app->request->get('shopId');
+    $isAllStoreAuth = Yii::$app->request->get('isAllStoreAuth');
+    $thirdStoreId = Yii::$app->request->get('thirdStoreId');
+    $storeId = Yii::$app->request->get('storeId');
+
+    // 验证state参数(防止CSRF)
+    if ($state != $yourUserId) {
+        throw new \yii\web\BadRequestHttpException('Invalid state parameter');
+    }
+
+    // 获取AccessToken
+    $auth = new \common\components\delivery\platform\shanSong\Auth();
+    $result = $auth->getAccessToken($code);
+
+    if (!$result['success']) {
+        throw new \yii\web\HttpException(500, '获取授权失败:' . $result['error']);
+    }
+
+    // 保存授权信息到数据库
+    $model = new ShansAuthToken();
+    $model->user_id = $state;
+    $model->shop_id = $shopId;
+    $model->access_token = $result['access_token'];
+    $model->refresh_token = $result['refresh_token'];
+    $model->expires_at = time() + $result['expires_in'];
+    $model->auth_type = $isAllStoreAuth ? 'all_store' : 'single_store';
+    
+    if (!$isAllStoreAuth) {
+        $model->third_store_id = $thirdStoreId;
+        $model->shans_store_id = $storeId;
+    }
+
+    if (!$model->save()) {
+        throw new \yii\web\HttpException(500, '保存授权信息失败');
+    }
+
+    return $this->redirect(['success']);
+}
+```
+
+### 3. 获取AccessToken
+
+```php
+$auth = new \common\components\delivery\platform\shanSong\Auth();
+$result = $auth->getAccessToken($authCode);
+
+// 返回格式
+$result = [
+    'success' => true,
+    'access_token' => '64850c4c-06fe-46fa-b30a-f680b193fe15',
+    'refresh_token' => '53521dad-60d2-4bf7-a81e-3882c2721bf1',
+    'expires_in' => 2592000,  // 30天
+];
+```
+
+### 4. 刷新AccessToken
+
+AccessToken 有效期为 30 天,建议在过期前 7 天开始刷新:
+
+```php
+$auth = new \common\components\delivery\platform\shanSong\Auth();
+$result = $auth->refreshAccessToken($refreshToken);
+
+if ($result['success']) {
+    // 更新数据库中的token
+    $model = ShansAuthToken::findOne(['user_id' => $userId]);
+    $model->access_token = $result['access_token'];
+    $model->expires_at = time() + $result['expires_in'];
+    $model->save();
+}
+```
+
+### 5. 取消授权
+
+```php
+$auth = new \common\components\delivery\platform\shanSong\Auth();
+$result = $auth->cancelAuthorization($accessToken);
+
+if ($result['success']) {
+    // 从数据库删除授权信息
+    ShansAuthToken::deleteAll(['user_id' => $userId]);
+}
+```
+
+## 📊 完整授权流程图
+
+```
+┌─────────────────────┐
+│  用户点击授权按钮   │
+└──────────┬──────────┘
+           │
+           ▼
+   ┌───────────────────────────────┐
+   │ generateMerchantAuthUrl()      │
+   │ 或 generateStoreAuthUrl()      │
+   └───────────┬───────────────────┘
+               │
+               ▼
+      ┌────────────────────────┐
+      │ 重定向到闪送授权页面   │
+      └────────┬───────────────┘
+               │
+               ▼
+      ┌────────────────────────────┐
+      │ 用户填写闪送账号授权       │
+      └────────┬───────────────────┘
+               │
+               ▼
+      ┌──────────────────────────┐
+      │ 闪送重定向回 redirect_uri │
+      │ 并携带 code 和 shopId    │
+      └────────┬────────────────┘
+               │
+               ▼
+      ┌─────────────────────────────┐
+      │ getAccessToken($code)       │
+      └────────┬────────────────────┘
+               │
+               ▼
+      ┌──────────────────────────┐
+      │ 保存 token 到数据库      │
+      └─────────────────────────┘
+```
+
+## 🗄️ 数据库表设计参考
+
+建议创建表来存储授权信息:
+
+```sql
+CREATE TABLE `xh_shans_auth_token` (
+  `id` int(11) NOT NULL AUTO_INCREMENT,
+  `user_id` int(11) NOT NULL COMMENT '用户ID',
+  `shop_id` varchar(50) NOT NULL COMMENT '闪送商户ID',
+  `access_token` varchar(100) NOT NULL COMMENT '访问令牌',
+  `refresh_token` varchar(100) NOT NULL COMMENT '刷新令牌',
+  `expires_at` int(11) NOT NULL COMMENT 'token过期时间戳',
+  `auth_type` enum('all_store','single_store') NOT NULL DEFAULT 'all_store' COMMENT '授权类型',
+  `third_store_id` int(11) COMMENT '第三方门店ID',
+  `shans_store_id` varchar(50) COMMENT '闪送门店ID',
+  `created_at` timestamp DEFAULT CURRENT_TIMESTAMP,
+  `updated_at` timestamp DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+  PRIMARY KEY (`id`),
+  UNIQUE KEY `uk_user_shop` (`user_id`, `shop_id`),
+  KEY `idx_expires_at` (`expires_at`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
+```
+
+## 🔐 环境配置
+
+配置文件 (`common/config/params.php`) 中已包含:
+
+```php
+'shanAppId' => 'ss9QcKVXwvcMTkHzw',        // App Key
+'shanAppSecret' => '5ivRvyBXLrprGPssYUUvpKXwoZAojwDg',  // App Secret
+'expressShanUrl' => 'http://open.s.bingex.com',  // 测试环境
+// 或 'https://open.ishansong.com'  生产环境
+```
+
+环境自动识别:
+- 生产环境(YII_ENV=production): `https://open.ishansong.com`
+- 其他环境(YII_ENV=dev/test等): `http://open.s.bingex.com`
+
+## ⚠️ 重要注意事项
+
+### 1. **Token 生命周期管理**
+- AccessToken 有效期:30 天(2592000秒)
+- RefreshToken 长期有效
+- Code 有效期:1 分钟,只能使用 1 次
+
+### 2. **签名算法**
+- 算法:MD5
+- 签名包含的参数必须按字典序排序
+- 空值和 null 值需要过滤
+
+### 3. **URL 编码**
+- redirectUri 必须进行 URLEncode 编码
+- 该类自动处理 redirectUri 的编码
+
+### 4. **错误处理**
+所有方法返回统一的响应格式,包含 `success` 字段:
+
+```php
+// 成功响应
+['success' => true, 'access_token' => '...', ...]
+
+// 失败响应
+['success' => false, 'error' => '错误信息', ...]
+```
+
+### 5. **测试环境**
+- 使用测试账号进行授权测试
+- 需在闪送开放平台同步测试环境获取测试账号
+- 测试环境 URL:`http://open.s.bingex.com`
+
+### 6. **安全建议**
+- 妥善保管 `appSecret`,不要暴露在前端
+- State 参数建议使用用户ID或其他唯一标识
+- 定期验证和刷新 token
+- 实现 token 过期检测和自动刷新机制
+
+## 🚀 集成示例
+
+完整的集成示例可以参考项目中的:
+- `app-ghs/controllers/DeliveryController.php` - 快递相关的授权处理
+- `common/components/delivery/services/adapter/ShansongAdapter.php` - 闪送API调用示例
+
+## 📝 相关文档
+
+- 闪送开放平台:https://open.ishansong.com
+- 应用申请路径:登录闪送开放平台 -> 账号中心 -> 应用信息
+- 获取 AppKey 和 AppSecret:登录闪送开放平台 -> 账号中心 -> 应用信息
+
+---
+
+如有问题,请参考闪送官方文档或查看类中的详细代码注释。

+ 363 - 0
common/components/delivery/platform/shanSong/Auth.php

@@ -0,0 +1,363 @@
+<?php
+
+
+namespace common\components\delivery\platform\shanSong;
+
+use common\components\delivery\helpers\HttpClient;
+use common\components\delivery\helpers\SignHelper;
+use Yii;
+
+/**
+ * 闪送商户授权
+ * 
+ * 支持两种授权方式:
+ * 1. 商户授权(isAllStoreAuth=true)- 授权后能为商户下所有门店发单
+ * 2. 门店授权(isAllStoreAuth=false)- 授权后只能为授权时选择的门店发单
+ * 
+ * ============ 使用示例 ============
+ * 
+ * 1. 生成商户授权URL(推荐用于首次授权):
+ *    $auth = new Auth();
+ *    $redirectUrl = 'http://your-domain.com/callback';
+ *    $authUrl = $auth->generateMerchantAuthUrl($userId, $redirectUrl);
+ *    // 重定向到 $authUrl 让用户登录授权
+ * 
+ * 2. 生成门店授权URL(用于特定门店授权):
+ *    $auth = new Auth();
+ *    $authUrl = $auth->generateStoreAuthUrl($thirdStoreId, $userId, $redirectUrl);
+ *    // 用户授权后会跳转到 redirectUrl 并携带 code
+ * 
+ * 3. 在回调页面(redirectUrl)获取 AccessToken:
+ *    $auth = new Auth();
+ *    $code = Yii::$app->request->get('code');
+ *    $shopId = Yii::$app->request->get('shopId');
+ *    $result = $auth->getAccessToken($code);
+ *    
+ *    if ($result['success']) {
+ *        // 保存 access_token 和 refresh_token
+ *        // $result['access_token'] - 访问令牌
+ *        // $result['refresh_token'] - 刷新令牌
+ *        // $result['expires_in'] - 过期秒数(默认30天)
+ *    }
+ * 
+ * 4. 刷新过期的 AccessToken:
+ *    $auth = new Auth();
+ *    $result = $auth->refreshAccessToken($refreshToken);
+ *    
+ *    if ($result['success']) {
+ *        // 使用新的 access_token
+ *    }
+ * 
+ * 5. 取消用户授权:
+ *    $auth = new Auth();
+ *    $result = $auth->cancelAuthorization($accessToken);
+ *    
+ *    if ($result['success']) {
+ *        // 授权已取消
+ *    }
+ * 
+ * ============ 授权流程 ============
+ * 
+ * 商户授权流程:
+ * 1. 用户点击「闪送授权」按钮 -> generateMerchantAuthUrl() 获取授权URL
+ * 2. 用户被重定向到闪送授权页面
+ * 3. 用户填写闪送账号进行授权
+ * 4. 闪送重定向回 redirectUrl,并携带 code 和 shopId
+ * 5. 在回调页面用 code 调用 getAccessToken() 获取 token
+ * 6. 保存 access_token 和 refresh_token 到数据库
+ * 
+ * 门店授权流程:
+ * 与商户授权流程类似,但用户可以在闪送授权页面选择授权特定门店
+ * 返回结果中会包含 storeId(闪送门店ID)和 thirdStoreId(平台门店ID)
+ * 
+ * ============ 注意事项 ============
+ * 
+ * 1. AccessToken 有效期为 30 天(2592000秒)
+ * 2. 授权码(code)有效期为 1 分钟,且只能使用 1 次
+ * 3. 刷新令牌(refresh_token)需要妥善保管,长期有效
+ * 4. 签名算法为 MD5,签名参数中不能包含 null 或空字符串值
+ * 5. 建议定期刷新 token,在过期前 7 天开始刷新
+ * 6. 环境配置通过 YII_ENV 环境变量自动区分(production 或其他)
+ * 
+ * @package common\components\shans
+ */
+class Auth
+{
+    // 授权方式常量
+    const AUTH_TYPE_ALL_STORE = true;   // 商户授权
+    const AUTH_TYPE_SINGLE_STORE = false; // 门店授权
+
+    // 固定参数
+    const RESPONSE_TYPE = 'code';
+    const SCOPE = 'shop_open_api';
+
+    protected $baseUrl;
+    protected $clientId;
+    protected $clientSecret;
+    protected $redirectUri;
+
+    /**
+     * 初始化授权类
+     * 根据环境获取配置信息
+     */
+    public function __construct()
+    {
+        $isProduction = getenv('YII_ENV') == 'production';
+
+        if ($isProduction) {
+            $this->baseUrl = 'https://open.ishansong.com';
+        } else {
+            $this->baseUrl = 'http://open.s.bingex.com';
+        }
+
+        $this->clientId = Yii::$app->params['shanAppId'] ?? '';
+        $this->clientSecret = Yii::$app->params['shanAppSecret'] ?? '';
+    }
+
+    /**
+     * 设置重定向URI
+     * 需要进行URLEncode编码
+     * 
+     * @param string $redirectUri 重定向地址
+     */
+    public function setRedirectUri($redirectUri)
+    {
+        $this->redirectUri = urlencode($redirectUri);
+        return $this;
+    }
+
+    /**
+     * 生成商户授权URL
+     * 授权后能为商户下所有门店发单
+     * 
+     * @param string $state 状态参数,用于标记平台用户,建议使用用户ID
+     * @param string $redirectUri 重定向地址
+     * @return string 授权URL
+     */
+    public function generateMerchantAuthUrl($state, $redirectUri = null)
+    {
+        if ($redirectUri) {
+            $this->setRedirectUri($redirectUri);
+        }
+
+        return $this->buildAuthUrl(self::AUTH_TYPE_ALL_STORE, $state);
+    }
+
+    /**
+     * 生成门店授权URL
+     * 授权后只能为授权时选择的门店发单
+     * 
+     * @param string $thirdStoreId 第三方平台的门店ID
+     * @param string $state 状态参数,用于标记平台用户
+     * @param string $redirectUri 重定向地址
+     * @return string 授权URL
+     */
+    public function generateStoreAuthUrl($thirdStoreId, $state, $redirectUri = null)
+    {
+        if ($redirectUri) {
+            $this->setRedirectUri($redirectUri);
+        }
+
+        return $this->buildAuthUrl(self::AUTH_TYPE_SINGLE_STORE, $state, $thirdStoreId);
+    }
+
+    /**
+     * 构建授权URL
+     * 
+     * @param bool $isAllStoreAuth 是否为商户授权
+     * @param string $state 状态参数
+     * @param string $thirdStoreId 门店授权时的门店ID
+     * @return string 授权URL
+     */
+    protected function buildAuthUrl($isAllStoreAuth, $state, $thirdStoreId = null)
+    {
+        $params = [
+            'isAllStoreAuth' => $isAllStoreAuth ? 'true' : 'false',
+            'response_type' => self::RESPONSE_TYPE,
+            'scope' => self::SCOPE,
+            'state' => $state,
+            'client_id' => $this->clientId,
+            'redirect_uri' => $this->redirectUri,
+        ];
+
+        // 门店授权需要添加thirdStoreId
+        if ($thirdStoreId !== null && !$isAllStoreAuth) {
+            $params['thirdStoreId'] = $thirdStoreId;
+        }
+
+        // 构建URL
+        $queryString = http_build_query($params);
+        return $this->baseUrl . '/auth?' . $queryString;
+    }
+
+    /**
+     * 获取AccessToken
+     * 使用授权码换取令牌
+     * 
+     * 文档:/openapi/oauth/token
+     * 入参:clientId, code
+     * 出参:access_token, refresh_token, expires_in
+     * 
+     * @param string $code 授权码(来自授权页面重定向)
+     * @return array 返回格式:['access_token' => '', 'refresh_token' => '', 'expires_in' => 0, 'error' => '']
+     */
+    public function getAccessToken($code)
+    {
+        $url = $this->baseUrl . '/openapi/oauth/token';
+
+        $data = [
+            'clientId' => $this->clientId,
+            'code' => $code,
+        ];
+
+        $response = HttpClient::post($url, $data);
+
+        return $this->parseResponse($response);
+    }
+
+    /**
+     * 刷新AccessToken
+     * 使用刷新令牌获取新的AccessToken
+     * 
+     * 文档:/openapi/oauth/refresh_token
+     * 入参:clientId, sign, timestamp, data
+     * 出参:access_token, expires_in
+     * 
+     * @param string $refreshToken 刷新令牌
+     * @return array 返回格式:['access_token' => '', 'expires_in' => 0, 'error' => '']
+     */
+    public function refreshAccessToken($refreshToken)
+    {
+        $url = $this->baseUrl . '/openapi/oauth/refresh_token';
+
+        $timestamp = (string) (int) (microtime(true) * 1000);
+        $data = json_encode(['refreshToken' => $refreshToken], JSON_UNESCAPED_UNICODE);
+
+        $params = [
+            'clientId' => $this->clientId,
+            'timestamp' => $timestamp,
+            'data' => $data,
+        ];
+
+        // 计算签名
+        $sign = SignHelper::makeSign($params, $this->clientSecret, 'md5', true);
+        $params['sign'] = $sign;
+
+        $response = HttpClient::post($url, $params);
+
+        return $this->parseResponse($response);
+    }
+
+    /**
+     * 取消授权
+     * 撤销用户的授权,使其accessToken失效
+     * 
+     * 文档:/openapi/oauth/cancel
+     * 入参:clientId, sign, timestamp, data
+     * 出参:无数据返回
+     * 
+     * @param string $accessToken 待取消的授权令牌
+     * @return array 返回格式:['success' => true/false, 'message' => '', 'error' => '']
+     */
+    public function cancelAuthorization($accessToken)
+    {
+        $url = $this->baseUrl . '/openapi/oauth/cancel';
+
+        $timestamp = (string) (int) (microtime(true) * 1000);
+        $data = json_encode(['accessToken' => $accessToken], JSON_UNESCAPED_UNICODE);
+
+        $params = [
+            'clientId' => $this->clientId,
+            'timestamp' => $timestamp,
+            'data' => $data,
+        ];
+
+        // 计算签名
+        $sign = SignHelper::makeSign($params, $this->clientSecret, 'md5', true);
+        $params['sign'] = $sign;
+
+        $response = HttpClient::post($url, $params);
+
+        Yii::info("[ShansAuthCancel] Response: " . json_encode($response));
+
+        if (isset($response['status']) && $response['status'] == 200) {
+            return [
+                'success' => true,
+                'message' => $response['msg'] ?? '取消授权成功',
+            ];
+        }
+
+        return [
+            'success' => false,
+            'message' => $response['msg'] ?? '取消授权失败',
+            'error' => $response['error'] ?? '',
+        ];
+    }
+
+    /**
+     * 解析API响应
+     * 
+     * @param array $response HTTP响应
+     * @return array 标准化的响应格式
+     */
+    protected function parseResponse($response)
+    {
+        // 如果是HTTP错误
+        if (isset($response['code']) && $response['code'] != 200) {
+            return [
+                'success' => false,
+                'error' => $response['error'] ?? '请求失败',
+                'status_code' => $response['code'] ?? 0,
+            ];
+        }
+
+        // 检查API状态码
+        if (isset($response['status']) && $response['status'] != 200) {
+            return [
+                'success' => false,
+                'error' => $response['msg'] ?? 'API返回异常',
+                'status' => $response['status'],
+            ];
+        }
+
+        // 成功响应
+        $data = $response['data'] ?? [];
+
+        return [
+            'success' => true,
+            'access_token' => $data['access_token'] ?? '',
+            'refresh_token' => $data['refresh_token'] ?? '',
+            'expires_in' => $data['expires_in'] ?? 0,
+        ];
+    }
+
+    /**
+     * 获取基础URL
+     * 
+     * @return string
+     */
+    public function getBaseUrl()
+    {
+        return $this->baseUrl;
+    }
+
+    /**
+     * 获取客户端ID
+     * 
+     * @return string
+     */
+    public function getClientId()
+    {
+        return $this->clientId;
+    }
+
+    /**
+     * 获取客户端密钥
+     * 
+     * @return string
+     */
+    public function getClientSecret()
+    {
+        return $this->clientSecret;
+    }
+}

+ 4 - 0
common/components/delivery/services/AccountBindService.php

@@ -0,0 +1,4 @@
+<?php
+namespace common\components\delivery\services;
+
+//商户账号绑定管理

+ 90 - 0
common/components/delivery/services/DispatchService.php

@@ -0,0 +1,90 @@
+<?php
+
+namespace common\components\delivery\services;
+
+use common\components\delivery\services\adapter\{ ShansongAdapter,  HuolalaAdapter, FengniaoAdapter}; // MeituanAdapter, DadaAdapter, SFAdapter, UUAdapter,
+use common\components\delivery\models\{DeliveryOrder, DeliveryAccount};
+use Yii;
+
+/**
+ * 聚合调度逻辑(平台选择/优先级)
+ * Class DispatchService
+ * @package App\Services
+ */
+class DispatchService
+{
+    protected $adapters;
+
+    public function __construct()
+    {
+        $this->adapters = [
+            //'meituan'  => new MeituanAdapter(),
+            //'dada'     => new DadaAdapter(),
+            //'sf'       => new SFAdapter(),
+            //'uu'       => new UUAdapter(),
+            'shansong' => new ShansongAdapter(),
+            //'huolala' => new HuolalaAdapter(),
+            //'fengniao' => new FengniaoAdapter(),
+        ];
+    }
+
+    /**
+     * 发单调度
+     */
+    public function createOrder($mainId, $orderData)
+    {
+        $merchantAccount = DeliveryAccount::where('mainId', $mainId)
+            ->where('is_active', 1)
+            ->first();
+
+        // 优先使用商户自配
+        if ($merchantAccount) {
+            $adapter = $this->adapters[$merchantAccount->platform];
+            return $adapter->createOrder($merchantAccount, $orderData);
+        }
+
+        // 否则使用平台优惠价策略调度
+        $platform = $this->getBestPlatform($orderData);
+        $adapter = $this->adapters[$platform];
+        return $adapter->createOrder($orderData);
+    }
+
+    protected function getBestPlatform($orderData)
+    {
+        // 简化策略:根据距离、重量、历史价格动态选择
+        $candidates = ['shansong']; //'meituan', 'dada', 'sf',  'uu'
+        return $candidates[array_rand($candidates)];
+    }
+
+    public function getBestPlatformByPrice($orderData)
+    {
+        $results = [];
+        $accessToken = '';
+
+        foreach ($this->adapters as $name => $adapter) {
+            try {
+                $quote = $adapter->getPrice($orderData, $accessToken);
+                if ($quote) {
+                    $results[] = $quote;
+                }
+            } catch (\Exception $e) {
+                Yii::warning("报价失败: {$name} - {$e->getMessage()}");
+            }
+        }
+
+        if (empty($results)) {
+            return ['error' => '全部平台报价失败'];
+        }
+
+        // 排序,选择最低价
+        usort($results, function($a, $b) {
+            return $a['price'] <=> $b['price'];
+        });
+
+        // 返回所有报价供前端展示
+        return [
+            'quotes' => $results,
+            'best' => $results[0],
+        ];
+    }
+}

+ 2 - 0
common/components/delivery/services/SettlementService.php

@@ -0,0 +1,2 @@
+<?php
+// 运价结算逻辑

+ 11 - 0
common/components/delivery/services/adapter/Adapter.php

@@ -0,0 +1,11 @@
+<?php
+
+
+namespace common\components\delivery\services\adapter;
+
+
+interface Adapter
+{
+    public function createOrder($order);
+    public function getPrice($orderData, $accessToken);
+}

+ 15 - 0
common/components/delivery/services/adapter/FengniaoAdapter.php

@@ -0,0 +1,15 @@
+<?php
+namespace common\components\delivery\services\adapter;
+
+class FengniaoAdapter implements Adapter
+{
+    public function createOrder($data)
+    {
+
+    }
+
+    public function getPrice($data)
+    {
+
+    }
+}

+ 15 - 0
common/components/delivery/services/adapter/HuolalaAdapter.php

@@ -0,0 +1,15 @@
+<?php
+namespace common\components\delivery\services\adapter;
+
+class HuolalaAdapter implements Adapter
+{
+    public function createOrder($data)
+    {
+
+    }
+
+    public function getPrice($data)
+    {
+
+    }
+}

+ 191 - 0
common/components/delivery/services/adapter/ShansongAdapter.php

@@ -0,0 +1,191 @@
+<?php
+
+namespace common\components\delivery\services\adapter;
+
+use common\components\delivery\helpers\HttpClient;
+use common\components\delivery\helpers\SignHelper;
+
+class ShansongAdapter implements Adapter
+{
+    protected $baseUrl;
+    protected $shopId;
+    protected $appSecret;
+    protected $clientId;
+
+    public function __construct()
+    {
+        if ( getenv('YII_ENV') == 'production') {
+            $cfg = [
+                'base_url' => 'https://open.ishansong.com',
+                'shop_id' => '20000000000070108',
+                'secret' => '5ivRvyBXLrprGPssYUUvpKXwoZAojwDg',
+                'client_id' => 'ss9QcKVXwvcMTkHzw', // App-key
+            ];
+        } else {
+            $cfg = [
+                'base_url' => 'http://open.s.bingex.com',
+                'shop_id' => '20000000000000969',
+                'secret' => '5ivRvyBXLrprGPssYUUvpKXwoZAojwDg',
+                'client_id' => 'ss9QcKVXwvcMTkHzw', // App-key
+            ];
+        }
+        $this->baseUrl = $cfg['base_url'];
+        $this->shopId = $cfg['shop_id'];
+        $this->appSecret = $cfg['secret'];
+        $this->clientId = $cfg['client_id'];
+    }
+
+    // 提交订单
+    public function createOrder($data)
+    {
+        $payload = [
+            "clientId" => $this->clientId,
+            //"accessToken" => $this->accessToken,
+            "timestamp" => (int) (microtime(true) * 1000),
+            "data" => [
+                "issOrderNo" => $data['iss_order_no'],
+            ],
+        ];
+
+        $sign = SignHelper::makeSign($payload, $this->appSecret);
+        $payload['sign'] = $sign;
+
+        $resp = HttpClient::post("{$this->baseUrl}/openapi/developer/v5/orderPlace", $payload);
+        return $resp;
+    }
+
+    /**
+     * 下单询价 - 订单计费接口
+     * 根据闪送官方文档 /openapi/developer/v5/orderCalculate
+     * 
+     * 入参 $data 结构参考:
+     * [
+     *     'city_name' => '北京市',
+     *     'sender' => [
+     *         'from_address' => '东升科技国际园',
+     *         'from_address_detail' => '2层202',
+     *         'from_sender_name' => '小闪',
+     *         'from_mobile' => '13800000000',
+     *         'from_latitude' => '40.047858',
+     *         'from_longitude' => '116.378424',
+     *     ],
+     *     'receiver_list' => [
+     *         [
+     *             'order_no' => 'C1119A000013053981',
+     *             'to_address' => '永泰庄地铁站',
+     *             'to_address_detail' => '1楼',
+     *             'to_receiver_name' => '小送',
+     *             'to_mobile' => '13800000001',
+     *             'to_latitude' => '40.043612',
+     *             'to_longitude' => '116.361199',
+     *             'good_type' => 5,           // 物品类型
+     *             'weight' => 2,              // 物品重量(kg,整数)
+     *             'remarks' => '',            // 备注
+     *         ]
+     *     ],
+     *     'appoint_type' => 0,              // 0: 立即单,1: 预约单
+     *     'appointment_date' => '',         // 预约时间 yyyy-MM-dd HH:mm
+     *     'store_id' => 393549,             // 店铺ID
+     *     'travel_way' => 0,                // 指定交通工具,0: 不限交通方式
+     *     'delivery_type' => 1,             // 1: 帮我送,2: 帮我取
+     *     'expect_start_time' => null,      // 期望送达时间起始(毫秒级时间戳)
+     *     'expect_end_time' => null,        // 期望送达时间终止(毫秒级时间戳)
+     * ]
+     */
+    public function getPrice($data, $accessToken)
+    {
+        // 构建发件人信息
+        $sd = $data['sender'];// $senderData
+        $sender = [
+            'fromAddress' => $sd['from_address'] ?? '',
+            'fromAddressDetail' => $sd['from_address_detail'] ?? '',
+            'fromSenderName' => $sd['from_sender_name'] ?? '',
+            'fromMobile' => $sd['from_mobile'] ?? '',
+            'fromLatitude' => $sd['from_latitude'] ?? '',
+            'fromLongitude' => $sd['from_longitude'] ?? '',
+        ];
+
+        // 构建收件人信息列表
+        $receiverList = [];
+        foreach ($data['receiver_list'] as $receiver) {
+            $receiverList[] = [
+                'orderNo' => $receiver['order_no'] ?? '',
+                'toAddress' => $receiver['to_address'] ?? '',
+                'toAddressDetail' => $receiver['to_address_detail'] ?? '',
+                'toReceiverName' => $receiver['to_receiver_name'] ?? '',
+                'toMobile' => $receiver['to_mobile'] ?? '',
+                'toLatitude' => $receiver['to_latitude'] ?? '',
+                'toLongitude' => $receiver['to_longitude'] ?? '',
+                'goodType' => (int) ($receiver['good_type'] ?? 10),  // 物品类型,默认10-其他
+                'weight' => (int) ($receiver['weight'] ?? 1),         // 物品重量(必须是整数kg)
+                'remarks' => $receiver['remarks'] ?? '',
+                'expectStartTime' => isset($receiver['expect_start_time']) ? (int) $receiver['expect_start_time'] : null,
+                'expectEndTime' => isset($receiver['expect_end_time']) ? (int) $receiver['expect_end_time'] : null,
+            ];
+
+            // 移除null值的期望时间字段
+            if ($receiverList[count($receiverList) - 1]['expectStartTime'] === null) {
+                unset($receiverList[count($receiverList) - 1]['expectStartTime']);
+            }
+            if ($receiverList[count($receiverList) - 1]['expectEndTime'] === null) {
+                unset($receiverList[count($receiverList) - 1]['expectEndTime']);
+            }
+        }
+
+        // 构建请求体
+        $requestData = [
+            'cityName' => $data['city_name'] ?? '',
+            'appointType' => (int) ($data['appoint_type'] ?? 0),
+            'sender' => $sender,
+            'receiverList' => $receiverList,
+            'storeId' => isset($data['store_id']) ? (int) $data['store_id'] : null,
+            'travelWay' => (int) ($data['travel_way'] ?? 0),
+            'deliveryType' => (int) ($data['delivery_type'] ?? 1),
+        ];
+
+        // 移除null值
+        $requestData = array_filter($requestData, function($value) {
+            return $value !== null && $value !== '';
+        });
+
+        // 如果有预约日期,添加到请求体
+        if (!empty($data['appointment_date'])) {
+            $requestData['appointmentDate'] = $data['appointment_date'];
+        }
+
+        // 构建签名用的完整payload
+        $payload = [
+            'clientId' => $this->clientId,
+            'accessToken' => $accessToken,
+            'timestamp' => (int) (microtime(true) * 1000),
+            'data' => $requestData,
+        ];
+
+        // 计算签名
+        $sign = SignHelper::makeSign($payload, $this->appSecret);
+        $payload['sign'] = $sign;
+
+        // 发送请求到计费接口
+        $resp = HttpClient::post("{$this->baseUrl}/openapi/developer/v5/orderCalculate", $payload);
+
+        // 处理响应
+        if (isset($resp['status']) && $resp['status'] == 200 && isset($resp['data'])) {
+            $respData = $resp['data'];
+            return [
+                'platform' => 'shansong',
+                'order_number' => $respData['orderNumber'] ?? '',
+                'total_distance' => $respData['totalDistance'] ?? 0,
+                'total_weight' => $respData['totalWeight'] ?? 0,
+                'total_amount' => $respData['totalAmount'] ?? 0,              // 订单总金额(分)
+                'coupon_save_fee' => $respData['couponSaveFee'] ?? 0,          // 优惠额度(分)
+                'total_fee_after_save' => $respData['totalFeeAfterSave'] ?? 0, // 实际支付费用(分)
+                'fee_info_list' => $respData['feeInfoList'] ?? [],             // 费用明细
+                'interest_dto_list' => $respData['interestDtoList'] ?? [],     // 增值服务明细
+                'estimate_grab_second' => $respData['estimateGrabSecond'] ?? -1,
+                'estimate_receive_second' => $respData['estimateReceiveSecond'] ?? -1,
+            ];
+        }
+
+        return null;
+    }
+}

+ 2 - 2
common/config/params.php

@@ -170,7 +170,7 @@ if (getenv('YII_ENV') == 'dev') {
 
     $config['ossBucket'] = 'pic-hhb-cs';
 
-    $config['expressShanUrl'] = 'http://open.s.bingex.com';
+    $config['expressShanUrl'] = 'http://open.s.bingex.com'; //闪送授权地址
 
 }
 if (getenv('YII_ENV') == 'production') {
@@ -200,7 +200,7 @@ if (getenv('YII_ENV') == 'production') {
 
     $config['ossBucket'] = 'pic-hhb-online';
 
-    $config['expressShanUrl'] = 'http://open.ishansong.com';
+    $config['expressShanUrl'] = 'http://open.ishansong.com'; //闪送授权地址
 
 }