feat: enhance download traffic quota lifecycle controls
This commit is contained in:
@@ -211,6 +211,9 @@ function authMiddleware(req, res, next) {
|
|||||||
storage_used: user.storage_used || 0,
|
storage_used: user.storage_used || 0,
|
||||||
download_traffic_quota: effectiveDownloadTrafficQuota,
|
download_traffic_quota: effectiveDownloadTrafficQuota,
|
||||||
download_traffic_used: cappedDownloadTrafficUsed,
|
download_traffic_used: cappedDownloadTrafficUsed,
|
||||||
|
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,
|
||||||
// 主题偏好
|
// 主题偏好
|
||||||
theme_preference: user.theme_preference || null
|
theme_preference: user.theme_preference || null
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -501,6 +501,24 @@ const UserDB = {
|
|||||||
* @private
|
* @private
|
||||||
*/
|
*/
|
||||||
_validateFieldValue(fieldName, value) {
|
_validateFieldValue(fieldName, value) {
|
||||||
|
const NULLABLE_STRING_FIELDS = new Set([
|
||||||
|
'oss_provider',
|
||||||
|
'oss_region',
|
||||||
|
'oss_access_key_id',
|
||||||
|
'oss_access_key_secret',
|
||||||
|
'oss_bucket',
|
||||||
|
'oss_endpoint',
|
||||||
|
'upload_api_key',
|
||||||
|
'verification_token',
|
||||||
|
'verification_expires_at',
|
||||||
|
'storage_permission',
|
||||||
|
'current_storage_type',
|
||||||
|
'download_traffic_quota_expires_at',
|
||||||
|
'download_traffic_reset_cycle',
|
||||||
|
'download_traffic_last_reset_at',
|
||||||
|
'theme_preference'
|
||||||
|
]);
|
||||||
|
|
||||||
// 字段类型白名单(根据数据库表结构定义)
|
// 字段类型白名单(根据数据库表结构定义)
|
||||||
const FIELD_TYPES = {
|
const FIELD_TYPES = {
|
||||||
// 文本类型字段
|
// 文本类型字段
|
||||||
@@ -518,6 +536,9 @@ const UserDB = {
|
|||||||
'verification_expires_at': 'string',
|
'verification_expires_at': 'string',
|
||||||
'storage_permission': 'string',
|
'storage_permission': 'string',
|
||||||
'current_storage_type': 'string',
|
'current_storage_type': 'string',
|
||||||
|
'download_traffic_quota_expires_at': 'string',
|
||||||
|
'download_traffic_reset_cycle': 'string',
|
||||||
|
'download_traffic_last_reset_at': 'string',
|
||||||
'theme_preference': 'string',
|
'theme_preference': 'string',
|
||||||
|
|
||||||
// 数值类型字段
|
// 数值类型字段
|
||||||
@@ -542,6 +563,9 @@ const UserDB = {
|
|||||||
|
|
||||||
// 检查类型匹配
|
// 检查类型匹配
|
||||||
if (expectedType === 'string') {
|
if (expectedType === 'string') {
|
||||||
|
if (value === null) {
|
||||||
|
return NULLABLE_STRING_FIELDS.has(fieldName);
|
||||||
|
}
|
||||||
return typeof value === 'string';
|
return typeof value === 'string';
|
||||||
} else if (expectedType === 'number') {
|
} else if (expectedType === 'number') {
|
||||||
// 允许数值或可转换为数值的字符串
|
// 允许数值或可转换为数值的字符串
|
||||||
@@ -593,6 +617,9 @@ const UserDB = {
|
|||||||
'oss_storage_quota': 'oss_storage_quota',
|
'oss_storage_quota': 'oss_storage_quota',
|
||||||
'download_traffic_quota': 'download_traffic_quota',
|
'download_traffic_quota': 'download_traffic_quota',
|
||||||
'download_traffic_used': 'download_traffic_used',
|
'download_traffic_used': 'download_traffic_used',
|
||||||
|
'download_traffic_quota_expires_at': 'download_traffic_quota_expires_at',
|
||||||
|
'download_traffic_reset_cycle': 'download_traffic_reset_cycle',
|
||||||
|
'download_traffic_last_reset_at': 'download_traffic_last_reset_at',
|
||||||
|
|
||||||
// 偏好设置
|
// 偏好设置
|
||||||
'theme_preference': 'theme_preference'
|
'theme_preference': 'theme_preference'
|
||||||
@@ -676,6 +703,9 @@ const UserDB = {
|
|||||||
'oss_storage_quota': 'oss_storage_quota',
|
'oss_storage_quota': 'oss_storage_quota',
|
||||||
'download_traffic_quota': 'download_traffic_quota',
|
'download_traffic_quota': 'download_traffic_quota',
|
||||||
'download_traffic_used': 'download_traffic_used',
|
'download_traffic_used': 'download_traffic_used',
|
||||||
|
'download_traffic_quota_expires_at': 'download_traffic_quota_expires_at',
|
||||||
|
'download_traffic_reset_cycle': 'download_traffic_reset_cycle',
|
||||||
|
'download_traffic_last_reset_at': 'download_traffic_last_reset_at',
|
||||||
|
|
||||||
// 偏好设置
|
// 偏好设置
|
||||||
'theme_preference': 'theme_preference'
|
'theme_preference': 'theme_preference'
|
||||||
@@ -726,6 +756,9 @@ const UserDB = {
|
|||||||
'upload_api_key': 'string', 'verification_token': 'string',
|
'upload_api_key': 'string', 'verification_token': 'string',
|
||||||
'verification_expires_at': 'string', 'storage_permission': 'string',
|
'verification_expires_at': 'string', 'storage_permission': 'string',
|
||||||
'current_storage_type': 'string', 'theme_preference': 'string',
|
'current_storage_type': 'string', 'theme_preference': 'string',
|
||||||
|
'download_traffic_quota_expires_at': 'string',
|
||||||
|
'download_traffic_reset_cycle': 'string',
|
||||||
|
'download_traffic_last_reset_at': 'string',
|
||||||
'is_admin': 'number', 'is_active': 'number', 'is_banned': 'number',
|
'is_admin': 'number', 'is_active': 'number', 'is_banned': 'number',
|
||||||
'has_oss_config': 'number', 'is_verified': 'number',
|
'has_oss_config': 'number', 'is_verified': 'number',
|
||||||
'local_storage_quota': 'number', 'local_storage_used': 'number',
|
'local_storage_quota': 'number', 'local_storage_used': 'number',
|
||||||
@@ -1298,6 +1331,9 @@ function migrateDownloadTrafficFields() {
|
|||||||
const columns = db.prepare("PRAGMA table_info(users)").all();
|
const columns = db.prepare("PRAGMA table_info(users)").all();
|
||||||
const hasDownloadTrafficQuota = columns.some(col => col.name === 'download_traffic_quota');
|
const hasDownloadTrafficQuota = columns.some(col => col.name === 'download_traffic_quota');
|
||||||
const hasDownloadTrafficUsed = columns.some(col => col.name === 'download_traffic_used');
|
const hasDownloadTrafficUsed = columns.some(col => col.name === 'download_traffic_used');
|
||||||
|
const hasDownloadTrafficQuotaExpiresAt = columns.some(col => col.name === 'download_traffic_quota_expires_at');
|
||||||
|
const hasDownloadTrafficResetCycle = columns.some(col => col.name === 'download_traffic_reset_cycle');
|
||||||
|
const hasDownloadTrafficLastResetAt = columns.some(col => col.name === 'download_traffic_last_reset_at');
|
||||||
|
|
||||||
if (!hasDownloadTrafficQuota) {
|
if (!hasDownloadTrafficQuota) {
|
||||||
console.log('[数据库迁移] 添加 download_traffic_quota 字段...');
|
console.log('[数据库迁移] 添加 download_traffic_quota 字段...');
|
||||||
@@ -1311,6 +1347,24 @@ function migrateDownloadTrafficFields() {
|
|||||||
console.log('[数据库迁移] ✓ download_traffic_used 字段已添加');
|
console.log('[数据库迁移] ✓ download_traffic_used 字段已添加');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!hasDownloadTrafficQuotaExpiresAt) {
|
||||||
|
console.log('[数据库迁移] 添加 download_traffic_quota_expires_at 字段...');
|
||||||
|
db.exec('ALTER TABLE users ADD COLUMN download_traffic_quota_expires_at DATETIME DEFAULT NULL');
|
||||||
|
console.log('[数据库迁移] ✓ download_traffic_quota_expires_at 字段已添加');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!hasDownloadTrafficResetCycle) {
|
||||||
|
console.log('[数据库迁移] 添加 download_traffic_reset_cycle 字段...');
|
||||||
|
db.exec("ALTER TABLE users ADD COLUMN download_traffic_reset_cycle TEXT DEFAULT 'none'");
|
||||||
|
console.log('[数据库迁移] ✓ download_traffic_reset_cycle 字段已添加');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!hasDownloadTrafficLastResetAt) {
|
||||||
|
console.log('[数据库迁移] 添加 download_traffic_last_reset_at 字段...');
|
||||||
|
db.exec('ALTER TABLE users ADD COLUMN download_traffic_last_reset_at DATETIME DEFAULT NULL');
|
||||||
|
console.log('[数据库迁移] ✓ download_traffic_last_reset_at 字段已添加');
|
||||||
|
}
|
||||||
|
|
||||||
// 统一策略:download_traffic_quota <= 0 表示不限流量
|
// 统一策略:download_traffic_quota <= 0 表示不限流量
|
||||||
const quotaBackfillResult = db.prepare(`
|
const quotaBackfillResult = db.prepare(`
|
||||||
UPDATE users
|
UPDATE users
|
||||||
@@ -1341,6 +1395,27 @@ function migrateDownloadTrafficFields() {
|
|||||||
if (usedCapResult.changes > 0) {
|
if (usedCapResult.changes > 0) {
|
||||||
console.log(`[数据库迁移] ✓ 下载流量已用值已按配额校准: ${usedCapResult.changes} 条记录`);
|
console.log(`[数据库迁移] ✓ 下载流量已用值已按配额校准: ${usedCapResult.changes} 条记录`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const resetCycleBackfillResult = db.prepare(`
|
||||||
|
UPDATE users
|
||||||
|
SET download_traffic_reset_cycle = 'none'
|
||||||
|
WHERE download_traffic_reset_cycle IS NULL
|
||||||
|
OR download_traffic_reset_cycle NOT IN ('none', 'daily', 'weekly', 'monthly')
|
||||||
|
`).run();
|
||||||
|
|
||||||
|
if (resetCycleBackfillResult.changes > 0) {
|
||||||
|
console.log(`[数据库迁移] ✓ 下载流量重置周期已回填: ${resetCycleBackfillResult.changes} 条记录`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const clearExpiryForUnlimitedResult = db.prepare(`
|
||||||
|
UPDATE users
|
||||||
|
SET download_traffic_quota_expires_at = NULL
|
||||||
|
WHERE download_traffic_quota <= 0
|
||||||
|
`).run();
|
||||||
|
|
||||||
|
if (clearExpiryForUnlimitedResult.changes > 0) {
|
||||||
|
console.log(`[数据库迁移] ✓ 不限流量用户已清理到期时间: ${clearExpiryForUnlimitedResult.changes} 条记录`);
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[数据库迁移] 下载流量字段迁移失败:', error);
|
console.error('[数据库迁移] 下载流量字段迁移失败:', error);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 ENFORCE_HTTPS = process.env.ENFORCE_HTTPS === 'true';
|
||||||
const DEFAULT_LOCAL_STORAGE_QUOTA_BYTES = 1024 * 1024 * 1024; // 1GB
|
const DEFAULT_LOCAL_STORAGE_QUOTA_BYTES = 1024 * 1024 * 1024; // 1GB
|
||||||
const DEFAULT_OSS_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 SHARE_CODE_REGEX = /^[A-Za-z0-9]{6,32}$/;
|
||||||
const COOKIE_SECURE_MODE = String(process.env.COOKIE_SECURE || '').toLowerCase();
|
const COOKIE_SECURE_MODE = String(process.env.COOKIE_SECURE || '').toLowerCase();
|
||||||
const SHOULD_USE_SECURE_COOKIES =
|
const SHOULD_USE_SECURE_COOKIES =
|
||||||
@@ -633,7 +635,7 @@ function normalizeDownloadTrafficQuota(rawQuota) {
|
|||||||
if (!Number.isFinite(parsedQuota) || parsedQuota <= 0) {
|
if (!Number.isFinite(parsedQuota) || parsedQuota <= 0) {
|
||||||
return 0; // 0 表示不限流量
|
return 0; // 0 表示不限流量
|
||||||
}
|
}
|
||||||
return Math.floor(parsedQuota);
|
return Math.min(MAX_DOWNLOAD_TRAFFIC_BYTES, Math.floor(parsedQuota));
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeDownloadTrafficUsed(rawUsed, quota = 0) {
|
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 applyDownloadTrafficUsageTransaction = db.transaction((userId, bytesToAdd) => {
|
||||||
const user = UserDB.findById(userId);
|
const policyState = enforceDownloadTrafficPolicyTransaction(userId, 'usage');
|
||||||
|
const user = policyState?.user || UserDB.findById(userId);
|
||||||
if (!user) {
|
if (!user) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -696,6 +863,42 @@ function applyDownloadTrafficUsage(userId, bytesToAdd) {
|
|||||||
return applyDownloadTrafficUsageTransaction(userId, Math.floor(parsedBytes));
|
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)
|
// 构建用于存储客户端的用户对象(自动尝试解密 OSS Secret)
|
||||||
function buildStorageUserContext(user, overrides = {}) {
|
function buildStorageUserContext(user, overrides = {}) {
|
||||||
if (!user) {
|
if (!user) {
|
||||||
@@ -2092,7 +2295,7 @@ app.post('/api/login',
|
|||||||
delete req.session.captchaTime;
|
delete req.session.captchaTime;
|
||||||
}
|
}
|
||||||
|
|
||||||
const user = UserDB.findByUsername(username);
|
let user = UserDB.findByUsername(username);
|
||||||
|
|
||||||
if (!user) {
|
if (!user) {
|
||||||
// 记录失败尝试
|
// 记录失败尝试
|
||||||
@@ -2156,6 +2359,12 @@ app.post('/api/login',
|
|||||||
user.local_storage_used = syncLocalStorageUsageFromDisk(user.id, user.local_storage_used);
|
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 token = generateToken(user);
|
||||||
const refreshToken = generateRefreshToken(user);
|
const refreshToken = generateRefreshToken(user);
|
||||||
|
|
||||||
@@ -2213,6 +2422,9 @@ app.post('/api/login',
|
|||||||
user.download_traffic_used,
|
user.download_traffic_used,
|
||||||
normalizeDownloadTrafficQuota(user.download_traffic_quota)
|
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配置来源(重要:用于前端判断是否使用OSS直连上传)
|
||||||
oss_config_source: SettingsDB.hasUnifiedOssConfig() ? 'unified' : (user.has_oss_config ? 'personal' : 'none')
|
oss_config_source: SettingsDB.hasUnifiedOssConfig() ? 'unified' : (user.has_oss_config ? 'personal' : 'none')
|
||||||
}
|
}
|
||||||
@@ -2280,6 +2492,19 @@ app.post('/api/logout', (req, res) => {
|
|||||||
app.get('/api/user/profile', authMiddleware, (req, res) => {
|
app.get('/api/user/profile', authMiddleware, (req, res) => {
|
||||||
const userPayload = { ...req.user };
|
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') {
|
if ((userPayload.current_storage_type || 'oss') === 'local') {
|
||||||
userPayload.local_storage_used = syncLocalStorageUsageFromDisk(userPayload.id, userPayload.local_storage_used);
|
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(避免绕过后端计量)
|
// 开启下载流量配额后,禁止发放直连 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) {
|
if (!latestUser) {
|
||||||
return res.status(401).json({
|
return res.status(401).json({
|
||||||
success: false,
|
success: false,
|
||||||
@@ -4004,7 +4230,8 @@ app.get('/api/files/download', authMiddleware, async (req, res) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
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) {
|
if (!latestUser) {
|
||||||
return res.status(401).json({
|
return res.status(401).json({
|
||||||
success: false,
|
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) {
|
if (!shareOwner) {
|
||||||
return res.status(404).json({
|
return res.status(404).json({
|
||||||
success: false,
|
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) {
|
if (!shareOwner) {
|
||||||
return res.status(404).json({
|
return res.status(404).json({
|
||||||
success: false,
|
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) => {
|
app.get('/api/admin/users', authMiddleware, adminMiddleware, (req, res) => {
|
||||||
try {
|
try {
|
||||||
const users = UserDB.getAll();
|
const users = UserDB.getAll().map(user => {
|
||||||
|
const policyState = enforceDownloadTrafficPolicy(user.id, 'admin_list');
|
||||||
|
return policyState?.user || user;
|
||||||
|
});
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
success: true,
|
success: true,
|
||||||
@@ -6038,7 +6270,10 @@ app.get('/api/admin/users', authMiddleware, adminMiddleware, (req, res) => {
|
|||||||
download_traffic_used: normalizeDownloadTrafficUsed(
|
download_traffic_used: normalizeDownloadTrafficUsed(
|
||||||
u.download_traffic_used,
|
u.download_traffic_used,
|
||||||
normalizeDownloadTrafficQuota(u.download_traffic_quota)
|
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) {
|
} 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('storage_permission').isIn(['local_only', 'oss_only', 'user_choice']).withMessage('无效的存储权限'),
|
||||||
body('local_storage_quota').optional({ nullable: true }).isInt({ min: 1048576, max: 10995116277760 }).withMessage('本地配额必须在 1MB 到 10TB 之间'),
|
body('local_storage_quota').optional({ nullable: true }).isInt({ min: 1048576, max: 10995116277760 }).withMessage('本地配额必须在 1MB 到 10TB 之间'),
|
||||||
body('oss_storage_quota').optional({ nullable: true }).isInt({ min: 1048576, max: 10995116277760 }).withMessage('OSS配额必须在 1MB 到 10TB 之间'),
|
body('oss_storage_quota').optional({ nullable: true }).isInt({ min: 1048576, max: 10995116277760 }).withMessage('OSS配额必须在 1MB 到 10TB 之间'),
|
||||||
body('download_traffic_quota').optional({ nullable: true }).isInt({ min: 0, max: 10995116277760 }).withMessage('下载流量配额必须在 0 到 10TB 之间(0表示不限)')
|
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) => {
|
(req, res) => {
|
||||||
const errors = validationResult(req);
|
const errors = validationResult(req);
|
||||||
@@ -6641,7 +6889,16 @@ app.post('/api/admin/users/:id/storage-permission',
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const { id } = req.params;
|
const { id } = req.params;
|
||||||
const { storage_permission, local_storage_quota, oss_storage_quota, 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 格式
|
// 参数验证:验证 ID 格式
|
||||||
const userId = parseInt(id, 10);
|
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 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) {
|
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);
|
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) {
|
if (!user) {
|
||||||
return res.status(404).json({
|
return res.status(404).json({
|
||||||
success: false,
|
success: false,
|
||||||
@@ -6678,17 +6943,59 @@ app.post('/api/admin/users/:id/storage-permission',
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// 如果管理员把配额调小,已用流量不能超过新配额
|
const beforeTrafficState = getDownloadTrafficState(user);
|
||||||
if (
|
let targetTrafficQuota = beforeTrafficState.quota;
|
||||||
updates.download_traffic_quota !== undefined &&
|
|
||||||
updates.download_traffic_quota > 0
|
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(
|
updates.download_traffic_used = normalizeDownloadTrafficUsed(
|
||||||
user.download_traffic_used,
|
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') {
|
if (storage_permission === 'local_only') {
|
||||||
updates.current_storage_type = 'local';
|
updates.current_storage_type = 'local';
|
||||||
} else if (storage_permission === 'oss_only') {
|
} else if (storage_permission === 'oss_only') {
|
||||||
@@ -6702,9 +7009,66 @@ app.post('/api/admin/users/:id/storage-permission',
|
|||||||
|
|
||||||
UserDB.update(userId, updates);
|
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({
|
res.json({
|
||||||
success: true,
|
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) {
|
} catch (error) {
|
||||||
console.error('设置存储权限失败:', error);
|
console.error('设置存储权限失败:', error);
|
||||||
|
|||||||
@@ -3190,6 +3190,12 @@
|
|||||||
不限
|
不限
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div style="font-size: 11px; color: var(--text-muted); margin-top: 2px;">
|
||||||
|
{{ getDownloadResetCycleText(u.download_traffic_reset_cycle || 'none') }}
|
||||||
|
</div>
|
||||||
|
<div v-if="u.download_traffic_quota_expires_at" style="font-size: 11px; color: #f59e0b; margin-top: 2px;">
|
||||||
|
到期: {{ formatDate(u.download_traffic_quota_expires_at) }}
|
||||||
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td style="padding: 10px; text-align: center;">
|
<td style="padding: 10px; text-align: center;">
|
||||||
<span v-if="u.is_banned" style="color: #ef4444; font-weight: 600;">已封禁</span>
|
<span v-if="u.is_banned" style="color: #ef4444; font-weight: 600;">已封禁</span>
|
||||||
@@ -3534,29 +3540,81 @@
|
|||||||
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label class="form-label">下载流量配额</label>
|
<label class="form-label">下载流量配额</label>
|
||||||
<div style="display: flex; align-items: center; gap: 10px; margin-bottom: 8px;">
|
<div style="display: flex; gap: 10px; margin-bottom: 8px;">
|
||||||
<label style="display: inline-flex; align-items: center; gap: 6px; font-size: 13px; color: var(--text-secondary);">
|
<select class="form-input" v-model="editStorageForm.download_quota_operation" style="flex: 1;">
|
||||||
<input type="checkbox" v-model="editStorageForm.download_quota_unlimited">
|
<option value="set">直接设置</option>
|
||||||
不限流量
|
<option value="increase">增加额度</option>
|
||||||
</label>
|
<option value="decrease">减少额度</option>
|
||||||
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="!editStorageForm.download_quota_unlimited" style="display: flex; gap: 10px;">
|
|
||||||
|
<div v-if="editStorageForm.download_quota_operation === 'set'">
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else style="display: flex; gap: 10px;">
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
class="form-input"
|
class="form-input"
|
||||||
v-model.number="editStorageForm.download_traffic_quota_value"
|
v-model.number="editStorageForm.download_quota_adjust_value"
|
||||||
min="1"
|
min="1"
|
||||||
max="10240"
|
max="10240"
|
||||||
step="1"
|
step="1"
|
||||||
style="flex: 1;">
|
style="flex: 1;">
|
||||||
<select class="form-input" v-model="editStorageForm.download_quota_unit" style="width: 100px;">
|
<select class="form-input" v-model="editStorageForm.download_quota_adjust_unit" style="width: 100px;">
|
||||||
<option value="MB">MB</option>
|
<option value="MB">MB</option>
|
||||||
<option value="GB">GB</option>
|
<option value="GB">GB</option>
|
||||||
<option value="TB">TB</option>
|
<option value="TB">TB</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<small style="color: var(--text-secondary); font-size: 12px; margin-top: 5px; display: block;">
|
<small style="color: var(--text-secondary); font-size: 12px; margin-top: 5px; display: block;">
|
||||||
下载流量范围: 不限 或 1MB - 10TB(按实际下载字节扣减)
|
下载流量支持直接设置、增减操作,范围: 不限 或 1MB - 10TB
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">下载流量到期时间(可选)</label>
|
||||||
|
<input type="datetime-local" class="form-input" v-model="editStorageForm.download_quota_expires_at">
|
||||||
|
<small style="color: var(--text-secondary); font-size: 12px; margin-top: 5px; display: block;">
|
||||||
|
到期后自动恢复为不限流量并清零已用流量;留空表示永不过期
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">下载流量重置周期</label>
|
||||||
|
<select class="form-input" v-model="editStorageForm.download_quota_reset_cycle">
|
||||||
|
<option value="none">不自动重置</option>
|
||||||
|
<option value="daily">每日重置</option>
|
||||||
|
<option value="weekly">每周重置</option>
|
||||||
|
<option value="monthly">每月重置</option>
|
||||||
|
</select>
|
||||||
|
<label style="display: inline-flex; align-items: center; gap: 6px; font-size: 13px; color: var(--text-secondary); margin-top: 8px;">
|
||||||
|
<input type="checkbox" v-model="editStorageForm.reset_download_used_now">
|
||||||
|
保存时立即将已用流量清零
|
||||||
|
</label>
|
||||||
|
<small style="color: var(--text-secondary); font-size: 12px; margin-top: 5px; display: block;">
|
||||||
|
可用于按日/周/月重置下载流量使用量
|
||||||
</small>
|
</small>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -3568,7 +3626,10 @@
|
|||||||
({{ editStorageForm.quota_unit === 'GB' ? (editStorageForm.local_storage_quota_value * 1024).toFixed(0) : editStorageForm.local_storage_quota_value }} MB)<br>
|
({{ editStorageForm.quota_unit === 'GB' ? (editStorageForm.local_storage_quota_value * 1024).toFixed(0) : editStorageForm.local_storage_quota_value }} MB)<br>
|
||||||
• 当前 OSS 配额: {{ editStorageForm.oss_storage_quota_value + ' ' + editStorageForm.oss_quota_unit }}<br>
|
• 当前 OSS 配额: {{ editStorageForm.oss_storage_quota_value + ' ' + editStorageForm.oss_quota_unit }}<br>
|
||||||
• 已用下载流量: {{ formatBytes(editStorageForm.download_traffic_used || 0) }}<br>
|
• 已用下载流量: {{ formatBytes(editStorageForm.download_traffic_used || 0) }}<br>
|
||||||
• 当前下载流量配额: {{ editStorageForm.download_quota_unlimited ? '不限' : (editStorageForm.download_traffic_quota_value + ' ' + editStorageForm.download_quota_unit) }}
|
• 下载策略: {{ editStorageForm.download_quota_operation === 'set' ? '直接设置' : (editStorageForm.download_quota_operation === 'increase' ? '增加额度' : '减少额度') }}<br>
|
||||||
|
• 当前下载流量配额: {{ editStorageForm.download_quota_unlimited ? '不限' : (editStorageForm.download_traffic_quota_value + ' ' + editStorageForm.download_quota_unit) }}<br>
|
||||||
|
• 自动重置: {{ getDownloadResetCycleText(editStorageForm.download_quota_reset_cycle) }}<br>
|
||||||
|
• 到期时间: {{ editStorageForm.download_quota_expires_at ? editStorageForm.download_quota_expires_at.replace('T', ' ') : '无' }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
117
frontend/app.js
117
frontend/app.js
@@ -275,7 +275,13 @@ createApp({
|
|||||||
download_traffic_quota_value: 1, // 下载流量配额数值
|
download_traffic_quota_value: 1, // 下载流量配额数值
|
||||||
download_quota_unit: 'GB', // 下载流量单位:MB / GB / TB
|
download_quota_unit: 'GB', // 下载流量单位:MB / GB / TB
|
||||||
download_quota_unlimited: true, // 下载流量:true=不限
|
download_quota_unlimited: true, // 下载流量:true=不限
|
||||||
download_traffic_used: 0 // 下载流量已使用(字节)
|
download_traffic_used: 0, // 下载流量已使用(字节)
|
||||||
|
download_quota_operation: 'set', // set/increase/decrease
|
||||||
|
download_quota_adjust_value: 1, // 增减额度数值
|
||||||
|
download_quota_adjust_unit: 'GB', // 增减额度单位
|
||||||
|
download_quota_expires_at: '', // 到期时间(datetime-local)
|
||||||
|
download_quota_reset_cycle: 'none', // none/daily/weekly/monthly
|
||||||
|
reset_download_used_now: false // 保存时立即重置已用流量
|
||||||
},
|
},
|
||||||
|
|
||||||
// 服务器存储统计
|
// 服务器存储统计
|
||||||
@@ -2943,6 +2949,12 @@ handleDragLeave(e) {
|
|||||||
this.editStorageForm.download_traffic_used = Number.isFinite(downloadUsedBytes) && downloadUsedBytes > 0
|
this.editStorageForm.download_traffic_used = Number.isFinite(downloadUsedBytes) && downloadUsedBytes > 0
|
||||||
? Math.floor(downloadUsedBytes)
|
? Math.floor(downloadUsedBytes)
|
||||||
: 0;
|
: 0;
|
||||||
|
this.editStorageForm.download_quota_operation = 'set';
|
||||||
|
this.editStorageForm.download_quota_adjust_value = 1;
|
||||||
|
this.editStorageForm.download_quota_adjust_unit = 'GB';
|
||||||
|
this.editStorageForm.download_quota_reset_cycle = user.download_traffic_reset_cycle || 'none';
|
||||||
|
this.editStorageForm.download_quota_expires_at = this.toDateTimeLocalInput(user.download_traffic_quota_expires_at);
|
||||||
|
this.editStorageForm.reset_download_used_now = false;
|
||||||
|
|
||||||
this.editStorageForm.download_quota_unlimited = effectiveDownloadQuotaBytes <= 0;
|
this.editStorageForm.download_quota_unlimited = effectiveDownloadQuotaBytes <= 0;
|
||||||
if (effectiveDownloadQuotaBytes <= 0) {
|
if (effectiveDownloadQuotaBytes <= 0) {
|
||||||
@@ -2993,31 +3005,65 @@ handleDragLeave(e) {
|
|||||||
ossQuotaBytes = this.editStorageForm.oss_storage_quota_value * 1024 * 1024;
|
ossQuotaBytes = this.editStorageForm.oss_storage_quota_value * 1024 * 1024;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 计算下载流量配额(字节,0表示不限)
|
const toBytes = (value, unit) => {
|
||||||
let downloadQuotaBytes = 0;
|
if (unit === 'TB') return value * 1024 * 1024 * 1024 * 1024;
|
||||||
if (!this.editStorageForm.download_quota_unlimited) {
|
if (unit === 'GB') return value * 1024 * 1024 * 1024;
|
||||||
if (!this.editStorageForm.download_traffic_quota_value || this.editStorageForm.download_traffic_quota_value < 1) {
|
return value * 1024 * 1024;
|
||||||
this.showToast('error', '参数错误', '下载流量配额必须大于 0,或选择不限流量');
|
};
|
||||||
|
|
||||||
|
// 下载流量:支持直接设置 / 增加 / 删减
|
||||||
|
let downloadQuotaBytes = null;
|
||||||
|
let downloadTrafficDelta = null;
|
||||||
|
if (this.editStorageForm.download_quota_operation === 'set') {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
downloadQuotaBytes = toBytes(
|
||||||
|
this.editStorageForm.download_traffic_quota_value,
|
||||||
|
this.editStorageForm.download_quota_unit
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (!this.editStorageForm.download_quota_adjust_value || this.editStorageForm.download_quota_adjust_value < 1) {
|
||||||
|
this.showToast('error', '参数错误', '下载流量增减值必须大于 0');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
const adjustBytes = toBytes(
|
||||||
|
this.editStorageForm.download_quota_adjust_value,
|
||||||
|
this.editStorageForm.download_quota_adjust_unit
|
||||||
|
);
|
||||||
|
downloadTrafficDelta = this.editStorageForm.download_quota_operation === 'increase'
|
||||||
|
? adjustBytes
|
||||||
|
: -adjustBytes;
|
||||||
|
}
|
||||||
|
|
||||||
if (this.editStorageForm.download_quota_unit === 'TB') {
|
const downloadQuotaExpiresAt = this.normalizeDateTimeLocalToApi(this.editStorageForm.download_quota_expires_at);
|
||||||
downloadQuotaBytes = this.editStorageForm.download_traffic_quota_value * 1024 * 1024 * 1024 * 1024;
|
if (this.editStorageForm.download_quota_expires_at && !downloadQuotaExpiresAt) {
|
||||||
} else if (this.editStorageForm.download_quota_unit === 'GB') {
|
this.showToast('error', '参数错误', '下载流量到期时间格式无效');
|
||||||
downloadQuotaBytes = this.editStorageForm.download_traffic_quota_value * 1024 * 1024 * 1024;
|
return;
|
||||||
} else {
|
}
|
||||||
downloadQuotaBytes = this.editStorageForm.download_traffic_quota_value * 1024 * 1024;
|
|
||||||
}
|
const payload = {
|
||||||
|
storage_permission: this.editStorageForm.storage_permission,
|
||||||
|
local_storage_quota: localQuotaBytes,
|
||||||
|
oss_storage_quota: ossQuotaBytes,
|
||||||
|
download_traffic_quota_expires_at: downloadQuotaExpiresAt,
|
||||||
|
download_traffic_reset_cycle: this.editStorageForm.download_quota_reset_cycle || 'none',
|
||||||
|
reset_download_traffic_used: !!this.editStorageForm.reset_download_used_now
|
||||||
|
};
|
||||||
|
if (downloadQuotaBytes !== null) {
|
||||||
|
payload.download_traffic_quota = downloadQuotaBytes;
|
||||||
|
}
|
||||||
|
if (downloadTrafficDelta !== null) {
|
||||||
|
payload.download_traffic_delta = downloadTrafficDelta;
|
||||||
}
|
}
|
||||||
|
|
||||||
const response = await axios.post(
|
const response = await axios.post(
|
||||||
`${this.apiBase}/api/admin/users/${this.editStorageForm.userId}/storage-permission`,
|
`${this.apiBase}/api/admin/users/${this.editStorageForm.userId}/storage-permission`,
|
||||||
{
|
payload,
|
||||||
storage_permission: this.editStorageForm.storage_permission,
|
|
||||||
local_storage_quota: localQuotaBytes,
|
|
||||||
oss_storage_quota: ossQuotaBytes,
|
|
||||||
download_traffic_quota: downloadQuotaBytes
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
|
|
||||||
if (response.data.success) {
|
if (response.data.success) {
|
||||||
@@ -3065,6 +3111,39 @@ handleDragLeave(e) {
|
|||||||
return Math.min(100, Math.round((used / quota) * 100));
|
return Math.min(100, Math.round((used / quota) * 100));
|
||||||
},
|
},
|
||||||
|
|
||||||
|
getDownloadResetCycleText(cycle) {
|
||||||
|
if (cycle === 'daily') return '每日重置';
|
||||||
|
if (cycle === 'weekly') return '每周重置';
|
||||||
|
if (cycle === 'monthly') return '每月重置';
|
||||||
|
return '不自动重置';
|
||||||
|
},
|
||||||
|
|
||||||
|
toDateTimeLocalInput(dateString) {
|
||||||
|
if (!dateString) return '';
|
||||||
|
const normalized = String(dateString).trim().replace(' ', 'T');
|
||||||
|
const match = normalized.match(/^(\d{4}-\d{2}-\d{2}T\d{2}:\d{2})/);
|
||||||
|
if (match) return match[1];
|
||||||
|
|
||||||
|
const parsed = new Date(normalized);
|
||||||
|
if (Number.isNaN(parsed.getTime())) return '';
|
||||||
|
const year = parsed.getFullYear();
|
||||||
|
const month = String(parsed.getMonth() + 1).padStart(2, '0');
|
||||||
|
const day = String(parsed.getDate()).padStart(2, '0');
|
||||||
|
const hours = String(parsed.getHours()).padStart(2, '0');
|
||||||
|
const minutes = String(parsed.getMinutes()).padStart(2, '0');
|
||||||
|
return `${year}-${month}-${day}T${hours}:${minutes}`;
|
||||||
|
},
|
||||||
|
|
||||||
|
normalizeDateTimeLocalToApi(localValue) {
|
||||||
|
if (!localValue) return null;
|
||||||
|
const normalized = String(localValue).trim();
|
||||||
|
if (!normalized) return null;
|
||||||
|
const fullMatch = normalized.match(/^(\d{4}-\d{2}-\d{2})T(\d{2}:\d{2})(?::(\d{2}))?$/);
|
||||||
|
if (!fullMatch) return null;
|
||||||
|
const seconds = fullMatch[3] || '00';
|
||||||
|
return `${fullMatch[1]} ${fullMatch[2]}:${seconds}`;
|
||||||
|
},
|
||||||
|
|
||||||
formatDate(dateString) {
|
formatDate(dateString) {
|
||||||
if (!dateString) return '-';
|
if (!dateString) return '-';
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user