Auth.php 13 KB

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