新增:用户信息SDK接入

This commit is contained in:
ivan 2026-02-26 14:44:36 +08:00
parent 2306b34e00
commit d1351345b0
8 changed files with 490 additions and 31 deletions

View File

@ -9,7 +9,8 @@ HTTP 请求基础封装,提供 `get` 和 `post` 方法,支持自定义请求
## 核心能力
- 统一 BASE_URL默认 `https://api.xtrader.vip`,可通过环境变量 `VITE_API_BASE_URL` 覆盖
- CLOB WebSocket URL`getClobWsUrl()` 返回与 REST API 同源的 `ws(s)://host/api/clob/ws`
- CLOB WebSocket URL`getClobWsUrl()` 返回与 REST API 同源的 `ws(s)://host/clob/ws`
- User WebSocket URL`getUserWsUrl()` 返回 `ws(s)://host/clob/ws/user`(订单/持仓/余额推送)
- GET 请求:支持 query 参数,自动序列化
- POST 请求:支持 JSON body
- 自定义 headers通过 `RequestConfig.headers` 传入
@ -17,10 +18,12 @@ HTTP 请求基础封装,提供 `get` 和 `post` 方法,支持自定义请求
## 使用方式
```typescript
import { get, post, getClobWsUrl } from '@/api/request'
import { get, post, getClobWsUrl, getUserWsUrl } from '@/api/request'
// CLOB WebSocket URL与 REST 同源)
const wsUrl = getClobWsUrl() // e.g. wss://api.xtrader.vip/api/clob/ws
const clobWsUrl = getClobWsUrl()
// User WebSocket URL订单/持仓/余额推送,需带 token 参数)
const userWsUrl = getUserWsUrl()
// GET 请求
const data = await get<MyResponse>('/path', { page: 1, keyword: 'x' })

View File

@ -10,12 +10,12 @@
- `token``user`:登录凭证与用户信息
- `isLoggedIn``avatarUrl`:派生状态
- `balance`USDC 余额显示(如 "0.00"
- `setUser`:设置登录数据并持久化
- `logout`:清空并清除持久化
- `balance`USDC 余额显示(如 "0.00",支持 **UserSocket** 实时推送更新
- `setUser`:设置登录数据并持久化,登录成功后自动连接 UserSocket
- `logout`:清空并断开 UserSocket
- `getAuthHeaders`:返回 `{ 'x-token', 'x-user-id' }`,未登录时返回 `undefined`
- `fetchUserInfo``fetchUsdcBalance`:拉取并更新用户信息与余额
- `fetchUserInfo` 兼容多种 API 字段名:`userName`/`username``nickName`/`nickname``headerImg`/`avatar`/`avatarUrl`;支持 `data` 直接为用户对象或 `data.user` 嵌套结构
- `fetchUserInfo``fetchUsdcBalance`:拉取并更新用户信息与余额`fetchUserInfo` 兼容多种 API 字段名
- `connectUserSocket`、`disconnectUserSocket`:连接/断开 `sdk/userSocket` 的 UserSdk用于订单/持仓/余额实时推送
## 使用方式
@ -39,9 +39,9 @@ await userStore.fetchUsdcBalance()
## 扩展方式
1. **多端登录**:扩展 `setUser` 支持多设备 token 管理
2. **Token 刷新**:在 `getAuthHeaders` 或请求拦截器中加入 refresh 逻辑
3. **登出回调**:在 `logout` 中增加清理逻辑(如取消订阅、重置其他 store
1. **订单/持仓推送**:在 `connectUserSocket` 中注册 `onOrderUpdate``onPositionUpdate`,触发 Wallet 刷新
2. **多端登录**:扩展 `setUser` 支持多设备 token 管理
3. **Token 刷新**:在 `getAuthHeaders` 或请求拦截器中加入 refresh 逻辑
## 登录后刷新用户信息

101
sdk/testUser.ts Normal file
View File

@ -0,0 +1,101 @@
import axios from 'axios';
import WebSocket from 'ws';
import { UserSdk, UserMsg, OrderData, PositionData, BalanceData } from './userSocket';
// Node 环境:注入 WebSocketuserSocket 使用全局 WebSocket
(global as any).WebSocket = WebSocket;
// 配置
const CONFIG = {
baseURL: 'http://localhost:8888', // API 基础地址
wsURL: 'ws://localhost:8888/clob/ws/user', // WebSocket 地址
};
// 模拟登录获取 Token
async function loginUser(index: number = 1): Promise<string | null> {
const hexIndex = index.toString(16).padStart(36, '0');
const walletAddress = `0x0000${hexIndex}`; // 测试用钱包地址
try {
const payload = {
walletAddress: walletAddress,
nonce: "test_nonce",
signature: "test_signature",
message: "test_message"
};
console.log(`正在登录用户 (地址: ${walletAddress})...`);
const response = await axios.post(`${CONFIG.baseURL}/base/walletLogin`, payload);
if (response.data.code === 0 && response.data.data) {
console.log(`✅ 登录成功! 用户ID: ${response.data.data.user.ID}`);
return response.data.data.token;
} else {
console.error(`❌ 登录失败:`, response.data.msg);
return null;
}
} catch (error: any) {
console.error(`❌ 登录请求错误:`, error.message);
return null;
}
}
async function main() {
// 1. 获取 Token
const token = await loginUser(1);
if (!token) {
console.error("无法获取 Token退出测试");
return;
}
// 2. 初始化 SDK
console.log("初始化 User SDK...");
const sdk = new UserSdk({
url: CONFIG.wsURL,
token: token,
autoReconnect: true
});
// 3. 注册事件监听
sdk.onConnect(() => {
console.log("✅ WebSocket 连接已建立");
});
sdk.onDisconnect((event) => {
console.log(`⚠️ WebSocket 连接断开 (Code: ${event.code})`);
});
sdk.onError((error) => {
console.error("❌ WebSocket 错误:", error);
});
sdk.onWelcome((msg) => {
console.log("👋 收到 Welcome 消息:", msg);
});
sdk.onOrderUpdate((data: OrderData) => {
console.log("\n📦 [订单更新] Received Order Update:");
console.log(` ID: ${data.ID}, Status: ${data.status}, Side: ${data.side}, Price: ${data.price}, Matched: ${data.sizeMatched}/${data.originalSize}`);
});
sdk.onPositionUpdate((data: PositionData) => {
console.log("\n📊 [持仓更新] Received Position Update:");
console.log(` Market: ${data.marketID}, Token: ${data.tokenId}, Side: ${data.side}, Size: ${data.size}`);
});
sdk.onBalanceUpdate((data: BalanceData) => {
console.log("\n💰 [余额更新] Received Balance Update:");
console.log(` Token: ${data.token_id || data.tokenType}, Amount: ${data.amount}, Available: ${data.available}`);
});
// 4. 连接
sdk.connect();
// 保持运行
console.log("正在监听消息... (按 Ctrl+C 退出)");
// 保持进程活跃
setInterval(() => {}, 1000);
}
main().catch(console.error);

216
sdk/userSocket.ts Normal file
View File

@ -0,0 +1,216 @@
// 前端 User WebSocket SDK浏览器使用原生 WebSocketNode 测试需注入 global.WebSocket
// 消息类型定义
export type UserUpdateType = 'order_update' | 'position_update' | 'balance_update' | 'welcome';
export interface UserMsg {
type: UserUpdateType;
data?: any;
msg?: string; // mainly for welcome
timestamp?: number;
t?: number; // for welcome timestamp
}
// 订单数据结构 (对应后端 ClobOrder)
export interface OrderData {
ID: number;
userID: number;
market: string;
status: number;
assetID: string;
side: number;
price: number;
originalSize: number;
sizeMatched: number;
outcome: string;
expiration: number;
orderType: number;
feeRateBps: number;
CreatedAt?: string;
UpdatedAt?: string;
}
// 持仓数据结构 (对应后端 ClobPosition)
export interface PositionData {
ID: number;
userID: number;
marketID: string;
tokenId: string;
side: number;
size: number;
available: number;
}
// 余额数据结构 (对应后端 PmTokenBalance)
export interface BalanceData {
ID: string;
userID: number;
amount: string;
available: string;
frozen: string;
tokenType: string;
token_id: string; // 注意后端字段可能是不一致的,这里根据 Go 结构体推测,或者是 json tag
market_id: string;
wallet_address: string;
}
// SDK 配置选项
export interface UserSocketOptions {
url?: string;
token: string; // 必须提供 Token
autoReconnect?: boolean;
reconnectInterval?: number;
maxReconnectAttempts?: number;
}
// 回调函数类型(兼容浏览器 Event/CloseEvent
export type ConnectCallback = (event: Event) => void;
export type DisconnectCallback = (event: CloseEvent) => void;
export type ErrorCallback = (event: Event) => void;
export type WelcomeCallback = (msg: UserMsg) => void;
export type OrderCallback = (data: OrderData) => void;
export type PositionCallback = (data: PositionData) => void;
export type BalanceCallback = (data: BalanceData) => void;
export class UserSdk {
private url: string;
private token: string;
private ws: InstanceType<typeof WebSocket> | null = null;
private autoReconnect: boolean;
private reconnectInterval: number;
private maxReconnectAttempts: number;
private reconnectAttempts = 0;
private isExplicitClose = false;
private listeners = {
connect: [] as ConnectCallback[],
disconnect: [] as DisconnectCallback[],
error: [] as ErrorCallback[],
welcome: [] as WelcomeCallback[],
order: [] as OrderCallback[],
position: [] as PositionCallback[],
balance: [] as BalanceCallback[]
};
constructor(options: UserSocketOptions) {
this.token = options.token;
this.autoReconnect = options.autoReconnect ?? true;
this.reconnectInterval = options.reconnectInterval ?? 3000;
this.maxReconnectAttempts = options.maxReconnectAttempts ?? 5;
// 构造 URL
let baseUrl = options.url || "ws://localhost:8888/clob/ws/user";
if (baseUrl.startsWith("/")) {
// 如果是相对路径 (浏览器环境),自动补充 host
if (typeof window !== 'undefined' && window.location) {
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
baseUrl = `${protocol}//${window.location.host}${baseUrl}`;
} else {
baseUrl = "ws://localhost:8888" + baseUrl;
}
}
// 拼接 Token
const separator = baseUrl.includes('?') ? '&' : '?';
this.url = `${baseUrl}${separator}token=${this.token}`;
}
// 连接
public connect() {
if (this.ws) {
console.warn("UserSdk: WebSocket already connected or connecting");
return;
}
try {
console.log(`UserSdk: Connecting to ${this.url}...`);
this.ws = new WebSocket(this.url);
this.ws.onopen = (event: Event) => {
console.log("UserSdk: Connected");
this.reconnectAttempts = 0;
this.listeners.connect.forEach(cb => cb(event));
};
this.ws.onclose = (event: CloseEvent) => {
console.log(`UserSdk: Disconnected (Code: ${event.code})`);
this.ws = null;
this.listeners.disconnect.forEach(cb => cb(event));
if (this.autoReconnect && !this.isExplicitClose) {
this.handleReconnect();
}
};
this.ws.onerror = (event: Event) => {
console.error("UserSdk: Error", event);
this.listeners.error.forEach(cb => cb(event));
};
this.ws.onmessage = (event: MessageEvent) => {
try {
const raw = typeof event.data === 'string' ? event.data : String(event.data);
const msg = JSON.parse(raw) as UserMsg;
this.handleMessage(msg);
} catch (e) {
console.error("UserSdk: Failed to parse message", event.data);
}
};
} catch (e) {
console.error("UserSdk: Connection failed", e);
if (this.autoReconnect) this.handleReconnect();
}
}
// 断开连接
public disconnect() {
this.isExplicitClose = true;
if (this.ws) {
this.ws.close();
this.ws = null;
}
}
// 消息分发
private handleMessage(msg: UserMsg) {
switch (msg.type) {
case 'welcome':
this.listeners.welcome.forEach(cb => cb(msg));
break;
case 'order_update':
this.listeners.order.forEach(cb => cb(msg.data));
break;
case 'position_update':
this.listeners.position.forEach(cb => cb(msg.data));
break;
case 'balance_update':
this.listeners.balance.forEach(cb => cb(msg.data));
break;
default:
console.warn("UserSdk: Unknown message type", msg);
}
}
// 重连逻辑
private handleReconnect() {
if (this.reconnectAttempts >= this.maxReconnectAttempts) {
console.error("UserSdk: Max reconnect attempts reached");
return;
}
this.reconnectAttempts++;
console.log(`UserSdk: Reconnecting in ${this.reconnectInterval}ms (Attempt ${this.reconnectAttempts})...`);
setTimeout(() => {
this.connect();
}, this.reconnectInterval);
}
// 事件注册方法
public onConnect(cb: ConnectCallback) { this.listeners.connect.push(cb); }
public onDisconnect(cb: DisconnectCallback) { this.listeners.disconnect.push(cb); }
public onError(cb: ErrorCallback) { this.listeners.error.push(cb); }
public onWelcome(cb: WelcomeCallback) { this.listeners.welcome.push(cb); }
public onOrderUpdate(cb: OrderCallback) { this.listeners.order.push(cb); }
public onPositionUpdate(cb: PositionCallback) { this.listeners.position.push(cb); }
public onBalanceUpdate(cb: BalanceCallback) { this.listeners.balance.push(cb); }
}

