feat: add online device management and desktop settings integration

This commit is contained in:
2026-02-19 17:34:41 +08:00
parent 365ada1a4a
commit 19f53875c9
7 changed files with 1070 additions and 48 deletions

View File

@@ -74,6 +74,7 @@ const {
DownloadTrafficReportDB,
DownloadTrafficReservationDB,
UploadSessionDB,
DeviceSessionDB,
FileHashIndexDB,
DownloadTrafficIngestDB,
SystemLogDB,
@@ -81,7 +82,17 @@ const {
WalManager
} = require('./database');
const StorageUsageCache = require('./utils/storage-cache');
const { JWT_SECRET, generateToken, generateRefreshToken, refreshAccessToken, authMiddleware, adminMiddleware, isJwtSecretSecure } = require('./auth');
const {
JWT_SECRET,
generateToken,
generateRefreshToken,
decodeAccessToken,
decodeRefreshToken,
refreshAccessToken,
authMiddleware,
adminMiddleware,
isJwtSecretSecure
} = require('./auth');
const { StorageInterface, LocalStorageClient, OssStorageClient, formatFileSize, formatOssError } = require('./storage');
const { encryptSecret, decryptSecret } = require('./utils/encryption');
@@ -2083,6 +2094,34 @@ setTimeout(() => {
cleanupExpiredUploadSessions('startup');
}, 20 * 1000);
const deviceSessionSweepTimer = setInterval(() => {
try {
const result = DeviceSessionDB.cleanupExpired(30);
const cleaned = Number(result?.changes || 0);
if (cleaned > 0) {
console.log(`[在线设备] 已清理过期会话 ${cleaned}`);
}
} catch (error) {
console.error('[在线设备] 清理过期会话失败:', error);
}
}, 6 * 60 * 60 * 1000);
if (deviceSessionSweepTimer && typeof deviceSessionSweepTimer.unref === 'function') {
deviceSessionSweepTimer.unref();
}
setTimeout(() => {
try {
const result = DeviceSessionDB.cleanupExpired(30);
const cleaned = Number(result?.changes || 0);
if (cleaned > 0) {
console.log(`[在线设备] 启动清理过期会话 ${cleaned}`);
}
} catch (error) {
console.error('[在线设备] 启动清理过期会话失败:', error);
}
}, 25 * 1000);
// 构建用于存储客户端的用户对象(自动尝试解密 OSS Secret
function buildStorageUserContext(user, overrides = {}) {
if (!user) {
@@ -2852,6 +2891,85 @@ function detectDeviceTypeFromUserAgent(userAgent = '') {
return mobilePattern.test(ua) ? 'mobile' : 'desktop';
}
function inferPlatformFromUserAgent(userAgent = '') {
const ua = String(userAgent || '');
if (!ua) return '未知平台';
if (/windows/i.test(ua)) return 'Windows';
if (/macintosh|mac os x/i.test(ua)) return 'macOS';
if (/android/i.test(ua)) return 'Android';
if (/iphone|ipad|ios/i.test(ua)) return 'iOS';
if (/linux/i.test(ua)) return 'Linux';
return '未知平台';
}
function normalizeClientType(value = '') {
const normalized = String(value || '').trim().toLowerCase();
if (['web', 'desktop', 'mobile', 'api'].includes(normalized)) {
return normalized;
}
return '';
}
function resolveClientType(clientType, userAgent = '') {
const normalized = normalizeClientType(clientType);
if (normalized) return normalized;
const ua = String(userAgent || '').toLowerCase();
if (ua.includes('tauri') || ua.includes('electron') || ua.includes('wanwan-cloud-desktop')) {
return 'desktop';
}
return detectDeviceTypeFromUserAgent(ua) === 'mobile' ? 'mobile' : 'web';
}
function sanitizeDeviceText(value, maxLength = 120) {
if (typeof value !== 'string') return '';
return value.trim().slice(0, maxLength);
}
function buildDeviceName({ clientType, deviceName, platform }) {
const preferred = sanitizeDeviceText(deviceName, 120);
if (preferred) return preferred;
const platformText = sanitizeDeviceText(platform, 80) || '未知平台';
if (clientType === 'desktop') return `桌面客户端 · ${platformText}`;
if (clientType === 'mobile') return `移动端浏览器 · ${platformText}`;
if (clientType === 'api') return `API 客户端 · ${platformText}`;
return `网页端浏览器 · ${platformText}`;
}
function buildDeviceSessionContext(req, payload = {}) {
const userAgent = sanitizeDeviceText(req.get('user-agent') || req.headers?.['user-agent'] || '', 1024);
const clientType = resolveClientType(payload.client_type || req.headers?.['x-client-type'], userAgent);
const platform = sanitizeDeviceText(payload.platform, 80) || inferPlatformFromUserAgent(userAgent);
return {
clientType,
deviceId: sanitizeDeviceText(payload.device_id, 128) || null,
deviceName: buildDeviceName({
clientType,
deviceName: payload.device_name,
platform
}),
platform,
ipAddress: sanitizeDeviceText(getClientIp(req), 80) || null,
userAgent
};
}
function formatOnlineDeviceRecord(row, currentSessionId = '') {
const sid = String(row?.session_id || '');
const isCurrent = !!currentSessionId && sid === currentSessionId;
return {
session_id: sid,
client_type: String(row?.client_type || 'web'),
device_name: String(row?.device_name || '未知设备'),
platform: String(row?.platform || ''),
ip_address: String(row?.ip_address || ''),
last_active_at: row?.last_active_at || row?.updated_at || row?.created_at || null,
created_at: row?.created_at || null,
expires_at: row?.expires_at || null,
is_current: isCurrent,
is_local: isCurrent
};
}
function normalizeTimeHHmm(value) {
if (typeof value !== 'string') return null;
const trimmed = value.trim();
@@ -3952,7 +4070,7 @@ app.post('/api/login',
});
}
const { username, password, captcha } = req.body;
const { username, password, captcha, client_type, device_id, device_name, platform } = req.body;
try {
// 检查是否需要验证码
@@ -4085,8 +4203,32 @@ app.post('/api/login',
user = loginPolicyState.user;
}
const token = generateToken(user);
const refreshToken = generateRefreshToken(user);
const deviceContext = buildDeviceSessionContext(req, {
client_type,
device_id,
device_name,
platform
});
let sessionId = null;
try {
const createdSession = DeviceSessionDB.create({
sessionId: crypto.randomBytes(24).toString('hex'),
userId: user.id,
clientType: deviceContext.clientType,
deviceId: deviceContext.deviceId,
deviceName: deviceContext.deviceName,
platform: deviceContext.platform,
ipAddress: deviceContext.ipAddress,
userAgent: deviceContext.userAgent,
ttlDays: 7
});
sessionId = createdSession?.session_id || null;
} catch (sessionError) {
console.error('[在线设备] 创建登录会话失败:', sessionError.message);
}
const token = generateToken(user, sessionId);
const refreshToken = generateRefreshToken(user, sessionId);
// 清除失败记录
if (req.rateLimitKeys) {
@@ -4173,7 +4315,10 @@ app.post('/api/refresh-token', (req, res) => {
});
}
const result = refreshAccessToken(refreshToken);
const result = refreshAccessToken(refreshToken, {
ipAddress: getClientIp(req),
userAgent: req.get('user-agent') || ''
});
if (!result.success) {
return res.status(401).json({
@@ -4200,6 +4345,20 @@ app.post('/api/refresh-token', (req, res) => {
// 登出清除Cookie
app.post('/api/logout', (req, res) => {
const accessToken = req.headers.authorization?.replace('Bearer ', '') || req.cookies?.token || '';
const refreshToken = req.cookies?.refreshToken || '';
const accessPayload = decodeAccessToken(accessToken);
const refreshPayload = decodeRefreshToken(refreshToken);
const sessionId = String(accessPayload?.sid || refreshPayload?.sid || '').trim();
if (sessionId) {
try {
DeviceSessionDB.revoke(sessionId, null, 'logout');
} catch (error) {
console.error('[在线设备] 登出时撤销会话失败:', error.message);
}
}
// 清除所有认证Cookie
res.clearCookie('token', { path: '/' });
res.clearCookie('refreshToken', { path: '/' });
@@ -4246,6 +4405,87 @@ app.get('/api/user/profile', authMiddleware, (req, res) => {
});
});
// 在线设备列表
app.get('/api/user/online-devices', authMiddleware, (req, res) => {
try {
const currentSessionId = String(req.authSessionId || '').trim();
const devices = DeviceSessionDB
.listActiveByUser(req.user.id, 80)
.map((row) => formatOnlineDeviceRecord(row, currentSessionId));
res.json({
success: true,
devices,
current_session_id: currentSessionId || null
});
} catch (error) {
console.error('获取在线设备失败:', error);
res.status(500).json({
success: false,
message: '获取在线设备失败'
});
}
});
// 强制踢下线(可踢其它设备,也可踢当前设备)
app.post('/api/user/online-devices/:sessionId/kick', authMiddleware, (req, res) => {
try {
const sessionId = String(req.params.sessionId || '').trim();
if (!sessionId || sessionId.length < 16 || sessionId.length > 128) {
return res.status(400).json({
success: false,
message: '会话标识无效'
});
}
const target = DeviceSessionDB.findBySessionId(sessionId);
if (!target || Number(target.user_id) !== Number(req.user.id)) {
return res.status(404).json({
success: false,
message: '目标设备不存在'
});
}
if (target.revoked_at) {
return res.status(409).json({
success: false,
message: '设备已离线'
});
}
const revokeResult = DeviceSessionDB.revoke(sessionId, req.user.id, 'kicked_by_user');
if (!(Number(revokeResult?.changes || 0) > 0)) {
return res.status(409).json({
success: false,
message: '设备已离线'
});
}
const kickedCurrent = String(req.authSessionId || '') === sessionId;
if (kickedCurrent) {
res.clearCookie('token', { path: '/' });
res.clearCookie('refreshToken', { path: '/' });
}
logAuth(req, 'device_kick', `用户踢下线设备: ${sessionId}`, {
userId: req.user.id,
kickedCurrent
});
res.json({
success: true,
kicked_current: kickedCurrent,
message: kickedCurrent ? '当前设备已下线' : '设备已下线'
});
} catch (error) {
console.error('踢设备下线失败:', error);
res.status(500).json({
success: false,
message: '踢设备下线失败'
});
}
});
// 获取用户下载流量额度与报表
app.get('/api/user/download-traffic-report', authMiddleware, (req, res) => {
try {