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:
FunMC
2026-02-23 08:21:09 +08:00
parent 7fdc570391
commit 80fe5e6e6e
8 changed files with 415 additions and 5 deletions

View File

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

View File

@@ -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;