feat(quota): add downloadable traffic quota with local/OSS/share metering

This commit is contained in:
2026-02-17 16:52:26 +08:00
parent b0e89df5c4
commit 2629237f9e
5 changed files with 750 additions and 84 deletions

View File

@@ -175,6 +175,17 @@ function authMiddleware(req, res, next) {
const effectiveOssQuota = Number.isFinite(rawOssQuota) && rawOssQuota > 0 const effectiveOssQuota = Number.isFinite(rawOssQuota) && rawOssQuota > 0
? rawOssQuota ? rawOssQuota
: DEFAULT_OSS_STORAGE_QUOTA_BYTES; : 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 = { req.user = {
@@ -198,6 +209,8 @@ function authMiddleware(req, res, next) {
local_storage_used: user.local_storage_used || 0, local_storage_used: user.local_storage_used || 0,
oss_storage_quota: effectiveOssQuota, oss_storage_quota: effectiveOssQuota,
storage_used: user.storage_used || 0, storage_used: user.storage_used || 0,
download_traffic_quota: effectiveDownloadTrafficQuota,
download_traffic_used: cappedDownloadTrafficUsed,
// 主题偏好 // 主题偏好
theme_preference: user.theme_preference || null theme_preference: user.theme_preference || null
}; };

View File

@@ -528,7 +528,9 @@ const UserDB = {
'is_verified': 'number', 'is_verified': 'number',
'local_storage_quota': 'number', 'local_storage_quota': 'number',
'local_storage_used': '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]; const expectedType = FIELD_TYPES[fieldName];
@@ -589,6 +591,8 @@ const UserDB = {
'local_storage_quota': 'local_storage_quota', 'local_storage_quota': 'local_storage_quota',
'local_storage_used': 'local_storage_used', 'local_storage_used': 'local_storage_used',
'oss_storage_quota': 'oss_storage_quota', 'oss_storage_quota': 'oss_storage_quota',
'download_traffic_quota': 'download_traffic_quota',
'download_traffic_used': 'download_traffic_used',
// 偏好设置 // 偏好设置
'theme_preference': 'theme_preference' 'theme_preference': 'theme_preference'
@@ -670,6 +674,8 @@ const UserDB = {
'local_storage_quota': 'local_storage_quota', 'local_storage_quota': 'local_storage_quota',
'local_storage_used': 'local_storage_used', 'local_storage_used': 'local_storage_used',
'oss_storage_quota': 'oss_storage_quota', 'oss_storage_quota': 'oss_storage_quota',
'download_traffic_quota': 'download_traffic_quota',
'download_traffic_used': 'download_traffic_used',
// 偏好设置 // 偏好设置
'theme_preference': 'theme_preference' 'theme_preference': 'theme_preference'
@@ -723,7 +729,8 @@ const UserDB = {
'is_admin': 'number', 'is_active': 'number', 'is_banned': 'number', 'is_admin': 'number', 'is_active': 'number', 'is_banned': 'number',
'has_oss_config': 'number', 'is_verified': 'number', 'has_oss_config': 'number', 'is_verified': 'number',
'local_storage_quota': 'number', 'local_storage_used': '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]; }[key];
console.warn(`[类型检查] 字段 ${key} 值类型不符: 期望 ${expectedType}, 实际 ${typeof value}, 值: ${JSON.stringify(value)}`); 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 = { const SystemLogDB = {
// 日志级别常量 // 日志级别常量
@@ -1471,6 +1532,7 @@ migrateToV2(); // 执行数据库迁移
migrateThemePreference(); // 主题偏好迁移 migrateThemePreference(); // 主题偏好迁移
migrateToOss(); // SFTP → OSS 迁移 migrateToOss(); // SFTP → OSS 迁移
migrateOssQuotaField(); // OSS 配额字段迁移 migrateOssQuotaField(); // OSS 配额字段迁移
migrateDownloadTrafficFields(); // 下载流量字段迁移
module.exports = { module.exports = {
db, db,

View File

@@ -80,6 +80,37 @@ const SHOULD_USE_SECURE_COOKIES =
COOKIE_SECURE_MODE === 'true' || COOKIE_SECURE_MODE === 'true' ||
(process.env.NODE_ENV === 'production' && COOKIE_SECURE_MODE !== 'false'); (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') { if (process.env.NODE_ENV === 'production' && COOKIE_SECURE_MODE !== 'true') {
console.warn('[安全警告] 生产环境建议设置 COOKIE_SECURE=true以避免会话Cookie在HTTP下传输'); console.warn('[安全警告] 生产环境建议设置 COOKIE_SECURE=true以避免会话Cookie在HTTP下传输');
} }
@@ -597,6 +628,74 @@ function normalizeOssQuota(rawQuota) {
return parsedQuota; 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 // 构建用于存储客户端的用户对象(自动尝试解密 OSS Secret
function buildStorageUserContext(user, overrides = {}) { function buildStorageUserContext(user, overrides = {}) {
if (!user) { 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 token = generateToken(user);
const refreshToken = generateRefreshToken(user); const refreshToken = generateRefreshToken(user);
@@ -2104,6 +2208,11 @@ app.post('/api/login',
local_storage_used: user.local_storage_used || 0, local_storage_used: user.local_storage_used || 0,
oss_storage_quota: normalizeOssQuota(user.oss_storage_quota), oss_storage_quota: normalizeOssQuota(user.oss_storage_quota),
storage_used: user.storage_used || 0, 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配置来源重要用于前端判断是否使用OSS直连上传
oss_config_source: SettingsDB.hasUnifiedOssConfig() ? 'unified' : (user.has_oss_config ? 'personal' : 'none') 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) => { 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 密钥) // 不返回敏感信息(密码和 OSS 密钥)
const { password, oss_access_key_secret, ...safeUser } = req.user; const { password, oss_access_key_secret, ...safeUser } = userPayload;
// 检查是否使用统一 OSS 配置 // 检查是否使用统一 OSS 配置
const hasUnifiedConfig = SettingsDB.hasUnifiedOssConfig(); 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({ res.json({
success: true, 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包括个人配置和系统级统一配置 // 检查用户是否配置了 OSS包括个人配置和系统级统一配置
const hasUnifiedConfig = SettingsDB.hasUnifiedOssConfig(); const hasUnifiedConfig = SettingsDB.hasUnifiedOssConfig();
if (!req.user.has_oss_config && !hasUnifiedConfig) { 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; const filePath = req.query.path;
let storage; let storage;
let storageEnded = false; // 防止重复关闭 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 () => { 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) { if (!filePath) {
return res.status(400).json({ return res.status(400).json({
success: false, success: false,
@@ -3818,26 +4004,66 @@ app.get('/api/files/download', authMiddleware, async (req, res) => {
} }
try { 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 } = require('./storage');
const storageInterface = new StorageInterface(req.user); const storageInterface = new StorageInterface(req.user);
storage = await storageInterface.connect(); storage = await storageInterface.connect();
// 获取文件名 // 获取文件名
const fileName = filePath.split('/').pop(); const fileName = normalizedPath.split('/').pop() || 'download.bin';
// 先获取文件信息(获取文件大小) // 先获取文件信息(获取文件大小)
const fileStats = await storage.stat(filePath); const fileStats = await storage.stat(normalizedPath);
const fileSize = fileStats.size; const fileSize = Math.max(0, Number(fileStats?.size) || 0);
console.log('[下载] 文件: ' + fileName + ', 大小: ' + fileSize + ' 字节'); 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-Type', 'application/octet-stream');
res.setHeader('Content-Length', fileSize); 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)); 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) => { stream.on('error', (error) => {
console.error('文件流错误:', error); console.error('文件流错误:', error);
@@ -3847,14 +4073,9 @@ app.get('/api/files/download', authMiddleware, async (req, res) => {
message: getSafeErrorMessage(error, '下载文件失败,请稍后重试', '下载文件') message: getSafeErrorMessage(error, '下载文件失败,请稍后重试', '下载文件')
}); });
} }
// 发生错误时关闭存储连接 finalizeTransfer('stream_error').catch(err => {
safeEndStorage(); console.error('流错误后资源释放失败:', err);
}); });
// 在传输完成后关闭存储连接
stream.on('close', () => {
console.log('[下载] 文件传输完成,关闭存储连接');
safeEndStorage();
}); });
stream.pipe(res); stream.pipe(res);
@@ -3863,7 +4084,7 @@ app.get('/api/files/download', authMiddleware, async (req, res) => {
console.error('下载文件失败:', error); console.error('下载文件失败:', error);
// 如果stream还未创建或发生错误关闭storage连接 // 如果stream还未创建或发生错误关闭storage连接
await safeEndStorage(); await finalizeTransfer('catch_error');
if (!res.headersSent) { if (!res.headersSent) {
res.status(500).json({ res.status(500).json({
success: false, success: false,
@@ -4783,9 +5004,10 @@ app.post('/api/share/:code/download-url', shareRateLimitMiddleware, async (req,
} }
const storageType = share.storage_type || 'oss'; 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)}`; let downloadUrl = `${getSecureBaseUrl(req)}/api/share/${code}/download-file?path=${encodeURIComponent(normalizedFilePath)}`;
if (share.share_password) { if (share.share_password) {
@@ -4800,7 +5022,8 @@ app.post('/api/share/:code/download-url', shareRateLimitMiddleware, async (req,
return res.json({ return res.json({
success: true, success: true,
downloadUrl, 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需要分享码和密码验证
// 注意OSS 模式请使用 /api/share/:code/download-url 获取直连下载链接
app.get('/api/share/:code/download-file', shareRateLimitMiddleware, async (req, res) => { app.get('/api/share/:code/download-file', shareRateLimitMiddleware, async (req, res) => {
const { code } = req.params; const { code } = req.params;
const rawFilePath = typeof req.query?.path === 'string' ? req.query.path : ''; 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); const filePath = normalizeVirtualPath(rawFilePath);
let storage; let storage;
let storageEnded = false; // 防止重复关闭 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 () => { 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) { if (!filePath) {
return res.status(400).json({ return res.status(400).json({
success: false, success: false,
@@ -4935,6 +5200,8 @@ app.get('/api/share/:code/download-file', shareRateLimitMiddleware, async (req,
message: '分享者不存在' message: '分享者不存在'
}); });
} }
shareOwnerId = shareOwner.id;
const ownerTrafficState = getDownloadTrafficState(shareOwner);
// 使用统一存储接口根据分享的storage_type选择存储后端 // 使用统一存储接口根据分享的storage_type选择存储后端
// 注意:必须使用分享创建时记录的 storage_type而不是分享者当前的存储类型 // 注意:必须使用分享创建时记录的 storage_type而不是分享者当前的存储类型
@@ -4959,17 +5226,48 @@ app.get('/api/share/:code/download-file', shareRateLimitMiddleware, async (req,
const fileSize = fileStats.size; const fileSize = fileStats.size;
console.log(`[分享下载] 文件: ${fileName}, 大小: ${fileSize} 字节`); 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); ShareDB.incrementDownloadCount(code);
// 设置响应头(包含文件大小,浏览器可显示下载进度) // 设置响应头(包含文件大小,浏览器可显示下载进度)
res.setHeader('Content-Type', 'application/octet-stream'); res.setHeader('Content-Type', 'application/octet-stream');
res.setHeader('Content-Length', fileSize); 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)}`); 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(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) => { stream.on('error', (error) => {
console.error('文件流错误:', error); console.error('文件流错误:', error);
if (!res.headersSent) { if (!res.headersSent) {
@@ -4978,14 +5276,9 @@ app.get('/api/share/:code/download-file', shareRateLimitMiddleware, async (req,
message: getSafeErrorMessage(error, '下载文件失败,请稍后重试', '分享下载') message: getSafeErrorMessage(error, '下载文件失败,请稍后重试', '分享下载')
}); });
} }
// 发生错误时关闭存储连接 finalizeTransfer('stream_error').catch(err => {
safeEndStorage(); console.error('分享下载流错误后资源释放失败:', err);
}); });
// 在传输完成后关闭存储连接
stream.on('close', () => {
console.log('[分享下载] 文件传输完成,关闭存储连接');
safeEndStorage();
}); });
stream.pipe(res); stream.pipe(res);
@@ -4998,8 +5291,8 @@ app.get('/api/share/:code/download-file', shareRateLimitMiddleware, async (req,
message: getSafeErrorMessage(error, '下载文件失败,请稍后重试', '分享下载') message: getSafeErrorMessage(error, '下载文件失败,请稍后重试', '分享下载')
}); });
} }
// 如果发生错误,关闭存储连接 // 如果发生错误,结算并关闭存储连接
await safeEndStorage(); await finalizeTransfer('catch_error');
} }
}); });
@@ -5423,7 +5716,7 @@ app.get('/api/admin/health-check', authMiddleware, adminMiddleware, async (req,
}); });
// 7. 存储目录检查 // 7. 存储目录检查
const storageRoot = process.env.STORAGE_ROOT || path.join(__dirname, 'storage'); const storageRoot = getResolvedStorageRoot();
let storageStatus = 'pass'; let storageStatus = 'pass';
let storageMessage = `存储目录正常: ${storageRoot}`; let storageMessage = `存储目录正常: ${storageRoot}`;
try { try {
@@ -5629,7 +5922,7 @@ app.post('/api/admin/wal-checkpoint',
app.get('/api/admin/storage-stats', authMiddleware, adminMiddleware, async (req, res) => { app.get('/api/admin/storage-stats', authMiddleware, adminMiddleware, async (req, res) => {
try { try {
// 获取本地存储目录(与 storage.js 保持一致) // 获取本地存储目录(与 storage.js 保持一致)
const localStorageDir = process.env.STORAGE_ROOT || path.join(__dirname, 'storage'); const localStorageDir = getResolvedStorageRoot();
// 确保存储目录存在 // 确保存储目录存在
if (!fs.existsSync(localStorageDir)) { 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_quota: u.local_storage_quota || DEFAULT_LOCAL_STORAGE_QUOTA_BYTES,
local_storage_used: u.local_storage_used || 0, local_storage_used: u.local_storage_used || 0,
oss_storage_quota: normalizeOssQuota(u.oss_storage_quota), 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) { } catch (error) {
@@ -6209,7 +6507,7 @@ app.delete('/api/admin/users/:id',
// 1. 删除本地存储文件(如果用户使用了本地存储) // 1. 删除本地存储文件(如果用户使用了本地存储)
const storagePermission = user.storage_permission || 'oss_only'; const storagePermission = user.storage_permission || 'oss_only';
if (storagePermission === 'local_only' || storagePermission === 'user_choice') { 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}`); const userStorageDir = path.join(storageRoot, `user_${userId}`);
if (fs.existsSync(userStorageDir)) { if (fs.existsSync(userStorageDir)) {
@@ -6295,6 +6593,10 @@ app.delete('/api/admin/users/:id',
// 辅助函数:计算目录大小 // 辅助函数:计算目录大小
function getUserDirectorySize(dirPath) { function getUserDirectorySize(dirPath) {
if (!dirPath || !fs.existsSync(dirPath)) {
return 0;
}
let totalSize = 0; let totalSize = 0;
function calculateSize(currentPath) { 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('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('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) => { (req, res) => {
const errors = validationResult(req); const errors = validationResult(req);
@@ -6338,7 +6641,7 @@ app.post('/api/admin/users/:id/storage-permission',
try { try {
const { id } = req.params; 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 格式 // 参数验证:验证 ID 格式
const userId = parseInt(id, 10); 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); 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 user = UserDB.findById(userId);
if (!user) { 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') { if (storage_permission === 'local_only') {
updates.current_storage_type = 'local'; updates.current_storage_type = 'local';
} else if (storage_permission === 'oss_only') { } else if (storage_permission === 'oss_only') {

View File

@@ -3118,18 +3118,19 @@
<div class="card"> <div class="card">
<h3 style="margin-bottom: 20px;">用户管理</h3> <h3 style="margin-bottom: 20px;">用户管理</h3>
<div class="admin-users-table-wrap" style="overflow-x: auto;"> <div class="admin-users-table-wrap" style="overflow-x: auto;">
<table class="admin-users-table" style="width: 100%; border-collapse: collapse; table-layout: fixed; min-width: 760px;"> <table class="admin-users-table" style="width: 100%; border-collapse: collapse; table-layout: fixed; min-width: 900px;">
<thead> <thead>
<tr style="background: rgba(255,255,255,0.05);"> <tr style="background: rgba(255,255,255,0.05);">
<th style="padding: 10px; text-align: left; width: 4%;">ID</th> <th style="padding: 10px; text-align: left; width: 4%;">ID</th>
<th style="padding: 10px; text-align: left; width: 10%;">用户名</th> <th style="padding: 10px; text-align: left; width: 9%;">用户名</th>
<th style="padding: 10px; text-align: center; width: 10%;">角色</th> <th style="padding: 10px; text-align: center; width: 9%;">角色</th>
<th style="padding: 10px; text-align: left; width: 14%;">邮箱</th> <th style="padding: 10px; text-align: left; width: 14%;">邮箱</th>
<th style="padding: 10px; text-align: center; width: 9%;">存储权限</th> <th style="padding: 10px; text-align: center; width: 8%;">存储权限</th>
<th style="padding: 10px; text-align: center; width: 9%;">当前存储</th> <th style="padding: 10px; text-align: center; width: 8%;">当前存储</th>
<th style="padding: 10px; text-align: center; width: 12%;">配额使用</th> <th style="padding: 10px; text-align: center; width: 11%;">存储配额</th>
<th style="padding: 10px; text-align: center; width: 8%;">状态</th> <th style="padding: 10px; text-align: center; width: 11%;">下载流量</th>
<th style="padding: 10px; text-align: center; width: 24%;">操作</th> <th style="padding: 10px; text-align: center; width: 7%;">状态</th>
<th style="padding: 10px; text-align: center; width: 19%;">操作</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@@ -3176,6 +3177,20 @@
</div> </div>
</div> </div>
</td> </td>
<td style="padding: 10px; text-align: center; font-size: 12px;">
<div v-if="u.download_traffic_quota > 0">
<div>{{ formatBytes(u.download_traffic_used || 0) }} / {{ formatBytes(u.download_traffic_quota) }}</div>
<div style="font-size: 11px; color: var(--text-muted);">
{{ getAdminUserDownloadQuotaPercentage(u) }}%
</div>
</div>
<div v-else>
<div>{{ formatBytes(u.download_traffic_used || 0) }} / 不限</div>
<div style="font-size: 11px; color: var(--text-muted);">
不限
</div>
</div>
</td>
<td style="padding: 10px; text-align: center;"> <td style="padding: 10px; text-align: center;">
<span v-if="u.is_banned" style="color: #ef4444; font-weight: 600;">已封禁</span> <span v-if="u.is_banned" style="color: #ef4444; font-weight: 600;">已封禁</span>
<span v-else-if="!u.is_verified" style="color: #f59e0b; font-weight: 600;">未激活</span> <span v-else-if="!u.is_verified" style="color: #f59e0b; font-weight: 600;">未激活</span>
@@ -3517,13 +3532,43 @@
</small> </small>
</div> </div>
<div class="form-group">
<label class="form-label">下载流量配额</label>
<div style="display: flex; align-items: center; gap: 10px; margin-bottom: 8px;">
<label style="display: inline-flex; align-items: center; gap: 6px; font-size: 13px; color: var(--text-secondary);">
<input type="checkbox" v-model="editStorageForm.download_quota_unlimited">
不限流量
</label>
</div>
<div v-if="!editStorageForm.download_quota_unlimited" style="display: flex; gap: 10px;">
<input
type="number"
class="form-input"
v-model.number="editStorageForm.download_traffic_quota_value"
min="1"
max="10240"
step="1"
style="flex: 1;">
<select class="form-input" v-model="editStorageForm.download_quota_unit" style="width: 100px;">
<option value="MB">MB</option>
<option value="GB">GB</option>
<option value="TB">TB</option>
</select>
</div>
<small style="color: var(--text-secondary); font-size: 12px; margin-top: 5px; display: block;">
下载流量范围: 不限 或 1MB - 10TB按实际下载字节扣减
</small>
</div>
<div style="padding: 12px; background: rgba(255,255,255,0.03); border-radius: 6px; margin-bottom: 20px;"> <div style="padding: 12px; background: rgba(255,255,255,0.03); border-radius: 6px; margin-bottom: 20px;">
<div style="font-size: 13px; color: var(--text-secondary); line-height: 1.6;"> <div style="font-size: 13px; color: var(--text-secondary); line-height: 1.6;">
<strong style="color: var(--text-primary);">配额说明:</strong><br> <strong style="color: var(--text-primary);">配额说明:</strong><br>
• 本地默认配额: 1GB<br> • 本地默认配额: 1GB<br>
• 当前本地配额: {{ editStorageForm.local_storage_quota_value }} {{ editStorageForm.quota_unit }} • 当前本地配额: {{ 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)<br> ({{ editStorageForm.quota_unit === 'GB' ? (editStorageForm.local_storage_quota_value * 1024).toFixed(0) : editStorageForm.local_storage_quota_value }} MB)<br>
• 当前 OSS 配额: {{ editStorageForm.oss_storage_quota_value + ' ' + editStorageForm.oss_quota_unit }} • 当前 OSS 配额: {{ editStorageForm.oss_storage_quota_value + ' ' + editStorageForm.oss_quota_unit }}<br>
• 已用下载流量: {{ formatBytes(editStorageForm.download_traffic_used || 0) }}<br>
• 当前下载流量配额: {{ editStorageForm.download_quota_unlimited ? '不限' : (editStorageForm.download_traffic_quota_value + ' ' + editStorageForm.download_quota_unit) }}
</div> </div>
</div> </div>
@@ -4884,6 +4929,11 @@
text-align: center !important; text-align: center !important;
} }
body.enterprise-netdisk .file-list-table td.file-list-action-col {
overflow: visible;
text-overflow: clip;
}
body.enterprise-netdisk .file-list-action-col .btn { body.enterprise-netdisk .file-list-action-col .btn {
margin: 0 auto; margin: 0 auto;
} }
@@ -5885,7 +5935,15 @@
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
gap: 6px; gap: 6px;
flex-wrap: nowrap; flex-wrap: wrap;
margin-left: auto;
justify-content: flex-end;
min-width: 0;
flex: 1 1 auto;
}
body.enterprise-netdisk .files-content-head-actions > * {
min-width: 0;
} }
body.enterprise-netdisk .files-head-action-btn { body.enterprise-netdisk .files-head-action-btn {
@@ -6242,6 +6300,177 @@
} }
</style> </style>
<script src="app.js?v=20260212007"></script> <style>
/* ===== Files Header Action Layout v3 ===== */
@media (min-width: 992px) {
body.enterprise-netdisk .files-content-head.files-content-head-compact {
display: grid;
grid-template-columns: max-content minmax(220px, 1fr) minmax(420px, 560px);
align-items: center;
column-gap: 10px;
row-gap: 8px;
}
body.enterprise-netdisk .files-content-head-meta {
width: 100%;
margin-left: 0;
justify-content: flex-end;
}
body.enterprise-netdisk .files-content-head-actions {
width: 100%;
margin-left: 0;
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 8px;
align-items: stretch;
}
body.enterprise-netdisk .files-head-action-btn,
body.enterprise-netdisk .files-head-folder-btn,
body.enterprise-netdisk .files-head-view-toggle {
width: 100%;
min-width: 0;
}
body.enterprise-netdisk .files-head-action-btn,
body.enterprise-netdisk .files-head-view-toggle .btn {
height: 36px;
padding: 0 10px;
font-size: 12px;
}
body.enterprise-netdisk .files-head-view-toggle {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 6px;
padding: 0;
border: none;
background: transparent;
}
body.enterprise-netdisk .files-head-view-toggle .btn {
min-width: 0;
}
}
@media (max-width: 991px) and (min-width: 769px) {
body.enterprise-netdisk .files-content-head-actions {
width: 100%;
margin-left: 0;
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 8px;
}
body.enterprise-netdisk .files-head-action-btn,
body.enterprise-netdisk .files-head-folder-btn,
body.enterprise-netdisk .files-head-view-toggle {
width: 100%;
min-width: 0;
}
body.enterprise-netdisk .files-head-action-btn,
body.enterprise-netdisk .files-head-view-toggle .btn {
height: 34px;
padding: 0 8px;
}
body.enterprise-netdisk .files-head-view-toggle {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 6px;
padding: 0;
border: none;
background: transparent;
}
}
</style>
<style>
/* ===== Mobile Overflow Guard v1 ===== */
@media (max-width: 768px) {
body.enterprise-netdisk .card,
body.enterprise-netdisk .files-view-card,
body.enterprise-netdisk .files-content-shell,
body.enterprise-netdisk .files-content-head,
body.enterprise-netdisk .files-content-head-meta,
body.enterprise-netdisk .files-content-head-actions,
body.enterprise-netdisk .settings-section,
body.enterprise-netdisk .settings-panel,
body.enterprise-netdisk .settings-subpanel,
body.enterprise-netdisk .settings-inline-tip,
body.enterprise-netdisk .settings-storage-switch,
body.enterprise-netdisk .settings-storage-grid,
body.enterprise-netdisk .settings-storage-option {
width: 100%;
max-width: 100%;
min-width: 0;
box-sizing: border-box;
}
body.enterprise-netdisk .settings-storage-grid {
grid-template-columns: 1fr !important;
gap: 10px !important;
}
body.enterprise-netdisk .settings-storage-head {
align-items: flex-start !important;
}
body.enterprise-netdisk .settings-storage-head > div {
width: 100%;
min-width: 0;
}
body.enterprise-netdisk .settings-storage-option [style*="display: flex"][style*="justify-content: space-between"] {
flex-wrap: wrap;
gap: 6px;
align-items: flex-start !important;
}
body.enterprise-netdisk .settings-storage-option button,
body.enterprise-netdisk .settings-oss-panel button,
body.enterprise-netdisk .settings-local-panel button {
max-width: 100%;
}
body.enterprise-netdisk .settings-page-subtitle,
body.enterprise-netdisk .settings-inline-tip,
body.enterprise-netdisk .files-content-title,
body.enterprise-netdisk .files-head-usage-progress-text {
overflow-wrap: anywhere;
word-break: break-word;
}
body.enterprise-netdisk .file-list {
overflow-x: auto;
-webkit-overflow-scrolling: touch;
}
body.enterprise-netdisk .file-list-table {
width: 100%;
min-width: 0;
}
body.enterprise-netdisk .modal-content {
width: calc(100vw - 16px);
max-width: calc(100vw - 16px);
}
}
@media (max-width: 480px) {
body.enterprise-netdisk .settings-panel,
body.enterprise-netdisk .settings-subpanel {
padding: 10px !important;
}
body.enterprise-netdisk .settings-inline-tip {
font-size: 12px !important;
}
}
</style>
<script src="app.js?v=20260217003"></script>
</body> </body>
</html> </html>

View File

@@ -271,7 +271,11 @@ createApp({
quota_unit: 'GB', // 本地配额单位MB 或 GB quota_unit: 'GB', // 本地配额单位MB 或 GB
oss_storage_quota_value: 1, // OSS配额数值默认1GB oss_storage_quota_value: 1, // OSS配额数值默认1GB
oss_quota_unit: 'GB', // OSS配额单位MB / GB / TB oss_quota_unit: 'GB', // OSS配额单位MB / GB / TB
oss_quota_unlimited: false // 兼容旧数据字段(当前固定为有限配额) oss_quota_unlimited: false, // 兼容旧数据字段(当前固定为有限配额)
download_traffic_quota_value: 1, // 下载流量配额数值
download_quota_unit: 'GB', // 下载流量单位MB / GB / TB
download_quota_unlimited: true, // 下载流量true=不限
download_traffic_used: 0 // 下载流量已使用(字节)
}, },
// 服务器存储统计 // 服务器存储统计
@@ -1454,36 +1458,13 @@ handleDragLeave(e) {
// 构建文件路径 // 构建文件路径
const filePath = this.currentPath === '/' ? `/${file.name}` : `${this.currentPath}/${file.name}`; const filePath = this.currentPath === '/' ? `/${file.name}` : `${this.currentPath}/${file.name}`;
// OSS 模式:使用签名 URL 直连下载(不经过后端) // 统一走后端下载接口,确保下载流量可精确计量
if (this.storageType === 'oss' && this.user?.oss_config_source !== 'none') {
this.downloadFromOSS(filePath);
} else {
// 本地存储模式:通过后端下载
this.downloadFromLocal(filePath); this.downloadFromLocal(filePath);
}
}, },
// OSS 直连下载使用签名URL不经过后端节省后端带宽 // 保留方法名兼容旧调用,内部统一转发到后端下载
async downloadFromOSS(filePath) { downloadFromOSS(filePath) {
try { this.downloadFromLocal(filePath);
// 获取签名 URL
const { data } = await axios.get(`${this.apiBase}/api/files/download-url`, {
params: { path: filePath }
});
if (data.success) {
// 直连 OSS 下载不经过后端充分利用OSS带宽和CDN
window.open(data.downloadUrl, '_blank');
} else {
// 处理后端返回的错误
console.error('获取下载链接失败:', data.message);
this.showToast('error', '下载失败', data.message || '获取下载链接失败');
}
} catch (error) {
console.error('获取下载链接失败:', error);
const errorMsg = error.response?.data?.message || error.message || '获取下载链接失败';
this.showToast('error', '下载失败', errorMsg);
}
}, },
// 本地存储下载 // 本地存储下载
@@ -1785,16 +1766,19 @@ handleDragLeave(e) {
? `/${file.name}` ? `/${file.name}`
: `${this.currentPath}/${file.name}`; : `${this.currentPath}/${file.name}`;
// OSS 模式:返回签名 URL用于媒体预览 const hasDownloadTrafficLimit = Number(this.user?.download_traffic_quota || 0) > 0;
if (this.storageType === 'oss' && this.user?.oss_config_source !== 'none') {
// OSS 模式且未启用下载流量限制时,返回签名 URL用于媒体预览
if (this.storageType === 'oss' && this.user?.oss_config_source !== 'none' && !hasDownloadTrafficLimit) {
try { try {
const { data } = await axios.get(`${this.apiBase}/api/files/download-url`, { const { data } = await axios.get(`${this.apiBase}/api/files/download-url`, {
params: { path: filePath } params: { path: filePath }
}); });
return data.success ? data.downloadUrl : null; if (data.success) {
return data.downloadUrl;
}
} catch (error) { } catch (error) {
console.error('获取媒体URL失败:', error); console.error('获取媒体URL失败:', error);
return null;
} }
} }
@@ -2785,6 +2769,13 @@ handleDragLeave(e) {
return; return;
} }
const confirmMessage = type === 'local'
? '切换到本地存储不会自动迁移 OSS 文件。切换后只会显示本地文件,确认继续?'
: '切换到 OSS 存储不会自动迁移本地文件。切换后只会显示 OSS 文件,确认继续?';
if (!window.confirm(confirmMessage)) {
return;
}
// 不再弹出配置引导弹窗,直接尝试切换 // 不再弹出配置引导弹窗,直接尝试切换
// 如果后端检测到没有OSS配置会返回错误提示 // 如果后端检测到没有OSS配置会返回错误提示
@@ -2942,6 +2933,32 @@ handleDragLeave(e) {
this.editStorageForm.oss_quota_unit = 'MB'; this.editStorageForm.oss_quota_unit = 'MB';
} }
// 下载流量配额0 表示不限)
const downloadQuotaBytes = Number(user.download_traffic_quota || 0);
const effectiveDownloadQuotaBytes = Number.isFinite(downloadQuotaBytes) && downloadQuotaBytes > 0
? downloadQuotaBytes
: 0;
const downloadUsedBytes = Number(user.download_traffic_used || 0);
this.editStorageForm.download_traffic_used = Number.isFinite(downloadUsedBytes) && downloadUsedBytes > 0
? Math.floor(downloadUsedBytes)
: 0;
this.editStorageForm.download_quota_unlimited = effectiveDownloadQuotaBytes <= 0;
if (effectiveDownloadQuotaBytes <= 0) {
this.editStorageForm.download_traffic_quota_value = 1;
this.editStorageForm.download_quota_unit = 'GB';
} else if (effectiveDownloadQuotaBytes >= tb && effectiveDownloadQuotaBytes % tb === 0) {
this.editStorageForm.download_traffic_quota_value = effectiveDownloadQuotaBytes / tb;
this.editStorageForm.download_quota_unit = 'TB';
} else if (effectiveDownloadQuotaBytes >= gb && effectiveDownloadQuotaBytes % gb === 0) {
this.editStorageForm.download_traffic_quota_value = effectiveDownloadQuotaBytes / gb;
this.editStorageForm.download_quota_unit = 'GB';
} else {
this.editStorageForm.download_traffic_quota_value = Math.max(1, Math.round(effectiveDownloadQuotaBytes / mb));
this.editStorageForm.download_quota_unit = 'MB';
}
this.showEditStorageModal = true; this.showEditStorageModal = true;
}, },
@@ -2976,12 +2993,30 @@ handleDragLeave(e) {
ossQuotaBytes = this.editStorageForm.oss_storage_quota_value * 1024 * 1024; ossQuotaBytes = this.editStorageForm.oss_storage_quota_value * 1024 * 1024;
} }
// 计算下载流量配额字节0表示不限
let downloadQuotaBytes = 0;
if (!this.editStorageForm.download_quota_unlimited) {
if (!this.editStorageForm.download_traffic_quota_value || this.editStorageForm.download_traffic_quota_value < 1) {
this.showToast('error', '参数错误', '下载流量配额必须大于 0或选择不限流量');
return;
}
if (this.editStorageForm.download_quota_unit === 'TB') {
downloadQuotaBytes = this.editStorageForm.download_traffic_quota_value * 1024 * 1024 * 1024 * 1024;
} else if (this.editStorageForm.download_quota_unit === 'GB') {
downloadQuotaBytes = this.editStorageForm.download_traffic_quota_value * 1024 * 1024 * 1024;
} else {
downloadQuotaBytes = this.editStorageForm.download_traffic_quota_value * 1024 * 1024;
}
}
const response = await axios.post( const response = await axios.post(
`${this.apiBase}/api/admin/users/${this.editStorageForm.userId}/storage-permission`, `${this.apiBase}/api/admin/users/${this.editStorageForm.userId}/storage-permission`,
{ {
storage_permission: this.editStorageForm.storage_permission, storage_permission: this.editStorageForm.storage_permission,
local_storage_quota: localQuotaBytes, local_storage_quota: localQuotaBytes,
oss_storage_quota: ossQuotaBytes oss_storage_quota: ossQuotaBytes,
download_traffic_quota: downloadQuotaBytes
}, },
); );
@@ -3022,6 +3057,14 @@ handleDragLeave(e) {
return Math.min(100, Math.round((used / quota) * 100)); return Math.min(100, Math.round((used / quota) * 100));
}, },
getAdminUserDownloadQuotaPercentage(user) {
const quota = Number(user?.download_traffic_quota || 0);
const used = Number(user?.download_traffic_used || 0);
if (!Number.isFinite(quota) || quota <= 0) return 0;
if (!Number.isFinite(used) || used <= 0) return 0;
return Math.min(100, Math.round((used / quota) * 100));
},
formatDate(dateString) { formatDate(dateString) {
if (!dateString) return '-'; if (!dateString) return '-';