Files

1042 lines
40 KiB
Markdown
Raw Permalink Normal View History

# 飞书登录实现指南
本文档包含两种飞书登录方式:
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
<?php
namespace app\controller;
use app\service\FeishuService;
use app\util\JwtUtil;
use app\model\User;
use app\model\Company;
use Webman\Http\Request;
use Webman\Http\Response;
class AuthController extends BaseController
{
/**
* 生成飞书扫码登录二维码
* POST /api/v1/auth/qrcode-login
*/
public function qrcodeLogin(Request $request): Response
{
$tenantKey = $request->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 <<<HTML
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>登录成功</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
display: flex;
justify-content: center;
align-items: center;
min-height: 100vh;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
}
.container {
text-align: center;
padding: 40px;
background: rgba(255,255,255,0.1);
border-radius: 20px;
backdrop-filter: blur(10px);
box-shadow: 0 8px 32px rgba(0,0,0,0.1);
}
.icon { font-size: 60px; margin-bottom: 20px; }
h1 { margin: 0 0 10px 0; font-size: 24px; font-weight: 500; }
p { margin: 10px 0; opacity: 0.9; }
.spinner {
width: 40px; height: 40px;
border: 4px solid rgba(255,255,255,0.3);
border-top-color: white;
border-radius: 50%;
animation: spin 1s linear infinite;
margin: 20px auto;
}
@keyframes spin { to { transform: rotate(360deg); } }
</style>
</head>
<body>
<div class="container">
<div class="icon">✅</div>
<h1>登录成功!</h1>
<p>欢迎回来,{$userName}</p>
<p>正在跳转到应用...</p>
<div class="spinner"></div>
</div>
<script>
try {
// 保存 token 到 localStorage
localStorage.setItem('token', '$token');
localStorage.setItem('i18nextLng', 'zh');
console.log('✅ Token saved');
// 通知父窗口(如果有)
if (window.opener) {
window.opener.postMessage({
type: 'feishu-login-success',
data: { token: '$token', user: { id: {$userId}, name: '{$userName}' } }
}, '*');
setTimeout(function() { window.close(); }, 500);
} else {
// 跳转到首页
setTimeout(function() {
window.location.href = '/dashboard';
}, 1500);
}
} catch (e) {
console.error('❌ Error:', e);
// 使用 sessionStorage 作为备用
sessionStorage.setItem('token', '$token');
setTimeout(function() {
window.location.href = '/dashboard';
}, 1500);
}
</script>
</body>
</html>
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
<!-- 飞书 JSAPI SDK(用于飞书内免登) -->
<script src="https://lf1-cdn-tos.bytegoofy.com/goofy/lark/op/h5-jsapi-1.5.20/index.js"></script>
<!-- 飞书扫码登录 SDK -->
<script src="https://lf-package-cn.feishucdn.com/obj/feishu-static/lark/passport/qrcode/LarkSSOSDKWebQRCode-1.0.3.js"></script>
```
#### 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<void>((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 <div>正在登录...</div>
}
return (
<div>
<h1>企业工具台</h1>
{environment === 'external' && (
<p>💡 提示:在飞书客户端内打开可自动登录</p>
)}
{/* 显示其他登录方式... */}
</div>
)
}
```
### 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
<!-- 飞书 JSAPI SDK(用于飞书内免登) -->
<script src="https://lf1-cdn-tos.bytegoofy.com/goofy/lark/op/h5-jsapi-1.5.20/index.js"></script>
<!-- 飞书扫码登录 SDK -->
<script src="https://lf-package-cn.feishucdn.com/obj/feishu-static/lark/passport/qrcode/LarkSSOSDKWebQRCode-1.0.3.js"></script>
```
### 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<HTMLDivElement>(null)
const qrObjRef = useRef<any>(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 (
<Dialog
visible={visible}
content={
<div className={styles.qrcodeContainer}>
{status === 'loading' && (
<div className={styles.loading}>正在生成二维码...</div>
)}
{status !== 'loading' && (
<>
<div id="feishu-qrcode-container" ref={qrContainerRef} className={styles.qrcode}></div>
<p className={styles.tip}>💡 打开飞书 APP,扫一扫登录</p>
</>
)}
</div>
}
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 配置
```