- 在editStorageForm中初始化oss_storage_quota_value和oss_quota_unit - 删除重复的旧配额说明块,保留新的当前配额设置显示 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1465 lines
48 KiB
JavaScript
1465 lines
48 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);
|
||
|
||
// ===== 性能优化配置(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
|
||
)
|
||
`);
|
||
|
||
// 系统设置表
|
||
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);
|
||
|
||
-- ===== 性能优化:复合索引(P0 优先级修复) =====
|
||
|
||
-- 1. 分享链接复合索引:share_code + expires_at
|
||
-- 优势:加速分享码查询(最常见的操作),同时过滤过期链接
|
||
-- 使用场景:ShareDB.findByCode, 分享访问验证
|
||
CREATE INDEX IF NOT EXISTS idx_shares_code_expires ON shares(share_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: 分享码+过期时间');
|
||
|
||
// 数据库迁移:添加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 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);
|
||
`);
|
||
|
||
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 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',
|
||
'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'
|
||
};
|
||
|
||
const expectedType = FIELD_TYPES[fieldName];
|
||
|
||
// 如果字段不在类型定义中,允许通过(向后兼容)
|
||
if (!expectedType) {
|
||
return true;
|
||
}
|
||
|
||
// 检查类型匹配
|
||
if (expectedType === 'string') {
|
||
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',
|
||
|
||
// 偏好设置
|
||
'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',
|
||
|
||
// 偏好设置
|
||
'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',
|
||
'is_admin': 'number', 'is_active': 'number', 'is_banned': 'number',
|
||
'has_oss_config': 'number', 'is_verified': 'number',
|
||
'local_storage_quota': 'number', 'local_storage_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);
|
||
},
|
||
|
||
// 删除用户
|
||
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(不需要传递给分享访问者)
|
||
findByCode(shareCode) {
|
||
const result = db.prepare(`
|
||
SELECT
|
||
s.id, s.user_id, s.share_code, s.share_path, s.share_type,
|
||
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();
|
||
}
|
||
};
|
||
|
||
// 系统设置管理
|
||
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 1073741824;
|
||
ALTER TABLE users ADD COLUMN local_storage_used INTEGER DEFAULT 0;
|
||
`);
|
||
|
||
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 migrateAddOssQuota() {
|
||
try {
|
||
const columns = db.prepare("PRAGMA table_info(users)").all();
|
||
const hasOssQuota = columns.some(col => col.name === 'oss_storage_quota');
|
||
|
||
if (!hasOssQuota) {
|
||
console.log('[数据库迁移] 添加 OSS 配额字段...');
|
||
db.exec();
|
||
console.log('[数据库迁移] ✅ OSS 配额字段已添加 (默认 0 表示无限制)');
|
||
}
|
||
} catch (error) {
|
||
console.error('[数据库迁移] OSS 配额迁移失败:', error);
|
||
}
|
||
}
|
||
|
||
// 系统日志操作
|
||
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 tokensDeleted = db.prepare('DELETE FROM password_reset_tokens WHERE user_id = ?').run(userId);
|
||
|
||
// 3. 更新日志中的用户引用(设为 NULL,保留日志记录)
|
||
db.prepare('UPDATE system_logs SET user_id = NULL WHERE user_id = ?').run(userId);
|
||
|
||
// 4. 删除用户记录
|
||
const userDeleted = db.prepare('DELETE FROM users WHERE id = ?').run(userId);
|
||
|
||
return {
|
||
sharesDeleted: sharesDeleted.changes,
|
||
tokensDeleted: tokensDeleted.changes,
|
||
userDeleted: userDeleted.changes
|
||
};
|
||
});
|
||
}
|
||
};
|
||
|
||
// 初始化数据库
|
||
initDatabase();
|
||
createDefaultAdmin();
|
||
initDefaultSettings();
|
||
migrateToV2(); // 执行数据库迁移
|
||
migrateThemePreference(); // 主题偏好迁移
|
||
migrateToOss(); // SFTP → OSS 迁移
|
||
migrateAddOssQuota(); // 添加 OSS 配额字段
|
||
|
||
module.exports = {
|
||
db,
|
||
UserDB,
|
||
ShareDB,
|
||
SettingsDB,
|
||
VerificationDB,
|
||
PasswordResetTokenDB,
|
||
SystemLogDB,
|
||
TransactionDB,
|
||
WalManager
|
||
};
|