diff --git a/backend/app/controller/AuthController.php b/backend/app/controller/AuthController.php index 0b52ca0..0041eb8 100644 --- a/backend/app/controller/AuthController.php +++ b/backend/app/controller/AuthController.php @@ -142,7 +142,8 @@ class AuthController extends BaseController // 构建扫码登录 URL(使用官方 passport 端点) $appId = 'cli_a8033d607cba500d'; - $redirectUri = urlencode("https://xsm.dev.hnxinyouhe.com/login/qrcode/{$loginSession->id}"); + // 使用固定的 redirect_uri,session_id 通过 state 传递 + $redirectUri = urlencode("https://xsm.dev.hnxinyouhe.com/login/callback"); $authUrl = "https://passport.feishu.cn/suite/passport/oauth/authorize?client_id={$appId}&redirect_uri={$redirectUri}&response_type=code&state={$loginSession->id}"; // 生成二维码内容(返回 URL,前端生成二维码) @@ -216,10 +217,12 @@ class AuthController extends BaseController /** * 飞书扫码登录回调(飞书授权后的回调) */ - public function qrcodeLoginCallback(Request $request, $id = null): Response + public function qrcodeLoginCallback(Request $request): Response { - $sessionId = $id; - $authCode = $request->get('auth_code'); + // 从 state 参数获取 session_id + $sessionId = $request->get('state'); + // OAuth 2.0 标准参数是 code + $authCode = $request->get('code'); if (!$sessionId) { return $this->error('缺少会话ID'); diff --git a/backend/app/service/FeishuService.php b/backend/app/service/FeishuService.php index b4cc0b8..23224f2 100644 --- a/backend/app/service/FeishuService.php +++ b/backend/app/service/FeishuService.php @@ -5,24 +5,21 @@ namespace app\service; use app\model\Company; use app\model\CompanyFeishuApp; use app\model\User; -use support\Db; +use app\util\FeishuUtil; +use app\util\HttpClient; +use Exception; class FeishuService { - private ?string $accessToken = null; private ?int $companyId = null; private ?int $appId = null; - // 简单的内存缓存(生产环境应使用 Redis) - private static array $tokenCache = []; - /** * 设置当前操作的公司 */ public function setCompany(int $companyId): self { $this->companyId = $companyId; - $this->accessToken = null; return $this; } @@ -32,7 +29,6 @@ class FeishuService public function setApp(int $appId): self { $this->appId = $appId; - $this->accessToken = null; return $this; } @@ -52,7 +48,7 @@ class FeishuService $app = CompanyFeishuApp::find($appId); if (!$app || $app->status !== 1) { - throw new \Exception('飞书应用配置不存在'); + throw new Exception('飞书应用配置不存在'); } return $app->toArray(); @@ -70,7 +66,7 @@ class FeishuService ->first(); if (!$app) { - throw new \Exception('未配置飞书应用'); + throw new Exception('未配置飞书应用'); } return $app->toArray(); @@ -82,7 +78,7 @@ class FeishuService private function getAppConfig(): array { if (!$this->companyId) { - throw new \Exception('未设置公司ID'); + throw new Exception('未设置公司ID'); } if ($this->appId) { @@ -93,44 +89,21 @@ class FeishuService } /** - * 获取飞书 access_token + * 获取当前应用ID + */ + private function getAppId(): int + { + $app = $this->getAppConfig(); + return $app['id']; + } + + /** + * 获取飞书 access_token(保留用于向后兼容) + * @deprecated 建议直接使用 FeishuUtil::getAppAccessToken() */ public function getAccessToken(): string { - if ($this->accessToken) { - return $this->accessToken; - } - - $app = $this->getAppConfig(); - $cacheKey = "feishu:access_token:{$app['id']}"; - - // 从内存缓存获取 - if (isset(self::$tokenCache[$cacheKey]) && self::$tokenCache[$cacheKey]['expire'] > time()) { - $this->accessToken = self::$tokenCache[$cacheKey]['token']; - return $this->accessToken; - } - - // 请求新的 token - $response = $this->httpPost('https://open.feishu.cn/open-apis/auth/v3/tenant_access_token/internal', [ - 'app_id' => $app['app_id'], - 'app_secret' => $app['app_secret'], - ]); - - if ($response['code'] !== 0) { - throw new \Exception('获取飞书 token 失败: ' . ($response['msg'] ?? '未知错误')); - } - - $token = $response['tenant_access_token']; - $expire = $response['expire'] ?? 7200; - - // 缓存 token(提前5分钟过期) - self::$tokenCache[$cacheKey] = [ - 'token' => $token, - 'expire' => time() + $expire - 300, - ]; - - $this->accessToken = $token; - return $token; + return FeishuUtil::getAppAccessToken($this->getAppId()); } /** @@ -139,31 +112,12 @@ class FeishuService public function getUserInfo(string $authCode): array { $app = $this->getAppConfig(); + $appId = $app['id']; - // 使用 passport 端点获取 user_access_token - $response = $this->httpPost('https://passport.feishu.cn/suite/passport/oauth/token', [ - 'grant_type' => 'authorization_code', - 'client_id' => $app['app_id'], - 'client_secret' => $app['app_secret'], - 'code' => $authCode, - ]); + error_log("🔑 使用应用ID: {$appId}, app_id: {$app['app_id']}"); - if (!isset($response['access_token'])) { - throw new \Exception('获取 user_access_token 失败: ' . ($response['error_description'] ?? '未知错误')); - } - - $userAccessToken = $response['access_token']; - - // 使用 user_access_token 获取用户信息 - $userInfo = $this->httpGet('https://passport.feishu.cn/suite/passport/oauth/userinfo', [ - 'access_token' => $userAccessToken, - ]); - - if (!isset($userInfo['data'])) { - throw new \Exception('获取用户信息失败'); - } - - return $userInfo['data']; + // 使用 FeishuUtil 获取完整用户信息 + return FeishuUtil::getCompleteUserInfo($authCode, $appId); } /** @@ -178,7 +132,7 @@ class FeishuService $email = $feishuData['email'] ?? null; if (!$unionId) { - throw new \Exception('缺少 union_id'); + throw new Exception('缺少 union_id'); } // 查找或创建用户 @@ -257,7 +211,7 @@ class FeishuService ]; // 发送消息 - $response = $this->httpPost( + $response = HttpClient::httpPost( "https://open.feishu.cn/open-apis/message/v4/send_messages", [ 'receive_id_type' => 'user_id', @@ -271,79 +225,10 @@ class FeishuService ); return ($response['code'] ?? -1) === 0; - } catch (\Exception $e) { + } catch (Exception $e) { // 记录错误但不中断流程 error_log('发送飞书消息失败: ' . $e->getMessage()); return false; } } - - /** - * HTTP POST 请求 - */ - private function httpPost(string $url, array $data, array $headers = []): array - { - $ch = curl_init(); - - curl_setopt($ch, CURLOPT_URL, $url); - curl_setopt($ch, CURLOPT_POST, true); - curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($data)); - curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); - curl_setopt($ch, CURLOPT_TIMEOUT, 30); - curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false); - - $defaultHeaders = [ - 'Content-Type: application/json', - ]; - - curl_setopt($ch, CURLOPT_HTTPHEADER, array_merge($defaultHeaders, $headers)); - - $response = curl_exec($ch); - $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); - // curl_close($ch); // 不再需要,PHP 8.5+ 自动关闭 - - if ($httpCode !== 200) { - throw new \Exception("HTTP 请求失败: {$httpCode}"); - } - - $result = json_decode($response, true); - - if (json_last_error() !== JSON_ERROR_NONE) { - throw new \Exception('JSON 解析失败'); - } - - return $result; - } - - /** - * HTTP GET 请求 - */ - private function httpGet(string $url, array $params = []): array - { - if (!empty($params)) { - $url .= '?' . http_build_query($params); - } - - $ch = curl_init(); - - curl_setopt($ch, CURLOPT_URL, $url); - curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); - curl_setopt($ch, CURLOPT_TIMEOUT, 30); - curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false); - - $response = curl_exec($ch); - $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); - - if ($httpCode !== 200) { - throw new \Exception("HTTP 请求失败: {$httpCode}"); - } - - $result = json_decode($response, true); - - if (json_last_error() !== JSON_ERROR_NONE) { - throw new \Exception('JSON 解析失败'); - } - - return $result; - } } diff --git a/backend/app/util/FeishuUtil.php b/backend/app/util/FeishuUtil.php new file mode 100644 index 0000000..fbf9eef --- /dev/null +++ b/backend/app/util/FeishuUtil.php @@ -0,0 +1,200 @@ + time()) { + return self::$tokenCache[$cacheKey]['token']; + } + + // 获取应用配置 + $app = CompanyFeishuApp::find($appId); + + if (!$app || $app->status !== 1) { + throw new Exception('飞书应用配置不存在或已禁用'); + } + + // 请求新的 token + try { + $response = HttpClient::httpPost(self::APP_ACCESS_TOKEN_URL, [ + 'app_id' => $app->app_id, + 'app_secret' => $app->app_secret, + ]); + + if ($response['code'] !== 0) { + throw new Exception('获取 app_access_token 失败: ' . ($response['msg'] ?? '未知错误')); + } + + $token = $response['tenant_access_token']; + $expire = $response['expire'] ?? 7200; + + // 缓存 token(提前5分钟过期) + self::$tokenCache[$cacheKey] = [ + 'token' => $token, + 'expire' => time() + $expire - 300, + ]; + + error_log("✅ 获取 app_access_token 成功: " . substr($token, 0, 30) . "..."); + + return $token; + } catch (Exception $e) { + error_log("❌ 获取 app_access_token 失败: " . $e->getMessage()); + throw $e; + } + } + + /** + * 使用 app_access_token 获取 user_access_token + * + * @param string $code 飞书授权码 + * @param int $appId 飞书应用ID + * @return string user_access_token + * @throws Exception + */ + public static function getUserAccessToken(string $code, int $appId): string + { + $appAccessToken = self::getAppAccessToken($appId); + $app = CompanyFeishuApp::find($appId); + + if (!$app || $app->status !== 1) { + throw new Exception('飞书应用配置不存在或已禁用'); + } + + error_log("🔍 获取 user_access_token, auth_code: " . substr($code, 0, 10) . "..."); + error_log("🔍 使用 app_id: " . $app->app_id); + error_log("👑 app_access_token: " . substr($appAccessToken, 0, 30) . "..."); + + try { + $response = HttpClient::httpPost( + self::USER_ACCESS_TOKEN_URL, + [ + 'grant_type' => 'authorization_code', + 'client_id' => $app->app_id, + 'client_secret' => $app->app_secret, + 'code' => $code, + ], + [ + 'Authorization: Bearer ' . $appAccessToken, + ] + ); + + error_log("📝 获取 user_access_token 响应: " . json_encode($response)); + + if ($response['code'] !== 0) { + throw new Exception('获取 user_access_token 失败: ' . ($response['msg'] ?? '未知错误')); + } + + if (!isset($response['data']['access_token'])) { + throw new Exception('响应中缺少 access_token: ' . json_encode($response)); + } + + $userAccessToken = $response['data']['access_token']; + error_log("✅ 获取 user_access_token 成功: " . substr($userAccessToken, 0, 20) . "..."); + + return $userAccessToken; + } catch (Exception $e) { + error_log("❌ 获取 user_access_token 失败: " . $e->getMessage()); + throw $e; + } + } + + /** + * 使用 user_access_token 获取用户信息 + * + * @param string $userAccessToken user_access_token + * @return array 用户信息 + * @throws Exception + */ + public static function getUserInfo(string $userAccessToken): array + { + error_log("🔍 获取用户信息, user_access_token: " . substr($userAccessToken, 0, 20) . "..."); + + try { + $response = HttpClient::httpGet( + self::USER_INFO_URL, + [], + [ + 'Authorization: Bearer ' . $userAccessToken, + ] + ); + + error_log("📝 获取用户信息响应: " . json_encode($response)); + + if ($response['code'] !== 0) { + throw new Exception('获取用户信息失败: ' . ($response['msg'] ?? '未知错误')); + } + + $userInfo = $response['data'] ?? []; + + error_log("✅ 获取用户信息成功: " . ($userInfo['name'] ?? '未知')); + + return $userInfo; + } catch (Exception $e) { + error_log("❌ 获取用户信息失败: " . $e->getMessage()); + throw $e; + } + } + + /** + * 清除指定应用的 token 缓存 + * + * @param int $appId 飞书应用ID + */ + public static function clearTokenCache(int $appId): void + { + $cacheKey = "feishu:app_access_token:{$appId}"; + unset(self::$tokenCache[$cacheKey]); + error_log("🗑️ 已清除应用 {$appId} 的 token 缓存"); + } + + /** + * 清除所有 token 缓存 + */ + public static function clearAllTokenCache(): void + { + self::$tokenCache = []; + error_log("🗑️ 已清除所有 token 缓存"); + } + + /** + * 获取完整的用户信息(一步完成整个授权流程) + * + * @param string $code 飞书授权码 + * @param int $appId 飞书应用ID + * @return array 用户信息 + * @throws Exception + */ + public static function getCompleteUserInfo(string $code, int $appId): array + { + $userAccessToken = self::getUserAccessToken($code, $appId); + return self::getUserInfo($userAccessToken); + } +} diff --git a/backend/app/util/HttpClient.php b/backend/app/util/HttpClient.php new file mode 100644 index 0000000..97ed271 --- /dev/null +++ b/backend/app/util/HttpClient.php @@ -0,0 +1,165 @@ + HTTP {$httpCode}"; + + error_log($logMessage); + + if (!empty($requestData)) { + error_log("📤 Request: " . json_encode($requestData)); + } + + error_log("📥 Response: {$response}"); + } + + /** + * 设置默认超时时间 + * + * @param int $timeout 超时时间(秒) + */ + public static function setDefaultTimeout(int $timeout): void + { + self::$defaultTimeout = $timeout; + } +} diff --git a/backend/config/route.php b/backend/config/route.php index eba94e5..d30ef7f 100644 --- a/backend/config/route.php +++ b/backend/config/route.php @@ -15,7 +15,7 @@ Route::group('/api/v1', function () { })->middleware($apiMiddleware); // 飞书登录回调(不在 /api/v1 下,供飞书重定向使用) -Route::get('/login/qrcode/{id}', [app\controller\AuthController::class, 'qrcodeLoginCallback'])->middleware($apiMiddleware); +Route::get('/login/callback', [app\controller\AuthController::class, 'qrcodeLoginCallback'])->middleware($apiMiddleware); // 用户路由(需要认证) Route::group('/api/v1/user', function () {