From 2629237f9e0fb7933181dc51f304242613f8e176 Mon Sep 17 00:00:00 2001 From: yuyx <237899745@qq.com> Date: Tue, 17 Feb 2026 16:52:26 +0800 Subject: [PATCH] feat(quota): add downloadable traffic quota with local/OSS/share metering --- backend/auth.js | 13 ++ backend/database.js | 66 +++++++- backend/server.js | 393 +++++++++++++++++++++++++++++++++++++++----- frontend/app.html | 251 ++++++++++++++++++++++++++-- frontend/app.js | 111 +++++++++---- 5 files changed, 750 insertions(+), 84 deletions(-) diff --git a/backend/auth.js b/backend/auth.js index e41ffb2..66ce63a 100644 --- a/backend/auth.js +++ b/backend/auth.js @@ -175,6 +175,17 @@ function authMiddleware(req, res, next) { const effectiveOssQuota = Number.isFinite(rawOssQuota) && rawOssQuota > 0 ? rawOssQuota : DEFAULT_OSS_STORAGE_QUOTA_BYTES; + const rawDownloadTrafficQuota = Number(user.download_traffic_quota); + const effectiveDownloadTrafficQuota = Number.isFinite(rawDownloadTrafficQuota) && rawDownloadTrafficQuota > 0 + ? Math.floor(rawDownloadTrafficQuota) + : 0; // 0 表示不限流量 + const rawDownloadTrafficUsed = Number(user.download_traffic_used); + const normalizedDownloadTrafficUsed = Number.isFinite(rawDownloadTrafficUsed) && rawDownloadTrafficUsed > 0 + ? Math.floor(rawDownloadTrafficUsed) + : 0; + const cappedDownloadTrafficUsed = effectiveDownloadTrafficQuota > 0 + ? Math.min(normalizedDownloadTrafficUsed, effectiveDownloadTrafficQuota) + : normalizedDownloadTrafficUsed; // 将用户信息附加到请求对象(包含所有存储相关字段) req.user = { @@ -198,6 +209,8 @@ function authMiddleware(req, res, next) { local_storage_used: user.local_storage_used || 0, oss_storage_quota: effectiveOssQuota, storage_used: user.storage_used || 0, + download_traffic_quota: effectiveDownloadTrafficQuota, + download_traffic_used: cappedDownloadTrafficUsed, // 主题偏好 theme_preference: user.theme_preference || null }; diff --git a/backend/database.js b/backend/database.js index 54f22f8..0207012 100644 --- a/backend/database.js +++ b/backend/database.js @@ -528,7 +528,9 @@ const UserDB = { 'is_verified': 'number', 'local_storage_quota': 'number', 'local_storage_used': 'number', - 'oss_storage_quota': 'number' + 'oss_storage_quota': 'number', + 'download_traffic_quota': 'number', + 'download_traffic_used': 'number' }; const expectedType = FIELD_TYPES[fieldName]; @@ -589,6 +591,8 @@ const UserDB = { 'local_storage_quota': 'local_storage_quota', 'local_storage_used': 'local_storage_used', 'oss_storage_quota': 'oss_storage_quota', + 'download_traffic_quota': 'download_traffic_quota', + 'download_traffic_used': 'download_traffic_used', // 偏好设置 'theme_preference': 'theme_preference' @@ -670,6 +674,8 @@ const UserDB = { 'local_storage_quota': 'local_storage_quota', 'local_storage_used': 'local_storage_used', 'oss_storage_quota': 'oss_storage_quota', + 'download_traffic_quota': 'download_traffic_quota', + 'download_traffic_used': 'download_traffic_used', // 偏好设置 'theme_preference': 'theme_preference' @@ -723,7 +729,8 @@ const UserDB = { 'is_admin': 'number', 'is_active': 'number', 'is_banned': 'number', 'has_oss_config': 'number', 'is_verified': 'number', 'local_storage_quota': 'number', 'local_storage_used': 'number', - 'oss_storage_quota': 'number' + 'oss_storage_quota': 'number', 'download_traffic_quota': 'number', + 'download_traffic_used': 'number' }[key]; console.warn(`[类型检查] 字段 ${key} 值类型不符: 期望 ${expectedType}, 实际 ${typeof value}, 值: ${JSON.stringify(value)}`); @@ -1285,6 +1292,60 @@ function migrateOssQuotaField() { } } +// 数据库迁移 - 下载流量配额字段 +function migrateDownloadTrafficFields() { + try { + 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'); + + if (!hasDownloadTrafficQuota) { + console.log('[数据库迁移] 添加 download_traffic_quota 字段...'); + db.exec('ALTER TABLE users ADD COLUMN download_traffic_quota INTEGER DEFAULT 0'); + console.log('[数据库迁移] ✓ download_traffic_quota 字段已添加'); + } + + if (!hasDownloadTrafficUsed) { + console.log('[数据库迁移] 添加 download_traffic_used 字段...'); + db.exec('ALTER TABLE users ADD COLUMN download_traffic_used INTEGER DEFAULT 0'); + console.log('[数据库迁移] ✓ download_traffic_used 字段已添加'); + } + + // 统一策略:download_traffic_quota <= 0 表示不限流量 + const quotaBackfillResult = db.prepare(` + UPDATE users + SET download_traffic_quota = 0 + WHERE download_traffic_quota IS NULL OR download_traffic_quota < 0 + `).run(); + + if (quotaBackfillResult.changes > 0) { + console.log(`[数据库迁移] ✓ 下载流量配额默认值已回填: ${quotaBackfillResult.changes} 条记录`); + } + + const usedBackfillResult = db.prepare(` + UPDATE users + SET download_traffic_used = 0 + WHERE download_traffic_used IS NULL OR download_traffic_used < 0 + `).run(); + + if (usedBackfillResult.changes > 0) { + console.log(`[数据库迁移] ✓ 下载流量已用值已回填: ${usedBackfillResult.changes} 条记录`); + } + + const usedCapResult = db.prepare(` + UPDATE users + SET download_traffic_used = download_traffic_quota + WHERE download_traffic_quota > 0 AND download_traffic_used > download_traffic_quota + `).run(); + + if (usedCapResult.changes > 0) { + console.log(`[数据库迁移] ✓ 下载流量已用值已按配额校准: ${usedCapResult.changes} 条记录`); + } + } catch (error) { + console.error('[数据库迁移] 下载流量字段迁移失败:', error); + } +} + // 系统日志操作 const SystemLogDB = { // 日志级别常量 @@ -1471,6 +1532,7 @@ migrateToV2(); // 执行数据库迁移 migrateThemePreference(); // 主题偏好迁移 migrateToOss(); // SFTP → OSS 迁移 migrateOssQuotaField(); // OSS 配额字段迁移 +migrateDownloadTrafficFields(); // 下载流量字段迁移 module.exports = { db, diff --git a/backend/server.js b/backend/server.js index a41dd01..21babbb 100644 --- a/backend/server.js +++ b/backend/server.js @@ -80,6 +80,37 @@ const SHOULD_USE_SECURE_COOKIES = COOKIE_SECURE_MODE === 'true' || (process.env.NODE_ENV === 'production' && COOKIE_SECURE_MODE !== 'false'); +function getResolvedStorageRoot() { + const configuredRoot = process.env.STORAGE_ROOT; + if (!configuredRoot) { + return path.join(__dirname, 'storage'); + } + return path.isAbsolute(configuredRoot) + ? configuredRoot + : path.resolve(__dirname, configuredRoot); +} + +function getLocalUserStorageDir(userId) { + return path.join(getResolvedStorageRoot(), `user_${userId}`); +} + +function syncLocalStorageUsageFromDisk(userId, currentUsed = 0) { + const userStorageDir = getLocalUserStorageDir(userId); + if (!fs.existsSync(userStorageDir)) { + fs.mkdirSync(userStorageDir, { recursive: true, mode: 0o755 }); + } + + const actualUsed = getUserDirectorySize(userStorageDir); + const normalizedCurrentUsed = Number(currentUsed) || 0; + + if (actualUsed !== normalizedCurrentUsed) { + UserDB.update(userId, { local_storage_used: actualUsed }); + console.log(`[本地存储] 已校准用户 ${userId} 本地用量: ${normalizedCurrentUsed} -> ${actualUsed}`); + } + + return actualUsed; +} + if (process.env.NODE_ENV === 'production' && COOKIE_SECURE_MODE !== 'true') { console.warn('[安全警告] 生产环境建议设置 COOKIE_SECURE=true,以避免会话Cookie在HTTP下传输'); } @@ -597,6 +628,74 @@ function normalizeOssQuota(rawQuota) { return parsedQuota; } +function normalizeDownloadTrafficQuota(rawQuota) { + const parsedQuota = Number(rawQuota); + if (!Number.isFinite(parsedQuota) || parsedQuota <= 0) { + return 0; // 0 表示不限流量 + } + return Math.floor(parsedQuota); +} + +function normalizeDownloadTrafficUsed(rawUsed, quota = 0) { + const parsedUsed = Number(rawUsed); + const normalizedUsed = Number.isFinite(parsedUsed) && parsedUsed > 0 + ? Math.floor(parsedUsed) + : 0; + if (quota > 0) { + return Math.min(normalizedUsed, quota); + } + return normalizedUsed; +} + +function getDownloadTrafficState(user) { + const quota = normalizeDownloadTrafficQuota(user?.download_traffic_quota); + const used = normalizeDownloadTrafficUsed(user?.download_traffic_used, quota); + return { + quota, + used, + isUnlimited: quota <= 0, + remaining: quota > 0 ? Math.max(0, quota - used) : Number.POSITIVE_INFINITY + }; +} + +const applyDownloadTrafficUsageTransaction = db.transaction((userId, bytesToAdd) => { + const user = UserDB.findById(userId); + if (!user) { + return null; + } + + const trafficState = getDownloadTrafficState(user); + if (bytesToAdd <= 0) { + return { + quota: trafficState.quota, + usedBefore: trafficState.used, + usedAfter: trafficState.used, + added: 0 + }; + } + + const nextUsed = trafficState.isUnlimited + ? trafficState.used + bytesToAdd + : Math.min(trafficState.quota, trafficState.used + bytesToAdd); + + UserDB.update(userId, { download_traffic_used: nextUsed }); + + return { + quota: trafficState.quota, + usedBefore: trafficState.used, + usedAfter: nextUsed, + added: Math.max(0, nextUsed - trafficState.used) + }; +}); + +function applyDownloadTrafficUsage(userId, bytesToAdd) { + const parsedBytes = Number(bytesToAdd); + if (!Number.isFinite(parsedBytes) || parsedBytes <= 0) { + return null; + } + return applyDownloadTrafficUsageTransaction(userId, Math.floor(parsedBytes)); +} + // 构建用于存储客户端的用户对象(自动尝试解密 OSS Secret) function buildStorageUserContext(user, overrides = {}) { if (!user) { @@ -2052,6 +2151,11 @@ app.post('/api/login', }); } + // 本地存储用量校准:防止目录被外部清理后仍显示旧用量 + if ((user.current_storage_type || 'oss') === 'local') { + user.local_storage_used = syncLocalStorageUsageFromDisk(user.id, user.local_storage_used); + } + const token = generateToken(user); const refreshToken = generateRefreshToken(user); @@ -2104,6 +2208,11 @@ app.post('/api/login', local_storage_used: user.local_storage_used || 0, oss_storage_quota: normalizeOssQuota(user.oss_storage_quota), storage_used: user.storage_used || 0, + download_traffic_quota: normalizeDownloadTrafficQuota(user.download_traffic_quota), + download_traffic_used: normalizeDownloadTrafficUsed( + user.download_traffic_used, + normalizeDownloadTrafficQuota(user.download_traffic_quota) + ), // OSS配置来源(重要:用于前端判断是否使用OSS直连上传) oss_config_source: SettingsDB.hasUnifiedOssConfig() ? 'unified' : (user.has_oss_config ? 'personal' : 'none') } @@ -2169,8 +2278,15 @@ app.post('/api/logout', (req, res) => { // 获取当前用户信息 app.get('/api/user/profile', authMiddleware, (req, res) => { + const userPayload = { ...req.user }; + + // 本地存储用量校准:避免“有占用但目录为空”的显示错乱 + if ((userPayload.current_storage_type || 'oss') === 'local') { + userPayload.local_storage_used = syncLocalStorageUsageFromDisk(userPayload.id, userPayload.local_storage_used); + } + // 不返回敏感信息(密码和 OSS 密钥) - const { password, oss_access_key_secret, ...safeUser } = req.user; + const { password, oss_access_key_secret, ...safeUser } = userPayload; // 检查是否使用统一 OSS 配置 const hasUnifiedConfig = SettingsDB.hasUnifiedOssConfig(); @@ -2800,8 +2916,19 @@ app.post('/api/user/switch-storage', } } - // 更新存储类型 - UserDB.update(req.user.id, { current_storage_type: storage_type }); + // 更新存储类型(切到本地时同步校准本地用量) + const updates = { current_storage_type: storage_type }; + if (storage_type === 'local') { + const latestUser = UserDB.findById(req.user.id); + if (latestUser) { + updates.local_storage_used = syncLocalStorageUsageFromDisk( + latestUser.id, + latestUser.local_storage_used + ); + } + } + + UserDB.update(req.user.id, updates); res.json({ success: true, @@ -3580,6 +3707,23 @@ app.get('/api/files/download-url', authMiddleware, async (req, res) => { }); } + // 开启下载流量配额后,禁止发放直连 URL(避免绕过后端计量) + const latestUser = UserDB.findById(req.user.id); + if (!latestUser) { + return res.status(401).json({ + success: false, + message: '用户不存在' + }); + } + + const trafficState = getDownloadTrafficState(latestUser); + if (!trafficState.isUnlimited) { + return res.status(403).json({ + success: false, + message: '当前账号已启用下载流量限制,请使用系统下载入口' + }); + } + // 检查用户是否配置了 OSS(包括个人配置和系统级统一配置) const hasUnifiedConfig = SettingsDB.hasUnifiedOssConfig(); if (!req.user.has_oss_config && !hasUnifiedConfig) { @@ -3788,6 +3932,15 @@ app.get('/api/files/download', authMiddleware, async (req, res) => { const filePath = req.query.path; let storage; let storageEnded = false; // 防止重复关闭 + let transferFinalized = false; // 防止重复结算 + let downloadedBytes = 0; + let responseBodyStartSocketBytes = 0; + + // Express 会将 HEAD 映射到 GET 处理器,这里显式拒绝,避免误触发下载计量 + if (req.method === 'HEAD') { + res.setHeader('Allow', 'GET'); + return res.status(405).end(); + } // 安全关闭存储连接的辅助函数 const safeEndStorage = async () => { @@ -3801,6 +3954,39 @@ app.get('/api/files/download', authMiddleware, async (req, res) => { } }; + const finalizeTransfer = async (reason = '') => { + if (transferFinalized) { + return; + } + transferFinalized = true; + + try { + const socketBytesWritten = Number(res.socket?.bytesWritten); + const socketBodyBytes = Number.isFinite(socketBytesWritten) && socketBytesWritten > responseBodyStartSocketBytes + ? Math.floor(socketBytesWritten - responseBodyStartSocketBytes) + : 0; + const billableBytes = socketBodyBytes > 0 + ? Math.min(downloadedBytes, socketBodyBytes) + : downloadedBytes; + + if (billableBytes > 0) { + const usageResult = applyDownloadTrafficUsage(req.user.id, billableBytes); + if (usageResult) { + const quotaText = usageResult.quota > 0 ? formatFileSize(usageResult.quota) : '不限'; + console.log( + `[下载流量] 用户 ${req.user.id} 新增 ${formatFileSize(usageResult.added)},` + + `累计 ${formatFileSize(usageResult.usedAfter)} / ${quotaText} ` + + `(reason=${reason || 'unknown'}, streamed=${downloadedBytes}, socket=${socketBodyBytes})` + ); + } + } + } catch (error) { + console.error(`[下载流量] 结算失败: user=${req.user.id}, bytes=${downloadedBytes}`, error); + } + + await safeEndStorage(); + }; + if (!filePath) { return res.status(400).json({ success: false, @@ -3818,26 +4004,66 @@ app.get('/api/files/download', authMiddleware, async (req, res) => { } try { + const latestUser = UserDB.findById(req.user.id); + if (!latestUser) { + return res.status(401).json({ + success: false, + message: '用户不存在' + }); + } + const trafficState = getDownloadTrafficState(latestUser); + // 使用统一存储接口 const { StorageInterface } = require('./storage'); const storageInterface = new StorageInterface(req.user); storage = await storageInterface.connect(); // 获取文件名 - const fileName = filePath.split('/').pop(); + const fileName = normalizedPath.split('/').pop() || 'download.bin'; // 先获取文件信息(获取文件大小) - const fileStats = await storage.stat(filePath); - const fileSize = fileStats.size; + const fileStats = await storage.stat(normalizedPath); + const fileSize = Math.max(0, Number(fileStats?.size) || 0); console.log('[下载] 文件: ' + fileName + ', 大小: ' + fileSize + ' 字节'); + if (!trafficState.isUnlimited && fileSize > trafficState.remaining) { + await safeEndStorage(); + return res.status(403).json({ + success: false, + message: `下载流量不足:文件 ${formatFileSize(fileSize)},剩余 ${formatFileSize(trafficState.remaining)}` + }); + } + // 设置响应头(包含文件大小,浏览器可显示下载进度) res.setHeader('Content-Type', 'application/octet-stream'); res.setHeader('Content-Length', fileSize); + // 关闭 Nginx 代理缓冲,避免上游提前读完整文件导致流量计量失真 + res.setHeader('X-Accel-Buffering', 'no'); res.setHeader('Content-Disposition', 'attachment; filename="' + encodeURIComponent(fileName) + '"; filename*=UTF-8\'\'' + encodeURIComponent(fileName)); + if (typeof res.flushHeaders === 'function') { + res.flushHeaders(); + } + responseBodyStartSocketBytes = Number(res.socket?.bytesWritten) || 0; // 创建文件流并传输(流式下载,服务器不保存临时文件) - const stream = await storage.createReadStream(filePath); + const stream = await storage.createReadStream(normalizedPath); + + stream.on('data', (chunk) => { + if (!chunk) return; + downloadedBytes += Buffer.isBuffer(chunk) ? chunk.length : Buffer.byteLength(chunk); + }); + + res.on('finish', () => { + finalizeTransfer('finish').catch(err => { + console.error('下载完成后资源释放失败:', err); + }); + }); + + res.on('close', () => { + finalizeTransfer('close').catch(err => { + console.error('下载连接关闭后资源释放失败:', err); + }); + }); stream.on('error', (error) => { console.error('文件流错误:', error); @@ -3847,14 +4073,9 @@ app.get('/api/files/download', authMiddleware, async (req, res) => { message: getSafeErrorMessage(error, '下载文件失败,请稍后重试', '下载文件') }); } - // 发生错误时关闭存储连接 - safeEndStorage(); - }); - - // 在传输完成后关闭存储连接 - stream.on('close', () => { - console.log('[下载] 文件传输完成,关闭存储连接'); - safeEndStorage(); + finalizeTransfer('stream_error').catch(err => { + console.error('流错误后资源释放失败:', err); + }); }); stream.pipe(res); @@ -3863,7 +4084,7 @@ app.get('/api/files/download', authMiddleware, async (req, res) => { console.error('下载文件失败:', error); // 如果stream还未创建或发生错误,关闭storage连接 - await safeEndStorage(); + await finalizeTransfer('catch_error'); if (!res.headersSent) { res.status(500).json({ success: false, @@ -4783,9 +5004,10 @@ app.post('/api/share/:code/download-url', shareRateLimitMiddleware, async (req, } const storageType = share.storage_type || 'oss'; + const ownerTrafficState = getDownloadTrafficState(shareOwner); - // 本地存储模式:返回后端下载 URL(短期 token,避免在 URL 中传密码) - if (storageType !== 'oss') { + // 本地存储,或分享者启用了下载流量上限:统一走后端下载(便于计量) + if (storageType !== 'oss' || !ownerTrafficState.isUnlimited) { let downloadUrl = `${getSecureBaseUrl(req)}/api/share/${code}/download-file?path=${encodeURIComponent(normalizedFilePath)}`; if (share.share_password) { @@ -4800,7 +5022,8 @@ app.post('/api/share/:code/download-url', shareRateLimitMiddleware, async (req, return res.json({ success: true, downloadUrl, - direct: false + direct: false, + quotaLimited: !ownerTrafficState.isUnlimited }); } @@ -4845,8 +5068,7 @@ app.post('/api/share/:code/download-url', shareRateLimitMiddleware, async (req, } }); -// 分享文件下载(支持本地存储,公开API,需要分享码和密码验证) -// 注意:OSS 模式请使用 /api/share/:code/download-url 获取直连下载链接 +// 分享文件下载(支持本地存储和 OSS,公开 API,需要分享码和密码验证) app.get('/api/share/:code/download-file', shareRateLimitMiddleware, async (req, res) => { const { code } = req.params; const rawFilePath = typeof req.query?.path === 'string' ? req.query.path : ''; @@ -4854,6 +5076,16 @@ app.get('/api/share/:code/download-file', shareRateLimitMiddleware, async (req, const filePath = normalizeVirtualPath(rawFilePath); let storage; let storageEnded = false; // 防止重复关闭 + let transferFinalized = false; // 防止重复结算 + let downloadedBytes = 0; + let responseBodyStartSocketBytes = 0; + let shareOwnerId = null; + + // Express 会将 HEAD 映射到 GET 处理器,这里显式拒绝,避免误触发下载计量 + if (req.method === 'HEAD') { + res.setHeader('Allow', 'GET'); + return res.status(405).end(); + } // 安全关闭存储连接的辅助函数 const safeEndStorage = async () => { @@ -4867,6 +5099,39 @@ app.get('/api/share/:code/download-file', shareRateLimitMiddleware, async (req, } }; + const finalizeTransfer = async (reason = '') => { + if (transferFinalized) { + return; + } + transferFinalized = true; + + try { + if (shareOwnerId && downloadedBytes > 0) { + const socketBytesWritten = Number(res.socket?.bytesWritten); + const socketBodyBytes = Number.isFinite(socketBytesWritten) && socketBytesWritten > responseBodyStartSocketBytes + ? Math.floor(socketBytesWritten - responseBodyStartSocketBytes) + : 0; + const billableBytes = socketBodyBytes > 0 + ? Math.min(downloadedBytes, socketBodyBytes) + : downloadedBytes; + + const usageResult = applyDownloadTrafficUsage(shareOwnerId, billableBytes); + if (usageResult) { + const quotaText = usageResult.quota > 0 ? formatFileSize(usageResult.quota) : '不限'; + console.log( + `[分享下载流量] 用户 ${shareOwnerId} 新增 ${formatFileSize(usageResult.added)},` + + `累计 ${formatFileSize(usageResult.usedAfter)} / ${quotaText} ` + + `(reason=${reason || 'unknown'}, streamed=${downloadedBytes}, socket=${socketBodyBytes})` + ); + } + } + } catch (error) { + console.error(`[分享下载流量] 结算失败: user=${shareOwnerId}, bytes=${downloadedBytes}`, error); + } + + await safeEndStorage(); + }; + if (!filePath) { return res.status(400).json({ success: false, @@ -4935,6 +5200,8 @@ app.get('/api/share/:code/download-file', shareRateLimitMiddleware, async (req, message: '分享者不存在' }); } + shareOwnerId = shareOwner.id; + const ownerTrafficState = getDownloadTrafficState(shareOwner); // 使用统一存储接口,根据分享的storage_type选择存储后端 // 注意:必须使用分享创建时记录的 storage_type,而不是分享者当前的存储类型 @@ -4959,17 +5226,48 @@ app.get('/api/share/:code/download-file', shareRateLimitMiddleware, async (req, const fileSize = fileStats.size; console.log(`[分享下载] 文件: ${fileName}, 大小: ${fileSize} 字节`); + if (!ownerTrafficState.isUnlimited && fileSize > ownerTrafficState.remaining) { + await safeEndStorage(); + return res.status(403).json({ + success: false, + message: `分享者下载流量不足:文件 ${formatFileSize(fileSize)},剩余 ${formatFileSize(ownerTrafficState.remaining)}` + }); + } + // 增加下载次数 ShareDB.incrementDownloadCount(code); // 设置响应头(包含文件大小,浏览器可显示下载进度) res.setHeader('Content-Type', 'application/octet-stream'); res.setHeader('Content-Length', fileSize); + // 关闭 Nginx 代理缓冲,减少代理预读导致的计量偏差 + res.setHeader('X-Accel-Buffering', 'no'); res.setHeader('Content-Disposition', `attachment; filename="${encodeURIComponent(fileName)}"; filename*=UTF-8''${encodeURIComponent(fileName)}`); + if (typeof res.flushHeaders === 'function') { + res.flushHeaders(); + } + responseBodyStartSocketBytes = Number(res.socket?.bytesWritten) || 0; // 创建文件流并传输(流式下载,服务器不保存临时文件) const stream = await storage.createReadStream(filePath); + stream.on('data', (chunk) => { + if (!chunk) return; + downloadedBytes += Buffer.isBuffer(chunk) ? chunk.length : Buffer.byteLength(chunk); + }); + + res.on('finish', () => { + finalizeTransfer('finish').catch(err => { + console.error('分享下载完成后资源释放失败:', err); + }); + }); + + res.on('close', () => { + finalizeTransfer('close').catch(err => { + console.error('分享下载连接关闭后资源释放失败:', err); + }); + }); + stream.on('error', (error) => { console.error('文件流错误:', error); if (!res.headersSent) { @@ -4978,14 +5276,9 @@ app.get('/api/share/:code/download-file', shareRateLimitMiddleware, async (req, message: getSafeErrorMessage(error, '下载文件失败,请稍后重试', '分享下载') }); } - // 发生错误时关闭存储连接 - safeEndStorage(); - }); - - // 在传输完成后关闭存储连接 - stream.on('close', () => { - console.log('[分享下载] 文件传输完成,关闭存储连接'); - safeEndStorage(); + finalizeTransfer('stream_error').catch(err => { + console.error('分享下载流错误后资源释放失败:', err); + }); }); stream.pipe(res); @@ -4998,8 +5291,8 @@ app.get('/api/share/:code/download-file', shareRateLimitMiddleware, async (req, message: getSafeErrorMessage(error, '下载文件失败,请稍后重试', '分享下载') }); } - // 如果发生错误,关闭存储连接 - await safeEndStorage(); + // 如果发生错误,结算并关闭存储连接 + await finalizeTransfer('catch_error'); } }); @@ -5423,7 +5716,7 @@ app.get('/api/admin/health-check', authMiddleware, adminMiddleware, async (req, }); // 7. 存储目录检查 - const storageRoot = process.env.STORAGE_ROOT || path.join(__dirname, 'storage'); + const storageRoot = getResolvedStorageRoot(); let storageStatus = 'pass'; let storageMessage = `存储目录正常: ${storageRoot}`; try { @@ -5629,7 +5922,7 @@ app.post('/api/admin/wal-checkpoint', app.get('/api/admin/storage-stats', authMiddleware, adminMiddleware, async (req, res) => { try { // 获取本地存储目录(与 storage.js 保持一致) - const localStorageDir = process.env.STORAGE_ROOT || path.join(__dirname, 'storage'); + const localStorageDir = getResolvedStorageRoot(); // 确保存储目录存在 if (!fs.existsSync(localStorageDir)) { @@ -5740,7 +6033,12 @@ app.get('/api/admin/users', authMiddleware, adminMiddleware, (req, res) => { local_storage_quota: u.local_storage_quota || DEFAULT_LOCAL_STORAGE_QUOTA_BYTES, local_storage_used: u.local_storage_used || 0, oss_storage_quota: normalizeOssQuota(u.oss_storage_quota), - storage_used: u.storage_used || 0 + storage_used: u.storage_used || 0, + download_traffic_quota: normalizeDownloadTrafficQuota(u.download_traffic_quota), + download_traffic_used: normalizeDownloadTrafficUsed( + u.download_traffic_used, + normalizeDownloadTrafficQuota(u.download_traffic_quota) + ) })) }); } catch (error) { @@ -6209,7 +6507,7 @@ app.delete('/api/admin/users/:id', // 1. 删除本地存储文件(如果用户使用了本地存储) const storagePermission = user.storage_permission || 'oss_only'; if (storagePermission === 'local_only' || storagePermission === 'user_choice') { - const storageRoot = process.env.STORAGE_ROOT || path.join(__dirname, 'storage'); + const storageRoot = getResolvedStorageRoot(); const userStorageDir = path.join(storageRoot, `user_${userId}`); if (fs.existsSync(userStorageDir)) { @@ -6295,6 +6593,10 @@ app.delete('/api/admin/users/:id', // 辅助函数:计算目录大小 function getUserDirectorySize(dirPath) { + if (!dirPath || !fs.existsSync(dirPath)) { + return 0; + } + let totalSize = 0; function calculateSize(currentPath) { @@ -6325,7 +6627,8 @@ 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('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表示不限)') ], (req, res) => { const errors = validationResult(req); @@ -6338,7 +6641,7 @@ app.post('/api/admin/users/:id/storage-permission', try { const { id } = req.params; - const { storage_permission, local_storage_quota, oss_storage_quota } = req.body; + const { storage_permission, local_storage_quota, oss_storage_quota, download_traffic_quota } = req.body; // 参数验证:验证 ID 格式 const userId = parseInt(id, 10); @@ -6361,6 +6664,11 @@ 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); if (!user) { @@ -6370,6 +6678,17 @@ app.post('/api/admin/users/:id/storage-permission', }); } + // 如果管理员把配额调小,已用流量不能超过新配额 + if ( + updates.download_traffic_quota !== undefined && + updates.download_traffic_quota > 0 + ) { + updates.download_traffic_used = normalizeDownloadTrafficUsed( + user.download_traffic_used, + updates.download_traffic_quota + ); + } + if (storage_permission === 'local_only') { updates.current_storage_type = 'local'; } else if (storage_permission === 'oss_only') { diff --git a/frontend/app.html b/frontend/app.html index 06c8eb6..98a661c 100644 --- a/frontend/app.html +++ b/frontend/app.html @@ -3118,18 +3118,19 @@
| ID | -用户名 | -角色 | +用户名 | +角色 | 邮箱 | -存储权限 | -当前存储 | -配额使用 | -状态 | -操作 | +存储权限 | +当前存储 | +存储配额 | +下载流量 | +状态 | +操作 |
+
+
+ {{ formatBytes(u.download_traffic_used || 0) }} / {{ formatBytes(u.download_traffic_quota) }}
+
+ {{ getAdminUserDownloadQuotaPercentage(u) }}%
+
+
+
+ {{ formatBytes(u.download_traffic_used || 0) }} / 不限
+
+ 不限
+
+ |
已封禁
未激活
@@ -3517,13 +3532,43 @@
+
+
+
+
+
+
+
+
+
+
+
+ 下载流量范围: 不限 或 1MB - 10TB(按实际下载字节扣减)
+
+
配额说明:
• 本地默认配额: 1GB • 当前本地配额: {{ editStorageForm.local_storage_quota_value }} {{ editStorageForm.quota_unit }} ({{ editStorageForm.quota_unit === 'GB' ? (editStorageForm.local_storage_quota_value * 1024).toFixed(0) : editStorageForm.local_storage_quota_value }} MB) - • 当前 OSS 配额: {{ editStorageForm.oss_storage_quota_value + ' ' + editStorageForm.oss_quota_unit }} + • 当前 OSS 配额: {{ editStorageForm.oss_storage_quota_value + ' ' + editStorageForm.oss_quota_unit }} + • 已用下载流量: {{ formatBytes(editStorageForm.download_traffic_used || 0) }} + • 当前下载流量配额: {{ editStorageForm.download_quota_unlimited ? '不限' : (editStorageForm.download_traffic_quota_value + ' ' + editStorageForm.download_quota_unit) }} |
|---|