From 594116f14158f659de7dddf6eb812b595e7053b6 Mon Sep 17 00:00:00 2001 From: Lawrence Liu Date: Sun, 9 Nov 2025 12:18:47 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8Dtoken=E8=BF=87?= =?UTF-8?q?=E6=9C=9F=E6=9C=AA=E9=87=8D=E6=96=B0=E7=99=BB=E5=BD=95=E7=9A=84?= =?UTF-8?q?=E9=97=AE=E9=A2=98=20(#803)=20*=20fix:=20=E4=BF=AE=E5=A4=8Dtoke?= =?UTF-8?q?n=E8=BF=87=E6=9C=9F=E6=9C=AA=E9=87=8D=E6=96=B0=E7=99=BB?= =?UTF-8?q?=E5=BD=95=E7=9A=84=E9=97=AE=E9=A2=98=20=E5=AE=9E=E7=8E=B0?= =?UTF-8?q?=E7=BB=9F=E4=B8=80=E7=9A=84401=E9=94=99=E8=AF=AF=E5=A4=84?= =?UTF-8?q?=E7=90=86=E6=9C=BA=E5=88=B6:=20-=20=E5=88=9B=E5=BB=BAhttpClient?= =?UTF-8?q?=E5=B0=81=E8=A3=85fetch=20API,=E6=B7=BB=E5=8A=A0=E5=93=8D?= =?UTF-8?q?=E5=BA=94=E6=8B=A6=E6=88=AA=E5=99=A8=20-=20401=E6=97=B6?= =?UTF-8?q?=E8=87=AA=E5=8A=A8=E6=B8=85=E7=90=86localStorage=E5=92=8CReact?= =?UTF-8?q?=E7=8A=B6=E6=80=81=20-=20=E6=98=BE=E7=A4=BA"=E8=AF=B7=E5=85=88?= =?UTF-8?q?=E7=99=BB=E5=BD=95"=E6=8F=90=E7=A4=BA=E5=B9=B6=E5=BB=B6?= =?UTF-8?q?=E8=BF=9F1.5=E7=A7=92=E5=90=8E=E8=B7=B3=E8=BD=AC=E7=99=BB?= =?UTF-8?q?=E5=BD=95=E9=A1=B5=20-=20=E4=BF=9D=E5=AD=98=E5=BD=93=E5=89=8DUR?= =?UTF-8?q?L=E5=88=B0sessionStorage=E7=94=A8=E4=BA=8E=E7=99=BB=E5=BD=95?= =?UTF-8?q?=E5=90=8E=E8=BF=94=E5=9B=9E=20-=20=E6=94=B9=E9=80=A0=E6=89=80?= =?UTF-8?q?=E6=9C=89API=E8=B0=83=E7=94=A8=E4=BD=BF=E7=94=A8httpClient?= =?UTF-8?q?=E7=BB=9F=E4=B8=80=E5=A4=84=E7=90=86=20Co-Authored-By:=20tinkle?= =?UTF-8?q?-community=20=20*=20fix:=20=E6=B7=BB?= =?UTF-8?q?=E5=8A=A0401=E5=A4=84=E7=90=86=E7=9A=84=E5=8D=95=E4=BE=8B?= =?UTF-8?q?=E4=BF=9D=E6=8A=A4=E9=98=B2=E6=AD=A2=E5=B9=B6=E5=8F=91=E7=AB=9E?= =?UTF-8?q?=E6=80=81=20=E9=97=AE=E9=A2=98:=20-=20=E5=A4=9A=E4=B8=AAAPI?= =?UTF-8?q?=E5=90=8C=E6=97=B6=E8=BF=94=E5=9B=9E401=E4=BC=9A=E5=AF=BC?= =?UTF-8?q?=E8=87=B4=E5=A4=9A=E4=B8=AA=E9=80=9A=E7=9F=A5=E5=8F=A0=E5=8A=A0?= =?UTF-8?q?=20-=20=E5=A4=9A=E4=B8=AAstyle=E5=85=83=E7=B4=A0=E8=A2=AB?= =?UTF-8?q?=E6=B7=BB=E5=8A=A0=E5=88=B0DOM=E9=80=A0=E6=88=90=E5=86=85?= =?UTF-8?q?=E5=AD=98=E6=B3=84=E6=BC=8F=20-=20=E5=8F=AF=E8=83=BD=E8=A7=A6?= =?UTF-8?q?=E5=8F=91=E5=A4=9A=E6=AC=A1=E7=99=BB=E5=BD=95=E9=A1=B5=E8=B7=B3?= =?UTF-8?q?=E8=BD=AC=20=E8=A7=A3=E5=86=B3=E6=96=B9=E6=A1=88:=20-=20?= =?UTF-8?q?=E6=B7=BB=E5=8A=A0=E9=9D=99=E6=80=81=E6=A0=87=E5=BF=97=E4=BD=8D?= =?UTF-8?q?=20isHandling401=20=E9=98=B2=E6=AD=A2=E9=87=8D=E5=A4=8D?= =?UTF-8?q?=E5=A4=84=E7=90=86=20-=20=E7=AC=AC=E4=B8=80=E4=B8=AA401?= =?UTF-8?q?=E8=A7=A6=E5=8F=91=E5=AE=8C=E6=95=B4=E5=A4=84=E7=90=86=E6=B5=81?= =?UTF-8?q?=E7=A8=8B=20-=20=E5=90=8E=E7=BB=AD401=E7=9B=B4=E6=8E=A5?= =?UTF-8?q?=E6=8A=9B=E5=87=BA=E9=94=99=E8=AF=AF,=E9=81=BF=E5=85=8D?= =?UTF-8?q?=E9=87=8D=E5=A4=8D=E6=93=8D=E4=BD=9C=20-=20=E7=A1=AE=E4=BF=9D?= =?UTF-8?q?=E5=8F=AA=E6=98=BE=E7=A4=BA=E4=B8=80=E6=AC=A1=E9=80=9A=E7=9F=A5?= =?UTF-8?q?=E5=92=8C=E4=B8=80=E6=AC=A1=E8=B7=B3=E8=BD=AC=20Co-Authored-By:?= =?UTF-8?q?=20tinkle-community=20=20*=20fix:=20?= =?UTF-8?q?=E4=BF=AE=E5=A4=8DisHandling401=E6=A0=87=E5=BF=97=E6=B0=B8?= =?UTF-8?q?=E4=B8=8D=E9=87=8D=E7=BD=AE=E7=9A=84=E9=97=AE=E9=A2=98=20?= =?UTF-8?q?=E9=97=AE=E9=A2=98:=20-=20isHandling401=E6=A0=87=E5=BF=97?= =?UTF-8?q?=E5=9C=A8401=E5=A4=84=E7=90=86=E5=90=8E=E6=B0=B8=E4=B8=8D?= =?UTF-8?q?=E9=87=8D=E7=BD=AE=20-=20=E5=AF=BC=E8=87=B4=E7=94=A8=E6=88=B7?= =?UTF-8?q?=E9=87=8D=E6=96=B0=E7=99=BB=E5=BD=95=E5=90=8E,=E5=90=8E?= =?UTF-8?q?=E7=BB=AD401=E4=BC=9A=E8=A2=AB=E9=9D=99=E9=BB=98=E5=BF=BD?= =?UTF-8?q?=E7=95=A5=20-=20=E9=A1=B5=E9=9D=A2=E5=88=B7=E6=96=B0=E6=88=96?= =?UTF-8?q?=E5=8F=96=E6=B6=88=E9=87=8D=E5=AE=9A=E5=90=91=E5=90=8E=E6=A0=87?= =?UTF-8?q?=E5=BF=97=E4=BB=8D=E4=B8=BAtrue=20=E8=A7=A3=E5=86=B3=E6=96=B9?= =?UTF-8?q?=E6=A1=88:=20-=20=E5=9C=A8HttpClient=E4=B8=AD=E6=B7=BB=E5=8A=A0?= =?UTF-8?q?reset401Flag()=E5=85=AC=E5=BC=80=E6=96=B9=E6=B3=95=20-=20?= =?UTF-8?q?=E7=99=BB=E5=BD=95=E6=88=90=E5=8A=9F=E5=90=8E=E8=B0=83=E7=94=A8?= =?UTF-8?q?reset401Flag()=E9=87=8D=E7=BD=AE=E6=A0=87=E5=BF=97=20-=20?= =?UTF-8?q?=E9=A1=B5=E9=9D=A2=E5=8A=A0=E8=BD=BD=E6=97=B6=E8=B0=83=E7=94=A8?= =?UTF-8?q?reset401Flag()=E7=A1=AE=E4=BF=9D=E6=96=B0=E4=BC=9A=E8=AF=9D?= =?UTF-8?q?=E6=AD=A3=E5=B8=B8=20=E5=BD=B1=E5=93=8D=E8=8C=83=E5=9B=B4:=20-?= =?UTF-8?q?=20web/src/lib/httpClient.ts:=20=E6=B7=BB=E5=8A=A0reset?= =?UTF-8?q?=E6=96=B9=E6=B3=95=E5=92=8C=E5=AF=BC=E5=87=BA=E5=87=BD=E6=95=B0?= =?UTF-8?q?=20-=20web/src/contexts/AuthContext.tsx:=20=E5=9C=A8=E7=99=BB?= =?UTF-8?q?=E5=BD=95=E5=92=8C=E9=A1=B5=E9=9D=A2=E5=8A=A0=E8=BD=BD=E6=97=B6?= =?UTF-8?q?=E9=87=8D=E7=BD=AE=20Co-Authored-By:=20tinkle-community=20=20*=20fix(auth):=20consume=20returnUrl=20afte?= =?UTF-8?q?r=20successful=20login=20(BLOCKING-1)=20=E4=BF=AE=E5=A4=8D?= =?UTF-8?q?=E7=99=BB=E5=BD=95=E5=90=8E=E6=9C=AA=E8=B7=B3=E8=BD=AC=E5=9B=9E?= =?UTF-8?q?=E5=8E=9F=E9=A1=B5=E9=9D=A2=E7=9A=84=E9=97=AE=E9=A2=98=E3=80=82?= =?UTF-8?q?=20=E9=97=AE=E9=A2=98:=20-=20httpClient=E5=9C=A8401=E6=97=B6?= =?UTF-8?q?=E4=BF=9D=E5=AD=98returnUrl=E5=88=B0sessionStorage=20-=20?= =?UTF-8?q?=E4=BD=86=E7=99=BB=E5=BD=95=E6=88=90=E5=8A=9F=E5=90=8E=E6=B2=A1?= =?UTF-8?q?=E6=9C=89=E8=AF=BB=E5=8F=96=E5=92=8C=E4=BD=BF=E7=94=A8returnUrl?= =?UTF-8?q?=20-=20=E5=AF=BC=E8=87=B4=E7=94=A8=E6=88=B7=E7=99=BB=E5=BD=95?= =?UTF-8?q?=E5=90=8E=E5=81=9C=E7=95=99=E5=9C=A8=E7=99=BB=E5=BD=95=E9=A1=B5?= =?UTF-8?q?=EF=BC=8C=E6=97=A0=E6=B3=95=E5=9B=9E=E5=88=B0=E5=8E=9F=E9=A1=B5?= =?UTF-8?q?=E9=9D=A2=20=E4=BF=AE=E5=A4=8D:=20-=20=E5=9C=A8loginAdmin?= =?UTF-8?q?=E3=80=81verifyOTP=E3=80=81completeRegistration=E4=B8=89?= =?UTF-8?q?=E4=B8=AA=E7=99=BB=E5=BD=95=E6=96=B9=E6=B3=95=E4=B8=AD=20-=20?= =?UTF-8?q?=E6=B7=BB=E5=8A=A0returnUrl=E6=A3=80=E6=9F=A5=E5=92=8C=E8=B7=B3?= =?UTF-8?q?=E8=BD=AC=E9=80=BB=E8=BE=91=20-=20=E7=99=BB=E5=BD=95=E6=88=90?= =?UTF-8?q?=E5=8A=9F=E5=90=8E=E4=BC=98=E5=85=88=E8=B7=B3=E8=BD=AC=E5=88=B0?= =?UTF-8?q?returnUrl=EF=BC=8C=E5=A6=82=E6=9E=9C=E6=B2=A1=E6=9C=89=E5=88=99?= =?UTF-8?q?=E4=BD=BF=E7=94=A8=E9=BB=98=E8=AE=A4=E9=A1=B5=E9=9D=A2=20?= =?UTF-8?q?=E5=BD=B1=E5=93=8D:=20-=20=E7=94=A8=E6=88=B7token=E8=BF=87?= =?UTF-8?q?=E6=9C=9F=E5=90=8E=E9=87=8D=E6=96=B0=E7=99=BB=E5=BD=95=EF=BC=8C?= =?UTF-8?q?=E4=BC=9A=E8=87=AA=E5=8A=A8=E8=BF=94=E5=9B=9E=E4=B9=8B=E5=89=8D?= =?UTF-8?q?=E8=AE=BF=E9=97=AE=E7=9A=84=E9=A1=B5=E9=9D=A2=20-=20=E6=8F=90?= =?UTF-8?q?=E5=8D=87=E7=94=A8=E6=88=B7=E4=BD=93=E9=AA=8C=EF=BC=8C=E9=81=BF?= =?UTF-8?q?=E5=85=8D=E6=89=8B=E5=8A=A8=E5=AF=BC=E8=88=AA=20=E6=B5=8B?= =?UTF-8?q?=E8=AF=95=E5=9C=BA=E6=99=AF:=201.=20=E7=94=A8=E6=88=B7=E8=AE=BF?= =?UTF-8?q?=E9=97=AE/traders=20=E2=86=92=20token=E8=BF=87=E6=9C=9F=20?= =?UTF-8?q?=E2=86=92=20=E7=99=BB=E5=BD=95=20=E2=86=92=20=E8=87=AA=E5=8A=A8?= =?UTF-8?q?=E5=9B=9E=E5=88=B0/traders=20=E2=9C=85=202.=20=E7=94=A8?= =?UTF-8?q?=E6=88=B7=E7=9B=B4=E6=8E=A5=E8=AE=BF=E9=97=AE/login=20=E2=86=92?= =?UTF-8?q?=20=E7=99=BB=E5=BD=95=20=E2=86=92=20=E8=B7=B3=E8=BD=AC=E5=88=B0?= =?UTF-8?q?=E9=BB=98=E8=AE=A4=E9=A1=B5=E9=9D=A2(/dashboard=E6=88=96/trader?= =?UTF-8?q?s)=20=E2=9C=85=20Related:=20BLOCKING-1=20in=20PR=20#803=20code?= =?UTF-8?q?=20review=20---------=20Co-authored-by:=20sue=20<177699783@qq.c?= =?UTF-8?q?om>=20Co-authored-by:=20tinkle-community=20?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- web/src/contexts/AuthContext.tsx | 73 +++++++++-- web/src/lib/api.ts | 179 ++++++++++++-------------- web/src/lib/httpClient.ts | 212 +++++++++++++++++++++++++++++++ 3 files changed, 355 insertions(+), 109 deletions(-) create mode 100644 web/src/lib/httpClient.ts diff --git a/web/src/contexts/AuthContext.tsx b/web/src/contexts/AuthContext.tsx index 1930c587..9d1cfd1c 100644 --- a/web/src/contexts/AuthContext.tsx +++ b/web/src/contexts/AuthContext.tsx @@ -1,5 +1,6 @@ import React, { createContext, useContext, useState, useEffect } from 'react' import { getSystemConfig } from '../lib/config' +import { reset401Flag } from '../lib/httpClient' interface User { id: string @@ -58,6 +59,9 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { const [isLoading, setIsLoading] = useState(true) useEffect(() => { + // Reset 401 flag on page load to allow fresh 401 handling + reset401Flag() + // 先检查是否为管理员模式(使用带缓存的系统配置获取) getSystemConfig() .then(() => { @@ -85,6 +89,23 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { }) }, []) + // Listen for unauthorized events from httpClient (401 responses) + useEffect(() => { + const handleUnauthorized = () => { + console.log('Unauthorized event received - clearing auth state') + // Clear auth state when 401 is detected + setUser(null) + setToken(null) + // Note: localStorage cleanup is already done in httpClient + } + + window.addEventListener('unauthorized', handleUnauthorized) + + return () => { + window.removeEventListener('unauthorized', handleUnauthorized) + } + }, []) + const login = async (email: string, password: string) => { try { const response = await fetch('/api/login', { @@ -125,6 +146,9 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { }) const data = await response.json() if (response.ok) { + // Reset 401 flag on successful login + reset401Flag() + const userInfo = { id: data.user_id || 'admin', email: data.email || 'admin@localhost', @@ -133,9 +157,18 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { setUser(userInfo) localStorage.setItem('auth_token', data.token) localStorage.setItem('auth_user', JSON.stringify(userInfo)) - // 跳转到仪表盘 - window.history.pushState({}, '', '/dashboard') - window.dispatchEvent(new PopStateEvent('popstate')) + + // Check and redirect to returnUrl if exists + const returnUrl = sessionStorage.getItem('returnUrl') + if (returnUrl) { + sessionStorage.removeItem('returnUrl') + window.history.pushState({}, '', returnUrl) + window.dispatchEvent(new PopStateEvent('popstate')) + } else { + // 跳转到仪表盘 + window.history.pushState({}, '', '/dashboard') + window.dispatchEvent(new PopStateEvent('popstate')) + } return { success: true } } else { return { success: false, message: data.error || '登录失败' } @@ -199,6 +232,9 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { const data = await response.json() if (response.ok) { + // Reset 401 flag on successful login + reset401Flag() + // 登录成功,保存token和用户信息 const userInfo = { id: data.user_id, email: data.email } setToken(data.token) @@ -206,9 +242,17 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { localStorage.setItem('auth_token', data.token) localStorage.setItem('auth_user', JSON.stringify(userInfo)) - // 跳转到配置页面 - window.history.pushState({}, '', '/traders') - window.dispatchEvent(new PopStateEvent('popstate')) + // Check and redirect to returnUrl if exists + const returnUrl = sessionStorage.getItem('returnUrl') + if (returnUrl) { + sessionStorage.removeItem('returnUrl') + window.history.pushState({}, '', returnUrl) + window.dispatchEvent(new PopStateEvent('popstate')) + } else { + // 跳转到配置页面 + window.history.pushState({}, '', '/traders') + window.dispatchEvent(new PopStateEvent('popstate')) + } return { success: true, message: data.message } } else { @@ -232,6 +276,9 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { const data = await response.json() if (response.ok) { + // Reset 401 flag on successful login + reset401Flag() + // 注册完成,自动登录 const userInfo = { id: data.user_id, email: data.email } setToken(data.token) @@ -239,9 +286,17 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { localStorage.setItem('auth_token', data.token) localStorage.setItem('auth_user', JSON.stringify(userInfo)) - // 跳转到配置页面 - window.history.pushState({}, '', '/traders') - window.dispatchEvent(new PopStateEvent('popstate')) + // Check and redirect to returnUrl if exists + const returnUrl = sessionStorage.getItem('returnUrl') + if (returnUrl) { + sessionStorage.removeItem('returnUrl') + window.history.pushState({}, '', returnUrl) + window.dispatchEvent(new PopStateEvent('popstate')) + } else { + // 跳转到配置页面 + window.history.pushState({}, '', '/traders') + window.dispatchEvent(new PopStateEvent('popstate')) + } return { success: true, message: data.message } } else { diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index 3d3eda69..a9390ea8 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -13,6 +13,7 @@ import type { CompetitionData, } from '../types' import { CryptoService } from './crypto' +import { httpClient } from './httpClient' const API_BASE = '/api' @@ -33,51 +34,51 @@ function getAuthHeaders(): Record { export const api = { // AI交易员管理接口 async getTraders(): Promise { - const res = await fetch(`${API_BASE}/my-traders`, { - headers: getAuthHeaders(), - }) + const res = await httpClient.get(`${API_BASE}/my-traders`, getAuthHeaders()) if (!res.ok) throw new Error('获取trader列表失败') return res.json() }, // 获取公开的交易员列表(无需认证) async getPublicTraders(): Promise { - const res = await fetch(`${API_BASE}/traders`) + const res = await httpClient.get(`${API_BASE}/traders`) if (!res.ok) throw new Error('获取公开trader列表失败') return res.json() }, async createTrader(request: CreateTraderRequest): Promise { - const res = await fetch(`${API_BASE}/traders`, { - method: 'POST', - headers: getAuthHeaders(), - body: JSON.stringify(request), - }) + const res = await httpClient.post( + `${API_BASE}/traders`, + request, + getAuthHeaders() + ) if (!res.ok) throw new Error('创建交易员失败') return res.json() }, async deleteTrader(traderId: string): Promise { - const res = await fetch(`${API_BASE}/traders/${traderId}`, { - method: 'DELETE', - headers: getAuthHeaders(), - }) + const res = await httpClient.delete( + `${API_BASE}/traders/${traderId}`, + getAuthHeaders() + ) if (!res.ok) throw new Error('删除交易员失败') }, async startTrader(traderId: string): Promise { - const res = await fetch(`${API_BASE}/traders/${traderId}/start`, { - method: 'POST', - headers: getAuthHeaders(), - }) + const res = await httpClient.post( + `${API_BASE}/traders/${traderId}/start`, + undefined, + getAuthHeaders() + ) if (!res.ok) throw new Error('启动交易员失败') }, async stopTrader(traderId: string): Promise { - const res = await fetch(`${API_BASE}/traders/${traderId}/stop`, { - method: 'POST', - headers: getAuthHeaders(), - }) + const res = await httpClient.post( + `${API_BASE}/traders/${traderId}/stop`, + undefined, + getAuthHeaders() + ) if (!res.ok) throw new Error('停止交易员失败') }, @@ -85,18 +86,19 @@ export const api = { traderId: string, customPrompt: string ): Promise { - const res = await fetch(`${API_BASE}/traders/${traderId}/prompt`, { - method: 'PUT', - headers: getAuthHeaders(), - body: JSON.stringify({ custom_prompt: customPrompt }), - }) + const res = await httpClient.put( + `${API_BASE}/traders/${traderId}/prompt`, + { custom_prompt: customPrompt }, + getAuthHeaders() + ) if (!res.ok) throw new Error('更新自定义策略失败') }, async getTraderConfig(traderId: string): Promise { - const res = await fetch(`${API_BASE}/traders/${traderId}/config`, { - headers: getAuthHeaders(), - }) + const res = await httpClient.get( + `${API_BASE}/traders/${traderId}/config`, + getAuthHeaders() + ) if (!res.ok) throw new Error('获取交易员配置失败') return res.json() }, @@ -105,27 +107,25 @@ export const api = { traderId: string, request: CreateTraderRequest ): Promise { - const res = await fetch(`${API_BASE}/traders/${traderId}`, { - method: 'PUT', - headers: getAuthHeaders(), - body: JSON.stringify(request), - }) + const res = await httpClient.put( + `${API_BASE}/traders/${traderId}`, + request, + getAuthHeaders() + ) if (!res.ok) throw new Error('更新交易员失败') return res.json() }, // AI模型配置接口 async getModelConfigs(): Promise { - const res = await fetch(`${API_BASE}/models`, { - headers: getAuthHeaders(), - }) + const res = await httpClient.get(`${API_BASE}/models`, getAuthHeaders()) if (!res.ok) throw new Error('获取模型配置失败') return res.json() }, // 获取系统支持的AI模型列表(无需认证) async getSupportedModels(): Promise { - const res = await fetch(`${API_BASE}/supported-models`) + const res = await httpClient.get(`${API_BASE}/supported-models`) if (!res.ok) throw new Error('获取支持的模型失败') return res.json() }, @@ -149,27 +149,25 @@ export const api = { ) // 发送加密数据 - const res = await fetch(`${API_BASE}/models`, { - method: 'PUT', - headers: getAuthHeaders(), - body: JSON.stringify(encryptedPayload), - }) + const res = await httpClient.put( + `${API_BASE}/models`, + encryptedPayload, + getAuthHeaders() + ) if (!res.ok) throw new Error('更新模型配置失败') }, // 交易所配置接口 async getExchangeConfigs(): Promise { - const res = await fetch(`${API_BASE}/exchanges`, { - headers: getAuthHeaders(), - }) + const res = await httpClient.get(`${API_BASE}/exchanges`, getAuthHeaders()) if (!res.ok) throw new Error('获取交易所配置失败') return res.json() }, // 获取系统支持的交易所列表(无需认证) async getSupportedExchanges(): Promise { - const res = await fetch(`${API_BASE}/supported-exchanges`) + const res = await httpClient.get(`${API_BASE}/supported-exchanges`) if (!res.ok) throw new Error('获取支持的交易所失败') return res.json() }, @@ -177,11 +175,11 @@ export const api = { async updateExchangeConfigs( request: UpdateExchangeConfigRequest ): Promise { - const res = await fetch(`${API_BASE}/exchanges`, { - method: 'PUT', - headers: getAuthHeaders(), - body: JSON.stringify(request), - }) + const res = await httpClient.put( + `${API_BASE}/exchanges`, + request, + getAuthHeaders() + ) if (!res.ok) throw new Error('更新交易所配置失败') }, @@ -207,11 +205,11 @@ export const api = { ) // 发送加密数据 - const res = await fetch(`${API_BASE}/exchanges`, { - method: 'PUT', - headers: getAuthHeaders(), - body: JSON.stringify(encryptedPayload), - }) + const res = await httpClient.put( + `${API_BASE}/exchanges`, + encryptedPayload, + getAuthHeaders() + ) if (!res.ok) throw new Error('更新交易所配置失败') }, @@ -220,9 +218,7 @@ export const api = { const url = traderId ? `${API_BASE}/status?trader_id=${traderId}` : `${API_BASE}/status` - const res = await fetch(url, { - headers: getAuthHeaders(), - }) + const res = await httpClient.get(url, getAuthHeaders()) if (!res.ok) throw new Error('获取系统状态失败') return res.json() }, @@ -232,7 +228,7 @@ export const api = { const url = traderId ? `${API_BASE}/account?trader_id=${traderId}` : `${API_BASE}/account` - const res = await fetch(url, { + const res = await httpClient.request(url, { cache: 'no-store', headers: { ...getAuthHeaders(), @@ -250,9 +246,7 @@ export const api = { const url = traderId ? `${API_BASE}/positions?trader_id=${traderId}` : `${API_BASE}/positions` - const res = await fetch(url, { - headers: getAuthHeaders(), - }) + const res = await httpClient.get(url, getAuthHeaders()) if (!res.ok) throw new Error('获取持仓列表失败') return res.json() }, @@ -262,9 +256,7 @@ export const api = { const url = traderId ? `${API_BASE}/decisions?trader_id=${traderId}` : `${API_BASE}/decisions` - const res = await fetch(url, { - headers: getAuthHeaders(), - }) + const res = await httpClient.get(url, getAuthHeaders()) if (!res.ok) throw new Error('获取决策日志失败') return res.json() }, @@ -274,9 +266,7 @@ export const api = { const url = traderId ? `${API_BASE}/decisions/latest?trader_id=${traderId}` : `${API_BASE}/decisions/latest` - const res = await fetch(url, { - headers: getAuthHeaders(), - }) + const res = await httpClient.get(url, getAuthHeaders()) if (!res.ok) throw new Error('获取最新决策失败') return res.json() }, @@ -286,9 +276,7 @@ export const api = { const url = traderId ? `${API_BASE}/statistics?trader_id=${traderId}` : `${API_BASE}/statistics` - const res = await fetch(url, { - headers: getAuthHeaders(), - }) + const res = await httpClient.get(url, getAuthHeaders()) if (!res.ok) throw new Error('获取统计信息失败') return res.json() }, @@ -298,21 +286,15 @@ export const api = { const url = traderId ? `${API_BASE}/equity-history?trader_id=${traderId}` : `${API_BASE}/equity-history` - const res = await fetch(url, { - headers: getAuthHeaders(), - }) + const res = await httpClient.get(url, getAuthHeaders()) if (!res.ok) throw new Error('获取历史数据失败') return res.json() }, // 批量获取多个交易员的历史数据(无需认证) async getEquityHistoryBatch(traderIds: string[]): Promise { - const res = await fetch(`${API_BASE}/equity-history-batch`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ trader_ids: traderIds }), + const res = await httpClient.post(`${API_BASE}/equity-history-batch`, { + trader_ids: traderIds, }) if (!res.ok) throw new Error('获取批量历史数据失败') return res.json() @@ -320,14 +302,14 @@ export const api = { // 获取前5名交易员数据(无需认证) async getTopTraders(): Promise { - const res = await fetch(`${API_BASE}/top-traders`) + const res = await httpClient.get(`${API_BASE}/top-traders`) if (!res.ok) throw new Error('获取前5名交易员失败') return res.json() }, // 获取公开交易员配置(无需认证) async getPublicTraderConfig(traderId: string): Promise { - const res = await fetch(`${API_BASE}/trader/${traderId}/config`) + const res = await httpClient.get(`${API_BASE}/trader/${traderId}/config`) if (!res.ok) throw new Error('获取公开交易员配置失败') return res.json() }, @@ -337,16 +319,14 @@ export const api = { const url = traderId ? `${API_BASE}/performance?trader_id=${traderId}` : `${API_BASE}/performance` - const res = await fetch(url, { - headers: getAuthHeaders(), - }) + const res = await httpClient.get(url, getAuthHeaders()) if (!res.ok) throw new Error('获取AI学习数据失败') return res.json() }, // 获取竞赛数据(无需认证) async getCompetition(): Promise { - const res = await fetch(`${API_BASE}/competition`) + const res = await httpClient.get(`${API_BASE}/competition`) if (!res.ok) throw new Error('获取竞赛数据失败') return res.json() }, @@ -356,9 +336,10 @@ export const api = { coin_pool_url: string oi_top_url: string }> { - const res = await fetch(`${API_BASE}/user/signal-sources`, { - headers: getAuthHeaders(), - }) + const res = await httpClient.get( + `${API_BASE}/user/signal-sources`, + getAuthHeaders() + ) if (!res.ok) throw new Error('获取用户信号源配置失败') return res.json() }, @@ -367,14 +348,14 @@ export const api = { coinPoolUrl: string, oiTopUrl: string ): Promise { - const res = await fetch(`${API_BASE}/user/signal-sources`, { - method: 'POST', - headers: getAuthHeaders(), - body: JSON.stringify({ + const res = await httpClient.post( + `${API_BASE}/user/signal-sources`, + { coin_pool_url: coinPoolUrl, oi_top_url: oiTopUrl, - }), - }) + }, + getAuthHeaders() + ) if (!res.ok) throw new Error('保存用户信号源配置失败') }, @@ -383,9 +364,7 @@ export const api = { public_ip: string message: string }> { - const res = await fetch(`${API_BASE}/server-ip`, { - headers: getAuthHeaders(), - }) + const res = await httpClient.get(`${API_BASE}/server-ip`, getAuthHeaders()) if (!res.ok) throw new Error('获取服务器IP失败') return res.json() }, diff --git a/web/src/lib/httpClient.ts b/web/src/lib/httpClient.ts new file mode 100644 index 00000000..9097c416 --- /dev/null +++ b/web/src/lib/httpClient.ts @@ -0,0 +1,212 @@ +/** + * HTTP Client with unified error handling and 401 interception + * + * Features: + * - Unified fetch wrapper + * - Automatic 401 token expiration handling + * - Auth state cleanup on unauthorized + * - Automatic redirect to login page + */ + +export class HttpClient { + // Singleton flag to prevent duplicate 401 handling + private static isHandling401 = false + + /** + * Reset 401 handling flag (call after successful login) + */ + public reset401Flag(): void { + HttpClient.isHandling401 = false + } + + /** + * Show login required notification to user + */ + private showLoginRequiredNotification(): void { + // Create notification element + const notification = document.createElement('div') + notification.style.cssText = ` + position: fixed; + top: 20px; + left: 50%; + transform: translateX(-50%); + background: linear-gradient(135deg, #F0B90B 0%, #FCD535 100%); + color: #0B0E11; + padding: 16px 24px; + border-radius: 8px; + font-size: 16px; + font-weight: bold; + z-index: 10000; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3); + animation: slideDown 0.3s ease-out; + ` + notification.textContent = '⚠️ 登录已过期,请先登录' + + // Add slide down animation + const style = document.createElement('style') + style.textContent = ` + @keyframes slideDown { + from { + opacity: 0; + transform: translateX(-50%) translateY(-20px); + } + to { + opacity: 1; + transform: translateX(-50%) translateY(0); + } + } + ` + document.head.appendChild(style) + + // Add to page + document.body.appendChild(notification) + + // Auto remove after animation + setTimeout(() => { + notification.style.animation = 'slideDown 0.3s ease-out reverse' + setTimeout(() => { + document.body.removeChild(notification) + document.head.removeChild(style) + }, 300) + }, 1800) + } + + /** + * Response interceptor - handles common HTTP errors + * + * @param response - Fetch Response object + * @returns Response if successful + * @throws Error with user-friendly message + */ + private async handleResponse(response: Response): Promise { + // Handle 401 Unauthorized - Token expired or invalid + if (response.status === 401) { + // Prevent duplicate 401 handling when multiple API calls fail simultaneously + if (HttpClient.isHandling401) { + throw new Error('登录已过期,请重新登录') + } + + // Set flag to prevent race conditions + HttpClient.isHandling401 = true + + // Clean up local storage + localStorage.removeItem('auth_token') + localStorage.removeItem('auth_user') + + // Notify global listeners (AuthContext will react to this) + window.dispatchEvent(new Event('unauthorized')) + + // Show user-friendly notification (only once) + this.showLoginRequiredNotification() + + // Delay redirect to let user see the notification + setTimeout(() => { + // Only redirect if not already on login page + if (!window.location.pathname.includes('/login')) { + // Save current location for post-login redirect + const returnUrl = window.location.pathname + window.location.search + if (returnUrl !== '/login' && returnUrl !== '/') { + sessionStorage.setItem('returnUrl', returnUrl) + } + + window.location.href = '/login' + } + // Note: No need to reset flag since we're redirecting + }, 1500) // 1.5秒延迟,让用户看到提示 + + throw new Error('登录已过期,请重新登录') + } + + // Handle other common errors + if (response.status === 403) { + throw new Error('没有权限访问此资源') + } + + if (response.status === 404) { + throw new Error('请求的资源不存在') + } + + if (response.status >= 500) { + throw new Error('服务器错误,请稍后重试') + } + + return response + } + + /** + * GET request + */ + async get(url: string, headers?: Record): Promise { + const response = await fetch(url, { + method: 'GET', + headers, + }) + return this.handleResponse(response) + } + + /** + * POST request + */ + async post( + url: string, + body?: any, + headers?: Record + ): Promise { + const response = await fetch(url, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + ...headers, + }, + body: body ? JSON.stringify(body) : undefined, + }) + return this.handleResponse(response) + } + + /** + * PUT request + */ + async put( + url: string, + body?: any, + headers?: Record + ): Promise { + const response = await fetch(url, { + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + ...headers, + }, + body: body ? JSON.stringify(body) : undefined, + }) + return this.handleResponse(response) + } + + /** + * DELETE request + */ + async delete( + url: string, + headers?: Record + ): Promise { + const response = await fetch(url, { + method: 'DELETE', + headers, + }) + return this.handleResponse(response) + } + + /** + * Generic request method for custom configurations + */ + async request(url: string, options: RequestInit = {}): Promise { + const response = await fetch(url, options) + return this.handleResponse(response) + } +} + +// Export singleton instance +export const httpClient = new HttpClient() + +// Export helper function to reset 401 flag +export const reset401Flag = () => httpClient.reset401Flag()