forked from lunanting/cangku
418 lines
15 KiB
JavaScript
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,
|
|
};
|