Procházet zdrojové kódy

把滴滴货运平台接入(实现主要方法)

shizhongqi před 8 měsíci
rodič
revize
8599c09b09

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

@@ -83,6 +83,7 @@ class DeliveryController extends BaseController
                 $Auth->shunfengAuth($mainId);
                 break;
             case 'didi':
+                $Auth->didiAuth($mainId, $this->shopId);
                 break;
             default:
                 util::error(-1, $platform . '不存在');

+ 10 - 0
common/components/delivery/platform/dada/CallBackHandler.php

@@ -0,0 +1,10 @@
+<?php
+
+
+namespace common\components\delivery\platform\dada;
+
+
+trait CallBackHandler
+{
+
+}

+ 10 - 0
common/components/delivery/platform/dada/Dada.php

@@ -0,0 +1,10 @@
+<?php
+
+
+namespace common\components\delivery\platform\dada;
+
+
+class Dada
+{
+
+}

+ 141 - 0
common/components/delivery/platform/didi/Auth.php

@@ -0,0 +1,141 @@
+<?php
+namespace common\components\delivery\platform\didi;
+
+use common\components\delivery\helpers\HttpClient;
+use Yii;
+
+/**
+ * 滴滴商户授权
+ * 
+ * 文档参考:
+ * 获取授权链接:/gateway?api=freight.open.platform.channel.standard.authUrl
+ * 取消授权:/gateway?api=freight.open.platform.channel.standard.cancelAuth
+ */
+class Auth
+{
+    // API 接口名称
+    const API_AUTH_URL = 'freight.open.platform.channel.standard.authUrl';
+    const API_CANCEL_AUTH = 'freight.open.platform.channel.standard.cancelAuth';
+
+    /**
+     * 获取授权页面链接
+     * 
+     * 用户发单前,需要先进行授权绑定账号,获取此链接跳转到滴滴h5页面进行授权
+     * 
+     * @param string $thirdUid 接入方用户id
+     * @param string $cityId 城市id(国标码,市级别)。与lat/lng二选一传递
+     * @param string $lat 纬度
+     * @param string $lng 经度
+     * @return array ['success' => bool, 'authUrl' => string, 'error' => string]
+     */
+    public function getAuthUrl($thirdUid, $cityId = '', $lat = '', $lng = '')
+    {
+        // 此接口不需要传token参数
+        $didi = new Didi();
+        
+        // 校验:城市编码与经纬度二选一
+        if (empty($cityId) && (empty($lat) || empty($lng))) {
+            return [
+                'success' => false,
+                'error' => '参数错误:城市编码(cityId)与经纬度(lat,lng)必须二选一传递'
+            ];
+        }
+
+        $params = [
+            'thirdUid' => (string)$thirdUid,
+        ];
+        
+        if ($cityId) {
+            $params['cityId'] = (string)$cityId;
+        }
+        if ($lat) {
+            $params['lat'] = (float)$lat;
+        }
+        if ($lng) {
+            $params['lng'] = (float)$lng;
+        }
+
+        $payload = $didi->buildRequestPayload($params);
+        $url = $didi->getApiUrl(self::API_AUTH_URL);
+
+        $response = HttpClient::post($url, $payload);
+        
+        return $this->parseResponse($response);
+    }
+
+    /**
+     * 取消授权
+     * 
+     * 用户不再使用滴滴平台发单,取消授权后token失效,后续再使用需要重新授权
+     * 
+     * @param string $accessToken 授权token
+     * @return array ['success' => bool, 'result' => int, 'error' => string]
+     */
+    public function cancelAuth($accessToken)
+    {
+        // 实例化 Didi 类传入 token,buildRequestPayload 会自动处理 token
+        $didi = new Didi($accessToken);
+        
+        // 入参说明无业务参数
+        $payload = $didi->buildRequestPayload(self::API_CANCEL_AUTH, []);
+        $url = $didi->getApiUrl(self::API_CANCEL_AUTH);
+        
+        $response = HttpClient::post($url, $payload);
+        
+        return $this->parseResponse($response);
+    }
+
+    /**
+     * 解析 API 响应
+     * 
+     * @param array $response
+     * @return array
+     */
+    protected function parseResponse($response)
+    {
+        // 检查 HttpClient 返回的错误结构
+        if (isset($response['code']) && isset($response['body']) && !isset($response['errno'])) {
+            return [
+                'success' => false,
+                'error' => 'HTTP Request Failed: ' . ($response['error'] ?? $response['body']),
+                'code' => $response['code']
+            ];
+        }
+
+        // 检查滴滴业务错误码 (errno)
+        if (isset($response['errno'])) {
+            if ($response['errno'] != 0) {
+                return [
+                    'success' => false,
+                    'error' => $response['errmsg'] ?? 'Unknown API error',
+                    'errno' => $response['errno']
+                ];
+            }
+            
+            $data = $response['data'] ?? [];
+            
+            // 构造统一返回格式
+            $result = [
+                'success' => true,
+                'data' => $data,
+            ];
+            
+            // 提取关键字段到顶层,方便调用
+            if (isset($data['authUrl'])) {
+                $result['authUrl'] = $data['authUrl'];
+            }
+            if (isset($data['result'])) {
+                $result['result'] = $data['result'];
+            }
+            
+            return $result;
+        }
+
+        // 未知格式
+        return [
+            'success' => false,
+            'error' => 'Invalid response format',
+            'raw' => $response
+        ];
+    }
+}

