新增:websocket订单薄对接

This commit is contained in:
ivan 2026-02-25 14:46:10 +08:00
parent 41b349fd09
commit 2cb64ddb09
17 changed files with 1086 additions and 71 deletions

2
.env
View File

@ -1,6 +1,6 @@
# API 基础地址,不设置时默认 https://api.xtrader.vip
# 连接测试服务器 192.168.3.21:8888 时复制本文件为 .env 或 .env.local 并取消下一行注释:
# VITE_API_BASE_URL=http://192.168.3.21:8888
# VITE_API_BASE_URL=http://10.117.63.212:8888
# SSH 部署npm run deploy可选覆盖
# DEPLOY_HOST=38.246.250.238

View File

@ -9,6 +9,7 @@ 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`
- GET 请求:支持 query 参数,自动序列化
- POST 请求:支持 JSON body
- 自定义 headers通过 `RequestConfig.headers` 传入
@ -16,7 +17,10 @@ HTTP 请求基础封装,提供 `get` 和 `post` 方法,支持自定义请求
## 使用方式
```typescript
import { get, post } from '@/api/request'
import { get, post, getClobWsUrl } from '@/api/request'
// CLOB WebSocket URL与 REST 同源)
const wsUrl = getClobWsUrl() // e.g. wss://api.xtrader.vip/api/clob/ws
// GET 请求
const data = await get<MyResponse>('/path', { page: 1, keyword: 'x' })

View File

@ -4,24 +4,44 @@
## 功能用途
订单簿组件,展示 Asks卖单与 Bids买单列表含价格、份额、累计总量以及横向进度条表示深度。当前使用 mock 数据。
订单簿组件,展示 Asks卖单与 Bids买单列表含价格、份额、累计总量以及横向进度条表示深度。支持通过 props 接收 CLOB WebSocket 实时数据,无 props 时回退到 mock 数据。
## 核心能力
- Trade Up / Trade Down Tab
- Asks、Bids 列表,带 `HorizontalProgressBar` 深度条
- Last price、Spread 展示
- Live / 连接中 状态展示
## Props
| 属性 | 类型 | 默认值 | 说明 |
|------|------|--------|------|
| asks | `{ price: number; shares: number }[]` | `[]` | 卖单列表(价格单位:¢) |
| bids | `{ price: number; shares: number }[]` | `[]` | 买单列表 |
| lastPrice | `number` | - | 最新成交价(¢) |
| spread | `number` | - | 买卖价差(¢) |
| loading | `boolean` | `false` | 是否连接中 |
| connected | `boolean` | `false` | 是否已连接 WebSocket |
## 使用方式
```vue
<!-- 由父组件传入 CLOB 数据(如 TradeDetail.vue -->
<OrderBook
:asks="orderBookAsks"
:bids="orderBookBids"
:last-price="clobLastPrice"
:spread="clobSpread"
:loading="clobLoading"
:connected="clobConnected"
/>
<!-- 无 props 时使用 mock 数据 -->
<OrderBook />
```
当前为展示组件,无 props。后续可扩展为接收 `marketId` 等,从接口或 WebSocket 获取真实数据。
## 扩展方式
1. **真实数据**:接收 `asks``bids` props或内部根据路由/context 拉取
2. **WebSocket**:订阅订单簿推送,实时更新
3. **点击下单**:点击某行价格时,将价格传入 TradeComponent
1. **点击下单**:点击某行价格时,将价格传入 TradeComponent
2. **Trade Up/Down**:可按 Yes/No Token 切换展示不同订单簿

View File

@ -10,7 +10,7 @@
## 核心能力
- 分时图ECharts 渲染,支持 Past、时间粒度切换
- 订单簿:`OrderBook` 组件
- 订单簿:`OrderBook` 组件,通过 **ClobSdk** 对接 CLOB WebSocket 实时数据(全量快照、增量更新、成交推送)
- 交易:`TradeComponent`,传入 `market``initialOption`
- 移动端:底部栏 + `v-bottom-sheet` 嵌入 `TradeComponent`
- Merge/Split通过 `TradeComponent` 或底部菜单触发
@ -22,7 +22,8 @@
## 扩展方式
1. **实时数据**:订单簿、分时图接入 WebSocket
2. **Comments**:对接评论接口,替换 placeholder
3. **Top Holders**:对接持仓接口
4. **Activity**:对接交易活动接口,替换 mock 数据
1. **订单簿**:已通过 `sdk/clobSocket.ts` 的 ClobSdk 对接 CLOB WebSocket使用 **Yes/No token ID** 订阅 `price_size_all``price_size_delta``trade` 消息
2. **分时图**:可接入 WebSocket 推送的图表数据
3. **Comments**:对接评论接口,替换 placeholder
4. **Top Holders**:对接持仓接口
5. **Activity**:已对接 CLOB `trade` 消息,实时追加成交记录

4
package-lock.json generated
View File

