Files
x-server-mobile/docs/modules/auth/feishu-qrcode.md
root e3fa7e86f3 docs: 重组文档结构,按模块分类
新的文档结构:
docs/
├── README.md              # 文档中心导航
├── modules/               # 模块文档
│   ├── auth/             # 认证模块
│   │   ├── README.md
│   │   ├── feishu-login.md
│   │   ├── feishu-qrcode.md
│   │   └── feishu-notification.md
│   ├── approval/         # 审批流程模块
│   │   └── README.md
│   ├── business-trip/    # 出差申请模块
│   │   └── README.md
│   └── user/             # 用户管理模块
│       └── README.md
└── guides/               # 指南文档
    ├── deployment.md
    ├── environment.md
    ├── git-workflow.md
    └── ...

每个模块都有独立的 README.md 说明概述、API、关键文件等
2026-02-28 11:42:51 +08:00

1042 lines
40 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 飞书登录实现指南
本文档包含两种飞书登录方式:
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 配置
```