cangku/backend/src/services/desktopPresenceService.js

418 lines
15 KiB
JavaScript

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,
};