@ -16,7 +16,8 @@
"siwe": "^3.0.0",
"vue": "^3.5.27",
"vue-router": "^5.0.1",
"vuetify": "^4.0.0-beta.0"
"vuetify": "^4.0.0-beta.0",
"ws": "^8.19.0"
},
"devDependencies": {
"@playwright/test": "^1.58.1",
@ -9085,7 +9086,6 @@
"version": "8.19.0",
"resolved": "https://registry.npmmirror.com/ws/-/ws-8.19.0.tgz",
"integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=10.0.0"

View File

@ -26,7 +26,8 @@
"siwe": "^3.0.0",
"vue": "^3.5.27",
"vue-router": "^5.0.1",
"vuetify": "^4.0.0-beta.0"
"vuetify": "^4.0.0-beta.0",
"ws": "^8.19.0"
},
"devDependencies": {
"@playwright/test": "^1.58.1",

0
sdk/.gitkeep Normal file
View File

296
sdk/clobSocket.ts Normal file
View File

@ -0,0 +1,296 @@
// 前端 CLOB WebSocket SDK
// 该 SDK 提供了连接到 CLOB WebSocket 服务的 TypeScript 接口。
// 它支持针对不同消息类型的特定回调:
// - Welcome (连接建立)
// - PriceSizeAll (全量订单簿快照)
// - PriceSizeDelta (增量订单簿更新)
// - Trade (成交记录)
export interface ClobSocketOptions {
// WebSocket URL (例如: "ws://localhost:8888/clob/ws" 或 "/api/clob/ws")
url?: string;
// 断开连接时是否自动重连
autoReconnect?: boolean;
// 重连间隔时间(毫秒)
reconnectInterval?: number;
// 最大重连尝试次数
maxReconnectAttempts?: number;
}
// 基于 Go 结构体的消息接口定义
export interface WelcomeMsg {
type: 'welcome';
t: number;
}
export interface PriceSizePolyMsg {
i: number; // 索引
e: 'price_size_all' | 'price_size_delta'; // 事件类型
m: string; // 市场 ID
t: number; // 时间戳
b?: Record<string, number>; // 买单: 价格 -> 数量
s?: Record<string, number>; // 卖单: 价格 -> 数量
}
export interface TradePolyMsg {
e: 'trade'; // 事件类型
m: string; // 市场 ID
p: string; // 价格
s: string; // 数量
side: string; // "buy" 或 "sell"
}
export type ClobMsg = WelcomeMsg | PriceSizePolyMsg | TradePolyMsg;
// 回调函数类型定义
export type WelcomeCallback = (data: WelcomeMsg) => void;
export type PriceSizeAllCallback = (data: PriceSizePolyMsg) => void;
export type PriceSizeDeltaCallback = (data: PriceSizePolyMsg) => void;
export type TradeCallback = (data: TradePolyMsg) => void;
export type ConnectCallback = (event: Event) => void;
export type DisconnectCallback = (event: CloseEvent) => void;
export type ErrorCallback = (event: Event) => void;
export class ClobSdk {
private url: string;
private tokenIds: string[];
private ws: WebSocket | null = null;
private autoReconnect: boolean;
private reconnectInterval: number;
private maxReconnectAttempts: number;
private reconnectAttempts = 0;
private isExplicitClose = false;
private listeners: {
welcome: WelcomeCallback[];
priceSizeAll: PriceSizeAllCallback[];
priceSizeDelta: PriceSizeDeltaCallback[];
trade: TradeCallback[];
connect: ConnectCallback[];
disconnect: DisconnectCallback[];
error: ErrorCallback[];
} = {
welcome: [],
priceSizeAll: [],
priceSizeDelta: [],
trade: [],
connect: [],
disconnect: [],
error: []
};
/**
* CLOB SDK
* @param tokenIds Yes/No token ID [yesTokenId, noTokenId]
* @param options
*/
constructor(tokenIds: string | string[], options: ClobSocketOptions = {}) {
this.tokenIds = Array.isArray(tokenIds) ? tokenIds : [tokenIds];
// 如果未提供 URL默认为相对路径
// 修改为 Node.js 测试环境适配:如果 window 未定义,回退到 localhost
let protocol = 'ws:';
let host = '10.117.63.212:8888';
if (typeof window !== 'undefined' && window.location) {
protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
host = window.location.host;
}
this.url = options.url || `${protocol}//${host}/clob/ws`;
this.autoReconnect = options.autoReconnect ?? true;
this.reconnectInterval = options.reconnectInterval || 3000;
this.maxReconnectAttempts = options.maxReconnectAttempts || 10;
console.log('[ClobSdk] 构造完成,最终参数:', {
tokenIds: this.tokenIds,
url: this.url,
autoReconnect: this.autoReconnect,
reconnectInterval: this.reconnectInterval,
maxReconnectAttempts: this.maxReconnectAttempts,
});
}
/**
* WebSocket
*/
public connect(): void {
this.isExplicitClose = false;
try {
this.ws = new WebSocket(this.url);
this.ws.onopen = (event: any) => {
console.log(`[ClobSdk] 已连接到 ${this.url}`);
this.reconnectAttempts = 0;
this.notifyConnect(event);
this.subscribe();
};
this.ws.onmessage = (event: any) => {
try {
const rawData = JSON.parse(event.data);
// console.log('[ClobSdk] 收到消息:', rawData);
this.handleMessage(rawData);
} catch (e) {
console.error('[ClobSdk] 消息解析失败:', e);
}
};
this.ws.onclose = (event: any) => {
console.log('[ClobSdk] 连接关闭', { code: event.code, reason: event.reason, explicit: this.isExplicitClose });
if (!this.isExplicitClose) {
this.notifyDisconnect(event);
if (this.autoReconnect) {
this.attemptReconnect();
}
} else {
this.notifyDisconnect(event);
}
};
this.ws.onerror = (event: any) => {
console.error('[ClobSdk] 错误:', event);
this.notifyError(event);
};
} catch (e) {
console.error('[ClobSdk] 连接错误:', e);
if (this.autoReconnect) {
this.attemptReconnect();
}
}
}
/**
* WebSocket
*/
public disconnect(): void {
this.isExplicitClose = true;
if (this.ws) {
this.ws.close();
this.ws = null;
}
}
// --- 事件监听器 ---
public onWelcome(callback: WelcomeCallback): () => void {
this.listeners.welcome.push(callback);
return () => this.removeListener('welcome', callback);
}
public onPriceSizeAll(callback: PriceSizeAllCallback): () => void {
this.listeners.priceSizeAll.push(callback);
return () => this.removeListener('priceSizeAll', callback);
}
public onPriceSizeDelta(callback: PriceSizeDeltaCallback): () => void {
this.listeners.priceSizeDelta.push(callback);
return () => this.removeListener('priceSizeDelta', callback);
}
public onTrade(callback: TradeCallback): () => void {
this.listeners.trade.push(callback);
return () => this.removeListener('trade', callback);
}
public onConnect(callback: ConnectCallback): () => void {
this.listeners.connect.push(callback);
return () => this.removeListener('connect', callback);
}
public onDisconnect(callback: DisconnectCallback): () => void {
this.listeners.disconnect.push(callback);
return () => this.removeListener('disconnect', callback);
}
public onError(callback: ErrorCallback): () => void {
this.listeners.error.push(callback);
return () => this.removeListener('error', callback);
}
private removeListener(type: keyof typeof this.listeners, callback: any): void {
this.listeners[type] = (this.listeners[type] as any[]).filter((cb: any) => cb !== callback);
}
// --- 内部方法 ---
private subscribe(): void {
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
const msg = {
type: 'market',
assets_ids: this.tokenIds
};
this.ws.send(JSON.stringify(msg));
console.log('[ClobSdk] 已订阅 tokenIds:', this.tokenIds);
}
}
private handleMessage(data: any): void {
// Polymarket 消息有时是数组,有时是对象 (welcome)
if (Array.isArray(data)) {
data.forEach(msg => this.dispatchMessage(msg));
} else {
this.dispatchMessage(data);
}
}
private dispatchMessage(msg: any): void {
// 1. Welcome 消息
if (msg.type === 'welcome') {
console.log('[ClobSdk] 回调: welcome', msg);
this.listeners.welcome.forEach(cb => cb(msg as WelcomeMsg));
return;
}
// 2. 基于 'e' 字段的事件
switch (msg.e) {
case 'price_size_all':
console.log('[ClobSdk] 回调: price_size_all', { m: msg.m, t: msg.t, bids: msg.b, asks: msg.s });
this.listeners.priceSizeAll.forEach(cb => cb(msg as PriceSizePolyMsg));
break;
case 'price_size_delta':
console.log('[ClobSdk] 回调: price_size_delta', { m: msg.m, t: msg.t, bids: msg.b, asks: msg.s });
this.listeners.priceSizeDelta.forEach(cb => cb(msg as PriceSizePolyMsg));
break;
case 'trade':
// console.log('[ClobSdk] 回调: trade', { m: msg.m, p: msg.p, s: msg.s, side: msg.side });
this.listeners.trade.forEach(cb => cb(msg as TradePolyMsg));
break;
default:
console.warn('[ClobSdk] 未知消息类型:', msg);
}
}
private notifyConnect(event: Event): void {
console.log('[ClobSdk] 回调: onConnect');
this.listeners.connect.forEach(cb => cb(event));
}
private notifyDisconnect(event: CloseEvent): void {
console.log('[ClobSdk] 回调: onDisconnect', { code: event.code, reason: event.reason });
this.listeners.disconnect.forEach(cb => cb(event));
}
private notifyError(event: Event): void {
console.log('[ClobSdk] 回调: onError', event);
this.listeners.error.forEach(cb => cb(event));
}
private attemptReconnect(): void {
if (this.reconnectAttempts >= this.maxReconnectAttempts) {
console.error('[ClobSdk] 达到最大重连次数。停止重连。');
return;
}
this.reconnectAttempts++;
console.log(`[ClobSdk] ${this.reconnectInterval}ms 后尝试重连 (尝试次数 ${this.reconnectAttempts}/${this.maxReconnectAttempts})...`);
setTimeout(() => {
this.connect();
}, this.reconnectInterval);
}
}

