# 飞书登录实现指南 本文档包含两种飞书登录方式: 1. **扫码登录** - 用户在飞书外打开应用,扫描二维码登录 2. **静默授权(免登)** - 用户在飞书客户端内打开应用,直接获取身份登录 --- ## 一、前置条件 ### 1.1 飞书开放平台配置 **必须配置**(关键!否则回调不会被调用): 1. 登录 [飞书开放平台](https://open.feishu.cn/) 2. 选择应用(如 `cli_a8033d607cba500d`) 3. 进入 **权限管理** → **安全设置** → **重定向 URL** 4. 添加回调地址:`https://your-domain.com/login/callback` 5. **保存并发布应用** ### 1.2 数据库表 确保存在以下表: ```sql -- 飞书登录会话表 CREATE TABLE feishu_login_session ( id INT AUTO_INCREMENT PRIMARY KEY, company_id INT NOT NULL, tenant_key VARCHAR(255) NOT NULL, status ENUM('pending', 'scanning', 'success', 'expired') DEFAULT 'pending', user_id INT DEFAULT NULL, expires_at DATETIME NOT NULL, created_at DATETIME DEFAULT CURRENT_TIMESTAMP ); -- 公司飞书应用配置表 CREATE TABLE company_feishu_apps ( id INT AUTO_INCREMENT PRIMARY KEY, company_id INT NOT NULL, app_id VARCHAR(255) NOT NULL, app_secret VARCHAR(255) NOT NULL, callback_url VARCHAR(500), mobile_callback_url VARCHAR(500), status TINYINT DEFAULT 1 ); ``` --- ## 二、后端实现 ### 2.1 路由配置 ```php // backend/config/route.php // 飞书扫码登录相关路由 Route::post('/api/v1/auth/qrcode-login', [app\controller\AuthController::class, 'qrcodeLogin']); Route::get('/api/v1/auth/qrcode-login/status', [app\controller\AuthController::class, 'qrcodeLoginStatus']); Route::get('/login/callback', [app\controller\AuthController::class, 'qrcodeLoginCallback']); ``` ### 2.2 控制器实现 ```php post('tenant_key'); if (!$tenantKey) { return $this->error('缺少租户密钥'); } try { $feishuService = new \app\service\FeishuService(); // 根据 tenant_key 获取公司 $company = $feishuService->getCompanyByTenantKey($tenantKey); if (!$company) { return $this->error('公司不存在'); } $feishuService->setCompany($company->id); // 获取飞书应用配置 $feishuApp = \app\model\CompanyFeishuApp::where('company_id', $company->id) ->where('system_id', 1) ->where('status', 1) ->first(); if (!$feishuApp) { return $this->error('未配置飞书应用'); } // 生成登录会话(5分钟过期) $loginSession = \app\model\FeishuLoginSession::create([ 'company_id' => $company->id, 'tenant_key' => $tenantKey, 'status' => 'pending', 'expires_at' => date('Y-m-d H:i:s', time() + 300), ]); // 构建扫码登录 URL $redirectUri = urlencode("https://your-domain.com/login/callback"); $authUrl = "https://passport.feishu.cn/suite/passport/oauth/authorize?" . "client_id={$feishuApp->app_id}&" . "redirect_uri={$redirectUri}&" . "response_type=code&" . "state={$loginSession->id}"; return $this->success([ 'session_id' => $loginSession->id, 'auth_url' => $authUrl, 'expires_at' => $loginSession->expires_at, ]); } catch (\Exception $e) { return $this->error($e->getMessage()); } } /** * 轮询扫码登录状态 * GET /api/v1/auth/qrcode-login/status?session_id=xxx */ public function qrcodeLoginStatus(Request $request): Response { $sessionId = $request->get('session_id'); if (!$sessionId) { return $this->error('缺少会话ID'); } $session = \app\model\FeishuLoginSession::find($sessionId); if (!$session) { return $this->error('登录会话不存在'); } // 检查是否过期 if (strtotime($session->expires_at) < time()) { $session->status = 'expired'; $session->save(); return $this->error('二维码已过期,请刷新重试'); } // 返回当前状态 if ($session->status === 'success') { $user = \app\model\User::find($session->user_id); if (!$user) { return $this->error('用户不存在'); } $token = \app\util\JwtUtil::encode([ 'user_id' => $user->id, 'company_id' => $user->company_id, 'feishu_user_id' => $user->feishu_user_id, 'union_id' => $user->feishu_union_id, ]); // 清除会话 $session->delete(); return $this->success([ 'status' => 'success', 'token' => $token, 'user' => $user->load('company'), ]); } else if ($session->status === 'expired') { return $this->error('二维码已过期,请刷新重试'); } else { // pending 或 scanning return $this->success([ 'status' => $session->status, ]); } } /** * 飞书扫码登录回调 * GET /login/callback?code=xxx&state=xxx */ public function qrcodeLoginCallback(Request $request): Response { $sessionId = $request->get('state'); $authCode = $request->get('code'); error_log("🔔 QRCode callback - session_id: {$sessionId}"); // 无参数时返回空页面(SDK 初始化时的预检请求) if (!$sessionId && !$authCode) { return new \Webman\Http\Response(200, ['Content-Type' => 'text/html; charset=utf-8'], ''); } if (!$sessionId || !$authCode) { return $this->error('参数错误'); } try { $session = \app\model\FeishuLoginSession::find($sessionId); if (!$session) { return $this->error('登录会话不存在'); } // 检查是否过期 if (strtotime($session->expires_at) < time()) { $session->status = 'expired'; $session->save(); return $this->error('二维码已过期'); } // 获取用户信息 $feishuService = new \app\service\FeishuService(); $feishuService->setCompany($session->company_id); $feishuData = $feishuService->getUserInfo($authCode); // 创建或更新用户 $user = $feishuService->createOrUpdateUser($feishuData, $session->company_id); // 更新会话状态 $session->status = 'success'; $session->user_id = $user->id; $session->save(); // 生成 JWT Token $token = \app\util\JwtUtil::encode([ 'user_id' => $user->id, 'company_id' => $user->company_id, 'feishu_user_id' => $user->feishu_user_id, 'union_id' => $user->feishu_union_id, ]); // 返回包含 token 的 HTML 页面 $userName = htmlspecialchars($user->name ?? '用户'); $html = $this->getSuccessHtml($token, $user->id, $userName); return new \Webman\Http\Response(200, ['Content-Type' => 'text/html; charset=utf-8'], $html); } catch (\Exception $e) { error_log("❌ Callback error: " . $e->getMessage()); return $this->error($e->getMessage()); } } /** * 生成登录成功 HTML 页面 */ private function getSuccessHtml(string $token, int $userId, string $userName): string { return << 登录成功
✅

