merge dev

This commit is contained in:
Icy
2025-11-12 23:40:58 +08:00
140 changed files with 33470 additions and 4481 deletions

View File

@@ -11,8 +11,9 @@ import type {
UpdateModelConfigRequest,
UpdateExchangeConfigRequest,
CompetitionData,
} from '../types';
import { CryptoService } from './crypto';
} 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,52 +107,66 @@ 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()
},
async updateModelConfigs(request: UpdateModelConfigRequest): Promise<void> {
const res = await fetch(`${API_BASE}/models`, {
method: 'PUT',
headers: getAuthHeaders(),
body: JSON.stringify(request),
})
// 获取RSA公钥
const publicKey = await CryptoService.fetchPublicKey()
// 初始化加密服务
await CryptoService.initialize(publicKey)
// 获取用户信息从localStorage或其他地方
const userId = localStorage.getItem('user_id') || ''
const sessionId = sessionStorage.getItem('session_id') || ''
// 加密敏感数据
const encryptedPayload = await CryptoService.encryptSensitiveData(
JSON.stringify(request),
userId,
sessionId
)
// 发送加密数据
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()
},
@@ -158,46 +174,42 @@ 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('更新交易所配置失败')
},
// 使用加密传输更新交易所配置
async updateExchangeConfigsEncrypted(request: UpdateExchangeConfigRequest): Promise<void> {
// 从系统配置获取公钥
const configRes = await fetch(`${API_BASE}/config`);
if (!configRes.ok) throw new Error('获取系统配置失败');
const config = await configRes.json();
if (!config.rsa_public_key) {
throw new Error('系统未配置RSA公钥无法使用加密传输');
}
async updateExchangeConfigsEncrypted(
request: UpdateExchangeConfigRequest
): Promise<void> {
// 获取RSA公钥
const publicKey = await CryptoService.fetchPublicKey()
// 初始化加密服务
await CryptoService.initialize(config.rsa_public_key);
await CryptoService.initialize(publicKey)
// 获取用户信息从localStorage或其他地方
const userId = localStorage.getItem('user_id') || '';
const sessionId = sessionStorage.getItem('session_id') || '';
const userId = localStorage.getItem('user_id') || ''
const sessionId = sessionStorage.getItem('session_id') || ''
// 加密敏感数据
const encryptedPayload = await CryptoService.encryptSensitiveData(
JSON.stringify(request),
userId,
sessionId
);
)
// 发送加密数据
const res = await fetch(`${API_BASE}/exchanges/encrypted`, {
method: 'PUT',
headers: getAuthHeaders(),
body: JSON.stringify(encryptedPayload),
});
if (!res.ok) throw new Error('更新交易所配置失败');
const res = await httpClient.put(
`${API_BASE}/exchanges`,
encryptedPayload,
getAuthHeaders()
)
if (!res.ok) throw new Error('更新交易所配置失败')
},
// 获取系统状态支持trader_id
@@ -205,9 +217,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()
},
@@ -217,7 +227,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(),
@@ -235,9 +245,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()
},
@@ -247,21 +255,26 @@ 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()
},
// 获取最新决策支持trader_id
async getLatestDecisions(traderId?: string): Promise<DecisionRecord[]> {
const url = traderId
? `${API_BASE}/decisions/latest?trader_id=${traderId}`
: `${API_BASE}/decisions/latest`
const res = await fetch(url, {
headers: getAuthHeaders(),
})
// 获取最新决策支持trader_id和limit参数
async getLatestDecisions(
traderId?: string,
limit: number = 5
): Promise<DecisionRecord[]> {
const params = new URLSearchParams()
if (traderId) {
params.append('trader_id', traderId)
}
params.append('limit', limit.toString())
const res = await httpClient.get(
`${API_BASE}/decisions/latest?${params}`,
getAuthHeaders()
)
if (!res.ok) throw new Error('获取最新决策失败')
return res.json()
},
@@ -271,9 +284,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()
},
@@ -283,21 +294,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()
@@ -305,14 +310,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()
},
@@ -322,16 +327,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()
},
@@ -341,9 +344,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()
},
@@ -352,26 +356,24 @@ 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('保存用户信号源配置失败')
},
// 获取服务器IP需要认证用于白名单配置
async getServerIP(): Promise<{
public_ip: string;
message: string;
public_ip: string
message: string
}> {
const res = await fetch(`${API_BASE}/server-ip`, {
headers: getAuthHeaders(),
});
if (!res.ok) throw new Error('获取服务器IP失败');
return res.json();
const res = await httpClient.get(`${API_BASE}/server-ip`, getAuthHeaders())
if (!res.ok) throw new Error('获取服务器IP失败')
return res.json()
},
};
}

