Auth.php 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360
  1. <?php
  2. namespace common\components\delivery\platform\shansong;
  3. use common\components\delivery\helpers\HttpClient;
  4. use common\components\delivery\helpers\SignHelper;
  5. use Yii;
  6. /**
  7. * 闪送商户授权
  8. *
  9. * 支持两种授权方式:
  10. * 1. 商户授权(isAllStoreAuth=true)- 授权后能为商户下所有门店发单
  11. * 2. 门店授权(isAllStoreAuth=false)- 授权后只能为授权时选择的门店发单
  12. *
  13. * ============ 使用示例 ============
  14. *
  15. * 1. 生成商户授权URL(推荐用于首次授权):
  16. * $auth = new Auth();
  17. * $redirectUrl = 'http://your-domain.com/callback';
  18. * $authUrl = $auth->generateMerchantAuthUrl($userId, $redirectUrl);
  19. * // 重定向到 $authUrl 让用户登录授权
  20. *
  21. * 2. 生成门店授权URL(用于特定门店授权):
  22. * $auth = new Auth();
  23. * $authUrl = $auth->generateStoreAuthUrl($thirdStoreId, $userId, $redirectUrl);
  24. * // 用户授权后会跳转到 redirectUrl 并携带 code
  25. *
  26. * 3. 在回调页面(redirectUrl)获取 AccessToken:
  27. * $auth = new Auth();
  28. * $code = Yii::$app->request->get('code');
  29. * $shopId = Yii::$app->request->get('shopId');
  30. * $result = $auth->getAccessToken($code);
  31. *
  32. * if ($result['success']) {
  33. * // 保存 access_token 和 refresh_token
  34. * // $result['access_token'] - 访问令牌
  35. * // $result['refresh_token'] - 刷新令牌
  36. * // $result['expires_in'] - 过期秒数(默认30天)
  37. * }
  38. *
  39. * 4. 刷新过期的 AccessToken:
  40. * $auth = new Auth();
  41. * $result = $auth->refreshAccessToken($refreshToken);
  42. *
  43. * if ($result['success']) {
  44. * // 使用新的 access_token
  45. * }
  46. *
  47. * 5. 取消用户授权:
  48. * $auth = new Auth();
  49. * $result = $auth->cancelAuthorization($accessToken);
  50. *
  51. * if ($result['success']) {
  52. * // 授权已取消
  53. * }
  54. *
  55. * ============ 授权流程 ============
  56. *
  57. * 商户授权流程:
  58. * 1. 用户点击「闪送授权」按钮 -> generateMerchantAuthUrl() 获取授权URL
  59. * 2. 用户被重定向到闪送授权页面
  60. * 3. 用户填写闪送账号进行授权
  61. * 4. 闪送重定向回 redirectUrl,并携带 code 和 shopId
  62. * 5. 在回调页面用 code 调用 getAccessToken() 获取 token
  63. * 6. 保存 access_token 和 refresh_token 到数据库
  64. *
  65. * 门店授权流程:
  66. * 与商户授权流程类似,但用户可以在闪送授权页面选择授权特定门店
  67. * 返回结果中会包含 storeId(闪送门店ID)和 thirdStoreId(平台门店ID)
  68. *
  69. * ============ 注意事项 ============
  70. *
  71. * 1. AccessToken 有效期为 30 天(2592000秒)
  72. * 2. 授权码(code)有效期为 1 分钟,且只能使用 1 次
  73. * 3. 刷新令牌(refresh_token)需要妥善保管,长期有效
  74. * 4. 签名算法为 MD5,签名参数中不能包含 null 或空字符串值
  75. * 5. 建议定期刷新 token,在过期前 7 天开始刷新
  76. * 6. 环境配置通过 YII_ENV 环境变量自动区分(production 或其他)
  77. *
  78. * @package common\components\shans
  79. */
  80. class Auth
  81. {
  82. // 授权方式常量
  83. const AUTH_TYPE_ALL_STORE = true; // 商户授权
  84. const AUTH_TYPE_SINGLE_STORE = false; // 门店授权
  85. // 固定参数
  86. const RESPONSE_TYPE = 'code';
  87. const SCOPE = 'shop_open_api';
  88. protected $baseUrl;
  89. protected $clientId;
  90. protected $appSecret;
  91. protected $redirectUri;
  92. /**
  93. * 初始化授权类
  94. * 根据环境获取配置信息
  95. */
  96. public function __construct()
  97. {
  98. $isProduction = getenv('YII_ENV') == 'production';
  99. if ($isProduction) {
  100. $this->baseUrl = 'https://open.ishansong.com';
  101. } else {
  102. $this->baseUrl = 'http://open.s.bingex.com';
  103. }
  104. $this->clientId = 'sswoXlqJvk7Be9GN3';
  105. $this->appSecret = 'mUa1uLQkybm6cGaUa4AW4krn4i5QROpD';
  106. }
  107. /**
  108. * 设置重定向URI
  109. * 需要进行URLEncode编码
  110. *
  111. * @param string $redirectUri 重定向地址
  112. */
  113. public function setRedirectUri($redirectUri)
  114. {
  115. $this->redirectUri = urlencode($redirectUri);
  116. return $this;
  117. }
  118. /**
  119. * 生成商户授权URL
  120. * 授权后能为商户下所有门店发单
  121. *
  122. * @param string $state 状态参数,用于标记平台用户,建议使用用户ID
  123. * @param string $redirectUri 重定向地址
  124. * @return string 授权URL
  125. */
  126. public function generateMerchantAuthUrl($state, $redirectUri = null)
  127. {
  128. if ($redirectUri) {
  129. $this->setRedirectUri($redirectUri);
  130. }
  131. return $this->buildAuthUrl(self::AUTH_TYPE_ALL_STORE, $state);
  132. }
  133. /**
  134. * 生成门店授权URL
  135. * 授权后只能为授权时选择的门店发单
  136. *
  137. * @param string $thirdStoreId 第三方平台的门店ID
  138. * @param string $state 状态参数,用于标记平台用户
  139. * @param string $redirectUri 重定向地址
  140. * @return string 授权URL
  141. */
  142. public function generateStoreAuthUrl($thirdStoreId, $state, $redirectUri = null)
  143. {
  144. if ($redirectUri) {
  145. $this->setRedirectUri($redirectUri);
  146. }
  147. return $this->buildAuthUrl(self::AUTH_TYPE_SINGLE_STORE, $state, $thirdStoreId);
  148. }
  149. /**
  150. * 构建授权URL
  151. *
  152. * @param bool $isAllStoreAuth 是否为商户授权
  153. * @param string $state 状态参数
  154. * @param string $thirdStoreId 门店授权时的门店ID
  155. * @return string 授权URL
  156. */
  157. protected function buildAuthUrl($isAllStoreAuth, $state, $thirdStoreId = null)
  158. {
  159. $params = [
  160. 'isAllStoreAuth' => $isAllStoreAuth ? 'true' : 'false',
  161. 'response_type' => self::RESPONSE_TYPE,
  162. 'scope' => self::SCOPE,
  163. 'state' => $state,
  164. 'client_id' => $this->clientId,
  165. 'redirect_uri' => $this->redirectUri,
  166. ];
  167. // 门店授权需要添加thirdStoreId
  168. if ($thirdStoreId !== null && !$isAllStoreAuth) {
  169. $params['thirdStoreId'] = $thirdStoreId;
  170. }
  171. // 构建URL
  172. $queryString = http_build_query($params);
  173. return $this->baseUrl . '/auth?' . $queryString;
  174. }
  175. /**
  176. * 获取AccessToken
  177. * 使用授权码换取令牌
  178. *
  179. * 文档:/openapi/oauth/token
  180. * 入参:clientId, code
  181. * 出参:access_token, refresh_token, expires_in
  182. *
  183. * @param string $code 授权码(来自授权页面重定向)
  184. * @return array 返回格式:['access_token' => '', 'refresh_token' => '', 'expires_in' => 0, 'error' => '']
  185. */
  186. public function getAccessToken($code)
  187. {
  188. $url = $this->baseUrl . '/openapi/oauth/token';
  189. $data = [
  190. 'clientId' => $this->clientId,
  191. 'code' => $code,
  192. ];
  193. $headers = ['Content-Type' => 'application/x-www-form-urlencoded;charset=utf-8'];
  194. $response = HttpClient::post($url, $data, $headers);
  195. return $this->parseResponse($response);
  196. }
  197. /**
  198. * 刷新AccessToken
  199. * 使用刷新令牌获取新的AccessToken
  200. *
  201. * 文档:/openapi/oauth/refresh_token
  202. * 入参:clientId, sign, timestamp, data
  203. * 出参:access_token, expires_in
  204. *
  205. * @param string $refreshToken 刷新令牌
  206. * @return array 返回格式:['access_token' => '', 'expires_in' => 0, 'error' => '']
  207. */
  208. public function refreshAccessToken($refreshToken)
  209. {
  210. $url = $this->baseUrl . '/openapi/oauth/refresh_token';
  211. $timestamp = (string) (int) (microtime(true) * 1000);
  212. $data = json_encode(['refreshToken' => $refreshToken], JSON_UNESCAPED_UNICODE);
  213. $params = [
  214. 'clientId' => $this->clientId,
  215. 'timestamp' => $timestamp,
  216. 'data' => $data,
  217. ];
  218. // 计算签名
  219. $sign = SignHelper::makeSign($params, $this->appSecret, 'md5', true);
  220. $params['sign'] = $sign;
  221. $headers = ['Content-Type' => 'application/x-www-form-urlencoded;charset=utf-8'];
  222. $response = HttpClient::post($url, $params, $headers);
  223. return $this->parseResponse($response);
  224. }
  225. /**
  226. * 取消授权
  227. * 撤销用户的授权,使其accessToken失效
  228. *
  229. * 文档:/openapi/oauth/cancel
  230. * 入参:clientId, sign, timestamp, data
  231. * 出参:无数据返回
  232. *
  233. * @param string $accessToken 待取消的授权令牌
  234. * @return array 返回格式:['success' => true/false, 'message' => '', 'error' => '']
  235. */
  236. public function cancelAuthorization($accessToken)
  237. {
  238. $url = $this->baseUrl . '/openapi/oauth/cancel';
  239. $timestamp = (string) (int) (microtime(true) * 1000);
  240. $data = json_encode(['accessToken' => $accessToken], JSON_UNESCAPED_UNICODE);
  241. $params = [
  242. 'clientId' => $this->clientId,
  243. 'timestamp' => $timestamp,
  244. 'data' => $data,
  245. ];
  246. // 计算签名
  247. $sign = SignHelper::makeSign($params, $this->appSecret, 'md5', true, 'shansong');
  248. $params['sign'] = $sign;
  249. $headers = ['Content-Type' => 'application/x-www-form-urlencoded;charset=utf-8'];
  250. $response = HttpClient::post($url, $params, $headers);
  251. Yii::info("[ShansAuthCancel] Response: " . json_encode($response));
  252. if (isset($response['status']) && $response['status'] == 200) {
  253. return [
  254. 'success' => true,
  255. 'message' => $response['msg'] ?? '取消授权成功',
  256. ];
  257. }
  258. return [
  259. 'success' => false,
  260. 'message' => $response['msg'] ?? '取消授权失败',
  261. 'error' => $response['error'] ?? '',
  262. ];
  263. }
  264. /**
  265. * 解析API响应
  266. *
  267. * @param array $response HTTP响应
  268. * @return array 标准化的响应格式
  269. */
  270. protected function parseResponse($response)
  271. {
  272. // 如果是HTTP错误
  273. if (isset($response['code']) && $response['code'] != 200) {
  274. return [
  275. 'success' => false,
  276. 'error' => $response['error'] ?? '请求失败',
  277. 'status_code' => $response['code'] ?? 0,
  278. ];
  279. }
  280. // 检查API状态码
  281. if (isset($response['status']) && $response['status'] != 200) {
  282. return [
  283. 'success' => false,
  284. 'error' => $response['msg'] ?? 'API返回异常',
  285. 'status' => $response['status'],
  286. ];
  287. }
  288. // 成功响应
  289. $data = $response['data'] ?? [];
  290. return [
  291. 'success' => true,
  292. 'access_token' => $data['access_token'] ?? '',
  293. 'refresh_token' => $data['refresh_token'] ?? '',
  294. 'expires_in' => $data['expires_in'] ?? 0,
  295. ];
  296. }
  297. /**
  298. * 获取基础URL
  299. *
  300. * @return string
  301. */
  302. public function getBaseUrl()
  303. {
  304. return $this->baseUrl;
  305. }
  306. /**
  307. * 获取客户端ID
  308. *
  309. * @return string
  310. */
  311. public function getClientId()
  312. {
  313. return $this->clientId;
  314. }
  315. /**
  316. * 获取客户端密钥
  317. *
  318. * @return string
  319. */
  320. public function getAppSecret()
  321. {
  322. return $this->appSecret;
  323. }
  324. }