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

22 KiB
Raw Permalink Blame History

飞书登录实现经验总结

本文档记录了飞书登录(扫码登录 + 静默免登)的完整实现经验和调试过程中遇到的问题。


一、两种登录方式对比

方式 使用场景 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)

<!-- 飞书 H5 JSSDK(网页应用免登用) -->
<script src="https://lf1-cdn-tos.bytegoofy.com/goofy/lark/op/h5-js-sdk-1.5.19.js"></script>

类型声明

declare global {
  interface Window {
    tt?: any
    h5sdk?: {
      ready: (callback: () => void) => void
    }
  }
}

免登实现

// 飞书内免登
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,用户无感知:

// 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)

<!-- 飞书扫码登录 SDK(飞书外扫码登录用) -->
<script src="https://lf-package-cn.feishucdn.com/obj/feishu-static/lark/passport/qrcode/LarkSSOSDKWebQRCode-1.0.3.js"></script>

二维码初始化

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 获取飞书配置

/**
 * 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 飞书登录

/**
 * 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 页面,用于检测飞书环境:

// 检测项目:
// 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 日志关键点

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 标记,每个请求只重试一次。

if (originalRequest._retry) {
  // 已经重试过,不再重试
  return Promise.reject(new Error('登录已过期'))
}
originalRequest._retry = true

6.3 参数传递的完整性

问题:从其他页面跳转到登录页时,company 参数可能丢失。

解决:从 redirect 参数中提取 company:

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)

十、快速调试命令

# 查看前端日志
# 浏览器控制台

# 查看后端日志
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