+ 7 - 0
common/components/delivery/platform/didi/CallBackHandler.php

@@ -0,0 +1,7 @@
+<?php
+
+
+class CallBackHandler
+{
+
+}

+ 114 - 0
common/components/delivery/platform/didi/Didi.php

@@ -0,0 +1,114 @@
+<?php
+namespace common\components\delivery\platform\didi;
+
+class Didi
+{
+    protected $baseUrl;
+    protected $appKey;    // URL参数中的appKey
+    protected $appId;     // Body参数中的appId
+    protected $appSecret; // 签名用的secret
+    protected $accessToken;
+    protected $apiVersion = '1.0.0';
+    protected $isSandbox;
+
+    public function __construct($accessToken = '')
+    {
+        if (getenv('YII_ENV') == 'production') {
+            $cfg = [
+                'base_url' => 'https://freight.xiaojukeji.com',
+                'app_key' => '6724e08ff1ac4afdbf1c000cfe722449',
+                // TODO: 请填入正式环境的 appId 和 secret
+                'app_id' => '', 
+                'secret' => '',
+            ];
+            $this->isSandbox = false;
+        } else {
+            $cfg = [
+                'base_url' => 'http://pinzhi.didichuxing.com/kop_osim',
+                'app_key' => '4329d266f40144829ca5fd47a025e106',
+                // 请填入测试环境的 appId 和 secret
+                'app_id' => 'MDdovjIS',
+                'secret' => 'b61603625ebcf69a6364fdc30ae47944630c738c',
+            ];
+            $this->isSandbox = true;
+        }
+        $this->baseUrl = $cfg['base_url'];
+        $this->appKey = $cfg['app_key'];
+        $this->appId = $cfg['app_id'];
+        $this->appSecret = $cfg['secret'];
+        $this->accessToken = $accessToken;
+    }
+
+    /**
+     * 构建API请求参数
+     *
+     * @param array $businessParams 业务参数
+     * @return array
+     */
+    public function buildRequestPayload($businessParams = [])
+    {
+        // 1. 准备公共参数
+        $timestamp = (int)(microtime(true) * 1000); // 毫秒级时间戳
+
+        // 2. 处理业务参数 logisticsParam (JSON字符串)
+        $logisticsParam = json_encode($businessParams, JSON_UNESCAPED_UNICODE); // 注意:根据文档,logisticsParam 是 json 字符串
+
+        // 3. 生成签名
+        $sign = $this->generateSignature($logisticsParam, $timestamp);
+
+        // 4. 构建最终请求体
+        $payload = [
+            'appId' => $this->appId,
+            'timestamp' => $timestamp,
+            'appSign' => $sign,
+            'logisticsParam' => $logisticsParam,
+        ];
+
+        // Token 可选,如果存在则添加
+        if (!empty($this->accessToken)) {
+            $payload['token'] = $this->accessToken;
+        }
+
+        // 默认自主模式 businessMode=0,如果有需要可扩展
+        // $payload['businessMode'] = 0;
+
+        return $payload;
+    }
+
+    /**
+     * 生成签名
+     * 签名规则:MD5(logisticsParam + secret + timestamp)
+     * 
+     * @param string $logisticsParam json字符串
+     * @param int $timestamp 毫秒时间戳
+     * @return string
+     */
+    protected function generateSignature($logisticsParam, $timestamp)
+    {
+        $str = $logisticsParam . $this->appSecret . $timestamp;
+        return md5($str);
+    }
+
+    /**
+     * 获取完整的API请求URL
+     *
+     * @param string $apiMethod API名称
+     * @return string
+     */
+    public function getApiUrl($apiMethod)
+    {
+        // url格式:{host}/gateway?api=xxx&apiVersion=1.0.0&appKey=xxx
+        $queryParams = [
+            'api' => $apiMethod,
+            'apiVersion' => $this->apiVersion,
+            'appKey' => $this->appKey,
+        ];
+        
+        return $this->baseUrl . '/gateway?' . http_build_query($queryParams);
+    }
+
+    public function getIsSandbox()
+    {
+        return $this->isSandbox;
+    }
+}

