feat: FunConnect v1.0.0 - Minecraft联机平台完整版
- server: Node.js TCP中继服务器,支持多节点集群 - web: React管理面板(仪表盘、房间管理、节点管理) - client: Electron桌面客户端(连接、创建/加入房间、本地代理) - deploy: Ubuntu一键部署脚本
This commit is contained in:
154
server/src/node-manager.ts
Normal file
154
server/src/node-manager.ts
Normal file
@@ -0,0 +1,154 @@
|
||||
import axios from 'axios';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import logger from './logger';
|
||||
import config from './config';
|
||||
|
||||
export interface RelayNode {
|
||||
id: string;
|
||||
name: string;
|
||||
host: string;
|
||||
apiPort: number;
|
||||
relayPort: number;
|
||||
status: 'online' | 'offline' | 'busy';
|
||||
roomCount: number;
|
||||
playerCount: number;
|
||||
maxRooms: number;
|
||||
region: string;
|
||||
lastHeartbeat: Date;
|
||||
registeredAt: Date;
|
||||
}
|
||||
|
||||
export class NodeManager {
|
||||
private nodes: Map<string, RelayNode> = new Map();
|
||||
private heartbeatTimers: Map<string, NodeJS.Timeout> = new Map();
|
||||
|
||||
constructor() {
|
||||
if (config.isMaster) {
|
||||
setInterval(() => this.checkNodeHealth(), config.heartbeatInterval);
|
||||
}
|
||||
}
|
||||
|
||||
registerNode(nodeInfo: {
|
||||
name: string;
|
||||
host: string;
|
||||
apiPort: number;
|
||||
relayPort: number;
|
||||
maxRooms: number;
|
||||
region: string;
|
||||
}): RelayNode {
|
||||
const existing = Array.from(this.nodes.values()).find(
|
||||
n => n.host === nodeInfo.host && n.apiPort === nodeInfo.apiPort
|
||||
);
|
||||
|
||||
if (existing) {
|
||||
existing.status = 'online';
|
||||
existing.lastHeartbeat = new Date();
|
||||
existing.name = nodeInfo.name;
|
||||
existing.maxRooms = nodeInfo.maxRooms;
|
||||
existing.region = nodeInfo.region;
|
||||
logger.info(`Node re-registered: ${existing.name} (${existing.id})`);
|
||||
return existing;
|
||||
}
|
||||
|
||||
const node: RelayNode = {
|
||||
id: uuidv4().substring(0, 8),
|
||||
name: nodeInfo.name,
|
||||
host: nodeInfo.host,
|
||||
apiPort: nodeInfo.apiPort,
|
||||
relayPort: nodeInfo.relayPort,
|
||||
status: 'online',
|
||||
roomCount: 0,
|
||||
playerCount: 0,
|
||||
maxRooms: nodeInfo.maxRooms,
|
||||
region: nodeInfo.region,
|
||||
lastHeartbeat: new Date(),
|
||||
registeredAt: new Date(),
|
||||
};
|
||||
|
||||
this.nodes.set(node.id, node);
|
||||
logger.info(`New node registered: ${node.name} (${node.id}) at ${node.host}:${node.relayPort}`);
|
||||
return node;
|
||||
}
|
||||
|
||||
removeNode(nodeId: string): boolean {
|
||||
const timer = this.heartbeatTimers.get(nodeId);
|
||||
if (timer) clearTimeout(timer);
|
||||
this.heartbeatTimers.delete(nodeId);
|
||||
|
||||
const removed = this.nodes.delete(nodeId);
|
||||
if (removed) {
|
||||
logger.info(`Node removed: ${nodeId}`);
|
||||
}
|
||||
return removed;
|
||||
}
|
||||
|
||||
updateNodeHeartbeat(nodeId: string, stats: { roomCount: number; playerCount: number }): boolean {
|
||||
const node = this.nodes.get(nodeId);
|
||||
if (!node) return false;
|
||||
|
||||
node.lastHeartbeat = new Date();
|
||||
node.roomCount = stats.roomCount;
|
||||
node.playerCount = stats.playerCount;
|
||||
node.status = node.roomCount >= node.maxRooms ? 'busy' : 'online';
|
||||
return true;
|
||||
}
|
||||
|
||||
getNode(nodeId: string): RelayNode | undefined {
|
||||
return this.nodes.get(nodeId);
|
||||
}
|
||||
|
||||
listNodes(): RelayNode[] {
|
||||
return Array.from(this.nodes.values());
|
||||
}
|
||||
|
||||
getAvailableNodes(): RelayNode[] {
|
||||
return Array.from(this.nodes.values()).filter(n => n.status === 'online');
|
||||
}
|
||||
|
||||
getBestNode(): RelayNode | undefined {
|
||||
const available = this.getAvailableNodes();
|
||||
if (available.length === 0) return undefined;
|
||||
|
||||
return available.sort((a, b) => {
|
||||
const aLoad = a.roomCount / a.maxRooms;
|
||||
const bLoad = b.roomCount / b.maxRooms;
|
||||
return aLoad - bLoad;
|
||||
})[0];
|
||||
}
|
||||
|
||||
private async checkNodeHealth(): Promise<void> {
|
||||
const now = new Date();
|
||||
for (const [nodeId, node] of this.nodes) {
|
||||
const timeSinceHeartbeat = now.getTime() - node.lastHeartbeat.getTime();
|
||||
|
||||
if (timeSinceHeartbeat > config.heartbeatInterval * 3) {
|
||||
node.status = 'offline';
|
||||
logger.warn(`Node ${node.name} (${nodeId}) marked offline - no heartbeat for ${Math.round(timeSinceHeartbeat / 1000)}s`);
|
||||
} else {
|
||||
try {
|
||||
const response = await axios.get(`http://${node.host}:${node.apiPort}/api/health`, {
|
||||
timeout: 5000,
|
||||
});
|
||||
if (response.data.status === 'ok') {
|
||||
node.status = node.roomCount >= node.maxRooms ? 'busy' : 'online';
|
||||
node.lastHeartbeat = new Date();
|
||||
}
|
||||
} catch {
|
||||
if (timeSinceHeartbeat > config.heartbeatInterval * 2) {
|
||||
node.status = 'offline';
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
getStats(): { totalNodes: number; onlineNodes: number; totalRooms: number; totalPlayers: number } {
|
||||
const nodes = Array.from(this.nodes.values());
|
||||
return {
|
||||
totalNodes: nodes.length,
|
||||
onlineNodes: nodes.filter(n => n.status !== 'offline').length,
|
||||
totalRooms: nodes.reduce((sum, n) => sum + n.roomCount, 0),
|
||||
totalPlayers: nodes.reduce((sum, n) => sum + n.playerCount, 0),
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user