OAUTH_DUPLICATE_CALL_FIX.md 5.0 KB

OAuth 回调重复调用问题修复

问题描述

在开发环境中,OAuth 回调端点 /api/oauth/callback 被调用了两次,导致第二次调用时出现错误:

{
  "detail": "OAuth 登录失败: 无效的令牌响应格式: {'error': 'invalid_grant', 'error_description': '授权码已被使用'}"
}

根本原因

React StrictMode

在开发环境中,React 18 的 StrictMode 会故意渲染组件两次,以帮助发现副作用问题。这导致 useEffect 被执行两次。

// main.tsx
<StrictMode>
  <BrowserRouter>
    <App />
  </BrowserRouter>
</StrictMode>

错误的修复尝试

最初尝试使用 useState 来防止重复调用:

const [isProcessing, setIsProcessing] = useState(false);

useEffect(() => {
  if (isProcessing) {
    return; // 试图阻止重复调用
  }
  
  setIsProcessing(true);
  // ... 处理逻辑
}, [searchParams, navigate, setAuth, isProcessing]); // ❌ 问题在这里!

问题isProcessing 被添加到依赖数组中,当它从 false 变为 true 时,会触发 useEffect 重新运行,从而绕过了防重复调用的检查。

正确的解决方案

使用 useRef 而不是 useState 来跟踪处理状态:

const isProcessingRef = useRef(false);

useEffect(() => {
  // 防止 React StrictMode 导致的重复调用
  if (isProcessingRef.current) {
    console.log('Already processing, skipping duplicate call');
    return;
  }

  const processCallback = async () => {
    isProcessingRef.current = true;
    console.log('Starting OAuth callback processing...');
    
    try {
      // ... OAuth 处理逻辑
    } catch (err) {
      // ... 错误处理
    }
  };

  processCallback();
}, [searchParams, navigate, setAuth]); // ✅ 不包含 isProcessingRef

为什么 useRef 有效?

  1. 不触发重渲染:修改 ref.current 不会触发组件重渲染
  2. 不在依赖数组中useRef 返回的对象在组件生命周期内保持不变,不需要添加到依赖数组
  3. 跨渲染持久化:即使组件被渲染两次,ref 的值也会保持

执行流程

StrictMode 下的执行顺序

1. 第一次渲染
   - isProcessingRef.current = false
   - 检查通过,开始处理
   - isProcessingRef.current = true
   - 调用 /api/oauth/callback (第一次)

2. StrictMode 触发第二次渲染
   - isProcessingRef.current = true (保持不变)
   - 检查失败,跳过处理
   - 不调用 /api/oauth/callback

结果:只调用一次 ✅

使用 useState 的错误流程

1. 第一次渲染
   - isProcessing = false
   - 检查通过,开始处理
   - setIsProcessing(true)
   - 调用 /api/oauth/callback (第一次)

2. isProcessing 变化触发 useEffect 重新运行
   - isProcessing = true
   - 检查失败,跳过... 等等!
   
3. StrictMode 触发第二次渲染
   - isProcessing = false (重置)
   - 检查通过,开始处理
   - 调用 /api/oauth/callback (第二次) ❌

结果:调用两次 ❌

测试验证

开发环境测试

  1. 启动前端:cd web && yarn nx serve lq_label
  2. 访问:http://localhost:4200/login
  3. 点击"使用 SSO 登录"
  4. 完成 OAuth 登录
  5. 查看浏览器控制台:

    OAuth callback URL parameters: {code: "...", state: "..."}
    Starting OAuth callback processing...
    Code: NhbmBfgF4XMJcsAlykbs2GOAec_0ylqu9JcAKvCcffA
    State: O_Fo8TTBRAHdTxw4GTZD_ZmvAz0fD1KnQIo4TE337fc
    Exchanging code for tokens...
    Already processing, skipping duplicate call  ← 第二次调用被阻止
    Token exchange successful
    
  6. 查看后端日志:

    INFO:     127.0.0.1:xxxxx - "GET /api/oauth/callback?code=...&state=... HTTP/1.1" 200 OK
    

只有一次请求!

生产环境

在生产环境中,StrictMode 会被禁用,所以不会有重复调用问题。但保留这个修复不会有任何负面影响。

其他解决方案(不推荐)

方案 1:禁用 StrictMode(不推荐)

// main.tsx
// ❌ 不推荐:失去 StrictMode 的开发时检查
<BrowserRouter>
  <App />
</BrowserRouter>

缺点:失去 React 18 的开发时检查和警告。

方案 2:使用 AbortController(过度设计)

useEffect(() => {
  const controller = new AbortController();
  
  const processCallback = async () => {
    try {
      const response = await fetch('/api/oauth/callback', {
        signal: controller.signal
      });
      // ...
    } catch (err) {
      if (err.name === 'AbortError') return;
      // ...
    }
  };
  
  processCallback();
  
  return () => controller.abort();
}, [searchParams]);

缺点:更复杂,且第一次请求仍然会被发送(只是被取消)。

总结

  • 使用 useRef 跟踪处理状态
  • 不要将 ref 添加到依赖数组
  • 保留 StrictMode 以获得开发时检查
  • 添加日志 以便调试

这个修复简单、有效,且不影响生产环境的行为。


修复日期: 2024-01-22
文件: web/apps/lq_label/src/components/oauth-callback/oauth-callback.tsx
状态: ✅ 已修复