feat: add user download traffic reports and restore OSS direct downloads
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user