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:
FunMC
2026-02-22 23:38:41 +08:00
parent b17679cec6
commit 9649519745
9 changed files with 411 additions and 3 deletions

View File

@@ -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: '仅主节点可查询最佳节点' });

View File

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

View File

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