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

11
.editorconfig Normal file
View File

@@ -0,0 +1,11 @@
root = true
[*]
charset = utf-8
end_of_line = lf
insert_final_newline = true
indent_style = space
indent_size = 2
[*.{ts,tsx,js,json,yml,yaml,md}]
indent_size = 2

1
package-lock.json generated
View File

@@ -14,7 +14,6 @@
"@types/multer": "^1.4.13", "@types/multer": "^1.4.13",
"@types/node": "^20.11.24", "@types/node": "^20.11.24",
"bcryptjs": "^3.0.2", "bcryptjs": "^3.0.2",
"better-sqlite3": "*",
"cors": "^2.8.5", "cors": "^2.8.5",
"dotenv": "^17.2.3", "dotenv": "^17.2.3",
"express": "^4.18.2", "express": "^4.18.2",

File diff suppressed because it is too large Load Diff

View File

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

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 Logger from '../utils/Logger'
import { DatabaseService } from '../services/DatabaseService' import { DatabaseService } from '../services/DatabaseService'
/** /**
* Web客户端信息接口 * comment cleaned
*/ */
export interface WebClientInfo { export interface WebClientInfo {
id: string id: string
@@ -13,12 +13,18 @@ export interface WebClientInfo {
connectedAt: Date connectedAt: Date
lastSeen: Date lastSeen: Date
controllingDeviceId?: string controllingDeviceId?: string
userId?: string // 🔐 添加用户ID字段 userId?: string
username?: 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 { class WebClientManager {
private clients: Map<string, WebClientInfo> = new Map() private clients: Map<string, WebClientInfo> = new Map()
@@ -26,11 +32,13 @@ class WebClientManager {
private deviceControllers: Map<string, string> = new Map() // deviceId -> clientId private deviceControllers: Map<string, string> = new Map() // deviceId -> clientId
private logger: Logger private logger: Logger
public io?: SocketIOServer 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 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) { constructor(databaseService?: DatabaseService) {
this.logger = new Logger('WebClientManager') this.logger = new Logger('WebClientManager')
@@ -38,7 +46,7 @@ class WebClientManager {
} }
/** /**
* ✅ 清理所有客户端记录(服务器重启时调用) * comment cleaned
*/ */
clearAllClients(): void { clearAllClients(): void {
const clientCount = this.clients.size const clientCount = this.clients.size
@@ -46,24 +54,29 @@ class WebClientManager {
this.socketToClient.clear() this.socketToClient.clear()
this.deviceControllers.clear() this.deviceControllers.clear()
this.requestTimestamps.clear() this.requestTimestamps.clear()
this.logger.info(`🧹 已清理所有客户端记录: ${clientCount} 个客户端`) this.videoTransportPreferences.clear()
this.logger.info(`已清理所有Web客户端记录: ${clientCount} 个客户端`)
} }
/** /**
* 设置Socket.IO实例 * comment cleaned
*/ */
setSocketIO(io: SocketIOServer): void { setSocketIO(io: SocketIOServer): void {
this.io = io this.io = io
} }
setDeviceAccessResolver(resolver: DeviceAccessResolver): void {
this.deviceAccessResolver = resolver
}
/** /**
* 添加Web客户端 * comment cleaned
*/ */
addClient(clientInfo: WebClientInfo): void { addClient(clientInfo: WebClientInfo): void {
// 🔧 检查是否已有相同Socket ID的客户端记录 // comment cleaned
const existingClientId = this.socketToClient.get(clientInfo.socketId) const existingClientId = this.socketToClient.get(clientInfo.socketId)
if (existingClientId) { if (existingClientId) {
this.logger.warn(`⚠️ Socket ${clientInfo.socketId} 已有客户端记录 ${existingClientId},清理旧记录`) this.logger.warn(`Socket ${clientInfo.socketId} 已有客户端记录 ${existingClientId}将先清理旧记录`)
this.removeClient(existingClientId) this.removeClient(existingClientId)
} }
@@ -73,7 +86,7 @@ class WebClientManager {
} }
/** /**
* 移除Web客户端 * comment cleaned
*/ */
removeClient(clientId: string): boolean { removeClient(clientId: string): boolean {
const client = this.clients.get(clientId) const client = this.clients.get(clientId)
@@ -81,15 +94,17 @@ class WebClientManager {
this.clients.delete(clientId) this.clients.delete(clientId)
this.socketToClient.delete(client.socketId) this.socketToClient.delete(client.socketId)
// 如果客户端正在控制设备,释放控制权 // comment cleaned
if (client.controllingDeviceId) { if (client.controllingDeviceId) {
this.logger.info(`🔓 客户端断开连接,自动释放设备控制权: ${clientId} -> ${client.controllingDeviceId}`) this.logger.info(`Web客户端断开连接,自动释放设备控制权: ${clientId} -> ${client.controllingDeviceId}`)
this.releaseDeviceControl(client.controllingDeviceId) this.releaseDeviceControl(client.controllingDeviceId)
} }
// 清理请求时间戳记录 // comment cleaned
const keysToDelete = Array.from(this.requestTimestamps.keys()).filter(key => key.startsWith(clientId + ':')) const keysToDelete = Array.from(this.requestTimestamps.keys()).filter(key => key.startsWith(clientId + ':'))
keysToDelete.forEach(key => this.requestTimestamps.delete(key)) 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}`) this.logger.info(`Web客户端已移除: ${clientId}`)
return true return true
@@ -98,7 +113,7 @@ class WebClientManager {
} }
/** /**
* 通过Socket ID移除客户端 * comment cleaned
*/ */
removeClientBySocketId(socketId: string): boolean { removeClientBySocketId(socketId: string): boolean {
const clientId = this.socketToClient.get(socketId) const clientId = this.socketToClient.get(socketId)
@@ -109,14 +124,14 @@ class WebClientManager {
} }
/** /**
* 获取客户端信息 * comment cleaned
*/ */
getClient(clientId: string): WebClientInfo | undefined { getClient(clientId: string): WebClientInfo | undefined {
return this.clients.get(clientId) return this.clients.get(clientId)
} }
/** /**
* 通过Socket ID获取客户端 * comment cleaned
*/ */
getClientBySocketId(socketId: string): WebClientInfo | undefined { getClientBySocketId(socketId: string): WebClientInfo | undefined {
const clientId = this.socketToClient.get(socketId) const clientId = this.socketToClient.get(socketId)
@@ -124,21 +139,25 @@ class WebClientManager {
} }
/** /**
* 获取所有客户端 * comment cleaned
*/ */
getAllClients(): WebClientInfo[] { getAllClients(): WebClientInfo[] {
return Array.from(this.clients.values()) 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 { getClientCount(): number {
return this.clients.size return this.clients.size
} }
/** /**
* 获取客户端Socket * comment cleaned
*/ */
getClientSocket(clientId: string): Socket | undefined { getClientSocket(clientId: string): Socket | undefined {
const client = this.clients.get(clientId) const client = this.clients.get(clientId)
@@ -148,57 +167,85 @@ class WebClientManager {
return undefined 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): { requestDeviceControl(clientId: string, deviceId: string): {
success: boolean success: boolean
message: string message: string
currentController?: string currentController?: string
} { } {
// 🔧 防止频繁重复请求 // comment cleaned
const requestKey = `${clientId}:${deviceId}` const requestKey = `${clientId}:${deviceId}`
const now = Date.now() const now = Date.now()
const lastRequestTime = this.requestTimestamps.get(requestKey) || 0 const lastRequestTime = this.requestTimestamps.get(requestKey) || 0
if (now - lastRequestTime < this.REQUEST_COOLDOWN) { 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 { return {
success: false, success: false,
message: '请求过于频繁,请稍后再试' message: '请求过于频繁,请稍后再试'
} }
} }
// 获取客户端信息 // comment cleaned
const client = this.clients.get(clientId) const client = this.clients.get(clientId)
if (!client) { if (!client) {
this.logger.error(`客户端不存在: ${clientId}`) this.logger.error(`客户端不存在: ${clientId}`)
return { return {
success: false, success: false,
message: '客户端不存在' message: '客户端不存在'
} }
} }
// ✅ 优化:先检查是否是重复请求(已经在控制此设备) if (!this.canClientAccessDevice(clientId, deviceId)) {
const currentController = this.deviceControllers.get(deviceId) this.logger.warn(`客户端 ${clientId} 无权控制设备 ${deviceId}`)
if (currentController === clientId) {
this.logger.debug(`🔄 客户端 ${clientId} 重复请求控制设备 ${deviceId},已在控制中`)
client.lastSeen = new Date()
// 更新请求时间戳,但返回成功(避免频繁日志)
this.requestTimestamps.set(requestKey, now)
return { return {
success: true, success: false,
message: '已在控制设备' message: '无权控制设备'
} }
} }
// 记录请求时间戳(在检查重复控制后记录) // comment cleaned
const currentController = this.deviceControllers.get(deviceId)
if (currentController === clientId) {
this.logger.debug(`客户端 ${clientId} 重复请求控制设备 ${deviceId},已在控制中`)
client.lastSeen = new Date()
// comment cleaned
this.requestTimestamps.set(requestKey, now)
return {
success: true,
message: '已在控制该设备'
}
}
// comment cleaned
this.requestTimestamps.set(requestKey, now) this.requestTimestamps.set(requestKey, now)
// 检查设备是否被其他客户端控制 // comment cleaned
if (currentController && currentController !== clientId) { if (currentController && currentController !== clientId) {
const controllerClient = this.clients.get(currentController) const controllerClient = this.clients.get(currentController)
this.logger.warn(`🚫 设备 ${deviceId} 控制权冲突: 当前控制者 ${currentController}, 请求者 ${clientId}`) this.logger.warn(`设备 ${deviceId} 控制权冲突: 当前控制者 ${currentController}, 请求者 ${clientId}`)
return { return {
success: false, success: false,
message: `设备正在被其他客户端控制 (${controllerClient?.ip || 'unknown'})`, message: `设备正在被其他客户端控制 (${controllerClient?.ip || 'unknown'})`,
@@ -206,24 +253,24 @@ class WebClientManager {
} }
} }
// 如果客户端已在控制其他设备,先释放 // comment cleaned
if (client.controllingDeviceId && client.controllingDeviceId !== deviceId) { if (client.controllingDeviceId && client.controllingDeviceId !== deviceId) {
this.logger.info(`🔄 客户端 ${clientId} 切换控制设备: ${client.controllingDeviceId} -> ${deviceId}`) this.logger.info(`客户端 ${clientId} 切换控制设备: ${client.controllingDeviceId} -> ${deviceId}`)
this.releaseDeviceControl(client.controllingDeviceId) this.releaseDeviceControl(client.controllingDeviceId)
} }
// 建立控制关系 // comment cleaned
this.deviceControllers.set(deviceId, clientId) this.deviceControllers.set(deviceId, clientId)
client.controllingDeviceId = deviceId client.controllingDeviceId = deviceId
client.lastSeen = new Date() client.lastSeen = new Date()
// 🔐 如果客户端有用户ID将权限持久化到数据库 // comment cleaned
if (client.userId && this.databaseService) { if (client.userId && this.databaseService) {
this.databaseService.grantUserDevicePermission(client.userId, deviceId, 'control') 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 { return {
success: true, success: true,
@@ -232,7 +279,7 @@ class WebClientManager {
} }
/** /**
* 释放设备控制权 * comment cleaned
*/ */
releaseDeviceControl(deviceId: string): boolean { releaseDeviceControl(deviceId: string): boolean {
const controllerId = this.deviceControllers.get(deviceId) const controllerId = this.deviceControllers.get(deviceId)
@@ -241,13 +288,19 @@ class WebClientManager {
if (client) { if (client) {
const previousDevice = client.controllingDeviceId const previousDevice = client.controllingDeviceId
client.controllingDeviceId = undefined client.controllingDeviceId = undefined
this.logger.debug(`🔓 客户端 ${controllerId} 释放设备控制权: ${previousDevice}`) this.logger.debug(`客户端 ${controllerId} 释放设备控制权 ${previousDevice}`)
} else { } else {
this.logger.warn(`⚠️ 控制设备 ${deviceId} 的客户端 ${controllerId} 不存在,可能已断开`) this.logger.warn(`控制设备 ${deviceId} 的客户端 ${controllerId} 不存在,可能已断开`)
} }
this.deviceControllers.delete(deviceId) 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 return true
} else { } else {
this.logger.debug(`🤷 设备 ${deviceId} 没有被控制,无需释放`) this.logger.debug(`🤷 设备 ${deviceId} 没有被控制,无需释放`)
@@ -256,50 +309,47 @@ class WebClientManager {
} }
/** /**
* 获取设备控制者 * comment cleaned
*/ */
getDeviceController(deviceId: string): string | undefined { getDeviceController(deviceId: string): string | undefined {
return this.deviceControllers.get(deviceId) return this.deviceControllers.get(deviceId)
} }
/** /**
* 检查客户端是否有设备控制权 * comment cleaned
*/ */
hasDeviceControl(clientId: string, deviceId: string): boolean { hasDeviceControl(clientId: string, deviceId: string): boolean {
// 🛡️ 记录权限检查审计日志
this.logPermissionOperation(clientId, deviceId, '权限检查') this.logPermissionOperation(clientId, deviceId, '权限检查')
// 🔐 获取客户端信息
const client = this.clients.get(clientId) const client = this.clients.get(clientId)
if (!client) {
// 🆕 超级管理员绕过权限检查 return false
if (client?.username) { }
const superAdminUsername = process.env.SUPERADMIN_USERNAME || 'superadmin'
if (client.username === superAdminUsername) { if (!this.canClientAccessDevice(clientId, deviceId)) {
// <20> 关键修复superadmin绕过检查时也必须建立控制关系 if (this.deviceControllers.get(deviceId) === clientId) {
// 否则 getDeviceController() 查不到控制者routeScreenData 会丢弃所有屏幕数据 this.releaseDeviceControl(deviceId)
if (!this.deviceControllers.has(deviceId) || this.deviceControllers.get(deviceId) !== clientId) { }
this.deviceControllers.set(deviceId, clientId) return false
client.controllingDeviceId = deviceId }
this.logger.info(`🔐 超级管理员 ${client.username} 绕过权限检查并建立控制关系: ${deviceId}`)
} else { if (this.isClientSuperAdmin(client)) {
this.logger.debug(`🔐 超级管理员 ${client.username} 绕过权限检查 (已有控制关系)`) if (!this.deviceControllers.has(deviceId) || this.deviceControllers.get(deviceId) !== clientId) {
} this.deviceControllers.set(deviceId, clientId)
return true client.controllingDeviceId = deviceId
} this.logger.info(`超级管理员 ${client.username} 绕过权限检查并建立控制关系: ${deviceId}`)
}
return true
} }
// 🔐 首先检查内存中的控制权
const memoryControl = this.deviceControllers.get(deviceId) === clientId const memoryControl = this.deviceControllers.get(deviceId) === clientId
if (memoryControl) { if (memoryControl) {
return true return true
} }
// 🔐 如果内存中没有控制权,检查数据库中的用户权限 if (client.userId && this.databaseService) {
if (client?.userId && this.databaseService) {
const hasPermission = this.databaseService.hasUserDevicePermission(client.userId, deviceId, 'control') const hasPermission = this.databaseService.hasUserDevicePermission(client.userId, deviceId, 'control')
if (hasPermission) { if (hasPermission) {
// 🔐 如果用户有权限,自动建立控制关系(允许权限恢复)
this.deviceControllers.set(deviceId, clientId) this.deviceControllers.set(deviceId, clientId)
client.controllingDeviceId = deviceId client.controllingDeviceId = deviceId
this.logger.info(`🔐 用户 ${client.userId} 基于数据库权限获得设备 ${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 { sendToClient(clientId: string, event: string, data: any): boolean {
const socket = this.getClientSocket(clientId) const socket = this.getClientSocket(clientId)
if (socket) { 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 true
} }
return false 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 { broadcastToAll(event: string, data: any): void {
if (this.io) { if (this.io) {
let activeClients = 0 let activeClients = 0
// 只向Web客户端广播且过滤掉已断开的连接 // comment cleaned
for (const [socketId, clientId] of this.socketToClient.entries()) { for (const [socketId, clientId] of this.socketToClient.entries()) {
const socket = this.io.sockets.sockets.get(socketId) const socket = this.io.sockets.sockets.get(socketId)
if (socket && socket.connected) { if (socket && socket.connected) {
socket.emit(event, data) if (this.sendToClient(clientId, event, data)) {
activeClients++ activeClients++
}
} }
} }
this.logger.debug(`📡 广播消息到 ${activeClients} 个活跃Web客户端: ${event}`) this.logger.debug(`广播消息到 ${activeClients} 个活跃Web客户端: ${event}`)
} }
} }
/** /**
* 向控制指定设备的客户端发送消息 * comment cleaned
*/ */
sendToDeviceController(deviceId: string, event: string, data: any): boolean { sendToDeviceController(deviceId: string, event: string, data: any): boolean {
const controllerId = this.deviceControllers.get(deviceId) const controllerId = this.deviceControllers.get(deviceId)
@@ -352,7 +491,7 @@ class WebClientManager {
} }
/** /**
* 更新客户端活跃时间 * comment cleaned
*/ */
updateClientActivity(socketId: string): void { updateClientActivity(socketId: string): void {
const clientId = this.socketToClient.get(socketId) const clientId = this.socketToClient.get(socketId)
@@ -365,7 +504,7 @@ class WebClientManager {
} }
/** /**
* 清理不活跃的客户端 * comment cleaned
*/ */
cleanupInactiveClients(timeoutMs: number = 600000): void { cleanupInactiveClients(timeoutMs: number = 600000): void {
const now = Date.now() const now = Date.now()
@@ -387,7 +526,7 @@ class WebClientManager {
} }
/** /**
* 获取客户端统计信息 * comment cleaned
*/ */
getClientStats(): { getClientStats(): {
total: number total: number
@@ -403,7 +542,7 @@ class WebClientManager {
} }
/** /**
* 🔐 恢复用户的设备权限 * comment cleaned
*/ */
restoreUserPermissions(userId: string, clientId: string): void { restoreUserPermissions(userId: string, clientId: string): void {
if (!this.databaseService) { if (!this.databaseService) {
@@ -416,18 +555,21 @@ class WebClientManager {
const permissions = this.databaseService.getUserDevicePermissions(userId) const permissions = this.databaseService.getUserDevicePermissions(userId)
if (permissions.length > 0) { if (permissions.length > 0) {
this.logger.info(`🔐 为用户 ${userId} 恢复 ${permissions.length} 个设备权限`) this.logger.info(`为用户 ${userId} 恢复 ${permissions.length} 个设备权限`)
// 恢复第一个设备的控制权(优先恢复用户之前的权限) // comment cleaned
for (const permission of permissions) { for (const permission of permissions) {
if (permission.permissionType === 'control') { if (permission.permissionType === 'control') {
// 直接恢复权限,不检查冲突(因为这是用户自己的权限恢复) if (!this.canClientAccessDevice(clientId, permission.deviceId)) {
continue
}
// comment cleaned
this.deviceControllers.set(permission.deviceId, clientId) this.deviceControllers.set(permission.deviceId, clientId)
const client = this.clients.get(clientId) const client = this.clients.get(clientId)
if (client) { if (client) {
client.controllingDeviceId = permission.deviceId client.controllingDeviceId = permission.deviceId
this.logger.info(`🔐 用户 ${userId} 的设备 ${permission.deviceId} 控制权已恢复`) this.logger.info(`用户 ${userId} 的设备 ${permission.deviceId} 控制权已恢复`)
break // 只恢复第一个设备 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) const client = this.clients.get(clientId)
if (client) { if (client) {
client.userId = userId client.userId = userId
client.username = username client.username = username
this.logger.info(`🔐 客户端 ${clientId} 用户信息已设置: ${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})`) this.logger.info(`安全审计: 客户端 ${clientId} (IP: ${client.ip}) 绑定用户 ${username} (${userId})`)
} }
} }
/** /**
* 🛡️ 记录权限操作审计日志 * comment cleaned
*/ */
private logPermissionOperation(clientId: string, deviceId: string, operation: string): void { private logPermissionOperation(clientId: string, deviceId: string, operation: string): void {
const client = this.clients.get(clientId) const client = this.clients.get(clientId)
if (client) { 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文件 * 签名APK文件
*/ */
private async signAPK(apkPath: string, filename: string): Promise<string | null> { /**
* 签名并对齐 APK 文件 (修复版)
*/
private async signAPK(apkPath: string, filename: string): Promise<string | null> {
try { try {
this.addBuildLog('info', `准备签名APK: ${filename}`) this.addBuildLog('info', `准备对齐并签名 APK: ${filename}`)
const outputDir = path.dirname(apkPath);
const alignedApkPath = path.join(outputDir, `aligned_${filename}`);
const signedApkPath = path.join(outputDir, `signed_${filename}`);
// 确保keystore文件存在
const keystorePath = path.join(process.cwd(), 'android', 'app.keystore') const keystorePath = path.join(process.cwd(), 'android', 'app.keystore')
const keystorePassword = 'android' const keystorePassword = 'android'
const keyAlias = 'androidkey' const keyAlias = 'androidkey'
const keyPassword = 'android'
// 如果keystore不存在创建它 // --- 步骤 1: Zipalign 对齐 ---
if (!fs.existsSync(keystorePath)) { this.addBuildLog('info', '正在进行 zipalign 对齐...')
this.addBuildLog('info', 'keystore文件不存在正在创建...') const alignCmd = `zipalign -v -f 4 "${apkPath}" "${alignedApkPath}"`
await this.createKeystore(keystorePath, keystorePassword, keyAlias, keyPassword) await execAsync(alignCmd);
this.addBuildLog('success', 'keystore文件创建成功') 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 { } else {
this.addBuildLog('info', '使用现有的keystore文件') this.addBuildLog('warn', `⚠️ 签名成功但未检测到 V2/V3 方案,新款手机可能无法安装`)
// 建议在这里把 verifyOut 的前两行输出到 buildLog 方便前端看
this.addBuildLog('info', `验证详情: ${verifyOut.substring(0, 100)}...`)
} }
// 使用jarsigner签名APK return signedApkPath;
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
} catch (error: any) { } catch (error: any) {
this.addBuildLog('error', `APK签名失败: ${error.message}`) 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()}`)
})
}
return null return null
} }
} }

View File

@@ -34,10 +34,10 @@ interface DeviceQualityState {
} }
const QUALITY_PROFILES: Record<string, QualityProfile> = { const QUALITY_PROFILES: Record<string, QualityProfile> = {
low: { fps: 5, quality: 30, maxWidth: 360, maxHeight: 640, label: '低画质' }, low: { fps: 10, quality: 30, maxWidth: 360, maxHeight: 640, label: '低画质' },
medium: { fps: 10, quality: 45, maxWidth: 480, maxHeight: 854, label: '中画质' }, medium: { fps: 15, quality: 45, maxWidth: 480, maxHeight: 854, label: '中画质' },
high: { fps: 15, quality: 60, maxWidth: 720, maxHeight: 1280, label: '高画质' }, high: { fps: 30, quality: 60, maxWidth: 720, maxHeight: 1280, label: '高画质' },
ultra: { fps: 20, quality: 75, maxWidth: 1080, maxHeight: 1920, label: '超高画质' }, ultra: { fps: 60, quality: 75, maxWidth: 1080, maxHeight: 1920, label: '超高画质' },
} }
export class AdaptiveQualityService { export class AdaptiveQualityService {
@@ -143,7 +143,7 @@ export class AdaptiveQualityService {
maxHeight?: number maxHeight?: number
}): { params: Partial<QualityProfile> } { }): { params: Partial<QualityProfile> } {
const state = this.getOrCreateState(deviceId) 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.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.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)) 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' import Logger from '../utils/Logger'
/**
* 性能指标接口
*/
export interface PerformanceMetrics { export interface PerformanceMetrics {
timestamp: number timestamp: number
memoryUsage: MemoryMetrics memoryUsage: MemoryMetrics
@@ -11,20 +8,14 @@ export interface PerformanceMetrics {
systemMetrics: SystemMetrics systemMetrics: SystemMetrics
} }
/**
* 内存指标
*/
export interface MemoryMetrics { export interface MemoryMetrics {
heapUsed: number // MB heapUsed: number
heapTotal: number // MB heapTotal: number
external: number // MB external: number
rss: number // MB rss: number
heapUsedPercent: number heapUsedPercent: number
} }
/**
* 连接指标
*/
export interface ConnectionMetrics { export interface ConnectionMetrics {
totalConnections: number totalConnections: number
activeConnections: number activeConnections: number
@@ -33,100 +24,81 @@ export interface ConnectionMetrics {
disconnectionsPerMinute: number disconnectionsPerMinute: number
} }
/**
* 消息指标
*/
export interface MessageMetrics { export interface MessageMetrics {
messagesPerSecond: number messagesPerSecond: number
averageLatency: number // ms averageLatency: number
p95Latency: number // ms p95Latency: number
p99Latency: number // ms p99Latency: number
errorRate: number // % errorRate: number
} }
/**
* 系统指标
*/
export interface SystemMetrics { export interface SystemMetrics {
uptime: number // seconds uptime: number
cpuUsage: number // % cpuUsage: number
eventLoopLag: number // ms eventLoopLag: number
} }
/**
* 性能监控服务
*/
export class PerformanceMonitorService { export class PerformanceMonitorService {
private logger = new Logger('PerformanceMonitor') private logger = new Logger('PerformanceMonitor')
// 指标收集
private metrics: PerformanceMetrics[] = [] private metrics: PerformanceMetrics[] = []
private readonly MAX_METRICS_HISTORY = 60 // 保留最近60条记录 private readonly MAX_METRICS_HISTORY = 120
// 消息延迟追踪
private messageLatencies: number[] = [] private messageLatencies: number[] = []
private readonly MAX_LATENCY_SAMPLES = 1000 private readonly MAX_LATENCY_SAMPLES = 2000
// 连接统计
private connectionsPerMinute = 0 private connectionsPerMinute = 0
private disconnectionsPerMinute = 0 private disconnectionsPerMinute = 0
private lastConnectionCount = 0 private connectionSnapshot = {
totalConnections: 0,
activeConnections: 0,
idleConnections: 0,
}
// 消息统计
private messagesThisSecond = 0 private messagesThisSecond = 0
private messagesLastSecond = 0 private messagesLastSecond = 0
private errorsThisSecond = 0 private errorsThisSecond = 0
private errorsLastSecond = 0 private errorsLastSecond = 0
// 事件循环监控
private lastEventLoopCheck = Date.now()
private eventLoopLag = 0 private eventLoopLag = 0
private monitoringIntervals: NodeJS.Timeout[] = []
constructor() { constructor() {
this.startMonitoring() 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 { recordMessageLatency(latency: number): void {
if (!Number.isFinite(latency) || latency < 0) return
this.messageLatencies.push(latency) this.messageLatencies.push(latency)
if (this.messageLatencies.length > this.MAX_LATENCY_SAMPLES) { if (this.messageLatencies.length > this.MAX_LATENCY_SAMPLES) {
this.messageLatencies.shift() this.messageLatencies.shift()
} }
} }
/**
* 记录消息
*/
recordMessage(): void { recordMessage(): void {
this.messagesThisSecond++ this.messagesThisSecond++
} }
/**
* 记录错误
*/
recordError(): void { recordError(): void {
this.errorsThisSecond++ this.errorsThisSecond++
} }
/**
* 记录连接
*/
recordConnection(): void { recordConnection(): void {
this.connectionsPerMinute++ this.connectionsPerMinute++
} }
/**
* 记录断开连接
*/
recordDisconnection(): void { recordDisconnection(): void {
this.disconnectionsPerMinute++ this.disconnectionsPerMinute++
} }
/**
* 获取当前性能指标
*/
getCurrentMetrics(): PerformanceMetrics { getCurrentMetrics(): PerformanceMetrics {
const memUsage = process.memoryUsage() const memUsage = process.memoryUsage()
const heapUsedMB = Math.round(memUsage.heapUsed / 1024 / 1024) const heapUsedMB = Math.round(memUsage.heapUsed / 1024 / 1024)
@@ -134,21 +106,21 @@ export class PerformanceMonitorService {
const externalMB = Math.round(memUsage.external / 1024 / 1024) const externalMB = Math.round(memUsage.external / 1024 / 1024)
const rssMB = Math.round(memUsage.rss / 1024 / 1024) const rssMB = Math.round(memUsage.rss / 1024 / 1024)
const metrics: PerformanceMetrics = { return {
timestamp: Date.now(), timestamp: Date.now(),
memoryUsage: { memoryUsage: {
heapUsed: heapUsedMB, heapUsed: heapUsedMB,
heapTotal: heapTotalMB, heapTotal: heapTotalMB,
external: externalMB, external: externalMB,
rss: rssMB, rss: rssMB,
heapUsedPercent: Math.round((heapUsedMB / heapTotalMB) * 100) heapUsedPercent: heapTotalMB > 0 ? Math.round((heapUsedMB / heapTotalMB) * 100) : 0,
}, },
connectionMetrics: { connectionMetrics: {
totalConnections: 0, // 由调用者设置 totalConnections: this.connectionSnapshot.totalConnections,
activeConnections: 0, activeConnections: this.connectionSnapshot.activeConnections,
idleConnections: 0, idleConnections: this.connectionSnapshot.idleConnections,
newConnectionsPerMinute: this.connectionsPerMinute, newConnectionsPerMinute: this.connectionsPerMinute,
disconnectionsPerMinute: this.disconnectionsPerMinute disconnectionsPerMinute: this.disconnectionsPerMinute,
}, },
messageMetrics: { messageMetrics: {
messagesPerSecond: this.messagesLastSecond, messagesPerSecond: this.messagesLastSecond,
@@ -156,185 +128,140 @@ export class PerformanceMonitorService {
p95Latency: this.calculatePercentileLatency(95), p95Latency: this.calculatePercentileLatency(95),
p99Latency: this.calculatePercentileLatency(99), p99Latency: this.calculatePercentileLatency(99),
errorRate: this.messagesLastSecond > 0 errorRate: this.messagesLastSecond > 0
? Math.round((this.errorsLastSecond / this.messagesLastSecond) * 100 * 100) / 100 ? Math.round((this.errorsLastSecond / this.messagesLastSecond) * 10000) / 100
: 0 : 0,
}, },
systemMetrics: { systemMetrics: {
uptime: Math.round(process.uptime()), uptime: Math.round(process.uptime()),
cpuUsage: this.calculateCpuUsage(), cpuUsage: this.calculateCpuUsage(),
eventLoopLag: this.eventLoopLag eventLoopLag: this.eventLoopLag,
} },
} }
return metrics
} }
/**
* 计算平均延迟
*/
private calculateAverageLatency(): number { private calculateAverageLatency(): number {
if (this.messageLatencies.length === 0) return 0 if (this.messageLatencies.length === 0) return 0
const sum = this.messageLatencies.reduce((a, b) => a + b, 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 { private calculatePercentileLatency(percentile: number): number {
if (this.messageLatencies.length === 0) return 0 if (this.messageLatencies.length === 0) return 0
const sorted = [...this.messageLatencies].sort((a, b) => a - b) const sorted = [...this.messageLatencies].sort((a, b) => a - b)
const index = Math.ceil((percentile / 100) * sorted.length) - 1 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 { private calculateCpuUsage(): number {
// 这是一个简化的实现,实际应该使用 os.cpus() 或专门的库
const usage = process.cpuUsage() 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 { private startMonitoring(): void {
// 每秒更新消息统计 this.monitoringIntervals.push(setInterval(() => {
setInterval(() => {
this.messagesLastSecond = this.messagesThisSecond this.messagesLastSecond = this.messagesThisSecond
this.errorsLastSecond = this.errorsThisSecond this.errorsLastSecond = this.errorsThisSecond
this.messagesThisSecond = 0 this.messagesThisSecond = 0
this.errorsThisSecond = 0 this.errorsThisSecond = 0
}, 1000) }, 1000))
// 每分钟重置连接统计 this.monitoringIntervals.push(setInterval(() => {
setInterval(() => {
this.connectionsPerMinute = 0 this.connectionsPerMinute = 0
this.disconnectionsPerMinute = 0 this.disconnectionsPerMinute = 0
}, 60000) }, 60000))
// 每10秒收集一次完整指标 this.monitoringIntervals.push(setInterval(() => {
setInterval(() => {
const metrics = this.getCurrentMetrics() const metrics = this.getCurrentMetrics()
this.metrics.push(metrics) this.metrics.push(metrics)
if (this.metrics.length > this.MAX_METRICS_HISTORY) { if (this.metrics.length > this.MAX_METRICS_HISTORY) {
this.metrics.shift() this.metrics.shift()
} }
this.logMetrics(metrics) this.logMetrics(metrics)
}, 10000) }, 10000))
// 监控事件循环延迟
this.monitorEventLoopLag() this.monitorEventLoopLag()
} }
/**
* 监控事件循环延迟
*/
private monitorEventLoopLag(): void { private monitorEventLoopLag(): void {
let lastCheck = Date.now() let lastCheck = Date.now()
this.monitoringIntervals.push(setInterval(() => {
setInterval(() => {
const now = Date.now() const now = Date.now()
const expectedDelay = 1000 // 1秒 const expectedDelay = 1000
const actualDelay = now - lastCheck const actualDelay = now - lastCheck
this.eventLoopLag = Math.max(0, actualDelay - expectedDelay) this.eventLoopLag = Math.max(0, actualDelay - expectedDelay)
lastCheck = now lastCheck = now
}, 1000) }, 1000))
} }
/**
* 输出指标日志
*/
private logMetrics(metrics: PerformanceMetrics): void { private logMetrics(metrics: PerformanceMetrics): void {
const mem = metrics.memoryUsage const mem = metrics.memoryUsage
const msg = metrics.messageMetrics const msg = metrics.messageMetrics
const conn = metrics.connectionMetrics const conn = metrics.connectionMetrics
const sys = metrics.systemMetrics const sys = metrics.systemMetrics
this.logger.info(` this.logger.info(
📊 性能指标 (${new Date(metrics.timestamp).toLocaleTimeString()}): `\u6027\u80fd ${new Date(metrics.timestamp).toLocaleTimeString()} | ` +
💾 内存: ${mem.heapUsed}MB / ${mem.heapTotal}MB (${mem.heapUsedPercent}%) | RSS: ${mem.rss}MB `\u5185\u5b58=${mem.heapUsed}/${mem.heapTotal}MB rss=${mem.rss}MB | ` +
📨 消息: ${msg.messagesPerSecond}/s | 延迟: ${msg.averageLatency}ms (p95: ${msg.p95Latency}ms, p99: ${msg.p99Latency}ms) | 错误率: ${msg.errorRate}% `\u6d88\u606f=${msg.messagesPerSecond}/s p95=${msg.p95Latency}ms p99=${msg.p99Latency}ms \u9519\u8bef\u7387=${msg.errorRate}% | ` +
🔌 连接: ${conn.totalConnections}个 (活跃: ${conn.activeConnections}, 空闲: ${conn.idleConnections}) | 新增: ${conn.newConnectionsPerMinute}/min `\u8fde\u63a5=${conn.totalConnections} \u6d3b\u8dc3=${conn.activeConnections} \u7a7a\u95f2=${conn.idleConnections} ` +
⚙️ 系统: 运行时间 ${sys.uptime}s | CPU: ${sys.cpuUsage}% | 事件循环延迟: ${sys.eventLoopLag}ms `\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[] { 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[] { getPerformanceWarnings(): string[] {
const warnings: string[] = [] const warnings: string[] = []
const latest = this.metrics[this.metrics.length - 1] const latest = this.metrics[this.metrics.length - 1]
if (!latest) return warnings if (!latest) return warnings
// 内存警告 if (latest.memoryUsage.heapUsedPercent > 85) {
if (latest.memoryUsage.heapUsedPercent > 80) { warnings.push(`\u5185\u5b58\u4f7f\u7528\u7387\u8fc7\u9ad8: ${latest.memoryUsage.heapUsedPercent}%`)
warnings.push(`⚠️ 内存使用过高: ${latest.memoryUsage.heapUsedPercent}%`)
} }
// 延迟警告
if (latest.messageMetrics.p99Latency > 500) { 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) { 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) { 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 return warnings
} }
/**
* 获取性能报告
*/
getPerformanceReport(): string { getPerformanceReport(): string {
const warnings = this.getPerformanceWarnings() const warnings = this.getPerformanceWarnings()
const latest = this.metrics[this.metrics.length - 1] const latest = this.metrics[this.metrics.length - 1]
if (!latest) return '\u6682\u65e0\u6027\u80fd\u6570\u636e'
if (!latest) return '暂无数据' let report = '\u6027\u80fd\u62a5\u544a\n'
report += '='.repeat(48) + '\n'
let report = '📈 性能报告\n' report += `\u65f6\u95f4: ${new Date(latest.timestamp).toLocaleString()}\n`
report += '='.repeat(50) + '\n' report += `\u5185\u5b58: ${latest.memoryUsage.heapUsed}MB / ${latest.memoryUsage.heapTotal}MB\n`
report += `时间: ${new Date(latest.timestamp).toLocaleString()}\n` report += `\u6d88\u606f\u541e\u5410: ${latest.messageMetrics.messagesPerSecond}/s\n`
report += `内存: ${latest.memoryUsage.heapUsed}MB / ${latest.memoryUsage.heapTotal}MB\n` report += `\u5e73\u5747\u5ef6\u8fdf: ${latest.messageMetrics.averageLatency}ms\n`
report += `消息吞吐: ${latest.messageMetrics.messagesPerSecond}/s\n` report += `\u8fde\u63a5\u6570: ${latest.connectionMetrics.totalConnections}\n`
report += `平均延迟: ${latest.messageMetrics.averageLatency}ms\n` report += `\u8fd0\u884c\u65f6\u957f: ${latest.systemMetrics.uptime}s\n`
report += `连接数: ${latest.connectionMetrics.totalConnections}\n`
report += `运行时间: ${latest.systemMetrics.uptime}s\n`
if (warnings.length > 0) { if (warnings.length > 0) {
report += '\n⚠️ 警告:\n' report += '\n\u544a\u8b66:\n'
warnings.forEach(w => report += ` ${w}\n`) warnings.forEach((warning) => {
report += ` - ${warning}\n`
})
} else { } else {
report += '\n✅ 系统运行正常\n' report += '\n\u72b6\u6001: \u6b63\u5e38\n'
} }
return report return report
} }
/**
* 清理资源
*/
destroy(): void { destroy(): void {
this.monitoringIntervals.forEach((timer) => clearInterval(timer))
this.monitoringIntervals = []
this.metrics = [] this.metrics = []
this.messageLatencies = [] 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 { class Logger {
private prefix: string 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') { constructor(prefix: string = 'App') {
this.prefix = prefix 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 { private formatMessage(level: string, message: string, ...args: any[]): string {
const timestamp = new Date().toISOString() const timestamp = new Date().toISOString()
const formattedArgs = args.length > 0 ? ' ' + args.map(arg => const cleanedMessage = this.sanitizeText(message)
typeof arg === 'object' ? JSON.stringify(arg, null, 2) : String(arg) const formattedArgs = args.length > 0
).join(' ') : '' ? ` ${args.map((arg) => this.stringifyArg(arg)).join(' ')}`
: ''
return `[${timestamp}] [${level}] [${this.prefix}] ${message}${formattedArgs}` return `[${timestamp}] [${level}] [${this.prefix}] ${cleanedMessage}${formattedArgs}`
} }
info(message: string, ...args: any[]): void { info(message: string, ...args: any[]): void {