登录成功!

欢迎回来,{$userName}

正在跳转到应用...

HTML; } } ``` --- ## 三、飞书静默授权(免登)实现 飞书静默授权是指用户在飞书客户端内打开应用时,可以直接获取用户身份登录,无需扫码或输入密码。 ### 3.1 后端接口 后端已有 `/api/v1/auth/feishu-login` 接口,用于处理飞书免登: ```php // backend/app/controller/AuthController.php /** * 飞书免登 */ public function feishuLogin(Request $request): Response { $authCode = $request->post('auth_code'); $tenantKey = $request->post('tenant_key'); if (!$authCode) { return $this->error('缺少授权码'); } try { $feishuService = new FeishuService(); // 根据 tenant_key 获取公司 $company = $feishuService->getCompanyByTenantKey($tenantKey); if (!$company) { return $this->error('公司不存在'); } // 设置公司并获取用户信息 $feishuService->setCompany($company->id); $feishuData = $feishuService->getUserInfo($authCode); // 创建或更新用户 $user = $feishuService->createOrUpdateUser($feishuData, $company->id); // 生成 JWT Token $token = JwtUtil::encode([ 'user_id' => $user->id, 'company_id' => $user->company_id, 'feishu_user_id' => $user->feishu_user_id, 'union_id' => $user->union_id, ]); return $this->success([ 'token' => $token, 'user' => $user->load('company'), ]); } catch (\Exception $e) { return $this->error($e->getMessage()); } } ``` ### 3.2 前端实现 #### 3.2.1 引入飞书 JSAPI SDK 在 `index.html` 中添加(**注意**:需要同时引入 JSAPI SDK 和 QR SDK): ```html ``` #### 3.2.2 类型声明 ```typescript // src/types/feishu.d.ts declare global { interface Window { // 飞书 JSAPI(免登) tt?: { ready: (callback: () => void) => void requestAuthCode: (params: { appId: string success?: (res: { code: string }) => void fail?: (err: any) => void }) => void checkJsApi?: (params: { jsApiList: string[] success: (res: any) => void fail?: (err: any) => void }) => void } // 飞书 QR SDK(扫码登录) QRLogin?: (config: { id: string goto: string width?: string height?: string style?: string }) => { matchOrigin: (origin: string) => boolean matchData: (data: any) => boolean } } } export {} ``` #### 3.2.3 登录组件实现 ```typescript // src/pages/login/index.tsx import { useEffect, useState } from 'react' import { useNavigate } from 'react-router-dom' import { Toast } from 'antd-mobile' import { useUserStore } from '@/stores/user' export default function Login() { const navigate = useNavigate() const { login } = useUserStore() const [loading, setLoading] = useState(true) const [environment, setEnvironment] = useState<'feishu' | 'external' | null>(null) useEffect(() => { // 检测是否在飞书客户端内 const ua = navigator.userAgent.toLowerCase() const isInFeishu = ua.includes('feishu') || ua.includes('lark') || window.tt !== undefined if (isInFeishu) { // 飞书内:静默授权 setEnvironment('feishu') handleFeishuAutoLogin() } else { // 飞书外:显示登录选项 setEnvironment('external') setLoading(false) } }, []) // 飞书内静默授权 const handleFeishuAutoLogin = () => { const appId = 'cli_a8033d607cba500d' const maxWaitTime = 5000 // 最多等待 5 秒 const checkInterval = 100 // 每 100ms 检查一次 let elapsedTime = 0 // 等待 tt API 加载完成 const waitForTT = () => { return new Promise((resolve, reject) => { const checkTT = () => { elapsedTime += checkInterval if (window.tt && typeof window.tt.requestAuthCode === 'function') { resolve() } else if (elapsedTime >= maxWaitTime) { reject(new Error('tt API 加载超时')) } else { setTimeout(checkTT, checkInterval) } } checkTT() }) } // 执行飞书免登 const doFeishuLogin = () => { if (!window.tt) { throw new Error('tt API 未加载') } // 调用 tt.ready 确保 SDK 准备就绪 window.tt.ready(() => { // 调用 requestAuthCode 获取授权码 window.tt.requestAuthCode({ appId: appId, success: (res) => { // 用授权码登录后端 handleFeishuLogin(res.code) }, fail: (error: any) => { console.error('requestAuthCode failed:', error) setLoading(false) Toast.show('获取授权失败') }, }) }) } // 开始执行 waitForTT() .then(() => doFeishuLogin()) .catch((error) => { console.error('Feishu auto login failed:', error) setLoading(false) Toast.show('飞书免登失败') }) } // 处理飞书登录 const handleFeishuLogin = async (authCode: string) => { try { const response = await fetch('/api/v1/auth/feishu-login', { method: 'POST', headers: { 'Content-Type' => 'application/json' }, body: JSON.stringify({ auth_code: authCode, tenant_key: 'your_tenant_key', }), }) const result = await response.json() if (result.code === 0) { login(result.data.user, result.data.token) Toast.show('登录成功') navigate('/dashboard') } else { Toast.show(result.message || '登录失败') setLoading(false) } } catch (error) { console.error('登录失败:', error) Toast.show('登录失败,请重试') setLoading(false) } } if (loading) { return
正在登录...
} return (

企业工具台

{environment === 'external' && (

💡 提示:在飞书客户端内打开可自动登录

)} {/* 显示其他登录方式... */}
) } ``` ### 3.3 飞书免登流程 ``` ┌────────────────────────────────────────────────────────────┐ │ 用户在飞书客户端内打开应用 │ └───────────────────────────┬────────────────────────────────┘ │ ▼ ┌────────────────────────────────────────────────────────────┐ │ 前端检测环境 (navigator.userAgent 或 window.tt) │ │ - 检测到飞书环境 → 执行免登流程 │ │ - 未检测到 → 显示登录选项(扫码登录等) │ └───────────────────────────┬────────────────────────────────┘ │ ▼ ┌────────────────────────────────────────────────────────────┐ │ 等待飞书 JSAPI SDK 加载完成 │ │ - 检测 window.tt.requestAuthCode 是否可用 │ │ - 超时时间:5 秒 │ └───────────────────────────┬────────────────────────────────┘ │ ▼ ┌────────────────────────────────────────────────────────────┐ │ 调用 tt.ready() 确保 SDK 准备就绪 │ └───────────────────────────┬────────────────────────────────┘ │ ▼ ┌────────────────────────────────────────────────────────────┐ │ 调用 tt.requestAuthCode({ appId, success, fail }) │ │ - appId: 飞书应用 ID │ │ - success: 回调函数,返回 { code: "xxx" } │ │ - fail: 错误回调 │ └───────────────────────────┬────────────────────────────────┘ │ ▼ ┌────────────────────────────────────────────────────────────┐ │ 获取 auth_code(临时授权码) │ │ - 有效期:5 分钟 │ │ - 只能使用一次 │ └───────────────────────────┬────────────────────────────────┘ │ ▼ ┌────────────────────────────────────────────────────────────┐ │ 前端调用后端接口 POST /api/v1/auth/feishu-login │ │ { auth_code: "xxx", tenant_key: "xxx" } │ └───────────────────────────┬────────────────────────────────┘ │ ▼ ┌────────────────────────────────────────────────────────────┐ │ 后端处理流程 │ │ 1. 用 auth_code 换取 user_access_token │ │ 2. 获取用户信息 │ │ 3. 创建/更新用户记录 │ │ 4. 生成 JWT token │ │ 5. 返回 { token, user } │ └───────────────────────────┬────────────────────────────────┘ │ ▼ ┌────────────────────────────────────────────────────────────┐ │ 前端保存 token 并跳转到首页 │ │ - localStorage.setItem('token', token) │ │ - navigate('/dashboard') │ └────────────────────────────────────────────────────────────┘ ``` ### 3.4 关键要点 | 要点 | 说明 | |------|------| | **SDK 引入** | 必须引入飞书 JSAPI SDK | | **环境检测** | 通过 `navigator.userAgent` 或 `window.tt` 检测 | | **tt.ready** | 调用前必须确保 SDK 准备就绪 | | **auth_code** | 临时授权码,5 分钟有效期,只能用一次 | | **错误处理** | 需要处理 SDK 加载超时、授权失败等情况 | --- ## 四、前端实现(扫码登录) ### 4.1 引入飞书 QR SDK 在 `index.html` 中添加(**注意**:需与 JSAPI SDK 一起引入): ```html ``` ### 4.2 类型声明 ```typescript // src/types/feishu.d.ts declare global { interface Window { QRLogin?: (config: { id: string goto: string width?: string height?: string style?: string }) => { matchOrigin: (origin: string) => boolean matchData: (data: any) => boolean } } } export {} ``` ### 3.3 React 组件实现 ```typescript // src/pages/login/QRCodeLogin.tsx import { useState, useEffect, useRef } from 'react' import { Dialog, Toast } from 'antd-mobile' import { useNavigate } from 'react-router-dom' import styles from './QRCodeLogin.module.css' interface QRCodeLoginProps { visible: boolean onClose: () => void } export default function QRCodeLogin({ visible, onClose }: QRCodeLoginProps) { const navigate = useNavigate() const [status, setStatus] = useState<'loading' | 'pending' | 'scanning' | 'success'>('loading') const [qrcodeUrl, setQrcodeUrl] = useState('') const qrContainerRef = useRef(null) const qrObjRef = useRef(null) useEffect(() => { if (visible) { initQRCode() } return () => { // 清理 if (qrObjRef.current?._cleanup) { qrObjRef.current._cleanup() } if (qrContainerRef.current) { qrContainerRef.current.innerHTML = '' } qrObjRef.current = null } }, [visible]) const initQRCode = async () => { setStatus('loading') setQrcodeUrl('') try { // 等待 SDK 加载 await new Promise(resolve => setTimeout(resolve, 100)) // 生成登录会话 const response = await fetch('/api/v1/auth/qrcode-login', { method: 'POST', headers: { 'Content-Type' => 'application/json' }, body: JSON.stringify({ tenant_key: 'your_tenant_key', // 替换为实际的 tenant_key }), }) const result = await response.json() if (result.code === 0) { const authUrl = result.data.auth_url if (window.QRLogin) { setStatus('pending') // 等待 DOM 渲染 const initQRSDK = (retryCount = 0) => { const container = document.getElementById('feishu-qrcode-container') if (container) { container.innerHTML = '' try { // 初始化 QR SDK qrObjRef.current = window.QRLogin({ id: 'feishu-qrcode-container', goto: authUrl, width: '250', height: '250', style: 'width:300px;height:270px;border:none' }) setStatus('pending') // ⭐ 关键:监听扫码后的 message 事件 const handleMessage = (event: MessageEvent) => { // 安全校验 if (!qrObjRef.current?.matchOrigin(event.origin)) return if (!qrObjRef.current?.matchData(event.data)) return // 获取临时授权码 const loginTmpCode = event.data?.tmp_code if (loginTmpCode) { console.log('✅ Got tmp_code:', loginTmpCode) // ⭐ 重定向到飞书授权页面(官方推荐方式) const authUrlWithCode = `${authUrl}&tmp_code=${loginTmpCode}` window.location.href = authUrlWithCode } } window.addEventListener('message', handleMessage) // 保存清理函数 qrObjRef.current._cleanup = () => { window.removeEventListener('message', handleMessage) } } catch (e) { console.error('❌ QR SDK init error:', e) Toast.show('二维码生成失败') } } else if (retryCount < 10) { setTimeout(() => initQRSDK(retryCount + 1), 100) } } setTimeout(() => initQRSDK(), 100) } } else { Toast.show(result.message || '生成二维码失败') } } catch (error) { console.error('生成二维码失败:', error) Toast.show('生成二维码失败') } } const getStatusText = () => { switch (status) { case 'loading': return '正在生成二维码...' case 'pending': return '请使用飞书扫码登录' case 'scanning': return '已扫码,请在手机上确认' case 'success': return '登录成功!' default: return '' } } return ( {status === 'loading' && (
正在生成二维码...
)} {status !== 'loading' && ( <>

💡 打开飞书 APP,扫一扫登录

)} } onClose={onClose} closeOnMaskClick={true} /> ) } ``` --- ## 四、Nginx 配置 ```nginx server { listen 80; server_name your-domain.com; # ⭐ 飞书登录回调代理(必须在前端代理之前) location /login/callback { proxy_pass http://127.0.0.1:8787; proxy_http_version 1.1; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; } # 后端 API 代理 location /api/ { proxy_pass http://127.0.0.1:8787/api/; proxy_http_version 1.1; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; } # 前端代理(放在最后) location / { proxy_pass http://127.0.0.1:5173; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection "upgrade"; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; } } ``` --- ## 五、完整流程图 ``` ┌────────────────────────────────────────────────────────────────────┐ │ 用户打开登录页面 │ └───────────────────────────┬────────────────────────────────────────┘ │ ▼ ┌────────────────────────────────────────────────────────────────────┐ │ 前端调用 POST /api/v1/auth/qrcode-login │ │ - 创建会话 (status: pending) │ │ - 返回 auth_url │ └───────────────────────────┬────────────────────────────────────────┘ │ ▼ ┌────────────────────────────────────────────────────────────────────┐ │ QR SDK 初始化 │ │ - window.QRLogin({ goto: authUrl }) │ │ - 渲染二维码 │ └───────────────────────────┬────────────────────────────────────────┘ │ ▼ ┌────────────────────────────────────────────────────────────────────┐ │ 用户用飞书 APP 扫码 │ └───────────────────────────┬────────────────────────────────────────┘ │ ▼ ┌────────────────────────────────────────────────────────────────────┐ │ QR SDK 触发 message 事件 │ │ - event.data.tmp_code = "xxx-xxx-xxx" │ └───────────────────────────┬────────────────────────────────────────┘ │ ▼ ┌────────────────────────────────────────────────────────────────────┐ │ 前端接收 tmp_code │ │ - window.location.href = authUrl + "&tmp_code=" + tmp_code │ │ - ⭐ 页面重定向到飞书授权页面 │ └───────────────────────────┬────────────────────────────────────────┘ │ ▼ ┌────────────────────────────────────────────────────────────────────┐ │ 飞书授权页面加载 │ │ - URL 包含 tmp_code 参数 │ └───────────────────────────┬────────────────────────────────────────┘ │ ▼ ┌────────────────────────────────────────────────────────────────────┐ │ 用户在手机上点击"允许" │ └───────────────────────────┬────────────────────────────────────────┘ │ ▼ ┌────────────────────────────────────────────────────────────────────┐ │ 飞书重定向到 redirect_uri │ │ - GET /login/callback?code=xxx&state=session_id │ │ - ⭐ code 只能使用一次 │ └───────────────────────────┬────────────────────────────────────────┘ │ ▼ ┌────────────────────────────────────────────────────────────────────┐ │ 后端处理回调 │ │ 1. 验证 state (session_id) │ │ 2. 用 code 换取 user_access_token │ │ 3. 获取用户信息 │ │ 4. 创建/更新用户记录 │ │ 5. 更新会话状态 (status: success) │ │ 6. 生成 JWT token │ │ 7. 返回包含 token 的 HTML 页面 │ └───────────────────────────┬────────────────────────────────────────┘ │ ▼ ┌────────────────────────────────────────────────────────────────────┐ │ HTML 页面执行 JavaScript │ │ 1. localStorage.setItem('token', token) │ │ 2. 跳转到 /dashboard │ └───────────────────────────┬────────────────────────────────────────┘ │ ▼ ┌────────────────────────────────────────────────────────────────────┐ │ 登录成功,用户进入应用 │ └────────────────────────────────────────────────────────────────────┘ ``` --- ## 六、关键要点总结 | 要点 | 说明 | |------|------| | **redirect_uri 白名单** | ⭐ 最重要!必须在飞书开放平台配置,否则回调不会被调用 | | **SDK 引入** | 使用官方 SDK: `LarkSSOSDKWebQRCode-1.0.3.js` | | **message 事件** | 扫码后 SDK 触发 `message` 事件,携带 `tmp_code` | | **页面重定向** | ⭐ 使用 `window.location.href` 重定向(不是弹窗) | | **tmp_code 参数** | 追加到 auth_url 后: `&tmp_code=xxx` | | **code 参数** | 飞书授权后通过 `redirect_uri?code=xxx` 返回,只能使用一次 | | **token 传递** | 后端通过 HTML 页面将 token 传递给前端 localStorage | | **会话管理** | 使用 `state` 参数传递 session_id,过期时间 5 分钟 | --- ## 七、常见问题 ### Q1: 扫码后没有反应 **A**: 检查 redirect_uri 是否在飞书开放平台白名单中 ### Q2: 回调接口没有被调用 **A**: 同上,redirect_uri 必须配置白名单 ### Q3: 提示 "code 无效" **A**: code 只能使用一次,不要重复使用测试 code ### Q4: 二维码不显示 **A**: 确保 SDK 已加载,DOM 容器已渲染 --- ## 八、相关文件路径 ``` /www/x-server-mobile/ ├── backend/ │ ├── app/controller/AuthController.php # 后端控制器 │ ├── config/route.php # 路由配置 │ └── ... ├── frontend/ │ ├── src/pages/login/QRCodeLogin.tsx # 前端组件 │ └── index.html # SDK 引入 └── nginx/ └── conf.d/xsm.dev.hnxinyouhe.com.conf # Nginx 配置 ```