From 3a22b88f2396cb8cf1cb8d87ac084a9956146a08 Mon Sep 17 00:00:00 2001 From: yuyx <237899745@qq.com> Date: Tue, 17 Feb 2026 17:36:26 +0800 Subject: [PATCH] feat: add user download traffic reports and restore OSS direct downloads --- backend/database.js | 117 ++++++++++++++++++++++++++ backend/server.js | 140 +++++++++++++++++++++++++++++++- frontend/app.html | 157 +++++++++++++++++++++++++++++++++++ frontend/app.js | 194 ++++++++++++++++++++++++++++++++++++++++++-- 4 files changed, 600 insertions(+), 8 deletions(-) diff --git a/backend/database.js b/backend/database.js index 0fb880d..770be8b 100644 --- a/backend/database.js +++ b/backend/database.js @@ -365,6 +365,21 @@ function initDatabase() { ) `); + // 下载流量日统计表(用于用户侧报表) + db.exec(` + CREATE TABLE IF NOT EXISTS user_download_traffic_daily ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL, + date_key TEXT NOT NULL, -- YYYY-MM-DD(本地时区) + bytes_used INTEGER NOT NULL DEFAULT 0, + download_count INTEGER NOT NULL DEFAULT 0, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, + UNIQUE(user_id, date_key), + FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE + ) + `); + // 日志表索引 db.exec(` CREATE INDEX IF NOT EXISTS idx_logs_created_at ON system_logs(created_at); @@ -377,6 +392,9 @@ function initDatabase() { -- 优势:快速查询用户最近的活动记录,支持时间范围过滤 -- 使用场景:用户活动历史、审计日志查询 CREATE INDEX IF NOT EXISTS idx_logs_user_created ON system_logs(user_id, created_at); + + -- 下载流量报表索引(按用户+日期查询) + CREATE INDEX IF NOT EXISTS idx_download_traffic_user_date ON user_download_traffic_daily(user_id, date_key); `); console.log('[数据库性能优化] ✓ 日志表复合索引已创建'); @@ -1421,6 +1439,104 @@ function migrateDownloadTrafficFields() { } } +// 下载流量报表(按天聚合) +const DownloadTrafficReportDB = { + normalizeDays(days, defaultDays = 30, maxDays = 365) { + const parsed = Number(days); + if (!Number.isFinite(parsed) || parsed <= 0) { + return defaultDays; + } + return Math.min(maxDays, Math.max(1, Math.floor(parsed))); + }, + + formatDateKey(date = new Date()) { + const target = date instanceof Date ? date : new Date(date); + if (Number.isNaN(target.getTime())) { + return null; + } + const year = target.getFullYear(); + const month = String(target.getMonth() + 1).padStart(2, '0'); + const day = String(target.getDate()).padStart(2, '0'); + return `${year}-${month}-${day}`; + }, + + addUsage(userId, bytesUsed, downloadCount = 1, date = new Date()) { + const uid = Number(userId); + const bytes = Math.floor(Number(bytesUsed)); + const count = Math.floor(Number(downloadCount)); + const dateKey = this.formatDateKey(date); + + if (!Number.isFinite(uid) || uid <= 0 || !Number.isFinite(bytes) || bytes <= 0 || !dateKey) { + return null; + } + + const normalizedCount = Number.isFinite(count) && count > 0 ? count : 1; + + return db.prepare(` + INSERT INTO user_download_traffic_daily (user_id, date_key, bytes_used, download_count, created_at, updated_at) + VALUES (?, ?, ?, ?, datetime('now', 'localtime'), datetime('now', 'localtime')) + ON CONFLICT(user_id, date_key) + DO UPDATE SET + bytes_used = user_download_traffic_daily.bytes_used + excluded.bytes_used, + download_count = user_download_traffic_daily.download_count + excluded.download_count, + updated_at = datetime('now', 'localtime') + `).run(uid, dateKey, bytes, normalizedCount); + }, + + getDailyUsage(userId, days = 30) { + const uid = Number(userId); + if (!Number.isFinite(uid) || uid <= 0) { + return []; + } + + const safeDays = this.normalizeDays(days); + const offset = `-${safeDays - 1} days`; + + return db.prepare(` + SELECT + date_key, + bytes_used, + download_count + FROM user_download_traffic_daily + WHERE user_id = ? + AND date_key >= date('now', 'localtime', ?) + ORDER BY date_key ASC + `).all(uid, offset); + }, + + getPeriodSummary(userId, days = null) { + const uid = Number(userId); + if (!Number.isFinite(uid) || uid <= 0) { + return { bytes_used: 0, download_count: 0 }; + } + + if (days === null || days === undefined) { + const row = db.prepare(` + SELECT + COALESCE(SUM(bytes_used), 0) AS bytes_used, + COALESCE(SUM(download_count), 0) AS download_count + FROM user_download_traffic_daily + WHERE user_id = ? + `).get(uid); + return row || { bytes_used: 0, download_count: 0 }; + } + + const safeDays = this.normalizeDays(days); + const offset = `-${safeDays - 1} days`; + + const row = db.prepare(` + SELECT + COALESCE(SUM(bytes_used), 0) AS bytes_used, + COALESCE(SUM(download_count), 0) AS download_count + FROM user_download_traffic_daily + WHERE user_id = ? + AND date_key >= date('now', 'localtime', ?) + `).get(uid, offset); + + return row || { bytes_used: 0, download_count: 0 }; + } +}; + // 系统日志操作 const SystemLogDB = { // 日志级别常量 @@ -1616,6 +1732,7 @@ module.exports = { SettingsDB, VerificationDB, PasswordResetTokenDB, + DownloadTrafficReportDB, SystemLogDB, TransactionDB, WalManager diff --git a/backend/server.js b/backend/server.js index 437ee67..9a0829b 100644 --- a/backend/server.js +++ b/backend/server.js @@ -62,7 +62,7 @@ function clearOssUsageCache(userId) { console.log(`[OSS缓存] 已清除: 用户 ${userId}`); } -const { db, UserDB, ShareDB, SettingsDB, VerificationDB, PasswordResetTokenDB, SystemLogDB, TransactionDB, WalManager } = require('./database'); +const { db, UserDB, ShareDB, SettingsDB, VerificationDB, PasswordResetTokenDB, DownloadTrafficReportDB, SystemLogDB, TransactionDB, WalManager } = require('./database'); const StorageUsageCache = require('./utils/storage-cache'); const { JWT_SECRET, generateToken, generateRefreshToken, refreshAccessToken, authMiddleware, adminMiddleware, isJwtSecretSecure } = require('./auth'); const { StorageInterface, LocalStorageClient, OssStorageClient, formatFileSize, formatOssError } = require('./storage'); @@ -691,6 +691,31 @@ function formatDateTimeForSqlite(date = new Date()) { return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`; } +function getDateKeyFromDate(date = new Date()) { + const target = date instanceof Date ? date : new Date(date); + if (Number.isNaN(target.getTime())) { + return null; + } + const year = target.getFullYear(); + const month = String(target.getMonth() + 1).padStart(2, '0'); + const day = String(target.getDate()).padStart(2, '0'); + return `${year}-${month}-${day}`; +} + +function getRecentDateKeys(days = 30, now = new Date()) { + const safeDays = Math.max(1, Math.floor(Number(days) || 30)); + const keys = []; + for (let i = safeDays - 1; i >= 0; i -= 1) { + const date = new Date(now.getTime()); + date.setDate(date.getDate() - i); + const key = getDateKeyFromDate(date); + if (key) { + keys.push(key); + } + } + return keys; +} + function getNextDownloadResetTime(lastResetAt, resetCycle) { const baseDate = parseDateTimeValue(lastResetAt); if (!baseDate) { @@ -847,11 +872,16 @@ const applyDownloadTrafficUsageTransaction = db.transaction((userId, bytesToAdd) UserDB.update(userId, { download_traffic_used: nextUsed }); + const addedBytes = Math.max(0, nextUsed - trafficState.used); + if (addedBytes > 0) { + DownloadTrafficReportDB.addUsage(userId, addedBytes, 1, new Date()); + } + return { quota: trafficState.quota, usedBefore: trafficState.used, usedAfter: nextUsed, - added: Math.max(0, nextUsed - trafficState.used) + added: addedBytes }; }); @@ -2526,6 +2556,112 @@ app.get('/api/user/profile', authMiddleware, (req, res) => { }); }); +// 获取用户下载流量额度与报表 +app.get('/api/user/download-traffic-report', authMiddleware, (req, res) => { + try { + const days = DownloadTrafficReportDB.normalizeDays(req.query.days, 30, 365); + + const policyState = enforceDownloadTrafficPolicy(req.user.id, 'traffic_report'); + const latestUser = policyState?.user || UserDB.findById(req.user.id); + if (!latestUser) { + return res.status(404).json({ + success: false, + message: '用户不存在' + }); + } + + const trafficState = getDownloadTrafficState(latestUser); + const dailyRows = DownloadTrafficReportDB.getDailyUsage(latestUser.id, days); + const dailyRowMap = new Map( + dailyRows.map(row => [row.date_key, row]) + ); + const dateKeys = getRecentDateKeys(days, new Date()); + const dailySeries = dateKeys.map(dateKey => { + const row = dailyRowMap.get(dateKey); + const bytesUsed = Number(row?.bytes_used || 0); + const downloadCount = Number(row?.download_count || 0); + return { + date: dateKey, + bytes_used: Number.isFinite(bytesUsed) && bytesUsed > 0 ? Math.floor(bytesUsed) : 0, + download_count: Number.isFinite(downloadCount) && downloadCount > 0 ? Math.floor(downloadCount) : 0 + }; + }); + + const selectedSummary = DownloadTrafficReportDB.getPeriodSummary(latestUser.id, days); + const todaySummary = DownloadTrafficReportDB.getPeriodSummary(latestUser.id, 1); + const sevenDaySummary = DownloadTrafficReportDB.getPeriodSummary(latestUser.id, 7); + const thirtyDaySummary = DownloadTrafficReportDB.getPeriodSummary(latestUser.id, 30); + const allSummary = DownloadTrafficReportDB.getPeriodSummary(latestUser.id, null); + + const peakDay = dailySeries.reduce((peak, current) => { + if (!peak || current.bytes_used > peak.bytes_used) { + return current; + } + return peak; + }, null); + + const safeQuota = trafficState.quota > 0 ? trafficState.quota : 0; + const remainingBytes = safeQuota > 0 ? Math.max(0, safeQuota - trafficState.used) : null; + const usagePercentage = safeQuota > 0 + ? Math.min(100, Math.round((trafficState.used / safeQuota) * 100)) + : null; + + res.json({ + success: true, + quota: { + quota: safeQuota, + used: trafficState.used, + remaining: remainingBytes, + usage_percentage: usagePercentage, + is_unlimited: trafficState.isUnlimited, + reset_cycle: latestUser.download_traffic_reset_cycle || 'none', + expires_at: latestUser.download_traffic_quota_expires_at || null, + last_reset_at: latestUser.download_traffic_last_reset_at || null + }, + report: { + days, + daily: dailySeries, + summary: { + today: { + bytes_used: Number(todaySummary?.bytes_used || 0), + download_count: Number(todaySummary?.download_count || 0) + }, + last_7_days: { + bytes_used: Number(sevenDaySummary?.bytes_used || 0), + download_count: Number(sevenDaySummary?.download_count || 0) + }, + last_30_days: { + bytes_used: Number(thirtyDaySummary?.bytes_used || 0), + download_count: Number(thirtyDaySummary?.download_count || 0) + }, + selected_range: { + bytes_used: Number(selectedSummary?.bytes_used || 0), + download_count: Number(selectedSummary?.download_count || 0), + average_daily_bytes: Math.round(Number(selectedSummary?.bytes_used || 0) / Math.max(1, days)) + }, + all_time: { + bytes_used: Number(allSummary?.bytes_used || 0), + download_count: Number(allSummary?.download_count || 0) + }, + peak_day: peakDay + ? { + date: peakDay.date, + bytes_used: peakDay.bytes_used, + download_count: peakDay.download_count + } + : null + } + } + }); + } catch (error) { + console.error('获取下载流量报表失败:', error); + res.status(500).json({ + success: false, + message: '获取下载流量报表失败' + }); + } +}); + // 获取用户主题偏好(包含全局默认主题) app.get('/api/user/theme', authMiddleware, (req, res) => { try { diff --git a/frontend/app.html b/frontend/app.html index d962224..766afb0 100644 --- a/frontend/app.html +++ b/frontend/app.html @@ -2330,6 +2330,163 @@ + + +
+

