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/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",
|
||||||
|
|||||||
1893
src/index.ts
1893
src/index.ts
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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 = []
|
||||||
}
|
}
|
||||||
|
|||||||
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 {
|
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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user