feat: upload latest server source changes
This commit is contained in:
11
.editorconfig
Normal file
11
.editorconfig
Normal 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
1
package-lock.json
generated
@@ -14,7 +14,6 @@
|
||||
"@types/multer": "^1.4.13",
|
||||
"@types/node": "^20.11.24",
|
||||
"bcryptjs": "^3.0.2",
|
||||
"better-sqlite3": "*",
|
||||
"cors": "^2.8.5",
|
||||
"dotenv": "^17.2.3",
|
||||
"express": "^4.18.2",
|
||||
|
||||
1895
src/index.ts
1895
src/index.ts
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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 = []
|
||||
}
|
||||
|
||||
595
src/services/SrtGatewayService.ts
Normal file
595
src/services/SrtGatewayService.ts
Normal 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
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user