View File

@@ -0,0 +1,370 @@
import { describe, it, expect } from 'vitest'
/**
* PR #669 測試: 防止 null token 導致未授權的 API 調用
*
* 問題當用戶未登入時user/token 為 nullSWR 仍然會使用空 key 發起 API 請求
* 修復:在 SWR key 中添加 `user && token` 檢查,當未登入時返回 null阻止 API 調用
*/
describe('API Guard Logic (PR #669)', () => {
/**
* 測試 SWR key 生成邏輯
* 核心修復key 必須包含 user && token 檢查
*/
describe('SWR key generation', () => {
it('should return null when user is null', () => {
const user = null
const token = 'valid-token'
const traderId = '123'
const currentPage = 'trader'
const key =
user && token && currentPage === 'trader' && traderId
? `status-${traderId}`
: null
expect(key).toBeNull()
})
it('should return null when token is null', () => {
const user = { id: '1', email: 'test@example.com' }
const token = null
const traderId = '123'
const currentPage = 'trader'
const key =
user && token && currentPage === 'trader' && traderId
? `status-${traderId}`
: null
expect(key).toBeNull()
})
it('should return null when both user and token are null', () => {
const user = null
const token = null
const traderId = '123'
const currentPage = 'trader'
const key =
user && token && currentPage === 'trader' && traderId
? `status-${traderId}`
: null
expect(key).toBeNull()
})
it('should return null when currentPage is not trader', () => {
const user = { id: '1', email: 'test@example.com' }
const token = 'valid-token'
const traderId = '123'
const currentPage: string = 'competition' // Not 'trader', so key should be null
const key =
user && token && currentPage === 'trader' && traderId
? `status-${traderId}`
: null
expect(key).toBeNull()
})
it('should return null when traderId is not set', () => {
const user = { id: '1', email: 'test@example.com' }
const token = 'valid-token'
const traderId = null
const currentPage = 'trader'
const key =
user && token && currentPage === 'trader' && traderId
? `status-${traderId}`
: null
expect(key).toBeNull()
})
it('should return valid key when all conditions are met', () => {
const user = { id: '1', email: 'test@example.com' }
const token = 'valid-token'
const traderId = '123'
const currentPage = 'trader'
const key =
user && token && currentPage === 'trader' && traderId
? `status-${traderId}`
: null
expect(key).toBe('status-123')
})
})
/**
* 測試不同 API 端點的條件邏輯
* 所有需要認證的端點都應該檢查 user && token
*/
describe('multiple API endpoints', () => {
it('should guard status API', () => {
const user = null
const token = null
const traderId = '123'
const currentPage = 'trader'
const statusKey =
user && token && currentPage === 'trader' && traderId
? `status-${traderId}`
: null
expect(statusKey).toBeNull()
})
it('should guard account API', () => {
const user = null
const token = null
const traderId = '123'
const currentPage = 'trader'
const accountKey =
user && token && currentPage === 'trader' && traderId
? `account-${traderId}`
: null
expect(accountKey).toBeNull()
})
it('should guard positions API', () => {
const user = null
const token = null
const traderId = '123'
const currentPage = 'trader'
const positionsKey =
user && token && currentPage === 'trader' && traderId
? `positions-${traderId}`
: null
expect(positionsKey).toBeNull()
})
it('should guard decisions API', () => {
const user = null
const token = null
const traderId = '123'
const currentPage = 'trader'
const decisionsKey =
user && token && currentPage === 'trader' && traderId
? `decisions/latest-${traderId}`
: null
expect(decisionsKey).toBeNull()
})
it('should guard statistics API', () => {
const user = null
const token = null
const traderId = '123'
const currentPage = 'trader'
const statsKey =
user && token && currentPage === 'trader' && traderId
? `statistics-${traderId}`
: null
expect(statsKey).toBeNull()
})
it('should allow all API calls when authenticated', () => {
const user = { id: '1', email: 'test@example.com' }
const token = 'valid-token'
const traderId = '123'
const currentPage = 'trader'
const statusKey =
user && token && currentPage === 'trader' && traderId
? `status-${traderId}`
: null
const accountKey =
user && token && currentPage === 'trader' && traderId
? `account-${traderId}`
: null
const positionsKey =
user && token && currentPage === 'trader' && traderId
? `positions-${traderId}`
: null
expect(statusKey).toBe('status-123')
expect(accountKey).toBe('account-123')
expect(positionsKey).toBe('positions-123')
})
})
/**
* 測試 EquityChart 組件的條件邏輯
* PR #669 同時修復了 EquityChart 中的相同問題
*/
describe('EquityChart API guard', () => {
it('should return null key when user is not authenticated', () => {
const user = null
const token = null
const traderId = '123'
const equityKey =
user && token && traderId ? `equity-history-${traderId}` : null
expect(equityKey).toBeNull()
})
it('should return null key when traderId is missing', () => {
const user = { id: '1', email: 'test@example.com' }
const token = 'valid-token'
const traderId = null
const equityKey =
user && token && traderId ? `equity-history-${traderId}` : null
expect(equityKey).toBeNull()
})
it('should return valid key when authenticated with traderId', () => {
const user = { id: '1', email: 'test@example.com' }
const token = 'valid-token'
const traderId = '123'
const equityKey =
user && token && traderId ? `equity-history-${traderId}` : null
const accountKey =
user && token && traderId ? `account-${traderId}` : null
expect(equityKey).toBe('equity-history-123')
expect(accountKey).toBe('account-123')
})
})
/**
* 測試邊界情況和特殊值
*/
describe('edge cases', () => {
it('should treat empty string token as falsy', () => {
const user = { id: '1', email: 'test@example.com' }
const token = ''
const traderId = '123'
const currentPage = 'trader'
const key =
user && token && currentPage === 'trader' && traderId
? `status-${traderId}`
: null
expect(key).toBeNull()
})
it('should treat empty string traderId as falsy', () => {
const user = { id: '1', email: 'test@example.com' }
const token = 'valid-token'
const traderId = ''
const currentPage = 'trader'
const key =
user && token && currentPage === 'trader' && traderId
? `status-${traderId}`
: null
expect(key).toBeNull()
})
it('should handle undefined user', () => {
const user = undefined
const token = 'valid-token'
const traderId = '123'
const currentPage = 'trader'
const key =
user && token && currentPage === 'trader' && traderId
? `status-${traderId}`
: null
expect(key).toBeNull()
})
it('should handle undefined token', () => {
const user = { id: '1', email: 'test@example.com' }
const token = undefined
const traderId = '123'
const currentPage = 'trader'
const key =
user && token && currentPage === 'trader' && traderId
? `status-${traderId}`
: null
expect(key).toBeNull()
})
it('should handle numeric traderId', () => {
const user = { id: '1', email: 'test@example.com' }
const token = 'valid-token'
const traderId = 123 // 數字而非字串
const currentPage = 'trader'
const key =
user && token && currentPage === 'trader' && traderId
? `status-${traderId}`
: null
expect(key).toBe('status-123')
})
it('should handle zero traderId as falsy', () => {
const user = { id: '1', email: 'test@example.com' }
const token = 'valid-token'
const traderId = 0
const currentPage = 'trader'
const key =
user && token && currentPage === 'trader' && traderId
? `status-${traderId}`
: null
expect(key).toBeNull() // 0 is falsy
})
})
/**
* 測試防止 API 調用的邏輯流程
*/
describe('API call prevention flow', () => {
it('should prevent API call when key is null', () => {
const key = null
const shouldCallAPI = key !== null
expect(shouldCallAPI).toBe(false)
})
it('should allow API call when key is valid', () => {
const key = 'status-123'
const shouldCallAPI = key !== null
expect(shouldCallAPI).toBe(true)
})
it('should simulate SWR behavior with null key', () => {
// SWR 不會在 key 為 null 時發起請求
const key = null
const fetcher = (k: string) => `API response for ${k}`
// 模擬 SWR 行為key 為 null 時不調用 fetcher
const data = key ? fetcher(key) : undefined
expect(data).toBeUndefined()
})
it('should simulate SWR behavior with valid key', () => {
const key = 'status-123'
const fetcher = (k: string) => `API response for ${k}`
const data = key ? fetcher(key) : undefined
expect(data).toBe('API response for status-123')
})
})
})

