fix: 修复token过期未重新登录的问题 (#803)

* fix: 修复token过期未重新登录的问题

实现统一的401错误处理机制:
- 创建httpClient封装fetch API,添加响应拦截器
- 401时自动清理localStorage和React状态
- 显示"请先登录"提示并延迟1.5秒后跳转登录页
- 保存当前URL到sessionStorage用于登录后返回
- 改造所有API调用使用httpClient统一处理

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: tinkle-community <tinklefund@gmail.com>

* fix: 添加401处理的单例保护防止并发竞态

问题:
- 多个API同时返回401会导致多个通知叠加
- 多个style元素被添加到DOM造成内存泄漏
- 可能触发多次登录页跳转

解决方案:
- 添加静态标志位 isHandling401 防止重复处理
- 第一个401触发完整处理流程
- 后续401直接抛出错误,避免重复操作
- 确保只显示一次通知和一次跳转

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: tinkle-community <tinklefund@gmail.com>

* fix: 修复isHandling401标志永不重置的问题

问题:
- isHandling401标志在401处理后永不重置
- 导致用户重新登录后,后续401会被静默忽略
- 页面刷新或取消重定向后标志仍为true

解决方案:
- 在HttpClient中添加reset401Flag()公开方法
- 登录成功后调用reset401Flag()重置标志
- 页面加载时调用reset401Flag()确保新会话正常

影响范围:
- web/src/lib/httpClient.ts: 添加reset方法和导出函数
- web/src/contexts/AuthContext.tsx: 在登录和页面加载时重置

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: tinkle-community <tinklefund@gmail.com>

* fix(auth): consume returnUrl after successful login (BLOCKING-1)

修复登录后未跳转回原页面的问题。

问题:
- httpClient在401时保存returnUrl到sessionStorage
- 但登录成功后没有读取和使用returnUrl
- 导致用户登录后停留在登录页,无法回到原页面

修复:
- 在loginAdmin、verifyOTP、completeRegistration三个登录方法中
- 添加returnUrl检查和跳转逻辑
- 登录成功后优先跳转到returnUrl,如果没有则使用默认页面

影响:
- 用户token过期后重新登录,会自动返回之前访问的页面
- 提升用户体验,避免手动导航

测试场景:
1. 用户访问/traders → token过期 → 登录 → 自动回到/traders 
2. 用户直接访问/login → 登录 → 跳转到默认页面(/dashboard或/traders) 

Related: BLOCKING-1 in PR #803 code review

---------

Co-authored-by: sue <177699783@qq.com>
Co-authored-by: tinkle-community <tinklefund@gmail.com>
This commit is contained in:
Lawrence Liu
2025-11-09 12:18:47 +08:00
committed by GitHub
parent 10ec3787d6
commit 1ea0d4c331
3 changed files with 355 additions and 109 deletions

View File

@@ -1,5 +1,6 @@
import React, { createContext, useContext, useState, useEffect } from 'react' import React, { createContext, useContext, useState, useEffect } from 'react'
import { getSystemConfig } from '../lib/config' import { getSystemConfig } from '../lib/config'
import { reset401Flag } from '../lib/httpClient'
interface User { interface User {
id: string id: string
@@ -58,6 +59,9 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
const [isLoading, setIsLoading] = useState(true) const [isLoading, setIsLoading] = useState(true)
useEffect(() => { useEffect(() => {
// Reset 401 flag on page load to allow fresh 401 handling
reset401Flag()
// 先检查是否为管理员模式(使用带缓存的系统配置获取) // 先检查是否为管理员模式(使用带缓存的系统配置获取)
getSystemConfig() getSystemConfig()
.then(() => { .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) => { const login = async (email: string, password: string) => {
try { try {
const response = await fetch('/api/login', { const response = await fetch('/api/login', {
@@ -125,6 +146,9 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
}) })
const data = await response.json() const data = await response.json()
if (response.ok) { if (response.ok) {
// Reset 401 flag on successful login
reset401Flag()
const userInfo = { const userInfo = {
id: data.user_id || 'admin', id: data.user_id || 'admin',
email: data.email || 'admin@localhost', email: data.email || 'admin@localhost',
@@ -133,9 +157,18 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
setUser(userInfo) setUser(userInfo)
localStorage.setItem('auth_token', data.token) localStorage.setItem('auth_token', data.token)
localStorage.setItem('auth_user', JSON.stringify(userInfo)) localStorage.setItem('auth_user', JSON.stringify(userInfo))
// 跳转到仪表盘
window.history.pushState({}, '', '/dashboard') // Check and redirect to returnUrl if exists
window.dispatchEvent(new PopStateEvent('popstate')) 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 } return { success: true }
} else { } else {
return { success: false, message: data.error || '登录失败' } return { success: false, message: data.error || '登录失败' }
@@ -199,6 +232,9 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
const data = await response.json() const data = await response.json()
if (response.ok) { if (response.ok) {
// Reset 401 flag on successful login
reset401Flag()
// 登录成功保存token和用户信息 // 登录成功保存token和用户信息
const userInfo = { id: data.user_id, email: data.email } const userInfo = { id: data.user_id, email: data.email }
setToken(data.token) setToken(data.token)
@@ -206,9 +242,17 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
localStorage.setItem('auth_token', data.token) localStorage.setItem('auth_token', data.token)
localStorage.setItem('auth_user', JSON.stringify(userInfo)) localStorage.setItem('auth_user', JSON.stringify(userInfo))
// 跳转到配置页面 // Check and redirect to returnUrl if exists
window.history.pushState({}, '', '/traders') const returnUrl = sessionStorage.getItem('returnUrl')
window.dispatchEvent(new PopStateEvent('popstate')) 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 } return { success: true, message: data.message }
} else { } else {
@@ -232,6 +276,9 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
const data = await response.json() const data = await response.json()
if (response.ok) { if (response.ok) {
// Reset 401 flag on successful login
reset401Flag()
// 注册完成,自动登录 // 注册完成,自动登录
const userInfo = { id: data.user_id, email: data.email } const userInfo = { id: data.user_id, email: data.email }
setToken(data.token) setToken(data.token)
@@ -239,9 +286,17 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
localStorage.setItem('auth_token', data.token) localStorage.setItem('auth_token', data.token)
localStorage.setItem('auth_user', JSON.stringify(userInfo)) localStorage.setItem('auth_user', JSON.stringify(userInfo))
// 跳转到配置页面 // Check and redirect to returnUrl if exists
window.history.pushState({}, '', '/traders') const returnUrl = sessionStorage.getItem('returnUrl')
window.dispatchEvent(new PopStateEvent('popstate')) 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 } return { success: true, message: data.message }
} else { } else {

View File

@@ -13,6 +13,7 @@ import type {
CompetitionData, CompetitionData,
} from '../types' } from '../types'
import { CryptoService } from './crypto' import { CryptoService } from './crypto'
import { httpClient } from './httpClient'
const API_BASE = '/api' const API_BASE = '/api'
@@ -33,51 +34,51 @@ function getAuthHeaders(): Record<string, string> {
export const api = { export const api = {
// AI交易员管理接口 // AI交易员管理接口
async getTraders(): Promise<TraderInfo[]> { async getTraders(): Promise<TraderInfo[]> {
const res = await fetch(`${API_BASE}/my-traders`, { const res = await httpClient.get(`${API_BASE}/my-traders`, getAuthHeaders())
headers: getAuthHeaders(),
})
if (!res.ok) throw new Error('获取trader列表失败') if (!res.ok) throw new Error('获取trader列表失败')
return res.json() return res.json()
}, },
// 获取公开的交易员列表(无需认证) // 获取公开的交易员列表(无需认证)
async getPublicTraders(): Promise<any[]> { async getPublicTraders(): Promise<any[]> {
const res = await fetch(`${API_BASE}/traders`) const res = await httpClient.get(`${API_BASE}/traders`)
if (!res.ok) throw new Error('获取公开trader列表失败') if (!res.ok) throw new Error('获取公开trader列表失败')
return res.json() return res.json()
}, },
async createTrader(request: CreateTraderRequest): Promise<TraderInfo> { async createTrader(request: CreateTraderRequest): Promise<TraderInfo> {
const res = await fetch(`${API_BASE}/traders`, { const res = await httpClient.post(
method: 'POST', `${API_BASE}/traders`,
headers: getAuthHeaders(), request,
body: JSON.stringify(request), getAuthHeaders()
}) )
if (!res.ok) throw new Error('创建交易员失败') if (!res.ok) throw new Error('创建交易员失败')
return res.json() return res.json()
}, },
async deleteTrader(traderId: string): Promise<void> { async deleteTrader(traderId: string): Promise<void> {
const res = await fetch(`${API_BASE}/traders/${traderId}`, { const res = await httpClient.delete(
method: 'DELETE', `${API_BASE}/traders/${traderId}`,
headers: getAuthHeaders(), getAuthHeaders()
}) )
if (!res.ok) throw new Error('删除交易员失败') if (!res.ok) throw new Error('删除交易员失败')
}, },
async startTrader(traderId: string): Promise<void> { async startTrader(traderId: string): Promise<void> {
const res = await fetch(`${API_BASE}/traders/${traderId}/start`, { const res = await httpClient.post(
method: 'POST', `${API_BASE}/traders/${traderId}/start`,
headers: getAuthHeaders(), undefined,
}) getAuthHeaders()
)
if (!res.ok) throw new Error('启动交易员失败') if (!res.ok) throw new Error('启动交易员失败')
}, },
async stopTrader(traderId: string): Promise<void> { async stopTrader(traderId: string): Promise<void> {
const res = await fetch(`${API_BASE}/traders/${traderId}/stop`, { const res = await httpClient.post(
method: 'POST', `${API_BASE}/traders/${traderId}/stop`,
headers: getAuthHeaders(), undefined,
}) getAuthHeaders()
)
if (!res.ok) throw new Error('停止交易员失败') if (!res.ok) throw new Error('停止交易员失败')
}, },
@@ -85,18 +86,19 @@ export const api = {
traderId: string, traderId: string,
customPrompt: string customPrompt: string
): Promise<void> { ): Promise<void> {
const res = await fetch(`${API_BASE}/traders/${traderId}/prompt`, { const res = await httpClient.put(
method: 'PUT', `${API_BASE}/traders/${traderId}/prompt`,
headers: getAuthHeaders(), { custom_prompt: customPrompt },
body: JSON.stringify({ custom_prompt: customPrompt }), getAuthHeaders()
}) )
if (!res.ok) throw new Error('更新自定义策略失败') if (!res.ok) throw new Error('更新自定义策略失败')
}, },
async getTraderConfig(traderId: string): Promise<any> { async getTraderConfig(traderId: string): Promise<any> {
const res = await fetch(`${API_BASE}/traders/${traderId}/config`, { const res = await httpClient.get(
headers: getAuthHeaders(), `${API_BASE}/traders/${traderId}/config`,
}) getAuthHeaders()
)
if (!res.ok) throw new Error('获取交易员配置失败') if (!res.ok) throw new Error('获取交易员配置失败')
return res.json() return res.json()
}, },
@@ -105,27 +107,25 @@ export const api = {
traderId: string, traderId: string,
request: CreateTraderRequest request: CreateTraderRequest
): Promise<TraderInfo> { ): Promise<TraderInfo> {
const res = await fetch(`${API_BASE}/traders/${traderId}`, { const res = await httpClient.put(
method: 'PUT', `${API_BASE}/traders/${traderId}`,
headers: getAuthHeaders(), request,
body: JSON.stringify(request), getAuthHeaders()
}) )
if (!res.ok) throw new Error('更新交易员失败') if (!res.ok) throw new Error('更新交易员失败')
return res.json() return res.json()
}, },
// AI模型配置接口 // AI模型配置接口
async getModelConfigs(): Promise<AIModel[]> { async getModelConfigs(): Promise<AIModel[]> {
const res = await fetch(`${API_BASE}/models`, { const res = await httpClient.get(`${API_BASE}/models`, getAuthHeaders())
headers: getAuthHeaders(),
})
if (!res.ok) throw new Error('获取模型配置失败') if (!res.ok) throw new Error('获取模型配置失败')
return res.json() return res.json()
}, },
// 获取系统支持的AI模型列表无需认证 // 获取系统支持的AI模型列表无需认证
async getSupportedModels(): Promise<AIModel[]> { async getSupportedModels(): Promise<AIModel[]> {
const res = await fetch(`${API_BASE}/supported-models`) const res = await httpClient.get(`${API_BASE}/supported-models`)
if (!res.ok) throw new Error('获取支持的模型失败') if (!res.ok) throw new Error('获取支持的模型失败')
return res.json() return res.json()
}, },
@@ -149,27 +149,25 @@ export const api = {
) )
// 发送加密数据 // 发送加密数据
const res = await fetch(`${API_BASE}/models`, { const res = await httpClient.put(
method: 'PUT', `${API_BASE}/models`,
headers: getAuthHeaders(), encryptedPayload,
body: JSON.stringify(encryptedPayload), getAuthHeaders()
}) )
if (!res.ok) throw new Error('更新模型配置失败') if (!res.ok) throw new Error('更新模型配置失败')
}, },
// 交易所配置接口 // 交易所配置接口
async getExchangeConfigs(): Promise<Exchange[]> { async getExchangeConfigs(): Promise<Exchange[]> {
const res = await fetch(`${API_BASE}/exchanges`, { const res = await httpClient.get(`${API_BASE}/exchanges`, getAuthHeaders())
headers: getAuthHeaders(),
})
if (!res.ok) throw new Error('获取交易所配置失败') if (!res.ok) throw new Error('获取交易所配置失败')
return res.json() return res.json()
}, },
// 获取系统支持的交易所列表(无需认证) // 获取系统支持的交易所列表(无需认证)
async getSupportedExchanges(): Promise<Exchange[]> { async getSupportedExchanges(): Promise<Exchange[]> {
const res = await fetch(`${API_BASE}/supported-exchanges`) const res = await httpClient.get(`${API_BASE}/supported-exchanges`)
if (!res.ok) throw new Error('获取支持的交易所失败') if (!res.ok) throw new Error('获取支持的交易所失败')
return res.json() return res.json()
}, },
@@ -177,11 +175,11 @@ export const api = {
async updateExchangeConfigs( async updateExchangeConfigs(
request: UpdateExchangeConfigRequest request: UpdateExchangeConfigRequest
): Promise<void> { ): Promise<void> {
const res = await fetch(`${API_BASE}/exchanges`, { const res = await httpClient.put(
method: 'PUT', `${API_BASE}/exchanges`,
headers: getAuthHeaders(), request,
body: JSON.stringify(request), getAuthHeaders()
}) )
if (!res.ok) throw new Error('更新交易所配置失败') if (!res.ok) throw new Error('更新交易所配置失败')
}, },
@@ -207,11 +205,11 @@ export const api = {
) )
// 发送加密数据 // 发送加密数据
const res = await fetch(`${API_BASE}/exchanges`, { const res = await httpClient.put(
method: 'PUT', `${API_BASE}/exchanges`,
headers: getAuthHeaders(), encryptedPayload,
body: JSON.stringify(encryptedPayload), getAuthHeaders()
}) )
if (!res.ok) throw new Error('更新交易所配置失败') if (!res.ok) throw new Error('更新交易所配置失败')
}, },
@@ -220,9 +218,7 @@ export const api = {
const url = traderId const url = traderId
? `${API_BASE}/status?trader_id=${traderId}` ? `${API_BASE}/status?trader_id=${traderId}`
: `${API_BASE}/status` : `${API_BASE}/status`
const res = await fetch(url, { const res = await httpClient.get(url, getAuthHeaders())
headers: getAuthHeaders(),
})
if (!res.ok) throw new Error('获取系统状态失败') if (!res.ok) throw new Error('获取系统状态失败')
return res.json() return res.json()
}, },
@@ -232,7 +228,7 @@ export const api = {
const url = traderId const url = traderId
? `${API_BASE}/account?trader_id=${traderId}` ? `${API_BASE}/account?trader_id=${traderId}`
: `${API_BASE}/account` : `${API_BASE}/account`
const res = await fetch(url, { const res = await httpClient.request(url, {
cache: 'no-store', cache: 'no-store',
headers: { headers: {
...getAuthHeaders(), ...getAuthHeaders(),
@@ -250,9 +246,7 @@ export const api = {
const url = traderId const url = traderId
? `${API_BASE}/positions?trader_id=${traderId}` ? `${API_BASE}/positions?trader_id=${traderId}`
: `${API_BASE}/positions` : `${API_BASE}/positions`
const res = await fetch(url, { const res = await httpClient.get(url, getAuthHeaders())
headers: getAuthHeaders(),
})
if (!res.ok) throw new Error('获取持仓列表失败') if (!res.ok) throw new Error('获取持仓列表失败')
return res.json() return res.json()
}, },
@@ -262,9 +256,7 @@ export const api = {
const url = traderId const url = traderId
? `${API_BASE}/decisions?trader_id=${traderId}` ? `${API_BASE}/decisions?trader_id=${traderId}`
: `${API_BASE}/decisions` : `${API_BASE}/decisions`
const res = await fetch(url, { const res = await httpClient.get(url, getAuthHeaders())
headers: getAuthHeaders(),
})
if (!res.ok) throw new Error('获取决策日志失败') if (!res.ok) throw new Error('获取决策日志失败')
return res.json() return res.json()
}, },
@@ -274,9 +266,7 @@ export const api = {
const url = traderId const url = traderId
? `${API_BASE}/decisions/latest?trader_id=${traderId}` ? `${API_BASE}/decisions/latest?trader_id=${traderId}`
: `${API_BASE}/decisions/latest` : `${API_BASE}/decisions/latest`
const res = await fetch(url, { const res = await httpClient.get(url, getAuthHeaders())
headers: getAuthHeaders(),
})
if (!res.ok) throw new Error('获取最新决策失败') if (!res.ok) throw new Error('获取最新决策失败')
return res.json() return res.json()
}, },
@@ -286,9 +276,7 @@ export const api = {
const url = traderId const url = traderId
? `${API_BASE}/statistics?trader_id=${traderId}` ? `${API_BASE}/statistics?trader_id=${traderId}`
: `${API_BASE}/statistics` : `${API_BASE}/statistics`
const res = await fetch(url, { const res = await httpClient.get(url, getAuthHeaders())
headers: getAuthHeaders(),
})
if (!res.ok) throw new Error('获取统计信息失败') if (!res.ok) throw new Error('获取统计信息失败')
return res.json() return res.json()
}, },
@@ -298,21 +286,15 @@ export const api = {
const url = traderId const url = traderId
? `${API_BASE}/equity-history?trader_id=${traderId}` ? `${API_BASE}/equity-history?trader_id=${traderId}`
: `${API_BASE}/equity-history` : `${API_BASE}/equity-history`
const res = await fetch(url, { const res = await httpClient.get(url, getAuthHeaders())
headers: getAuthHeaders(),
})
if (!res.ok) throw new Error('获取历史数据失败') if (!res.ok) throw new Error('获取历史数据失败')
return res.json() return res.json()
}, },
// 批量获取多个交易员的历史数据(无需认证) // 批量获取多个交易员的历史数据(无需认证)
async getEquityHistoryBatch(traderIds: string[]): Promise<any> { async getEquityHistoryBatch(traderIds: string[]): Promise<any> {
const res = await fetch(`${API_BASE}/equity-history-batch`, { const res = await httpClient.post(`${API_BASE}/equity-history-batch`, {
method: 'POST', trader_ids: traderIds,
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ trader_ids: traderIds }),
}) })
if (!res.ok) throw new Error('获取批量历史数据失败') if (!res.ok) throw new Error('获取批量历史数据失败')
return res.json() return res.json()
@@ -320,14 +302,14 @@ export const api = {
// 获取前5名交易员数据无需认证 // 获取前5名交易员数据无需认证
async getTopTraders(): Promise<any[]> { async getTopTraders(): Promise<any[]> {
const res = await fetch(`${API_BASE}/top-traders`) const res = await httpClient.get(`${API_BASE}/top-traders`)
if (!res.ok) throw new Error('获取前5名交易员失败') if (!res.ok) throw new Error('获取前5名交易员失败')
return res.json() return res.json()
}, },
// 获取公开交易员配置(无需认证) // 获取公开交易员配置(无需认证)
async getPublicTraderConfig(traderId: string): Promise<any> { async getPublicTraderConfig(traderId: string): Promise<any> {
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('获取公开交易员配置失败') if (!res.ok) throw new Error('获取公开交易员配置失败')
return res.json() return res.json()
}, },
@@ -337,16 +319,14 @@ export const api = {
const url = traderId const url = traderId
? `${API_BASE}/performance?trader_id=${traderId}` ? `${API_BASE}/performance?trader_id=${traderId}`
: `${API_BASE}/performance` : `${API_BASE}/performance`
const res = await fetch(url, { const res = await httpClient.get(url, getAuthHeaders())
headers: getAuthHeaders(),
})
if (!res.ok) throw new Error('获取AI学习数据失败') if (!res.ok) throw new Error('获取AI学习数据失败')
return res.json() return res.json()
}, },
// 获取竞赛数据(无需认证) // 获取竞赛数据(无需认证)
async getCompetition(): Promise<CompetitionData> { async getCompetition(): Promise<CompetitionData> {
const res = await fetch(`${API_BASE}/competition`) const res = await httpClient.get(`${API_BASE}/competition`)
if (!res.ok) throw new Error('获取竞赛数据失败') if (!res.ok) throw new Error('获取竞赛数据失败')
return res.json() return res.json()
}, },
@@ -356,9 +336,10 @@ export const api = {
coin_pool_url: string coin_pool_url: string
oi_top_url: string oi_top_url: string
}> { }> {
const res = await fetch(`${API_BASE}/user/signal-sources`, { const res = await httpClient.get(
headers: getAuthHeaders(), `${API_BASE}/user/signal-sources`,
}) getAuthHeaders()
)
if (!res.ok) throw new Error('获取用户信号源配置失败') if (!res.ok) throw new Error('获取用户信号源配置失败')
return res.json() return res.json()
}, },
@@ -367,14 +348,14 @@ export const api = {
coinPoolUrl: string, coinPoolUrl: string,
oiTopUrl: string oiTopUrl: string
): Promise<void> { ): Promise<void> {
const res = await fetch(`${API_BASE}/user/signal-sources`, { const res = await httpClient.post(
method: 'POST', `${API_BASE}/user/signal-sources`,
headers: getAuthHeaders(), {
body: JSON.stringify({
coin_pool_url: coinPoolUrl, coin_pool_url: coinPoolUrl,
oi_top_url: oiTopUrl, oi_top_url: oiTopUrl,
}), },
}) getAuthHeaders()
)
if (!res.ok) throw new Error('保存用户信号源配置失败') if (!res.ok) throw new Error('保存用户信号源配置失败')
}, },
@@ -383,9 +364,7 @@ export const api = {
public_ip: string public_ip: string
message: string message: string
}> { }> {
const res = await fetch(`${API_BASE}/server-ip`, { const res = await httpClient.get(`${API_BASE}/server-ip`, getAuthHeaders())
headers: getAuthHeaders(),
})
if (!res.ok) throw new Error('获取服务器IP失败') if (!res.ok) throw new Error('获取服务器IP失败')
return res.json() return res.json()
}, },

212
web/src/lib/httpClient.ts Normal file
View File

@@ -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<Response> {
// 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<string, string>): Promise<Response> {
const response = await fetch(url, {
method: 'GET',
headers,
})
return this.handleResponse(response)
}
/**
* POST request
*/
async post(
url: string,
body?: any,
headers?: Record<string, string>
): Promise<Response> {
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<string, string>
): Promise<Response> {
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<string, string>
): Promise<Response> {
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<Response> {
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()