1042 lines
40 KiB
Markdown
1042 lines
40 KiB
Markdown
|
|
# 飞书登录实现指南
|
|||
|
|
|
|||
|
|
本文档包含两种飞书登录方式:
|
|||
|
|
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 配置
|
|||
|
|
```
|