Auth.php 16 KB

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