diff --git a/docs/FEISHU_LOGIN_EXPERIENCE.md b/docs/FEISHU_LOGIN_EXPERIENCE.md new file mode 100644 index 0000000..9bb2775 --- /dev/null +++ b/docs/FEISHU_LOGIN_EXPERIENCE.md @@ -0,0 +1,562 @@ +# 飞书登录实现经验总结 + +本文档记录了飞书登录(扫码登录 + 静默免登)的完整实现经验和调试过程中遇到的问题。 + +--- + +## 一、两种登录方式对比 + +| 方式 | 使用场景 | 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 + + +``` + +#### 类型声明 +```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 + + +``` + +#### 二维码初始化 +```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