const database = require('../config/database'); const HEARTBEAT_INTERVAL_SECONDS = 15; const OFFLINE_AFTER_SECONDS = 120; const memoryPresence = new Map(); let persistenceState = 'unknown'; let lastPersistenceWarning = ''; class DesktopPresenceValidationError extends Error { constructor(message) { super(message); this.name = 'DesktopPresenceValidationError'; this.status = 400; } } function normalizeUserId(value) { const normalized = String(value ?? '').trim(); if (!/^[1-9]\d{0,19}$/.test(normalized)) { throw new DesktopPresenceValidationError('登录用户标识无效'); } return normalized; } function normalizeClientId(value) { const normalized = String(value || '').trim().toLowerCase(); if (!/^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/.test(normalized)) { throw new DesktopPresenceValidationError('客户端设备标识无效'); } return normalized; } function normalizeText(value, maxLength, fallback = '') { const normalized = String(value || '').trim(); return (normalized || fallback).slice(0, maxLength); } function normalizeHeartbeatPayload(payload = {}) { return { clientId: normalizeClientId(payload.clientId), clientName: normalizeText(payload.clientName, 100, '拼多多运营自动化'), clientVersion: normalizeText(payload.clientVersion, 32), platform: normalizeText(payload.platform, 32), platformVersion: normalizeText(payload.platformVersion, 128), deviceName: normalizeText(payload.deviceName, 255), }; } function normalizeIdentity(identity = {}, userId) { return { userId, username: normalizeText(identity.username, 100), name: normalizeText(identity.name, 100, `用户 ${userId}`), role: normalizeText(identity.role, 20), authSource: normalizeText(identity.authSource, 20), departmentName: normalizeText(identity.departmentName, 255), }; } function presenceCutoff(now = new Date()) { return new Date(now.getTime() - OFFLINE_AFTER_SECONDS * 1000); } function isPresenceOnline(record, now = new Date()) { if (!record || record.offlineAt || record.offline_at) return false; const lastSeenAt = new Date(record.lastSeenAt || record.last_seen_at || 0).getTime(); if (!Number.isFinite(lastSeenAt)) return false; return now.getTime() - lastSeenAt <= OFFLINE_AFTER_SECONDS * 1000; } function toIsoString(value) { const parsed = value instanceof Date ? value : new Date(value); return Number.isFinite(parsed.getTime()) ? parsed.toISOString() : null; } function durationSeconds(startAt, now) { const started = new Date(startAt || 0).getTime(); if (!Number.isFinite(started)) return 0; return Math.max(0, Math.floor((now.getTime() - started) / 1000)); } function aggregateDesktopPresence(rows = [], now = new Date()) { const usersById = new Map(); rows.forEach((row) => { const userId = String(row.userId ?? row.user_id ?? ''); if (!userId) return; const online = isPresenceOnline(row, now); const onlineSinceAt = toIsoString(row.onlineSinceAt || row.online_since_at); const lastSeenAt = toIsoString(row.lastSeenAt || row.last_seen_at); const sessionEndedAt = online ? now : new Date(lastSeenAt || 0); const sessionDurationSeconds = durationSeconds(onlineSinceAt, sessionEndedAt); const device = { clientId: String(row.clientId || row.client_id || ''), clientName: row.clientName || row.client_name || '', clientVersion: row.clientVersion || row.client_version || '', platform: row.platform || '', platformVersion: row.platformVersion || row.platform_version || '', deviceName: row.deviceName || row.device_name || '', ipAddress: row.ipAddress || row.ip_address || '', userAgent: row.userAgent || row.user_agent || '', onlineSinceAt, lastSeenAt, offlineAt: toIsoString(row.offlineAt || row.offline_at), isOnline: online, sessionDurationSeconds, }; device.onlineDurationSeconds = online ? sessionDurationSeconds : 0; if (!usersById.has(userId)) { usersById.set(userId, { userId, name: row.name || row.username || `用户 ${userId}`, username: row.username || '', role: row.role || '', authSource: row.authSource || row.auth_source || '', departmentName: row.departmentName || row.department_name || row.departmentNames || row.department_names || '', devices: [], }); } usersById.get(userId).devices.push(device); }); const users = Array.from(usersById.values()).map((user) => { user.devices.sort((left, right) => { if (left.isOnline !== right.isOnline) return left.isOnline ? -1 : 1; return String(right.lastSeenAt).localeCompare(String(left.lastSeenAt)); }); const onlineDevices = user.devices.filter((device) => device.isOnline); const onlineTimes = onlineDevices.map((device) => device.onlineSinceAt).filter(Boolean).sort(); const versions = [...new Set(user.devices.map((device) => device.clientVersion).filter(Boolean))]; return { ...user, isOnline: onlineDevices.length > 0, activeDeviceCount: onlineDevices.length, deviceCount: user.devices.length, offlineDeviceCount: user.devices.length - onlineDevices.length, clientVersions: versions, onlineSinceAt: onlineTimes[0] || null, onlineDurationSeconds: onlineTimes[0] ? durationSeconds(onlineTimes[0], now) : 0, lastSeenAt: user.devices[0]?.lastSeenAt || null, }; }); users.sort((left, right) => { if (left.isOnline !== right.isOnline) return left.isOnline ? -1 : 1; return String(right.lastSeenAt).localeCompare(String(left.lastSeenAt)); }); const onlineUsers = users.filter((user) => user.isOnline); const knownDeviceCount = users.reduce((total, user) => total + user.deviceCount, 0); const onlineDeviceCount = users.reduce((total, user) => total + user.activeDeviceCount, 0); return { onlineUserCount: onlineUsers.length, onlineDeviceCount, offlineUserCount: users.length - onlineUsers.length, offlineDeviceCount: knownDeviceCount - onlineDeviceCount, knownUserCount: users.length, knownDeviceCount, heartbeatIntervalSeconds: HEARTBEAT_INTERVAL_SECONDS, offlineAfterSeconds: OFFLINE_AFTER_SECONDS, serverTime: now.toISOString(), users, }; } function presenceKey(userId, clientId) { return `${userId}:${clientId}`; } function cleanupMemoryPresence(now = new Date()) { memoryPresence.forEach((record, key) => { if (!isPresenceOnline(record, now)) memoryPresence.delete(key); }); } function rememberHeartbeat({ userId, client, identity, ipAddress, userAgent, now }) { cleanupMemoryPresence(now); const key = presenceKey(userId, client.clientId); const existing = memoryPresence.get(key); const resumesExistingSession = existing && isPresenceOnline(existing, now); memoryPresence.set(key, { ...normalizeIdentity(identity, userId), ...client, ipAddress, userAgent, onlineSinceAt: resumesExistingSession ? existing.onlineSinceAt : now, lastSeenAt: now, offlineAt: null, }); } function isPersistenceUnavailable(error) { return [ 'ER_TABLEACCESS_DENIED_ERROR', 'ER_DBACCESS_DENIED_ERROR', 'ER_NO_SUCH_TABLE', 'ER_BAD_TABLE_ERROR', ].includes(error?.code); } function warnPersistenceFallback(error) { const warningKey = `${error?.code || 'DATABASE_ERROR'}:${error?.message || ''}`; if (warningKey === lastPersistenceWarning) return; lastPersistenceWarning = warningKey; console.warn(`桌面在线状态数据库不可用,已使用进程内心跳表:${error?.message || '未知错误'}`); } async function runOptionalPersistence(operation) { if (persistenceState === 'unavailable') return null; try { const result = await operation(); persistenceState = 'available'; lastPersistenceWarning = ''; return result; } catch (error) { if (isPersistenceUnavailable(error)) persistenceState = 'unavailable'; warnPersistenceFallback(error); return null; } } async function desktopPresenceTableExists(db = database.local) { const rows = await db.query( `SELECT 1 FROM information_schema.tables WHERE table_schema = DATABASE() AND table_name = 'desktop_client_presence' LIMIT 1` ); return Array.isArray(rows) && rows.length > 0; } async function ensureDesktopPresenceSchema(db = database.local) { try { // Production's application account can read and write this table but cannot create it. if (await desktopPresenceTableExists(db)) { persistenceState = 'available'; lastPersistenceWarning = ''; return true; } await db.query(` CREATE TABLE IF NOT EXISTS desktop_client_presence ( id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT 'Presence record ID', user_id BIGINT NOT NULL COMMENT 'Authenticated user ID', client_id CHAR(36) NOT NULL COMMENT 'Persistent desktop installation ID', client_name VARCHAR(100) NOT NULL DEFAULT '拼多多运营自动化' COMMENT 'Desktop application name', client_version VARCHAR(32) NULL COMMENT 'Desktop application version', platform VARCHAR(32) NULL COMMENT 'Operating system platform', platform_version VARCHAR(128) NULL COMMENT 'Operating system version', device_name VARCHAR(255) NULL COMMENT 'Device hostname', ip_address VARCHAR(64) NULL COMMENT 'Latest client IP address', user_agent VARCHAR(500) NULL COMMENT 'Latest HTTP user agent', online_since_at DATETIME(3) NOT NULL COMMENT 'Current online session start time', last_seen_at DATETIME(3) NOT NULL COMMENT 'Latest heartbeat time', offline_at DATETIME(3) NULL COMMENT 'Explicit offline time', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT 'Created time', updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT 'Updated time', UNIQUE KEY uk_desktop_presence_user_client (user_id, client_id), INDEX idx_desktop_presence_last_seen (last_seen_at), INDEX idx_desktop_presence_user (user_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='Desktop client online presence' `); persistenceState = 'available'; return true; } catch (error) { if (!isPersistenceUnavailable(error)) throw error; persistenceState = 'unavailable'; warnPersistenceFallback(error); return false; } } async function recordDesktopHeartbeat(input = {}, options = {}) { const db = options.db || database.local; const now = input.now instanceof Date ? input.now : new Date(); const userId = normalizeUserId(input.userId); const client = normalizeHeartbeatPayload(input.payload); const ipAddress = normalizeText(input.ipAddress, 64); const userAgent = normalizeText(input.userAgent, 500); rememberHeartbeat({ userId, client, identity: input.identity || {}, ipAddress, userAgent, now, }); await runOptionalPersistence(() => db.query( `INSERT INTO desktop_client_presence ( user_id, client_id, client_name, client_version, platform, platform_version, device_name, ip_address, user_agent, online_since_at, last_seen_at, offline_at ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NULL) ON DUPLICATE KEY UPDATE online_since_at = IF( offline_at IS NOT NULL OR last_seen_at < DATE_SUB(VALUES(last_seen_at), INTERVAL ${OFFLINE_AFTER_SECONDS} SECOND), VALUES(online_since_at), online_since_at ), client_name = VALUES(client_name), client_version = VALUES(client_version), platform = VALUES(platform), platform_version = VALUES(platform_version), device_name = VALUES(device_name), ip_address = VALUES(ip_address), user_agent = VALUES(user_agent), last_seen_at = VALUES(last_seen_at), offline_at = NULL`, [ userId, client.clientId, client.clientName, client.clientVersion || null, client.platform || null, client.platformVersion || null, client.deviceName || null, ipAddress || null, userAgent || null, now, now, ] )); return { clientId: client.clientId, serverTime: now.toISOString(), nextHeartbeatSeconds: HEARTBEAT_INTERVAL_SECONDS, offlineAfterSeconds: OFFLINE_AFTER_SECONDS, }; } async function markDesktopOffline(input = {}, options = {}) { const db = options.db || database.local; const now = input.now instanceof Date ? input.now : new Date(); const userId = normalizeUserId(input.userId); const clientId = normalizeClientId(input.clientId); memoryPresence.delete(presenceKey(userId, clientId)); await runOptionalPersistence(() => db.query( `UPDATE desktop_client_presence SET offline_at = ?, updated_at = ? WHERE user_id = ? AND client_id = ?`, [now, now, userId, clientId] )); return { clientId, offlineAt: now.toISOString() }; } async function listOnlineDesktopUsers(options = {}) { const db = options.db || database.local; const now = options.now instanceof Date ? options.now : new Date(); cleanupMemoryPresence(now); const databaseRows = await runOptionalPersistence(() => db.query( `SELECT p.user_id AS userId, p.client_id AS clientId, p.client_name AS clientName, p.client_version AS clientVersion, p.platform, p.platform_version AS platformVersion, p.device_name AS deviceName, p.ip_address AS ipAddress, p.user_agent AS userAgent, p.online_since_at AS onlineSinceAt, p.last_seen_at AS lastSeenAt, p.offline_at AS offlineAt, u.username, u.name, u.role, u.auth_source AS authSource, u.department_names AS departmentNames, d.name AS departmentName FROM desktop_client_presence p LEFT JOIN users u ON u.id = p.user_id LEFT JOIN departments d ON d.id = u.department_id ORDER BY p.last_seen_at DESC` )); const rowsByDevice = new Map(); (Array.isArray(databaseRows) ? databaseRows : []).forEach((row) => { rowsByDevice.set(presenceKey(row.userId, row.clientId), row); }); memoryPresence.forEach((row, key) => { const databaseRow = rowsByDevice.get(key); const databaseSeenAt = new Date(databaseRow?.lastSeenAt || 0).getTime(); const memorySeenAt = new Date(row.lastSeenAt || 0).getTime(); if (!databaseRow || memorySeenAt >= databaseSeenAt) rowsByDevice.set(key, row); }); return aggregateDesktopPresence(Array.from(rowsByDevice.values()), now); } function resetDesktopPresenceStateForTests() { memoryPresence.clear(); persistenceState = 'unknown'; lastPersistenceWarning = ''; } module.exports = { DesktopPresenceValidationError, HEARTBEAT_INTERVAL_SECONDS, OFFLINE_AFTER_SECONDS, aggregateDesktopPresence, aggregateOnlinePresence: aggregateDesktopPresence, desktopPresenceTableExists, ensureDesktopPresenceSchema, isPresenceOnline, listOnlineDesktopUsers, markDesktopOffline, normalizeClientId, normalizeHeartbeatPayload, presenceCutoff, recordDesktopHeartbeat, resetDesktopPresenceStateForTests, };