Files
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

563 lines
22 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.
# 飞书登录实现经验总结
本文档记录了飞书登录(扫码登录 + 静默免登)的完整实现经验和调试过程中遇到的问题。
---
## 一、两种登录方式对比
| 方式 | 使用场景 | 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