91
sdk/index.ts Normal file
View File

@ -0,0 +1,91 @@
import { ClobSdk, PriceSizePolyMsg, TradePolyMsg, WelcomeMsg } from './clobSocket';
import WebSocket from 'ws';
// 模拟浏览器环境
// 因为 ClobSdk 主要是为前端设计的,这里需要在 Node.js 环境中注入 WebSocket 全局对象
(global as any).WebSocket = WebSocket;
(global as any).window = undefined; // 确保在构造函数中命中回退路径 (fallback path)
// 配置
// 从环境变量中获取配置TOKEN_IDS 为逗号分隔的 Yes/No token ID
const TOKEN_IDS = process.env.TOKEN_IDS
? process.env.TOKEN_IDS.split(',').map((s) => s.trim()).filter(Boolean)
: ['token_yes', 'token_no']
const WS_URL = process.env.WS_URL || "ws://localhost:8888/clob/ws";
console.log(`正在启动 CLOB WebSocket 测试客户端...`);
console.log(`目标 URL: ${WS_URL}`);
console.log(`Token IDs: ${TOKEN_IDS.join(', ')}`);
// 初始化 SDK
const sdk = new ClobSdk(TOKEN_IDS, {
url: WS_URL,
autoReconnect: true, // 启用自动重连
reconnectInterval: 2000 // 重连间隔 2 秒
});
// 注册回调函数
// 1. 连接建立事件
sdk.onConnect(() => {
console.log('✅ 已连接到 WebSocket 服务器');
});
// 2. Welcome 消息 (连接成功后服务器发送的第一条消息)
sdk.onWelcome((msg: WelcomeMsg) => {
console.log('👋 收到 Welcome 消息:', msg);
});
// 3. 全量订单簿快照 (PriceSizeAll)
// 当订阅成功时,通常会收到一次全量快照
sdk.onPriceSizeAll((msg: PriceSizePolyMsg) => {
console.log('\n📚 收到全量订单簿快照:');
console.log(` 市场: ${msg.m}`);
console.log(` 时间戳: ${msg.t}`);
console.log(` 买单 (Buys): ${JSON.stringify(msg.b)}`);
console.log(` 卖单 (Sells): ${JSON.stringify(msg.s)}`);
});
// 4. 增量更新 (PriceSizeDelta)
// 当订单簿发生变化时,会收到增量更新
sdk.onPriceSizeDelta((msg: PriceSizePolyMsg) => {
console.log('\n🔄 收到订单簿增量更新:');
console.log(` 市场: ${msg.m}`);
console.log(` 时间戳: ${msg.t}`);
if (msg.b) console.log(` 买单更新: ${JSON.stringify(msg.b)}`);
if (msg.s) console.log(` 卖单更新: ${JSON.stringify(msg.s)}`);
});
// 5. 成交记录 (Trade Execution)
// 当发生撮合交易时触发
sdk.onTrade((msg: TradePolyMsg) => {
console.log('\n💰 交易成交:');
console.log(` 市场: ${msg.m}`);
console.log(` 价格: ${msg.p}`);
console.log(` 数量: ${msg.s}`);
console.log(` 方向: ${msg.side}`);
});
// 6. 错误处理
sdk.onError((err: Event) => {
console.error('❌ WebSocket 错误:', err);
});
// 7. 断开连接
sdk.onDisconnect((event: CloseEvent) => {
console.log(`⚠️ 连接断开: 代码 ${event.code}`);
});
// 开始连接
sdk.connect();
// 保持进程运行 (防止 Node.js 脚本立即退出)
process.stdin.resume();
// 处理退出信号 (Ctrl+C)
process.on('SIGINT', () => {
console.log('\n正在断开连接...');
sdk.disconnect();
process.exit();
});

