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

View File

@@ -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<string, string> {
export const api = {
// AI交易员管理接口
async getTraders(): Promise<TraderInfo[]> {
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<any[]> {
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<TraderInfo> {
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<void> {
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<void> {
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<void> {
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<void> {
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<any> {
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<TraderInfo> {
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<AIModel[]> {
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<AIModel[]> {
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<Exchange[]> {
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<Exchange[]> {
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<void> {
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<any> {
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<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名交易员失败')
return res.json()
},
// 获取公开交易员配置(无需认证)
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('获取公开交易员配置失败')
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<CompetitionData> {
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<void> {
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()
},

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