feat(quota): add downloadable traffic quota with local/OSS/share metering
This commit is contained in:
@@ -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
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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') {
|
||||
|
||||
@@ -3118,18 +3118,19 @@
|
||||
<div class="card">
|
||||
<h3 style="margin-bottom: 20px;">用户管理</h3>
|
||||
<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>
|
||||
<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: 10%;">用户名</th>
|
||||
<th style="padding: 10px; text-align: center; width: 10%;">角色</th>
|
||||
<th style="padding: 10px; text-align: left; width: 9%;">用户名</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: center; width: 9%;">存储权限</th>
|
||||
<th style="padding: 10px; text-align: center; width: 9%;">当前存储</th>
|
||||
<th style="padding: 10px; text-align: center; width: 12%;">配额使用</th>
|
||||
<th style="padding: 10px; text-align: center; width: 8%;">状态</th>
|
||||
<th style="padding: 10px; text-align: center; width: 24%;">操作</th>
|
||||
<th style="padding: 10px; text-align: center; width: 8%;">存储权限</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: 11%;">下载流量</th>
|
||||
<th style="padding: 10px; text-align: center; width: 7%;">状态</th>
|
||||
<th style="padding: 10px; text-align: center; width: 19%;">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -3176,6 +3177,20 @@
|
||||
</div>
|
||||
</div>
|
||||
</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;">
|
||||
<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>
|
||||
@@ -3517,13 +3532,43 @@
|
||||
</small>
|
||||
</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="font-size: 13px; color: var(--text-secondary); line-height: 1.6;">
|
||||
<strong style="color: var(--text-primary);">配额说明:</strong><br>
|
||||
• 本地默认配额: 1GB<br>
|
||||
• 当前本地配额: {{ 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>
|
||||
• 当前 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>
|
||||
|
||||
@@ -4884,6 +4929,11 @@
|
||||
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 {
|
||||
margin: 0 auto;
|
||||
}
|
||||
@@ -5885,7 +5935,15 @@
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
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 {
|
||||
@@ -6242,6 +6300,177 @@
|
||||
}
|
||||
</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>
|
||||
</html>
|
||||
|
||||
111
frontend/app.js
111
frontend/app.js
@@ -271,7 +271,11 @@ createApp({
|
||||
quota_unit: 'GB', // 本地配额单位:MB 或 GB
|
||||
oss_storage_quota_value: 1, // OSS配额数值(默认1GB)
|
||||
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}`;
|
||||
|
||||
// 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) {
|
||||
try {
|
||||
// 获取签名 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);
|
||||
}
|
||||
// 保留方法名兼容旧调用,内部统一转发到后端下载
|
||||
downloadFromOSS(filePath) {
|
||||
this.downloadFromLocal(filePath);
|
||||
},
|
||||
|
||||
// 本地存储下载
|
||||
@@ -1785,16 +1766,19 @@ handleDragLeave(e) {
|
||||
? `/${file.name}`
|
||||
: `${this.currentPath}/${file.name}`;
|
||||
|
||||
// OSS 模式:返回签名 URL(用于媒体预览)
|
||||
if (this.storageType === 'oss' && this.user?.oss_config_source !== 'none') {
|
||||
const hasDownloadTrafficLimit = Number(this.user?.download_traffic_quota || 0) > 0;
|
||||
|
||||
// OSS 模式且未启用下载流量限制时,返回签名 URL(用于媒体预览)
|
||||
if (this.storageType === 'oss' && this.user?.oss_config_source !== 'none' && !hasDownloadTrafficLimit) {
|
||||
try {
|
||||
const { data } = await axios.get(`${this.apiBase}/api/files/download-url`, {
|
||||
params: { path: filePath }
|
||||
});
|
||||
return data.success ? data.downloadUrl : null;
|
||||
if (data.success) {
|
||||
return data.downloadUrl;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取媒体URL失败:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2785,6 +2769,13 @@ handleDragLeave(e) {
|
||||
return;
|
||||
}
|
||||
|
||||
const confirmMessage = type === 'local'
|
||||
? '切换到本地存储不会自动迁移 OSS 文件。切换后只会显示本地文件,确认继续?'
|
||||
: '切换到 OSS 存储不会自动迁移本地文件。切换后只会显示 OSS 文件,确认继续?';
|
||||
if (!window.confirm(confirmMessage)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 不再弹出配置引导弹窗,直接尝试切换
|
||||
// 如果后端检测到没有OSS配置,会返回错误提示
|
||||
|
||||
@@ -2942,6 +2933,32 @@ handleDragLeave(e) {
|
||||
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;
|
||||
},
|
||||
|
||||
@@ -2976,12 +2993,30 @@ handleDragLeave(e) {
|
||||
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(
|
||||
`${this.apiBase}/api/admin/users/${this.editStorageForm.userId}/storage-permission`,
|
||||
{
|
||||
storage_permission: this.editStorageForm.storage_permission,
|
||||
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));
|
||||
},
|
||||
|
||||
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) {
|
||||
if (!dateString) return '-';
|
||||
|
||||
|
||||
Reference in New Issue
Block a user