feat: enhance download traffic quota lifecycle controls

This commit is contained in:
2026-02-17 17:19:25 +08:00
parent 2629237f9e
commit 7687397954
5 changed files with 635 additions and 53 deletions

View File

@@ -74,6 +74,8 @@ const USERNAME_REGEX = /^[A-Za-z0-9_.\u4e00-\u9fa5-]{3,20}$/u; // 允许中英
const ENFORCE_HTTPS = process.env.ENFORCE_HTTPS === 'true';
const DEFAULT_LOCAL_STORAGE_QUOTA_BYTES = 1024 * 1024 * 1024; // 1GB
const DEFAULT_OSS_STORAGE_QUOTA_BYTES = 1024 * 1024 * 1024; // 1GB
const MAX_DOWNLOAD_TRAFFIC_BYTES = 10 * 1024 * 1024 * 1024 * 1024; // 10TB
const DOWNLOAD_POLICY_SWEEP_INTERVAL_MS = 30 * 60 * 1000; // 30分钟
const SHARE_CODE_REGEX = /^[A-Za-z0-9]{6,32}$/;
const COOKIE_SECURE_MODE = String(process.env.COOKIE_SECURE || '').toLowerCase();
const SHOULD_USE_SECURE_COOKIES =
@@ -633,7 +635,7 @@ function normalizeDownloadTrafficQuota(rawQuota) {
if (!Number.isFinite(parsedQuota) || parsedQuota <= 0) {
return 0; // 0 表示不限流量
}
return Math.floor(parsedQuota);
return Math.min(MAX_DOWNLOAD_TRAFFIC_BYTES, Math.floor(parsedQuota));
}
function normalizeDownloadTrafficUsed(rawUsed, quota = 0) {
@@ -658,8 +660,173 @@ function getDownloadTrafficState(user) {
};
}
function parseDateTimeValue(value) {
if (!value || typeof value !== 'string') {
return null;
}
const directDate = new Date(value);
if (!Number.isNaN(directDate.getTime())) {
return directDate;
}
// 兼容 SQLite 常见 DATETIME 格式: YYYY-MM-DD HH:mm:ss
const normalized = value.replace(' ', 'T');
const normalizedDate = new Date(normalized);
if (!Number.isNaN(normalizedDate.getTime())) {
return normalizedDate;
}
return null;
}
function formatDateTimeForSqlite(date = new Date()) {
const target = date instanceof Date ? date : new Date(date);
const year = target.getFullYear();
const month = String(target.getMonth() + 1).padStart(2, '0');
const day = String(target.getDate()).padStart(2, '0');
const hours = String(target.getHours()).padStart(2, '0');
const minutes = String(target.getMinutes()).padStart(2, '0');
const seconds = String(target.getSeconds()).padStart(2, '0');
return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`;
}
function getNextDownloadResetTime(lastResetAt, resetCycle) {
const baseDate = parseDateTimeValue(lastResetAt);
if (!baseDate) {
return null;
}
const next = new Date(baseDate.getTime());
if (resetCycle === 'daily') {
next.setDate(next.getDate() + 1);
return next;
}
if (resetCycle === 'weekly') {
next.setDate(next.getDate() + 7);
return next;
}
if (resetCycle === 'monthly') {
next.setMonth(next.getMonth() + 1);
return next;
}
return null;
}
function resolveDownloadTrafficPolicyUpdates(user, now = new Date()) {
if (!user) {
return {
updates: {},
hasUpdates: false,
expired: false,
resetApplied: false
};
}
const updates = {};
let hasUpdates = false;
let expired = false;
let resetApplied = false;
const normalizedQuota = normalizeDownloadTrafficQuota(user.download_traffic_quota);
const normalizedUsed = normalizeDownloadTrafficUsed(user.download_traffic_used, normalizedQuota);
if (normalizedQuota !== Number(user.download_traffic_quota || 0)) {
updates.download_traffic_quota = normalizedQuota;
hasUpdates = true;
}
if (normalizedUsed !== Number(user.download_traffic_used || 0)) {
updates.download_traffic_used = normalizedUsed;
hasUpdates = true;
}
const resetCycle = ['none', 'daily', 'weekly', 'monthly'].includes(user.download_traffic_reset_cycle)
? user.download_traffic_reset_cycle
: 'none';
if (resetCycle !== (user.download_traffic_reset_cycle || 'none')) {
updates.download_traffic_reset_cycle = resetCycle;
hasUpdates = true;
}
const expiresAt = parseDateTimeValue(user.download_traffic_quota_expires_at);
if (normalizedQuota <= 0 && user.download_traffic_quota_expires_at) {
updates.download_traffic_quota_expires_at = null;
hasUpdates = true;
} else if (normalizedQuota > 0 && expiresAt && now >= expiresAt) {
// 到期后自动恢复为不限并重置已用量
updates.download_traffic_quota = 0;
updates.download_traffic_used = 0;
updates.download_traffic_quota_expires_at = null;
updates.download_traffic_reset_cycle = 'none';
updates.download_traffic_last_reset_at = null;
hasUpdates = true;
expired = true;
}
if (!expired && resetCycle !== 'none') {
const lastResetAt = user.download_traffic_last_reset_at;
if (!lastResetAt) {
updates.download_traffic_last_reset_at = formatDateTimeForSqlite(now);
hasUpdates = true;
} else {
const nextResetAt = getNextDownloadResetTime(lastResetAt, resetCycle);
if (nextResetAt && now >= nextResetAt) {
updates.download_traffic_used = 0;
updates.download_traffic_last_reset_at = formatDateTimeForSqlite(now);
hasUpdates = true;
resetApplied = true;
}
}
} else if (resetCycle === 'none' && user.download_traffic_last_reset_at) {
updates.download_traffic_last_reset_at = null;
hasUpdates = true;
}
return {
updates,
hasUpdates,
expired,
resetApplied
};
}
const enforceDownloadTrafficPolicyTransaction = db.transaction((userId, trigger = 'runtime') => {
let user = UserDB.findById(userId);
if (!user) {
return null;
}
const now = new Date();
const policyResult = resolveDownloadTrafficPolicyUpdates(user, now);
if (policyResult.hasUpdates) {
UserDB.update(userId, policyResult.updates);
user = UserDB.findById(userId);
if (policyResult.expired) {
console.log(`[下载流量策略] 用户 ${userId} 配额已到期,已自动恢复为不限 (trigger=${trigger})`);
} else if (policyResult.resetApplied) {
console.log(`[下载流量策略] 用户 ${userId} 流量已按周期重置 (trigger=${trigger})`);
}
}
return {
user,
expired: policyResult.expired,
resetApplied: policyResult.resetApplied
};
});
function enforceDownloadTrafficPolicy(userId, trigger = 'runtime') {
if (!userId) {
return null;
}
return enforceDownloadTrafficPolicyTransaction(userId, trigger);
}
const applyDownloadTrafficUsageTransaction = db.transaction((userId, bytesToAdd) => {
const user = UserDB.findById(userId);
const policyState = enforceDownloadTrafficPolicyTransaction(userId, 'usage');
const user = policyState?.user || UserDB.findById(userId);
if (!user) {
return null;
}
@@ -696,6 +863,42 @@ function applyDownloadTrafficUsage(userId, bytesToAdd) {
return applyDownloadTrafficUsageTransaction(userId, Math.floor(parsedBytes));
}
function runDownloadTrafficPolicySweep(trigger = 'scheduled') {
try {
const users = UserDB.getAll();
let affected = 0;
for (const user of users) {
if (
!user.download_traffic_quota_expires_at &&
(!user.download_traffic_reset_cycle || user.download_traffic_reset_cycle === 'none')
) {
continue;
}
const result = enforceDownloadTrafficPolicy(user.id, trigger);
if (result?.expired || result?.resetApplied) {
affected += 1;
}
}
if (affected > 0) {
console.log(`[下载流量策略] 扫描完成: ${affected} 个用户发生变化 (trigger=${trigger})`);
}
} catch (error) {
console.error(`[下载流量策略] 扫描失败 (trigger=${trigger}):`, error);
}
}
const downloadPolicySweepTimer = setInterval(() => {
runDownloadTrafficPolicySweep('interval');
}, DOWNLOAD_POLICY_SWEEP_INTERVAL_MS);
if (downloadPolicySweepTimer && typeof downloadPolicySweepTimer.unref === 'function') {
downloadPolicySweepTimer.unref();
}
setTimeout(() => {
runDownloadTrafficPolicySweep('startup');
}, 10 * 1000);
// 构建用于存储客户端的用户对象(自动尝试解密 OSS Secret
function buildStorageUserContext(user, overrides = {}) {
if (!user) {
@@ -2092,7 +2295,7 @@ app.post('/api/login',
delete req.session.captchaTime;
}
const user = UserDB.findByUsername(username);
let user = UserDB.findByUsername(username);
if (!user) {
// 记录失败尝试
@@ -2156,6 +2359,12 @@ app.post('/api/login',
user.local_storage_used = syncLocalStorageUsageFromDisk(user.id, user.local_storage_used);
}
// 下载流量策略应用:处理到期恢复/周期重置
const loginPolicyState = enforceDownloadTrafficPolicy(user.id, 'login');
if (loginPolicyState?.user) {
user = loginPolicyState.user;
}
const token = generateToken(user);
const refreshToken = generateRefreshToken(user);
@@ -2213,6 +2422,9 @@ app.post('/api/login',
user.download_traffic_used,
normalizeDownloadTrafficQuota(user.download_traffic_quota)
),
download_traffic_quota_expires_at: user.download_traffic_quota_expires_at || null,
download_traffic_reset_cycle: user.download_traffic_reset_cycle || 'none',
download_traffic_last_reset_at: user.download_traffic_last_reset_at || null,
// OSS配置来源重要用于前端判断是否使用OSS直连上传
oss_config_source: SettingsDB.hasUnifiedOssConfig() ? 'unified' : (user.has_oss_config ? 'personal' : 'none')
}
@@ -2280,6 +2492,19 @@ app.post('/api/logout', (req, res) => {
app.get('/api/user/profile', authMiddleware, (req, res) => {
const userPayload = { ...req.user };
const profilePolicyState = enforceDownloadTrafficPolicy(userPayload.id, 'profile');
if (profilePolicyState?.user) {
const policyUser = profilePolicyState.user;
userPayload.download_traffic_quota = normalizeDownloadTrafficQuota(policyUser.download_traffic_quota);
userPayload.download_traffic_used = normalizeDownloadTrafficUsed(
policyUser.download_traffic_used,
userPayload.download_traffic_quota
);
userPayload.download_traffic_quota_expires_at = policyUser.download_traffic_quota_expires_at || null;
userPayload.download_traffic_reset_cycle = policyUser.download_traffic_reset_cycle || 'none';
userPayload.download_traffic_last_reset_at = policyUser.download_traffic_last_reset_at || null;
}
// 本地存储用量校准:避免“有占用但目录为空”的显示错乱
if ((userPayload.current_storage_type || 'oss') === 'local') {
userPayload.local_storage_used = syncLocalStorageUsageFromDisk(userPayload.id, userPayload.local_storage_used);
@@ -3708,7 +3933,8 @@ app.get('/api/files/download-url', authMiddleware, async (req, res) => {
}
// 开启下载流量配额后,禁止发放直连 URL避免绕过后端计量
const latestUser = UserDB.findById(req.user.id);
const policyState = enforceDownloadTrafficPolicy(req.user.id, 'download_url');
const latestUser = policyState?.user || UserDB.findById(req.user.id);
if (!latestUser) {
return res.status(401).json({
success: false,
@@ -4004,7 +4230,8 @@ app.get('/api/files/download', authMiddleware, async (req, res) => {
}
try {
const latestUser = UserDB.findById(req.user.id);
const policyState = enforceDownloadTrafficPolicy(req.user.id, 'download');
const latestUser = policyState?.user || UserDB.findById(req.user.id);
if (!latestUser) {
return res.status(401).json({
success: false,
@@ -4780,7 +5007,8 @@ app.post('/api/share/:code/list', shareRateLimitMiddleware, async (req, res) =>
}
// 获取分享者的用户信息
const shareOwner = UserDB.findById(share.user_id);
const ownerPolicyState = enforceDownloadTrafficPolicy(share.user_id, 'share_download_url');
const shareOwner = ownerPolicyState?.user || UserDB.findById(share.user_id);
if (!shareOwner) {
return res.status(404).json({
success: false,
@@ -4995,7 +5223,8 @@ app.post('/api/share/:code/download-url', shareRateLimitMiddleware, async (req,
}
// 获取分享者的用户信息
const shareOwner = UserDB.findById(share.user_id);
const ownerPolicyState = enforceDownloadTrafficPolicy(share.user_id, 'share_download');
const shareOwner = ownerPolicyState?.user || UserDB.findById(share.user_id);
if (!shareOwner) {
return res.status(404).json({
success: false,
@@ -6013,7 +6242,10 @@ app.get('/api/admin/storage-stats', authMiddleware, adminMiddleware, async (req,
// 获取所有用户
app.get('/api/admin/users', authMiddleware, adminMiddleware, (req, res) => {
try {
const users = UserDB.getAll();
const users = UserDB.getAll().map(user => {
const policyState = enforceDownloadTrafficPolicy(user.id, 'admin_list');
return policyState?.user || user;
});
res.json({
success: true,
@@ -6038,7 +6270,10 @@ app.get('/api/admin/users', authMiddleware, adminMiddleware, (req, res) => {
download_traffic_used: normalizeDownloadTrafficUsed(
u.download_traffic_used,
normalizeDownloadTrafficQuota(u.download_traffic_quota)
)
),
download_traffic_quota_expires_at: u.download_traffic_quota_expires_at || null,
download_traffic_reset_cycle: u.download_traffic_reset_cycle || 'none',
download_traffic_last_reset_at: u.download_traffic_last_reset_at || null
}))
});
} catch (error) {
@@ -6628,7 +6863,20 @@ 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('download_traffic_quota').optional({ nullable: true }).isInt({ min: 0, max: 10995116277760 }).withMessage('下载流量配额必须在 0 到 10TB 之间0表示不限')
body('download_traffic_quota').optional({ nullable: true }).isInt({ min: 0, max: MAX_DOWNLOAD_TRAFFIC_BYTES }).withMessage('下载流量配额必须在 0 到 10TB 之间0表示不限'),
body('download_traffic_delta').optional({ nullable: true }).isInt({ min: -MAX_DOWNLOAD_TRAFFIC_BYTES, max: MAX_DOWNLOAD_TRAFFIC_BYTES }).withMessage('下载流量增减值必须在 -10TB 到 10TB 之间'),
body('download_traffic_reset_cycle').optional({ nullable: true }).isIn(['none', 'daily', 'weekly', 'monthly']).withMessage('下载流量重置周期无效'),
body('download_traffic_quota_expires_at').optional({ nullable: true }).custom((value) => {
if (value === null || value === undefined || value === '') {
return true;
}
const parsedDate = parseDateTimeValue(String(value));
if (!parsedDate) {
throw new Error('下载流量到期时间格式无效');
}
return true;
}),
body('reset_download_traffic_used').optional({ nullable: true }).isBoolean().withMessage('reset_download_traffic_used 必须是布尔值')
],
(req, res) => {
const errors = validationResult(req);
@@ -6641,7 +6889,16 @@ app.post('/api/admin/users/:id/storage-permission',
try {
const { id } = req.params;
const { storage_permission, local_storage_quota, oss_storage_quota, download_traffic_quota } = req.body;
const {
storage_permission,
local_storage_quota,
oss_storage_quota,
download_traffic_quota,
download_traffic_delta,
download_traffic_quota_expires_at,
download_traffic_reset_cycle,
reset_download_traffic_used
} = req.body;
// 参数验证:验证 ID 格式
const userId = parseInt(id, 10);
@@ -6652,7 +6909,19 @@ app.post('/api/admin/users/:id/storage-permission',
});
}
const now = new Date();
const nowSql = formatDateTimeForSqlite(now);
const updates = { storage_permission };
const hasSetTrafficQuota = download_traffic_quota !== undefined && download_traffic_quota !== null;
const hasTrafficDelta = download_traffic_delta !== undefined && download_traffic_delta !== null && Number(download_traffic_delta) !== 0;
const shouldResetTrafficUsedNow = reset_download_traffic_used === true || reset_download_traffic_used === 'true';
if (hasSetTrafficQuota && hasTrafficDelta) {
return res.status(400).json({
success: false,
message: '下载流量配额不能同时使用“直接设置”和“增减调整”'
});
}
// 如果提供了本地配额,更新配额(单位:字节)
if (local_storage_quota !== undefined && local_storage_quota !== null) {
@@ -6664,13 +6933,9 @@ 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);
const beforePolicyState = enforceDownloadTrafficPolicy(userId, 'admin_update_before');
const user = beforePolicyState?.user || UserDB.findById(userId);
if (!user) {
return res.status(404).json({
success: false,
@@ -6678,17 +6943,59 @@ app.post('/api/admin/users/:id/storage-permission',
});
}
// 如果管理员把配额调小,已用流量不能超过新配额
if (
updates.download_traffic_quota !== undefined &&
updates.download_traffic_quota > 0
) {
const beforeTrafficState = getDownloadTrafficState(user);
let targetTrafficQuota = beforeTrafficState.quota;
if (hasSetTrafficQuota) {
targetTrafficQuota = normalizeDownloadTrafficQuota(parseInt(download_traffic_quota, 10));
} else if (hasTrafficDelta) {
const delta = parseInt(download_traffic_delta, 10);
targetTrafficQuota = Math.max(
0,
Math.min(MAX_DOWNLOAD_TRAFFIC_BYTES, beforeTrafficState.quota + delta)
);
}
if (hasSetTrafficQuota || hasTrafficDelta) {
updates.download_traffic_quota = targetTrafficQuota;
updates.download_traffic_used = normalizeDownloadTrafficUsed(
user.download_traffic_used,
updates.download_traffic_quota
targetTrafficQuota
);
}
if (targetTrafficQuota <= 0) {
updates.download_traffic_quota_expires_at = null;
updates.download_traffic_reset_cycle = 'none';
updates.download_traffic_last_reset_at = null;
} else {
if (download_traffic_quota_expires_at !== undefined) {
if (download_traffic_quota_expires_at === null || download_traffic_quota_expires_at === '') {
updates.download_traffic_quota_expires_at = null;
} else {
const parsedExpireAt = parseDateTimeValue(String(download_traffic_quota_expires_at));
updates.download_traffic_quota_expires_at = parsedExpireAt
? formatDateTimeForSqlite(parsedExpireAt)
: null;
}
}
if (download_traffic_reset_cycle !== undefined && download_traffic_reset_cycle !== null) {
updates.download_traffic_reset_cycle = download_traffic_reset_cycle;
if (download_traffic_reset_cycle === 'none') {
updates.download_traffic_last_reset_at = null;
} else if (!user.download_traffic_last_reset_at) {
updates.download_traffic_last_reset_at = nowSql;
}
}
}
if (shouldResetTrafficUsedNow) {
updates.download_traffic_used = 0;
const effectiveCycle = updates.download_traffic_reset_cycle || user.download_traffic_reset_cycle || 'none';
updates.download_traffic_last_reset_at = effectiveCycle === 'none' ? null : nowSql;
}
if (storage_permission === 'local_only') {
updates.current_storage_type = 'local';
} else if (storage_permission === 'oss_only') {
@@ -6702,9 +7009,66 @@ app.post('/api/admin/users/:id/storage-permission',
UserDB.update(userId, updates);
const afterPolicyState = enforceDownloadTrafficPolicy(userId, 'admin_update_after');
const updatedUser = afterPolicyState?.user || UserDB.findById(userId);
logUser(
req,
'update_user_storage_and_traffic',
`管理员更新用户额度策略: ${user.username}`,
{
targetUserId: userId,
targetUsername: user.username,
storage_permission,
downloadTrafficOperation: hasSetTrafficQuota
? 'set'
: (hasTrafficDelta ? (parseInt(download_traffic_delta, 10) > 0 ? 'increase' : 'decrease') : 'none'),
before: {
quota: beforeTrafficState.quota,
used: beforeTrafficState.used,
expires_at: user.download_traffic_quota_expires_at || null,
reset_cycle: user.download_traffic_reset_cycle || 'none',
last_reset_at: user.download_traffic_last_reset_at || null
},
request: {
set_quota: hasSetTrafficQuota ? parseInt(download_traffic_quota, 10) : null,
delta: hasTrafficDelta ? parseInt(download_traffic_delta, 10) : 0,
expires_at: download_traffic_quota_expires_at ?? undefined,
reset_cycle: download_traffic_reset_cycle ?? undefined,
reset_used_now: shouldResetTrafficUsedNow
},
after: updatedUser ? {
quota: normalizeDownloadTrafficQuota(updatedUser.download_traffic_quota),
used: normalizeDownloadTrafficUsed(
updatedUser.download_traffic_used,
normalizeDownloadTrafficQuota(updatedUser.download_traffic_quota)
),
expires_at: updatedUser.download_traffic_quota_expires_at || null,
reset_cycle: updatedUser.download_traffic_reset_cycle || 'none',
last_reset_at: updatedUser.download_traffic_last_reset_at || null
} : null
}
);
res.json({
success: true,
message: '存储权限已更新'
message: '存储权限已更新',
user: updatedUser ? {
id: updatedUser.id,
storage_permission: updatedUser.storage_permission || 'oss_only',
current_storage_type: updatedUser.current_storage_type || 'oss',
local_storage_quota: updatedUser.local_storage_quota || DEFAULT_LOCAL_STORAGE_QUOTA_BYTES,
local_storage_used: updatedUser.local_storage_used || 0,
oss_storage_quota: normalizeOssQuota(updatedUser.oss_storage_quota),
download_traffic_quota: normalizeDownloadTrafficQuota(updatedUser.download_traffic_quota),
download_traffic_used: normalizeDownloadTrafficUsed(
updatedUser.download_traffic_used,
normalizeDownloadTrafficQuota(updatedUser.download_traffic_quota)
),
download_traffic_quota_expires_at: updatedUser.download_traffic_quota_expires_at || null,
download_traffic_reset_cycle: updatedUser.download_traffic_reset_cycle || 'none',
download_traffic_last_reset_at: updatedUser.download_traffic_last_reset_at || null
} : null
});
} catch (error) {
console.error('设置存储权限失败:', error);