From 7687397954f7fbdbeb11d0405bc34d1d759786f8 Mon Sep 17 00:00:00 2001 From: yuyx <237899745@qq.com> Date: Tue, 17 Feb 2026 17:19:25 +0800 Subject: [PATCH] feat: enhance download traffic quota lifecycle controls --- backend/auth.js | 3 + backend/database.js | 75 ++++++++ backend/server.js | 412 +++++++++++++++++++++++++++++++++++++++++--- frontend/app.html | 81 +++++++-- frontend/app.js | 117 +++++++++++-- 5 files changed, 635 insertions(+), 53 deletions(-) diff --git a/backend/auth.js b/backend/auth.js index 66ce63a..f726ef3 100644 --- a/backend/auth.js +++ b/backend/auth.js @@ -211,6 +211,9 @@ function authMiddleware(req, res, next) { storage_used: user.storage_used || 0, download_traffic_quota: effectiveDownloadTrafficQuota, download_traffic_used: cappedDownloadTrafficUsed, + download_traffic_quota_expires_at: user.download_traffic_quota_expires_at || null, + download_traffic_reset_cycle: user.download_traffic_reset_cycle || 'none', + download_traffic_last_reset_at: user.download_traffic_last_reset_at || null, // 主题偏好 theme_preference: user.theme_preference || null }; diff --git a/backend/database.js b/backend/database.js index 0207012..0fb880d 100644 --- a/backend/database.js +++ b/backend/database.js @@ -501,6 +501,24 @@ const UserDB = { * @private */ _validateFieldValue(fieldName, value) { + const NULLABLE_STRING_FIELDS = new Set([ + 'oss_provider', + 'oss_region', + 'oss_access_key_id', + 'oss_access_key_secret', + 'oss_bucket', + 'oss_endpoint', + 'upload_api_key', + 'verification_token', + 'verification_expires_at', + 'storage_permission', + 'current_storage_type', + 'download_traffic_quota_expires_at', + 'download_traffic_reset_cycle', + 'download_traffic_last_reset_at', + 'theme_preference' + ]); + // 字段类型白名单(根据数据库表结构定义) const FIELD_TYPES = { // 文本类型字段 @@ -518,6 +536,9 @@ const UserDB = { 'verification_expires_at': 'string', 'storage_permission': 'string', 'current_storage_type': 'string', + 'download_traffic_quota_expires_at': 'string', + 'download_traffic_reset_cycle': 'string', + 'download_traffic_last_reset_at': 'string', 'theme_preference': 'string', // 数值类型字段 @@ -542,6 +563,9 @@ const UserDB = { // 检查类型匹配 if (expectedType === 'string') { + if (value === null) { + return NULLABLE_STRING_FIELDS.has(fieldName); + } return typeof value === 'string'; } else if (expectedType === 'number') { // 允许数值或可转换为数值的字符串 @@ -593,6 +617,9 @@ const UserDB = { 'oss_storage_quota': 'oss_storage_quota', 'download_traffic_quota': 'download_traffic_quota', 'download_traffic_used': 'download_traffic_used', + 'download_traffic_quota_expires_at': 'download_traffic_quota_expires_at', + 'download_traffic_reset_cycle': 'download_traffic_reset_cycle', + 'download_traffic_last_reset_at': 'download_traffic_last_reset_at', // 偏好设置 'theme_preference': 'theme_preference' @@ -676,6 +703,9 @@ const UserDB = { 'oss_storage_quota': 'oss_storage_quota', 'download_traffic_quota': 'download_traffic_quota', 'download_traffic_used': 'download_traffic_used', + 'download_traffic_quota_expires_at': 'download_traffic_quota_expires_at', + 'download_traffic_reset_cycle': 'download_traffic_reset_cycle', + 'download_traffic_last_reset_at': 'download_traffic_last_reset_at', // 偏好设置 'theme_preference': 'theme_preference' @@ -726,6 +756,9 @@ const UserDB = { 'upload_api_key': 'string', 'verification_token': 'string', 'verification_expires_at': 'string', 'storage_permission': 'string', 'current_storage_type': 'string', 'theme_preference': 'string', + 'download_traffic_quota_expires_at': 'string', + 'download_traffic_reset_cycle': 'string', + 'download_traffic_last_reset_at': 'string', 'is_admin': 'number', 'is_active': 'number', 'is_banned': 'number', 'has_oss_config': 'number', 'is_verified': 'number', 'local_storage_quota': 'number', 'local_storage_used': 'number', @@ -1298,6 +1331,9 @@ function migrateDownloadTrafficFields() { const columns = db.prepare("PRAGMA table_info(users)").all(); const hasDownloadTrafficQuota = columns.some(col => col.name === 'download_traffic_quota'); const hasDownloadTrafficUsed = columns.some(col => col.name === 'download_traffic_used'); + const hasDownloadTrafficQuotaExpiresAt = columns.some(col => col.name === 'download_traffic_quota_expires_at'); + const hasDownloadTrafficResetCycle = columns.some(col => col.name === 'download_traffic_reset_cycle'); + const hasDownloadTrafficLastResetAt = columns.some(col => col.name === 'download_traffic_last_reset_at'); if (!hasDownloadTrafficQuota) { console.log('[数据库迁移] 添加 download_traffic_quota 字段...'); @@ -1311,6 +1347,24 @@ function migrateDownloadTrafficFields() { console.log('[数据库迁移] ✓ download_traffic_used 字段已添加'); } + if (!hasDownloadTrafficQuotaExpiresAt) { + console.log('[数据库迁移] 添加 download_traffic_quota_expires_at 字段...'); + db.exec('ALTER TABLE users ADD COLUMN download_traffic_quota_expires_at DATETIME DEFAULT NULL'); + console.log('[数据库迁移] ✓ download_traffic_quota_expires_at 字段已添加'); + } + + if (!hasDownloadTrafficResetCycle) { + console.log('[数据库迁移] 添加 download_traffic_reset_cycle 字段...'); + db.exec("ALTER TABLE users ADD COLUMN download_traffic_reset_cycle TEXT DEFAULT 'none'"); + console.log('[数据库迁移] ✓ download_traffic_reset_cycle 字段已添加'); + } + + if (!hasDownloadTrafficLastResetAt) { + console.log('[数据库迁移] 添加 download_traffic_last_reset_at 字段...'); + db.exec('ALTER TABLE users ADD COLUMN download_traffic_last_reset_at DATETIME DEFAULT NULL'); + console.log('[数据库迁移] ✓ download_traffic_last_reset_at 字段已添加'); + } + // 统一策略:download_traffic_quota <= 0 表示不限流量 const quotaBackfillResult = db.prepare(` UPDATE users @@ -1341,6 +1395,27 @@ function migrateDownloadTrafficFields() { if (usedCapResult.changes > 0) { console.log(`[数据库迁移] ✓ 下载流量已用值已按配额校准: ${usedCapResult.changes} 条记录`); } + + const resetCycleBackfillResult = db.prepare(` + UPDATE users + SET download_traffic_reset_cycle = 'none' + WHERE download_traffic_reset_cycle IS NULL + OR download_traffic_reset_cycle NOT IN ('none', 'daily', 'weekly', 'monthly') + `).run(); + + if (resetCycleBackfillResult.changes > 0) { + console.log(`[数据库迁移] ✓ 下载流量重置周期已回填: ${resetCycleBackfillResult.changes} 条记录`); + } + + const clearExpiryForUnlimitedResult = db.prepare(` + UPDATE users + SET download_traffic_quota_expires_at = NULL + WHERE download_traffic_quota <= 0 + `).run(); + + if (clearExpiryForUnlimitedResult.changes > 0) { + console.log(`[数据库迁移] ✓ 不限流量用户已清理到期时间: ${clearExpiryForUnlimitedResult.changes} 条记录`); + } } catch (error) { console.error('[数据库迁移] 下载流量字段迁移失败:', error); } diff --git a/backend/server.js b/backend/server.js index 21babbb..437ee67 100644 --- a/backend/server.js +++ b/backend/server.js @@ -74,6 +74,8 @@ const USERNAME_REGEX = /^[A-Za-z0-9_.\u4e00-\u9fa5-]{3,20}$/u; // 允许中英 const ENFORCE_HTTPS = process.env.ENFORCE_HTTPS === 'true'; const DEFAULT_LOCAL_STORAGE_QUOTA_BYTES = 1024 * 1024 * 1024; // 1GB const DEFAULT_OSS_STORAGE_QUOTA_BYTES = 1024 * 1024 * 1024; // 1GB +const MAX_DOWNLOAD_TRAFFIC_BYTES = 10 * 1024 * 1024 * 1024 * 1024; // 10TB +const DOWNLOAD_POLICY_SWEEP_INTERVAL_MS = 30 * 60 * 1000; // 30分钟 const SHARE_CODE_REGEX = /^[A-Za-z0-9]{6,32}$/; const COOKIE_SECURE_MODE = String(process.env.COOKIE_SECURE || '').toLowerCase(); const SHOULD_USE_SECURE_COOKIES = @@ -633,7 +635,7 @@ function normalizeDownloadTrafficQuota(rawQuota) { if (!Number.isFinite(parsedQuota) || parsedQuota <= 0) { return 0; // 0 表示不限流量 } - return Math.floor(parsedQuota); + return Math.min(MAX_DOWNLOAD_TRAFFIC_BYTES, Math.floor(parsedQuota)); } function normalizeDownloadTrafficUsed(rawUsed, quota = 0) { @@ -658,8 +660,173 @@ function getDownloadTrafficState(user) { }; } +function parseDateTimeValue(value) { + if (!value || typeof value !== 'string') { + return null; + } + + const directDate = new Date(value); + if (!Number.isNaN(directDate.getTime())) { + return directDate; + } + + // 兼容 SQLite 常见 DATETIME 格式: YYYY-MM-DD HH:mm:ss + const normalized = value.replace(' ', 'T'); + const normalizedDate = new Date(normalized); + if (!Number.isNaN(normalizedDate.getTime())) { + return normalizedDate; + } + + return null; +} + +function formatDateTimeForSqlite(date = new Date()) { + const target = date instanceof Date ? date : new Date(date); + const year = target.getFullYear(); + const month = String(target.getMonth() + 1).padStart(2, '0'); + const day = String(target.getDate()).padStart(2, '0'); + const hours = String(target.getHours()).padStart(2, '0'); + const minutes = String(target.getMinutes()).padStart(2, '0'); + const seconds = String(target.getSeconds()).padStart(2, '0'); + return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`; +} + +function getNextDownloadResetTime(lastResetAt, resetCycle) { + const baseDate = parseDateTimeValue(lastResetAt); + if (!baseDate) { + return null; + } + + const next = new Date(baseDate.getTime()); + if (resetCycle === 'daily') { + next.setDate(next.getDate() + 1); + return next; + } + + if (resetCycle === 'weekly') { + next.setDate(next.getDate() + 7); + return next; + } + + if (resetCycle === 'monthly') { + next.setMonth(next.getMonth() + 1); + return next; + } + + return null; +} + +function resolveDownloadTrafficPolicyUpdates(user, now = new Date()) { + if (!user) { + return { + updates: {}, + hasUpdates: false, + expired: false, + resetApplied: false + }; + } + + const updates = {}; + let hasUpdates = false; + let expired = false; + let resetApplied = false; + + const normalizedQuota = normalizeDownloadTrafficQuota(user.download_traffic_quota); + const normalizedUsed = normalizeDownloadTrafficUsed(user.download_traffic_used, normalizedQuota); + if (normalizedQuota !== Number(user.download_traffic_quota || 0)) { + updates.download_traffic_quota = normalizedQuota; + hasUpdates = true; + } + if (normalizedUsed !== Number(user.download_traffic_used || 0)) { + updates.download_traffic_used = normalizedUsed; + hasUpdates = true; + } + + const resetCycle = ['none', 'daily', 'weekly', 'monthly'].includes(user.download_traffic_reset_cycle) + ? user.download_traffic_reset_cycle + : 'none'; + if (resetCycle !== (user.download_traffic_reset_cycle || 'none')) { + updates.download_traffic_reset_cycle = resetCycle; + hasUpdates = true; + } + + const expiresAt = parseDateTimeValue(user.download_traffic_quota_expires_at); + if (normalizedQuota <= 0 && user.download_traffic_quota_expires_at) { + updates.download_traffic_quota_expires_at = null; + hasUpdates = true; + } else if (normalizedQuota > 0 && expiresAt && now >= expiresAt) { + // 到期后自动恢复为不限并重置已用量 + updates.download_traffic_quota = 0; + updates.download_traffic_used = 0; + updates.download_traffic_quota_expires_at = null; + updates.download_traffic_reset_cycle = 'none'; + updates.download_traffic_last_reset_at = null; + hasUpdates = true; + expired = true; + } + + if (!expired && resetCycle !== 'none') { + const lastResetAt = user.download_traffic_last_reset_at; + if (!lastResetAt) { + updates.download_traffic_last_reset_at = formatDateTimeForSqlite(now); + hasUpdates = true; + } else { + const nextResetAt = getNextDownloadResetTime(lastResetAt, resetCycle); + if (nextResetAt && now >= nextResetAt) { + updates.download_traffic_used = 0; + updates.download_traffic_last_reset_at = formatDateTimeForSqlite(now); + hasUpdates = true; + resetApplied = true; + } + } + } else if (resetCycle === 'none' && user.download_traffic_last_reset_at) { + updates.download_traffic_last_reset_at = null; + hasUpdates = true; + } + + return { + updates, + hasUpdates, + expired, + resetApplied + }; +} + +const enforceDownloadTrafficPolicyTransaction = db.transaction((userId, trigger = 'runtime') => { + let user = UserDB.findById(userId); + if (!user) { + return null; + } + + const now = new Date(); + const policyResult = resolveDownloadTrafficPolicyUpdates(user, now); + if (policyResult.hasUpdates) { + UserDB.update(userId, policyResult.updates); + user = UserDB.findById(userId); + if (policyResult.expired) { + console.log(`[下载流量策略] 用户 ${userId} 配额已到期,已自动恢复为不限 (trigger=${trigger})`); + } else if (policyResult.resetApplied) { + console.log(`[下载流量策略] 用户 ${userId} 流量已按周期重置 (trigger=${trigger})`); + } + } + + return { + user, + expired: policyResult.expired, + resetApplied: policyResult.resetApplied + }; +}); + +function enforceDownloadTrafficPolicy(userId, trigger = 'runtime') { + if (!userId) { + return null; + } + return enforceDownloadTrafficPolicyTransaction(userId, trigger); +} + const applyDownloadTrafficUsageTransaction = db.transaction((userId, bytesToAdd) => { - const user = UserDB.findById(userId); + const policyState = enforceDownloadTrafficPolicyTransaction(userId, 'usage'); + const user = policyState?.user || UserDB.findById(userId); if (!user) { return null; } @@ -696,6 +863,42 @@ function applyDownloadTrafficUsage(userId, bytesToAdd) { return applyDownloadTrafficUsageTransaction(userId, Math.floor(parsedBytes)); } +function runDownloadTrafficPolicySweep(trigger = 'scheduled') { + try { + const users = UserDB.getAll(); + let affected = 0; + for (const user of users) { + if ( + !user.download_traffic_quota_expires_at && + (!user.download_traffic_reset_cycle || user.download_traffic_reset_cycle === 'none') + ) { + continue; + } + const result = enforceDownloadTrafficPolicy(user.id, trigger); + if (result?.expired || result?.resetApplied) { + affected += 1; + } + } + if (affected > 0) { + console.log(`[下载流量策略] 扫描完成: ${affected} 个用户发生变化 (trigger=${trigger})`); + } + } catch (error) { + console.error(`[下载流量策略] 扫描失败 (trigger=${trigger}):`, error); + } +} + +const downloadPolicySweepTimer = setInterval(() => { + runDownloadTrafficPolicySweep('interval'); +}, DOWNLOAD_POLICY_SWEEP_INTERVAL_MS); + +if (downloadPolicySweepTimer && typeof downloadPolicySweepTimer.unref === 'function') { + downloadPolicySweepTimer.unref(); +} + +setTimeout(() => { + runDownloadTrafficPolicySweep('startup'); +}, 10 * 1000); + // 构建用于存储客户端的用户对象(自动尝试解密 OSS Secret) function buildStorageUserContext(user, overrides = {}) { if (!user) { @@ -2092,7 +2295,7 @@ app.post('/api/login', delete req.session.captchaTime; } - const user = UserDB.findByUsername(username); + let user = UserDB.findByUsername(username); if (!user) { // 记录失败尝试 @@ -2156,6 +2359,12 @@ app.post('/api/login', user.local_storage_used = syncLocalStorageUsageFromDisk(user.id, user.local_storage_used); } + // 下载流量策略应用:处理到期恢复/周期重置 + const loginPolicyState = enforceDownloadTrafficPolicy(user.id, 'login'); + if (loginPolicyState?.user) { + user = loginPolicyState.user; + } + const token = generateToken(user); const refreshToken = generateRefreshToken(user); @@ -2213,6 +2422,9 @@ app.post('/api/login', user.download_traffic_used, normalizeDownloadTrafficQuota(user.download_traffic_quota) ), + download_traffic_quota_expires_at: user.download_traffic_quota_expires_at || null, + download_traffic_reset_cycle: user.download_traffic_reset_cycle || 'none', + download_traffic_last_reset_at: user.download_traffic_last_reset_at || null, // OSS配置来源(重要:用于前端判断是否使用OSS直连上传) oss_config_source: SettingsDB.hasUnifiedOssConfig() ? 'unified' : (user.has_oss_config ? 'personal' : 'none') } @@ -2280,6 +2492,19 @@ app.post('/api/logout', (req, res) => { app.get('/api/user/profile', authMiddleware, (req, res) => { const userPayload = { ...req.user }; + const profilePolicyState = enforceDownloadTrafficPolicy(userPayload.id, 'profile'); + if (profilePolicyState?.user) { + const policyUser = profilePolicyState.user; + userPayload.download_traffic_quota = normalizeDownloadTrafficQuota(policyUser.download_traffic_quota); + userPayload.download_traffic_used = normalizeDownloadTrafficUsed( + policyUser.download_traffic_used, + userPayload.download_traffic_quota + ); + userPayload.download_traffic_quota_expires_at = policyUser.download_traffic_quota_expires_at || null; + userPayload.download_traffic_reset_cycle = policyUser.download_traffic_reset_cycle || 'none'; + userPayload.download_traffic_last_reset_at = policyUser.download_traffic_last_reset_at || null; + } + // 本地存储用量校准:避免“有占用但目录为空”的显示错乱 if ((userPayload.current_storage_type || 'oss') === 'local') { userPayload.local_storage_used = syncLocalStorageUsageFromDisk(userPayload.id, userPayload.local_storage_used); @@ -3708,7 +3933,8 @@ app.get('/api/files/download-url', authMiddleware, async (req, res) => { } // 开启下载流量配额后,禁止发放直连 URL(避免绕过后端计量) - const latestUser = UserDB.findById(req.user.id); + const policyState = enforceDownloadTrafficPolicy(req.user.id, 'download_url'); + const latestUser = policyState?.user || UserDB.findById(req.user.id); if (!latestUser) { return res.status(401).json({ success: false, @@ -4004,7 +4230,8 @@ app.get('/api/files/download', authMiddleware, async (req, res) => { } try { - const latestUser = UserDB.findById(req.user.id); + const policyState = enforceDownloadTrafficPolicy(req.user.id, 'download'); + const latestUser = policyState?.user || UserDB.findById(req.user.id); if (!latestUser) { return res.status(401).json({ success: false, @@ -4780,7 +5007,8 @@ app.post('/api/share/:code/list', shareRateLimitMiddleware, async (req, res) => } // 获取分享者的用户信息 - const shareOwner = UserDB.findById(share.user_id); + const ownerPolicyState = enforceDownloadTrafficPolicy(share.user_id, 'share_download_url'); + const shareOwner = ownerPolicyState?.user || UserDB.findById(share.user_id); if (!shareOwner) { return res.status(404).json({ success: false, @@ -4995,7 +5223,8 @@ app.post('/api/share/:code/download-url', shareRateLimitMiddleware, async (req, } // 获取分享者的用户信息 - const shareOwner = UserDB.findById(share.user_id); + const ownerPolicyState = enforceDownloadTrafficPolicy(share.user_id, 'share_download'); + const shareOwner = ownerPolicyState?.user || UserDB.findById(share.user_id); if (!shareOwner) { return res.status(404).json({ success: false, @@ -6013,7 +6242,10 @@ app.get('/api/admin/storage-stats', authMiddleware, adminMiddleware, async (req, // 获取所有用户 app.get('/api/admin/users', authMiddleware, adminMiddleware, (req, res) => { try { - const users = UserDB.getAll(); + const users = UserDB.getAll().map(user => { + const policyState = enforceDownloadTrafficPolicy(user.id, 'admin_list'); + return policyState?.user || user; + }); res.json({ success: true, @@ -6038,7 +6270,10 @@ app.get('/api/admin/users', authMiddleware, adminMiddleware, (req, res) => { download_traffic_used: normalizeDownloadTrafficUsed( u.download_traffic_used, normalizeDownloadTrafficQuota(u.download_traffic_quota) - ) + ), + download_traffic_quota_expires_at: u.download_traffic_quota_expires_at || null, + download_traffic_reset_cycle: u.download_traffic_reset_cycle || 'none', + download_traffic_last_reset_at: u.download_traffic_last_reset_at || null })) }); } catch (error) { @@ -6628,7 +6863,20 @@ app.post('/api/admin/users/:id/storage-permission', body('storage_permission').isIn(['local_only', 'oss_only', 'user_choice']).withMessage('无效的存储权限'), body('local_storage_quota').optional({ nullable: true }).isInt({ min: 1048576, max: 10995116277760 }).withMessage('本地配额必须在 1MB 到 10TB 之间'), body('oss_storage_quota').optional({ nullable: true }).isInt({ min: 1048576, max: 10995116277760 }).withMessage('OSS配额必须在 1MB 到 10TB 之间'), - body('download_traffic_quota').optional({ nullable: true }).isInt({ min: 0, max: 10995116277760 }).withMessage('下载流量配额必须在 0 到 10TB 之间(0表示不限)') + body('download_traffic_quota').optional({ nullable: true }).isInt({ min: 0, max: MAX_DOWNLOAD_TRAFFIC_BYTES }).withMessage('下载流量配额必须在 0 到 10TB 之间(0表示不限)'), + body('download_traffic_delta').optional({ nullable: true }).isInt({ min: -MAX_DOWNLOAD_TRAFFIC_BYTES, max: MAX_DOWNLOAD_TRAFFIC_BYTES }).withMessage('下载流量增减值必须在 -10TB 到 10TB 之间'), + body('download_traffic_reset_cycle').optional({ nullable: true }).isIn(['none', 'daily', 'weekly', 'monthly']).withMessage('下载流量重置周期无效'), + body('download_traffic_quota_expires_at').optional({ nullable: true }).custom((value) => { + if (value === null || value === undefined || value === '') { + return true; + } + const parsedDate = parseDateTimeValue(String(value)); + if (!parsedDate) { + throw new Error('下载流量到期时间格式无效'); + } + return true; + }), + body('reset_download_traffic_used').optional({ nullable: true }).isBoolean().withMessage('reset_download_traffic_used 必须是布尔值') ], (req, res) => { const errors = validationResult(req); @@ -6641,7 +6889,16 @@ app.post('/api/admin/users/:id/storage-permission', try { const { id } = req.params; - const { storage_permission, local_storage_quota, oss_storage_quota, download_traffic_quota } = req.body; + const { + storage_permission, + local_storage_quota, + oss_storage_quota, + download_traffic_quota, + download_traffic_delta, + download_traffic_quota_expires_at, + download_traffic_reset_cycle, + reset_download_traffic_used + } = req.body; // 参数验证:验证 ID 格式 const userId = parseInt(id, 10); @@ -6652,7 +6909,19 @@ app.post('/api/admin/users/:id/storage-permission', }); } + const now = new Date(); + const nowSql = formatDateTimeForSqlite(now); const updates = { storage_permission }; + const hasSetTrafficQuota = download_traffic_quota !== undefined && download_traffic_quota !== null; + const hasTrafficDelta = download_traffic_delta !== undefined && download_traffic_delta !== null && Number(download_traffic_delta) !== 0; + const shouldResetTrafficUsedNow = reset_download_traffic_used === true || reset_download_traffic_used === 'true'; + + if (hasSetTrafficQuota && hasTrafficDelta) { + return res.status(400).json({ + success: false, + message: '下载流量配额不能同时使用“直接设置”和“增减调整”' + }); + } // 如果提供了本地配额,更新配额(单位:字节) if (local_storage_quota !== undefined && local_storage_quota !== null) { @@ -6664,13 +6933,9 @@ app.post('/api/admin/users/:id/storage-permission', updates.oss_storage_quota = parseInt(oss_storage_quota, 10); } - // 如果提供了下载流量配额,更新配额(单位:字节,0 表示不限) - if (download_traffic_quota !== undefined && download_traffic_quota !== null) { - updates.download_traffic_quota = parseInt(download_traffic_quota, 10); - } - // 根据权限设置自动调整存储类型 - const user = UserDB.findById(userId); + const beforePolicyState = enforceDownloadTrafficPolicy(userId, 'admin_update_before'); + const user = beforePolicyState?.user || UserDB.findById(userId); if (!user) { return res.status(404).json({ success: false, @@ -6678,17 +6943,59 @@ app.post('/api/admin/users/:id/storage-permission', }); } - // 如果管理员把配额调小,已用流量不能超过新配额 - if ( - updates.download_traffic_quota !== undefined && - updates.download_traffic_quota > 0 - ) { + const beforeTrafficState = getDownloadTrafficState(user); + let targetTrafficQuota = beforeTrafficState.quota; + + if (hasSetTrafficQuota) { + targetTrafficQuota = normalizeDownloadTrafficQuota(parseInt(download_traffic_quota, 10)); + } else if (hasTrafficDelta) { + const delta = parseInt(download_traffic_delta, 10); + targetTrafficQuota = Math.max( + 0, + Math.min(MAX_DOWNLOAD_TRAFFIC_BYTES, beforeTrafficState.quota + delta) + ); + } + + if (hasSetTrafficQuota || hasTrafficDelta) { + updates.download_traffic_quota = targetTrafficQuota; updates.download_traffic_used = normalizeDownloadTrafficUsed( user.download_traffic_used, - updates.download_traffic_quota + targetTrafficQuota ); } + if (targetTrafficQuota <= 0) { + updates.download_traffic_quota_expires_at = null; + updates.download_traffic_reset_cycle = 'none'; + updates.download_traffic_last_reset_at = null; + } else { + if (download_traffic_quota_expires_at !== undefined) { + if (download_traffic_quota_expires_at === null || download_traffic_quota_expires_at === '') { + updates.download_traffic_quota_expires_at = null; + } else { + const parsedExpireAt = parseDateTimeValue(String(download_traffic_quota_expires_at)); + updates.download_traffic_quota_expires_at = parsedExpireAt + ? formatDateTimeForSqlite(parsedExpireAt) + : null; + } + } + + if (download_traffic_reset_cycle !== undefined && download_traffic_reset_cycle !== null) { + updates.download_traffic_reset_cycle = download_traffic_reset_cycle; + if (download_traffic_reset_cycle === 'none') { + updates.download_traffic_last_reset_at = null; + } else if (!user.download_traffic_last_reset_at) { + updates.download_traffic_last_reset_at = nowSql; + } + } + } + + if (shouldResetTrafficUsedNow) { + updates.download_traffic_used = 0; + const effectiveCycle = updates.download_traffic_reset_cycle || user.download_traffic_reset_cycle || 'none'; + updates.download_traffic_last_reset_at = effectiveCycle === 'none' ? null : nowSql; + } + if (storage_permission === 'local_only') { updates.current_storage_type = 'local'; } else if (storage_permission === 'oss_only') { @@ -6702,9 +7009,66 @@ app.post('/api/admin/users/:id/storage-permission', UserDB.update(userId, updates); + const afterPolicyState = enforceDownloadTrafficPolicy(userId, 'admin_update_after'); + const updatedUser = afterPolicyState?.user || UserDB.findById(userId); + + logUser( + req, + 'update_user_storage_and_traffic', + `管理员更新用户额度策略: ${user.username}`, + { + targetUserId: userId, + targetUsername: user.username, + storage_permission, + downloadTrafficOperation: hasSetTrafficQuota + ? 'set' + : (hasTrafficDelta ? (parseInt(download_traffic_delta, 10) > 0 ? 'increase' : 'decrease') : 'none'), + before: { + quota: beforeTrafficState.quota, + used: beforeTrafficState.used, + expires_at: user.download_traffic_quota_expires_at || null, + reset_cycle: user.download_traffic_reset_cycle || 'none', + last_reset_at: user.download_traffic_last_reset_at || null + }, + request: { + set_quota: hasSetTrafficQuota ? parseInt(download_traffic_quota, 10) : null, + delta: hasTrafficDelta ? parseInt(download_traffic_delta, 10) : 0, + expires_at: download_traffic_quota_expires_at ?? undefined, + reset_cycle: download_traffic_reset_cycle ?? undefined, + reset_used_now: shouldResetTrafficUsedNow + }, + after: updatedUser ? { + quota: normalizeDownloadTrafficQuota(updatedUser.download_traffic_quota), + used: normalizeDownloadTrafficUsed( + updatedUser.download_traffic_used, + normalizeDownloadTrafficQuota(updatedUser.download_traffic_quota) + ), + expires_at: updatedUser.download_traffic_quota_expires_at || null, + reset_cycle: updatedUser.download_traffic_reset_cycle || 'none', + last_reset_at: updatedUser.download_traffic_last_reset_at || null + } : null + } + ); + res.json({ success: true, - message: '存储权限已更新' + message: '存储权限已更新', + user: updatedUser ? { + id: updatedUser.id, + storage_permission: updatedUser.storage_permission || 'oss_only', + current_storage_type: updatedUser.current_storage_type || 'oss', + local_storage_quota: updatedUser.local_storage_quota || DEFAULT_LOCAL_STORAGE_QUOTA_BYTES, + local_storage_used: updatedUser.local_storage_used || 0, + oss_storage_quota: normalizeOssQuota(updatedUser.oss_storage_quota), + download_traffic_quota: normalizeDownloadTrafficQuota(updatedUser.download_traffic_quota), + download_traffic_used: normalizeDownloadTrafficUsed( + updatedUser.download_traffic_used, + normalizeDownloadTrafficQuota(updatedUser.download_traffic_quota) + ), + download_traffic_quota_expires_at: updatedUser.download_traffic_quota_expires_at || null, + download_traffic_reset_cycle: updatedUser.download_traffic_reset_cycle || 'none', + download_traffic_last_reset_at: updatedUser.download_traffic_last_reset_at || null + } : null }); } catch (error) { console.error('设置存储权限失败:', error); diff --git a/frontend/app.html b/frontend/app.html index 98a661c..d962224 100644 --- a/frontend/app.html +++ b/frontend/app.html @@ -3190,6 +3190,12 @@ 不限 +