| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528 |
- <?php
- namespace common\components\delivery\platform\dada;
- use common\components\delivery\helpers\HttpClient;
- use common\components\util;
- use Yii;
- /**
- * 达达(DaDa)开放平台授权认证类
- *
- * 处理达达开放平台的授权流程,包括:
- * - OAuth2.0 授权
- * - 授权码兑换令牌
- * - 令牌刷新
- */
- class Auth
- {
- // OAuth 参数
- const GRANT_TYPE_AUTH_CODE = 'authorization_code';
- const GRANT_TYPE_REFRESH = 'refresh_token';
-
- // 授权端点
- const AUTHORIZE_URL_PROD = 'https://newopen.imdada.cn/';
- const TOKEN_URL_PROD = '';
-
- const AUTHORIZE_URL_TEST = 'https://newopen.qa.imdada.cn/';
- const TOKEN_URL_TEST = '';
-
- protected $appKey;
- protected $appSecret;
- protected $redirectUri;
- protected $isSandbox;
- protected $authorizeUrl;
- protected $tokenUrl;
-
- /**
- * 初始化授权类
- * 根据环境获取配置信息
- */
- public function __construct()
- {
- $isProduction = getenv('YII_ENV') == 'dev'; // TODO
-
- if ($isProduction) {
- // 生产环境配置(需要替换为实际的生产环境凭证)
- $this->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;
- }
- }
|