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

40 KiB
Raw Permalink Blame History

飞书登录实现指南

本文档包含两种飞书登录方式:

  1. 扫码登录 - 用户在飞书外打开应用,扫描二维码登录
  2. 静默授权(免登) - 用户在飞书客户端内打开应用,直接获取身份登录

一、前置条件

1.1 飞书开放平台配置

必须配置(关键!否则回调不会被调用):

  1. 登录 飞书开放平台
  2. 选择应用(如 cli_a8033d607cba500d)
  3. 进入 权限管理 → 安全设置 → 重定向 URL
  4. 添加回调地址:https://your-domain.com/login/callback
  5. 保存并发布应用

1.2 数据库表

确保存在以下表:

-- 飞书登录会话表
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 路由配置

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

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 接口,用于处理飞书免登:

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

<!-- 飞书 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 类型声明

// 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 登录组件实现

// 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 一起引入):

<!-- 飞书 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 类型声明

// 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 组件实现

// 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 配置

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 配置