feat: v1.1.0 迭代更新
Server: - 添加房间密码验证 POST /rooms/:id/join - 添加玩家踢出 POST /rooms/:id/kick/:playerId - 添加房间过期自动清理(30分钟无活动) - 添加流量统计 GET /traffic - 添加token认证中间件保护写操作API - 房间详情返回玩家列表 Client: - 添加设置持久化(electron-store) - 添加设置页面(玩家名、本地端口、自动重连、托盘最小化) - 添加系统托盘支持(最小化到托盘、右键菜单) - 添加最近连接服务器记录 - 连接成功自动保存服务器地址 - 加入房间自动填充默认端口
This commit is contained in:
@@ -5,12 +5,31 @@ import { RelayEngine } from './relay';
|
||||
import config from './config';
|
||||
import logger from './logger';
|
||||
|
||||
function formatBytes(bytes: number): string {
|
||||
if (bytes === 0) return '0 B';
|
||||
const k = 1024;
|
||||
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
|
||||
}
|
||||
|
||||
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'];
|
||||
if (req.method === 'GET' && publicPaths.some(p => req.path.startsWith(p))) return next();
|
||||
res.status(401).json({ error: '认证失败,请提供有效的token' });
|
||||
}
|
||||
|
||||
export function createApiRouter(
|
||||
roomManager: RoomManager,
|
||||
nodeManager: NodeManager,
|
||||
relayEngine: RelayEngine
|
||||
): Router {
|
||||
const router = Router();
|
||||
router.use(authMiddleware);
|
||||
|
||||
// ===== Health & Status =====
|
||||
router.get('/health', (_req: Request, res: Response) => {
|
||||
@@ -84,7 +103,49 @@ export function createApiRouter(
|
||||
res.status(404).json({ error: '房间不存在' });
|
||||
return;
|
||||
}
|
||||
res.json({ room: room.toInfo() });
|
||||
res.json({ room: room.toInfo(), players: room.getPlayerList() });
|
||||
});
|
||||
|
||||
// Join room with password verification
|
||||
router.post('/rooms/:roomId/join', (req: Request, res: Response) => {
|
||||
const room = roomManager.getRoom(req.params.roomId);
|
||||
if (!room) {
|
||||
res.status(404).json({ error: '房间不存在' });
|
||||
return;
|
||||
}
|
||||
if (room.isFull) {
|
||||
res.status(403).json({ error: '房间已满' });
|
||||
return;
|
||||
}
|
||||
const { password } = req.body;
|
||||
if (!room.checkPassword(password)) {
|
||||
res.status(403).json({ error: '房间密码错误' });
|
||||
return;
|
||||
}
|
||||
res.json({
|
||||
allowed: true,
|
||||
room: room.toInfo(),
|
||||
connectInfo: {
|
||||
host: config.isMaster ? 'MASTER_HOST' : config.nodeName,
|
||||
port: config.port,
|
||||
roomId: room.id,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
// Kick player from room
|
||||
router.post('/rooms/:roomId/kick/:playerId', (req: Request, res: Response) => {
|
||||
const room = roomManager.getRoom(req.params.roomId);
|
||||
if (!room) {
|
||||
res.status(404).json({ error: '房间不存在' });
|
||||
return;
|
||||
}
|
||||
const kicked = room.kickPlayer(req.params.playerId, req.body.reason);
|
||||
if (!kicked) {
|
||||
res.status(404).json({ error: '玩家不存在' });
|
||||
return;
|
||||
}
|
||||
res.json({ message: '玩家已踢出' });
|
||||
});
|
||||
|
||||
router.delete('/rooms/:roomId', (req: Request, res: Response) => {
|
||||
@@ -164,6 +225,18 @@ export function createApiRouter(
|
||||
res.json({ message: '节点已移除' });
|
||||
});
|
||||
|
||||
// Traffic stats
|
||||
router.get('/traffic', (_req: Request, res: Response) => {
|
||||
const traffic = roomManager.getTrafficStats();
|
||||
res.json({
|
||||
...traffic,
|
||||
rooms: roomManager.getRoomCount(),
|
||||
players: roomManager.getTotalPlayers(),
|
||||
formattedIn: formatBytes(traffic.totalBytesIn),
|
||||
formattedOut: formatBytes(traffic.totalBytesOut),
|
||||
});
|
||||
});
|
||||
|
||||
router.get('/nodes/best', (_req: Request, res: Response) => {
|
||||
if (!config.isMaster) {
|
||||
res.status(403).json({ error: '仅主节点可查询最佳节点' });
|
||||
|
||||
@@ -58,6 +58,9 @@ async function main() {
|
||||
|
||||
await relayEngine.start(config.port);
|
||||
|
||||
// Start room expiry cleanup (every 5 minutes, 30 min idle timeout)
|
||||
roomManager.startCleanupTimer();
|
||||
|
||||
if (!config.isMaster && config.masterUrl) {
|
||||
await registerWithMaster();
|
||||
startHeartbeat(roomManager);
|
||||
|
||||
@@ -37,6 +37,9 @@ export class Room {
|
||||
public password?: string;
|
||||
public nodeId: string;
|
||||
public createdAt: Date;
|
||||
public lastActivity: Date;
|
||||
public bytesIn: number = 0;
|
||||
public bytesOut: number = 0;
|
||||
public players: Map<string, Player> = new Map();
|
||||
private playerSockets: Map<string, net.Socket> = new Map();
|
||||
|
||||
@@ -61,6 +64,37 @@ export class Room {
|
||||
this.nodeId = options.nodeId;
|
||||
this.password = options.password;
|
||||
this.createdAt = new Date();
|
||||
this.lastActivity = new Date();
|
||||
}
|
||||
|
||||
checkPassword(pwd?: string): boolean {
|
||||
if (!this.password) return true;
|
||||
return this.password === pwd;
|
||||
}
|
||||
|
||||
touch(): void {
|
||||
this.lastActivity = new Date();
|
||||
}
|
||||
|
||||
addTraffic(bytesIn: number, bytesOut: number): void {
|
||||
this.bytesIn += bytesIn;
|
||||
this.bytesOut += bytesOut;
|
||||
this.touch();
|
||||
}
|
||||
|
||||
kickPlayer(playerId: string, reason?: string): boolean {
|
||||
const player = this.players.get(playerId);
|
||||
if (!player) return false;
|
||||
logger.info(`Player ${player.name} kicked from room ${this.name}: ${reason || 'no reason'}`);
|
||||
try { player.socket.destroy(); } catch (e) { /* ignore */ }
|
||||
this.players.delete(playerId);
|
||||
this.playerSockets.delete(playerId);
|
||||
return true;
|
||||
}
|
||||
|
||||
isExpired(maxIdleMs: number): boolean {
|
||||
if (this.currentPlayers > 0) return false;
|
||||
return Date.now() - this.lastActivity.getTime() > maxIdleMs;
|
||||
}
|
||||
|
||||
get currentPlayers(): number {
|
||||
@@ -93,6 +127,14 @@ export class Room {
|
||||
}
|
||||
}
|
||||
|
||||
getPlayerList(): { id: string; name: string; joinedAt: Date }[] {
|
||||
return Array.from(this.players.values()).map(p => ({
|
||||
id: p.id,
|
||||
name: p.name,
|
||||
joinedAt: p.joinedAt,
|
||||
}));
|
||||
}
|
||||
|
||||
toInfo(): RoomInfo {
|
||||
return {
|
||||
id: this.id,
|
||||
@@ -184,4 +226,36 @@ export class RoomManager {
|
||||
}
|
||||
return total;
|
||||
}
|
||||
|
||||
getTrafficStats(): { totalBytesIn: number; totalBytesOut: number } {
|
||||
let totalBytesIn = 0;
|
||||
let totalBytesOut = 0;
|
||||
for (const [, room] of this.rooms) {
|
||||
totalBytesIn += room.bytesIn;
|
||||
totalBytesOut += room.bytesOut;
|
||||
}
|
||||
return { totalBytesIn, totalBytesOut };
|
||||
}
|
||||
|
||||
cleanupExpiredRooms(maxIdleMs: number = 30 * 60 * 1000): number {
|
||||
let cleaned = 0;
|
||||
for (const [id, room] of this.rooms) {
|
||||
if (room.isExpired(maxIdleMs)) {
|
||||
logger.info(`Room ${room.name} (${id}) expired, cleaning up`);
|
||||
room.destroy();
|
||||
this.rooms.delete(id);
|
||||
cleaned++;
|
||||
}
|
||||
}
|
||||
return cleaned;
|
||||
}
|
||||
|
||||
startCleanupTimer(intervalMs: number = 5 * 60 * 1000, maxIdleMs: number = 30 * 60 * 1000): void {
|
||||
setInterval(() => {
|
||||
const cleaned = this.cleanupExpiredRooms(maxIdleMs);
|
||||
if (cleaned > 0) {
|
||||
logger.info(`Cleaned up ${cleaned} expired rooms`);
|
||||
}
|
||||
}, intervalMs);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user