generateMerchantAuthUrl($userId, $redirectUrl); * // 重定向到 $authUrl 让用户登录授权 * * 2. 生成门店授权URL(用于特定门店授权): * $auth = new Auth(); * $authUrl = $auth->generateStoreAuthUrl($thirdStoreId, $userId, $redirectUrl); * // 用户授权后会跳转到 redirectUrl 并携带 code * * 3. 在回调页面(redirectUrl)获取 AccessToken: * $auth = new Auth(); * $code = Yii::$app->request->get('code'); * $shopId = Yii::$app->request->get('shopId'); * $result = $auth->getAccessToken($code); * * if ($result['success']) { * // 保存 access_token 和 refresh_token * // $result['access_token'] - 访问令牌 * // $result['refresh_token'] - 刷新令牌 * // $result['expires_in'] - 过期秒数(默认30天) * } * * 4. 刷新过期的 AccessToken: * $auth = new Auth(); * $result = $auth->refreshAccessToken($refreshToken); * * if ($result['success']) { * // 使用新的 access_token * } * * 5. 取消用户授权: * $auth = new Auth(); * $result = $auth->cancelAuthorization($accessToken); * * if ($result['success']) { * // 授权已取消 * } * * ============ 授权流程 ============ * * 商户授权流程: * 1. 用户点击「闪送授权」按钮 -> generateMerchantAuthUrl() 获取授权URL * 2. 用户被重定向到闪送授权页面 * 3. 用户填写闪送账号进行授权 * 4. 闪送重定向回 redirectUrl,并携带 code 和 shopId * 5. 在回调页面用 code 调用 getAccessToken() 获取 token * 6. 保存 access_token 和 refresh_token 到数据库 * * 门店授权流程: * 与商户授权流程类似,但用户可以在闪送授权页面选择授权特定门店 * 返回结果中会包含 storeId(闪送门店ID)和 thirdStoreId(平台门店ID) * * ============ 注意事项 ============ * * 1. AccessToken 有效期为 30 天(2592000秒) * 2. 授权码(code)有效期为 1 分钟,且只能使用 1 次 * 3. 刷新令牌(refresh_token)需要妥善保管,长期有效 * 4. 签名算法为 MD5,签名参数中不能包含 null 或空字符串值 * 5. 建议定期刷新 token,在过期前 7 天开始刷新 * 6. 环境配置通过 YII_ENV 环境变量自动区分(production 或其他) * * @package common\components\shans */ class Auth { // 授权方式常量 const AUTH_TYPE_ALL_STORE = true; // 商户授权 const AUTH_TYPE_SINGLE_STORE = false; // 门店授权 // 固定参数 const RESPONSE_TYPE = 'code'; const SCOPE = 'shop_open_api'; protected $baseUrl; protected $clientId; protected $appSecret; protected $redirectUri; /** * 初始化授权类 * 根据环境获取配置信息 */ public function __construct() { $isProduction = getenv('YII_ENV') == 'production'; if ($isProduction) { $this->baseUrl = 'https://open.ishansong.com'; } else { $this->baseUrl = 'http://open.s.bingex.com'; } $this->clientId = 'sswoXlqJvk7Be9GN3'; $this->appSecret = 'mUa1uLQkybm6cGaUa4AW4krn4i5QROpD'; } /** * 设置重定向URI * 需要进行URLEncode编码 * * @param string $redirectUri 重定向地址 */ public function setRedirectUri($redirectUri) { $this->redirectUri = urlencode($redirectUri); return $this; } /** * 生成商户授权URL * 授权后能为商户下所有门店发单 * * @param string $state 状态参数,用于标记平台用户,建议使用用户ID * @param string $redirectUri 重定向地址 * @return string 授权URL */ public function generateMerchantAuthUrl($state, $redirectUri = null) { if ($redirectUri) { $this->setRedirectUri($redirectUri); } return $this->buildAuthUrl(self::AUTH_TYPE_ALL_STORE, $state); } /** * 生成门店授权URL * 授权后只能为授权时选择的门店发单 * * @param string $thirdStoreId 第三方平台的门店ID * @param string $state 状态参数,用于标记平台用户 * @param string $redirectUri 重定向地址 * @return string 授权URL */ public function generateStoreAuthUrl($thirdStoreId, $state, $redirectUri = null) { if ($redirectUri) { $this->setRedirectUri($redirectUri); } return $this->buildAuthUrl(self::AUTH_TYPE_SINGLE_STORE, $state, $thirdStoreId); } /** * 构建授权URL * * @param bool $isAllStoreAuth 是否为商户授权 * @param string $state 状态参数 * @param string $thirdStoreId 门店授权时的门店ID * @return string 授权URL */ protected function buildAuthUrl($isAllStoreAuth, $state, $thirdStoreId = null) { $params = [ 'isAllStoreAuth' => $isAllStoreAuth ? 'true' : 'false', 'response_type' => self::RESPONSE_TYPE, 'scope' => self::SCOPE, 'state' => $state, 'client_id' => $this->clientId, 'redirect_uri' => $this->redirectUri, ]; // 门店授权需要添加thirdStoreId if ($thirdStoreId !== null && !$isAllStoreAuth) { $params['thirdStoreId'] = $thirdStoreId; } // 构建URL $queryString = http_build_query($params); return $this->baseUrl . '/auth?' . $queryString; } /** * 获取AccessToken * 使用授权码换取令牌 * * 文档:/openapi/oauth/token * 入参:clientId, code * 出参:access_token, refresh_token, expires_in * * @param string $code 授权码(来自授权页面重定向) * @return array 返回格式:['access_token' => '', 'refresh_token' => '', 'expires_in' => 0, 'error' => ''] */ public function getAccessToken($code) { $url = $this->baseUrl . '/openapi/oauth/token'; $data = [ 'clientId' => $this->clientId, 'code' => $code, ]; $headers = ['Content-Type' => 'application/x-www-form-urlencoded;charset=utf-8']; $response = HttpClient::post($url, $data, $headers); return $this->parseResponse($response); } /** * 刷新AccessToken * 使用刷新令牌获取新的AccessToken * * 文档:/openapi/oauth/refresh_token * 入参:clientId, sign, timestamp, data * 出参:access_token, expires_in * * @param string $refreshToken 刷新令牌 * @return array 返回格式:['access_token' => '', 'expires_in' => 0, 'error' => ''] */ public function refreshAccessToken($refreshToken) { $url = $this->baseUrl . '/openapi/oauth/refresh_token'; $timestamp = (string) (int) (microtime(true) * 1000); $data = json_encode(['refreshToken' => $refreshToken], JSON_UNESCAPED_UNICODE); $params = [ 'clientId' => $this->clientId, 'timestamp' => $timestamp, 'data' => $data, ]; // 计算签名 $sign = SignHelper::makeSign($params, $this->appSecret, 'md5', true); $params['sign'] = $sign; $headers = ['Content-Type' => 'application/x-www-form-urlencoded;charset=utf-8']; $response = HttpClient::post($url, $params, $headers); return $this->parseResponse($response); } /** * 取消授权 * 撤销用户的授权,使其accessToken失效 * * 文档:/openapi/oauth/cancel * 入参:clientId, sign, timestamp, data * 出参:无数据返回 * * @param string $accessToken 待取消的授权令牌 * @return array 返回格式:['success' => true/false, 'message' => '', 'error' => ''] */ public function cancelAuthorization($accessToken) { $url = $this->baseUrl . '/openapi/oauth/cancel'; $timestamp = (string) (int) (microtime(true) * 1000); $data = json_encode(['accessToken' => $accessToken], JSON_UNESCAPED_UNICODE); $params = [ 'clientId' => $this->clientId, 'timestamp' => $timestamp, 'data' => $data, ]; // 计算签名 $sign = SignHelper::makeSign($params, $this->appSecret, 'md5', true, 'shansong'); $params['sign'] = $sign; $headers = ['Content-Type' => 'application/x-www-form-urlencoded;charset=utf-8']; $response = HttpClient::post($url, $params, $headers); Yii::info("[ShansAuthCancel] Response: " . json_encode($response)); if (isset($response['status']) && $response['status'] == 200) { return [ 'success' => true, 'message' => $response['msg'] ?? '取消授权成功', ]; } return [ 'success' => false, 'message' => $response['msg'] ?? '取消授权失败', 'error' => $response['error'] ?? '', ]; } /** * 解析API响应 * * @param array $response HTTP响应 * @return array 标准化的响应格式 */ protected function parseResponse($response) { // 如果是HTTP错误 if (isset($response['code']) && $response['code'] != 200) { return [ 'success' => false, 'error' => $response['error'] ?? '请求失败', 'status_code' => $response['code'] ?? 0, ]; } // 检查API状态码 if (isset($response['status']) && $response['status'] != 200) { return [ 'success' => false, 'error' => $response['msg'] ?? 'API返回异常', 'status' => $response['status'], ]; } // 成功响应 $data = $response['data'] ?? []; return [ 'success' => true, 'access_token' => $data['access_token'] ?? '', 'refresh_token' => $data['refresh_token'] ?? '', 'expires_in' => $data['expires_in'] ?? 0, ]; } /** * 获取基础URL * * @return string */ public function getBaseUrl() { return $this->baseUrl; } /** * 获取客户端ID * * @return string */ public function getClientId() { return $this->clientId; } /** * 获取客户端密钥 * * @return string */ public function getAppSecret() { return $this->appSecret; } }