feat: v1.2.0 房间详情/聊天/踢人 + 速率限制 + WebSocket增强
Server: - API速率限制中间件 (120 req/min per IP, X-RateLimit headers) - 房间聊天API: POST /rooms/:id/chat - 认证中间件放行公开GET路由和房间join - WebSocket: 房间订阅/取消订阅 (subscribe/unsubscribe) - WebSocket: 房间聊天广播 (chat -> broadcastToRoom) - WebSocket: 房间事件通知 (roomCreated/Deleted/playerJoined/Left) Client: - 房间详情弹窗: 点击房间卡片打开 - 房间信息网格 (房间号/房主/版本/人数) - 在线玩家列表 (5秒自动刷新) - 踢出玩家 (确认对话框) - 房间聊天 (实时发送/显示) - 加入房间 / 删除房间按钮 - 连接状态指示器动画 (online/offline/connecting) - 房间卡片hover效果 - 版本更新到 v1.2.0 - ApiClient: 新增 getRoomDetail/kickPlayer/sendChat - Preload: 新增对应IPC方法 - Main: 新增 rooms:detail/kick/chat handlers
This commit is contained in:
@@ -13,13 +13,44 @@ function formatBytes(bytes: number): string {
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
|
||||
}
|
||||
|
||||
// Rate limiter: max requests per window per IP
|
||||
const rateLimitMap = new Map<string, { count: number; resetAt: number }>();
|
||||
function rateLimiter(maxReqs: number = 60, windowMs: number = 60000) {
|
||||
return (req: Request, res: Response, next: any) => {
|
||||
const ip = req.ip || req.socket.remoteAddress || 'unknown';
|
||||
const now = Date.now();
|
||||
let entry = rateLimitMap.get(ip);
|
||||
if (!entry || now > entry.resetAt) {
|
||||
entry = { count: 0, resetAt: now + windowMs };
|
||||
rateLimitMap.set(ip, entry);
|
||||
}
|
||||
entry.count++;
|
||||
res.setHeader('X-RateLimit-Limit', maxReqs);
|
||||
res.setHeader('X-RateLimit-Remaining', Math.max(0, maxReqs - entry.count));
|
||||
if (entry.count > maxReqs) {
|
||||
res.status(429).json({ error: '请求过于频繁,请稍后再试' });
|
||||
return;
|
||||
}
|
||||
next();
|
||||
};
|
||||
}
|
||||
|
||||
// Cleanup stale rate limit entries every 5 min
|
||||
setInterval(() => {
|
||||
const now = Date.now();
|
||||
for (const [ip, entry] of rateLimitMap) {
|
||||
if (now > entry.resetAt) rateLimitMap.delete(ip);
|
||||
}
|
||||
}, 300000);
|
||||
|
||||
function authMiddleware(req: Request, res: Response, next: any) {
|
||||
if (!config.secret) return next();
|
||||
const token = req.headers['x-auth-token'] || req.query.token;
|
||||
if (token === config.secret) return next();
|
||||
// Allow public read endpoints
|
||||
const publicPaths = ['/health', '/rooms', '/stats'];
|
||||
const publicPaths = ['/health', '/rooms', '/stats', '/traffic', '/nodes'];
|
||||
if (req.method === 'GET' && publicPaths.some(p => req.path.startsWith(p))) return next();
|
||||
// Allow POST to /rooms/:id/join without token (password-protected)
|
||||
if (req.method === 'POST' && req.path.match(/\/rooms\/[^/]+\/join$/)) return next();
|
||||
res.status(401).json({ error: '认证失败,请提供有效的token' });
|
||||
}
|
||||
|
||||
@@ -29,6 +60,7 @@ export function createApiRouter(
|
||||
relayEngine: RelayEngine
|
||||
): Router {
|
||||
const router = Router();
|
||||
router.use(rateLimiter(120, 60000));
|
||||
router.use(authMiddleware);
|
||||
|
||||
// ===== Health & Status =====
|
||||
@@ -225,6 +257,29 @@ export function createApiRouter(
|
||||
res.json({ message: '节点已移除' });
|
||||
});
|
||||
|
||||
// ===== Room Chat =====
|
||||
router.post('/rooms/:roomId/chat', (req: Request, res: Response) => {
|
||||
const room = roomManager.getRoom(req.params.roomId);
|
||||
if (!room) {
|
||||
res.status(404).json({ error: '房间不存在' });
|
||||
return;
|
||||
}
|
||||
const { sender, message } = req.body;
|
||||
if (!sender || !message) {
|
||||
res.status(400).json({ error: '缺少参数: sender, message' });
|
||||
return;
|
||||
}
|
||||
const chatMsg = {
|
||||
id: Date.now().toString(36),
|
||||
roomId: room.id,
|
||||
sender: sender.substring(0, 20),
|
||||
message: message.substring(0, 200),
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
// Broadcast via WebSocket (caller should handle this via wsHandler)
|
||||
res.json({ sent: true, chat: chatMsg });
|
||||
});
|
||||
|
||||
// Traffic stats
|
||||
router.get('/traffic', (_req: Request, res: Response) => {
|
||||
const traffic = roomManager.getTrafficStats();
|
||||
|
||||
@@ -8,6 +8,7 @@ import config from './config';
|
||||
export class WebSocketHandler {
|
||||
private wss: WebSocket.Server | null = null;
|
||||
private clients: Set<WebSocket> = new Set();
|
||||
private roomSubscriptions: Map<string, Set<WebSocket>> = new Map();
|
||||
private broadcastInterval: NodeJS.Timeout | null = null;
|
||||
|
||||
constructor(
|
||||
@@ -42,6 +43,10 @@ export class WebSocketHandler {
|
||||
|
||||
ws.on('close', () => {
|
||||
this.clients.delete(ws);
|
||||
// Remove from all room subscriptions
|
||||
for (const [, subs] of this.roomSubscriptions) {
|
||||
subs.delete(ws);
|
||||
}
|
||||
logger.debug(`WebSocket client disconnected, total: ${this.clients.size}`);
|
||||
});
|
||||
|
||||
@@ -87,11 +92,69 @@ export class WebSocketHandler {
|
||||
}));
|
||||
break;
|
||||
|
||||
case 'subscribe': {
|
||||
const roomId = msg.data?.roomId;
|
||||
if (roomId) {
|
||||
if (!this.roomSubscriptions.has(roomId)) {
|
||||
this.roomSubscriptions.set(roomId, new Set());
|
||||
}
|
||||
this.roomSubscriptions.get(roomId)!.add(ws);
|
||||
ws.send(JSON.stringify({ type: 'subscribed', data: { roomId } }));
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'unsubscribe': {
|
||||
const rid = msg.data?.roomId;
|
||||
if (rid) {
|
||||
this.roomSubscriptions.get(rid)?.delete(ws);
|
||||
ws.send(JSON.stringify({ type: 'unsubscribed', data: { roomId: rid } }));
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'chat': {
|
||||
const { roomId, sender, message } = msg.data || {};
|
||||
if (!roomId || !sender || !message) {
|
||||
ws.send(JSON.stringify({ type: 'error', data: { message: '聊天需要 roomId, sender, message' } }));
|
||||
break;
|
||||
}
|
||||
const room = this.roomManager.getRoom(roomId);
|
||||
if (!room) {
|
||||
ws.send(JSON.stringify({ type: 'error', data: { message: '房间不存在' } }));
|
||||
break;
|
||||
}
|
||||
const chatMsg = {
|
||||
id: Date.now().toString(36) + Math.random().toString(36).slice(2, 5),
|
||||
roomId,
|
||||
sender: sender.substring(0, 20),
|
||||
message: message.substring(0, 200),
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
this.broadcastToRoom(roomId, 'chat', chatMsg);
|
||||
break;
|
||||
}
|
||||
|
||||
default:
|
||||
ws.send(JSON.stringify({ type: 'error', data: { message: `未知消息类型: ${msg.type}` } }));
|
||||
}
|
||||
}
|
||||
|
||||
broadcastToRoom(roomId: string, type: string, data: any): void {
|
||||
const subs = this.roomSubscriptions.get(roomId);
|
||||
if (!subs || subs.size === 0) return;
|
||||
const message = JSON.stringify({ type, data });
|
||||
for (const client of subs) {
|
||||
if (client.readyState === WebSocket.OPEN) {
|
||||
client.send(message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
notifyRoomEvent(event: 'roomCreated' | 'roomDeleted' | 'playerJoined' | 'playerLeft', data: any): void {
|
||||
this.broadcast(event, data);
|
||||
}
|
||||
|
||||
private broadcastStatus(): void {
|
||||
if (this.clients.size === 0) return;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user