563 lines
22 KiB
Markdown
563 lines
22 KiB
Markdown
|
|
# 飞书登录实现经验总结
|
|||
|
|
|
|||
|
|
本文档记录了飞书登录(扫码登录 + 静默免登)的完整实现经验和调试过程中遇到的问题。
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## 一、两种登录方式对比
|
|||
|
|
|
|||
|
|
| 方式 | 使用场景 | SDK | 核心API |
|
|||
|
|
|------|----------|-----|---------|
|
|||
|
|
| **飞书内免登** | 用户在飞书客户端内打开应用 | H5 JSSDK | `tt.requestAuthCode()` |
|
|||
|
|
| **飞书外扫码登录** | 用户在普通浏览器中打开 | QR SDK | `window.QRLogin()` |
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## 二、飞书内免登(静默授权)
|
|||
|
|
|
|||
|
|
### 2.1 完整流程
|
|||
|
|
|
|||
|
|
```
|
|||
|
|
┌─────────────────────────────────────────────────────────────┐
|
|||
|
|
│ 用户在飞书客户端内打开应用 │
|
|||
|
|
└───────────────────────────┬─────────────────────────────────┘
|
|||
|
|
│
|
|||
|
|
▼
|
|||
|
|
┌─────────────────────────────────────────────────────────────┐
|
|||
|
|
│ 前端检测环境 (navigator.userAgent 包含 Lark/feishu/lark) │
|
|||
|
|
│ 同时在 URL 中携带 company 参数 (如 ?company=2) │
|
|||
|
|
└───────────────────────────┬─────────────────────────────────┘
|
|||
|
|
│
|
|||
|
|
▼
|
|||
|
|
┌─────────────────────────────────────────────────────────────┐
|
|||
|
|
│ 等待飞书 H5 JSSDK 加载完成 │
|
|||
|
|
│ - 检查 window.h5sdk?.ready │
|
|||
|
|
│ - 如果存在,调用 h5sdk.ready() 等待 tt 对象注入 │
|
|||
|
|
│ - 如果不存在,轮询等待 window.tt 出现(最多5秒) │
|
|||
|
|
└───────────────────────────┬─────────────────────────────────┘
|
|||
|
|
│
|
|||
|
|
▼
|
|||
|
|
┌─────────────────────────────────────────────────────────────┐
|
|||
|
|
│ 获取 appId │
|
|||
|
|
│ 1. 优先从 URL 参数获取 (?app_id=xxx) │
|
|||
|
|
│ 2. 如果没有,从 URL 获取 company 参数 │
|
|||
|
|
│ 3. 调用 GET /api/v1/auth/feishu-config?company=2 │
|
|||
|
|
│ 4. 后端返回该公司的 appId │
|
|||
|
|
└───────────────────────────┬─────────────────────────────────┘
|
|||
|
|
│
|
|||
|
|
▼
|
|||
|
|
┌─────────────────────────────────────────────────────────────┐
|
|||
|
|
│ 调用 tt.requestAuthCode({ │
|
|||
|
|
│ appId: "cli_xxx", │
|
|||
|
|
│ redirectUri: "https://xxx.com/login/callback" │
|
|||
|
|
│ }) │
|
|||
|
|
└───────────────────────────┬─────────────────────────────────┘
|
|||
|
|
│
|
|||
|
|
▼
|
|||
|
|
┌─────────────────────────────────────────────────────────────┐
|
|||
|
|
│ success 回调获取 auth_code (临时授权码,5分钟有效) │
|
|||
|
|
└───────────────────────────┬─────────────────────────────────┘
|
|||
|
|
│
|
|||
|
|
▼
|
|||
|
|
┌─────────────────────────────────────────────────────────────┐
|
|||
|
|
│ 前端调用 POST /api/v1/auth/feishu-login │
|
|||
|
|
│ { auth_code: "xxx", company: "2" } │
|
|||
|
|
└───────────────────────────┬─────────────────────────────────┘
|
|||
|
|
│
|
|||
|
|
▼
|
|||
|
|
┌─────────────────────────────────────────────────────────────┐
|
|||
|
|
│ 后端处理并返回 token 和 user │
|
|||
|
|
│ 前端保存到 localStorage 和 Zustand store │
|
|||
|
|
│ 跳转到 dashboard 或 redirect 参数指定的页面 │
|
|||
|
|
└─────────────────────────────────────────────────────────────┘
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
### 2.2 关键代码
|
|||
|
|
|
|||
|
|
#### 引入 SDK (index.html)
|
|||
|
|
```html
|
|||
|
|
<!-- 飞书 H5 JSSDK(网页应用免登用) -->
|
|||
|
|
<script src="https://lf1-cdn-tos.bytegoofy.com/goofy/lark/op/h5-js-sdk-1.5.19.js"></script>
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
#### 类型声明
|
|||
|
|
```typescript
|
|||
|
|
declare global {
|
|||
|
|
interface Window {
|
|||
|
|
tt?: any
|
|||
|
|
h5sdk?: {
|
|||
|
|
ready: (callback: () => void) => void
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
#### 免登实现
|
|||
|
|
```typescript
|
|||
|
|
// 飞书内免登
|
|||
|
|
const attemptFeishuAutoLogin = async (companyParam?: string) => {
|
|||
|
|
// 如果 h5sdk 存在,等待 ready 后再调用
|
|||
|
|
if (window.h5sdk?.ready) {
|
|||
|
|
window.h5sdk.ready(() => {
|
|||
|
|
performFeishuLoginWithAppId(companyParam)
|
|||
|
|
})
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 如果 tt 对象已存在,直接使用
|
|||
|
|
if (window.tt) {
|
|||
|
|
performFeishuLoginWithAppId(companyParam)
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 轮询等待 tt 对象(最多 5 秒)
|
|||
|
|
let attempts = 0
|
|||
|
|
const maxAttempts = 50
|
|||
|
|
|
|||
|
|
const checkInterval = setInterval(() => {
|
|||
|
|
attempts++
|
|||
|
|
if (window.tt) {
|
|||
|
|
clearInterval(checkInterval)
|
|||
|
|
performFeishuLoginWithAppId(companyParam)
|
|||
|
|
} else if (attempts >= maxAttempts) {
|
|||
|
|
clearInterval(checkInterval)
|
|||
|
|
console.error('等待 tt 对象超时')
|
|||
|
|
setLoading(false)
|
|||
|
|
}
|
|||
|
|
}, 100)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 获取 appId 并执行免登
|
|||
|
|
const performFeishuLoginWithAppId = async (companyParam?: string) => {
|
|||
|
|
let appId: string | undefined | null
|
|||
|
|
const urlParams = new URLSearchParams(window.location.search)
|
|||
|
|
appId = urlParams.get('app_id')
|
|||
|
|
|
|||
|
|
if (!appId && companyParam) {
|
|||
|
|
try {
|
|||
|
|
const response = await fetch(`/api/v1/auth/feishu-config?company=${companyParam}`)
|
|||
|
|
const result = await response.json()
|
|||
|
|
if (result.code === 0 && result.data.app_id) {
|
|||
|
|
appId = result.data.app_id
|
|||
|
|
}
|
|||
|
|
} catch (e) {
|
|||
|
|
console.error('获取 appId 失败:', e)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
performFeishuLogin(companyParam, appId || undefined)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 执行飞书免登
|
|||
|
|
const performFeishuLogin = async (companyParam?: string, appId?: string | null) => {
|
|||
|
|
if (!window.tt) {
|
|||
|
|
setLoading(false)
|
|||
|
|
Toast.show('飞书环境异常,请刷新重试')
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (!appId) {
|
|||
|
|
setLoading(false)
|
|||
|
|
Toast.show('飞书应用配置错误')
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const redirectUri = `${window.location.origin}/login/callback`
|
|||
|
|
|
|||
|
|
window.tt.requestAuthCode({
|
|||
|
|
appId: appId,
|
|||
|
|
redirectUri: redirectUri,
|
|||
|
|
success: (res: any) => {
|
|||
|
|
handleFeishuLogin(res.code, companyParam)
|
|||
|
|
},
|
|||
|
|
fail: (error: any) => {
|
|||
|
|
console.error('免登失败:', error)
|
|||
|
|
setLoading(false)
|
|||
|
|
Toast.show('免登失败:' + (error.errMsg || JSON.stringify(error)))
|
|||
|
|
},
|
|||
|
|
})
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
### 2.3 Token 过期自动刷新
|
|||
|
|
|
|||
|
|
当 API 返回 401 时,在飞书环境中自动刷新 token,用户无感知:
|
|||
|
|
|
|||
|
|
```typescript
|
|||
|
|
// api.ts - 响应拦截器
|
|||
|
|
api.interceptors.response.use(
|
|||
|
|
(response) => { ... },
|
|||
|
|
async (error) => {
|
|||
|
|
const originalRequest = error.config
|
|||
|
|
|
|||
|
|
// 处理 401 未授权错误
|
|||
|
|
if (error.response?.status === 401 || error.response?.data?.code === 401) {
|
|||
|
|
// 防止死循环:如果已经重试过一次,不再重试
|
|||
|
|
if (originalRequest._retry) {
|
|||
|
|
localStorage.removeItem('token')
|
|||
|
|
window.location.href = '/login'
|
|||
|
|
return Promise.reject(new Error('登录已过期'))
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
try {
|
|||
|
|
// 尝试飞书免登刷新 token
|
|||
|
|
await handleUnauthorized()
|
|||
|
|
// token 刷新成功,重试原始请求
|
|||
|
|
originalRequest._retry = true
|
|||
|
|
originalRequest.headers.Authorization = `Bearer ${localStorage.getItem('token')}`
|
|||
|
|
return api.request(originalRequest)
|
|||
|
|
} catch (e) {
|
|||
|
|
return Promise.reject(new Error('登录已过期'))
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
...
|
|||
|
|
}
|
|||
|
|
)
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
### 2.4 常见错误和解决方案
|
|||
|
|
|
|||
|
|
| 错误信息 | 原因 | 解决方案 |
|
|||
|
|
|---------|------|----------|
|
|||
|
|
| `requestAuthCode:fail invalid appId` | appId 参数为空 | 确保从 URL 或后端获取到正确的 appId |
|
|||
|
|
| `requestAuthCode:fail invalid url` | 缺少 redirectUri 参数 | 添加 `redirectUri: ${window.location.origin}/login/callback` |
|
|||
|
|
| `等待 tt 对象超时` | H5 JSSDK 未加载或未注入 tt | 确保引入了 H5 JSSDK,使用 h5sdk.ready() |
|
|||
|
|
| `tt.requestAccess is not a function` | API 名称错误 | 应使用 `tt.requestAuthCode` 不是 `requestAccess` |
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## 三、飞书外扫码登录
|
|||
|
|
|
|||
|
|
### 3.1 完整流程
|
|||
|
|
|
|||
|
|
```
|
|||
|
|
┌─────────────────────────────────────────────────────────────┐
|
|||
|
|
│ 用户点击"飞书扫码登录"按钮 │
|
|||
|
|
└───────────────────────────┬─────────────────────────────────┘
|
|||
|
|
│
|
|||
|
|
▼
|
|||
|
|
┌─────────────────────────────────────────────────────────────┐
|
|||
|
|
│ 前端调用 POST /api/v1/auth/qrcode-login │
|
|||
|
|
│ 后端创建 session,返回 auth_url 和 session_id │
|
|||
|
|
└───────────────────────────┬─────────────────────────────────┘
|
|||
|
|
│
|
|||
|
|
▼
|
|||
|
|
┌─────────────────────────────────────────────────────────────┐
|
|||
|
|
│ 前端使用 QRLogin SDK 初始化二维码 │
|
|||
|
|
│ window.QRLogin({ id: "xxx", goto: authUrl }) │
|
|||
|
|
└───────────────────────────┬─────────────────────────────────┘
|
|||
|
|
│
|
|||
|
|
▼
|
|||
|
|
┌─────────────────────────────────────────────────────────────┐
|
|||
|
|
│ 用户用飞书 APP 扫码 │
|
|||
|
|
│ QR SDK 触发 message 事件,返回 tmp_code │
|
|||
|
|
└───────────────────────────┬─────────────────────────────────┘
|
|||
|
|
│
|
|||
|
|
▼
|
|||
|
|
┌─────────────────────────────────────────────────────────────┐
|
|||
|
|
│ 前端重定向到飞书授权页面 │
|
|||
|
|
│ window.location.href = authUrl + "&tmp_code=" + tmp_code │
|
|||
|
|
└───────────────────────────┬─────────────────────────────────┘
|
|||
|
|
│
|
|||
|
|
▼
|
|||
|
|
┌─────────────────────────────────────────────────────────────┐
|
|||
|
|
│ 用户在手机上点击"允许" │
|
|||
|
|
│ 飞书重定向到 /login/callback?code=xxx&state=session_id │
|
|||
|
|
└───────────────────────────┬─────────────────────────────────┘
|
|||
|
|
│
|
|||
|
|
▼
|
|||
|
|
┌─────────────────────────────────────────────────────────────┐
|
|||
|
|
│ 后端处理回调,更新 session 状态为 success │
|
|||
|
|
│ 返回包含 token 的 HTML 页面 │
|
|||
|
|
└───────────────────────────┬─────────────────────────────────┘
|
|||
|
|
│
|
|||
|
|
▼
|
|||
|
|
┌─────────────────────────────────────────────────────────────┐
|
|||
|
|
│ HTML 页面 JS 将 token 保存到 localStorage │
|
|||
|
|
│ 跳转到 dashboard │
|
|||
|
|
└─────────────────────────────────────────────────────────────┘
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
### 3.2 关键代码
|
|||
|
|
|
|||
|
|
#### 引入 SDK (index.html)
|
|||
|
|
```html
|
|||
|
|
<!-- 飞书扫码登录 SDK(飞书外扫码登录用) -->
|
|||
|
|
<script src="https://lf-package-cn.feishucdn.com/obj/feishu-static/lark/passport/qrcode/LarkSSOSDKWebQRCode-1.0.3.js"></script>
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
#### 二维码初始化
|
|||
|
|
```typescript
|
|||
|
|
const initQRCode = async () => {
|
|||
|
|
// 生成登录会话
|
|||
|
|
const response = await fetch('/api/v1/auth/qrcode-login', {
|
|||
|
|
method: 'POST',
|
|||
|
|
headers: { 'Content-Type': 'application/json' },
|
|||
|
|
body: JSON.stringify({ company: companyParam }),
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
const result = await response.json()
|
|||
|
|
const authUrl = result.data.auth_url
|
|||
|
|
|
|||
|
|
if (window.QRLogin) {
|
|||
|
|
qrObjRef.current = window.QRLogin({
|
|||
|
|
id: 'feishu-qrcode-container',
|
|||
|
|
goto: authUrl,
|
|||
|
|
width: '250',
|
|||
|
|
height: '250',
|
|||
|
|
style: 'width:300px;height:270px;border:none'
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
// 监听扫码后的 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) {
|
|||
|
|
// 重定向到飞书授权页面
|
|||
|
|
const authUrlWithCode = `${authUrl}&tmp_code=${loginTmpCode}`
|
|||
|
|
window.location.href = authUrlWithCode
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
window.addEventListener('message', handleMessage)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
### 3.3 常见错误和解决方案
|
|||
|
|
|
|||
|
|
| 错误信息 | 原因 | 解决方案 |
|
|||
|
|
|---------|------|----------|
|
|||
|
|
| `缺少 company、app_id 或 tenant_key 参数` | 请求时缺少必要参数 | 确保 URL 中有 `?company=2` 参数 |
|
|||
|
|
| 二维码不显示 | QR SDK 未加载或容器未渲染 | 确保 SDK 已加载,使用 setTimeout 等待 DOM 渲染 |
|
|||
|
|
| 扫码后没反应 | message 事件未正确监听 | 检查 matchOrigin 和 matchData 安全校验 |
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## 四、后端接口
|
|||
|
|
|
|||
|
|
### 4.1 获取飞书配置
|
|||
|
|
```php
|
|||
|
|
/**
|
|||
|
|
* GET /api/v1/auth/feishu-config?company=2
|
|||
|
|
* 返回指定公司的飞书应用 appId
|
|||
|
|
*/
|
|||
|
|
public function feishuConfig(Request $request): Response
|
|||
|
|
{
|
|||
|
|
$companyParam = $request->get('company');
|
|||
|
|
$company = \app\model\Company::find($companyParam);
|
|||
|
|
|
|||
|
|
$feishuApp = \app\model\CompanyFeishuApp::where('company_id', $company->id)
|
|||
|
|
->where('system_id', 1)
|
|||
|
|
->where('status', 1)
|
|||
|
|
->first();
|
|||
|
|
|
|||
|
|
return $this->success([
|
|||
|
|
'app_id' => $feishuApp->app_id,
|
|||
|
|
'company_id' => $company->id,
|
|||
|
|
]);
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
### 4.2 飞书登录
|
|||
|
|
```php
|
|||
|
|
/**
|
|||
|
|
* POST /api/v1/auth/feishu-login
|
|||
|
|
* 用 auth_code 换取用户信息和 token
|
|||
|
|
*/
|
|||
|
|
public function feishuLogin(Request $request): Response
|
|||
|
|
{
|
|||
|
|
$authCode = $request->post('auth_code');
|
|||
|
|
$companyParam = $request->post('company');
|
|||
|
|
|
|||
|
|
// 用 auth_code 获取用户信息
|
|||
|
|
$feishuService = new FeishuService();
|
|||
|
|
$feishuService->setCompany($companyParam);
|
|||
|
|
$feishuData = $feishuService->getUserInfo($authCode);
|
|||
|
|
|
|||
|
|
// 创建或更新用户
|
|||
|
|
$user = $feishuService->createOrUpdateUser($feishuData, $companyParam);
|
|||
|
|
|
|||
|
|
// 生成 JWT token
|
|||
|
|
$token = JwtUtil::encode([...]);
|
|||
|
|
|
|||
|
|
return $this->success([
|
|||
|
|
'token' => $token,
|
|||
|
|
'user' => $user,
|
|||
|
|
]);
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## 五、调试技巧
|
|||
|
|
|
|||
|
|
### 5.1 环境检测页面
|
|||
|
|
创建 `/feishu-env-test` 页面,用于检测飞书环境:
|
|||
|
|
|
|||
|
|
```typescript
|
|||
|
|
// 检测项目:
|
|||
|
|
// 1. navigator.userAgent
|
|||
|
|
// 2. window.tt 是否存在
|
|||
|
|
// 3. window.h5sdk 是否存在
|
|||
|
|
// 4. tt.requestAuthCode 是否可用
|
|||
|
|
// 5. 实际调用 tt.requestAuthCode 测试
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
### 5.2 常用 URL 参数
|
|||
|
|
| 参数 | 说明 | 示例 |
|
|||
|
|
|------|------|------|
|
|||
|
|
| `company` | 公司ID(必需) | `?company=2` |
|
|||
|
|
| `app_id` | 飞书应用ID(可选) | `?app_id=cli_xxx` |
|
|||
|
|
| `redirect` | 登录成功后跳转地址 | `?redirect=/dashboard` |
|
|||
|
|
| `auth_code` | 飞书授权码(回调时使用) | `?auth_code=xxx` |
|
|||
|
|
|
|||
|
|
### 5.3 日志关键点
|
|||
|
|
```typescript
|
|||
|
|
console.log('🔍 company 参数:', companyParam)
|
|||
|
|
console.log('🔍 appId:', appId)
|
|||
|
|
console.log('🔍 redirect_uri:', redirectUri)
|
|||
|
|
console.log('✅ 免登成功, code:', res.code)
|
|||
|
|
console.error('❌ 免登失败:', error)
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## 六、最佳实践
|
|||
|
|
|
|||
|
|
### 6.1 不要依赖本地 token 检查
|
|||
|
|
**问题**:前端检查 localStorage token 存在就直接跳转,可能导致使用过期 token。
|
|||
|
|
|
|||
|
|
**解决**:由后端验证 token 有效性,前端只负责在 401 时处理。
|
|||
|
|
|
|||
|
|
### 6.2 防止死循环
|
|||
|
|
**问题**:401 → 刷新 token → 重试请求 → 又 401 → 无限循环
|
|||
|
|
|
|||
|
|
**解决**:使用 `_retry` 标记,每个请求只重试一次。
|
|||
|
|
|
|||
|
|
```typescript
|
|||
|
|
if (originalRequest._retry) {
|
|||
|
|
// 已经重试过,不再重试
|
|||
|
|
return Promise.reject(new Error('登录已过期'))
|
|||
|
|
}
|
|||
|
|
originalRequest._retry = true
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
### 6.3 参数传递的完整性
|
|||
|
|
**问题**:从其他页面跳转到登录页时,company 参数可能丢失。
|
|||
|
|
|
|||
|
|
**解决**:从 redirect 参数中提取 company:
|
|||
|
|
|
|||
|
|
```typescript
|
|||
|
|
let companyParam = urlParams.get('company')
|
|||
|
|
if (!companyParam) {
|
|||
|
|
const redirect = urlParams.get('redirect')
|
|||
|
|
if (redirect) {
|
|||
|
|
const redirectUrl = new URL(redirect, window.location.origin)
|
|||
|
|
companyParam = redirectUrl.searchParams.get('company')
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
### 6.4 错误提示的准确性
|
|||
|
|
**问题**:显示误导性的错误信息(如"飞书应用配置错误"但实际已登录成功)。
|
|||
|
|
|
|||
|
|
**解决**:只在确定失败时显示 Toast,可能成功的情况用 console.error 记录。
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## 七、飞书开放平台配置
|
|||
|
|
|
|||
|
|
### 7.1 应用类型
|
|||
|
|
- **网页应用** - 用于飞书内免登和飞书外扫码登录
|
|||
|
|
- 注意:飞书没有"H5应用"这个类型,只有"网页应用"
|
|||
|
|
|
|||
|
|
### 7.2 必配项
|
|||
|
|
| 配置项 | 说明 | 示例 |
|
|||
|
|
|--------|------|------|
|
|||
|
|
| 重定向 URL | 扫码登录回调地址 | `https://xxx.com/login/callback` |
|
|||
|
|
| 可信域名 | 允许嵌入应用的域名 | `xxx.com` |
|
|||
|
|
|
|||
|
|
### 7.3 权限配置
|
|||
|
|
- 获取用户基本信息
|
|||
|
|
- 获取用户邮箱
|
|||
|
|
- 获取用户手机号
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## 八、相关文件路径
|
|||
|
|
|
|||
|
|
```
|
|||
|
|
/www/x-server-mobile/
|
|||
|
|
├── frontend/
|
|||
|
|
│ ├── index.html # SDK 引入
|
|||
|
|
│ ├── src/
|
|||
|
|
│ │ ├── pages/
|
|||
|
|
│ │ │ ├── login/
|
|||
|
|
│ │ │ │ ├── index.tsx # 登录页主逻辑
|
|||
|
|
│ │ │ │ └── QRCodeLogin.tsx # 扫码登录组件
|
|||
|
|
│ │ │ └── feishu-env-test.tsx # 环境检测页
|
|||
|
|
│ │ └── services/
|
|||
|
|
│ │ └── api.ts # API 封装(含 401 处理)
|
|||
|
|
├── backend/
|
|||
|
|
│ ├── app/
|
|||
|
|
│ │ ├── controller/
|
|||
|
|
│ │ │ └── AuthController.php # 认证控制器
|
|||
|
|
│ │ └── model/
|
|||
|
|
│ │ ├── Company.php # 公司模型
|
|||
|
|
│ │ └── CompanyFeishuApp.php # 飞书应用配置
|
|||
|
|
│ └── config/
|
|||
|
|
│ └── route.php # 路由配置
|
|||
|
|
└── docs/
|
|||
|
|
├── FEISHU_LOGIN_EXPERIENCE.md # 本文档
|
|||
|
|
└── feishu-qrcode-login.md # 原始文档
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## 九、检查清单
|
|||
|
|
|
|||
|
|
部署前检查:
|
|||
|
|
|
|||
|
|
- [ ] 飞书开放平台已配置重定向 URL
|
|||
|
|
- [ ] index.html 已引入 H5 JSSDK 和 QR SDK
|
|||
|
|
- [ ] 后端 `/api/v1/auth/feishu-config` 接口正常
|
|||
|
|
- [ ] 后端 `/api/v1/auth/feishu-login` 接口正常
|
|||
|
|
- [ ] 后端 `/api/v1/auth/qrcode-login` 接口正常
|
|||
|
|
- [ ] 后端 `/login/callback` 路由已配置
|
|||
|
|
- [ ] Nginx 回调路由代理配置正确
|
|||
|
|
- [ ] 数据库 company 表有对应的飞书应用配置
|
|||
|
|
- [ ] 测试 URL 包含 company 参数(如 `?company=2`)
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## 十、快速调试命令
|
|||
|
|
|
|||
|
|
```bash
|
|||
|
|
# 查看前端日志
|
|||
|
|
# 浏览器控制台
|
|||
|
|
|
|||
|
|
# 查看后端日志
|
|||
|
|
docker logs x-server-backend -f --tail 50
|
|||
|
|
|
|||
|
|
# 重启后端
|
|||
|
|
docker compose restart backend
|
|||
|
|
|
|||
|
|
# 构建前端
|
|||
|
|
cd /www/x-server-mobile/frontend
|
|||
|
|
npm run build
|
|||
|
|
|
|||
|
|
# 访问环境检测页
|
|||
|
|
https://xxx.com/feishu-env-test?company=2
|
|||
|
|
|
|||
|
|
# 访问登录页
|
|||
|
|
https://xxx.com/login?company=2
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
**最后更新**: 2026-02-28
|
|||
|
|
**维护者**: mzl
|