320
sdk/loadTest.ts Normal file
View File

@ -0,0 +1,320 @@
import axios from 'axios';
import WebSocket from 'ws';
// 配置
const CONFIG = {
baseURL: 'http://localhost:8888', // API 基础地址
wsURL: 'ws://localhost:8888/clob/ws', // WebSocket 地址
marketId: 'market_123', // 默认市场 ID (如果没有从 API 获取到)
tokenId: 'market_123', // 默认 Token ID (如果没有从 API 获取到)
userCount: 20, // 并发用户数量
ordersPerUser: 100, // 每个用户的下单总数
orderIntervalMs: 1000, // 下单间隔 (毫秒)
basePrice: 5000, // 基准价格 (例如: 0.50,如果精度是 10000)
priceSpread: 10, // 价格波动范围 (+/- 10 个步长,每个步长 100)
baseSize: 1000000, // 基准数量 (1 个单位)
sizeSpread: 5 // 数量波动范围 (+/- 5 个单位)
};
// 市场响应接口定义
interface PmMarket {
ID: number;
clobTokenIds: string[]; // 数据库中可能是 JSON 字符串,但 API 可能返回解析后的对象或数组
}
// 用户会话状态接口
interface UserSession {
index: number; // 用户索引 (1-based)
walletAddress: string; // 钱包地址
token: string; // 登录 Token
userId: number; // 用户 ID
ordersPlaced: number; // 已下单数量
successCount: number; // 下单成功数量
failCount: number; // 下单失败数量
wsMessages: number; // 接收到的 WebSocket 消息数量
totalLatency: number; // 总延迟 (毫秒,用于计算平均值)
intervalId?: NodeJS.Timeout;// 定时器 ID
ws?: WebSocket; // WebSocket 连接实例
}
// 生成确定性的测试钱包地址
// 格式: 0x0000... + 索引 (补全到 40 个字符)
// 这种格式用于配合后端的测试模式,绕过签名验证
function getTestWalletAddress(index: number): string {
const hexIndex = index.toString(16).padStart(36, '0');
return `0x0000${hexIndex}`;
}
// 用户登录
// 使用测试钱包地址登录,获取 Token
async function loginUser(index: number): Promise<UserSession | null> {
const walletAddress = getTestWalletAddress(index + 1); // 从 1 开始编号
try {
// 准备登录负载
// 服务器逻辑: 如果是测试环境且钱包地址以 "0x0000" 开头,则跳过签名验证
const payload = {
walletAddress: walletAddress,
nonce: "test_nonce",
signature: "test_signature",
message: "test_message"
};
const response = await axios.post(`${CONFIG.baseURL}/base/walletLogin`, payload);
if (response.data.code === 0 && response.data.data) {
const userData = response.data.data.user;
const token = response.data.data.token;
// console.log(`[用户 ${index + 1}] 登录成功。ID: ${userData.ID}`);
return {
index: index + 1,
walletAddress,
token,
userId: userData.ID,
ordersPlaced: 0,
successCount: 0,
failCount: 0,
wsMessages: 0,
totalLatency: 0
};
} else {
console.error(`[用户 ${index + 1}] 登录失败:`, response.data.msg);
return null;
}
} catch (error: any) {
console.error(`[用户 ${index + 1}] 登录错误:`, error.message);
return null;
}
}
// 建立 WebSocket 连接并订阅市场
function connectWs(session: UserSession, tokenId: string) {
// 当前系统 WebSocket 连接不需要鉴权 (基于 public group)
const ws = new WebSocket(CONFIG.wsURL);
ws.on('open', () => {
// 连接打开后发送订阅消息
const msg = {
type: "market",
assets_ids: [tokenId]
};
ws.send(JSON.stringify(msg));
});
ws.on('message', (data: WebSocket.Data) => {
session.wsMessages++;
// const msg = JSON.parse(data.toString());
// console.log(`[用户 ${session.index}] WS 消息:`, msg);
});
ws.on('error', (err) => {
// console.error(`[用户 ${session.index}] WS 错误:`, err.message);
});
session.ws = ws;
}
// 执行下单操作
async function placeOrder(session: UserSession) {
// 检查是否达到下单上限
if (session.ordersPlaced >= CONFIG.ordersPerUser) {
if (session.intervalId) {
clearInterval(session.intervalId);
}
if (session.ws) {
session.ws.close();
}
return;
}
// 方向: 随机买入/卖出 (1=买, 2=卖)
const isBuy = Math.random() > 0.5;
const side = isBuy ? 1 : 2;
// 价格: 基准价格 +/- 随机波动 (步长 100)
// 5000 +/- (0..10)*100
const priceOffset = Math.floor(Math.random() * CONFIG.priceSpread) * 100;
const price = isBuy ? CONFIG.basePrice - priceOffset : CONFIG.basePrice + priceOffset;
// 数量: 基准数量 +/- 随机波动 (步长 1000000)
const sizeOffset = Math.floor(Math.random() * CONFIG.sizeSpread) * 1000000;
const size = CONFIG.baseSize + sizeOffset;
const payload = {
tokenID: CONFIG.tokenId,
side: side,
price: price,
size: size,
feeRateBps: 10,
nonce: Date.now(), // 简单的 nonce
expiration: 0, // 0 表示不过期 (GTC)
orderType: 0 // 0: GTC (Good Till Cancel)
};
const start = Date.now();
try {
const response = await axios.post(`${CONFIG.baseURL}/clob/gateway/submitOrder`, payload, {
headers: {
'x-token': session.token
}
});
const latency = Date.now() - start;
session.totalLatency += latency;
session.ordersPlaced++;
if (response.data.code === 0) {
session.successCount++;
} else {
session.failCount++;
// 仅打印前 5 个错误,避免日志刷屏
if (session.failCount <= 5) {
console.error(`[用户 ${session.index}] 下单失败:`, response.data.msg);
}
}
} catch (error: any) {
session.ordersPlaced++;
session.failCount++;
// 仅打印前 5 个错误
if (session.failCount <= 5) {
let msg = error.message;
if (error.response) {
msg += ` 状态码: ${error.response.status}`;
}
console.error(`[用户 ${session.index}] 下单错误:`, msg);
}
}
}
// 主函数:运行负载测试
async function runLoadTest() {
console.log(`开始负载测试,并发用户数: ${CONFIG.userCount}...`);
console.log(`目标: 每用户 ${CONFIG.ordersPerUser}`);
console.log(`速率: 每 ${CONFIG.orderIntervalMs}ms 一单`);
// 1. 所有用户登录
const sessions: UserSession[] = [];
const loginPromises = [];
for (let i = 0; i < CONFIG.userCount; i++) {
loginPromises.push(loginUser(i).then(session => {
if (session) sessions.push(session);
}));
// 稍微错开登录时间,避免瞬间压垮数据库连接池
await new Promise(r => setTimeout(r, 50));
}
await Promise.all(loginPromises);
console.log(`\n成功登录 ${sessions.length}/${CONFIG.userCount} 个用户。`);
// 2. 获取市场列表以找到有效的 Token ID
if (sessions.length > 0) {
try {
console.log("正在获取市场列表以查找有效 Token ID...");
const marketRes = await axios.get(`${CONFIG.baseURL}/PmMarket/getPmMarketList?page=1&pageSize=1`, {
headers: {
'x-token': sessions[0].token
}
});
if (marketRes.data.code === 0 && marketRes.data.data.list && marketRes.data.data.list.length > 0) {
const market = marketRes.data.data.list[0];
console.log(`找到市场: ${market.ID} - ${market.question}`);
// 解析 clobTokenIds
let tokenIds = market.clobTokenIds;
if (typeof tokenIds === 'string') {
try {
tokenIds = JSON.parse(tokenIds);
} catch (e) {
// console.error("解析 clobTokenIds JSON 失败:", e);
}
}
if (Array.isArray(tokenIds) && tokenIds.length > 0) {
CONFIG.tokenId = tokenIds[0];
CONFIG.marketId = market.ID.toString();
console.log(`使用配置 -> MarketID: ${CONFIG.marketId}, TokenID: ${CONFIG.tokenId}`);
} else {
console.error("市场没有有效的 clobTokenIds 数组:", tokenIds);
}
} else {
console.error("未找到市场或 API 错误:", marketRes.data);
}
} catch (error: any) {
console.error("获取市场列表失败:", error.message);
}
}
// 3. 所有用户建立 WebSocket 连接
console.log("正在连接 WebSocket...");
sessions.forEach(s => connectWs(s, CONFIG.tokenId));
console.log('开始下单循环...\n');
const startTime = Date.now();
// 4. 启动下单循环
// 使用 Promise 跟踪每个用户的任务完成情况
const userTasks = sessions.map(session => {
return new Promise<void>((resolve) => {
// 初始随机延迟,错开启动时间
setTimeout(() => {
session.intervalId = setInterval(async () => {
if (session.ordersPlaced >= CONFIG.ordersPerUser) {
if (session.intervalId) clearInterval(session.intervalId);
if (session.ws) session.ws.close();
resolve();
return;
}
await placeOrder(session);
}, CONFIG.orderIntervalMs);
}, Math.random() * 1000); // 1秒内的随机延迟
});
});
// 5. 状态监控 (每秒刷新)
const monitorInterval = setInterval(() => {
const totalPlaced = sessions.reduce((sum, s) => sum + s.ordersPlaced, 0);
const totalSuccess = sessions.reduce((sum, s) => sum + s.successCount, 0);
const totalFail = sessions.reduce((sum, s) => sum + s.failCount, 0);
const totalWs = sessions.reduce((sum, s) => sum + s.wsMessages, 0);
const totalLatency = sessions.reduce((sum, s) => sum + s.totalLatency, 0);
const avgLatency = totalSuccess > 0 ? (totalLatency / totalSuccess).toFixed(0) : "0";
const elapsed = (Date.now() - startTime) / 1000;
const rate = elapsed > 0 ? (totalPlaced / elapsed).toFixed(1) : "0.0";
// 覆盖当前行输出状态
process.stdout.write(`\r[状态] 时间: ${elapsed.toFixed(1)}s | 订单: ${totalPlaced}/${CONFIG.userCount * CONFIG.ordersPerUser} | 速率: ${rate} ops | 成功: ${totalSuccess} | 失败: ${totalFail} | WS消息: ${totalWs} | 延迟: ${avgLatency}ms`);
if (totalPlaced >= CONFIG.userCount * CONFIG.ordersPerUser) {
clearInterval(monitorInterval);
process.stdout.write('\n'); // 完成后换行
}
}, 1000);
// 等待所有用户完成
await Promise.all(userTasks);
const endTime = Date.now();
const duration = (endTime - startTime) / 1000;
console.log('\n\n负载测试完成!');
console.log(`总耗时: ${duration.toFixed(2)}s`);
const totalSuccess = sessions.reduce((sum, s) => sum + s.successCount, 0);
const totalFail = sessions.reduce((sum, s) => sum + s.failCount, 0);
const totalWs = sessions.reduce((sum, s) => sum + s.wsMessages, 0);
console.log(`总订单数: ${totalSuccess + totalFail} (成功: ${totalSuccess}, 失败: ${totalFail})`);
console.log(`总共接收 WS 消息数: ${totalWs}`);
}
// 运行测试
runLoadTest().catch(console.error);

