Auth.php 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528
  1. <?php
  2. namespace common\components\delivery\platform\dada;
  3. use common\components\delivery\helpers\HttpClient;
  4. use common\components\util;
  5. use Yii;
  6. /**
  7. * 达达(DaDa)开放平台授权认证类
  8. *
  9. * 处理达达开放平台的授权流程,包括:
  10. * - OAuth2.0 授权
  11. * - 授权码兑换令牌
  12. * - 令牌刷新
  13. */
  14. class Auth
  15. {
  16. // OAuth 参数
  17. const GRANT_TYPE_AUTH_CODE = 'authorization_code';
  18. const GRANT_TYPE_REFRESH = 'refresh_token';
  19. // 授权端点
  20. const AUTHORIZE_URL_PROD = 'https://newopen.imdada.cn/';
  21. const TOKEN_URL_PROD = '';
  22. const AUTHORIZE_URL_TEST = 'https://newopen.qa.imdada.cn/';
  23. const TOKEN_URL_TEST = '';
  24. protected $appKey;
  25. protected $appSecret;
  26. protected $redirectUri;
  27. protected $isSandbox;
  28. protected $authorizeUrl;
  29. protected $tokenUrl;
  30. /**
  31. * 初始化授权类
  32. * 根据环境获取配置信息
  33. */
  34. public function __construct()
  35. {
  36. $isProduction = getenv('YII_ENV') == 'dev'; // TODO
  37. if ($isProduction) {
  38. // 生产环境配置(需要替换为实际的生产环境凭证)
  39. $this->appKey = 'dadaf2279d901a5ba40';
  40. $this->appSecret = '828a03677a1c00a3a5b3c59209c4433d';
  41. $this->redirectUri = 'https://api.shop.hzghd.com/delivery/dada-auth-callback';
  42. $this->authorizeUrl = self::AUTHORIZE_URL_PROD;
  43. $this->tokenUrl = self::TOKEN_URL_PROD;
  44. } else {
  45. // 测试环境配置
  46. $this->appKey = 'dadaf2279d901a5ba40';
  47. $this->appSecret = '828a03677a1c00a3a5b3c59209c4433d';
  48. $this->redirectUri = 'https://api.shop.hzghd.com/delivery/dada-auth-callback';
  49. $this->authorizeUrl = self::AUTHORIZE_URL_TEST;
  50. $this->tokenUrl = self::TOKEN_URL_TEST;
  51. }
  52. $this->isSandbox = !$isProduction;
  53. }
  54. /**
  55. * 获取授权码
  56. *
  57. * 调用达达 API 获取一次性授权码(ticket),后续授权流程需要此 ticket
  58. * 接口地址:GET /third/party/ticket
  59. *
  60. * 返回结果参数说明:
  61. * - status: 响应状态
  62. * - code: 响应编码
  63. * - msg: 响应描述
  64. * - result: ticket,一次性准入码
  65. * - errorCode: 错误编码
  66. *
  67. * @return string|null ticket 一次性授权码,获取失败返回 null
  68. */
  69. public function getTicket()
  70. {
  71. // 生成随机数
  72. $nonce = $this->generateNonce(15);
  73. //$nonce = 'VV7JK4BJXAUSYP8'; // 写死,后期修改回去
  74. // 生成签名
  75. $sign = $this->generateTicketSign($nonce);
  76. // 构建请求参数
  77. $params = [
  78. 'appKey' => $this->appKey,
  79. 'nonce' => $nonce,
  80. 'sign' => $sign,
  81. ];
  82. // 发送 GET 请求
  83. $url = $this->authorizeUrl . 'third/party/ticket';
  84. try {
  85. // 构建完整请求 URL
  86. $requestUrl = $url;
  87. if (!empty($params)) {
  88. $requestUrl .= '?' . http_build_query($params);
  89. }
  90. Yii::info("\n\n\n\n\n\nrequestUrl: " . $requestUrl);
  91. // 初始化 cURL
  92. $ch = curl_init();
  93. // 设置选项
  94. curl_setopt($ch, CURLOPT_URL, $requestUrl);
  95. curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
  96. curl_setopt($ch, CURLOPT_HEADER, false);
  97. curl_setopt($ch, CURLOPT_TIMEOUT, 15);
  98. curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false);
  99. curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, false);
  100. curl_setopt($ch, CURLOPT_HTTPHEADER, [
  101. 'Content-Type: application/json'
  102. ]);
  103. // 执行请求
  104. $response = curl_exec($ch);
  105. $error = curl_error($ch);
  106. curl_close($ch);
  107. // 检查错误
  108. if ($error) {
  109. throw new \Exception('cURL Request Error: ' . $error);
  110. }
  111. // 解析响应
  112. $resp = json_decode($response, true);
  113. if (json_last_error() !== JSON_ERROR_NONE) {
  114. // 如果解析失败,可能是非 JSON 响应
  115. // throw new \Exception('Invalid JSON Response: ' . $response);
  116. Yii::error('Invalid JSON Response: ' . $response);
  117. util::fail('请求达达获取ticket失败');
  118. }
  119. // 检查响应状态,返回 ticket
  120. if (isset($resp['result']) && !empty($resp['result'])) {
  121. Yii::debug('成功获取授权码:' . $resp['result']);
  122. return $resp['result'];
  123. }
  124. Yii::warning('获取授权码失败:' . json_encode($resp));
  125. return null;
  126. } catch (\Exception $e) {
  127. Yii::error('获取授权码异常:' . $e->getMessage());
  128. return null;
  129. }
  130. }
  131. /**
  132. * 生成获取授权码请求的签名
  133. *
  134. * 签名算法:
  135. * 1. 参与签名的参数按字典排序:appKey、appSecret、nonce
  136. * 2. 拼接参数值
  137. * 3. SHA1 加密
  138. *
  139. * 示例:
  140. * - appKey: dada6c68011157c5f63
  141. * - appSecret: 828a03677a1c00a3a5b3c59209c4433d
  142. * - nonce: RHU3RY4YR234238
  143. * 排序后拼接:dada6c68011157c5f63828a03677a1c00a3a5b3c59209c4433dRHU3RY4YR234238
  144. * SHA1(上述字符串)
  145. *
  146. * @param string $nonce 随机数
  147. * @return string SHA1 签名
  148. */
  149. private function generateTicketSign($nonce)
  150. {
  151. // 参与签名的参数
  152. $signParams = [
  153. 'appKey' => $this->appKey,
  154. 'nonce' => $nonce,
  155. 'appSecret' => $this->appSecret,
  156. ];
  157. // 第一步
  158. // 按键排序
  159. //ksort($signParams, SORT_STRING);
  160. // 按值排序且不改变key
  161. //asort($signParams);
  162. sort($signParams, SORT_STRING);
  163. // 第二步:拼接所有参数值
  164. $signString = implode('', $signParams);
  165. // 第二步:拼接 key 和 value
  166. // $signString = '';
  167. // foreach ($signParams as $key => $value) {
  168. // $signString .= $key . $value;
  169. // }
  170. // $signString = $signString;
  171. // 第三步:SHA1 加密
  172. $hex = sha1($signString);
  173. // $len = 36;
  174. // if ($len < strlen($hex)) {
  175. // $hex = substr($hex, 0, $len);
  176. // }
  177. //return strtoupper(substr($hex, 0, 36));
  178. //return strtoupper($hex);
  179. return $hex;
  180. }
  181. /**
  182. * 生成获取授权码请求的签名
  183. *
  184. * 签名算法:
  185. * 1. 参与签名的参数值按字典排序:appKey、appSecret、nonce
  186. * 2. 拼接参数值
  187. * 3. SHA1 加密
  188. *
  189. * @param string $nonce 随机数
  190. * @return string SHA1 签名
  191. */
  192. function generateTicketSign_234($nonce)
  193. {
  194. // 1. 按字段名升序拼接
  195. $map = [
  196. 'appKey' => $this->appKey,
  197. 'appSecret' =>$this->appSecret,
  198. 'nonce' => $nonce,
  199. ];
  200. ksort($map, SORT_STRING); // 按 key 升序
  201. $raw = '';
  202. foreach ($map as $k => $v) {
  203. $raw .= $k . $v; // 不要加 & = 空格
  204. }
  205. //$raw = implode('', $map);
  206. // 2. SHA1 得到 40 位 16 进制(小写)
  207. $sha1 = sha1($raw, false); // 第二个参数=false 返回十六进制字符串
  208. // 3. 转大写 + 截前 36 位
  209. //return strtoupper(substr($sha1, 0, 36));
  210. return strtoupper($sha1);
  211. return sha1($raw);
  212. }
  213. private function newGenerateTicketSign($nonce)
  214. {
  215. // 参与签名的参数
  216. $signParams = [
  217. 'appKey' => $this->appKey,
  218. 'nonce' => $nonce,
  219. 'appSecret' => $this->appSecret,
  220. ];
  221. // 第一步:将参与签名的参数按照键值(key)进行字典排序
  222. ksort($signParams);
  223. // 第二步:将排序过后的参数,进行key和value字符串拼接
  224. $signString = '';
  225. foreach ($signParams as $key => $value) {
  226. $signString .= $key . $value;
  227. }
  228. // 第三步:将拼接后的字符串首尾加上app_secret秘钥,合成签名字符串
  229. $finalSignString = $this->appSecret . $signString . $this->appSecret;
  230. // 第四步:对签名字符串进行MD5加密,生成32位的字符串
  231. $sign = md5($finalSignString);
  232. // 第五步:将签名生成的32位字符串转换为大写
  233. return strtoupper($sign);
  234. }
  235. /**
  236. * 生成授权链接
  237. *
  238. * 用户需要访问此链接进行授权,授权后会重定向到 redirectUri
  239. * 根据达达文档,授权链接需要以下参数:
  240. * - appKey: 应用 key
  241. * - shopId: 三方门店编号(可选)
  242. * - redirectUrl: 回调地址
  243. * - state: 回调标识(用于 CSRF 防护)
  244. * - nonce: 随机数
  245. * - ticket: 渠道授权码(需要从获取渠道授权码接口获取)
  246. * - sign: 签名字符串
  247. * - resultType: 是否跳转(可选,0-默认结果页,1-跳转)
  248. *
  249. * @param string $ticket 渠道授权码(从"获取渠道授权码"接口获取)
  250. * @param string $state 应用程序定义的不透明值(用于 CSRF 防护)
  251. * @param string $shopId 三方门店编号(可选)
  252. * @param int $resultType 是否跳转(可选,0-默认结果页,1-跳转)
  253. * @return string 授权链接
  254. * @throws \Exception 当缺少必要参数时
  255. */
  256. public function generateAuthUrl($ticket, $state = '', $shopId = '', $resultType = 0)
  257. {
  258. if (empty($ticket)) {
  259. throw new \Exception('ticket 参数不能为空,需要从"获取渠道授权码"接口获取');
  260. }
  261. // 生成随机数 nonce
  262. $nonce = $this->generateNonce();
  263. // 生成签名
  264. $sign = $this->generateAuthSign($ticket, $nonce, $shopId);
  265. // 构建授权链接参数
  266. $params = [
  267. 'appKey' => $this->appKey,
  268. 'redirectUrl' => $this->redirectUri,
  269. 'state' => $state ?: uniqid(),
  270. 'nonce' => $nonce,
  271. 'ticket' => $ticket,
  272. 'sign' => $sign,
  273. ];
  274. // 添加可选参数
  275. if (!empty($shopId)) {
  276. $params['shopId'] = $shopId;
  277. }
  278. if ($resultType != 0) {
  279. $params['resultType'] = $resultType;
  280. }
  281. return $this->authorizeUrl . 'third/party/oauth' . '?' . http_build_query($params);
  282. }
  283. private function generateAuthSign($ticket, $nonce, $shopId = '')
  284. {
  285. // 参与签名的参数
  286. $signParams = [
  287. 'appKey' => $this->appKey,
  288. 'ticket' => $ticket,
  289. 'nonce' => $nonce,
  290. 'appSecret' => $this->appSecret,
  291. ];
  292. if($shopId!=''){
  293. $signParams['shopId'] = $shopId;
  294. }
  295. // 第一步
  296. sort($signParams, SORT_STRING);
  297. // 第二步:拼接所有参数值
  298. $signString = implode('', $signParams);
  299. // 第三步:SHA1 加密
  300. $hex = sha1($signString);
  301. return $hex;
  302. }
  303. /**
  304. * 生成随机数(nonce)
  305. *
  306. * @return string 8位随机字母数字组合
  307. */
  308. private function generateNonce($length = 8)
  309. {
  310. $characters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
  311. $nonce = '';
  312. for ($i = 0; $i < $length; $i++) {
  313. $nonce .= $characters[rand(0, strlen($characters) - 1)];
  314. }
  315. return $nonce;
  316. }
  317. /**
  318. * 生成签名(sign)
  319. *
  320. * 签名算法:
  321. * 1. 参与签名的参数按字典顺序排列:appKey、nonce、ticket、shopId(如果有)
  322. * 2. 按 key+value 拼接
  323. * 3. 首尾加上 appSecret
  324. * 4. MD5 加密
  325. * 5. 转大写
  326. *
  327. * @param string $ticket 渠道授权码
  328. * @param string $nonce 随机数
  329. * @param string $shopId 三方门店编号(可选)
  330. * @return string 签名字符串(32位大写MD5)
  331. */
  332. private function generateSign($ticket, $nonce, $shopId = '')
  333. {
  334. // 参与签名的参数
  335. $signParams = [
  336. 'appKey' => $this->appKey,
  337. 'nonce' => $nonce,
  338. 'ticket' => $ticket,
  339. ];
  340. // 如果有shopId,也加入签名参数
  341. if (!empty($shopId)) {
  342. $signParams['shopId'] = $shopId;
  343. }
  344. // 第一步:按键值字典排序
  345. ksort($signParams);
  346. // 第二步:拼接 key 和 value
  347. $signString = '';
  348. foreach ($signParams as $key => $value) {
  349. $signString .= $key . $value;
  350. }
  351. // 第三步:首尾加上 appSecret
  352. $finalSignString = $this->appSecret . $signString . $this->appSecret;
  353. // 第四步:MD5 加密
  354. $sign = md5($finalSignString);
  355. // 第五步:转大写
  356. return strtoupper($sign);
  357. }
  358. /**
  359. * 通过授权码获取访问令牌
  360. *
  361. * 用户授权后,使用授权码兑换访问令牌
  362. *
  363. * @param string $code 授权码(从授权回调中获取)
  364. * @return array|null 令牌信息
  365. * [
  366. * 'access_token' => '访问令牌',
  367. * 'token_type' => 'Bearer',
  368. * 'expires_in' => 令牌过期时间(秒),
  369. * 'refresh_token' => '刷新令牌',
  370. * 'scope' => '授权范围'
  371. * ]
  372. */
  373. public function getAccessToken($code)
  374. {
  375. if (empty($code)) {
  376. return null;
  377. }
  378. $params = [
  379. 'grant_type' => self::GRANT_TYPE_AUTH_CODE,
  380. 'code' => $code,
  381. 'client_id' => $this->appKey,
  382. 'client_secret' => $this->appSecret,
  383. 'redirect_uri' => $this->redirectUri,
  384. ];
  385. $resp = HttpClient::post($this->tokenUrl, $params);
  386. // 检查响应是否成功
  387. if (isset($resp['access_token'])) {
  388. return [
  389. 'access_token' => $resp['access_token'],
  390. 'token_type' => $resp['token_type'] ?? 'Bearer',
  391. 'expires_in' => $resp['expires_in'] ?? 3600,
  392. 'refresh_token' => $resp['refresh_token'] ?? null,
  393. 'scope' => $resp['scope'] ?? '',
  394. 'create_time' => time(),
  395. ];
  396. }
  397. Yii::error('Failed to get access token: ' . json_encode($resp));
  398. return null;
  399. }
  400. /**
  401. * 使用刷新令牌获取新的访问令牌
  402. *
  403. * 当访问令牌过期时,使用刷新令牌获取新的访问令牌
  404. *
  405. * @param string $refreshToken 刷新令牌
  406. * @return array|null 新的令牌信息
  407. */
  408. public function refreshAccessToken($refreshToken)
  409. {
  410. if (empty($refreshToken)) {
  411. return null;
  412. }
  413. $params = [
  414. 'grant_type' => self::GRANT_TYPE_REFRESH,
  415. 'refresh_token' => $refreshToken,
  416. 'client_id' => $this->appKey,
  417. 'client_secret' => $this->appSecret,
  418. ];
  419. $resp = HttpClient::post($this->tokenUrl, $params);
  420. // 检查响应是否成功
  421. if (isset($resp['access_token'])) {
  422. return [
  423. 'access_token' => $resp['access_token'],
  424. 'token_type' => $resp['token_type'] ?? 'Bearer',
  425. 'expires_in' => $resp['expires_in'] ?? 3600,
  426. 'refresh_token' => $resp['refresh_token'] ?? $refreshToken,
  427. 'scope' => $resp['scope'] ?? '',
  428. 'create_time' => time(),
  429. ];
  430. }
  431. Yii::error('Failed to refresh access token: ' . json_encode($resp));
  432. return null;
  433. }
  434. /**
  435. * 检查访问令牌是否已过期
  436. *
  437. * @param array $tokenInfo 令牌信息
  438. * [
  439. * 'access_token' => '...',
  440. * 'expires_in' => 3600,
  441. * 'create_time' => 时间戳,
  442. * ]
  443. * @return bool 是否已过期
  444. */
  445. public function isTokenExpired($tokenInfo)
  446. {
  447. if (empty($tokenInfo) || !is_array($tokenInfo)) {
  448. return true;
  449. }
  450. $createTime = $tokenInfo['create_time'] ?? 0;
  451. $expiresIn = $tokenInfo['expires_in'] ?? 0;
  452. $currentTime = time();
  453. // 提前 300 秒(5 分钟)进行刷新,避免临界情况
  454. return ($createTime + $expiresIn - 300) <= $currentTime;
  455. }
  456. }