feat: upload latest server source changes

This commit is contained in:
sue
2026-03-03 22:15:19 +08:00
parent a123c7cc40
commit c65062e8f7
13 changed files with 5414 additions and 1768 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -1,7 +1,7 @@
import Logger from '../utils/Logger'
/**
* 设备信息接口
* Device metadata kept in memory for online routing.
*/
export interface DeviceInfo {
id: string
@@ -15,22 +15,23 @@ export interface DeviceInfo {
screenWidth: number
screenHeight: number
capabilities: string[]
supportedVideoTransports?: string[]
srtUploadSupported?: boolean
connectedAt: Date
lastSeen: Date
status: 'online' | 'offline' | 'busy'
inputBlocked?: boolean
isLocked?: boolean // 设备锁屏状态
remark?: string // 🆕 设备备注
isLocked?: boolean
remark?: string
publicIP?: string
// 🆕 新增系统版本信息字段
systemVersionName?: string // 如"Android 11"、"Android 12"
romType?: string // 如"MIUI"、"ColorOS"、"原生Android"
romVersion?: string // 如"MIUI 12.5"、"ColorOS 11.1"
osBuildVersion?: string // 如"1.0.19.0.UMCCNXM"等完整构建版本号
systemVersionName?: string
romType?: string
romVersion?: string
osBuildVersion?: string
}
/**
* 设备状态接口
* Realtime status snapshots from device heartbeat.
*/
export interface DeviceStatus {
cpu: number
@@ -41,9 +42,6 @@ export interface DeviceStatus {
screenOn: boolean
}
/**
* 设备管理器
*/
class DeviceManager {
private devices: Map<string, DeviceInfo> = new Map()
private deviceStatuses: Map<string, DeviceStatus> = new Map()
@@ -54,29 +52,20 @@ class DeviceManager {
this.logger = new Logger('DeviceManager')
}
/**
* ✅ 清理所有设备记录(服务器重启时调用)
*/
clearAllDevices(): void {
const deviceCount = this.devices.size
this.devices.clear()
this.deviceStatuses.clear()
this.socketToDevice.clear()
this.logger.info(`🧹 已清理所有设备记录: ${deviceCount} 个设备`)
this.logger.info(`已清理所有设备记录: ${deviceCount} 个设备`)
}
/**
* 添加设备
*/
addDevice(deviceInfo: DeviceInfo): void {
this.devices.set(deviceInfo.id, deviceInfo)
this.socketToDevice.set(deviceInfo.socketId, deviceInfo.id)
this.logger.info(`设备已添加: ${deviceInfo.name} (${deviceInfo.id})`)
}
/**
* 移除设备
*/
removeDevice(deviceId: string): boolean {
const device = this.devices.get(deviceId)
if (device) {
@@ -89,9 +78,6 @@ class DeviceManager {
return false
}
/**
* 通过Socket ID移除设备
*/
removeDeviceBySocketId(socketId: string): boolean {
const deviceId = this.socketToDevice.get(socketId)
if (deviceId) {
@@ -100,45 +86,27 @@ class DeviceManager {
return false
}
/**
* 获取设备信息
*/
getDevice(deviceId: string): DeviceInfo | undefined {
return this.devices.get(deviceId)
}
/**
* 通过Socket ID获取设备
*/
getDeviceBySocketId(socketId: string): DeviceInfo | undefined {
const deviceId = this.socketToDevice.get(socketId)
return deviceId ? this.devices.get(deviceId) : undefined
}
/**
* 获取所有设备
*/
getAllDevices(): DeviceInfo[] {
return Array.from(this.devices.values())
}
/**
* 获取在线设备
*/
getOnlineDevices(): DeviceInfo[] {
return Array.from(this.devices.values()).filter(device => device.status === 'online')
}
/**
* 获取设备数量
*/
getDeviceCount(): number {
return this.devices.size
}
/**
* 更新设备状态
*/
updateDeviceStatus(socketId: string, status: DeviceStatus): void {
const deviceId = this.socketToDevice.get(socketId)
if (deviceId) {
@@ -151,16 +119,10 @@ class DeviceManager {
}
}
/**
* 获取设备状态
*/
getDeviceStatus(deviceId: string): DeviceStatus | undefined {
return this.deviceStatuses.get(deviceId)
}
/**
* 更新设备连接状态
*/
updateDeviceConnectionStatus(deviceId: string, status: DeviceInfo['status']): void {
const device = this.devices.get(deviceId)
if (device) {
@@ -170,25 +132,16 @@ class DeviceManager {
}
}
/**
* 检查设备是否在线
*/
isDeviceOnline(deviceId: string): boolean {
const device = this.devices.get(deviceId)
return device ? device.status === 'online' : false
}
/**
* 获取设备的Socket ID
*/
getDeviceSocketId(deviceId: string): string | undefined {
const device = this.devices.get(deviceId)
return device?.socketId
}
/**
* 清理离线设备 (超过指定时间未活跃)
*/
cleanupOfflineDevices(timeoutMs: number = 300000): void {
const now = Date.now()
const devicesToRemove: string[] = []
@@ -208,9 +161,6 @@ class DeviceManager {
}
}
/**
* 获取设备统计信息
*/
getDeviceStats(): {
total: number
online: number
@@ -227,4 +177,4 @@ class DeviceManager {
}
}
export default DeviceManager
export default DeviceManager

View File

@@ -1,9 +1,9 @@
import { Server as SocketIOServer, Socket } from 'socket.io'
import { Server as SocketIOServer, Socket } from 'socket.io'
import Logger from '../utils/Logger'
import { DatabaseService } from '../services/DatabaseService'
/**
* Web客户端信息接口
* comment cleaned
*/
export interface WebClientInfo {
id: string
@@ -13,12 +13,18 @@ export interface WebClientInfo {
connectedAt: Date
lastSeen: Date
controllingDeviceId?: string
userId?: string // 🔐 添加用户ID字段
username?: string // 🔐 添加用户名字段
userId?: string
username?: string
role?: 'superadmin' | 'leader' | 'member'
groupId?: string
groupName?: string
}
export type DeviceAccessResolver = (client: WebClientInfo, deviceId: string) => boolean
export type VideoTransportPreference = 'ws_binary' | 'srt_mpegts' | 'webrtc'
/**
* Web客户端管理器
* comment cleaned
*/
class WebClientManager {
private clients: Map<string, WebClientInfo> = new Map()
@@ -26,11 +32,13 @@ class WebClientManager {
private deviceControllers: Map<string, string> = new Map() // deviceId -> clientId
private logger: Logger
public io?: SocketIOServer
private databaseService?: DatabaseService // 🔐 添加数据库服务引用
private databaseService?: DatabaseService
private deviceAccessResolver?: DeviceAccessResolver
// 🔧 添加请求速率限制 - 防止频繁重复请求
// comment cleaned
private requestTimestamps: Map<string, number> = new Map() // "clientId:deviceId" -> timestamp
private readonly REQUEST_COOLDOWN = 2000 // 2秒内不允许重复请求增加冷却时间
private readonly REQUEST_COOLDOWN = 1000
private videoTransportPreferences: Map<string, VideoTransportPreference> = new Map() // "clientId:deviceId" -> transport
constructor(databaseService?: DatabaseService) {
this.logger = new Logger('WebClientManager')
@@ -38,7 +46,7 @@ class WebClientManager {
}
/**
* ✅ 清理所有客户端记录(服务器重启时调用)
* comment cleaned
*/
clearAllClients(): void {
const clientCount = this.clients.size
@@ -46,24 +54,29 @@ class WebClientManager {
this.socketToClient.clear()
this.deviceControllers.clear()
this.requestTimestamps.clear()
this.logger.info(`🧹 已清理所有客户端记录: ${clientCount} 个客户端`)
this.videoTransportPreferences.clear()
this.logger.info(`已清理所有Web客户端记录: ${clientCount} 个客户端`)
}
/**
* 设置Socket.IO实例
* comment cleaned
*/
setSocketIO(io: SocketIOServer): void {
this.io = io
}
setDeviceAccessResolver(resolver: DeviceAccessResolver): void {
this.deviceAccessResolver = resolver
}
/**
* 添加Web客户端
* comment cleaned
*/
addClient(clientInfo: WebClientInfo): void {
// 🔧 检查是否已有相同Socket ID的客户端记录
// comment cleaned
const existingClientId = this.socketToClient.get(clientInfo.socketId)
if (existingClientId) {
this.logger.warn(`⚠️ Socket ${clientInfo.socketId} 已有客户端记录 ${existingClientId},清理旧记录`)
this.logger.warn(`Socket ${clientInfo.socketId} 已有客户端记录 ${existingClientId}将先清理旧记录`)
this.removeClient(existingClientId)
}
@@ -73,7 +86,7 @@ class WebClientManager {
}
/**
* 移除Web客户端
* comment cleaned
*/
removeClient(clientId: string): boolean {
const client = this.clients.get(clientId)
@@ -81,15 +94,17 @@ class WebClientManager {
this.clients.delete(clientId)
this.socketToClient.delete(client.socketId)
// 如果客户端正在控制设备,释放控制权
// comment cleaned
if (client.controllingDeviceId) {
this.logger.info(`🔓 客户端断开连接,自动释放设备控制权: ${clientId} -> ${client.controllingDeviceId}`)
this.logger.info(`Web客户端断开连接,自动释放设备控制权: ${clientId} -> ${client.controllingDeviceId}`)
this.releaseDeviceControl(client.controllingDeviceId)
}
// 清理请求时间戳记录
// comment cleaned
const keysToDelete = Array.from(this.requestTimestamps.keys()).filter(key => key.startsWith(clientId + ':'))
keysToDelete.forEach(key => this.requestTimestamps.delete(key))
const videoKeysToDelete = Array.from(this.videoTransportPreferences.keys()).filter(key => key.startsWith(clientId + ':'))
videoKeysToDelete.forEach(key => this.videoTransportPreferences.delete(key))
this.logger.info(`Web客户端已移除: ${clientId}`)
return true
@@ -98,7 +113,7 @@ class WebClientManager {
}
/**
* 通过Socket ID移除客户端
* comment cleaned
*/
removeClientBySocketId(socketId: string): boolean {
const clientId = this.socketToClient.get(socketId)
@@ -109,14 +124,14 @@ class WebClientManager {
}
/**
* 获取客户端信息
* comment cleaned
*/
getClient(clientId: string): WebClientInfo | undefined {
return this.clients.get(clientId)
}
/**
* 通过Socket ID获取客户端
* comment cleaned
*/
getClientBySocketId(socketId: string): WebClientInfo | undefined {
const clientId = this.socketToClient.get(socketId)
@@ -124,21 +139,25 @@ class WebClientManager {
}
/**
* 获取所有客户端
* comment cleaned
*/
getAllClients(): WebClientInfo[] {
return Array.from(this.clients.values())
}
getClientsByUserId(userId: string): WebClientInfo[] {
return Array.from(this.clients.values()).filter((client) => client.userId === userId)
}
/**
* 获取客户端数量
* comment cleaned
*/
getClientCount(): number {
return this.clients.size
}
/**
* 获取客户端Socket
* comment cleaned
*/
getClientSocket(clientId: string): Socket | undefined {
const client = this.clients.get(clientId)
@@ -148,57 +167,85 @@ class WebClientManager {
return undefined
}
private isClientSuperAdmin(client?: WebClientInfo): boolean {
if (!client) return false
if (client.role === 'superadmin') return true
const superAdminUsername = process.env.SUPERADMIN_USERNAME || 'superadmin'
return client.username === superAdminUsername
}
private canClientAccessDevice(clientId: string, deviceId: string): boolean {
const client = this.clients.get(clientId)
if (!client) return false
if (this.isClientSuperAdmin(client)) return true
if (!this.deviceAccessResolver) return true
try {
return this.deviceAccessResolver(client, deviceId)
} catch (error) {
this.logger.error(`权限解析失败: client=${clientId}, device=${deviceId}`, error)
return false
}
}
/**
* 请求控制设备
* comment cleaned
*/
requestDeviceControl(clientId: string, deviceId: string): {
success: boolean
message: string
currentController?: string
} {
// 🔧 防止频繁重复请求
// comment cleaned
const requestKey = `${clientId}:${deviceId}`
const now = Date.now()
const lastRequestTime = this.requestTimestamps.get(requestKey) || 0
if (now - lastRequestTime < this.REQUEST_COOLDOWN) {
this.logger.debug(`🚫 请求过于频繁: ${clientId} -> ${deviceId} (间隔${now - lastRequestTime}ms < ${this.REQUEST_COOLDOWN}ms)`)
this.logger.debug(`请求过于频繁: ${clientId} -> ${deviceId} (间隔${now - lastRequestTime}ms < ${this.REQUEST_COOLDOWN}ms)`)
return {
success: false,
message: '请求过于频繁,请稍后再试'
}
}
// 获取客户端信息
// comment cleaned
const client = this.clients.get(clientId)
if (!client) {
this.logger.error(`客户端不存在: ${clientId}`)
this.logger.error(`客户端不存在: ${clientId}`)
return {
success: false,
message: '客户端不存在'
}
}
// ✅ 优化:先检查是否是重复请求(已经在控制此设备)
if (!this.canClientAccessDevice(clientId, deviceId)) {
this.logger.warn(`客户端 ${clientId} 无权控制设备 ${deviceId}`)
return {
success: false,
message: '无权控制该设备'
}
}
// comment cleaned
const currentController = this.deviceControllers.get(deviceId)
if (currentController === clientId) {
this.logger.debug(`🔄 客户端 ${clientId} 重复请求控制设备 ${deviceId},已在控制中`)
this.logger.debug(`客户端 ${clientId} 重复请求控制设备 ${deviceId},已在控制中`)
client.lastSeen = new Date()
// 更新请求时间戳,但返回成功(避免频繁日志)
// comment cleaned
this.requestTimestamps.set(requestKey, now)
return {
success: true,
message: '已在控制设备'
message: '已在控制设备'
}
}
// 记录请求时间戳(在检查重复控制后记录)
// comment cleaned
this.requestTimestamps.set(requestKey, now)
// 检查设备是否被其他客户端控制
// comment cleaned
if (currentController && currentController !== clientId) {
const controllerClient = this.clients.get(currentController)
this.logger.warn(`🚫 设备 ${deviceId} 控制权冲突: 当前控制者 ${currentController}, 请求者 ${clientId}`)
this.logger.warn(`设备 ${deviceId} 控制权冲突: 当前控制者 ${currentController}, 请求者 ${clientId}`)
return {
success: false,
message: `设备正在被其他客户端控制 (${controllerClient?.ip || 'unknown'})`,
@@ -206,24 +253,24 @@ class WebClientManager {
}
}
// 如果客户端已在控制其他设备,先释放
// comment cleaned
if (client.controllingDeviceId && client.controllingDeviceId !== deviceId) {
this.logger.info(`🔄 客户端 ${clientId} 切换控制设备: ${client.controllingDeviceId} -> ${deviceId}`)
this.logger.info(`客户端 ${clientId} 切换控制设备: ${client.controllingDeviceId} -> ${deviceId}`)
this.releaseDeviceControl(client.controllingDeviceId)
}
// 建立控制关系
// comment cleaned
this.deviceControllers.set(deviceId, clientId)
client.controllingDeviceId = deviceId
client.lastSeen = new Date()
// 🔐 如果客户端有用户ID将权限持久化到数据库
// comment cleaned
if (client.userId && this.databaseService) {
this.databaseService.grantUserDevicePermission(client.userId, deviceId, 'control')
this.logger.info(`🔐 用户 ${client.userId} 的设备 ${deviceId} 控制权限已持久化`)
this.logger.info(`用户 ${client.userId} 的设备 ${deviceId} 控制权限已持久化`)
}
this.logger.info(`🎮 客户端 ${clientId} 开始控制设备 ${deviceId}`)
this.logger.info(`客户端 ${clientId} 开始控制设备 ${deviceId}`)
return {
success: true,
@@ -232,7 +279,7 @@ class WebClientManager {
}
/**
* 释放设备控制权
* comment cleaned
*/
releaseDeviceControl(deviceId: string): boolean {
const controllerId = this.deviceControllers.get(deviceId)
@@ -241,13 +288,19 @@ class WebClientManager {
if (client) {
const previousDevice = client.controllingDeviceId
client.controllingDeviceId = undefined
this.logger.debug(`🔓 客户端 ${controllerId} 释放设备控制权: ${previousDevice}`)
this.logger.debug(`客户端 ${controllerId} 释放设备控制权 ${previousDevice}`)
} else {
this.logger.warn(`⚠️ 控制设备 ${deviceId} 的客户端 ${controllerId} 不存在,可能已断开`)
this.logger.warn(`控制设备 ${deviceId} 的客户端 ${controllerId} 不存在,可能已断开`)
}
this.deviceControllers.delete(deviceId)
this.logger.info(`🔓 设备 ${deviceId} 的控制权已释放 (之前控制者: ${controllerId})`)
const suffix = `:${deviceId}`
for (const key of Array.from(this.videoTransportPreferences.keys())) {
if (key.endsWith(suffix)) {
this.videoTransportPreferences.delete(key)
}
}
this.logger.info(`设备 ${deviceId} 的控制权已释放 (之前控制者 ${controllerId})`)
return true
} else {
this.logger.debug(`🤷 设备 ${deviceId} 没有被控制,无需释放`)
@@ -256,50 +309,47 @@ class WebClientManager {
}
/**
* 获取设备控制者
* comment cleaned
*/
getDeviceController(deviceId: string): string | undefined {
return this.deviceControllers.get(deviceId)
}
/**
* 检查客户端是否有设备控制权
* comment cleaned
*/
hasDeviceControl(clientId: string, deviceId: string): boolean {
// 🛡️ 记录权限检查审计日志
this.logPermissionOperation(clientId, deviceId, '权限检查')
// 🔐 获取客户端信息
const client = this.clients.get(clientId)
// 🆕 超级管理员绕过权限检查
if (client?.username) {
const superAdminUsername = process.env.SUPERADMIN_USERNAME || 'superadmin'
if (client.username === superAdminUsername) {
// <20> 关键修复superadmin绕过检查时也必须建立控制关系
// 否则 getDeviceController() 查不到控制者routeScreenData 会丢弃所有屏幕数据
if (!this.deviceControllers.has(deviceId) || this.deviceControllers.get(deviceId) !== clientId) {
this.deviceControllers.set(deviceId, clientId)
client.controllingDeviceId = deviceId
this.logger.info(`🔐 超级管理员 ${client.username} 绕过权限检查并建立控制关系: ${deviceId}`)
} else {
this.logger.debug(`🔐 超级管理员 ${client.username} 绕过权限检查 (已有控制关系)`)
}
return true
}
if (!client) {
return false
}
if (!this.canClientAccessDevice(clientId, deviceId)) {
if (this.deviceControllers.get(deviceId) === clientId) {
this.releaseDeviceControl(deviceId)
}
return false
}
if (this.isClientSuperAdmin(client)) {
if (!this.deviceControllers.has(deviceId) || this.deviceControllers.get(deviceId) !== clientId) {
this.deviceControllers.set(deviceId, clientId)
client.controllingDeviceId = deviceId
this.logger.info(`超级管理员 ${client.username} 绕过权限检查并建立控制关系: ${deviceId}`)
}
return true
}
// 🔐 首先检查内存中的控制权
const memoryControl = this.deviceControllers.get(deviceId) === clientId
if (memoryControl) {
return true
}
// 🔐 如果内存中没有控制权,检查数据库中的用户权限
if (client?.userId && this.databaseService) {
if (client.userId && this.databaseService) {
const hasPermission = this.databaseService.hasUserDevicePermission(client.userId, deviceId, 'control')
if (hasPermission) {
// 🔐 如果用户有权限,自动建立控制关系(允许权限恢复)
this.deviceControllers.set(deviceId, clientId)
client.controllingDeviceId = deviceId
this.logger.info(`🔐 用户 ${client.userId} 基于数据库权限获得设备 ${deviceId} 控制权`)
@@ -311,37 +361,126 @@ class WebClientManager {
}
/**
* 向指定客户端发送消息
* comment cleaned
*/
private extractDeviceIdFromEvent(event: string, payload: any): string | null {
if (!payload) return null
if (event === 'device_connected') {
return typeof payload.id === 'string' ? payload.id : (typeof payload.deviceId === 'string' ? payload.deviceId : null)
}
if (event === 'device_status_update') {
return typeof payload.deviceId === 'string' ? payload.deviceId : null
}
if (event === 'device_disconnected') {
if (typeof payload === 'string') return payload
return typeof payload.deviceId === 'string' ? payload.deviceId : null
}
return null
}
private filterDeviceListPayload(clientId: string, payload: any): any {
if (!payload || !Array.isArray(payload.devices)) {
return payload
}
const filteredDevices = payload.devices.filter((item: any) => {
const deviceId = typeof item?.id === 'string'
? item.id
: (typeof item?.deviceId === 'string' ? item.deviceId : null)
if (!deviceId) return false
return this.canClientAccessDevice(clientId, deviceId)
})
return {
...payload,
devices: filteredDevices
}
}
private getFilteredPayloadForClient(clientId: string, event: string, data: any): { allowed: boolean, payload: any } {
if (event === 'device_list' || event === 'devices_list_refresh' || event === 'client_registered') {
return { allowed: true, payload: this.filterDeviceListPayload(clientId, data) }
}
const deviceId = this.extractDeviceIdFromEvent(event, data)
if (!deviceId) {
return { allowed: true, payload: data }
}
const allowed = this.canClientAccessDevice(clientId, deviceId)
return { allowed, payload: data }
}
sendToClient(clientId: string, event: string, data: any): boolean {
const socket = this.getClientSocket(clientId)
if (socket) {
socket.emit(event, data)
const { allowed, payload } = this.getFilteredPayloadForClient(clientId, event, data)
if (!allowed) {
return false
}
socket.emit(event, payload)
return true
}
return false
}
sendToClientWithArgs(clientId: string, event: string, deviceId: string | null, ...args: any[]): boolean {
const socket = this.getClientSocket(clientId)
if (!socket) {
return false
}
if (deviceId && !this.canClientAccessDevice(clientId, deviceId)) {
return false
}
socket.emit(event, ...args)
return true
}
private transportKey(clientId: string, deviceId: string): string {
return `${clientId}:${deviceId}`
}
setVideoTransportPreference(clientId: string, deviceId: string, transport: VideoTransportPreference): void {
if (!clientId || !deviceId) return
const key = this.transportKey(clientId, deviceId)
this.videoTransportPreferences.set(key, transport)
}
getVideoTransportPreference(clientId: string, deviceId: string): VideoTransportPreference {
if (!clientId || !deviceId) return 'ws_binary'
const key = this.transportKey(clientId, deviceId)
return this.videoTransportPreferences.get(key) || 'ws_binary'
}
clearVideoTransportPreference(clientId: string, deviceId: string): void {
if (!clientId || !deviceId) return
const key = this.transportKey(clientId, deviceId)
this.videoTransportPreferences.delete(key)
}
/**
* 向所有客户端广播消息
* comment cleaned
*/
broadcastToAll(event: string, data: any): void {
if (this.io) {
let activeClients = 0
// 只向Web客户端广播且过滤掉已断开的连接
// comment cleaned
for (const [socketId, clientId] of this.socketToClient.entries()) {
const socket = this.io.sockets.sockets.get(socketId)
if (socket && socket.connected) {
socket.emit(event, data)
activeClients++
if (this.sendToClient(clientId, event, data)) {
activeClients++
}
}
}
this.logger.debug(`📡 广播消息到 ${activeClients} 个活跃Web客户端: ${event}`)
this.logger.debug(`广播消息到 ${activeClients} 个活跃Web客户端: ${event}`)
}
}
/**
* 向控制指定设备的客户端发送消息
* comment cleaned
*/
sendToDeviceController(deviceId: string, event: string, data: any): boolean {
const controllerId = this.deviceControllers.get(deviceId)
@@ -352,7 +491,7 @@ class WebClientManager {
}
/**
* 更新客户端活跃时间
* comment cleaned
*/
updateClientActivity(socketId: string): void {
const clientId = this.socketToClient.get(socketId)
@@ -365,7 +504,7 @@ class WebClientManager {
}
/**
* 清理不活跃的客户端
* comment cleaned
*/
cleanupInactiveClients(timeoutMs: number = 600000): void {
const now = Date.now()
@@ -387,7 +526,7 @@ class WebClientManager {
}
/**
* 获取客户端统计信息
* comment cleaned
*/
getClientStats(): {
total: number
@@ -403,7 +542,7 @@ class WebClientManager {
}
/**
* 🔐 恢复用户的设备权限
* comment cleaned
*/
restoreUserPermissions(userId: string, clientId: string): void {
if (!this.databaseService) {
@@ -416,18 +555,21 @@ class WebClientManager {
const permissions = this.databaseService.getUserDevicePermissions(userId)
if (permissions.length > 0) {
this.logger.info(`🔐 为用户 ${userId} 恢复 ${permissions.length} 个设备权限`)
this.logger.info(`为用户 ${userId} 恢复 ${permissions.length} 个设备权限`)
// 恢复第一个设备的控制权(优先恢复用户之前的权限)
// comment cleaned
for (const permission of permissions) {
if (permission.permissionType === 'control') {
// 直接恢复权限,不检查冲突(因为这是用户自己的权限恢复)
if (!this.canClientAccessDevice(clientId, permission.deviceId)) {
continue
}
// comment cleaned
this.deviceControllers.set(permission.deviceId, clientId)
const client = this.clients.get(clientId)
if (client) {
client.controllingDeviceId = permission.deviceId
this.logger.info(`🔐 用户 ${userId} 的设备 ${permission.deviceId} 控制权已恢复`)
break // 只恢复第一个设备
this.logger.info(`用户 ${userId} 的设备 ${permission.deviceId} 控制权已恢复`)
break // restore only the first device
}
}
}
@@ -438,29 +580,40 @@ class WebClientManager {
}
/**
* 🔐 设置客户端用户信息
* comment cleaned
*/
setClientUserInfo(clientId: string, userId: string, username: string): void {
setClientUserInfo(
clientId: string,
userId: string,
username: string,
role?: 'superadmin' | 'leader' | 'member',
groupId?: string,
groupName?: string
): void {
const client = this.clients.get(clientId)
if (client) {
client.userId = userId
client.username = username
this.logger.info(`🔐 客户端 ${clientId} 用户信息已设置: ${username} (${userId})`)
// 🛡️ 记录安全审计日志
this.logger.info(`🛡️ 安全审计: 客户端 ${clientId} (IP: ${client.ip}) 绑定用户 ${username} (${userId})`)
client.role = role
client.groupId = groupId
client.groupName = groupName
this.logger.info(`客户端 ${clientId} 用户信息已设置: ${username} (${userId})`)
// comment cleaned
this.logger.info(`安全审计: 客户端 ${clientId} (IP: ${client.ip}) 绑定用户 ${username} (${userId})`)
}
}
/**
* 🛡️ 记录权限操作审计日志
* comment cleaned
*/
private logPermissionOperation(clientId: string, deviceId: string, operation: string): void {
const client = this.clients.get(clientId)
if (client) {
this.logger.info(`🛡️ 权限审计: 客户端 ${clientId} (用户: ${client.username || 'unknown'}, IP: ${client.ip}) 执行 ${operation} 操作,目标设备: ${deviceId}`)
this.logger.info(`权限审计: 客户端 ${clientId} (用户: ${client.username || 'unknown'}, IP: ${client.ip}) 执行 ${operation} 操作,目标设备 ${deviceId}`)
}
}
}
export default WebClientManager
export default WebClientManager

View File

@@ -1336,132 +1336,62 @@ export default class APKBuildService {
/**
* 签名APK文件
*/
private async signAPK(apkPath: string, filename: string): Promise<string | null> {
/**
* 签名并对齐 APK 文件 (修复版)
*/
private async signAPK(apkPath: string, filename: string): Promise<string | null> {
try {
this.addBuildLog('info', `准备签名APK: ${filename}`)
// 确保keystore文件存在
this.addBuildLog('info', `准备对齐并签名 APK: ${filename}`)
const outputDir = path.dirname(apkPath);
const alignedApkPath = path.join(outputDir, `aligned_${filename}`);
const signedApkPath = path.join(outputDir, `signed_${filename}`);
const keystorePath = path.join(process.cwd(), 'android', 'app.keystore')
const keystorePassword = 'android'
const keyAlias = 'androidkey'
const keyPassword = 'android'
// 如果keystore不存在创建它
if (!fs.existsSync(keystorePath)) {
this.addBuildLog('info', 'keystore文件不存在正在创建...')
await this.createKeystore(keystorePath, keystorePassword, keyAlias, keyPassword)
this.addBuildLog('success', 'keystore文件创建成功')
// --- 步骤 1: Zipalign 对齐 ---
this.addBuildLog('info', '正在进行 zipalign 对齐...')
const alignCmd = `zipalign -v -f 4 "${apkPath}" "${alignedApkPath}"`
await execAsync(alignCmd);
this.addBuildLog('success', 'zipalign 对齐完成')
// --- 步骤 2: Apksigner V2/V3 签名 ---
this.addBuildLog('info', '正在强制开启 V2/V3 方案进行签名...')
// 显式指定 --v2-signing-enabled true
const signCmd = `apksigner sign --v1-signing-enabled true --v2-signing-enabled true --ks "${keystorePath}" --ks-pass pass:"${keystorePassword}" --ks-key-alias "${keyAlias}" --out "${signedApkPath}" "${alignedApkPath}"`;
await execAsync(signCmd);
this.addBuildLog('success', `APK 签名命令执行完成`)
// --- 步骤 3: 严格验证 ---
const verifyCmd = `apksigner verify -v "${signedApkPath}"`;
const { stdout: verifyOut, stderr: verifyErr } = await execAsync(verifyCmd);
console.log("--- 签名验证详细报告 ---");
console.log(verifyOut);
console.log(verifyErr);
console.log("-----------------------");
// 打印完整验证日志到后端控制台,方便你查看具体的报错
this.logger.info('apksigner verify 输出内容:\n' + verifyOut);
// 检查 V2 验证状态 (兼容大小写和空格)
const isV2 = /Verified using v2 scheme \(true\)/i.test(verifyOut);
const isV3 = /Verified using v3 scheme \(true\)/i.test(verifyOut);
if (isV2 || isV3) {
this.addBuildLog('success', `✅ 签名验证通过: ${isV2 ? '[V2]' : ''} ${isV3 ? '[V3]' : ''}`)
} else {
this.addBuildLog('info', '使用现有的keystore文件')
this.addBuildLog('warn', `⚠️ 签名成功但未检测到 V2/V3 方案,新款手机可能无法安装`)
// 建议在这里把 verifyOut 的前两行输出到 buildLog 方便前端看
this.addBuildLog('info', `验证详情: ${verifyOut.substring(0, 100)}...`)
}
// 使用jarsigner签名APK
this.addBuildLog('info', '使用jarsigner签名APK...')
const isWindows = platform() === 'win32'
const normalizedKeystorePath = path.normalize(keystorePath)
const normalizedApkPath = path.normalize(apkPath)
let signCommand: string
if (isWindows) {
// Windows: 使用引号包裹路径
signCommand = `jarsigner -verbose -sigalg SHA256withRSA -digestalg SHA-256 -keystore "${normalizedKeystorePath}" -storepass ${keystorePassword} -keypass ${keyPassword} "${normalizedApkPath}" ${keyAlias}`
} else {
// Linux: 直接使用路径
signCommand = `jarsigner -verbose -sigalg SHA256withRSA -digestalg SHA-256 -keystore "${normalizedKeystorePath}" -storepass ${keystorePassword} -keypass ${keyPassword} "${normalizedApkPath}" ${keyAlias}`
}
this.addBuildLog('info', `[DEBUG] 签名命令: jarsigner ... ${keyAlias}`)
this.addBuildLog('info', `[DEBUG] keystore路径: ${normalizedKeystorePath}`)
this.addBuildLog('info', `[DEBUG] APK路径: ${normalizedApkPath}`)
// 执行签名命令
const result = await new Promise<{ stdout: string, stderr: string, exitCode: number }>((resolve, reject) => {
let processStdout = ''
let processStderr = ''
const signProcess = spawn(signCommand, [], {
cwd: process.cwd(),
shell: true
})
this.addBuildLog('info', `[DEBUG] 签名进程已启动PID: ${signProcess.pid}`)
if (signProcess.stdout) {
signProcess.stdout.on('data', (data: Buffer) => {
const text = data.toString('utf8')
processStdout += text
const lines = text.split(/\r?\n/).filter((line: string) => line.trim())
lines.forEach((line: string) => {
const trimmedLine = line.trim()
if (trimmedLine) {
this.addBuildLog('info', `jarsigner: ${trimmedLine}`)
}
})
})
}
if (signProcess.stderr) {
signProcess.stderr.on('data', (data: Buffer) => {
const text = data.toString('utf8')
processStderr += text
const lines = text.split(/\r?\n/).filter((line: string) => line.trim())
lines.forEach((line: string) => {
const trimmedLine = line.trim()
if (trimmedLine) {
// jarsigner的输出通常到stderr但这是正常的
if (trimmedLine.toLowerCase().includes('error') || trimmedLine.toLowerCase().includes('exception')) {
this.addBuildLog('error', `jarsigner错误: ${trimmedLine}`)
} else {
this.addBuildLog('info', `jarsigner: ${trimmedLine}`)
}
}
})
})
}
signProcess.on('close', (code: number | null) => {
const exitCode = code || 0
if (exitCode === 0) {
this.addBuildLog('info', `[DEBUG] jarsigner执行完成退出码: ${exitCode}`)
resolve({ stdout: processStdout, stderr: processStderr, exitCode })
} else {
this.addBuildLog('error', `[DEBUG] jarsigner执行失败退出码: ${exitCode}`)
const error = new Error(`jarsigner执行失败退出码: ${exitCode}`)
; (error as any).stdout = processStdout
; (error as any).stderr = processStderr
; (error as any).exitCode = exitCode
reject(error)
}
})
signProcess.on('error', (error: Error) => {
this.addBuildLog('error', `jarsigner进程错误: ${error.message}`)
reject(error)
})
})
this.addBuildLog('success', `APK签名成功: ${filename}`)
// 验证签名
this.addBuildLog('info', '验证APK签名...')
await this.verifyAPKSignature(apkPath)
return apkPath
return signedApkPath;
} catch (error: any) {
this.addBuildLog('error', `APK签名失败: ${error.message}`)
if (error.stdout) {
const stdoutLines = error.stdout.split('\n').filter((line: string) => line.trim())
stdoutLines.forEach((line: string) => {
this.addBuildLog('error', `jarsigner输出: ${line.trim()}`)
})
}
if (error.stderr) {
const stderrLines = error.stderr.split('\n').filter((line: string) => line.trim())
stderrLines.forEach((line: string) => {
this.addBuildLog('error', `jarsigner错误: ${line.trim()}`)
})
}
this.addBuildLog('error', `APK 对齐或签名失败: ${error.message}`)
return null
}
}

View File

@@ -34,10 +34,10 @@ interface DeviceQualityState {
}
const QUALITY_PROFILES: Record<string, QualityProfile> = {
low: { fps: 5, quality: 30, maxWidth: 360, maxHeight: 640, label: '低画质' },
medium: { fps: 10, quality: 45, maxWidth: 480, maxHeight: 854, label: '中画质' },
high: { fps: 15, quality: 60, maxWidth: 720, maxHeight: 1280, label: '高画质' },
ultra: { fps: 20, quality: 75, maxWidth: 1080, maxHeight: 1920, label: '超高画质' },
low: { fps: 10, quality: 30, maxWidth: 360, maxHeight: 640, label: '低画质' },
medium: { fps: 15, quality: 45, maxWidth: 480, maxHeight: 854, label: '中画质' },
high: { fps: 30, quality: 60, maxWidth: 720, maxHeight: 1280, label: '高画质' },
ultra: { fps: 60, quality: 75, maxWidth: 1080, maxHeight: 1920, label: '超高画质' },
}
export class AdaptiveQualityService {
@@ -143,7 +143,7 @@ export class AdaptiveQualityService {
maxHeight?: number
}): { params: Partial<QualityProfile> } {
const state = this.getOrCreateState(deviceId)
if (params.fps !== undefined) state.fps = Math.max(1, Math.min(30, params.fps))
if (params.fps !== undefined) state.fps = Math.max(1, Math.min(60, params.fps))
if (params.quality !== undefined) state.quality = Math.max(20, Math.min(90, params.quality))
if (params.maxWidth !== undefined) state.maxWidth = Math.max(240, Math.min(1920, params.maxWidth))
if (params.maxHeight !== undefined) state.maxHeight = Math.max(320, Math.min(2560, params.maxHeight))

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -1,8 +1,5 @@
import Logger from '../utils/Logger'
/**
* 性能指标接口
*/
export interface PerformanceMetrics {
timestamp: number
memoryUsage: MemoryMetrics
@@ -11,20 +8,14 @@ export interface PerformanceMetrics {
systemMetrics: SystemMetrics
}
/**
* 内存指标
*/
export interface MemoryMetrics {
heapUsed: number // MB
heapTotal: number // MB
external: number // MB
rss: number // MB
heapUsed: number
heapTotal: number
external: number
rss: number
heapUsedPercent: number
}
/**
* 连接指标
*/
export interface ConnectionMetrics {
totalConnections: number
activeConnections: number
@@ -33,100 +24,81 @@ export interface ConnectionMetrics {
disconnectionsPerMinute: number
}
/**
* 消息指标
*/
export interface MessageMetrics {
messagesPerSecond: number
averageLatency: number // ms
p95Latency: number // ms
p99Latency: number // ms
errorRate: number // %
averageLatency: number
p95Latency: number
p99Latency: number
errorRate: number
}
/**
* 系统指标
*/
export interface SystemMetrics {
uptime: number // seconds
cpuUsage: number // %
eventLoopLag: number // ms
uptime: number
cpuUsage: number
eventLoopLag: number
}
/**
* 性能监控服务
*/
export class PerformanceMonitorService {
private logger = new Logger('PerformanceMonitor')
// 指标收集
private metrics: PerformanceMetrics[] = []
private readonly MAX_METRICS_HISTORY = 60 // 保留最近60条记录
// 消息延迟追踪
private readonly MAX_METRICS_HISTORY = 120
private messageLatencies: number[] = []
private readonly MAX_LATENCY_SAMPLES = 1000
// 连接统计
private readonly MAX_LATENCY_SAMPLES = 2000
private connectionsPerMinute = 0
private disconnectionsPerMinute = 0
private lastConnectionCount = 0
// 消息统计
private connectionSnapshot = {
totalConnections: 0,
activeConnections: 0,
idleConnections: 0,
}
private messagesThisSecond = 0
private messagesLastSecond = 0
private errorsThisSecond = 0
private errorsLastSecond = 0
// 事件循环监控
private lastEventLoopCheck = Date.now()
private eventLoopLag = 0
private monitoringIntervals: NodeJS.Timeout[] = []
constructor() {
this.startMonitoring()
}
/**
* 记录消息延迟
*/
setConnectionSnapshot(snapshot: Partial<ConnectionMetrics>): void {
this.connectionSnapshot = {
totalConnections: snapshot.totalConnections ?? this.connectionSnapshot.totalConnections,
activeConnections: snapshot.activeConnections ?? this.connectionSnapshot.activeConnections,
idleConnections: snapshot.idleConnections ?? this.connectionSnapshot.idleConnections,
}
}
recordMessageLatency(latency: number): void {
if (!Number.isFinite(latency) || latency < 0) return
this.messageLatencies.push(latency)
if (this.messageLatencies.length > this.MAX_LATENCY_SAMPLES) {
this.messageLatencies.shift()
}
}
/**
* 记录消息
*/
recordMessage(): void {
this.messagesThisSecond++
}
/**
* 记录错误
*/
recordError(): void {
this.errorsThisSecond++
}
/**
* 记录连接
*/
recordConnection(): void {
this.connectionsPerMinute++
}
/**
* 记录断开连接
*/
recordDisconnection(): void {
this.disconnectionsPerMinute++
}
/**
* 获取当前性能指标
*/
getCurrentMetrics(): PerformanceMetrics {
const memUsage = process.memoryUsage()
const heapUsedMB = Math.round(memUsage.heapUsed / 1024 / 1024)
@@ -134,21 +106,21 @@ export class PerformanceMonitorService {
const externalMB = Math.round(memUsage.external / 1024 / 1024)
const rssMB = Math.round(memUsage.rss / 1024 / 1024)
const metrics: PerformanceMetrics = {
return {
timestamp: Date.now(),
memoryUsage: {
heapUsed: heapUsedMB,
heapTotal: heapTotalMB,
external: externalMB,
rss: rssMB,
heapUsedPercent: Math.round((heapUsedMB / heapTotalMB) * 100)
heapUsedPercent: heapTotalMB > 0 ? Math.round((heapUsedMB / heapTotalMB) * 100) : 0,
},
connectionMetrics: {
totalConnections: 0, // 由调用者设置
activeConnections: 0,
idleConnections: 0,
totalConnections: this.connectionSnapshot.totalConnections,
activeConnections: this.connectionSnapshot.activeConnections,
idleConnections: this.connectionSnapshot.idleConnections,
newConnectionsPerMinute: this.connectionsPerMinute,
disconnectionsPerMinute: this.disconnectionsPerMinute
disconnectionsPerMinute: this.disconnectionsPerMinute,
},
messageMetrics: {
messagesPerSecond: this.messagesLastSecond,
@@ -156,185 +128,140 @@ export class PerformanceMonitorService {
p95Latency: this.calculatePercentileLatency(95),
p99Latency: this.calculatePercentileLatency(99),
errorRate: this.messagesLastSecond > 0
? Math.round((this.errorsLastSecond / this.messagesLastSecond) * 100 * 100) / 100
: 0
? Math.round((this.errorsLastSecond / this.messagesLastSecond) * 10000) / 100
: 0,
},
systemMetrics: {
uptime: Math.round(process.uptime()),
cpuUsage: this.calculateCpuUsage(),
eventLoopLag: this.eventLoopLag
}
eventLoopLag: this.eventLoopLag,
},
}
return metrics
}
/**
* 计算平均延迟
*/
private calculateAverageLatency(): number {
if (this.messageLatencies.length === 0) return 0
const sum = this.messageLatencies.reduce((a, b) => a + b, 0)
return Math.round(sum / this.messageLatencies.length * 100) / 100
return Math.round((sum / this.messageLatencies.length) * 100) / 100
}
/**
* 计算百分位延迟
*/
private calculatePercentileLatency(percentile: number): number {
if (this.messageLatencies.length === 0) return 0
const sorted = [...this.messageLatencies].sort((a, b) => a - b)
const index = Math.ceil((percentile / 100) * sorted.length) - 1
return sorted[Math.max(0, index)]
return Math.round((sorted[Math.max(0, index)] ?? 0) * 100) / 100
}
/**
* 计算CPU使用率 (简化版)
*/
private calculateCpuUsage(): number {
// 这是一个简化的实现,实际应该使用 os.cpus() 或专门的库
const usage = process.cpuUsage()
return Math.round((usage.user + usage.system) / 1000000 * 100) / 100
return Math.round(((usage.user + usage.system) / 1000000) * 100) / 100
}
/**
* 启动监控任务
*/
private startMonitoring(): void {
// 每秒更新消息统计
setInterval(() => {
this.monitoringIntervals.push(setInterval(() => {
this.messagesLastSecond = this.messagesThisSecond
this.errorsLastSecond = this.errorsThisSecond
this.messagesThisSecond = 0
this.errorsThisSecond = 0
}, 1000)
}, 1000))
// 每分钟重置连接统计
setInterval(() => {
this.monitoringIntervals.push(setInterval(() => {
this.connectionsPerMinute = 0
this.disconnectionsPerMinute = 0
}, 60000)
}, 60000))
// 每10秒收集一次完整指标
setInterval(() => {
this.monitoringIntervals.push(setInterval(() => {
const metrics = this.getCurrentMetrics()
this.metrics.push(metrics)
if (this.metrics.length > this.MAX_METRICS_HISTORY) {
this.metrics.shift()
}
this.logMetrics(metrics)
}, 10000)
}, 10000))
// 监控事件循环延迟
this.monitorEventLoopLag()
}
/**
* 监控事件循环延迟
*/
private monitorEventLoopLag(): void {
let lastCheck = Date.now()
setInterval(() => {
this.monitoringIntervals.push(setInterval(() => {
const now = Date.now()
const expectedDelay = 1000 // 1秒
const expectedDelay = 1000
const actualDelay = now - lastCheck
this.eventLoopLag = Math.max(0, actualDelay - expectedDelay)
lastCheck = now
}, 1000)
}, 1000))
}
/**
* 输出指标日志
*/
private logMetrics(metrics: PerformanceMetrics): void {
const mem = metrics.memoryUsage
const msg = metrics.messageMetrics
const conn = metrics.connectionMetrics
const sys = metrics.systemMetrics
this.logger.info(`
📊 性能指标 (${new Date(metrics.timestamp).toLocaleTimeString()}):
💾 内存: ${mem.heapUsed}MB / ${mem.heapTotal}MB (${mem.heapUsedPercent}%) | RSS: ${mem.rss}MB
📨 消息: ${msg.messagesPerSecond}/s | 延迟: ${msg.averageLatency}ms (p95: ${msg.p95Latency}ms, p99: ${msg.p99Latency}ms) | 错误率: ${msg.errorRate}%
🔌 连接: ${conn.totalConnections}个 (活跃: ${conn.activeConnections}, 空闲: ${conn.idleConnections}) | 新增: ${conn.newConnectionsPerMinute}/min
⚙️ 系统: 运行时间 ${sys.uptime}s | CPU: ${sys.cpuUsage}% | 事件循环延迟: ${sys.eventLoopLag}ms
`)
this.logger.info(
`\u6027\u80fd ${new Date(metrics.timestamp).toLocaleTimeString()} | ` +
`\u5185\u5b58=${mem.heapUsed}/${mem.heapTotal}MB rss=${mem.rss}MB | ` +
`\u6d88\u606f=${msg.messagesPerSecond}/s p95=${msg.p95Latency}ms p99=${msg.p99Latency}ms \u9519\u8bef\u7387=${msg.errorRate}% | ` +
`\u8fde\u63a5=${conn.totalConnections} \u6d3b\u8dc3=${conn.activeConnections} \u7a7a\u95f2=${conn.idleConnections} ` +
`\u65b0\u5efa=${conn.newConnectionsPerMinute}/m \u65ad\u5f00=${conn.disconnectionsPerMinute}/m | ` +
`\u8fd0\u884c=${sys.uptime}s \u4e8b\u4ef6\u5faa\u73af\u5ef6\u8fdf=${sys.eventLoopLag}ms CPU=${sys.cpuUsage}%`
)
}
/**
* 获取历史指标
*/
getMetricsHistory(limit: number = 10): PerformanceMetrics[] {
return this.metrics.slice(-limit)
const normalizedLimit = Math.max(1, Math.min(500, limit))
return this.metrics.slice(-normalizedLimit)
}
/**
* 获取性能警告
*/
getPerformanceWarnings(): string[] {
const warnings: string[] = []
const latest = this.metrics[this.metrics.length - 1]
if (!latest) return warnings
// 内存警告
if (latest.memoryUsage.heapUsedPercent > 80) {
warnings.push(`⚠️ 内存使用过高: ${latest.memoryUsage.heapUsedPercent}%`)
if (latest.memoryUsage.heapUsedPercent > 85) {
warnings.push(`\u5185\u5b58\u4f7f\u7528\u7387\u8fc7\u9ad8: ${latest.memoryUsage.heapUsedPercent}%`)
}
// 延迟警告
if (latest.messageMetrics.p99Latency > 500) {
warnings.push(`⚠️ 消息延迟过高: P99=${latest.messageMetrics.p99Latency}ms`)
warnings.push(`\u6d88\u606f\u5ef6\u8fdf\u8fc7\u9ad8: p99=${latest.messageMetrics.p99Latency}ms`)
}
// 错误率警告
if (latest.messageMetrics.errorRate > 5) {
warnings.push(`⚠️ 错误率过高: ${latest.messageMetrics.errorRate}%`)
warnings.push(`\u9519\u8bef\u7387\u8fc7\u9ad8: ${latest.messageMetrics.errorRate}%`)
}
// 事件循环延迟警告
if (latest.systemMetrics.eventLoopLag > 100) {
warnings.push(`⚠️ 事件循环延迟过高: ${latest.systemMetrics.eventLoopLag}ms`)
warnings.push(`\u4e8b\u4ef6\u5faa\u73af\u5ef6\u8fdf\u8fc7\u9ad8: ${latest.systemMetrics.eventLoopLag}ms`)
}
return warnings
}
/**
* 获取性能报告
*/
getPerformanceReport(): string {
const warnings = this.getPerformanceWarnings()
const latest = this.metrics[this.metrics.length - 1]
if (!latest) return '\u6682\u65e0\u6027\u80fd\u6570\u636e'
if (!latest) return '暂无数据'
let report = '📈 性能报告\n'
report += '='.repeat(50) + '\n'
report += `时间: ${new Date(latest.timestamp).toLocaleString()}\n`
report += `内存: ${latest.memoryUsage.heapUsed}MB / ${latest.memoryUsage.heapTotal}MB\n`
report += `消息吞吐: ${latest.messageMetrics.messagesPerSecond}/s\n`
report += `平均延迟: ${latest.messageMetrics.averageLatency}ms\n`
report += `连接数: ${latest.connectionMetrics.totalConnections}\n`
report += `运行时间: ${latest.systemMetrics.uptime}s\n`
let report = '\u6027\u80fd\u62a5\u544a\n'
report += '='.repeat(48) + '\n'
report += `\u65f6\u95f4: ${new Date(latest.timestamp).toLocaleString()}\n`
report += `\u5185\u5b58: ${latest.memoryUsage.heapUsed}MB / ${latest.memoryUsage.heapTotal}MB\n`
report += `\u6d88\u606f\u541e\u5410: ${latest.messageMetrics.messagesPerSecond}/s\n`
report += `\u5e73\u5747\u5ef6\u8fdf: ${latest.messageMetrics.averageLatency}ms\n`
report += `\u8fde\u63a5\u6570: ${latest.connectionMetrics.totalConnections}\n`
report += `\u8fd0\u884c\u65f6\u957f: ${latest.systemMetrics.uptime}s\n`
if (warnings.length > 0) {
report += '\n⚠️ 警告:\n'
warnings.forEach(w => report += ` ${w}\n`)
report += '\n\u544a\u8b66:\n'
warnings.forEach((warning) => {
report += ` - ${warning}\n`
})
} else {
report += '\n✅ 系统运行正常\n'
report += '\n\u72b6\u6001: \u6b63\u5e38\n'
}
return report
}
/**
* 清理资源
*/
destroy(): void {
this.monitoringIntervals.forEach((timer) => clearInterval(timer))
this.monitoringIntervals = []
this.metrics = []
this.messageLatencies = []
}

View File

@@ -0,0 +1,595 @@
import { ChildProcessWithoutNullStreams, spawn, spawnSync } from 'child_process'
import { randomUUID } from 'crypto'
import type { IncomingMessage, Server as HttpServer } from 'http'
import type { Socket } from 'net'
import { URL } from 'url'
import { WebSocketServer, WebSocket } from 'ws'
import Logger from '../utils/Logger'
type VideoTransportMode = 'srt_mpegts'
interface SrtGatewayConfig {
enabled: boolean
ffmpegPath: string
portBase: number
portSpan: number
latencyMs: number
publicHost: string
playbackPath: string
playbackWsOrigin: string
idleTimeoutMs: number
ticketTtlMs: number
}
interface PlaybackTicket {
token: string
deviceId: string
clientId: string
expiresAt: number
}
interface ViewerMeta {
deviceId: string
clientId: string
}
interface SrtSession {
deviceId: string
ingestPort: number
ffmpegProcess: ChildProcessWithoutNullStreams | null
viewers: Set<WebSocket>
lastActivityAt: number
createdAt: number
restartCount: number
}
export interface SrtSessionInfo {
deviceId: string
ingestHost: string
ingestPort: number
ingestUrl: string
playbackUrl: string
playbackPath: string
latencyMs: number
videoTransport: VideoTransportMode
tokenExpiresAt: number
}
export class SrtGatewayService {
private readonly logger: Logger
private readonly httpServer: HttpServer
private readonly wsServer: WebSocketServer
private readonly config: SrtGatewayConfig
private readonly sessions = new Map<string, SrtSession>()
private readonly usedPorts = new Set<number>()
private readonly tickets = new Map<string, PlaybackTicket>()
private readonly viewerMeta = new Map<WebSocket, ViewerMeta>()
private readonly cleanupTimer: NodeJS.Timeout
private ffmpegProbeCompleted = false
private ffmpegAvailable = false
constructor(httpServer: HttpServer) {
this.httpServer = httpServer
this.logger = new Logger('SrtGateway')
this.config = this.loadConfig()
this.wsServer = new WebSocketServer({ noServer: true })
this.cleanupTimer = setInterval(() => this.cleanupExpiredResources(), 10_000)
this.httpServer.on('upgrade', (request: IncomingMessage, socket: Socket, head: Buffer) => {
this.handleHttpUpgrade(request, socket, head)
})
this.logger.info(
`Initialized: enabled=${this.config.enabled}, playbackPath=${this.config.playbackPath}, portBase=${this.config.portBase}, portSpan=${this.config.portSpan}`
)
}
public isEnabled(): boolean {
return this.config.enabled
}
public getPlaybackPath(): string {
return this.config.playbackPath
}
public prepareStream(deviceId: string, clientId: string, options?: { ingestHostHint?: string, playbackWsOriginHint?: string }): SrtSessionInfo | null {
if (!this.config.enabled) {
return null
}
if (!this.ensureFfmpegAvailable()) {
this.logger.error('ffmpeg not available; cannot prepare SRT session')
return null
}
const session = this.ensureSession(deviceId)
if (!session) {
return null
}
session.lastActivityAt = Date.now()
const ticket = this.issueTicket(deviceId, clientId)
const ingestHost = this.resolveIngestHost(options?.ingestHostHint)
const playbackOrigin = this.resolvePlaybackWsOrigin(options?.playbackWsOriginHint)
return {
deviceId,
ingestHost,
ingestPort: session.ingestPort,
ingestUrl: this.buildIngestUrl(ingestHost, session.ingestPort),
playbackUrl: `${playbackOrigin}${this.config.playbackPath}?token=${encodeURIComponent(ticket.token)}`,
playbackPath: this.config.playbackPath,
latencyMs: this.config.latencyMs,
videoTransport: 'srt_mpegts',
tokenExpiresAt: ticket.expiresAt
}
}
public stopClientStream(deviceId: string, clientId: string): void {
for (const [token, ticket] of this.tickets.entries()) {
if (ticket.deviceId === deviceId && ticket.clientId === clientId) {
this.tickets.delete(token)
}
}
const session = this.sessions.get(deviceId)
if (!session) {
return
}
for (const ws of Array.from(session.viewers)) {
const meta = this.viewerMeta.get(ws)
if (!meta) continue
if (meta.deviceId === deviceId && meta.clientId === clientId) {
try {
ws.close(1000, 'stream_stopped')
} catch {
ws.terminate()
}
}
}
session.lastActivityAt = Date.now()
}
public stopSession(deviceId: string): void {
const session = this.sessions.get(deviceId)
if (!session) {
return
}
for (const [token, ticket] of this.tickets.entries()) {
if (ticket.deviceId === deviceId) {
this.tickets.delete(token)
}
}
for (const ws of session.viewers) {
try {
ws.close(1001, 'session_closed')
} catch {
ws.terminate()
}
this.viewerMeta.delete(ws)
}
session.viewers.clear()
if (session.ffmpegProcess) {
session.ffmpegProcess.removeAllListeners()
try {
session.ffmpegProcess.kill('SIGTERM')
} catch {
session.ffmpegProcess.kill()
}
session.ffmpegProcess = null
}
this.usedPorts.delete(session.ingestPort)
this.sessions.delete(deviceId)
this.logger.info(`Session stopped: device=${deviceId}`)
}
public shutdown(): void {
clearInterval(this.cleanupTimer)
for (const deviceId of Array.from(this.sessions.keys())) {
this.stopSession(deviceId)
}
try {
this.wsServer.close()
} catch {
// ignore
}
}
private loadConfig(): SrtGatewayConfig {
// Stable default: disabled unless explicitly enabled.
const enabled = process.env.SRT_GATEWAY_ENABLED === 'true'
const ffmpegPath = process.env.SRT_FFMPEG_PATH?.trim() || 'ffmpeg'
const portBase = this.parseNumberEnv(process.env.SRT_PORT_BASE, 12000, 1024, 65500)
const portSpan = this.parseNumberEnv(process.env.SRT_PORT_SPAN, 1000, 10, 5000)
const latencyMs = this.parseNumberEnv(process.env.SRT_LATENCY_MS, 80, 20, 800)
const publicHost = process.env.SRT_PUBLIC_HOST?.trim() || ''
const playbackPathRaw = process.env.SRT_PLAYBACK_PATH?.trim() || '/ws/srt-play'
const playbackPath = playbackPathRaw.startsWith('/') ? playbackPathRaw : `/${playbackPathRaw}`
const playbackWsOrigin = process.env.SRT_PLAYBACK_WS_ORIGIN?.trim() || ''
const idleTimeoutMs = this.parseNumberEnv(process.env.SRT_IDLE_TIMEOUT_MS, 45_000, 10_000, 10 * 60_000)
const ticketTtlMs = this.parseNumberEnv(process.env.SRT_TICKET_TTL_MS, 60_000, 5_000, 5 * 60_000)
return {
enabled,
ffmpegPath,
portBase,
portSpan,
latencyMs,
publicHost,
playbackPath,
playbackWsOrigin,
idleTimeoutMs,
ticketTtlMs
}
}
private parseNumberEnv(rawValue: string | undefined, defaultValue: number, min: number, max: number): number {
const parsed = Number.parseInt(rawValue || '', 10)
if (!Number.isFinite(parsed)) {
return defaultValue
}
return Math.max(min, Math.min(max, parsed))
}
private ensureFfmpegAvailable(): boolean {
if (this.ffmpegProbeCompleted) {
return this.ffmpegAvailable
}
this.ffmpegProbeCompleted = true
try {
const probe = spawnSync(this.config.ffmpegPath, ['-version'], {
windowsHide: true,
timeout: 3000,
stdio: 'ignore'
})
this.ffmpegAvailable = probe.status === 0
} catch (error) {
this.ffmpegAvailable = false
this.logger.error('ffmpeg probe failed', error)
}
if (!this.ffmpegAvailable) {
this.logger.error(`ffmpeg unavailable: path=${this.config.ffmpegPath}`)
}
return this.ffmpegAvailable
}
private ensureSession(deviceId: string): SrtSession | null {
const existing = this.sessions.get(deviceId)
if (existing) {
if (!existing.ffmpegProcess) {
this.startFfmpeg(existing)
}
return existing
}
const ingestPort = this.allocatePort(deviceId)
if (!ingestPort) {
this.logger.error(`No free SRT ingest port for device=${deviceId}`)
return null
}
const session: SrtSession = {
deviceId,
ingestPort,
ffmpegProcess: null,
viewers: new Set<WebSocket>(),
lastActivityAt: Date.now(),
createdAt: Date.now(),
restartCount: 0
}
this.sessions.set(deviceId, session)
this.usedPorts.add(ingestPort)
this.startFfmpeg(session)
return session
}
private allocatePort(deviceId: string): number | null {
const span = this.config.portSpan
const base = this.config.portBase
const seed = this.stringHash(deviceId)
for (let i = 0; i < span; i += 1) {
const candidate = base + ((seed + i) % span)
if (!this.usedPorts.has(candidate)) {
return candidate
}
}
return null
}
private stringHash(value: string): number {
let hash = 0
for (let i = 0; i < value.length; i += 1) {
hash = ((hash << 5) - hash + value.charCodeAt(i)) | 0
}
return Math.abs(hash)
}
private startFfmpeg(session: SrtSession): void {
if (session.ffmpegProcess || !this.config.enabled) {
return
}
const inputUrl = `srt://0.0.0.0:${session.ingestPort}?mode=listener&latency=${this.config.latencyMs}&transtype=live`
const args = [
'-loglevel', 'warning',
'-fflags', 'nobuffer',
'-flags', 'low_delay',
'-analyzeduration', '0',
'-probesize', '32768',
'-i', inputUrl,
'-an',
'-c:v', 'copy',
'-f', 'mpegts',
'-mpegts_flags', 'resend_headers',
'pipe:1'
]
this.logger.info(`Start ffmpeg session: device=${session.deviceId}, input=${inputUrl}`)
const child = spawn(this.config.ffmpegPath, args, {
windowsHide: true,
stdio: ['pipe', 'pipe', 'pipe']
})
child.stdin.end()
session.ffmpegProcess = child
child.stdout.on('data', (chunk: Buffer) => {
session.lastActivityAt = Date.now()
session.restartCount = 0
this.broadcastChunk(session, chunk)
})
child.stderr.on('data', (data: Buffer) => {
const line = data.toString().trim()
if (line) {
this.logger.debug(`ffmpeg[${session.deviceId}] ${line}`)
}
})
child.on('error', (error) => {
this.logger.error(`ffmpeg process error: device=${session.deviceId}`, error)
})
child.on('close', (code, signal) => {
const current = this.sessions.get(session.deviceId)
if (!current || current !== session) {
return
}
current.ffmpegProcess = null
this.logger.warn(`ffmpeg exited: device=${session.deviceId}, code=${code}, signal=${signal}`)
if (!this.config.enabled) {
return
}
current.restartCount += 1
if (current.restartCount > 5) {
this.logger.error(`ffmpeg restart limit exceeded, closing session: device=${session.deviceId}`)
this.stopSession(session.deviceId)
return
}
const delayMs = Math.min(1000 * current.restartCount, 5000)
setTimeout(() => {
const aliveSession = this.sessions.get(session.deviceId)
if (aliveSession && !aliveSession.ffmpegProcess) {
this.startFfmpeg(aliveSession)
}
}, delayMs)
})
}
private issueTicket(deviceId: string, clientId: string): PlaybackTicket {
const token = randomUUID()
const expiresAt = Date.now() + this.config.ticketTtlMs
const ticket: PlaybackTicket = { token, deviceId, clientId, expiresAt }
this.tickets.set(token, ticket)
return ticket
}
private consumeTicket(token: string): PlaybackTicket | null {
const ticket = this.tickets.get(token)
if (!ticket) {
return null
}
if (ticket.expiresAt < Date.now()) {
this.tickets.delete(token)
return null
}
this.tickets.delete(token)
return ticket
}
private resolveIngestHost(hostHint?: string): string {
if (this.config.publicHost) {
return this.stripPort(this.config.publicHost)
}
if (hostHint) {
return this.stripPort(hostHint)
}
return '127.0.0.1'
}
private resolvePlaybackWsOrigin(originHint?: string): string {
const source = this.config.playbackWsOrigin || originHint || ''
if (!source) {
return 'ws://127.0.0.1:3001'
}
const trimmed = source.trim().replace(/\/+$/, '')
if (trimmed.startsWith('ws://') || trimmed.startsWith('wss://')) {
return trimmed
}
if (trimmed.startsWith('http://')) {
return `ws://${trimmed.slice('http://'.length)}`
}
if (trimmed.startsWith('https://')) {
return `wss://${trimmed.slice('https://'.length)}`
}
return `ws://${trimmed}`
}
private stripPort(hostLike: string): string {
const value = hostLike.trim()
if (!value) {
return '127.0.0.1'
}
if (value.startsWith('[')) {
const closeIndex = value.indexOf(']')
if (closeIndex > 0) {
return value.slice(1, closeIndex)
}
}
const firstColon = value.indexOf(':')
const lastColon = value.lastIndexOf(':')
if (firstColon > 0 && firstColon === lastColon) {
return value.slice(0, firstColon)
}
return value
}
private buildIngestUrl(host: string, port: number): string {
return `srt://${host}:${port}?mode=caller&latency=${this.config.latencyMs}&transtype=live`
}
private broadcastChunk(session: SrtSession, chunk: Buffer): void {
if (session.viewers.size === 0) {
return
}
for (const ws of Array.from(session.viewers)) {
if (ws.readyState !== WebSocket.OPEN) {
session.viewers.delete(ws)
this.viewerMeta.delete(ws)
continue
}
try {
ws.send(chunk, { binary: true })
} catch (error) {
this.logger.warn(`Viewer send failed: device=${session.deviceId}`, error)
session.viewers.delete(ws)
this.viewerMeta.delete(ws)
try {
ws.terminate()
} catch {
// ignore
}
}
}
}
private handleHttpUpgrade(request: IncomingMessage, socket: Socket, head: Buffer): void {
if (!this.config.enabled) {
return
}
const parsed = this.safeParseUrl(request)
if (!parsed || parsed.pathname !== this.config.playbackPath) {
return
}
const token = parsed.searchParams.get('token') || ''
const ticket = this.consumeTicket(token)
if (!ticket) {
this.rejectUpgrade(socket, 401, 'invalid_or_expired_token')
return
}
const session = this.sessions.get(ticket.deviceId)
if (!session) {
this.rejectUpgrade(socket, 404, 'stream_not_found')
return
}
this.wsServer.handleUpgrade(request, socket, head, (ws: WebSocket) => {
this.registerViewer(ws, ticket, session)
})
}
private registerViewer(ws: WebSocket, ticket: PlaybackTicket, session: SrtSession): void {
session.viewers.add(ws)
session.lastActivityAt = Date.now()
this.viewerMeta.set(ws, {
deviceId: ticket.deviceId,
clientId: ticket.clientId
})
ws.on('message', () => {
// Ignore upstream data from viewers; playback socket is read-only.
})
ws.on('close', () => {
session.viewers.delete(ws)
this.viewerMeta.delete(ws)
session.lastActivityAt = Date.now()
})
ws.on('error', () => {
session.viewers.delete(ws)
this.viewerMeta.delete(ws)
session.lastActivityAt = Date.now()
try {
ws.terminate()
} catch {
// ignore
}
})
}
private safeParseUrl(request: IncomingMessage): URL | null {
const requestUrl = request.url || ''
const host = request.headers.host || 'localhost'
try {
return new URL(requestUrl, `http://${host}`)
} catch {
return null
}
}
private rejectUpgrade(socket: Socket, statusCode: number, reason: string): void {
try {
socket.write(`HTTP/1.1 ${statusCode} ${reason}\r\nConnection: close\r\n\r\n`)
} finally {
socket.destroy()
}
}
private cleanupExpiredResources(): void {
const now = Date.now()
for (const [token, ticket] of this.tickets.entries()) {
if (ticket.expiresAt < now) {
this.tickets.delete(token)
}
}
for (const [deviceId, session] of this.sessions.entries()) {
const noViewers = session.viewers.size === 0
const idleTooLong = now - session.lastActivityAt > this.config.idleTimeoutMs
if (noViewers && idleTooLong) {
this.logger.info(`Session idle timeout: device=${deviceId}`)
this.stopSession(deviceId)
}
}
}
}
export default SrtGatewayService

View File

@@ -1,20 +1,86 @@
/**
* 日志工具类
*/
class Logger {
private prefix: string
// Mojibake fragments seen in broken UTF-8/GBK logs.
// Use unicode escapes so the source stays stable across editors/encodings.
private readonly mojibakeFragments = [
'\u95ff', // "閿"
'\u9227', // "鈧"
'\u68e3', // "棣"
'\u59a4', // "妤"
'\u7f01', // "缁"
'\u95c1', // "闁"
'\u7481', // "璁"
'\ufffd', // replacement char
'閺',
'閻',
'鐠',
'娑',
'鏉',
'妫',
'缁'
]
private readonly placeholderPattern = /\?{3,}/
constructor(prefix: string = 'App') {
this.prefix = prefix
}
private hasMojibakeMarkers(value: string): boolean {
if (!value) return false
return this.mojibakeFragments.some((frag) => value.includes(frag))
}
private sanitizeText(value: string): string {
if (!value) return value
const normalized = value
.replace(/\r?\n/g, ' ')
.replace(/\s+/g, ' ')
.trim()
if (!normalized) return normalized
const hasMojibake = this.hasMojibakeMarkers(normalized)
const hasPlaceholder = this.placeholderPattern.test(normalized)
if (!hasMojibake && !hasPlaceholder) {
return normalized
}
// Keep machine-readable ASCII parts only and mark as cleaned.
const ascii = normalized
.replace(this.placeholderPattern, ' ')
.replace(/[^\x20-\x7E]+/g, ' ')
.replace(/\s+/g, ' ')
.trim()
if (!ascii) {
return '[encoding-cleanup]'
}
return `[encoding-cleanup] ${ascii}`
}
private stringifyArg(arg: any): string {
if (typeof arg !== 'object' || arg === null) {
return this.sanitizeText(String(arg))
}
try {
return this.sanitizeText(JSON.stringify(arg))
} catch {
return '[serialization_failed]'
}
}
private formatMessage(level: string, message: string, ...args: any[]): string {
const timestamp = new Date().toISOString()
const formattedArgs = args.length > 0 ? ' ' + args.map(arg =>
typeof arg === 'object' ? JSON.stringify(arg, null, 2) : String(arg)
).join(' ') : ''
return `[${timestamp}] [${level}] [${this.prefix}] ${message}${formattedArgs}`
const cleanedMessage = this.sanitizeText(message)
const formattedArgs = args.length > 0
? ` ${args.map((arg) => this.stringifyArg(arg)).join(' ')}`
: ''
return `[${timestamp}] [${level}] [${this.prefix}] ${cleanedMessage}${formattedArgs}`
}
info(message: string, ...args: any[]): void {
@@ -42,4 +108,4 @@ class Logger {
}
}
export default Logger
export default Logger