30
web/src/lib/clipboard.ts Normal file
View File

@@ -0,0 +1,30 @@
import { notify } from './notify'
/**
* 复制文本到剪贴板,并显示轻量提示。
*/
export async function copyWithToast(text: string, successMsg = '已复制') {
try {
if (navigator?.clipboard?.writeText) {
await navigator.clipboard.writeText(text)
} else {
// 兼容降级:创建临时文本域执行复制
const el = document.createElement('textarea')
el.value = text
el.style.position = 'fixed'
el.style.left = '-9999px'
document.body.appendChild(el)
el.select()
document.execCommand('copy')
document.body.removeChild(el)
}
notify.success(successMsg)
return true
} catch (err) {
console.error('Clipboard copy failed:', err)
notify.error('复制失败')
return false
}
}
export default { copyWithToast }

7
web/src/lib/cn.ts Normal file
View File

@@ -0,0 +1,7 @@
import { type ClassValue } from 'clsx'
import { clsx } from 'clsx'
import { twMerge } from 'tailwind-merge'
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}

View File

@@ -1,47 +1,56 @@
export interface EncryptedPayload {
wrappedKey: string; // RSA-OAEP(K)
iv: string; // 12 bytes
ciphertext: string; // AES-GCM 输出(含 tag)
aad?: string; // 可选:额外认证数据
kid?: string; // 可选:服务端公钥标识
ts?: number; // 可选unix 秒,用于重放保护
wrappedKey: string // RSA-OAEP(K)
iv: string // 12 bytes
ciphertext: string // AES-GCM 输出(含 tag)
aad?: string // 可选:额外认证数据
kid?: string // 可选:服务端公钥标识
ts?: number // 可选unix 秒,用于重放保护
}
export interface WebCryptoEnvironmentInfo {
isBrowser: boolean
isSecureContext: boolean
hasSubtleCrypto: boolean
origin?: string
protocol?: string
hostname?: string
isLocalhost?: boolean
}
export class CryptoService {
private static publicKey: CryptoKey | null = null;
private static publicKeyPEM: string | null = null;
private static publicKey: CryptoKey | null = null
private static publicKeyPEM: string | null = null
static async initialize(publicKeyPEM: string) {
// 检查 Web Crypto API 是否可用
if (!window.crypto || !window.crypto.subtle) {
throw new Error('Web Crypto API is not available. Please use HTTPS or localhost to access the application.');
}
if (this.publicKey && this.publicKeyPEM === publicKeyPEM) {
return;
return
}
this.publicKeyPEM = publicKeyPEM;
this.publicKey = await this.importPublicKey(publicKeyPEM);
this.publicKeyPEM = publicKeyPEM
this.publicKey = await this.importPublicKey(publicKeyPEM)
}
private static async importPublicKey(pem: string): Promise<CryptoKey> {
const pemHeader = '-----BEGIN PUBLIC KEY-----';
const pemFooter = '-----END PUBLIC KEY-----';
const headerIndex = pem.indexOf(pemHeader);
const footerIndex = pem.indexOf(pemFooter);
const pemHeader = '-----BEGIN PUBLIC KEY-----'
const pemFooter = '-----END PUBLIC KEY-----'
const headerIndex = pem.indexOf(pemHeader)
const footerIndex = pem.indexOf(pemFooter)
if (headerIndex === -1 || footerIndex === -1 || headerIndex >= footerIndex) {
throw new Error('Invalid PEM formatted public key');
if (
headerIndex === -1 ||
footerIndex === -1 ||
headerIndex >= footerIndex
) {
throw new Error('Invalid PEM formatted public key')
}
const pemContents = pem
.substring(headerIndex + pemHeader.length, footerIndex)
.replace(/\s+/g, ''); // 移除所有空白字符(包括换行符、空格等)
const binaryDerString = atob(pemContents);
const binaryDer = new Uint8Array(binaryDerString.length);
.replace(/\s+/g, '') // 移除所有空白字符(包括换行符、空格等)
const binaryDerString = atob(pemContents)
const binaryDer = new Uint8Array(binaryDerString.length)
for (let i = 0; i < binaryDerString.length; i++) {
binaryDer[i] = binaryDerString.charCodeAt(i);
binaryDer[i] = binaryDerString.charCodeAt(i)
}
return crypto.subtle.importKey(
@@ -53,7 +62,7 @@ export class CryptoService {
},
false,
['encrypt']
);
)
}
static async encryptSensitiveData(
@@ -62,7 +71,9 @@ export class CryptoService {
sessionId?: string
): Promise<EncryptedPayload> {
if (!this.publicKey) {
throw new Error('Crypto service not initialized. Call initialize() first.');
throw new Error(
'Crypto service not initialized. Call initialize() first.'
)
}
// 1. 生成 256-bit AES 密钥
@@ -73,24 +84,24 @@ export class CryptoService {
},
true,
['encrypt']
);
)
// 2. 生成 12 字节随机 IV
const iv = crypto.getRandomValues(new Uint8Array(12));
const iv = crypto.getRandomValues(new Uint8Array(12))
// 3. 准备 AAD (额外认证数据)
const ts = Math.floor(Date.now() / 1000);
const ts = Math.floor(Date.now() / 1000)
const aadObject = {
userId: userId || '',
sessionId: sessionId || '',
ts: ts,
purpose: 'sensitive_data_encryption'
};
const aadString = JSON.stringify(aadObject);
const aadBytes = new TextEncoder().encode(aadString);
purpose: 'sensitive_data_encryption',
}
const aadString = JSON.stringify(aadObject)
const aadBytes = new TextEncoder().encode(aadString)
// 4. 使用 AES-GCM 加密数据
const plaintextBytes = new TextEncoder().encode(plaintext);
const plaintextBytes = new TextEncoder().encode(plaintext)
const ciphertext = await crypto.subtle.encrypt(
{
name: 'AES-GCM',
@@ -100,10 +111,10 @@ export class CryptoService {
},
aesKey,
plaintextBytes
);
)
// 5. 导出 AES 密钥
const aesKeyRaw = await crypto.subtle.exportKey('raw', aesKey);
const rawAesKey = await crypto.subtle.exportKey('raw', aesKey)
// 6. 使用 RSA-OAEP 加密 AES 密钥
const wrappedKey = await crypto.subtle.encrypt(
@@ -111,37 +122,114 @@ export class CryptoService {
name: 'RSA-OAEP',
},
this.publicKey,
aesKeyRaw
);
rawAesKey
)
// 7. 转换为 base64url 格式
// 7. 编码为 base64url
return {
wrappedKey: this.arrayBufferToBase64Url(wrappedKey),
iv: this.arrayBufferToBase64Url(iv),
iv: this.arrayBufferToBase64Url(iv.buffer),
ciphertext: this.arrayBufferToBase64Url(ciphertext),
aad: this.arrayBufferToBase64Url(aadBytes),
kid: 'rsa-key-2025-11-05',
aad: this.arrayBufferToBase64Url(aadBytes.buffer),
ts: ts,
};
}
}
private static arrayBufferToBase64Url(buffer: ArrayBuffer | Uint8Array): string {
const bytes = buffer instanceof Uint8Array ? buffer : new Uint8Array(buffer);
let binary = '';
for (let i = 0; i < bytes.byteLength; i++) {
binary += String.fromCharCode(bytes[i]);
private static arrayBufferToBase64Url(buffer: ArrayBuffer): string {
const bytes = new Uint8Array(buffer)
let binary = ''
for (let i = 0; i < bytes.length; i++) {
binary += String.fromCharCode(bytes[i])
}
return btoa(binary)
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=/g, '');
.replace(/=/g, '')
}
static async encryptWalletPrivateKey(privateKey: string, userId?: string, sessionId?: string): Promise<EncryptedPayload> {
return this.encryptSensitiveData(privateKey, userId, sessionId);
static async fetchPublicKey(): Promise<string> {
const response = await fetch('/api/crypto/public-key')
if (!response.ok) {
throw new Error(`Failed to fetch public key: ${response.statusText}`)
}
const data = await response.json()
return data.public_key
}
static async encryptExchangeSecret(secretKey: string, userId?: string, sessionId?: string): Promise<EncryptedPayload> {
return this.encryptSensitiveData(secretKey, userId, sessionId);
static async decryptSensitiveData(
payload: EncryptedPayload
): Promise<string> {
const response = await fetch('/api/crypto/decrypt', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(payload),
})
if (!response.ok) {
throw new Error(`Decryption failed: ${response.statusText}`)
}
const result = await response.json()
return result.plaintext
}
}
// 生成混淆字符串(用于剪贴板混淆)
export function generateObfuscation(): string {
const bytes = new Uint8Array(32)
crypto.getRandomValues(bytes)
return Array.from(bytes, (byte) => byte.toString(16).padStart(2, '0')).join(
''
)
}
// 验证私钥格式
export function validatePrivateKeyFormat(
value: string,
expectedLength: number = 64
): boolean {
const normalized = value.startsWith('0x') ? value.slice(2) : value
if (normalized.length !== expectedLength) {
return false
}
return /^[0-9a-fA-F]+$/.test(normalized)
}
export function diagnoseWebCryptoEnvironment(): WebCryptoEnvironmentInfo {
if (typeof window === 'undefined') {
return {
isBrowser: false,
isSecureContext: false,
hasSubtleCrypto: false,
}
}
const { location } = window
const hostname = location?.hostname
const protocol = location?.protocol
const origin = location?.origin
const isLocalhost = hostname
? ['localhost', '127.0.0.1', '::1'].includes(hostname)
: false
const secureContext =
typeof window.isSecureContext === 'boolean'
? window.isSecureContext
: protocol === 'https:' || (protocol === 'http:' && isLocalhost)
const hasSubtleCrypto =
typeof window.crypto !== 'undefined' &&
typeof window.crypto.subtle !== 'undefined'
return {
isBrowser: true,
isSecureContext: secureContext,
hasSubtleCrypto,
origin: origin || undefined,
protocol: protocol || undefined,
hostname,
isLocalhost,
}
}

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

@@ -0,0 +1,169 @@
/**
* 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
*/
import { toast } from 'sonner'
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 {
toast.warning('登录已过期,请先登录', { duration: 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()

87
web/src/lib/notify.tsx Normal file
View File

@@ -0,0 +1,87 @@
import { toast } from 'sonner'
import type { ReactNode } from 'react'
export interface ConfirmOptions {
title?: string
message?: string
okText?: string
cancelText?: string
}
// 全局 confirm 函数的引用,将在 ConfirmDialogProvider 中设置
let globalConfirm:
| ((options: ConfirmOptions & { message: string }) => Promise<boolean>)
| null = null
export function setGlobalConfirm(
confirmFn: (options: ConfirmOptions & { message: string }) => Promise<boolean>
) {
globalConfirm = confirmFn
}
// 确认对话框函数,使用 shadcn AlertDialog
export function confirmToast(
message: string,
options: ConfirmOptions = {}
): Promise<boolean> {
if (!globalConfirm) {
console.error('ConfirmDialogProvider not initialized')
return Promise.resolve(false)
}
return globalConfirm({
message,
...options,
})
}
// 统一通知封装,避免组件直接依赖 sonner
type Message = string | ReactNode
function message(msg: Message, options?: Parameters<typeof toast>[1]) {
return toast(msg as any, options)
}
function success(msg: Message, options?: Parameters<typeof toast.success>[1]) {
return toast.success(msg as any, options)
}
function error(msg: Message, options?: Parameters<typeof toast.error>[1]) {
return toast.error(msg as any, options)
}
function info(msg: Message, options?: Parameters<typeof toast.info>[1]) {
return toast.info?.(msg as any, options) ?? toast(msg as any, options)
}
function warning(msg: Message, options?: Parameters<typeof toast.warning>[1]) {
return toast.warning?.(msg as any, options) ?? toast(msg as any, options)
}
function custom(
renderer: Parameters<typeof toast.custom>[0],
options?: Parameters<typeof toast.custom>[1]
) {
return toast.custom(renderer, options)
}
function dismiss(id?: string | number) {
return toast.dismiss(id as any)
}
function promise<T>(p: Promise<T> | (() => Promise<T>), msgs: any) {
return toast.promise<T>(p as any, msgs as any)
}
export const notify = {
message,
success,
error,
info,
warning,
custom,
dismiss,
promise,
}
export default { confirmToast, notify }

28
web/src/lib/text.ts Normal file
View File

@@ -0,0 +1,28 @@
/**
* 文本工具
*
* stripLeadingIcons: 去掉翻译文案或标题前面用于装饰的 Emoji/符号,
* 以便在组件里自行放置图标时不重复显示。
*/
/**
* 去掉开头的装饰性 Emoji/符号以及随后的分隔符(空格/冒号/点号等)。
*/
export function stripLeadingIcons(input: string | undefined | null): string {
if (!input) return ''
let s = String(input)
// 1) 去除常见的 Emoji/符号块(箭头、杂项符号、几何图形、表情等)
// 覆盖常见范围,兼容性好于使用 Unicode 属性类。
s = s.replace(
/^[\s\u2190-\u21FF\u2300-\u23FF\u2460-\u24FF\u25A0-\u25FF\u2600-\u27BF\u2B00-\u2BFF\u1F000-\u1FAFF]+/u,
''
)
// 2) 去掉开头可能残留的分隔符(空格、连字符、冒号、居中点等)
s = s.replace(/^[\s\-:•·]+/, '')
return s.trim()
}
export default { stripLeadingIcons }