Files
vue-driven-cloud-storage/backend/database.js

2917 lines
98 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 加载环境变量(确保在 server.js 之前也能读取)
require('dotenv').config();
const Database = require('better-sqlite3');
const bcrypt = require('bcryptjs');
const path = require('path');
const fs = require('fs');
const crypto = require('crypto');
// 引入加密工具(用于敏感数据加密存储)
const { encryptSecret, decryptSecret, validateEncryption } = require('./utils/encryption');
// 验证加密系统在启动时正常工作
try {
validateEncryption();
} catch (error) {
console.error('[安全] 加密系统验证失败,服务无法启动');
console.error('[安全] 请检查 ENCRYPTION_KEY 配置');
process.exit(1);
}
// 数据库路径配置
// 优先使用环境变量 DATABASE_PATH默认为 ./data/database.db
const DEFAULT_DB_PATH = path.join(__dirname, 'data', 'database.db');
const dbPath = process.env.DATABASE_PATH
? path.resolve(__dirname, process.env.DATABASE_PATH)
: DEFAULT_DB_PATH;
// 确保数据库目录存在
const dbDir = path.dirname(dbPath);
if (!fs.existsSync(dbDir)) {
fs.mkdirSync(dbDir, { recursive: true });
console.log(`[数据库] 创建目录: ${dbDir}`);
}
console.log(`[数据库] 路径: ${dbPath}`);
// 创建或连接数据库
const db = new Database(dbPath);
const DEFAULT_LOCAL_STORAGE_QUOTA_BYTES = 1024 * 1024 * 1024; // 1GB
const DEFAULT_OSS_STORAGE_QUOTA_BYTES = 1024 * 1024 * 1024; // 1GB
// ===== 性能优化配置P0 优先级修复) =====
// 1. 启用 WAL 模式Write-Ahead Logging
// 优势:支持并发读写,大幅提升数据库性能
db.pragma('journal_mode = WAL');
// 2. 配置同步模式为 NORMAL
// 性能提升:在安全性和性能之间取得平衡,比 FULL 模式快很多
db.pragma('synchronous = NORMAL');
// 3. 增加缓存大小到 64MB
// 性能提升:减少磁盘 I/O缓存更多数据页和索引页
// 负值表示 KB-64000 = 64MB
db.pragma('cache_size = -64000');
// 4. 临时表存储在内存中
// 性能提升:避免临时表写入磁盘,加速排序和分组操作
db.pragma('temp_store = MEMORY');
// 5. 启用外键约束
db.pragma('foreign_keys = ON');
console.log('[数据库性能优化] ✓ WAL 模式已启用');
console.log('[数据库性能优化] ✓ 同步模式: NORMAL');
console.log('[数据库性能优化] ✓ 缓存大小: 64MB');
console.log('[数据库性能优化] ✓ 临时表存储: 内存');
// ===== 第二轮修复WAL 文件定期清理机制 =====
/**
* 执行数据库检查点Checkpoint
* 将 WAL 文件中的内容写入主数据库文件,并清理 WAL
* @param {Database} database - 数据库实例
* @returns {boolean} 是否成功执行
*/
function performCheckpoint(database = db) {
try {
// 执行 checkpoint将 WAL 内容合并到主数据库)
database.pragma('wal_checkpoint(PASSIVE)');
// 获取 WAL 文件大小信息
const walInfo = database.pragma('wal_checkpoint(TRUNCATE)', { simple: true });
console.log('[WAL清理] ✓ 检查点完成');
return true;
} catch (error) {
console.error('[WAL清理] ✗ 检查点失败:', error.message);
return false;
}
}
/**
* 获取 WAL 文件大小
* @param {Database} database - 数据库实例
* @returns {number} WAL 文件大小(字节)
*/
function getWalFileSize(database = db) {
try {
const dbPath = database.name;
const walPath = `${dbPath}-wal`;
if (fs.existsSync(walPath)) {
const stats = fs.statSync(walPath);
return stats.size;
}
return 0;
} catch (error) {
console.error('[WAL清理] 获取 WAL 文件大小失败:', error.message);
return 0;
}
}
/**
* 启动时检查 WAL 文件大小,如果超过阈值则执行清理
* @param {number} threshold - 阈值(字节),默认 100MB
*/
function checkWalOnStartup(threshold = 100 * 1024 * 1024) {
try {
const walSize = getWalFileSize();
if (walSize > threshold) {
console.warn(`[WAL清理] ⚠ 启动时检测到 WAL 文件过大: ${(walSize / 1024 / 1024).toFixed(2)}MB`);
console.log('[WAL清理] 正在执行自动清理...');
const success = performCheckpoint();
if (success) {
const newSize = getWalFileSize();
console.log(`[WAL清理] ✓ 清理完成: ${walSize}${newSize} 字节`);
}
} else {
console.log(`[WAL清理] ✓ WAL 文件大小正常: ${(walSize / 1024 / 1024).toFixed(2)}MB`);
}
} catch (error) {
console.error('[WAL清理] 启动检查失败:', error.message);
}
}
/**
* 设置定期 WAL 检查点
* 每隔指定时间自动执行一次检查点,防止 WAL 文件无限增长
* @param {number} intervalHours - 间隔时间(小时),默认 24 小时
* @returns {NodeJS.Timeout} 定时器 ID可用于取消
*/
function schedulePeriodicCheckpoint(intervalHours = 24) {
const intervalMs = intervalHours * 60 * 60 * 1000;
const timerId = setInterval(() => {
const walSize = getWalFileSize();
console.log(`[WAL清理] 定期检查点执行中... (当前 WAL: ${(walSize / 1024 / 1024).toFixed(2)}MB)`);
performCheckpoint();
}, intervalMs);
console.log(`[WAL清理] ✓ 定期检查点已启用: 每 ${intervalHours} 小时执行一次`);
return timerId;
}
// 立即执行启动时检查
checkWalOnStartup(100 * 1024 * 1024); // 100MB 阈值
// 启动定期检查点24 小时)
let walCheckpointTimer = null;
if (process.env.WAL_CHECKPOINT_ENABLED !== 'false') {
const interval = parseInt(process.env.WAL_CHECKPOINT_INTERVAL_HOURS || '24', 10);
walCheckpointTimer = schedulePeriodicCheckpoint(interval);
} else {
console.log('[WAL清理] 定期检查点已禁用WAL_CHECKPOINT_ENABLED=false');
}
// 导出 WAL 管理函数
const WalManager = {
performCheckpoint,
getWalFileSize,
checkWalOnStartup,
schedulePeriodicCheckpoint
};
// 初始化数据库表
function initDatabase() {
// 用户表
db.exec(`
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT UNIQUE NOT NULL,
email TEXT UNIQUE NOT NULL,
password TEXT NOT NULL,
-- OSS配置可选
oss_provider TEXT,
oss_region TEXT,
oss_access_key_id TEXT,
oss_access_key_secret TEXT,
oss_bucket TEXT,
oss_endpoint TEXT,
-- 上传工具API密钥
upload_api_key TEXT,
-- 用户状态
is_admin INTEGER DEFAULT 0,
is_active INTEGER DEFAULT 1,
is_banned INTEGER DEFAULT 0,
has_oss_config INTEGER DEFAULT 0,
-- 时间戳
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
)
`);
// 分享链接表
db.exec(`
CREATE TABLE IF NOT EXISTS shares (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
share_code TEXT UNIQUE NOT NULL,
share_path TEXT NOT NULL,
share_type TEXT DEFAULT 'file',
share_password TEXT,
max_downloads INTEGER DEFAULT NULL,
ip_whitelist TEXT,
device_limit TEXT DEFAULT 'all',
access_time_start TEXT,
access_time_end TEXT,
-- 分享统计
view_count INTEGER DEFAULT 0,
download_count INTEGER DEFAULT 0,
-- 时间戳
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
expires_at DATETIME,
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE
)
`);
// 分享直链表(与 shares 独立,互不影响)
db.exec(`
CREATE TABLE IF NOT EXISTS direct_links (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
link_code TEXT UNIQUE NOT NULL,
file_path TEXT NOT NULL,
file_name TEXT,
storage_type TEXT DEFAULT 'oss',
-- 直链统计
download_count INTEGER DEFAULT 0,
last_accessed_at DATETIME,
-- 时间戳
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
expires_at DATETIME,
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE
)
`);
// 系统设置表
db.exec(`
CREATE TABLE IF NOT EXISTS system_settings (
key TEXT PRIMARY KEY,
value TEXT NOT NULL,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
)
`);
// 创建索引
db.exec(`
-- 基础索引
CREATE INDEX IF NOT EXISTS idx_users_username ON users(username);
CREATE INDEX IF NOT EXISTS idx_users_email ON users(email);
CREATE INDEX IF NOT EXISTS idx_users_upload_api_key ON users(upload_api_key);
CREATE INDEX IF NOT EXISTS idx_shares_code ON shares(share_code);
CREATE INDEX IF NOT EXISTS idx_shares_user ON shares(user_id);
CREATE INDEX IF NOT EXISTS idx_shares_expires ON shares(expires_at);
CREATE INDEX IF NOT EXISTS idx_direct_links_code ON direct_links(link_code);
CREATE INDEX IF NOT EXISTS idx_direct_links_user ON direct_links(user_id);
CREATE INDEX IF NOT EXISTS idx_direct_links_expires ON direct_links(expires_at);
-- ===== 性能优化复合索引P0 优先级修复) =====
-- 1. 分享链接复合索引share_code + expires_at
-- 优势:加速分享码查询(最常见的操作),同时过滤过期链接
-- 使用场景ShareDB.findByCode, 分享访问验证
CREATE INDEX IF NOT EXISTS idx_shares_code_expires ON shares(share_code, expires_at);
-- 直链复合索引link_code + expires_at
-- 使用场景DirectLinkDB.findByCode
CREATE INDEX IF NOT EXISTS idx_direct_links_code_expires ON direct_links(link_code, expires_at);
-- 注意system_logs 表的复合索引在表创建后创建第372行之后
-- 2. 活动日志复合索引user_id + created_at
-- 优势:快速查询用户最近的活动记录,支持时间范围过滤
-- 使用场景:用户活动历史、审计日志查询
-- CREATE INDEX IF NOT EXISTS idx_logs_user_created ON system_logs(user_id, created_at);
-- 3. 文件复合索引user_id + parent_path
-- 注意:当前系统使用 OSS不直接存储文件元数据到数据库
-- 如果未来需要文件系统功能,此索引将优化目录浏览性能
-- CREATE INDEX IF NOT EXISTS idx_files_user_parent ON files(user_id, parent_path);
`);
console.log('[数据库性能优化] ✓ 基础索引已创建');
console.log(' - idx_shares_code_expires: 分享码+过期时间');
console.log(' - idx_direct_links_code_expires: 直链码+过期时间');
// 数据库迁移添加upload_api_key字段如果不存在
try {
const columns = db.prepare("PRAGMA table_info(users)").all();
const hasUploadApiKey = columns.some(col => col.name === 'upload_api_key');
if (!hasUploadApiKey) {
db.exec(`ALTER TABLE users ADD COLUMN upload_api_key TEXT`);
console.log('数据库迁移添加upload_api_key字段完成');
}
} catch (error) {
console.error('数据库迁移失败:', error);
}
// 数据库迁移添加share_type字段如果不存在
try {
const shareColumns = db.prepare("PRAGMA table_info(shares)").all();
const hasShareType = shareColumns.some(col => col.name === 'share_type');
if (!hasShareType) {
db.exec(`ALTER TABLE shares ADD COLUMN share_type TEXT DEFAULT 'file'`);
console.log('数据库迁移添加share_type字段完成');
}
} catch (error) {
console.error('数据库迁移share_type失败:', error);
}
// 数据库迁移:分享高级安全策略字段
try {
const shareColumns = db.prepare("PRAGMA table_info(shares)").all();
const hasMaxDownloads = shareColumns.some(col => col.name === 'max_downloads');
const hasIpWhitelist = shareColumns.some(col => col.name === 'ip_whitelist');
const hasDeviceLimit = shareColumns.some(col => col.name === 'device_limit');
const hasAccessTimeStart = shareColumns.some(col => col.name === 'access_time_start');
const hasAccessTimeEnd = shareColumns.some(col => col.name === 'access_time_end');
if (!hasMaxDownloads) {
db.exec('ALTER TABLE shares ADD COLUMN max_downloads INTEGER DEFAULT NULL');
console.log('数据库迁移:添加 shares.max_downloads 字段完成');
}
if (!hasIpWhitelist) {
db.exec('ALTER TABLE shares ADD COLUMN ip_whitelist TEXT');
console.log('数据库迁移:添加 shares.ip_whitelist 字段完成');
}
if (!hasDeviceLimit) {
db.exec('ALTER TABLE shares ADD COLUMN device_limit TEXT DEFAULT "all"');
console.log('数据库迁移:添加 shares.device_limit 字段完成');
}
if (!hasAccessTimeStart) {
db.exec('ALTER TABLE shares ADD COLUMN access_time_start TEXT');
console.log('数据库迁移:添加 shares.access_time_start 字段完成');
}
if (!hasAccessTimeEnd) {
db.exec('ALTER TABLE shares ADD COLUMN access_time_end TEXT');
console.log('数据库迁移:添加 shares.access_time_end 字段完成');
}
} catch (error) {
console.error('数据库迁移(分享安全策略)失败:', error);
}
// 数据库迁移:邮箱验证字段
try {
const columns = db.prepare("PRAGMA table_info(users)").all();
const hasVerified = columns.some(col => col.name === 'is_verified');
const hasVerifyToken = columns.some(col => col.name === 'verification_token');
const hasVerifyExpires = columns.some(col => col.name === 'verification_expires_at');
if (!hasVerified) {
db.exec(`ALTER TABLE users ADD COLUMN is_verified INTEGER DEFAULT 0`);
}
if (!hasVerifyToken) {
db.exec(`ALTER TABLE users ADD COLUMN verification_token TEXT`);
}
if (!hasVerifyExpires) {
db.exec(`ALTER TABLE users ADD COLUMN verification_expires_at DATETIME`);
}
// 注意:不再自动将未验证用户设为已验证
// 仅修复 is_verified 为 NULL 的旧数据(添加字段前创建的用户)
// 这些用户没有 verification_token说明是在邮箱验证功能上线前注册的
db.exec(`UPDATE users SET is_verified = 1 WHERE is_verified IS NULL AND verification_token IS NULL`);
} catch (error) {
console.error('数据库迁移(邮箱验证)失败:', error);
}
// 数据库迁移密码重置Token表
try {
db.exec(`
CREATE TABLE IF NOT EXISTS password_reset_tokens (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
token TEXT UNIQUE NOT NULL,
expires_at DATETIME NOT NULL,
used INTEGER DEFAULT 0,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE
)
`);
db.exec(`CREATE INDEX IF NOT EXISTS idx_reset_tokens_token ON password_reset_tokens(token);`);
db.exec(`CREATE INDEX IF NOT EXISTS idx_reset_tokens_user ON password_reset_tokens(user_id);`);
} catch (error) {
console.error('数据库迁移密码重置Token失败:', error);
}
// 系统日志表
db.exec(`
CREATE TABLE IF NOT EXISTS system_logs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
level TEXT NOT NULL DEFAULT 'info',
category TEXT NOT NULL,
action TEXT NOT NULL,
message TEXT NOT NULL,
user_id INTEGER,
username TEXT,
ip_address TEXT,
user_agent TEXT,
details TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE SET NULL
)
`);
// 下载流量日统计表(用于用户侧报表)
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 TABLE IF NOT EXISTS user_download_traffic_reservations (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
source TEXT NOT NULL DEFAULT 'direct', -- direct/share_direct
object_key TEXT,
reserved_bytes INTEGER NOT NULL DEFAULT 0,
remaining_bytes INTEGER NOT NULL DEFAULT 0,
status TEXT NOT NULL DEFAULT 'pending', -- pending/confirmed/expired/cancelled
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
expires_at DATETIME NOT NULL,
finalized_at DATETIME,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE
)
`);
// OSS 日志已处理记录(避免重复入账)
db.exec(`
CREATE TABLE IF NOT EXISTS download_traffic_ingested_logs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
bucket TEXT NOT NULL,
log_key TEXT NOT NULL,
etag TEXT,
file_size INTEGER DEFAULT 0,
line_count INTEGER DEFAULT 0,
bytes_count INTEGER DEFAULT 0,
status TEXT NOT NULL DEFAULT 'success',
error_message TEXT,
processed_at DATETIME DEFAULT CURRENT_TIMESTAMP,
UNIQUE(bucket, log_key, etag)
)
`);
// 本地分片上传会话(断点续传)
db.exec(`
CREATE TABLE IF NOT EXISTS upload_sessions (
session_id TEXT PRIMARY KEY,
user_id INTEGER NOT NULL,
storage_type TEXT NOT NULL DEFAULT 'local',
target_path TEXT NOT NULL,
file_name TEXT NOT NULL,
file_size INTEGER NOT NULL DEFAULT 0,
chunk_size INTEGER NOT NULL DEFAULT 0,
total_chunks INTEGER NOT NULL DEFAULT 0,
uploaded_chunks TEXT NOT NULL DEFAULT '',
uploaded_bytes INTEGER NOT NULL DEFAULT 0,
temp_file_path TEXT NOT NULL,
file_hash TEXT,
status TEXT NOT NULL DEFAULT 'active', -- active/completed/expired/cancelled
expires_at DATETIME NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
completed_at DATETIME,
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE
)
`);
// 文件哈希索引(用于秒传)
db.exec(`
CREATE TABLE IF NOT EXISTS user_file_hash_index (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
storage_type TEXT NOT NULL DEFAULT 'local',
file_hash TEXT NOT NULL,
file_size INTEGER NOT NULL DEFAULT 0,
file_path TEXT NOT NULL,
object_key TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
UNIQUE(user_id, storage_type, file_hash, file_size, file_path),
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);
CREATE INDEX IF NOT EXISTS idx_logs_category ON system_logs(category);
CREATE INDEX IF NOT EXISTS idx_logs_level ON system_logs(level);
CREATE INDEX IF NOT EXISTS idx_logs_user_id ON system_logs(user_id);
-- ===== 性能优化复合索引P0 优先级修复) =====
-- 活动日志复合索引user_id + created_at
-- 优势:快速查询用户最近的活动记录,支持时间范围过滤
-- 使用场景:用户活动历史、审计日志查询
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);
-- 下载预扣索引(按用户+状态+到期时间)
CREATE INDEX IF NOT EXISTS idx_download_reservation_user_status_expires
ON user_download_traffic_reservations(user_id, status, expires_at);
-- 已处理日志索引(按处理时间回溯)
CREATE INDEX IF NOT EXISTS idx_ingested_logs_processed_at
ON download_traffic_ingested_logs(processed_at);
-- 断点上传会话索引
CREATE INDEX IF NOT EXISTS idx_upload_sessions_user_status_expires
ON upload_sessions(user_id, status, expires_at);
CREATE INDEX IF NOT EXISTS idx_upload_sessions_hash
ON upload_sessions(user_id, file_hash, file_size);
-- 秒传索引
CREATE INDEX IF NOT EXISTS idx_file_hash_index_lookup
ON user_file_hash_index(user_id, storage_type, file_hash, file_size);
CREATE INDEX IF NOT EXISTS idx_file_hash_index_path
ON user_file_hash_index(user_id, storage_type, file_path);
`);
console.log('[数据库性能优化] ✓ 日志表复合索引已创建');
console.log(' - idx_logs_user_created: 用户+创建时间');
// 数据库迁移:添加 storage_used 字段P0 性能优化)
try {
const columns = db.prepare("PRAGMA table_info(users)").all();
const hasStorageUsed = columns.some(col => col.name === 'storage_used');
if (!hasStorageUsed) {
db.exec(`ALTER TABLE users ADD COLUMN storage_used INTEGER DEFAULT 0`);
console.log('[数据库迁移] ✓ storage_used 字段已添加');
}
} catch (error) {
console.error('[数据库迁移] storage_used 字段添加失败:', error);
}
console.log('数据库初始化完成');
}
// 创建默认管理员账号
function createDefaultAdmin() {
const adminExists = db.prepare('SELECT id FROM users WHERE is_admin = 1').get();
if (!adminExists) {
// 从环境变量读取管理员账号密码,如果没有则使用默认值
const adminUsername = process.env.ADMIN_USERNAME || 'admin';
const adminPassword = process.env.ADMIN_PASSWORD || 'admin123';
const hashedPassword = bcrypt.hashSync(adminPassword, 10);
db.prepare(`
INSERT INTO users (
username, email, password,
is_admin, is_active, has_oss_config, is_verified
) VALUES (?, ?, ?, ?, ?, ?, ?)
`).run(
adminUsername,
`${adminUsername}@example.com`,
hashedPassword,
1,
1,
0, // 管理员不需要OSS配置
1 // 管理员默认已验证
);
console.log('默认管理员账号已创建');
console.log('用户名:', adminUsername);
console.log('密码: ********');
console.log('⚠️ 请登录后立即修改密码!');
}
}
// 用户相关操作
const UserDB = {
// 创建用户
create(userData) {
const hashedPassword = bcrypt.hashSync(userData.password, 10);
const hasOssConfig = userData.oss_provider && userData.oss_access_key_id && userData.oss_access_key_secret && userData.oss_bucket ? 1 : 0;
// 对验证令牌进行哈希存储(与 VerificationDB.setVerification 保持一致)
const hashedVerificationToken = userData.verification_token
? crypto.createHash('sha256').update(userData.verification_token).digest('hex')
: null;
const stmt = db.prepare(`
INSERT INTO users (
username, email, password,
oss_provider, oss_region, oss_access_key_id, oss_access_key_secret, oss_bucket, oss_endpoint,
has_oss_config,
is_verified, verification_token, verification_expires_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`);
const result = stmt.run(
userData.username,
userData.email,
hashedPassword,
userData.oss_provider || null,
userData.oss_region || null,
userData.oss_access_key_id || null,
userData.oss_access_key_secret || null,
userData.oss_bucket || null,
userData.oss_endpoint || null,
hasOssConfig,
userData.is_verified !== undefined ? userData.is_verified : 0,
hashedVerificationToken,
userData.verification_expires_at || null
);
return result.lastInsertRowid;
},
// 根据用户名查找
findByUsername(username) {
return db.prepare('SELECT * FROM users WHERE username = ?').get(username);
},
// 根据邮箱查找
findByEmail(email) {
return db.prepare('SELECT * FROM users WHERE email = ?').get(email);
},
// 根据ID查找
findById(id) {
return db.prepare('SELECT * FROM users WHERE id = ?').get(id);
},
// 验证密码
verifyPassword(plainPassword, hashedPassword) {
return bcrypt.compareSync(plainPassword, hashedPassword);
},
/**
* 字段类型验证函数
* 确保所有字段值类型符合数据库要求
* @param {string} fieldName - 字段名
* @param {*} value - 字段值
* @returns {boolean} 是否有效
* @private
*/
_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 = {
// 文本类型字段
'username': 'string',
'email': 'string',
'password': 'string',
'oss_provider': 'string',
'oss_region': 'string',
'oss_access_key_id': 'string',
'oss_access_key_secret': 'string',
'oss_bucket': 'string',
'oss_endpoint': 'string',
'upload_api_key': 'string',
'verification_token': 'string',
'verification_expires_at': 'string',
'storage_permission': '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',
// 数值类型字段
'is_admin': 'number',
'is_active': 'number',
'is_banned': 'is_banned',
'has_oss_config': 'number',
'is_verified': 'number',
'local_storage_quota': 'number',
'local_storage_used': 'number',
'oss_storage_quota': 'number',
'download_traffic_quota': 'number',
'download_traffic_used': 'number'
};
const expectedType = FIELD_TYPES[fieldName];
// 如果字段不在类型定义中,允许通过(向后兼容)
if (!expectedType) {
return true;
}
// 检查类型匹配
if (expectedType === 'string') {
if (value === null) {
return NULLABLE_STRING_FIELDS.has(fieldName);
}
return typeof value === 'string';
} else if (expectedType === 'number') {
// 允许数值或可转换为数值的字符串
return typeof value === 'number' || (typeof value === 'string' && !isNaN(Number(value)));
}
return true;
},
/**
* 验证字段映射完整性
* 确保 FIELD_MAP 中定义的所有字段都在数据库表中存在
* @returns {Object} 验证结果 { valid: boolean, missing: string[], extra: string[] }
* @private
*/
_validateFieldMapping() {
// 字段映射白名单:防止别名攻击(如 toString、valueOf 等原型方法)
const FIELD_MAP = {
// 基础字段
'username': 'username',
'email': 'email',
'password': 'password',
// OSS 配置字段
'oss_provider': 'oss_provider',
'oss_region': 'oss_region',
'oss_access_key_id': 'oss_access_key_id',
'oss_access_key_secret': 'oss_access_key_secret',
'oss_bucket': 'oss_bucket',
'oss_endpoint': 'oss_endpoint',
// API 密钥和权限字段
'upload_api_key': 'upload_api_key',
'is_admin': 'is_admin',
'is_active': 'is_active',
'is_banned': 'is_banned',
'has_oss_config': 'has_oss_config',
// 验证字段
'is_verified': 'is_verified',
'verification_token': 'verification_token',
'verification_expires_at': 'verification_expires_at',
// 存储配置字段
'storage_permission': 'storage_permission',
'current_storage_type': 'current_storage_type',
'local_storage_quota': 'local_storage_quota',
'local_storage_used': 'local_storage_used',
'oss_storage_quota': 'oss_storage_quota',
'download_traffic_quota': 'download_traffic_quota',
'download_traffic_used': 'download_traffic_used',
'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'
};
try {
// 获取数据库表的实际列信息
const columns = db.prepare("PRAGMA table_info(users)").all();
const dbFields = new Set(columns.map(col => col.name));
// 检查 FIELD_MAP 中的字段是否都在数据库中存在
const mappedFields = new Set(Object.values(FIELD_MAP));
const missingFields = [];
const extraFields = [];
for (const field of mappedFields) {
if (!dbFields.has(field)) {
missingFields.push(field);
}
}
// 检查数据库中是否有 FIELD_MAP 未定义的字段(可选)
for (const dbField of dbFields) {
if (!mappedFields.has(dbField) && !['id', 'created_at', 'updated_at'].includes(dbField)) {
extraFields.push(dbField);
}
}
const isValid = missingFields.length === 0;
if (!isValid) {
console.error(`[数据库错误] 字段映射验证失败,缺失字段: ${missingFields.join(', ')}`);
}
if (extraFields.length > 0) {
console.warn(`[数据库警告] 数据库存在 FIELD_MAP 未定义的字段: ${extraFields.join(', ')}`);
}
return { valid: isValid, missing: missingFields, extra: extraFields };
} catch (error) {
console.error(`[数据库错误] 字段映射验证失败: ${error.message}`);
return { valid: false, missing: [], extra: [], error: error.message };
}
},
// 更新用户
// 安全修复:使用字段映射白名单,防止 SQL 注入和原型污染攻击
update(id, updates) {
// 字段映射白名单:防止别名攻击(如 toString、valueOf 等原型方法)
const FIELD_MAP = {
// 基础字段
'username': 'username',
'email': 'email',
'password': 'password',
// OSS 配置字段
'oss_provider': 'oss_provider',
'oss_region': 'oss_region',
'oss_access_key_id': 'oss_access_key_id',
'oss_access_key_secret': 'oss_access_key_secret',
'oss_bucket': 'oss_bucket',
'oss_endpoint': 'oss_endpoint',
// API 密钥和权限字段
'upload_api_key': 'upload_api_key',
'is_admin': 'is_admin',
'is_active': 'is_active',
'is_banned': 'is_banned',
'has_oss_config': 'has_oss_config',
// 验证字段
'is_verified': 'is_verified',
'verification_token': 'verification_token',
'verification_expires_at': 'verification_expires_at',
// 存储配置字段
'storage_permission': 'storage_permission',
'current_storage_type': 'current_storage_type',
'local_storage_quota': 'local_storage_quota',
'local_storage_used': 'local_storage_used',
'oss_storage_quota': 'oss_storage_quota',
'download_traffic_quota': 'download_traffic_quota',
'download_traffic_used': 'download_traffic_used',
'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'
};
const fields = [];
const values = [];
const rejectedFields = []; // 记录被拒绝的字段(类型不符)
for (const [key, value] of Object.entries(updates)) {
// 安全检查 1确保是对象自身的属性防止原型污染
// 使用 Object.prototype.hasOwnProperty.call() 避免原型链污染
if (!Object.prototype.hasOwnProperty.call(updates, key)) {
console.warn(`[安全警告] 跳过非自身属性: ${key} (类型: ${typeof key})`);
continue;
}
// 安全检查 2字段名必须是字符串类型
if (typeof key !== 'string' || key.trim() === '') {
console.warn(`[安全警告] 跳过无效字段名: ${key} (类型: ${typeof key})`);
rejectedFields.push({ field: key, reason: '字段名不是有效字符串' });
continue;
}
// 安全检查 3验证字段映射防止别名攻击
const mappedField = FIELD_MAP[key];
if (!mappedField) {
console.warn(`[安全警告] 尝试更新非法字段: ${key}`);
rejectedFields.push({ field: key, reason: '字段不在白名单中' });
continue;
}
// 安全检查 4确保字段名不包含特殊字符或 SQL 关键字
// 只允许字母、数字和下划线
if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(mappedField)) {
console.warn(`[安全警告] 字段名包含非法字符: ${mappedField}`);
rejectedFields.push({ field: key, reason: '字段名包含非法字符' });
continue;
}
// 安全检查 5验证字段值类型第二轮修复
if (!this._validateFieldValue(key, value)) {
const expectedType = {
'username': 'string', 'email': 'string', 'password': 'string',
'oss_provider': 'string', 'oss_region': 'string',
'oss_access_key_id': 'string', 'oss_access_key_secret': 'string',
'oss_bucket': 'string', 'oss_endpoint': 'string',
'upload_api_key': 'string', 'verification_token': 'string',
'verification_expires_at': 'string', 'storage_permission': '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',
'has_oss_config': 'number', 'is_verified': 'number',
'local_storage_quota': 'number', 'local_storage_used': 'number',
'oss_storage_quota': 'number', 'download_traffic_quota': 'number',
'download_traffic_used': 'number'
}[key];
console.warn(`[类型检查] 字段 ${key} 值类型不符: 期望 ${expectedType}, 实际 ${typeof value}, 值: ${JSON.stringify(value)}`);
rejectedFields.push({ field: key, reason: `值类型不符 (期望: ${expectedType}, 实际: ${typeof value})` });
continue;
}
// 特殊处理密码字段(需要哈希)
if (key === 'password') {
fields.push(`${mappedField} = ?`);
values.push(bcrypt.hashSync(value, 10));
} else {
fields.push(`${mappedField} = ?`);
values.push(value);
}
}
// 记录被拒绝的字段(用于调试)
if (rejectedFields.length > 0) {
console.log(`[类型检查] 用户 ${id} 更新请求拒绝了 ${rejectedFields.length} 个字段:`, rejectedFields);
}
// 如果没有有效字段,返回空结果
if (fields.length === 0) {
console.warn(`[安全警告] 没有有效字段可更新用户ID: ${id}`);
return { changes: 0, rejectedFields };
}
// 添加 updated_at 时间戳
fields.push('updated_at = CURRENT_TIMESTAMP');
values.push(id);
// 使用参数化查询执行更新(防止 SQL 注入)
const stmt = db.prepare(`UPDATE users SET ${fields.join(', ')} WHERE id = ?`);
const result = stmt.run(...values);
// 附加被拒绝字段信息到返回结果
result.rejectedFields = rejectedFields;
return result;
},
// 获取所有用户
getAll(filters = {}) {
let query = 'SELECT * FROM users WHERE 1=1';
const params = [];
if (filters.is_admin !== undefined) {
query += ' AND is_admin = ?';
params.push(filters.is_admin);
}
if (filters.is_banned !== undefined) {
query += ' AND is_banned = ?';
params.push(filters.is_banned);
}
query += ' ORDER BY created_at DESC';
return db.prepare(query).all(...params);
},
queryAdminUsers(options = {}) {
const parsedPage = Number.parseInt(options.page, 10);
const parsedPageSize = Number.parseInt(options.pageSize, 10);
const safePage = Number.isFinite(parsedPage) && parsedPage > 0 ? parsedPage : 1;
const safePageSize = Number.isFinite(parsedPageSize)
? Math.min(200, Math.max(1, parsedPageSize))
: 20;
const keyword = typeof options.keyword === 'string' ? options.keyword.trim() : '';
const role = ['all', 'admin', 'user'].includes(options.role) ? options.role : 'all';
const status = ['all', 'active', 'banned', 'unverified', 'download_blocked'].includes(options.status)
? options.status
: 'all';
const storage = ['all', 'local', 'oss', 'local_only', 'oss_only', 'user_choice'].includes(options.storage)
? options.storage
: 'all';
const sort = [
'created_desc',
'created_asc',
'username_asc',
'username_desc',
'storage_usage_desc',
'download_usage_desc'
].includes(options.sort)
? options.sort
: 'created_desc';
const whereClauses = ['1=1'];
const params = [];
if (keyword) {
const likeKeyword = `%${keyword}%`;
whereClauses.push('(CAST(id AS TEXT) LIKE ? OR username LIKE ? OR email LIKE ?)');
params.push(likeKeyword, likeKeyword, likeKeyword);
}
if (role === 'admin') {
whereClauses.push('is_admin = 1');
} else if (role === 'user') {
whereClauses.push('is_admin = 0');
}
if (status === 'banned') {
whereClauses.push('is_banned = 1');
} else if (status === 'unverified') {
whereClauses.push('is_banned = 0 AND is_verified = 0');
} else if (status === 'download_blocked') {
whereClauses.push(`
is_banned = 0
AND is_verified = 1
AND COALESCE(download_traffic_quota, 0) >= 0
AND (
COALESCE(download_traffic_quota, 0) = 0
OR COALESCE(download_traffic_used, 0) >= COALESCE(download_traffic_quota, 0)
)
`);
} else if (status === 'active') {
whereClauses.push(`
is_banned = 0
AND is_verified = 1
AND (
COALESCE(download_traffic_quota, -1) < 0
OR (
COALESCE(download_traffic_quota, 0) > 0
AND COALESCE(download_traffic_used, 0) < COALESCE(download_traffic_quota, 0)
)
)
`);
}
if (storage === 'local' || storage === 'oss') {
whereClauses.push("COALESCE(current_storage_type, 'oss') = ?");
params.push(storage);
} else if (storage !== 'all') {
whereClauses.push("COALESCE(storage_permission, 'oss_only') = ?");
params.push(storage);
}
const defaultOssQuota = 1024 * 1024 * 1024; // 1GB
const storageUsageExpr = `
CASE
WHEN COALESCE(current_storage_type, 'oss') = 'local' THEN
CASE
WHEN COALESCE(local_storage_quota, 0) > 0
THEN (COALESCE(local_storage_used, 0) * 100.0 / COALESCE(local_storage_quota, 1))
ELSE 0
END
ELSE
CASE
WHEN COALESCE(oss_storage_quota, 0) > 0
THEN (COALESCE(storage_used, 0) * 100.0 / COALESCE(oss_storage_quota, ${defaultOssQuota}))
ELSE 0
END
END
`;
const downloadUsageExpr = `
CASE
WHEN COALESCE(download_traffic_quota, 0) < 0 THEN 0
WHEN COALESCE(download_traffic_quota, 0) = 0 THEN 100
ELSE MIN(
100,
COALESCE(download_traffic_used, 0) * 100.0 / MAX(COALESCE(download_traffic_quota, 0), 1)
)
END
`;
const orderByMap = {
created_desc: 'datetime(created_at) DESC, id DESC',
created_asc: 'datetime(created_at) ASC, id ASC',
username_asc: "LOWER(COALESCE(username, '')) ASC, id ASC",
username_desc: "LOWER(COALESCE(username, '')) DESC, id DESC",
storage_usage_desc: `${storageUsageExpr} DESC, id DESC`,
download_usage_desc: `${downloadUsageExpr} DESC, id DESC`
};
const orderBySql = orderByMap[sort] || orderByMap.created_desc;
const whereSql = whereClauses.join(' AND ');
const globalTotalRow = db.prepare('SELECT COUNT(*) AS total FROM users').get();
const globalTotal = Number(globalTotalRow?.total || 0);
const totalRow = db.prepare(`
SELECT COUNT(*) AS total
FROM users
WHERE ${whereSql}
`).get(...params);
const filteredTotal = Number(totalRow?.total || 0);
const summaryRow = db.prepare(`
SELECT
COUNT(*) AS filtered_total,
SUM(CASE WHEN is_banned = 1 THEN 1 ELSE 0 END) AS banned,
SUM(CASE WHEN is_banned = 0 AND is_verified = 0 THEN 1 ELSE 0 END) AS unverified,
SUM(
CASE
WHEN is_banned = 0
AND is_verified = 1
AND COALESCE(download_traffic_quota, 0) >= 0
AND (
COALESCE(download_traffic_quota, 0) = 0
OR COALESCE(download_traffic_used, 0) >= COALESCE(download_traffic_quota, 0)
)
THEN 1 ELSE 0
END
) AS download_blocked,
SUM(
CASE
WHEN is_banned = 0
AND is_verified = 1
AND (
COALESCE(download_traffic_quota, -1) < 0
OR (
COALESCE(download_traffic_quota, 0) > 0
AND COALESCE(download_traffic_used, 0) < COALESCE(download_traffic_quota, 0)
)
)
THEN 1 ELSE 0
END
) AS active
FROM users
WHERE ${whereSql}
`).get(...params);
const totalPages = Math.max(1, Math.ceil(filteredTotal / safePageSize));
const page = Math.min(Math.max(1, safePage), totalPages);
const offset = (page - 1) * safePageSize;
const rows = db.prepare(`
SELECT *
FROM users
WHERE ${whereSql}
ORDER BY ${orderBySql}
LIMIT ? OFFSET ?
`).all(...params, safePageSize, offset);
return {
rows,
pagination: {
page,
pageSize: safePageSize,
total: filteredTotal,
totalPages
},
summary: {
global_total: Number.isFinite(globalTotal) ? globalTotal : 0,
filtered_total: Number(summaryRow?.filtered_total || 0),
active: Number(summaryRow?.active || 0),
banned: Number(summaryRow?.banned || 0),
unverified: Number(summaryRow?.unverified || 0),
download_blocked: Number(summaryRow?.download_blocked || 0)
}
};
},
// 删除用户
delete(id) {
return db.prepare('DELETE FROM users WHERE id = ?').run(id);
},
// 封禁/解封用户
setBanStatus(id, isBanned) {
return db.prepare('UPDATE users SET is_banned = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?')
.run(isBanned ? 1 : 0, id);
}
};
// 分享链接相关操作
const ShareDB = {
// 生成随机分享码
generateShareCode(length = 8) {
const chars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
const bytes = crypto.randomBytes(length);
let code = '';
for (let i = 0; i < length; i++) {
code += chars[bytes[i] % chars.length];
}
return code;
},
// 创建分享链接
// 创建分享链接
create(userId, options = {}) {
const {
share_type = 'file',
file_path = '',
file_name = '',
password = null,
expiry_days = null,
max_downloads = null,
ip_whitelist = null,
device_limit = 'all',
access_time_start = null,
access_time_end = null
} = options;
let shareCode;
let attempts = 0;
// 尝试生成唯一的分享码
do {
shareCode = this.generateShareCode();
attempts++;
if (attempts > 10) {
shareCode = this.generateShareCode(10); // 增加长度
}
} while (this.findByCode(shareCode) && attempts < 20);
// 计算过期时间
let expiresAt = null;
if (expiry_days) {
const expireDate = new Date();
expireDate.setDate(expireDate.getDate() + parseInt(expiry_days));
// 使用本地时区时间,而不是UTC时间
// 这样前端解析时会正确显示为本地时间
const year = expireDate.getFullYear();
const month = String(expireDate.getMonth() + 1).padStart(2, '0');
const day = String(expireDate.getDate()).padStart(2, '0');
const hours = String(expireDate.getHours()).padStart(2, '0');
const minutes = String(expireDate.getMinutes()).padStart(2, '0');
const seconds = String(expireDate.getSeconds()).padStart(2, '0');
expiresAt = `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`;
}
const stmt = db.prepare(`
INSERT INTO shares (
user_id, share_code, share_path, share_type, share_password, expires_at,
max_downloads, ip_whitelist, device_limit, access_time_start, access_time_end
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`);
const hashedPassword = password ? bcrypt.hashSync(password, 10) : null;
// 修复:正确处理不同类型的分享路径
let sharePath;
if (share_type === 'file') {
// 单文件分享:使用完整文件路径
sharePath = file_path;
} else if (share_type === 'directory') {
// 文件夹分享:使用文件夹路径
sharePath = file_path;
} else {
// all类型分享根目录
sharePath = '/';
}
const result = stmt.run(
userId,
shareCode,
sharePath,
share_type,
hashedPassword,
expiresAt,
max_downloads,
ip_whitelist,
device_limit || 'all',
access_time_start,
access_time_end
);
return {
id: result.lastInsertRowid,
share_code: shareCode,
share_type: share_type,
expires_at: expiresAt,
max_downloads: max_downloads ?? null,
ip_whitelist: ip_whitelist ?? null,
device_limit: device_limit || 'all',
access_time_start: access_time_start ?? null,
access_time_end: access_time_end ?? null
};
},
// 根据分享码查找
// 增强: 检查分享者是否被封禁(被封禁用户的分享不可访问)
// ===== 性能优化P0 优先级修复):只查询必要字段,避免 N+1 查询 =====
// 不返回 oss_access_key_id / oss_access_key_secret
// share_password 仅用于服务端验证,不会透传给前端。
findByCode(shareCode) {
const result = db.prepare(`
SELECT
s.id, s.user_id, s.share_code, s.share_path, s.share_type,
s.storage_type,
s.share_password,
s.max_downloads, s.ip_whitelist, s.device_limit, s.access_time_start, s.access_time_end,
s.view_count, s.download_count, s.created_at, s.expires_at,
u.username,
-- OSS 配置(访问分享文件所需)
u.oss_provider, u.oss_region, u.oss_bucket, u.oss_endpoint,
-- 用户偏好(主题)
u.theme_preference,
-- 安全检查
u.is_banned
FROM shares s
JOIN users u ON s.user_id = u.id
WHERE s.share_code = ?
AND (s.expires_at IS NULL OR s.expires_at > datetime('now', 'localtime'))
AND u.is_banned = 0
`).get(shareCode);
return result;
},
// 根据ID查找
findById(id) {
return db.prepare('SELECT * FROM shares WHERE id = ?').get(id);
},
// 验证分享密码
verifyPassword(plainPassword, hashedPassword) {
return bcrypt.compareSync(plainPassword, hashedPassword);
},
// 获取用户的所有分享
getUserShares(userId) {
return db.prepare(`
SELECT * FROM shares
WHERE user_id = ?
ORDER BY created_at DESC
`).all(userId);
},
// 增加查看次数
incrementViewCount(shareCode) {
return db.prepare(`
UPDATE shares
SET view_count = view_count + 1
WHERE share_code = ?
`).run(shareCode);
},
// 增加下载次数
incrementDownloadCount(shareCode) {
return db.prepare(`
UPDATE shares
SET download_count = download_count + 1
WHERE share_code = ?
`).run(shareCode);
},
// 删除分享
delete(id, userId = null) {
if (userId) {
return db.prepare('DELETE FROM shares WHERE id = ? AND user_id = ?').run(id, userId);
}
return db.prepare('DELETE FROM shares WHERE id = ?').run(id);
},
// 获取所有分享(管理员)
getAll() {
return db.prepare(`
SELECT s.*, u.username
FROM shares s
JOIN users u ON s.user_id = u.id
ORDER BY s.created_at DESC
`).all();
}
};
// 分享直链相关操作(与 ShareDB 独立)
const DirectLinkDB = {
// 生成随机直链码
generateLinkCode(length = 10) {
const chars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
const bytes = crypto.randomBytes(length);
let code = '';
for (let i = 0; i < length; i++) {
code += chars[bytes[i] % chars.length];
}
return code;
},
create(userId, options = {}) {
const {
file_path = '',
file_name = '',
storage_type = 'oss',
expiry_days = null
} = options;
let linkCode;
let attempts = 0;
do {
linkCode = this.generateLinkCode();
attempts++;
if (attempts > 10) {
linkCode = this.generateLinkCode(14);
}
} while (
db.prepare('SELECT 1 FROM direct_links WHERE link_code = ?').get(linkCode)
&& attempts < 20
);
let expiresAt = null;
if (expiry_days) {
const expireDate = new Date();
expireDate.setDate(expireDate.getDate() + parseInt(expiry_days, 10));
const year = expireDate.getFullYear();
const month = String(expireDate.getMonth() + 1).padStart(2, '0');
const day = String(expireDate.getDate()).padStart(2, '0');
const hours = String(expireDate.getHours()).padStart(2, '0');
const minutes = String(expireDate.getMinutes()).padStart(2, '0');
const seconds = String(expireDate.getSeconds()).padStart(2, '0');
expiresAt = `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`;
}
const result = db.prepare(`
INSERT INTO direct_links (user_id, link_code, file_path, file_name, storage_type, expires_at)
VALUES (?, ?, ?, ?, ?, ?)
`).run(
userId,
linkCode,
file_path,
file_name || null,
storage_type || 'oss',
expiresAt
);
return {
id: result.lastInsertRowid,
link_code: linkCode,
file_path,
file_name: file_name || null,
storage_type: storage_type || 'oss',
expires_at: expiresAt
};
},
findByCode(linkCode) {
return db.prepare(`
SELECT
dl.*,
u.username,
u.is_banned
FROM direct_links dl
JOIN users u ON dl.user_id = u.id
WHERE dl.link_code = ?
AND (dl.expires_at IS NULL OR dl.expires_at > datetime('now', 'localtime'))
AND u.is_banned = 0
`).get(linkCode);
},
findById(id) {
return db.prepare('SELECT * FROM direct_links WHERE id = ?').get(id);
},
getUserLinks(userId) {
return db.prepare(`
SELECT *
FROM direct_links
WHERE user_id = ?
ORDER BY created_at DESC
`).all(userId);
},
incrementDownloadCount(linkCode) {
return db.prepare(`
UPDATE direct_links
SET download_count = download_count + 1,
last_accessed_at = CURRENT_TIMESTAMP
WHERE link_code = ?
`).run(linkCode);
},
touchAccess(linkCode) {
return db.prepare(`
UPDATE direct_links
SET last_accessed_at = CURRENT_TIMESTAMP
WHERE link_code = ?
`).run(linkCode);
},
delete(id, userId = null) {
if (userId) {
return db.prepare('DELETE FROM direct_links WHERE id = ? AND user_id = ?').run(id, userId);
}
return db.prepare('DELETE FROM direct_links WHERE id = ?').run(id);
}
};
// 系统设置管理
const SettingsDB = {
// 获取设置
get(key) {
const row = db.prepare('SELECT value FROM system_settings WHERE key = ?').get(key);
return row ? row.value : null;
},
// 设置值
set(key, value) {
db.prepare(`
INSERT INTO system_settings (key, value, updated_at)
VALUES (?, ?, CURRENT_TIMESTAMP)
ON CONFLICT(key) DO UPDATE SET
value = excluded.value,
updated_at = CURRENT_TIMESTAMP
`).run(key, value);
},
// 获取所有设置
getAll() {
return db.prepare('SELECT key, value FROM system_settings').all();
},
// ===== 统一 OSS 配置管理(管理员配置,所有用户共享) =====
/**
* 获取统一的 OSS 配置
* @returns {Object|null} OSS 配置对象,如果未配置则返回 null
*/
getUnifiedOssConfig() {
const config = {
provider: this.get('oss_provider'),
region: this.get('oss_region'),
access_key_id: this.get('oss_access_key_id'),
access_key_secret: this.get('oss_access_key_secret'),
bucket: this.get('oss_bucket'),
endpoint: this.get('oss_endpoint')
};
// 检查是否所有必需字段都已配置
if (!config.provider || !config.access_key_id || !config.access_key_secret || !config.bucket) {
return null;
}
// 安全修复:解密 OSS Access Key Secret
try {
if (config.access_key_secret) {
config.access_key_secret = decryptSecret(config.access_key_secret);
}
} catch (error) {
console.error('[安全] 解密统一 OSS 配置失败:', error.message);
return null;
}
return config;
},
/**
* 设置统一的 OSS 配置
* @param {Object} ossConfig - OSS 配置对象
* @param {string} ossConfig.provider - 服务商aliyun/tencent/aws
* @param {string} ossConfig.region - 区域
* @param {string} ossConfig.access_key_id - Access Key ID
* @param {string} ossConfig.access_key_secret - Access Key Secret
* @param {string} ossConfig.bucket - 存储桶名称
* @param {string} [ossConfig.endpoint] - 自定义 Endpoint可选
*/
setUnifiedOssConfig(ossConfig) {
this.set('oss_provider', ossConfig.provider);
this.set('oss_region', ossConfig.region);
this.set('oss_access_key_id', ossConfig.access_key_id);
// 安全修复:加密存储 OSS Access Key Secret
try {
const encryptedSecret = encryptSecret(ossConfig.access_key_secret);
this.set('oss_access_key_secret', encryptedSecret);
} catch (error) {
console.error('[安全] 加密统一 OSS 配置失败:', error.message);
throw new Error('保存 OSS 配置失败:加密错误');
}
this.set('oss_bucket', ossConfig.bucket);
this.set('oss_endpoint', ossConfig.endpoint || '');
console.log('[系统设置] 统一 OSS 配置已更新(已加密)');
},
/**
* 删除统一的 OSS 配置
*/
clearUnifiedOssConfig() {
db.prepare('DELETE FROM system_settings WHERE key LIKE "oss_%"').run();
console.log('[系统设置] 统一 OSS 配置已清除');
},
/**
* 检查是否已配置统一的 OSS
* @returns {boolean}
*/
hasUnifiedOssConfig() {
return this.getUnifiedOssConfig() !== null;
}
};
// 邮箱验证管理(增强安全:哈希存储)
const VerificationDB = {
setVerification(userId, token, expiresAtMs) {
// 对令牌进行哈希后存储
const hashedToken = crypto.createHash('sha256').update(token).digest('hex');
db.prepare(`
UPDATE users
SET verification_token = ?, verification_expires_at = ?, is_verified = 0, updated_at = CURRENT_TIMESTAMP
WHERE id = ?
`).run(hashedToken, expiresAtMs, userId);
},
consumeVerificationToken(token) {
// 对用户提供的令牌进行哈希
const hashedToken = crypto.createHash('sha256').update(token).digest('hex');
const row = db.prepare(`
SELECT * FROM users
WHERE verification_token = ?
AND (
verification_expires_at IS NULL
OR verification_expires_at = ''
OR verification_expires_at > strftime('%s','now')*1000 -- 数值时间戳ms
OR verification_expires_at > CURRENT_TIMESTAMP -- 兼容旧的字符串时间
)
AND is_verified = 0
`).get(hashedToken);
if (!row) return null;
db.prepare(`
UPDATE users
SET is_verified = 1, verification_token = NULL, verification_expires_at = NULL, updated_at = CURRENT_TIMESTAMP
WHERE id = ?
`).run(row.id);
return row;
}
};
// 密码重置 Token 管理(增强安全:哈希存储)
const PasswordResetTokenDB = {
// 创建令牌时存储哈希值
create(userId, token, expiresAtMs) {
// 对令牌进行哈希后存储(防止数据库泄露时令牌被直接使用)
const hashedToken = crypto.createHash('sha256').update(token).digest('hex');
db.prepare(`
INSERT INTO password_reset_tokens (user_id, token, expires_at, used)
VALUES (?, ?, ?, 0)
`).run(userId, hashedToken, expiresAtMs);
},
// 验证令牌时先哈希再比较
use(token) {
// 对用户提供的令牌进行哈希
const hashedToken = crypto.createHash('sha256').update(token).digest('hex');
const row = db.prepare(`
SELECT * FROM password_reset_tokens
WHERE token = ? AND used = 0 AND (
expires_at > strftime('%s','now')*1000 -- 数值时间戳
OR expires_at > CURRENT_TIMESTAMP -- 兼容旧的字符串时间
)
`).get(hashedToken);
if (!row) return null;
// 立即标记为已使用(防止重复使用)
db.prepare(`UPDATE password_reset_tokens SET used = 1 WHERE id = ?`).run(row.id);
return row;
}
};
// 初始化默认设置
function initDefaultSettings() {
// 默认上传限制为10GB
if (!SettingsDB.get('max_upload_size')) {
SettingsDB.set('max_upload_size', '10737418240'); // 10GB in bytes
}
// 默认全局主题为暗色
if (!SettingsDB.get('global_theme')) {
SettingsDB.set('global_theme', 'dark');
}
// 下载安全策略默认值同IP同文件限速5分钟3次、1小时10次、1天20次
if (!SettingsDB.get('download_security_enabled')) {
SettingsDB.set('download_security_enabled', 'true');
}
if (!SettingsDB.get('download_security_same_file_enabled')) {
SettingsDB.set('download_security_same_file_enabled', 'true');
}
if (!SettingsDB.get('download_security_same_file_limit_5m')) {
SettingsDB.set('download_security_same_file_limit_5m', '3');
}
if (!SettingsDB.get('download_security_same_file_limit_1h')) {
SettingsDB.set('download_security_same_file_limit_1h', '10');
}
if (!SettingsDB.get('download_security_same_file_limit_1d')) {
SettingsDB.set('download_security_same_file_limit_1d', '20');
}
// 扩展策略默认关闭(管理员可按需开启)
if (!SettingsDB.get('download_security_same_user_enabled')) {
SettingsDB.set('download_security_same_user_enabled', 'false');
}
if (!SettingsDB.get('download_security_same_user_limit_1h')) {
SettingsDB.set('download_security_same_user_limit_1h', '80');
}
if (!SettingsDB.get('download_security_same_user_limit_1d')) {
SettingsDB.set('download_security_same_user_limit_1d', '300');
}
if (!SettingsDB.get('download_security_same_file_min_interval_enabled')) {
SettingsDB.set('download_security_same_file_min_interval_enabled', 'false');
}
if (!SettingsDB.get('download_security_same_file_min_interval_seconds')) {
SettingsDB.set('download_security_same_file_min_interval_seconds', '2');
}
}
// 数据库迁移 - 主题偏好字段
function migrateThemePreference() {
try {
const columns = db.prepare("PRAGMA table_info(users)").all();
const hasThemePreference = columns.some(col => col.name === 'theme_preference');
if (!hasThemePreference) {
console.log('[数据库迁移] 添加主题偏好字段...');
db.exec(`ALTER TABLE users ADD COLUMN theme_preference TEXT DEFAULT NULL`);
console.log('[数据库迁移] ✓ 主题偏好字段已添加');
}
} catch (error) {
console.error('[数据库迁移] 主题偏好迁移失败:', error);
}
}
// 数据库版本迁移 - v2.0 本地存储功能
function migrateToV2() {
try {
const columns = db.prepare("PRAGMA table_info(users)").all();
const hasStoragePermission = columns.some(col => col.name === 'storage_permission');
if (!hasStoragePermission) {
console.log('[数据库迁移] 检测到旧版本数据库,开始升级到 v2.0...');
// 添加本地存储相关字段
db.exec(`
ALTER TABLE users ADD COLUMN storage_permission TEXT DEFAULT 'sftp_only';
ALTER TABLE users ADD COLUMN current_storage_type TEXT DEFAULT 'sftp';
ALTER TABLE users ADD COLUMN local_storage_quota INTEGER DEFAULT ${DEFAULT_LOCAL_STORAGE_QUOTA_BYTES};
ALTER TABLE users ADD COLUMN local_storage_used INTEGER DEFAULT 0;
ALTER TABLE users ADD COLUMN oss_storage_quota INTEGER DEFAULT ${DEFAULT_OSS_STORAGE_QUOTA_BYTES};
`);
console.log('[数据库迁移] ✓ 用户表已升级');
// 为分享表添加存储类型字段
const shareColumns = db.prepare("PRAGMA table_info(shares)").all();
const hasShareStorageType = shareColumns.some(col => col.name === 'storage_type');
if (!hasShareStorageType) {
db.exec(`ALTER TABLE shares ADD COLUMN storage_type TEXT DEFAULT 'sftp';`);
console.log('[数据库迁移] ✓ 分享表已升级');
}
console.log('[数据库迁移] ✅ 数据库升级到 v2.0 完成!本地存储功能已启用');
}
} catch (error) {
console.error('[数据库迁移] 迁移失败:', error);
throw error;
}
}
// 数据库版本迁移 - v3.0 SFTP → OSS
function migrateToOss() {
try {
const columns = db.prepare("PRAGMA table_info(users)").all();
const hasOssProvider = columns.some(col => col.name === 'oss_provider');
if (!hasOssProvider) {
console.log('[数据库迁移] 检测到 SFTP 版本,开始升级到 v3.0 OSS...');
// 添加 OSS 相关字段
db.exec(`
ALTER TABLE users ADD COLUMN oss_provider TEXT DEFAULT NULL;
ALTER TABLE users ADD COLUMN oss_region TEXT DEFAULT NULL;
ALTER TABLE users ADD COLUMN oss_access_key_id TEXT DEFAULT NULL;
ALTER TABLE users ADD COLUMN oss_access_key_secret TEXT DEFAULT NULL;
ALTER TABLE users ADD COLUMN oss_bucket TEXT DEFAULT NULL;
ALTER TABLE users ADD COLUMN oss_endpoint TEXT DEFAULT NULL;
ALTER TABLE users ADD COLUMN has_oss_config INTEGER DEFAULT 0;
`);
console.log('[数据库迁移] ✓ OSS 字段已添加');
}
// 修复:无论 OSS 字段是否刚添加,都要确保更新现有的 sftp 数据
// 检查是否有用户仍使用 sftp 类型
const sftpUsers = db.prepare("SELECT COUNT(*) as count FROM users WHERE storage_permission = 'sftp_only' OR current_storage_type = 'sftp'").get();
if (sftpUsers.count > 0) {
console.log(`[数据库迁移] 检测到 ${sftpUsers.count} 个用户仍使用 sftp 类型,正在更新...`);
// 更新存储权限枚举值sftp_only → oss_only
db.exec(`UPDATE users SET storage_permission = 'oss_only' WHERE storage_permission = 'sftp_only'`);
console.log('[数据库迁移] ✓ 存储权限枚举值已更新');
// 更新存储类型sftp → oss
db.exec(`UPDATE users SET current_storage_type = 'oss' WHERE current_storage_type = 'sftp'`);
console.log('[数据库迁移] ✓ 存储类型已更新');
// 更新分享表的存储类型
const shareColumns = db.prepare("PRAGMA table_info(shares)").all();
const hasStorageType = shareColumns.some(col => col.name === 'storage_type');
if (hasStorageType) {
db.exec(`UPDATE shares SET storage_type = 'oss' WHERE storage_type = 'sftp'`);
console.log('[数据库迁移] ✓ 分享表存储类型已更新');
}
console.log('[数据库迁移] ✅ SFTP → OSS 数据更新完成!');
}
} catch (error) {
console.error('[数据库迁移] OSS 迁移失败:', error);
// 不抛出错误,允许服务继续启动
}
}
// 数据库迁移 - OSS 存储配额字段
function migrateOssQuotaField() {
try {
const columns = db.prepare("PRAGMA table_info(users)").all();
const hasOssStorageQuota = columns.some(col => col.name === 'oss_storage_quota');
if (!hasOssStorageQuota) {
console.log('[数据库迁移] 添加 oss_storage_quota 字段...');
db.exec(`ALTER TABLE users ADD COLUMN oss_storage_quota INTEGER DEFAULT ${DEFAULT_OSS_STORAGE_QUOTA_BYTES}`);
console.log('[数据库迁移] ✓ oss_storage_quota 字段已添加');
}
// 统一策略:未配置或无效值默认 1GB
const backfillResult = db.prepare(`
UPDATE users
SET oss_storage_quota = ?
WHERE oss_storage_quota IS NULL OR oss_storage_quota <= 0
`).run(DEFAULT_OSS_STORAGE_QUOTA_BYTES);
if (backfillResult.changes > 0) {
console.log(`[数据库迁移] ✓ OSS 配额默认值已回填: ${backfillResult.changes} 条记录`);
}
} catch (error) {
console.error('[数据库迁移] oss_storage_quota 迁移失败:', error);
}
}
// 数据库迁移 - 下载流量配额字段
function migrateDownloadTrafficFields() {
try {
const columns = db.prepare("PRAGMA table_info(users)").all();
const hasDownloadTrafficQuota = columns.some(col => col.name === 'download_traffic_quota');
const hasDownloadTrafficUsed = columns.some(col => col.name === 'download_traffic_used');
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) {
console.log('[数据库迁移] 添加 download_traffic_quota 字段...');
db.exec('ALTER TABLE users ADD COLUMN download_traffic_quota INTEGER DEFAULT 0');
console.log('[数据库迁移] ✓ download_traffic_quota 字段已添加');
}
if (!hasDownloadTrafficUsed) {
console.log('[数据库迁移] 添加 download_traffic_used 字段...');
db.exec('ALTER TABLE users ADD COLUMN download_traffic_used INTEGER DEFAULT 0');
console.log('[数据库迁移] ✓ download_traffic_used 字段已添加');
}
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 为空时回填为 00 表示禁止下载,-1 表示不限流量)
const quotaBackfillResult = db.prepare(`
UPDATE users
SET download_traffic_quota = 0
WHERE download_traffic_quota IS NULL
`).run();
if (quotaBackfillResult.changes > 0) {
console.log(`[数据库迁移] ✓ 下载流量配额默认值已回填: ${quotaBackfillResult.changes} 条记录`);
}
const quotaUnlimitedNormalizeResult = db.prepare(`
UPDATE users
SET download_traffic_quota = -1
WHERE download_traffic_quota < 0
`).run();
if (quotaUnlimitedNormalizeResult.changes > 0) {
console.log(`[数据库迁移] ✓ 不限流量配额已标准化为 -1: ${quotaUnlimitedNormalizeResult.changes} 条记录`);
}
const usedBackfillResult = db.prepare(`
UPDATE users
SET download_traffic_used = 0
WHERE download_traffic_used IS NULL OR download_traffic_used < 0
`).run();
if (usedBackfillResult.changes > 0) {
console.log(`[数据库迁移] ✓ 下载流量已用值已回填: ${usedBackfillResult.changes} 条记录`);
}
const usedCapResult = db.prepare(`
UPDATE users
SET download_traffic_used = download_traffic_quota
WHERE download_traffic_quota >= 0 AND download_traffic_used > download_traffic_quota
`).run();
if (usedCapResult.changes > 0) {
console.log(`[数据库迁移] ✓ 下载流量已用值已按配额校准: ${usedCapResult.changes} 条记录`);
}
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 clearExpiryForNonPositiveQuotaResult = db.prepare(`
UPDATE users
SET download_traffic_quota_expires_at = NULL
WHERE download_traffic_quota <= 0
`).run();
if (clearExpiryForNonPositiveQuotaResult.changes > 0) {
console.log(`[数据库迁移] ✓ 非正下载配额用户已清理到期时间: ${clearExpiryForNonPositiveQuotaResult.changes} 条记录`);
}
} catch (error) {
console.error('[数据库迁移] 下载流量字段迁移失败:', error);
}
}
// 下载流量报表(按天聚合)
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 DownloadTrafficReservationDB = {
create({ userId, source = 'direct', objectKey = null, reservedBytes, expiresAt }) {
const uid = Number(userId);
const bytes = Math.floor(Number(reservedBytes));
const expiresAtValue = typeof expiresAt === 'string' ? expiresAt : null;
if (!Number.isFinite(uid) || uid <= 0 || !Number.isFinite(bytes) || bytes <= 0 || !expiresAtValue) {
return null;
}
const result = db.prepare(`
INSERT INTO user_download_traffic_reservations (
user_id, source, object_key, reserved_bytes, remaining_bytes,
status, expires_at, created_at, updated_at
) VALUES (?, ?, ?, ?, ?, 'pending', ?, datetime('now', 'localtime'), datetime('now', 'localtime'))
`).run(uid, source, objectKey, bytes, bytes, expiresAtValue);
return db.prepare('SELECT * FROM user_download_traffic_reservations WHERE id = ?').get(result.lastInsertRowid);
},
getPendingReservedBytes(userId) {
const uid = Number(userId);
if (!Number.isFinite(uid) || uid <= 0) {
return 0;
}
const row = db.prepare(`
SELECT COALESCE(SUM(remaining_bytes), 0) AS reserved
FROM user_download_traffic_reservations
WHERE user_id = ?
AND status = 'pending'
AND expires_at > datetime('now', 'localtime')
`).get(uid);
return Number(row?.reserved || 0);
},
expirePendingReservations() {
return db.prepare(`
UPDATE user_download_traffic_reservations
SET status = 'expired',
remaining_bytes = 0,
finalized_at = datetime('now', 'localtime'),
updated_at = datetime('now', 'localtime')
WHERE status = 'pending'
AND expires_at <= datetime('now', 'localtime')
`).run();
},
consumePendingBytes(userId, bytes) {
const uid = Number(userId);
let remaining = Math.floor(Number(bytes));
if (!Number.isFinite(uid) || uid <= 0 || !Number.isFinite(remaining) || remaining <= 0) {
return { consumed: 0, finalizedCount: 0 };
}
const pendingRows = db.prepare(`
SELECT id, remaining_bytes
FROM user_download_traffic_reservations
WHERE user_id = ?
AND status = 'pending'
AND remaining_bytes > 0
ORDER BY created_at ASC, id ASC
`).all(uid);
let consumed = 0;
let finalizedCount = 0;
for (const row of pendingRows) {
if (remaining <= 0) break;
const rowRemaining = Number(row.remaining_bytes || 0);
if (!Number.isFinite(rowRemaining) || rowRemaining <= 0) continue;
const useBytes = Math.min(remaining, rowRemaining);
const nextRemaining = rowRemaining - useBytes;
const nextStatus = nextRemaining <= 0 ? 'confirmed' : 'pending';
db.prepare(`
UPDATE user_download_traffic_reservations
SET remaining_bytes = ?,
status = ?,
finalized_at = CASE WHEN ? = 'confirmed' THEN datetime('now', 'localtime') ELSE finalized_at END,
updated_at = datetime('now', 'localtime')
WHERE id = ?
`).run(nextRemaining, nextStatus, nextStatus, row.id);
consumed += useBytes;
remaining -= useBytes;
if (nextStatus === 'confirmed') {
finalizedCount += 1;
}
}
return { consumed, finalizedCount };
},
getAdminList(options = {}) {
const where = ['1=1'];
const params = [];
const status = typeof options.status === 'string' ? options.status.trim() : '';
const allowedStatus = new Set(['pending', 'confirmed', 'expired', 'cancelled']);
if (allowedStatus.has(status)) {
where.push('r.status = ?');
params.push(status);
}
const uid = Number(options.userId);
if (Number.isFinite(uid) && uid > 0) {
where.push('r.user_id = ?');
params.push(Math.floor(uid));
}
const keyword = typeof options.keyword === 'string' ? options.keyword.trim() : '';
if (keyword) {
const likeValue = `%${keyword}%`;
where.push('(u.username LIKE ? OR COALESCE(r.object_key, \'\') LIKE ? OR r.source LIKE ?)');
params.push(likeValue, likeValue, likeValue);
}
const whereSql = where.join(' AND ');
const page = Math.max(1, Math.floor(Number(options.page) || 1));
const pageSize = Math.min(200, Math.max(1, Math.floor(Number(options.pageSize) || 20)));
const offset = (page - 1) * pageSize;
const totalRow = db.prepare(`
SELECT COUNT(*) AS total
FROM user_download_traffic_reservations r
LEFT JOIN users u ON u.id = r.user_id
WHERE ${whereSql}
`).get(...params);
const total = Math.max(0, Number(totalRow?.total || 0));
const totalPages = Math.max(1, Math.ceil(total / pageSize));
const safePage = Math.min(page, totalPages);
const safeOffset = (safePage - 1) * pageSize;
const rows = db.prepare(`
SELECT
r.*,
u.username
FROM user_download_traffic_reservations r
LEFT JOIN users u ON u.id = r.user_id
WHERE ${whereSql}
ORDER BY r.created_at DESC, r.id DESC
LIMIT ? OFFSET ?
`).all(...params, pageSize, safeOffset);
return {
rows,
pagination: {
page: safePage,
pageSize,
total,
totalPages
}
};
},
getAdminSummary() {
const statusRows = db.prepare(`
SELECT
status,
COUNT(*) AS count,
COALESCE(SUM(remaining_bytes), 0) AS remaining_bytes,
COALESCE(SUM(reserved_bytes), 0) AS reserved_bytes
FROM user_download_traffic_reservations
GROUP BY status
`).all();
const summary = {
total: 0,
pending: 0,
confirmed: 0,
expired: 0,
cancelled: 0,
pending_remaining_bytes: 0,
pending_reserved_bytes: 0,
pending_expiring_soon: 0
};
for (const row of statusRows) {
const status = String(row.status || '').trim();
const count = Number(row.count || 0);
const remaining = Number(row.remaining_bytes || 0);
const reserved = Number(row.reserved_bytes || 0);
summary.total += count;
if (Object.prototype.hasOwnProperty.call(summary, status)) {
summary[status] = count;
}
if (status === 'pending') {
summary.pending_remaining_bytes = Math.max(0, remaining);
summary.pending_reserved_bytes = Math.max(0, reserved);
}
}
const expiringSoonRow = db.prepare(`
SELECT COUNT(*) AS count
FROM user_download_traffic_reservations
WHERE status = 'pending'
AND expires_at <= datetime('now', 'localtime', '+5 minutes')
`).get();
summary.pending_expiring_soon = Number(expiringSoonRow?.count || 0);
return summary;
},
cancelPendingById(id) {
const rid = Number(id);
if (!Number.isFinite(rid) || rid <= 0) {
return { changes: 0, row: null };
}
const row = db.prepare(`
SELECT *
FROM user_download_traffic_reservations
WHERE id = ?
AND status = 'pending'
`).get(Math.floor(rid));
if (!row) {
return { changes: 0, row: null };
}
const result = db.prepare(`
UPDATE user_download_traffic_reservations
SET status = 'cancelled',
remaining_bytes = 0,
finalized_at = datetime('now', 'localtime'),
updated_at = datetime('now', 'localtime')
WHERE id = ?
AND status = 'pending'
`).run(Math.floor(rid));
return {
changes: Number(result?.changes || 0),
row: db.prepare('SELECT * FROM user_download_traffic_reservations WHERE id = ?').get(Math.floor(rid))
};
},
cleanupFinalizedHistory(keepDays = 0) {
// keepDays=0 表示立即清理全部已完成历史confirmed/expired/cancelled
const normalized = Number(keepDays);
const days = Number.isFinite(normalized)
? Math.min(365, Math.max(0, Math.floor(normalized)))
: 0;
return db.prepare(`
DELETE FROM user_download_traffic_reservations
WHERE status IN ('confirmed', 'expired', 'cancelled')
AND updated_at < datetime('now', 'localtime', '-' || ? || ' days')
`).run(days);
}
};
const UploadSessionDB = {
_normalizeSessionId(sessionId) {
return typeof sessionId === 'string' ? sessionId.trim() : '';
},
parseUploadedChunks(chunksText) {
const raw = typeof chunksText === 'string' ? chunksText.trim() : '';
if (!raw) return [];
const set = new Set();
for (const part of raw.split(',')) {
const idx = Number(part);
if (Number.isFinite(idx) && idx >= 0) {
set.add(Math.floor(idx));
}
}
return Array.from(set).sort((a, b) => a - b);
},
formatUploadedChunks(chunks = []) {
if (!Array.isArray(chunks) || chunks.length === 0) {
return '';
}
const set = new Set();
for (const chunk of chunks) {
const idx = Number(chunk);
if (Number.isFinite(idx) && idx >= 0) {
set.add(Math.floor(idx));
}
}
return Array.from(set).sort((a, b) => a - b).join(',');
},
create(data = {}) {
const sessionId = this._normalizeSessionId(data.sessionId);
const userId = Number(data.userId);
const storageType = typeof data.storageType === 'string' ? data.storageType : 'local';
const targetPath = typeof data.targetPath === 'string' ? data.targetPath : '';
const fileName = typeof data.fileName === 'string' ? data.fileName : '';
const fileSize = Math.max(0, Math.floor(Number(data.fileSize) || 0));
const chunkSize = Math.max(1, Math.floor(Number(data.chunkSize) || 1));
const totalChunks = Math.max(1, Math.floor(Number(data.totalChunks) || 1));
const uploadedChunks = this.formatUploadedChunks(data.uploadedChunks || []);
const uploadedBytes = Math.max(0, Math.floor(Number(data.uploadedBytes) || 0));
const tempFilePath = typeof data.tempFilePath === 'string' ? data.tempFilePath : '';
const fileHash = typeof data.fileHash === 'string' ? data.fileHash : null;
const expiresAt = typeof data.expiresAt === 'string' ? data.expiresAt : null;
if (!sessionId || !Number.isFinite(userId) || userId <= 0 || !targetPath || !fileName || !tempFilePath || !expiresAt) {
return null;
}
db.prepare(`
INSERT INTO upload_sessions (
session_id, user_id, storage_type, target_path, file_name,
file_size, chunk_size, total_chunks, uploaded_chunks, uploaded_bytes,
temp_file_path, file_hash, status, expires_at, created_at, updated_at
) VALUES (
?, ?, ?, ?, ?,
?, ?, ?, ?, ?,
?, ?, 'active', ?, datetime('now', 'localtime'), datetime('now', 'localtime')
)
ON CONFLICT(session_id) DO UPDATE SET
storage_type = excluded.storage_type,
target_path = excluded.target_path,
file_name = excluded.file_name,
file_size = excluded.file_size,
chunk_size = excluded.chunk_size,
total_chunks = excluded.total_chunks,
uploaded_chunks = excluded.uploaded_chunks,
uploaded_bytes = excluded.uploaded_bytes,
temp_file_path = excluded.temp_file_path,
file_hash = excluded.file_hash,
status = 'active',
expires_at = excluded.expires_at,
completed_at = NULL,
updated_at = datetime('now', 'localtime')
`).run(
sessionId,
Math.floor(userId),
storageType,
targetPath,
fileName,
fileSize,
chunkSize,
totalChunks,
uploadedChunks,
uploadedBytes,
tempFilePath,
fileHash,
expiresAt
);
return this.findBySessionId(sessionId);
},
findBySessionId(sessionId) {
const sid = this._normalizeSessionId(sessionId);
if (!sid) return null;
return db.prepare('SELECT * FROM upload_sessions WHERE session_id = ?').get(sid);
},
findActiveForResume(userId, targetPath, fileSize, fileHash = null) {
const uid = Number(userId);
const safePath = typeof targetPath === 'string' ? targetPath : '';
const size = Math.floor(Number(fileSize) || 0);
const hash = typeof fileHash === 'string' ? fileHash.trim() : '';
if (!Number.isFinite(uid) || uid <= 0 || !safePath || size <= 0) {
return null;
}
if (hash) {
return db.prepare(`
SELECT *
FROM upload_sessions
WHERE user_id = ?
AND target_path = ?
AND file_size = ?
AND status = 'active'
AND expires_at > datetime('now', 'localtime')
AND COALESCE(file_hash, '') = ?
ORDER BY updated_at DESC, created_at DESC
LIMIT 1
`).get(Math.floor(uid), safePath, size, hash);
}
return db.prepare(`
SELECT *
FROM upload_sessions
WHERE user_id = ?
AND target_path = ?
AND file_size = ?
AND status = 'active'
AND expires_at > datetime('now', 'localtime')
ORDER BY updated_at DESC, created_at DESC
LIMIT 1
`).get(Math.floor(uid), safePath, size);
},
updateProgress(sessionId, uploadedChunks = [], uploadedBytes = 0, expiresAt = null) {
const sid = this._normalizeSessionId(sessionId);
if (!sid) return { changes: 0 };
const chunksText = this.formatUploadedChunks(uploadedChunks);
const bytes = Math.max(0, Math.floor(Number(uploadedBytes) || 0));
if (typeof expiresAt === 'string' && expiresAt) {
return db.prepare(`
UPDATE upload_sessions
SET uploaded_chunks = ?,
uploaded_bytes = ?,
expires_at = ?,
updated_at = datetime('now', 'localtime')
WHERE session_id = ?
`).run(chunksText, bytes, expiresAt, sid);
}
return db.prepare(`
UPDATE upload_sessions
SET uploaded_chunks = ?,
uploaded_bytes = ?,
updated_at = datetime('now', 'localtime')
WHERE session_id = ?
`).run(chunksText, bytes, sid);
},
setStatus(sessionId, status, { completed = false, expiresAt = null } = {}) {
const sid = this._normalizeSessionId(sessionId);
const safeStatus = typeof status === 'string' ? status : '';
if (!sid || !safeStatus) return { changes: 0 };
if (expiresAt && completed) {
return db.prepare(`
UPDATE upload_sessions
SET status = ?,
expires_at = ?,
completed_at = datetime('now', 'localtime'),
updated_at = datetime('now', 'localtime')
WHERE session_id = ?
`).run(safeStatus, expiresAt, sid);
}
if (expiresAt) {
return db.prepare(`
UPDATE upload_sessions
SET status = ?,
expires_at = ?,
completed_at = CASE WHEN ? = 1 THEN datetime('now', 'localtime') ELSE completed_at END,
updated_at = datetime('now', 'localtime')
WHERE session_id = ?
`).run(safeStatus, expiresAt, completed ? 1 : 0, sid);
}
return db.prepare(`
UPDATE upload_sessions
SET status = ?,
completed_at = CASE WHEN ? = 1 THEN datetime('now', 'localtime') ELSE completed_at END,
updated_at = datetime('now', 'localtime')
WHERE session_id = ?
`).run(safeStatus, completed ? 1 : 0, sid);
},
delete(sessionId) {
const sid = this._normalizeSessionId(sessionId);
if (!sid) return { changes: 0 };
return db.prepare('DELETE FROM upload_sessions WHERE session_id = ?').run(sid);
},
listExpiredActive(limit = 100) {
const safeLimit = Math.min(500, Math.max(1, Math.floor(Number(limit) || 100)));
return db.prepare(`
SELECT *
FROM upload_sessions
WHERE status = 'active'
AND expires_at <= datetime('now', 'localtime')
ORDER BY expires_at ASC
LIMIT ?
`).all(safeLimit);
},
expireSession(sessionId) {
const sid = this._normalizeSessionId(sessionId);
if (!sid) return { changes: 0 };
return db.prepare(`
UPDATE upload_sessions
SET status = 'expired',
updated_at = datetime('now', 'localtime')
WHERE session_id = ?
AND status = 'active'
`).run(sid);
}
};
const FileHashIndexDB = {
normalizeStorageType(storageType) {
return storageType === 'oss' ? 'oss' : 'local';
},
upsert(entry = {}) {
const userId = Number(entry.userId);
const storageType = this.normalizeStorageType(entry.storageType);
const fileHash = typeof entry.fileHash === 'string' ? entry.fileHash.trim() : '';
const fileSize = Math.max(0, Math.floor(Number(entry.fileSize) || 0));
const filePath = typeof entry.filePath === 'string' ? entry.filePath : '';
const objectKey = typeof entry.objectKey === 'string' && entry.objectKey ? entry.objectKey : null;
if (!Number.isFinite(userId) || userId <= 0 || !fileHash || fileSize <= 0 || !filePath) {
return null;
}
return db.prepare(`
INSERT INTO user_file_hash_index (
user_id, storage_type, file_hash, file_size, file_path, object_key, created_at, updated_at
) VALUES (?, ?, ?, ?, ?, ?, datetime('now', 'localtime'), datetime('now', 'localtime'))
ON CONFLICT(user_id, storage_type, file_hash, file_size, file_path)
DO UPDATE SET
object_key = excluded.object_key,
updated_at = datetime('now', 'localtime')
`).run(
Math.floor(userId),
storageType,
fileHash,
fileSize,
filePath,
objectKey
);
},
findLatestByHash(userId, storageType, fileHash, fileSize) {
const uid = Number(userId);
const normalizedStorage = this.normalizeStorageType(storageType);
const hash = typeof fileHash === 'string' ? fileHash.trim() : '';
const size = Math.max(0, Math.floor(Number(fileSize) || 0));
if (!Number.isFinite(uid) || uid <= 0 || !hash || size <= 0) {
return null;
}
return db.prepare(`
SELECT *
FROM user_file_hash_index
WHERE user_id = ?
AND storage_type = ?
AND file_hash = ?
AND file_size = ?
ORDER BY updated_at DESC, id DESC
LIMIT 1
`).get(Math.floor(uid), normalizedStorage, hash, size);
},
deleteByPath(userId, storageType, filePath) {
const uid = Number(userId);
const normalizedStorage = this.normalizeStorageType(storageType);
const safePath = typeof filePath === 'string' ? filePath : '';
if (!Number.isFinite(uid) || uid <= 0 || !safePath) {
return { changes: 0 };
}
return db.prepare(`
DELETE FROM user_file_hash_index
WHERE user_id = ?
AND storage_type = ?
AND file_path = ?
`).run(Math.floor(uid), normalizedStorage, safePath);
},
getByPath(userId, storageType, filePath) {
const uid = Number(userId);
const normalizedStorage = this.normalizeStorageType(storageType);
const safePath = typeof filePath === 'string' ? filePath : '';
if (!Number.isFinite(uid) || uid <= 0 || !safePath) {
return null;
}
return db.prepare(`
SELECT *
FROM user_file_hash_index
WHERE user_id = ?
AND storage_type = ?
AND file_path = ?
ORDER BY updated_at DESC, id DESC
LIMIT 1
`).get(Math.floor(uid), normalizedStorage, safePath);
}
};
const DownloadTrafficIngestDB = {
isProcessed(bucket, logKey, etag = null) {
const row = db.prepare(`
SELECT id
FROM download_traffic_ingested_logs
WHERE bucket = ?
AND log_key = ?
AND COALESCE(etag, '') = COALESCE(?, '')
LIMIT 1
`).get(bucket, logKey, etag);
return !!row;
},
markProcessed({
bucket,
logKey,
etag = null,
fileSize = 0,
lineCount = 0,
bytesCount = 0,
status = 'success',
errorMessage = null
}) {
return db.prepare(`
INSERT INTO download_traffic_ingested_logs (
bucket, log_key, etag, file_size, line_count, bytes_count,
status, error_message, processed_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, datetime('now', 'localtime'))
ON CONFLICT(bucket, log_key, etag)
DO UPDATE SET
file_size = excluded.file_size,
line_count = excluded.line_count,
bytes_count = excluded.bytes_count,
status = excluded.status,
error_message = excluded.error_message,
processed_at = datetime('now', 'localtime')
`).run(
bucket,
logKey,
etag || '',
Number(fileSize) || 0,
Number(lineCount) || 0,
Number(bytesCount) || 0,
status || 'success',
errorMessage || null
);
}
};
// 系统日志操作
const SystemLogDB = {
// 日志级别常量
LEVELS: {
DEBUG: 'debug',
INFO: 'info',
WARN: 'warn',
ERROR: 'error'
},
// 日志分类常量
CATEGORIES: {
AUTH: 'auth', // 认证相关(登录、登出、注册)
USER: 'user', // 用户管理(创建、修改、删除、封禁)
FILE: 'file', // 文件操作(上传、下载、删除、重命名)
SHARE: 'share', // 分享操作(创建、删除、访问)
SYSTEM: 'system', // 系统操作(设置修改、服务启动)
SECURITY: 'security' // 安全事件(登录失败、暴力破解、异常访问)
},
// 写入日志
log({ level = 'info', category, action, message, userId = null, username = null, ipAddress = null, userAgent = null, details = null }) {
try {
const stmt = db.prepare(`
INSERT INTO system_logs (level, category, action, message, user_id, username, ip_address, user_agent, details, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, datetime('now', 'localtime'))
`);
const detailsStr = details ? (typeof details === 'string' ? details : JSON.stringify(details)) : null;
stmt.run(level, category, action, message, userId, username, ipAddress, userAgent, detailsStr);
} catch (error) {
console.error('写入日志失败:', error);
}
},
// 查询日志(支持分页和筛选)
query({ page = 1, pageSize = 50, level = null, category = null, userId = null, startDate = null, endDate = null, keyword = null }) {
let sql = 'SELECT * FROM system_logs WHERE 1=1';
let countSql = 'SELECT COUNT(*) as total FROM system_logs WHERE 1=1';
const params = [];
if (level) {
sql += ' AND level = ?';
countSql += ' AND level = ?';
params.push(level);
}
if (category) {
sql += ' AND category = ?';
countSql += ' AND category = ?';
params.push(category);
}
if (userId) {
sql += ' AND user_id = ?';
countSql += ' AND user_id = ?';
params.push(userId);
}
if (startDate) {
sql += ' AND created_at >= ?';
countSql += ' AND created_at >= ?';
params.push(startDate);
}
if (endDate) {
sql += ' AND created_at <= ?';
countSql += ' AND created_at <= ?';
params.push(endDate);
}
if (keyword) {
sql += ' AND (message LIKE ? OR username LIKE ? OR action LIKE ?)';
countSql += ' AND (message LIKE ? OR username LIKE ? OR action LIKE ?)';
const kw = `%${keyword}%`;
params.push(kw, kw, kw);
}
// 获取总数
const totalResult = db.prepare(countSql).get(...params);
const total = totalResult ? totalResult.total : 0;
// 分页查询
sql += ' ORDER BY created_at DESC LIMIT ? OFFSET ?';
const offset = (page - 1) * pageSize;
const logs = db.prepare(sql).all(...params, pageSize, offset);
return {
logs,
total,
page,
pageSize,
totalPages: Math.ceil(total / pageSize)
};
},
// 获取最近的日志
getRecent(limit = 100) {
return db.prepare('SELECT * FROM system_logs ORDER BY created_at DESC LIMIT ?').all(limit);
},
// 按分类统计
getStatsByCategory() {
return db.prepare(`
SELECT category, COUNT(*) as count
FROM system_logs
GROUP BY category
ORDER BY count DESC
`).all();
},
// 按日期统计最近7天
getStatsByDate(days = 7) {
return db.prepare(`
SELECT DATE(created_at) as date, COUNT(*) as count
FROM system_logs
WHERE created_at >= datetime('now', 'localtime', '-' || ? || ' days')
GROUP BY DATE(created_at)
ORDER BY date DESC
`).all(days);
},
// 清理旧日志(保留指定天数)
cleanup(keepDays = 90) {
const result = db.prepare(`
DELETE FROM system_logs
WHERE created_at < datetime('now', 'localtime', '-' || ? || ' days')
`).run(keepDays);
return result.changes;
}
};
// 事务工具函数
const TransactionDB = {
/**
* 在事务中执行操作
* @param {Function} fn - 要执行的函数,接收 db 作为参数
* @returns {*} 函数返回值
* @throws {Error} 如果事务失败则抛出错误
*/
run(fn) {
const transaction = db.transaction((callback) => {
return callback(db);
});
return transaction(fn);
},
/**
* 删除用户及其所有相关数据(使用事务)
* @param {number} userId - 用户ID
* @returns {object} 删除结果
*/
deleteUserWithData(userId) {
return this.run(() => {
// 1. 删除用户的所有分享
const sharesDeleted = db.prepare('DELETE FROM shares WHERE user_id = ?').run(userId);
// 2. 删除用户的所有直链
const directLinksDeleted = db.prepare('DELETE FROM direct_links WHERE user_id = ?').run(userId);
// 3. 删除密码重置令牌
const tokensDeleted = db.prepare('DELETE FROM password_reset_tokens WHERE user_id = ?').run(userId);
// 4. 更新日志中的用户引用(设为 NULL保留日志记录
db.prepare('UPDATE system_logs SET user_id = NULL WHERE user_id = ?').run(userId);
// 5. 删除用户记录
const userDeleted = db.prepare('DELETE FROM users WHERE id = ?').run(userId);
return {
sharesDeleted: sharesDeleted.changes,
directLinksDeleted: directLinksDeleted.changes,
tokensDeleted: tokensDeleted.changes,
userDeleted: userDeleted.changes
};
});
}
};
// 初始化数据库
initDatabase();
createDefaultAdmin();
initDefaultSettings();
migrateToV2(); // 执行数据库迁移
migrateThemePreference(); // 主题偏好迁移
migrateToOss(); // SFTP → OSS 迁移
migrateOssQuotaField(); // OSS 配额字段迁移
migrateDownloadTrafficFields(); // 下载流量字段迁移
module.exports = {
db,
UserDB,
ShareDB,
DirectLinkDB,
SettingsDB,
VerificationDB,
PasswordResetTokenDB,
DownloadTrafficReportDB,
DownloadTrafficReservationDB,
UploadSessionDB,
FileHashIndexDB,
DownloadTrafficIngestDB,
SystemLogDB,
TransactionDB,
WalManager
};