+ 下载流量额度与统计 +

+
+
+
+ 管理员设置的下载流量限制会在这里实时展示,单位自动按 B/KB/MB/GB/TB 切换 +
+
+ + + + + +
+
+ +
+ {{ downloadTrafficReport.error }} +
+ +
+
+
当前限制
+
+ {{ downloadTrafficIsUnlimited ? '不限' : formatBytes(downloadTrafficQuotaBytes) }} +
+
+
+
已使用
+
+ {{ formatBytes(downloadTrafficUsedBytes) }} +
+
+
+
剩余可用
+
+ {{ downloadTrafficIsUnlimited ? '不限' : formatBytes(downloadTrafficRemainingBytes || 0) }} +
+
+
+
策略
+
+ {{ getDownloadResetCycleText(downloadTrafficResetCycle) }} +
+
+ 到期: {{ downloadTrafficExpiresAt ? formatDate(downloadTrafficExpiresAt) : '无' }} +
+
+
+ +
+
+ 额度使用率 + {{ downloadTrafficUsagePercentage }}% +
+
+
+
+
+ +
+
+
今天使用
+
+ {{ formatBytes(downloadTrafficReport.summary?.today?.bytes_used || 0) }} +
+
+
+
近7天
+
+ {{ formatBytes(downloadTrafficReport.summary?.last_7_days?.bytes_used || 0) }} +
+
+
+
近30天
+
+ {{ formatBytes(downloadTrafficReport.summary?.last_30_days?.bytes_used || 0) }} +
+
+
+
所选区间
+
+ {{ formatBytes(downloadTrafficReport.summary?.selected_range?.bytes_used || 0) }} +
+
+
+
历史累计
+
+ {{ formatBytes(downloadTrafficReport.summary?.all_time?.bytes_used || 0) }} +
+
+
+ +
+
+ 按天用量明细(最近 {{ downloadTrafficReport.days }} 天) + 峰值: {{ formatReportDateLabel(downloadTrafficReport.summary.peak_day.date) }} / {{ formatBytes(downloadTrafficReport.summary.peak_day.bytes_used || 0) }} +
+ +
+ 报表加载中... +
+
+ 暂无下载流量记录 +
+
+ + + + + + + + + + + + + + + +
日期下载流量下载次数
{{ formatReportDateLabel(row.date) }}{{ formatBytes(row.bytes_used || 0) }}{{ row.download_count || 0 }}
+
+
+
+