View File

@ -146,8 +146,8 @@ export async function getPmEventPublic(
if (tokenid != null) {
query.tokenid = Array.isArray(tokenid) ? tokenid : [tokenid]
}
if (tagId != null && Number.isFinite(tagId)) query.tagId = tagId
if (tagSlug != null && tagSlug !== '') query.tagSlug = tagSlug
// if (tagId != null && Number.isFinite(tagId)) query.tagId = tagId
// if (tagSlug != null && tagSlug !== '') query.tagSlug = tagSlug
return get<PmEventListResponse>('/PmEvent/getPmEventPublic', query)
}

View File

@ -7,6 +7,14 @@ const BASE_URL =
? (import.meta as unknown as { env: Record<string, string> }).env.VITE_API_BASE_URL
: 'https://api.xtrader.vip'
/** CLOB WebSocket URL与 REST API 同源 */
export function getClobWsUrl(): 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`
}
export interface RequestConfig {
/** 请求头,如 { 'x-token': token, 'x-user-id': userId } */
headers?: Record<string, string>

View File

@ -4,7 +4,12 @@
<div class="order-book-header">
<h3 class="order-book-title">Order Book</h3>
<div class="order-book-vol">
$4.4k Vol.
<span v-if="connected" class="live-badge">
<span class="live-dot"></span>
Live
</span>
<span v-else-if="loading" class="loading-badge">连接中...</span>
<span v-else class="vol-text">$4.4k Vol.</span>
<v-icon size="14" class="order-book-icon">mdi-chevron-up</v-icon>
</div>
</div>
@ -67,23 +72,45 @@
<!-- Footer -->
<div class="order-book-footer">
<div class="last-price">Last: {{ lastPrice }}¢</div>
<div class="spread">Spread: {{ spread }}¢</div>
<div class="last-price">Last: {{ displayLastPrice }}¢</div>
<div class="spread">Spread: {{ displaySpread }}¢</div>
</div>
</v-card>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import { ref, computed, watch } from 'vue'
import HorizontalProgressBar from './HorizontalProgressBar.vue'
export interface OrderBookRow {
price: number
shares: number
}
const props = withDefaults(
defineProps<{
asks?: OrderBookRow[]
bids?: OrderBookRow[]
lastPrice?: number
spread?: number
loading?: boolean
connected?: boolean
}>(),
{
asks: () => [],
bids: () => [],
lastPrice: undefined,
spread: undefined,
loading: false,
connected: false,
},
)
// State
const activeTrade = ref('up')
const lastPrice = ref(37)
const spread = ref(1)
// Mock asks data ()
const asks = ref([
// 使 props 退 mock
const internalAsks = ref<OrderBookRow[]>([
{ price: 45, shares: 1000.0 },
{ price: 44, shares: 2500.0 },
{ price: 43, shares: 1800.0 },
@ -94,9 +121,7 @@ const asks = ref([
{ price: 38, shares: 500.0 },
{ price: 37, shares: 300.0 },
])
// Mock bids data ()
const bids = ref([
const internalBids = ref<OrderBookRow[]>([
{ price: 36, shares: 200.0 },
{ price: 35, shares: 500.0 },
{ price: 34, shares: 1000.0 },
@ -110,36 +135,54 @@ const bids = ref([
{ price: 26, shares: 1500.0 },
{ price: 25, shares: 1000.0 },
])
const internalLastPrice = ref(37)
const internalSpread = ref(1)
// Simulate dynamic data updates
setInterval(() => {
// Update random ask price and shares
const randomAskIndex = Math.floor(Math.random() * asks.value.length)
const askItem = asks.value[randomAskIndex]
// 使 props mock
const asks = computed(() =>
props.asks?.length ? props.asks : internalAsks.value,
)
const bids = computed(() =>
props.bids?.length ? props.bids : internalBids.value,
)
const displayLastPrice = computed(() =>
props.lastPrice ?? internalLastPrice.value,
)
const displaySpread = computed(() => props.spread ?? internalSpread.value)
// mock
let mockInterval: ReturnType<typeof setInterval> | undefined
watch(
() => props.connected || (props.asks?.length ?? 0) > 0,
(hasRealData) => {
if (hasRealData && mockInterval) {
clearInterval(mockInterval)
mockInterval = undefined
} else if (!hasRealData && !mockInterval) {
mockInterval = setInterval(() => {
const randomAskIndex = Math.floor(Math.random() * internalAsks.value.length)
const askItem = internalAsks.value[randomAskIndex]
if (askItem) {
asks.value[randomAskIndex] = {
internalAsks.value[randomAskIndex] = {
price: askItem.price,
shares: Math.max(0, askItem.shares + Math.floor(Math.random() * 100) - 50),
}
}
// Update random bid price and shares
const randomBidIndex = Math.floor(Math.random() * bids.value.length)
const bidItem = bids.value[randomBidIndex]
const randomBidIndex = Math.floor(Math.random() * internalBids.value.length)
const bidItem = internalBids.value[randomBidIndex]
if (bidItem) {
bids.value[randomBidIndex] = {
internalBids.value[randomBidIndex] = {
price: bidItem.price,
shares: Math.max(0, bidItem.shares + Math.floor(Math.random() * 100) - 50),
}
}
// Update last price
lastPrice.value = lastPrice.value + Math.floor(Math.random() * 3) - 1
// Update spread
spread.value = spread.value + Math.floor(Math.random() * 2) - 1
if (spread.value < 1) spread.value = 1
}, 2000) // Update every 2 seconds
internalLastPrice.value = Math.max(1, Math.min(99, internalLastPrice.value + Math.floor(Math.random() * 3) - 1))
internalSpread.value = Math.max(1, internalSpread.value + Math.floor(Math.random() * 2) - 1)
}, 2000)
}
},
{ immediate: true },
)
// Calculate cumulative total for asks
const asksWithCumulativeTotal = computed(() => {
@ -238,6 +281,41 @@ const maxBidsTotal = computed(() => {
color: #666666;
}
.live-badge {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 13px;
color: #059669;
}
.live-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background-color: #059669;
animation: live-pulse 1.5s ease-in-out infinite;
}
@keyframes live-pulse {
0%,
100% {
opacity: 1;
}
50% {
opacity: 0.5;
}
}
.loading-badge {
font-size: 13px;
color: #6b7280;
}
.vol-text {
margin-right: 4px;
}
.trade-tabs-container {
display: flex;
align-items: center;

View File

@ -1718,9 +1718,16 @@ async function submitOrder() {
orderError.value = '请先登录'
return
}
const uid = userStore.user?.id ?? userStore.user?.ID
const userIdNum = uid != null ? Number(uid) : 0
const uid = userStore?.user?.ID ?? 0
console.log('[submitOrder] 用户信息: user=', userStore.user, 'uid=', uid)
const userIdNum =
typeof uid === 'number'
? uid
: uid != null
? parseInt(String(uid), 10)
: 0
if (!Number.isFinite(userIdNum) || userIdNum <= 0) {
console.warn('[submitOrder] 用户信息异常: user=', userStore.user, 'uid=', uid)
orderError.value = '用户信息异常'
return
}

View File

@ -52,10 +52,25 @@ export const useUserStore = defineStore('user', () => {
function setUser(loginData: { token?: string; user?: UserInfo }) {
const t = loginData.token ?? ''
const u = loginData.user ?? null
const raw = loginData.user ?? null
token.value = t
user.value = u
if (t && u) saveToStorage(t, u)
if (raw) {
const rawId = raw.ID ?? raw.id
const numId =
typeof rawId === 'number'
? rawId
: rawId != null
? parseInt(String(rawId), 10)
: undefined
user.value = {
...raw,
id: rawId ?? numId,
ID: Number.isFinite(numId) ? numId : raw.ID,
}
} else {
user.value = null
}
if (t && user.value) saveToStorage(t, user.value)
else clearStorage()
}
@ -67,8 +82,8 @@ export const useUserStore = defineStore('user', () => {
/** 鉴权请求头x-token 与 x-user-id未登录时返回 undefined */
function getAuthHeaders(): Record<string, string> | undefined {
if (!token.value || !user.value) return undefined
const uid = user.value.id ?? user.value.ID
if (!token.value) return undefined
const uid = user.value?.id ?? user.value?.ID
return {
'x-token': token.value,
...(uid != null && uid !== '' && { 'x-user-id': String(uid) }),
@ -97,16 +112,27 @@ export const useUserStore = defineStore('user', () => {
const res = await getUserInfo(headers)
console.log('[fetchUserInfo] 接口响应:', JSON.stringify(res, null, 2))
const data = res.data as Record<string, unknown> | undefined
const u = (data?.user ?? data) as Record<string, unknown>
// 接口返回 data.userInfo 或 data.user取实际用户对象若仍含 userInfo 则再取一层
let u = (data?.userInfo ?? data?.user ?? data) as Record<string, unknown>
if (u?.userInfo && (u.ID == null && u.id == null)) {
u = u.userInfo as Record<string, unknown>
}
if ((res.code === 0 || res.code === 200) && u) {
const rawId = u.ID ?? u.id
const numId =
typeof rawId === 'number'
? rawId
: rawId != null
? parseInt(String(rawId), 10)
: undefined
user.value = {
id: u.ID as number | string | undefined,
ID: u.ID as number | undefined,
...u,
userName: (u.userName ?? u.username) as string | undefined,
nickName: (u.nickName ?? u.nickname) as string | undefined,
headerImg: (u.headerImg ?? u.avatar ?? u.avatarUrl) as string | undefined,
...u,
}
id: (rawId ?? numId) as number | string | undefined,
ID: Number.isFinite(numId) ? numId : undefined,
} as UserInfo
if (token.value && user.value) saveToStorage(token.value, user.value)
}
} catch (e) {

View File

@ -181,7 +181,15 @@ const connectWithWallet = async () => {
console.log('[walletLogin] 登录响应:', JSON.stringify(loginData, null, 2))
if (loginData.code === 0 && loginData.data) {
const { token, user } = loginData.data
const data = loginData.data as Record<string, unknown>
const token = data.token as string | undefined
// data.user data user data
const user =
(data.user as UserInfo | undefined) ??
(data.ID != null || data.id != null || data.userName != null ? (data as unknown as UserInfo) : undefined)
if (!user) {
console.warn('[walletLogin] data 中无 user将依赖 fetchUserInfo 拉取。data 结构:', Object.keys(data))
}
console.log('[walletLogin] 存入 store 的 user:', JSON.stringify(user, null, 2))
userStore.setUser({ token, user })
await userStore.fetchUserInfo()

View File

@ -46,7 +46,14 @@
<!-- Order Book Section -->
<v-card class="order-book-card" elevation="0" rounded="lg" style="margin-top: 32px">
<OrderBook />
<OrderBook
:asks="orderBookAsks"
:bids="orderBookBids"
:last-price="clobLastPrice"
:spread="clobSpread"
:loading="clobLoading"
:connected="clobConnected"
/>
</v-card>
<!-- Comments / Top Holders / Activity与左侧图表订单簿同宽 -->
@ -192,10 +199,17 @@ import TradeComponent from '../components/TradeComponent.vue'
import {
findPmEvent,
getMarketId,
getClobTokenId,
type FindPmEventParams,
type PmEventListItem,
} from '../api/event'
import { getClobWsUrl } from '../api/request'
import { useUserStore } from '../stores/user'
import {
ClobSdk,
type PriceSizePolyMsg,
type TradePolyMsg,
} from '../../sdk/clobSocket'
/**
* 分时图服务端推送数据格式约定
@ -318,6 +332,121 @@ const currentMarket = computed(() => {
return list[0]
})
// --- CLOB WebSocket 簿 ---
const clobSdkRef = ref<ClobSdk | null>(null)
const orderBookAsks = ref<{ price: number; shares: number }[]>([])
const orderBookBids = ref<{ price: number; shares: number }[]>([])
const clobLastPrice = ref<number | undefined>(undefined)
const clobSpread = ref<number | undefined>(undefined)
const clobConnected = ref(false)
const clobLoading = ref(false)
function priceSizeToRows(record: Record<string, number> | undefined): { price: number; shares: number }[] {
if (!record) return []
return Object.entries(record)
.filter(([, shares]) => shares > 0)
.map(([p, shares]) => ({
price: Math.round(parseFloat(p) /100),
shares,
}))
}
function applyPriceSizeAll(msg: PriceSizePolyMsg) {
orderBookAsks.value = priceSizeToRows(msg.s).sort((a, b) => a.price - b.price)
orderBookBids.value = priceSizeToRows(msg.b).sort((a, b) => b.price - a.price)
updateSpreadFromBook()
}
function applyPriceSizeDelta(msg: PriceSizePolyMsg) {
const mergeDelta = (
current: { price: number; shares: number }[],
delta: Record<string, number> | undefined,
asc: boolean,
) => {
const map = new Map(current.map((r) => [r.price, r.shares]))
if (delta) {
Object.entries(delta).forEach(([p, shares]) => {
const price = Math.round(parseFloat(p) / 100)
if (shares <= 0) map.delete(price)
else map.set(price, shares)
})
}
return Array.from(map.entries())
.map(([price, shares]) => ({ price, shares }))
.sort((a, b) => (asc ? a.price - b.price : b.price - a.price))
}
orderBookAsks.value = mergeDelta(orderBookAsks.value, msg.s, true)
orderBookBids.value = mergeDelta(orderBookBids.value, msg.b, false)
updateSpreadFromBook()
}
function updateSpreadFromBook() {
const bestAsk = orderBookAsks.value[0]?.price
const bestBid = orderBookBids.value[0]?.price
if (bestAsk != null && bestBid != null) {
clobSpread.value = bestAsk - bestBid
}
}
function connectClob(tokenIds: string[]) {
clobSdkRef.value?.disconnect()
clobSdkRef.value = null
clobLoading.value = true
clobConnected.value = false
orderBookAsks.value = []
orderBookBids.value = []
const options = {
url: getClobWsUrl(),
autoReconnect: true,
reconnectInterval: 2000,
}
const sdk = new ClobSdk(tokenIds, options)
sdk.onConnect(() => {
clobConnected.value = true
clobLoading.value = false
})
sdk.onDisconnect(() => {
clobConnected.value = false
})
sdk.onError(() => {
clobLoading.value = false
})
sdk.onPriceSizeAll(applyPriceSizeAll)
sdk.onPriceSizeDelta(applyPriceSizeDelta)
sdk.onTrade((msg: TradePolyMsg) => {
const priceNum = parseFloat(msg.p)
if (Number.isFinite(priceNum)) {
clobLastPrice.value = Math.round(priceNum * 100)
}
// Activity
const side = msg.side?.toLowerCase() === 'buy' ? 'Yes' : 'No'
const action = side === 'Yes' ? 'bought' : 'sold'
activityList.value.unshift({
id: `clob-${Date.now()}-${Math.random().toString(36).slice(2)}`,
user: '0x...',
avatarClass: 'avatar-gradient-1',
action: action as 'bought' | 'sold',
side: side as 'Yes' | 'No',
amount: Math.round(parseFloat(msg.s) || 0),
price: `${Math.round((priceNum || 0) * 100)}¢`,
total: `$${((parseFloat(msg.s) || 0) * (priceNum || 0)).toFixed(2)}`,
time: Date.now(),
})
})
clobSdkRef.value = sdk
sdk.connect()
}
function disconnectClob() {
clobSdkRef.value?.disconnect()
clobSdkRef.value = null
clobConnected.value = false
clobLoading.value = false
}
/** 传给 TradeComponent 的 market供 Split 调用 /PmMarket/split接口未返回时用 query 兜底 */
const tradeMarketPayload = computed(() => {
const m = currentMarket.value
@ -748,6 +877,31 @@ function stopDynamicUpdate() {
watch(selectedTimeRange, () => updateChartData())
// CLOB market clobTokenIds 使 Yes/No token ID
const clobTokenIds = computed(() => {
const m = currentMarket.value
if (!m?.clobTokenIds?.length) return []
const yes = getClobTokenId(m, 0)
const no = getClobTokenId(m, 1)
return [yes, no].filter((id): id is string => !!id)
})
watch(
clobTokenIds,
(tokenIds) => {
if (tokenIds.length > 0) {
// Yes lastPrice
const payload = tradeMarketPayload.value
if (payload?.yesPrice != null) {
clobLastPrice.value = Math.round(payload.yesPrice * 100)
}
connectClob(tokenIds)
} else {
disconnectClob()
}
},
{ immediate: true },
)
const handleResize = () => {
if (!chartInstance || !chartContainerRef.value) return
chartInstance.resize()
@ -774,6 +928,7 @@ onUnmounted(() => {
window.removeEventListener('resize', handleResize)
chartInstance?.dispose()
chartInstance = null
disconnectClob()
})
</script>