fix: force OSS direct download even when traffic quota is enabled

This commit is contained in:
2026-02-17 17:40:55 +08:00
parent 3a22b88f23
commit b171b41599
2 changed files with 156 additions and 17 deletions

View File

@@ -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) => {
// 生成签名 URL1小时有效
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,
// 生成签名 URL1小时有效
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
});

View File

@@ -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) {