界面设置

diff --git a/frontend/app.js b/frontend/app.js index 1d22fc3..83b9eaa 100644 --- a/frontend/app.js +++ b/frontend/app.js @@ -311,6 +311,17 @@ createApp({ ossUsageLoading: false, ossUsageError: null, + // 下载流量报表 + downloadTrafficReport: { + days: 30, + loading: false, + error: null, + quota: null, + daily: [], + summary: null, + lastUpdatedAt: null + }, + // 主题设置 currentTheme: 'dark', // 当前生效的主题: 'dark' 或 'light' globalTheme: 'dark', // 全局默认主题(管理员设置) @@ -374,6 +385,80 @@ createApp({ return Math.min(100, Math.round((this.ossUsedBytes / this.ossQuotaBytes) * 100)); }, + downloadTrafficQuotaBytes() { + const reportQuota = Number(this.downloadTrafficReport?.quota?.quota); + if (Number.isFinite(reportQuota) && reportQuota >= 0) { + return reportQuota; + } + const userQuota = Number(this.user?.download_traffic_quota || 0); + return Number.isFinite(userQuota) && userQuota > 0 ? Math.floor(userQuota) : 0; + }, + + downloadTrafficUsedBytes() { + const reportUsed = Number(this.downloadTrafficReport?.quota?.used); + if (Number.isFinite(reportUsed) && reportUsed >= 0) { + return Math.floor(reportUsed); + } + const userUsed = Number(this.user?.download_traffic_used || 0); + return Number.isFinite(userUsed) && userUsed > 0 ? Math.floor(userUsed) : 0; + }, + + downloadTrafficIsUnlimited() { + if (this.downloadTrafficReport?.quota?.is_unlimited === true) { + return true; + } + return this.downloadTrafficQuotaBytes <= 0; + }, + + downloadTrafficRemainingBytes() { + if (this.downloadTrafficIsUnlimited) { + return null; + } + + const reportRemaining = Number(this.downloadTrafficReport?.quota?.remaining); + if (Number.isFinite(reportRemaining) && reportRemaining >= 0) { + return Math.floor(reportRemaining); + } + + return Math.max(0, this.downloadTrafficQuotaBytes - this.downloadTrafficUsedBytes); + }, + + downloadTrafficUsagePercentage() { + if (this.downloadTrafficIsUnlimited) { + return 0; + } + + const reportPercentage = Number(this.downloadTrafficReport?.quota?.usage_percentage); + if (Number.isFinite(reportPercentage) && reportPercentage >= 0) { + return Math.min(100, Math.round(reportPercentage)); + } + + if (this.downloadTrafficQuotaBytes <= 0) { + return 0; + } + + return Math.min(100, Math.round((this.downloadTrafficUsedBytes / this.downloadTrafficQuotaBytes) * 100)); + }, + + downloadTrafficResetCycle() { + return this.downloadTrafficReport?.quota?.reset_cycle + || this.user?.download_traffic_reset_cycle + || 'none'; + }, + + downloadTrafficExpiresAt() { + return this.downloadTrafficReport?.quota?.expires_at + || this.user?.download_traffic_quota_expires_at + || null; + }, + + downloadTrafficDailyRowsDesc() { + const rows = Array.isArray(this.downloadTrafficReport?.daily) + ? this.downloadTrafficReport.daily + : []; + return [...rows].reverse(); + }, + // 存储类型显示文本 storageTypeText() { return this.storageType === 'local' ? '本地存储' : 'OSS存储'; @@ -1460,17 +1545,50 @@ handleDragLeave(e) { this.loadFiles(newPath); }, - downloadFile(file) { + async downloadFile(file) { // 构建文件路径 const filePath = this.currentPath === '/' ? `/${file.name}` : `${this.currentPath}/${file.name}`; - // 统一走后端下载接口,确保下载流量可精确计量 + const hasDownloadTrafficLimit = Number(this.user?.download_traffic_quota || 0) > 0; + const canDirectOssDownload = this.storageType === 'oss' + && this.user?.oss_config_source !== 'none' + && !hasDownloadTrafficLimit; + + // OSS 且未启用下载限流:优先使用 OSS 直连下载(速度更快) + if (canDirectOssDownload) { + const directResult = await this.downloadFromOSS(filePath); + if (directResult) { + return; + } + } + + // 其他场景走后端下载接口(支持下载流量计量/权限控制) this.downloadFromLocal(filePath); }, - // 保留方法名兼容旧调用,内部统一转发到后端下载 - downloadFromOSS(filePath) { - this.downloadFromLocal(filePath); + async downloadFromOSS(filePath) { + try { + const response = await axios.get(`${this.apiBase}/api/files/download-url`, { + params: { path: filePath } + }); + + if (!response.data?.success || !response.data?.downloadUrl) { + return false; + } + + const link = document.createElement('a'); + link.href = response.data.downloadUrl; + link.setAttribute('download', ''); + link.rel = 'noopener noreferrer'; + link.target = '_blank'; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + return true; + } catch (error) { + console.error('OSS直连下载失败,将回退到后端下载:', error); + return false; + } }, // 本地存储下载 @@ -2731,6 +2849,67 @@ handleDragLeave(e) { } }, + async loadDownloadTrafficReport(days = this.downloadTrafficReport.days) { + if (!this.isLoggedIn || !this.user || this.user.is_admin) { + return; + } + + const allowedDays = [7, 30, 90, 180]; + const normalizedDays = allowedDays.includes(Number(days)) ? Number(days) : 30; + + this.downloadTrafficReport.days = normalizedDays; + this.downloadTrafficReport.loading = true; + this.downloadTrafficReport.error = null; + + try { + const response = await axios.get( + `${this.apiBase}/api/user/download-traffic-report?days=${normalizedDays}`, + ); + + if (response.data.success) { + const quota = response.data.quota || null; + const report = response.data.report || {}; + this.downloadTrafficReport.quota = quota; + this.downloadTrafficReport.daily = Array.isArray(report.daily) ? report.daily : []; + this.downloadTrafficReport.summary = report.summary || null; + this.downloadTrafficReport.lastUpdatedAt = new Date().toISOString(); + + // 同步到 user 对象,保证文件页/设置页显示一致 + if (this.user && quota) { + this.user.download_traffic_quota = Number(quota.quota || 0); + this.user.download_traffic_used = Number(quota.used || 0); + this.user.download_traffic_reset_cycle = quota.reset_cycle || 'none'; + this.user.download_traffic_quota_expires_at = quota.expires_at || null; + this.user.download_traffic_last_reset_at = quota.last_reset_at || null; + } + } else { + this.downloadTrafficReport.error = response.data.message || '获取报表失败'; + } + } catch (error) { + console.error('获取下载流量报表失败:', error); + this.downloadTrafficReport.error = error.response?.data?.message || '获取报表失败'; + } finally { + this.downloadTrafficReport.loading = false; + } + }, + + setDownloadTrafficReportDays(days) { + const nextDays = Number(days); + if (this.downloadTrafficReport.loading || nextDays === this.downloadTrafficReport.days) { + return; + } + this.loadDownloadTrafficReport(nextDays); + }, + + formatReportDateLabel(dateKey) { + if (!dateKey) return '-'; + const match = String(dateKey).match(/^(\d{4})-(\d{2})-(\d{2})$/); + if (match) { + return `${match[2]}-${match[3]}`; + } + return dateKey; + }, + // 刷新存储空间使用统计(根据当前存储类型) async refreshStorageUsage() { if (this.storageType === 'oss' && this.user?.oss_config_source !== 'none') { @@ -2894,7 +3073,9 @@ handleDragLeave(e) { } break; case 'settings': - // 设置页面不需要额外加载数据 + if (this.user && !this.user.is_admin) { + this.loadDownloadTrafficReport(); + } break; } }, @@ -3612,6 +3793,7 @@ handleDragLeave(e) { } else if (newView === 'settings' && this.user && !this.user.is_admin) { // 普通用户进入设置页面时加载OSS配置 this.loadOssConfig(); + this.loadDownloadTrafficReport(); } // 记住最后停留的视图(需合法且已登录)