2292 lines
76 KiB
JavaScript
2292 lines
76 KiB
JavaScript
// 加载环境变量(确保在 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,
|
||
|
||
-- 分享统计
|
||
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 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 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);
|
||
`);
|
||
|
||
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
|
||
} = 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)
|
||
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
|
||
);
|
||
|
||
return {
|
||
id: result.lastInsertRowid,
|
||
share_code: shareCode,
|
||
share_type: share_type,
|
||
expires_at: expiresAt,
|
||
};
|
||
},
|
||
|
||
// 根据分享码查找
|
||
// 增强: 检查分享者是否被封禁(被封禁用户的分享不可访问)
|
||
// ===== 性能优化(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.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');
|
||
}
|
||
}
|
||
|
||
// 数据库迁移 - 主题偏好字段
|
||
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 为空时回填为 0(0 表示禁止下载,-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 };
|
||
}
|
||
};
|
||
|
||
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,
|
||
DownloadTrafficIngestDB,
|
||
SystemLogDB,
|
||
TransactionDB,
|
||
WalManager
|
||
};
|