1
1
mirror of https://github.com/ZeroCatDev/ClassworksKV.git synced 2025-12-07 13:03:09 +00:00
This commit is contained in:
Sunwuyuan 2025-11-23 16:48:27 +08:00
parent d6330c81fe
commit 7a010faa54
No known key found for this signature in database
GPG Key ID: A6A54CF66F56BB64
4 changed files with 411 additions and 33 deletions

View File

@ -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

View File

@ -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"

9
pnpm-lock.yaml generated
View File

@ -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:

View File

@ -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<socketId>
const onlineMap = new Map();
// 在线 token 映射token -> Set<socketId> (用于指标统计)
const onlineTokens = new Map();
// 令牌信息缓存token -> {appId, isReadOnly, deviceType, note, deviceUuid, deviceName}
const tokenInfoCache = new Map();
// 事件历史记录每个设备最多保存1000条事件记录
const eventHistory = new Map(); // uuid -> Array<EventRecord>
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"});
}
}