appKey = 'dadaf2279d901a5ba40'; $this->appSecret = '828a03677a1c00a3a5b3c59209c4433d'; $this->redirectUri = 'https://api.shop.hzghd.com/delivery/dada-auth-callback'; $this->authorizeUrl = self::AUTHORIZE_URL_PROD; $this->tokenUrl = self::TOKEN_URL_PROD; } else { // 测试环境配置 $this->appKey = 'dadaf2279d901a5ba40'; $this->appSecret = '828a03677a1c00a3a5b3c59209c4433d'; $this->redirectUri = 'https://api.shop.hzghd.com/delivery/dada-auth-callback'; $this->authorizeUrl = self::AUTHORIZE_URL_TEST; $this->tokenUrl = self::TOKEN_URL_TEST; } $this->isSandbox = !$isProduction; } /** * 获取授权码 * * 调用达达 API 获取一次性授权码(ticket),后续授权流程需要此 ticket * 接口地址:GET /third/party/ticket * * 返回结果参数说明: * - status: 响应状态 * - code: 响应编码 * - msg: 响应描述 * - result: ticket,一次性准入码 * - errorCode: 错误编码 * * @return string|null ticket 一次性授权码,获取失败返回 null */ public function getTicket() { // 生成随机数 $nonce = $this->generateNonce(15); //$nonce = 'VV7JK4BJXAUSYP8'; // 写死,后期修改回去 // 生成签名 $sign = $this->generateTicketSign($nonce); // 构建请求参数 $params = [ 'appKey' => $this->appKey, 'nonce' => $nonce, 'sign' => $sign, ]; // 发送 GET 请求 $url = $this->authorizeUrl . 'third/party/ticket'; try { // 构建完整请求 URL $requestUrl = $url; if (!empty($params)) { $requestUrl .= '?' . http_build_query($params); } Yii::info("\n\n\n\n\n\nrequestUrl: " . $requestUrl); // 初始化 cURL $ch = curl_init(); // 设置选项 curl_setopt($ch, CURLOPT_URL, $requestUrl); curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); curl_setopt($ch, CURLOPT_HEADER, false); curl_setopt($ch, CURLOPT_TIMEOUT, 15); curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false); curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, false); curl_setopt($ch, CURLOPT_HTTPHEADER, [ 'Content-Type: application/json' ]); // 执行请求 $response = curl_exec($ch); $error = curl_error($ch); curl_close($ch); // 检查错误 if ($error) { throw new \Exception('cURL Request Error: ' . $error); } // 解析响应 $resp = json_decode($response, true); if (json_last_error() !== JSON_ERROR_NONE) { // 如果解析失败,可能是非 JSON 响应 // throw new \Exception('Invalid JSON Response: ' . $response); Yii::error('Invalid JSON Response: ' . $response); util::fail('请求达达获取ticket失败'); } // 检查响应状态,返回 ticket if (isset($resp['result']) && !empty($resp['result'])) { Yii::debug('成功获取授权码:' . $resp['result']); return $resp['result']; } Yii::warning('获取授权码失败:' . json_encode($resp)); return null; } catch (\Exception $e) { Yii::error('获取授权码异常:' . $e->getMessage()); return null; } } /** * 生成获取授权码请求的签名 * * 签名算法: * 1. 参与签名的参数按字典排序:appKey、appSecret、nonce * 2. 拼接参数值 * 3. SHA1 加密 * * 示例: * - appKey: dada6c68011157c5f63 * - appSecret: 828a03677a1c00a3a5b3c59209c4433d * - nonce: RHU3RY4YR234238 * 排序后拼接:dada6c68011157c5f63828a03677a1c00a3a5b3c59209c4433dRHU3RY4YR234238 * SHA1(上述字符串) * * @param string $nonce 随机数 * @return string SHA1 签名 */ private function generateTicketSign($nonce) { // 参与签名的参数 $signParams = [ 'appKey' => $this->appKey, 'nonce' => $nonce, 'appSecret' => $this->appSecret, ]; // 第一步 // 按键排序 //ksort($signParams, SORT_STRING); // 按值排序且不改变key //asort($signParams); sort($signParams, SORT_STRING); // 第二步:拼接所有参数值 $signString = implode('', $signParams); // 第二步:拼接 key 和 value // $signString = ''; // foreach ($signParams as $key => $value) { // $signString .= $key . $value; // } // $signString = $signString; // 第三步:SHA1 加密 $hex = sha1($signString); // $len = 36; // if ($len < strlen($hex)) { // $hex = substr($hex, 0, $len); // } //return strtoupper(substr($hex, 0, 36)); //return strtoupper($hex); return $hex; } /** * 生成获取授权码请求的签名 * * 签名算法: * 1. 参与签名的参数值按字典排序:appKey、appSecret、nonce * 2. 拼接参数值 * 3. SHA1 加密 * * @param string $nonce 随机数 * @return string SHA1 签名 */ function generateTicketSign_234($nonce) { // 1. 按字段名升序拼接 $map = [ 'appKey' => $this->appKey, 'appSecret' =>$this->appSecret, 'nonce' => $nonce, ]; ksort($map, SORT_STRING); // 按 key 升序 $raw = ''; foreach ($map as $k => $v) { $raw .= $k . $v; // 不要加 & = 空格 } //$raw = implode('', $map); // 2. SHA1 得到 40 位 16 进制(小写) $sha1 = sha1($raw, false); // 第二个参数=false 返回十六进制字符串 // 3. 转大写 + 截前 36 位 //return strtoupper(substr($sha1, 0, 36)); return strtoupper($sha1); return sha1($raw); } private function newGenerateTicketSign($nonce) { // 参与签名的参数 $signParams = [ 'appKey' => $this->appKey, 'nonce' => $nonce, 'appSecret' => $this->appSecret, ]; // 第一步:将参与签名的参数按照键值(key)进行字典排序 ksort($signParams); // 第二步:将排序过后的参数,进行key和value字符串拼接 $signString = ''; foreach ($signParams as $key => $value) { $signString .= $key . $value; } // 第三步:将拼接后的字符串首尾加上app_secret秘钥,合成签名字符串 $finalSignString = $this->appSecret . $signString . $this->appSecret; // 第四步:对签名字符串进行MD5加密,生成32位的字符串 $sign = md5($finalSignString); // 第五步:将签名生成的32位字符串转换为大写 return strtoupper($sign); } /** * 生成授权链接 * * 用户需要访问此链接进行授权,授权后会重定向到 redirectUri * 根据达达文档,授权链接需要以下参数: * - appKey: 应用 key * - shopId: 三方门店编号(可选) * - redirectUrl: 回调地址 * - state: 回调标识(用于 CSRF 防护) * - nonce: 随机数 * - ticket: 渠道授权码(需要从获取渠道授权码接口获取) * - sign: 签名字符串 * - resultType: 是否跳转(可选,0-默认结果页,1-跳转) * * @param string $ticket 渠道授权码(从"获取渠道授权码"接口获取) * @param string $state 应用程序定义的不透明值(用于 CSRF 防护) * @param string $shopId 三方门店编号(可选) * @param int $resultType 是否跳转(可选,0-默认结果页,1-跳转) * @return string 授权链接 * @throws \Exception 当缺少必要参数时 */ public function generateAuthUrl($ticket, $state = '', $shopId = '', $resultType = 0) { if (empty($ticket)) { throw new \Exception('ticket 参数不能为空,需要从"获取渠道授权码"接口获取'); } // 生成随机数 nonce $nonce = $this->generateNonce(); // 生成签名 $sign = $this->generateAuthSign($ticket, $nonce, $shopId); // 构建授权链接参数 $params = [ 'appKey' => $this->appKey, 'redirectUrl' => $this->redirectUri, 'state' => $state ?: uniqid(), 'nonce' => $nonce, 'ticket' => $ticket, 'sign' => $sign, ]; // 添加可选参数 if (!empty($shopId)) { $params['shopId'] = $shopId; } if ($resultType != 0) { $params['resultType'] = $resultType; } return $this->authorizeUrl . 'third/party/oauth' . '?' . http_build_query($params); } private function generateAuthSign($ticket, $nonce, $shopId = '') { // 参与签名的参数 $signParams = [ 'appKey' => $this->appKey, 'ticket' => $ticket, 'nonce' => $nonce, 'appSecret' => $this->appSecret, ]; if($shopId!=''){ $signParams['shopId'] = $shopId; } // 第一步 sort($signParams, SORT_STRING); // 第二步:拼接所有参数值 $signString = implode('', $signParams); // 第三步:SHA1 加密 $hex = sha1($signString); return $hex; } /** * 生成随机数(nonce) * * @return string 8位随机字母数字组合 */ private function generateNonce($length = 8) { $characters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'; $nonce = ''; for ($i = 0; $i < $length; $i++) { $nonce .= $characters[rand(0, strlen($characters) - 1)]; } return $nonce; } /** * 生成签名(sign) * * 签名算法: * 1. 参与签名的参数按字典顺序排列:appKey、nonce、ticket、shopId(如果有) * 2. 按 key+value 拼接 * 3. 首尾加上 appSecret * 4. MD5 加密 * 5. 转大写 * * @param string $ticket 渠道授权码 * @param string $nonce 随机数 * @param string $shopId 三方门店编号(可选) * @return string 签名字符串(32位大写MD5) */ private function generateSign($ticket, $nonce, $shopId = '') { // 参与签名的参数 $signParams = [ 'appKey' => $this->appKey, 'nonce' => $nonce, 'ticket' => $ticket, ]; // 如果有shopId,也加入签名参数 if (!empty($shopId)) { $signParams['shopId'] = $shopId; } // 第一步:按键值字典排序 ksort($signParams); // 第二步:拼接 key 和 value $signString = ''; foreach ($signParams as $key => $value) { $signString .= $key . $value; } // 第三步:首尾加上 appSecret $finalSignString = $this->appSecret . $signString . $this->appSecret; // 第四步:MD5 加密 $sign = md5($finalSignString); // 第五步:转大写 return strtoupper($sign); } /** * 通过授权码获取访问令牌 * * 用户授权后,使用授权码兑换访问令牌 * * @param string $code 授权码(从授权回调中获取) * @return array|null 令牌信息 * [ * 'access_token' => '访问令牌', * 'token_type' => 'Bearer', * 'expires_in' => 令牌过期时间(秒), * 'refresh_token' => '刷新令牌', * 'scope' => '授权范围' * ] */ public function getAccessToken($code) { if (empty($code)) { return null; } $params = [ 'grant_type' => self::GRANT_TYPE_AUTH_CODE, 'code' => $code, 'client_id' => $this->appKey, 'client_secret' => $this->appSecret, 'redirect_uri' => $this->redirectUri, ]; $resp = HttpClient::post($this->tokenUrl, $params); // 检查响应是否成功 if (isset($resp['access_token'])) { return [ 'access_token' => $resp['access_token'], 'token_type' => $resp['token_type'] ?? 'Bearer', 'expires_in' => $resp['expires_in'] ?? 3600, 'refresh_token' => $resp['refresh_token'] ?? null, 'scope' => $resp['scope'] ?? '', 'create_time' => time(), ]; } Yii::error('Failed to get access token: ' . json_encode($resp)); return null; } /** * 使用刷新令牌获取新的访问令牌 * * 当访问令牌过期时,使用刷新令牌获取新的访问令牌 * * @param string $refreshToken 刷新令牌 * @return array|null 新的令牌信息 */ public function refreshAccessToken($refreshToken) { if (empty($refreshToken)) { return null; } $params = [ 'grant_type' => self::GRANT_TYPE_REFRESH, 'refresh_token' => $refreshToken, 'client_id' => $this->appKey, 'client_secret' => $this->appSecret, ]; $resp = HttpClient::post($this->tokenUrl, $params); // 检查响应是否成功 if (isset($resp['access_token'])) { return [ 'access_token' => $resp['access_token'], 'token_type' => $resp['token_type'] ?? 'Bearer', 'expires_in' => $resp['expires_in'] ?? 3600, 'refresh_token' => $resp['refresh_token'] ?? $refreshToken, 'scope' => $resp['scope'] ?? '', 'create_time' => time(), ]; } Yii::error('Failed to refresh access token: ' . json_encode($resp)); return null; } /** * 检查访问令牌是否已过期 * * @param array $tokenInfo 令牌信息 * [ * 'access_token' => '...', * 'expires_in' => 3600, * 'create_time' => 时间戳, * ] * @return bool 是否已过期 */ public function isTokenExpired($tokenInfo) { if (empty($tokenInfo) || !is_array($tokenInfo)) { return true; } $createTime = $tokenInfo['create_time'] ?? 0; $expiresIn = $tokenInfo['expires_in'] ?? 0; $currentTime = time(); // 提前 300 秒(5 分钟)进行刷新,避免临界情况 return ($createTime + $expiresIn - 300) <= $currentTime; } }