+ 17 - 0
common/components/delivery/services/AuthService.php

@@ -1,6 +1,7 @@
 <?php
 namespace common\components\delivery\services;
 
+use bizHd\shop\classes\ShopClass;
 use common\components\util;
 
 Class AuthService{
@@ -70,4 +71,20 @@ Class AuthService{
         $authUrl = $shunfengAuth->generateAuthUrl($mainId);
         util::success(['url'=>$authUrl]);
     }
+
+    public function didiAuth($mainId, $shopId)
+    {
+        // $shop = ShopClass::getById($shopId, false, 'id,lat,long');
+        // if(!$shop){
+        //     util::fail('门店不存在');
+        // }
+        // $lat = $shop['lat'];
+        // $lng = $shop['long'];
+        // if(empty($lat) || empty($lng)){
+        //     util::fail('门店经纬度不存在');
+        // }
+        $didiAuth = new \common\components\delivery\platform\didi\Auth();
+        $authUrl = $didiAuth->getAuthUrl($mainId, 110100, '', '');
+        util::success(['url'=>$authUrl]);
+    }
 }

+ 370 - 0
common/components/delivery/services/adapter/DidiAdapter.php

@@ -0,0 +1,370 @@
+<?php
+namespace common\components\delivery\services\adapter;
+
+use common\components\delivery\helpers\HttpClient;
+use common\components\delivery\platform\didi\Didi;
+use Yii;
+
+class DidiAdapter extends Didi implements Adapter
+{
+    /**
+     * 物品类型映射
+     * Didi: FOOD, FLOWER, CAKE, FILE, FRUIT, ELECTRONIC, DRESS, MEDICINAL, TEXTILE, FRESH, CAR_PART, OTHER
+     */
+    const GOODS_TYPE_MAP = [
+        '1' => 'FILE',      // 文件
+        '2' => 'FOOD',      // 餐饮
+        '3' => 'CAKE',      // 蛋糕
+        '4' => 'FLOWER',    // 鲜花
+        '5' => 'ELECTRONIC',// 数码
+        '6' => 'MEDICINAL', // 医药
+        '8' => 'OTHER',     // 其他
+        // 根据实际情况补充
+    ];
+
+    public function cityList()
+    {
+        // 滴滴快送暂无城市列表接口,返回空
+        return [];
+    }
+
+    /**
+     * 构建订单计费请求信息(供并发请求使用)
+     * 
+     * @param array $data 订单数据
+     * @param string $orderTime 配送时间
+     * @return array 包含 url, data, headers, timeout 的请求信息
+     */
+    public function buildPriceRequest($data, $orderTime)
+    {
+        $sender = $data['sender'];
+        // 滴滴目前主要支持单点对单点,取第一个收件人
+        $receiver = $data['receiverList'][0] ?? [];
+        if (empty($receiver)) {
+            return [];
+        }
+
+        // 服务参数列表 (询价时可以传入多个,获取不同服务的价格)
+        // 假设这里主要请求快送(BizType=12)
+        $serviceCategoryList = [
+            [
+                'bizType' => 12,     // 业务类型 -- 快送
+                'carType' => 1018,   // 车型 -- 两轮车
+                'serviceLevel' => 0, // 服服务等级
+                'serviceType' => 1,  // 服务类型(快送专用,拉货不需要传) -- 直送
+            ],
+            [
+                'bizType' => 12,
+                'carType' => 1017,   // 小轿车
+                'serviceLevel' => 0,
+                'serviceType' => 1,
+            ]
+        ];
+
+        // 构造起点信息
+        $fromAddress = [
+            'name' => $sender['fromSenderName'],
+            'phone' => $sender['fromMobile'],
+            'poiName' => $sender['fromAddress'],
+            'poiAddress' => $sender['fromAddress'],
+            'addressInfo' => $sender['fromAddressDetail'],
+            'latitude' => (float)$sender['fromLatitude'],
+            'longitude' => (float)$sender['fromLongitude'],
+            'virtualPhoneType' => 0, // 0-真实号码
+        ];
+
+        // 构造终点信息
+        $toAddress = [
+            'name' => $receiver['toReceiverName'],
+            'phone' => $receiver['toMobile'],
+            'poiName' => $receiver['toAddress'],
+            'poiAddress' => $receiver['toAddress'],
+            'addressInfo' => $receiver['toAddressDetail'],
+            'latitude' => (float)$receiver['toLatitude'],
+            'longitude' => (float)$receiver['toLongitude'],
+            'virtualPhoneType' => 0,
+        ];
+
+        // 物品信息
+        $weight = (int)($receiver['weight'] ?? 1); // kg
+        $weightGram = $weight * 1000; // 转为克
+        
+        $goodTypeInt = $receiver['goodType'] ?? '8';
+        $goodsType = self::GOODS_TYPE_MAP[$goodTypeInt] ?? 'FLOWER';
+
+        // 预约单处理
+        $modeType = 1; // 1-实时单
+        $useTime = null;
+        if ($orderTime) {
+            $ts = strtotime($orderTime);
+            if ($ts > time() + 1800) { // 30分钟后算预约? 滴滴快送暂不支持预约单,这里保留逻辑备用
+                // $modeType = 2; 
+                // $useTime = $ts * 1000;
+                // 注意:文档说“快送暂不支持预约单都按照实时单处理”,所以这里可能无需处理,或者传了也没用
+            }
+        }
+
+        // ------------------ 入参构建 ------------------
+        $businessParams = [
+            'thirdUid' => $sender['fromMobile'], // 使用发件人手机作为接入方用户唯一标识
+            'serviceCategoryList' => $serviceCategoryList,
+            'fromAddress' => $fromAddress,
+            'toAddress' => $toAddress,
+            'goodsType' => $goodsType,
+            'weight' => $weightGram,
+            'needPickupCode' => false,
+            'needReceiveCode' => true, // 通常需要收件码
+            'modeType' => $modeType,
+        ];
+
+        if ($useTime) {
+            $businessParams['useTime'] = $useTime;
+        }
+        
+        // 构建请求Payload
+        $payload = $this->buildRequestPayload($businessParams);
+        $apiUrl = $this->getApiUrl('freight.open.platform.channel.standard.estimate');
+
+        return [
+            'url' => $apiUrl,
+            'data' => $payload,
+            'headers' => [],
+            'timeout' => 10.0,
+        ];
+    }
+
+    /**
+     * 处理询价响应
+     */
+    public function processPriceResponse($resp)
+    {
+        if (isset($resp['code']) && $resp['code'] == 0 && isset($resp['data'])) {
+            $data = $resp['data'];
+            $estimateList = $data['estimateList'] ?? [];
+            if (empty($estimateList)) {
+                return null;
+            }
+
+            // 选取第一个结果,或者根据策略选取(比如最便宜) TODO
+            // 这里简单取第一个
+            $estimate = $estimateList[0];
+
+            return [
+                'platform' => 'didi',
+                'estimate_id' => $estimate['estimateId'], // 预估ID,发单用
+                'total_distance' => $estimate['distance'] ?? 0,
+                'total_amount' => $estimate['fee'] ?? 0, // 分
+                'total_fee_after_save' => $estimate['fee'] ?? 0,
+                'coupon_save_fee' => $estimate['discountFee'] ?? 0,
+                'fee_info_list' => $estimate['feeList'] ?? [],
+                // 补充其他字段以匹配通用结构
+                'total_weight' => 0, // 响应里没返回重量
+            ];
+        }
+        return null;
+    }
+
+    /**
+     * 发单
+     */
+    public function createOrder($order)
+    {
+        // 解析 $order 数据,注意这里的 $order 结构可能与 buildPriceRequest 的 $data 不同
+        // 通常 service 层会将 price check 的结果合并进来,或者前端传回 estimateId
+        
+        $estimateId = $order['estimate_id'] ?? null;
+        if (!$estimateId) {
+            return [
+                'code' => -1,
+                'platform' => 'didi',
+                'msg' => '缺少estimate_id',
+                'data' => []
+            ];
+        }
+
+        $sender = $order['sender'];
+        $receiver = $order['receiverList'][0];
+        
+        $fromAddress = [
+            'name' => $sender['fromSenderName'],
+            'phone' => $sender['fromMobile'],
+            'poiName' => $sender['fromAddress'],
+            'poiAddress' => $sender['fromAddress'],
+            'addressInfo' => $sender['fromAddressDetail'],
+            'latitude' => (float)$sender['fromLatitude'],
+            'longitude' => (float)$sender['fromLongitude'],
+            'virtualPhoneType' => 0,
+        ];
+
+        $toAddress = [
+            'name' => $receiver['toReceiverName'],
+            'phone' => $receiver['toMobile'],
+            'poiName' => $receiver['toAddress'],
+            'poiAddress' => $receiver['toAddress'],
+            'addressInfo' => $receiver['toAddressDetail'],
+            'latitude' => (float)$receiver['toLatitude'],
+            'longitude' => (float)$receiver['toLongitude'],
+            'virtualPhoneType' => 0,
+        ];
+
+        $weight = (int)($receiver['weight'] ?? 1);
+        $weightGram = $weight * 1000;
+        
+        $goodTypeInt = $receiver['goodType'] ?? '8';
+        $goodsType = self::GOODS_TYPE_MAP[$goodTypeInt] ?? 'OTHER';
+
+        // ------------------ 入参构建 ------------------
+        $businessParams = [
+            'estimateIdList' => [$estimateId], // 预估ID
+            'outOrderNo' => $order['issOrderNo'], // 接入方订单号
+            'fromAddress' => $fromAddress,
+            'toAddress' => $toAddress,
+            'goodsType' => $goodsType,
+            'weight' => $weightGram,
+            'needPickupCode' => false,
+            'needReceiveCode' => true,
+            'thirdUid' => $sender['fromMobile'],
+            'remark' => $order['remark'] ?? '',
+        ];
+
+        $payload = $this->buildRequestPayload($businessParams);
+        $url = $this->getApiUrl('freight.open.platform.channel.standard.createOrder');
+
+        $resp = HttpClient::post($url, $payload);
+
+        if (isset($resp['code']) && $resp['code'] == 0 && isset($resp['data'])) {
+            $respData = $resp['data'];
+            return [
+                'code' => 0,
+                'platform' => 'didi',
+                'data' => [
+                    'order_id' => $respData['didiOrderNo'], // 滴滴订单号
+                    // 可以附带其他信息
+                ]
+            ];
+        }
+
+        $msg = $resp['msg'] ?? 'Unknown error';
+        if (isset($resp['code']) && isset($resp['subCode'])) {
+             $msg .= " ({$resp['code']}-{$resp['subCode']})";
+        }
+
+        Yii::error('[DidiAdapter] createOrder failed: ' . json_encode($resp, JSON_UNESCAPED_UNICODE));
+        
+        return [
+            'code' => -1,
+            'platform' => 'didi',
+            'msg' => $msg,
+            'data' => []
+        ];
+    }
+
+    /**
+     * 添加小费
+     */
+    public function addTip($data)
+    {
+        $outOrderNo = $data['orderId'] ?? null; // 这里假设传入的是接入方订单号,或者需要映射
+        // 注意:ShansongAdapter用的是 issOrderNo (内部订单号),Didi addTip需要 outOrderNo (接入方订单号)
+        // 假设 $data['orderId'] 就是 outOrderNo
+        
+        $fee = $data['tips'] ?? 0; // 分
+
+        if (empty($outOrderNo)) {
+             return ['code' => -1, 'platform' => 'didi', 'msg' => '订单号不能为空'];
+        }
+        if ($fee <= 0) {
+            return ['code' => -1, 'platform' => 'didi', 'msg' => '小费金额必须大于0'];
+        }
+
+        $businessParams = [
+            'outOrderNo' => $outOrderNo,
+            'fee' => (int)$fee,
+        ];
+
+        $payload = $this->buildRequestPayload($businessParams);
+        $url = $this->getApiUrl('freight.open.platform.channel.standard.addTipFee');
+
+        $resp = HttpClient::post($url, $payload);
+
+        if (isset($resp['code']) && $resp['code'] == 0) {
+            return [
+                'code' => 0,
+                'platform' => 'didi',
+                'data' => $resp['data'] ?? []
+            ];
+        }
+
+        return [
+            'code' => $resp['code'] ?? -1,
+            'platform' => 'didi',
+            'msg' => $resp['msg'] ?? 'Unknown error',
+            'data' => null
+        ];
+    }
+
+    /**
+     * 取消订单
+     */
+    public function cancelOrder($orderId)
+    {
+        // $orderId 应该是 outOrderNo (接入方订单号)
+        // Didi API 需要 outOrderNo 和 didiOrderNo。
+        // 如果我们只有 one of them,可能需要先查询?
+        // 接口文档说: outOrderNo 是必须, didiOrderNo 是必须.
+        // 这有点麻烦。通常 Adapter 的 input 只包含一个 ID。
+        // 假设 $orderId 是 outOrderNo。
+        // 此时我们可能没有 didiOrderNo,或者它需要作为第二个参数传入?
+        // 参考 ShansongAdapter,它只传了一个 $orderId (issOrderNo).
+        // 如果必须两个都传,可能需要修改调用方,或者我们只传 outOrderNo 试试,或者 look up mapping.
+        // 文档: outOrderNo (String, 是), didiOrderNo (Long, 是).
+        // 如果无法提供 didiOrderNo,可能无法取消。
+        // 但通常业务系统会保存 didiOrderNo。
+        // 这里假设调用方会在参数里传,或者 $orderId 是一个包含多个ID的数组?
+        // 按照 ShansongAdapter: cancelOrder($orderId).
+        // 只能假设 $orderId 结构可能包含 didiOrderNo,或者我们尝试只传 outOrderNo 看是否可行 (文档说是必须)。
+        // 为了兼容性,如果 $orderId 是数组,则取值;如果是字符串,视为 outOrderNo,且 didiOrderNo 填 0 或空?
+        // 让我们看看能不能只传 outOrderNo. "接口根据接入方订单号幂等...".
+        // 还是严格按照文档吧。如果调用方没传 didiOrderNo,这步会失败。
+        
+        // 临时处理:检查 $orderId 是否是数组
+        $didiOrderNo = 0;
+        $outOrderNo = $orderId;
+        
+        if (is_array($orderId)) {
+            $outOrderNo = $orderId['order_no'] ?? '';
+            $didiOrderNo = $orderId['platform_order_no'] ?? 0;
+        }
+
+        $businessParams = [
+            'outOrderNo' => (string)$outOrderNo,
+            'didiOrderNo' => (int)$didiOrderNo,
+            'cancelSource' => 1, // 用户取消
+            'cancelReason' => '暂不需要',
+        ];
+
+        $payload = $this->buildRequestPayload($businessParams);
+        $url = $this->getApiUrl('freight.open.platform.channel.standard.cancel');
+
+        $resp = HttpClient::post($url, $payload);
+
+        if (isset($resp['code']) && $resp['code'] == 0 && isset($resp['data'])) {
+            $data = $resp['data'];
+            return [
+                'code' => 0,
+                'platform' => 'didi',
+                'data' => [
+                    'deductionFee' => $data['cancelFee'] ?? 0, // 分
+                    'cancelResult' => $data['cancelResult'],
+                ]
+            ];
+        }
+
+        return [
+            'code' => $resp['code'] ?? -1,
+            'platform' => 'didi',
+            'msg' => $resp['msg'] ?? 'Unknown error',
+            'data' => null
+        ];
+    }
+}