View File

@ -168,33 +168,83 @@ function mapPmTagToTreeNode(item: PmTagMainItem): CategoryTreeNode {
}
}
/** getPmTagList 响应data 可能为 { All: PmTag[] } 或 PmTag[] */
type GetPmTagListData = PmTagMainItem[] | { All?: PmTagMainItem[] }
/** PmTagCatalog 结构definitions polymarket.PmTagCatalog */
export interface PmTagCatalogItem {
ID?: number
name?: string
tagId?: { slug?: string; label?: string; ID?: number } | number
children?: PmTagCatalogItem[]
level?: number
parentId?: number
sort?: number
isShow?: boolean
createdAt?: string
updatedAt?: string
/** 扩展字段 */
slug?: string
label?: string
icon?: string
sectionTitle?: string
}
/** 将 PmTagCatalogItem 转为 CategoryTreeNode */
function mapCatalogToTreeNode(item: PmTagCatalogItem): CategoryTreeNode {
const rawId = item.ID
const id = rawId != null ? String(rawId) : ''
const tagIdObj =
typeof item.tagId === 'object' && item.tagId != null ? item.tagId : {}
const slug =
item.slug ??
(typeof tagIdObj === 'object' && 'slug' in tagIdObj
? (tagIdObj as { slug?: string }).slug
: undefined) ??
''
const label = item.label ?? item.name ?? ''
const children = Array.isArray(item.children)
? item.children.map(mapCatalogToTreeNode)
: undefined
const icon = item.icon ?? resolveCategoryIcon({ label, slug })
return {
id,
label,
slug,
icon,
sectionTitle: item.sectionTitle,
sort: item.sort,
children: children?.length ? children : undefined,
}
}
/** getPmTagCatalogPublic 响应data 可能为数组、{ All: [] }、{ list: [] } 等 */
type GetPmTagCatalogData =
| PmTagCatalogItem[]
| { All?: PmTagCatalogItem[]; list?: PmTagCatalogItem[] }
/**
* Tab
* GET /PmTag/getPmTagList getPmTagMain
* GET /pmTagCatalog/getPmTagCatalogPublic
*
* data.All data.All
* { All: [] } / { list: [] } All
*/
export async function getPmTagMain(): Promise<CategoryTreeResponse> {
const res = await get<{ code: number; data: GetPmTagListData; msg: string }>(
'/PmTag/getPmTagList'
const res = await get<{ code: number; data: GetPmTagCatalogData; msg: string }>(
'/pmTagCatalog/getPmTagCatalogPublic'
)
// 调试:打印接口原始数据
console.log('[getPmTagList] 原始响应:', JSON.stringify(res, null, 2))
let raw: PmTagMainItem[] = []
console.log('[getPmTagCatalogPublic] 原始响应:', JSON.stringify(res, null, 2))
let raw: PmTagCatalogItem[] = []
const d = res.data
if (d && typeof d === 'object' && !Array.isArray(d) && 'All' in d) {
raw = Array.isArray((d as { All?: PmTagMainItem[] }).All)
? (d as { All: PmTagMainItem[] }).All
: []
if (d && typeof d === 'object' && !Array.isArray(d)) {
const obj = d as { All?: PmTagCatalogItem[]; list?: PmTagCatalogItem[] }
raw = Array.isArray(obj.All)
? obj.All
: Array.isArray(obj.list)
? obj.list
: []
} else if (Array.isArray(d)) {
raw = d
}
let mapped = raw.map(mapPmTagToTreeNode)
let mapped = raw.map(mapCatalogToTreeNode)
mapped = sortBySortField(mapped)
// 第一层不显示,取 All 菜单下的三层作为展示数据(政治、体育、加密等)
const allNode = mapped.find(
(n) =>
n.slug?.toLowerCase() === 'all' ||

View File

@ -15,6 +15,14 @@ export function getClobWsUrl(): string {
return `${protocol}//${url.host}/clob/ws`
}
/** User WebSocket URL订单/持仓/余额推送),与 REST API 同源 */
export function getUserWsUrl(): string {
const base = BASE_URL || (typeof window !== 'undefined' ? window.location.origin : 'https://api.xtrader.vip')
const url = new URL(base)
const protocol = url.protocol === 'https:' ? 'wss:' : 'ws:'
return `${protocol}//${url.host}/clob/ws/user`
}
export interface RequestConfig {
/** 请求头,如 { 'x-token': token, 'x-user-id': userId } */
headers?: Record<string, string>

View File

@ -1,6 +1,8 @@
import { ref, computed } from 'vue'
import { defineStore } from 'pinia'
import { getUsdcBalance, formatUsdcBalance, getUserInfo } from '@/api/user'
import { getUserWsUrl } from '@/api/request'
import { UserSdk, type BalanceData } from '../../sdk/userSocket'
export interface UserInfo {
/** 用户 IDAPI 可能返回 id 或 ID */
@ -47,9 +49,52 @@ export const useUserStore = defineStore('user', () => {
const isLoggedIn = computed(() => !!token.value && !!user.value)
const avatarUrl = computed(() => user.value?.headerImg ?? '')
/** 钱包余额显示,如 "0.00",可从接口更新 */
/** 钱包余额显示,如 "0.00",可从接口或 UserSocket 推送更新 */
const balance = ref<string>('0.00')
let userSdkRef: UserSdk | null = null
// 若从 storage 恢复登录态,自动连接 UserSocket
if (stored?.token && stored?.user) {
// 延迟到 nextTick 后连接,避免 store 未完全初始化
Promise.resolve().then(() => connectUserSocket())
}
function connectUserSocket() {
if (!token.value) return
disconnectUserSocket()
const sdk = new UserSdk({
url: getUserWsUrl(),
token: token.value,
autoReconnect: true,
reconnectInterval: 2000,
})
sdk.onBalanceUpdate((data: BalanceData) => {
const avail = data.available ?? data.amount
if (avail != null) {
balance.value = formatUsdcBalance(String(avail))
}
})
sdk.onConnect(() => {
console.log('[UserStore] UserSocket 已连接')
})
sdk.onDisconnect(() => {
console.log('[UserStore] UserSocket 已断开')
})
sdk.onError((e) => {
console.error('[UserStore] UserSocket 错误:', e)
})
userSdkRef = sdk
sdk.connect()
}
function disconnectUserSocket() {
if (userSdkRef) {
userSdkRef.disconnect()
userSdkRef = null
}
}
function setUser(loginData: { token?: string; user?: UserInfo }) {
const t = loginData.token ?? ''
const raw = loginData.user ?? null
@ -70,11 +115,17 @@ export const useUserStore = defineStore('user', () => {
} else {
user.value = null
}
if (t && user.value) saveToStorage(t, user.value)
else clearStorage()
if (t && user.value) {
saveToStorage(t, user.value)
connectUserSocket()
} else {
clearStorage()
disconnectUserSocket()
}
}
function logout() {
disconnectUserSocket()
token.value = ''
user.value = null
clearStorage()
@ -151,5 +202,7 @@ export const useUserStore = defineStore('user', () => {
getAuthHeaders,
fetchUsdcBalance,
fetchUserInfo,
connectUserSocket,
disconnectUserSocket,
}
})

View File

@ -302,7 +302,7 @@
<script setup lang="ts">
defineOptions({ name: 'Home' })
import { ref, onMounted, onUnmounted, nextTick, computed } from 'vue'
import { ref, onMounted, onUnmounted, onActivated, onDeactivated, nextTick, computed } from 'vue'
import { useDisplay } from 'vuetify'
import MarketCard from '../components/MarketCard.vue'
import TradeComponent from '../components/TradeComponent.vue'
@ -697,7 +697,7 @@ onMounted(() => {
})
})
onUnmounted(() => {
function removeScrollListeners() {
const sentinel = sentinelRef.value
if (observer && sentinel) observer.unobserve(sentinel)
observer = null
@ -706,6 +706,34 @@ onUnmounted(() => {
resizeObserver = null
}
window.removeEventListener('scroll', checkScrollLoad)
}
// keep-alive onUnmounted onDeactivated loadMore
onDeactivated(removeScrollListeners)
onUnmounted(removeScrollListeners)
// observer removeScrollListeners
onActivated(() => {
nextTick(() => {
const sentinel = sentinelRef.value
if (sentinel && !observer) {
observer = new IntersectionObserver(
(entries) => {
if (!entries[0]?.isIntersecting) return
loadMore()
},
{ root: null, rootMargin: `${SCROLL_LOAD_THRESHOLD}px`, threshold: 0 },
)
observer.observe(sentinel)
window.addEventListener('scroll', checkScrollLoad, { passive: true })
}
const listEl = listRef.value
if (listEl && !resizeObserver) {
updateGridColumns()
resizeObserver = new ResizeObserver(updateGridColumns)
resizeObserver.observe(listEl)
}
})
})
</script>