From b171b415990777dc538a4f4f32e12ecaed3955f4 Mon Sep 17 00:00:00 2001 From: yuyx <237899745@qq.com> Date: Tue, 17 Feb 2026 17:40:55 +0800 Subject: [PATCH] fix: force OSS direct download even when traffic quota is enabled --- backend/server.js | 167 ++++++++++++++++++++++++++++++++++++++++++---- frontend/app.js | 6 +- 2 files changed, 156 insertions(+), 17 deletions(-) diff --git a/backend/server.js b/backend/server.js index 9a0829b..0295bd0 100644 --- a/backend/server.js +++ b/backend/server.js @@ -893,6 +893,63 @@ function applyDownloadTrafficUsage(userId, bytesToAdd) { return applyDownloadTrafficUsageTransaction(userId, Math.floor(parsedBytes)); } +const reserveDirectDownloadTrafficTransaction = db.transaction((userId, bytesToReserve) => { + const policyState = enforceDownloadTrafficPolicyTransaction(userId, 'direct_download_reserve'); + const user = policyState?.user || UserDB.findById(userId); + if (!user) { + return { ok: false, reason: 'user_not_found' }; + } + + const reserveBytes = Math.floor(Number(bytesToReserve)); + if (!Number.isFinite(reserveBytes) || reserveBytes <= 0) { + return { + ok: true, + quota: normalizeDownloadTrafficQuota(user.download_traffic_quota), + usedBefore: normalizeDownloadTrafficUsed(user.download_traffic_used, normalizeDownloadTrafficQuota(user.download_traffic_quota)), + usedAfter: normalizeDownloadTrafficUsed(user.download_traffic_used, normalizeDownloadTrafficQuota(user.download_traffic_quota)), + reserved: 0 + }; + } + + const trafficState = getDownloadTrafficState(user); + if (!trafficState.isUnlimited && reserveBytes > trafficState.remaining) { + return { + ok: false, + reason: 'insufficient', + quota: trafficState.quota, + usedBefore: trafficState.used, + remaining: trafficState.remaining + }; + } + + const nextUsed = trafficState.used + reserveBytes; + UserDB.update(userId, { download_traffic_used: nextUsed }); + DownloadTrafficReportDB.addUsage(userId, reserveBytes, 1, new Date()); + + return { + ok: true, + quota: trafficState.quota, + usedBefore: trafficState.used, + usedAfter: nextUsed, + reserved: reserveBytes + }; +}); + +function reserveDirectDownloadTraffic(userId, bytesToReserve) { + const parsedBytes = Number(bytesToReserve); + if (!Number.isFinite(parsedBytes) || parsedBytes <= 0) { + return { + ok: true, + quota: 0, + usedBefore: 0, + usedAfter: 0, + reserved: 0 + }; + } + + return reserveDirectDownloadTrafficTransaction(userId, Math.floor(parsedBytes)); +} + function runDownloadTrafficPolicySweep(trigger = 'scheduled') { try { const users = UserDB.getAll(); @@ -4068,7 +4125,6 @@ app.get('/api/files/download-url', authMiddleware, async (req, res) => { }); } - // 开启下载流量配额后,禁止发放直连 URL(避免绕过后端计量) const policyState = enforceDownloadTrafficPolicy(req.user.id, 'download_url'); const latestUser = policyState?.user || UserDB.findById(req.user.id); if (!latestUser) { @@ -4079,12 +4135,6 @@ app.get('/api/files/download-url', authMiddleware, async (req, res) => { } const trafficState = getDownloadTrafficState(latestUser); - if (!trafficState.isUnlimited) { - return res.status(403).json({ - success: false, - message: '当前账号已启用下载流量限制,请使用系统下载入口' - }); - } // 检查用户是否配置了 OSS(包括个人配置和系统级统一配置) const hasUnifiedConfig = SettingsDB.hasUnifiedOssConfig(); @@ -4096,11 +4146,44 @@ app.get('/api/files/download-url', authMiddleware, async (req, res) => { } try { - const { GetObjectCommand } = require('@aws-sdk/client-s3'); + const { GetObjectCommand, HeadObjectCommand } = require('@aws-sdk/client-s3'); const { getSignedUrl } = require('@aws-sdk/s3-request-presigner'); - const { client, bucket, ossClient } = createS3ClientContextForUser(req.user); + const { client, bucket, ossClient } = createS3ClientContextForUser(latestUser); const objectKey = ossClient.getObjectKey(normalizedPath); + let fileSize = 0; + + // 启用下载流量限制时,签发前先校验文件大小与剩余额度 + if (!trafficState.isUnlimited) { + let headResponse; + try { + headResponse = await client.send(new HeadObjectCommand({ + Bucket: bucket, + Key: objectKey + })); + } catch (headError) { + const statusCode = headError?.$metadata?.httpStatusCode; + if (headError?.name === 'NotFound' || headError?.name === 'NoSuchKey' || statusCode === 404) { + return res.status(404).json({ + success: false, + message: '文件不存在' + }); + } + throw headError; + } + + const contentLength = Number(headResponse?.ContentLength || 0); + fileSize = Number.isFinite(contentLength) && contentLength > 0 + ? Math.floor(contentLength) + : 0; + + if (fileSize > trafficState.remaining) { + return res.status(403).json({ + success: false, + message: `下载流量不足:文件 ${formatFileSize(fileSize)},剩余 ${formatFileSize(trafficState.remaining)}` + }); + } + } // 创建 GetObject 命令 const command = new GetObjectCommand({ @@ -4112,10 +4195,24 @@ app.get('/api/files/download-url', authMiddleware, async (req, res) => { // 生成签名 URL(1小时有效) const signedUrl = await getSignedUrl(client, command, { expiresIn: 3600 }); + // 直连模式下无法精确获知真实下载字节;限流时在签发前预扣文件大小 + if (!trafficState.isUnlimited && fileSize > 0) { + const reserveResult = reserveDirectDownloadTraffic(latestUser.id, fileSize); + if (!reserveResult?.ok) { + const remaining = Number(reserveResult?.remaining || 0); + return res.status(403).json({ + success: false, + message: `下载流量不足:文件 ${formatFileSize(fileSize)},剩余 ${formatFileSize(remaining)}` + }); + } + } + res.json({ success: true, downloadUrl: signedUrl, - expiresIn: 3600 + expiresIn: 3600, + direct: true, + quotaLimited: !trafficState.isUnlimited }); } catch (error) { console.error('[OSS签名] 生成下载签名失败:', error); @@ -5371,8 +5468,8 @@ app.post('/api/share/:code/download-url', shareRateLimitMiddleware, async (req, const storageType = share.storage_type || 'oss'; const ownerTrafficState = getDownloadTrafficState(shareOwner); - // 本地存储,或分享者启用了下载流量上限:统一走后端下载(便于计量) - if (storageType !== 'oss' || !ownerTrafficState.isUnlimited) { + // 本地存储:继续走后端下载 + if (storageType !== 'oss') { let downloadUrl = `${getSecureBaseUrl(req)}/api/share/${code}/download-file?path=${encodeURIComponent(normalizedFilePath)}`; if (share.share_password) { @@ -5401,11 +5498,43 @@ app.post('/api/share/:code/download-url', shareRateLimitMiddleware, async (req, }); } - const { GetObjectCommand } = require('@aws-sdk/client-s3'); + const { GetObjectCommand, HeadObjectCommand } = require('@aws-sdk/client-s3'); const { getSignedUrl } = require('@aws-sdk/s3-request-presigner'); const { client, bucket, ossClient } = createS3ClientContextForUser(shareOwner); const objectKey = ossClient.getObjectKey(normalizedFilePath); + let fileSize = 0; + + if (!ownerTrafficState.isUnlimited) { + let headResponse; + try { + headResponse = await client.send(new HeadObjectCommand({ + Bucket: bucket, + Key: objectKey + })); + } catch (headError) { + const statusCode = headError?.$metadata?.httpStatusCode; + if (headError?.name === 'NotFound' || headError?.name === 'NoSuchKey' || statusCode === 404) { + return res.status(404).json({ + success: false, + message: '文件不存在' + }); + } + throw headError; + } + + const contentLength = Number(headResponse?.ContentLength || 0); + fileSize = Number.isFinite(contentLength) && contentLength > 0 + ? Math.floor(contentLength) + : 0; + + if (fileSize > ownerTrafficState.remaining) { + return res.status(403).json({ + success: false, + message: `下载流量不足:文件 ${formatFileSize(fileSize)},剩余 ${formatFileSize(ownerTrafficState.remaining)}` + }); + } + } // 创建 GetObject 命令 const command = new GetObjectCommand({ @@ -5417,10 +5546,22 @@ app.post('/api/share/:code/download-url', shareRateLimitMiddleware, async (req, // 生成签名 URL(1小时有效) const signedUrl = await getSignedUrl(client, command, { expiresIn: 3600 }); + if (!ownerTrafficState.isUnlimited && fileSize > 0) { + const reserveResult = reserveDirectDownloadTraffic(shareOwner.id, fileSize); + if (!reserveResult?.ok) { + const remaining = Number(reserveResult?.remaining || 0); + return res.status(403).json({ + success: false, + message: `下载流量不足:文件 ${formatFileSize(fileSize)},剩余 ${formatFileSize(remaining)}` + }); + } + } + res.json({ success: true, downloadUrl: signedUrl, direct: true, + quotaLimited: !ownerTrafficState.isUnlimited, expiresIn: 3600 }); diff --git a/frontend/app.js b/frontend/app.js index 83b9eaa..a628e71 100644 --- a/frontend/app.js +++ b/frontend/app.js @@ -1549,12 +1549,10 @@ handleDragLeave(e) { // 构建文件路径 const filePath = this.currentPath === '/' ? `/${file.name}` : `${this.currentPath}/${file.name}`; - const hasDownloadTrafficLimit = Number(this.user?.download_traffic_quota || 0) > 0; const canDirectOssDownload = this.storageType === 'oss' - && this.user?.oss_config_source !== 'none' - && !hasDownloadTrafficLimit; + && this.user?.oss_config_source !== 'none'; - // OSS 且未启用下载限流:优先使用 OSS 直连下载(速度更快) + // OSS 下载优先使用直连(避免服务器中转导致带宽与费用双重损耗) if (canDirectOssDownload) { const directResult = await this.downloadFromOSS(filePath); if (directResult) {