From 7a010faa54cbdc3618c6b59e3b656d7e5f11c7a7 Mon Sep 17 00:00:00 2001 From: Sunwuyuan Date: Sun, 23 Nov 2025 16:48:27 +0800 Subject: [PATCH] 1 --- middleware/device.js | 17 +- package.json | 1 + pnpm-lock.yaml | 9 + utils/socket.js | 417 +++++++++++++++++++++++++++++++++++++++---- 4 files changed, 411 insertions(+), 33 deletions(-) diff --git a/middleware/device.js b/middleware/device.js index 2a8c474..da3909b 100644 --- a/middleware/device.js +++ b/middleware/device.js @@ -10,6 +10,7 @@ import {PrismaClient} from "@prisma/client"; import errors from "../utils/errors.js"; import {verifyDevicePassword} from "../utils/crypto.js"; +import {analyzeDevice} from "../utils/deviceDetector.js"; const prisma = new PrismaClient(); @@ -38,7 +39,7 @@ async function createDefaultAutoAuth(deviceId) { * 设备中间件 - 统一处理设备UUID * * 从req.params.deviceUuid或req.body.deviceUuid获取UUID - * 如果设备不存在则自动创建 + * 如果设备不存在则自动创建,并智能生成设备名称 * 将设备信息存储到res.locals.device * * 使用方式: @@ -58,11 +59,18 @@ export const deviceMiddleware = errors.catchAsync(async (req, res, next) => { }); if (!device) { - // 设备不存在,自动创建 + // 设备不存在,自动创建并生成智能设备名称 + const userAgent = req.headers['user-agent']; + const customDeviceType = req.body.deviceType || req.query.deviceType; + const note = req.body.note || req.query.note; + + // 生成设备名称,确保不为空 + const deviceName = analyzeDevice(userAgent, req.headers, customDeviceType, note).generatedName; + device = await prisma.device.create({ data: { uuid: deviceUuid, - name: null, + name: deviceName, password: null, passwordHint: null, accountId: null, @@ -71,6 +79,9 @@ export const deviceMiddleware = errors.catchAsync(async (req, res, next) => { // 为新创建的设备添加默认的自动登录配置 await createDefaultAutoAuth(device.id); + + // 将设备分析结果添加到响应中 + res.locals.deviceAnalysis = deviceAnalysis; } // 将设备信息存储到res.locals diff --git a/package.json b/package.json index 1bdcc9d..db2ed0d 100644 --- a/package.json +++ b/package.json @@ -30,6 +30,7 @@ "js-base64": "^3.7.7", "jsonwebtoken": "^9.0.2", "morgan": "~1.10.0", + "node-device-detector": "^2.2.4", "prom-client": "^15.1.3", "socket.io": "^4.8.1", "uuid": "^11.1.0" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9b5194f..05a8363 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -71,6 +71,9 @@ importers: morgan: specifier: ~1.10.0 version: 1.10.0 + node-device-detector: + specifier: ^2.2.4 + version: 2.2.4 prom-client: specifier: ^15.1.3 version: 15.1.3 @@ -2097,6 +2100,10 @@ packages: resolution: {integrity: sha512-lytcDEdxKjGJPTLEfW4mYMigRezMlyJY8W4wxJK8zE533Jlb8L8dRuObJFWg2P+AuOIxoCgKF+2Oq4d4Zd0OUA==} engines: {node: ^18 || ^20 || >= 21} + node-device-detector@2.2.4: + resolution: {integrity: sha512-0nhi8XWLViGKeQyLLlg3bcUGdhTKc56ARAHx6kKWvwy39ITk7BZn5Gy6AmTSX4slM35iQMJaKAIxagR/xXsS+Q==} + engines: {node: '>= 10.x', npm: '>= 6.x'} + node-fetch-native@1.6.7: resolution: {integrity: sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q==} @@ -4706,6 +4713,8 @@ snapshots: node-addon-api@8.3.1: {} + node-device-detector@2.2.4: {} + node-fetch-native@1.6.7: {} node-fetch@2.7.0: diff --git a/utils/socket.js b/utils/socket.js index ee6aa41..682e85e 100644 --- a/utils/socket.js +++ b/utils/socket.js @@ -7,21 +7,88 @@ * - 同一设备的不同 token 会被归入同一频道 * - 维护在线设备列表 * - 提供广播 KV 键变更的工具方法 + * - 支持任意类型事件转发:客户端可发送自定义事件类型和JSON内容到其他设备 + * - 记录事件历史:包含时间戳、来源令牌、设备类型、权限等完整元数据 + * - 令牌信息缓存:在连接时预加载令牌详细信息以提高性能 */ import {Server} from "socket.io"; import {PrismaClient} from "@prisma/client"; import {onlineDevicesGauge} from "./metrics.js"; +import DeviceDetector from "node-device-detector"; +import ClientHints from "node-device-detector/client-hints.js"; // Socket.IO 单例实例 let io = null; +// 设备检测器实例 +const deviceDetector = new DeviceDetector({ + clientIndexes: true, + deviceIndexes: true, + deviceAliasCode: false, +}); +const clientHints = new ClientHints(); + // 在线设备映射:uuid -> Set const onlineMap = new Map(); // 在线 token 映射:token -> Set (用于指标统计) const onlineTokens = new Map(); +// 令牌信息缓存:token -> {appId, isReadOnly, deviceType, note, deviceUuid, deviceName} +const tokenInfoCache = new Map(); +// 事件历史记录:每个设备最多保存1000条事件记录 +const eventHistory = new Map(); // uuid -> Array +const MAX_EVENT_HISTORY = 1000; const prisma = new PrismaClient(); +/** + * 检测设备并生成友好的设备名称 + * @param {string} userAgent 用户代理字符串 + * @param {object} headers HTTP headers对象 + * @returns {string} 生成的设备名称 + */ +function detectDeviceName(userAgent, headers = {}) { + if (!userAgent) return "Unknown Device"; + + try { + const clientHintsData = clientHints.parse(headers); + const deviceInfo = deviceDetector.detect(userAgent, clientHintsData); + const botInfo = deviceDetector.parseBot(userAgent); + + // 如果是bot,返回bot名称 + if (botInfo && botInfo.name) { + return `Bot: ${botInfo.name}`; + } + + // 构建设备名称 + let deviceName = ""; + + if (deviceInfo.device && deviceInfo.device.brand && deviceInfo.device.model) { + deviceName = `${deviceInfo.device.brand} ${deviceInfo.device.model}`; + } else if (deviceInfo.os && deviceInfo.os.name) { + deviceName = deviceInfo.os.name; + if (deviceInfo.os.version) { + deviceName += ` ${deviceInfo.os.version}`; + } + } + + // 添加客户端信息 + if (deviceInfo.client && deviceInfo.client.name) { + deviceName += deviceName ? ` (${deviceInfo.client.name}` : deviceInfo.client.name; + if (deviceInfo.client.version) { + deviceName += ` ${deviceInfo.client.version}`; + } + if (deviceName.includes("(")) { + deviceName += ")"; + } + } + + return deviceName || "Unknown Device"; + } catch (error) { + console.warn("Device detection error:", error); + return "Unknown Device"; + } +} + /** * 初始化 Socket.IO * @param {import('http').Server} server HTTP Server 实例 @@ -86,29 +153,128 @@ export function initSocket(server) { uuids.forEach((u) => leaveDeviceRoom(socket, u)); }); - // 聊天室:发送文本消息到加入的设备频道 - socket.on("chat:send", (data) => { + // 获取事件历史记录 + socket.on("get-event-history", (data) => { try { - const text = typeof data === "string" ? data : data?.text; - if (typeof text !== "string") return; - const trimmed = text.trim(); - if (!trimmed) return; - - // 限制消息最大长度,避免滥用 - const MAX_LEN = 2000; - const safeText = trimmed.length > MAX_LEN ? trimmed.slice(0, MAX_LEN) : trimmed; - + const {limit = 50, offset = 0} = data || {}; const uuids = Array.from(socket.data.deviceUuids || []); - if (uuids.length === 0) return; - const at = new Date().toISOString(); - const payload = {text: safeText, at, senderId: socket.id}; + if (uuids.length === 0) { + socket.emit("event-history-error", {reason: "not_joined_any_device"}); + return; + } + // 返回所有加入设备的事件历史 + const historyData = {}; uuids.forEach((uuid) => { - io.to(uuid).emit("chat:message", {uuid, ...payload}); + historyData[uuid] = getEventHistory(uuid, limit, offset); }); + + socket.emit("event-history", { + devices: historyData, + timestamp: new Date().toISOString(), + requestedBy: { + deviceType: socket.data.tokenInfo?.deviceType, + deviceName: socket.data.tokenInfo?.deviceName, + isReadOnly: socket.data.tokenInfo?.isReadOnly + } + }); + } catch (err) { - console.error("chat:send error:", err); + console.error("get-event-history error:", err); + socket.emit("event-history-error", {reason: "internal_error"}); + } + }); + + // 通用事件转发:允许发送任意类型事件到其他设备 + socket.on("send-event", (data) => { + try { + // 验证数据结构 + if (!data || typeof data !== "object") { + socket.emit("event-error", {reason: "invalid_data_format"}); + return; + } + + const {type, content} = data; + + // 验证事件类型 + if (typeof type !== "string" || type.trim().length === 0) { + socket.emit("event-error", {reason: "invalid_event_type"}); + return; + } + + // 验证内容格式(必须是对象或null) + if (content !== null && (typeof content !== "object" || Array.isArray(content))) { + socket.emit("event-error", {reason: "content_must_be_object_or_null"}); + return; + } + + // 获取当前socket所在的设备房间 + const uuids = Array.from(socket.data.deviceUuids || []); + if (uuids.length === 0) { + socket.emit("event-error", {reason: "not_joined_any_device"}); + return; + } + + // 检查只读权限 + const tokenInfo = socket.data.tokenInfo; + if (tokenInfo?.isReadOnly) { + socket.emit("event-error", {reason: "readonly_token_cannot_send_events"}); + return; + } + + // 限制序列化后内容大小,避免滥用 + const MAX_SIZE = 10240; // 10KB + const serializedContent = JSON.stringify(content); + if (serializedContent.length > MAX_SIZE) { + socket.emit("event-error", {reason: "content_too_large", maxSize: MAX_SIZE}); + return; + } + + const timestamp = new Date().toISOString(); + const eventId = `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; + + // 构建完整的事件载荷,包含发送者信息 + const eventPayload = { + eventId, + content, + timestamp, + senderId: socket.id, + senderInfo: { + appId: tokenInfo?.appId, + deviceType: tokenInfo?.deviceType, + deviceName: tokenInfo?.deviceName, + isReadOnly: tokenInfo?.isReadOnly || false, + note: tokenInfo?.note + } + }; + + // 记录事件到历史记录(包含type用于历史记录) + const historyPayload = { + ...eventPayload, + type: type.trim() + }; + uuids.forEach((uuid) => { + recordEventHistory(uuid, historyPayload); + }); + + // 直接使用事件名称发送到所有相关设备房间(排除发送者所在的socket) + uuids.forEach((uuid) => { + socket.to(uuid).emit(type.trim(), eventPayload); + }); + + // 发送确认回执给发送者 + socket.emit("event-sent", { + eventId: eventPayload.eventId, + eventName: type.trim(), + timestamp: eventPayload.timestamp, + targetDevices: uuids.length, + senderInfo: eventPayload.senderInfo + }); + + } catch (err) { + console.error("send-event error:", err); + socket.emit("event-error", {reason: "internal_error"}); } }); @@ -119,6 +285,16 @@ export function initSocket(server) { // 清理 token 连接跟踪 const tokens = Array.from(socket.data.tokens || []); tokens.forEach((token) => removeTokenConnection(token, socket.id)); + + // 清理socket相关缓存 + if (socket.data.currentToken) { + // 如果这是该token的最后一个连接,考虑清理缓存 + const tokenSet = onlineTokens.get(socket.data.currentToken); + if (!tokenSet || tokenSet.size === 0) { + // 可以选择保留缓存一段时间,这里暂时保留 + // tokenInfoCache.delete(socket.data.currentToken); + } + } }); }); @@ -212,7 +388,134 @@ function removeTokenConnection(token, socketId) { */ export function broadcastKeyChanged(uuid, payload) { if (!io || !uuid) return; - io.to(uuid).emit("kv-key-changed", {uuid, ...payload}); + + // 发送KV变更事件 + const timestamp = new Date().toISOString(); + const eventId = `kv-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; + const eventPayload = { + eventId, + content: payload, + timestamp, + senderId: "realtime", + senderInfo: { + appId: "5c2a54d553951a37b47066ead68c8642", + deviceType: "server", + deviceName: "realtime", + isReadOnly: false, + note: "Database realtime sync" + } + }; + + // 记录到事件历史(包含type用于历史记录) + const historyPayload = { + ...eventPayload, + type: "kv-key-changed" + }; + recordEventHistory(uuid, historyPayload); + + // 直接发送kv-key-changed事件 + io.to(uuid).emit("kv-key-changed", eventPayload); +} + +/** + * 向指定设备广播自定义事件 + * @param {string} uuid 设备 uuid + * @param {string} type 事件类型 + * @param {object|null} content 事件内容(JSON对象或null) + * @param {string} [senderId] 发送者ID(可选) + */ +export function broadcastDeviceEvent(uuid, type, content = null, senderId = "system") { + if (!io || !uuid || typeof type !== "string" || type.trim().length === 0) return; + + // 验证内容格式 + if (content !== null && (typeof content !== "object" || Array.isArray(content))) { + console.warn("broadcastDeviceEvent: content must be object or null"); + return; + } + + const timestamp = new Date().toISOString(); + const eventId = `sys-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; + const eventPayload = { + eventId, + content, + timestamp, + senderId, + senderInfo: { + appId: "system", + deviceType: "system", + deviceName: "System", + isReadOnly: false, + note: "System broadcast" + } + }; + + // 记录系统事件到历史(包含type用于历史记录) + const historyPayload = { + ...eventPayload, + type: type.trim() + }; + recordEventHistory(uuid, historyPayload); + + io.to(uuid).emit(type.trim(), eventPayload); +} + +/** + * 记录事件到历史记录 + * @param {string} uuid 设备UUID + * @param {object} eventPayload 事件载荷 + */ +function recordEventHistory(uuid, eventPayload) { + if (!eventHistory.has(uuid)) { + eventHistory.set(uuid, []); + } + + const history = eventHistory.get(uuid); + history.push({ + ...eventPayload, + recordedAt: new Date().toISOString() + }); + + // 保持历史记录在限制范围内 + if (history.length > MAX_EVENT_HISTORY) { + history.splice(0, history.length - MAX_EVENT_HISTORY); + } +} + +/** + * 获取设备事件历史记录 + * @param {string} uuid 设备UUID + * @param {number} [limit=50] 返回记录数量限制 + * @param {number} [offset=0] 偏移量 + * @returns {Array} 事件历史记录 + */ +export function getEventHistory(uuid, limit = 50, offset = 0) { + const history = eventHistory.get(uuid) || []; + return history.slice(offset, offset + limit); +} + +/** + * 获取令牌信息 + * @param {string} token 令牌 + * @returns {object|null} 令牌信息 + */ +export function getTokenInfo(token) { + return tokenInfoCache.get(token) || null; +} + +/** + * 清理设备相关缓存 + * @param {string} uuid 设备UUID + */ +function cleanupDeviceCache(uuid) { + // 清理事件历史 + eventHistory.delete(uuid); + + // 清理相关令牌缓存 + for (const [token, info] of tokenInfoCache.entries()) { + if (info.deviceUuid === uuid) { + tokenInfoCache.delete(token); + } + } } /** @@ -232,7 +535,10 @@ export default { initSocket, getIO, broadcastKeyChanged, + broadcastDeviceEvent, getOnlineDevices, + getEventHistory, + getTokenInfo, }; /** @@ -241,18 +547,69 @@ export default { * @param {string} token */ async function joinByToken(socket, token) { - const appInstall = await prisma.appInstall.findUnique({ - where: {token}, - include: {device: {select: {uuid: true}}}, - }); - const uuid = appInstall?.device?.uuid; - if (uuid) { - joinDeviceRoom(socket, uuid); - // 跟踪 token 连接用于指标统计 - trackTokenConnection(socket, token); - // 可选:回执 - socket.emit("joined", {by: "token", uuid, token}); - } else { - socket.emit("join-error", {by: "token", reason: "invalid_token"}); + try { + const appInstall = await prisma.appInstall.findUnique({ + where: {token}, + include: { + device: { + select: { + uuid: true, + name: true + } + } + }, + }); + + const uuid = appInstall?.device?.uuid; + if (uuid && appInstall) { + // 检测设备信息 + const userAgent = socket.handshake?.headers?.['user-agent']; + const detectedDeviceName = detectDeviceName(userAgent, socket.handshake?.headers); + + // 拼接设备名称:检测到的设备信息 + token的note + let finalDeviceName = detectedDeviceName; + if (appInstall.note && appInstall.note.trim()) { + finalDeviceName = `${detectedDeviceName} - ${appInstall.note.trim()}`; + } + + // 缓存令牌信息,使用拼接后的设备名称 + const tokenInfo = { + appId: appInstall.appId, + isReadOnly: appInstall.isReadOnly, + deviceType: appInstall.deviceType, + note: appInstall.note, + deviceUuid: uuid, + deviceName: finalDeviceName, // 使用拼接后的设备名称 + detectedDevice: detectedDeviceName, // 保留检测到的设备信息 + originalNote: appInstall.note, // 保留原始备注 + installedAt: appInstall.installedAt + }; + tokenInfoCache.set(token, tokenInfo); + + // 在socket上记录当前令牌信息 + socket.data.currentToken = token; + socket.data.tokenInfo = tokenInfo; + + joinDeviceRoom(socket, uuid); + // 跟踪 token 连接用于指标统计 + trackTokenConnection(socket, token); + // 可选:回执 + socket.emit("joined", { + by: "token", + uuid, + token, + tokenInfo: { + isReadOnly: tokenInfo.isReadOnly, + deviceType: tokenInfo.deviceType, + deviceName: tokenInfo.deviceName, + userAgent: userAgent + } + }); + } else { + socket.emit("join-error", {by: "token", reason: "invalid_token"}); + } + } catch (error) { + console.error("joinByToken error:", error); + socket.emit("join-error", {by: "token", reason: "database_error"}); } }