import Database from 'better-sqlite3'; import path from 'path'; import bcrypt from 'bcryptjs'; import config from '../config'; import { formatLocalDateTime } from '../utils/time'; let db: Database.Database | null = null; export function getDb(): Database.Database { if (db) return db; const dbDir = path.dirname(config.dbPath); const fs = require('fs'); if (!fs.existsSync(dbDir)) { fs.mkdirSync(dbDir, { recursive: true }); } db = new Database(config.dbPath); db.pragma('journal_mode = WAL'); db.pragma('foreign_keys = ON'); runMigrations(db); seedAdmin(db); return db; } function runMigrations(db: Database.Database): void { db.exec(` CREATE TABLE IF NOT EXISTS admins ( id INTEGER PRIMARY KEY AUTOINCREMENT, username TEXT UNIQUE NOT NULL, password_hash TEXT NOT NULL, created_at TEXT NOT NULL DEFAULT (datetime('now', 'localtime')), last_login TEXT ); CREATE TABLE IF NOT EXISTS cloud_configs ( id INTEGER PRIMARY KEY AUTOINCREMENT, cloud_type TEXT NOT NULL, cookie TEXT, nickname TEXT, is_active INTEGER NOT NULL DEFAULT 1, storage_used TEXT, storage_total TEXT, checkin_status TEXT NOT NULL DEFAULT 'none', last_checkin_at TEXT, checkin_message TEXT, consecutive_failures INTEGER DEFAULT 0, last_used_at TEXT, total_saves INTEGER DEFAULT 0, created_at TEXT NOT NULL DEFAULT (datetime('now', 'localtime')), updated_at TEXT NOT NULL DEFAULT (datetime('now', 'localtime')) ); CREATE TABLE IF NOT EXISTS promotions ( id INTEGER PRIMARY KEY AUTOINCREMENT, title TEXT NOT NULL, description TEXT, image_url TEXT, link_url TEXT, position TEXT, sort_order INTEGER NOT NULL DEFAULT 0, active INTEGER NOT NULL DEFAULT 1, click_count INTEGER NOT NULL DEFAULT 0, start_time TEXT, end_time TEXT, created_at TEXT NOT NULL DEFAULT (datetime('now', 'localtime')), updated_at TEXT NOT NULL DEFAULT (datetime('now', 'localtime')) ); CREATE TABLE IF NOT EXISTS save_records ( id INTEGER PRIMARY KEY AUTOINCREMENT, source_type TEXT, source_title TEXT, source_url TEXT, target_cloud TEXT, share_url TEXT, share_pwd TEXT, file_size TEXT, file_count INTEGER DEFAULT 0, duration_ms INTEGER DEFAULT 0, status TEXT NOT NULL DEFAULT '', error_message TEXT, ip_address TEXT, created_at TEXT NOT NULL DEFAULT (datetime('now', 'localtime')) ); CREATE TABLE IF NOT EXISTS search_stats ( id INTEGER PRIMARY KEY AUTOINCREMENT, keyword TEXT, intent TEXT, result_count INTEGER DEFAULT 0, ip_address TEXT, created_at TEXT NOT NULL DEFAULT (datetime('now', 'localtime')) ); CREATE TABLE IF NOT EXISTS hot_keywords ( id INTEGER PRIMARY KEY AUTOINCREMENT, keyword TEXT UNIQUE NOT NULL, search_count INTEGER NOT NULL DEFAULT 1, updated_at TEXT NOT NULL DEFAULT (datetime('now', 'localtime')) ); CREATE TABLE IF NOT EXISTS system_configs ( key TEXT PRIMARY KEY, value TEXT NOT NULL DEFAULT '', description TEXT, updated_at TEXT NOT NULL DEFAULT (datetime('now', 'localtime')) ); CREATE TABLE IF NOT EXISTS content_cache ( id INTEGER PRIMARY KEY AUTOINCREMENT, keyword TEXT UNIQUE NOT NULL, title TEXT, description TEXT, tags TEXT, cover TEXT, source TEXT, updated_at TEXT NOT NULL DEFAULT (datetime('now', 'localtime')) ); `); seedSystemConfigs(db); migrateSaveRecords(db); migrateContentCache(db); migrateCloudConfigs(db); cleanupOldSaveRecords(db); } /** 迁移: 给已有 save_records 表补充新列 */ function migrateSaveRecords(db: Database.Database): void { const newCols: { col: string; def: string }[] = [ { col: 'share_pwd', def: 'TEXT' }, { col: 'file_count', def: 'INTEGER DEFAULT 0' }, { col: 'folder_count', def: 'INTEGER DEFAULT 0' }, { col: 'duration_ms', def: 'INTEGER DEFAULT 0' }, { col: 'status', def: "TEXT NOT NULL DEFAULT ''" }, { col: 'error_message', def: 'TEXT' }, { col: 'folder_name', def: 'TEXT' }, { col: 'request_url', def: 'TEXT' }, { col: 'ip_location', def: 'TEXT' }, { col: 'original_folder_name', def: 'TEXT' }, ]; for (const { col, def } of newCols) { try { db.exec(`ALTER TABLE save_records ADD COLUMN ${col} ${def}`); } catch { // Column already exists — ignore } } } /** 迁移: 给 content_cache 表加 douban_url 列 */ function migrateContentCache(db: Database.Database): void { const columns: { col: string; def: string }[] = [ { col: 'douban_url', def: 'TEXT' }, { col: 'rating', def: 'TEXT' }, { col: 'rating_count', def: 'TEXT' }, { col: 'year', def: 'TEXT' }, { col: 'genres', def: 'TEXT' }, { col: 'directors', def: 'TEXT' }, { col: 'actors', def: 'TEXT' }, { col: 'region', def: 'TEXT' }, { col: 'duration', def: 'TEXT' }, ]; for (const { col, def } of columns) { try { db.exec(`ALTER TABLE content_cache ADD COLUMN ${col} ${def}`); } catch { // Column already exists — ignore } } // 修复旧记录:source 为 NULL 但实际有 TMDB 数据的,标记为 tmdb db.exec(`UPDATE content_cache SET source = 'tmdb' WHERE source IS NULL AND title IS NOT NULL AND title != ''`); } /** 迁移: 给 cloud_configs 表去UNIQUE约束 + 加签到/轮训字段 */ function migrateCloudConfigs(db: Database.Database): void { // 加新列 const newCols: { col: string; def: string }[] = [ { col: 'checkin_status', def: "TEXT NOT NULL DEFAULT 'none'" }, { col: 'last_checkin_at', def: 'TEXT' }, { col: 'checkin_message', def: 'TEXT' }, { col: 'consecutive_failures', def: 'INTEGER DEFAULT 0' }, { col: 'last_used_at', def: 'TEXT' }, { col: 'total_saves', def: 'INTEGER DEFAULT 0' }, ]; for (const { col, def } of newCols) { try { db.exec(`ALTER TABLE cloud_configs ADD COLUMN ${col} ${def}`); } catch {} } // 检查旧表是否有 UNIQUE 约束,有则重建表 const row = db.prepare(`SELECT sql FROM sqlite_master WHERE type='table' AND name='cloud_configs'`).get() as any; if (row && row.sql && row.sql.includes('cloud_type TEXT UNIQUE')) { db.exec(` CREATE TABLE IF NOT EXISTS cloud_configs_v2 ( id INTEGER PRIMARY KEY AUTOINCREMENT, cloud_type TEXT NOT NULL, cookie TEXT, nickname TEXT, is_active INTEGER NOT NULL DEFAULT 1, storage_used TEXT, storage_total TEXT, checkin_status TEXT NOT NULL DEFAULT 'none', last_checkin_at TEXT, checkin_message TEXT, consecutive_failures INTEGER DEFAULT 0, last_used_at TEXT, total_saves INTEGER DEFAULT 0, created_at TEXT NOT NULL DEFAULT (datetime('now', 'localtime')), updated_at TEXT NOT NULL DEFAULT (datetime('now', 'localtime')) ); INSERT INTO cloud_configs_v2 (id, cloud_type, cookie, nickname, is_active, storage_used, storage_total, checkin_status, last_checkin_at, checkin_message, consecutive_failures, last_used_at, total_saves, created_at, updated_at) SELECT id, cloud_type, cookie, nickname, is_active, storage_used, storage_total, COALESCE(checkin_status,'none'), last_checkin_at, checkin_message, COALESCE(consecutive_failures,0), last_used_at, COALESCE(total_saves,0), created_at, updated_at FROM cloud_configs; DROP TABLE cloud_configs; ALTER TABLE cloud_configs_v2 RENAME TO cloud_configs; `); console.log('[DB] cloud_configs migration: UNIQUE constraint removed, new fields added'); } // Migration 2: Add verification_status column const row2 = db.prepare("SELECT sql FROM sqlite_master WHERE name='cloud_configs' AND sql LIKE '%verification_status%'").get(); if (!row2) { db.exec("ALTER TABLE cloud_configs ADD COLUMN verification_status TEXT DEFAULT NULL"); console.log('[DB] cloud_configs migration: verification_status column added'); } } function seedAdmin(db: Database.Database): void { const existing = db.prepare('SELECT id FROM admins WHERE username = ?').get(config.adminUsername); if (existing) return; const salt = bcrypt.genSaltSync(10); const hash = bcrypt.hashSync(config.adminPassword, salt); db.prepare( 'INSERT INTO admins (username, password_hash) VALUES (?, ?)' ).run(config.adminUsername, hash); console.log(`[DB] Admin user "${config.adminUsername}" created`); } function seedSystemConfigs(db: Database.Database): void { const defaults: { key: string; value: string; description: string }[] = [ { key: 'pansou_url', value: config.pansouUrl, description: 'PanSou 搜索引擎服务地址' }, { key: 'video_parser_url', value: config.videoParserUrl, description: '视频解析服务地址' }, { key: 'validation_concurrency', value: String(config.validation.concurrency), description: '链接验证并发数' }, { key: 'validation_timeout', value: String(config.validation.timeout), description: '链接验证超时(ms)' }, { key: 'validation_cache_ttl_valid', value: String(config.validation.cacheTtlValid), description: '有效链接缓存时间(s)' }, { key: 'validation_cache_ttl_invalid', value: String(config.validation.cacheTtlInvalid), description: '无效链接缓存时间(s)' }, { key: 'search_proxy_enabled', value: 'false', description: '搜索代理开关(true/false)' }, { key: 'search_proxy_url', value: '', description: '搜索代理地址 (如 http://127.0.0.1:7890)' }, { key: 'search_strategy', value: 'wait_all', description: '搜索结果展示方式: wait_all=等待全部后展示, stream_channel=频道逐步展示' }, { key: 'link_validation_enabled', value: 'true', description: '资源链接有效性检测开关(true/false)' }, { key: 'cloud_enabled_quark', value: 'true', description: '夸克网盘' }, { key: 'cloud_enabled_baidu', value: 'true', description: '百度网盘' }, { key: 'cloud_enabled_aliyun', value: 'true', description: '阿里云盘' }, { key: 'cloud_enabled_115', value: 'true', description: '115 网盘' }, { key: 'cloud_enabled_tianyi', value: 'true', description: '天翼云盘' }, { key: 'cloud_enabled_123pan', value: 'true', description: '123 云盘' }, { key: 'cloud_enabled_uc', value: 'true', description: 'UC 网盘' }, { key: 'cloud_enabled_xunlei', value: 'true', description: '迅雷网盘' }, { key: 'cloud_enabled_pikpak', value: 'true', description: 'PikPak 网盘' }, { key: 'cloud_enabled_magnet', value: 'true', description: '磁力链接' }, { key: 'cloud_enabled_ed2k', value: 'true', description: '电驴链接' }, { key: 'cloud_enabled_others', value: 'false', description: '其他类型(默认关闭)' }, { key: 'search_result_limit', value: '10', description: '每类网盘最多展示的有效结果数' }, { key: 'search_fallback_image', value: '', description: '无图资源的兜底封面图 URL(留空使用渐变色)' }, { key: 'site_logo', value: '', description: '网站 LOGO 图片 URL(留空使用默认图标/文字)' }, { key: 'site_name', value: 'CloudSearch', description: '网站名称(显示在首页标题/页脚)' }, { key: 'site_disclaimer', value: '本站为非盈利性个人站点,所有资源仅供学习、研究使用,版权归原作者所有。请于下载后24小时内删除,切勿用于商业或非法用途。若侵犯了您的权益,请联系我们(邮箱:3337598077@qq.com),我们将及时处理。', description: '网站底部免责声明' }, { key: 'site_marquee', value: '📢 欢迎使用CloudSearch,所有资源仅供学习交流,请于下载后24小时内删除', description: '搜索栏下方滚动通知文字(从右往左滚动显示)' }, { key: 'tmdb_api_token', value: '', description: 'TMDB API 读取令牌(用于增强豆瓣内容信息)' }, { key: 'ip_geo_api_url', value: 'https://cn.apihz.cn/api/ip/chaapi.php?id=10014356&key=ca7ccb3b9ca044dd993c8604bc9afd93&ip={ip}&td=0', description: 'IP 归属地查询接口({ip} 会被替换为实际IP)' }, { key: 'ip_geo_api_key', value: '', description: 'IP 归属地备用 API Key(留空使用默认)' }, { key: 'title_filter_rules', value: '', description: '搜索结果标题过滤规则(一行一条:纯文本直接移除 / 正则用/包围/)' }, { key: 'timezone', value: 'Asia/Shanghai', description: '系统时区(如 Asia/Shanghai、America/New_York、UTC)' }, { key: 'redis_url', value: 'redis://redis:6379', description: 'Redis 连接地址(用于缓存优化)' }, { key: 'pansou_auth_token', value: '', description: 'PanSou API 认证令牌(用于私有搜索服务)' }, { key: 'pansou_web_enabled', value: 'false', description: '启用 PanSou Web 端访问(在 /pansou 路径提供 PanSou 搜索引擎管理界面)' }, { key: 'cleanup_enabled', value: 'true', description: '启用自动清理(每天检查一次,移入回收站+清空日志+清空回收站)' }, { key: 'cleanup_file_retention_days', value: '7', description: '云盘文件保留天数(超过此天数的日期文件夹将被移入回收站)' }, { key: 'cleanup_log_retention_days', value: '30', description: '转存日志保留天数' }, { key: 'cleanup_empty_trash', value: 'true', description: '清理时是否清空回收站(永久删除释放空间)' }, { key: 'cleanup_space_threshold_enabled', value: 'false', description: '启用空间阈值自动清理(已用空间超过XX%时按比例删除最旧的转存文件)' }, { key: 'cleanup_space_threshold_percent', value: '90', description: '空间使用阈值百分比(超过此值时触发强制清理)' }, { key: 'cleanup_space_threshold_delete_percent', value: '10', description: '触发阈值清理时释放总空间的百分比(如 10 表示累计删除最旧文件直到达到总空间的 10%,6TB 总空间 → 释放 ~600GB)' }, { key: 'save_reuse_enabled', value: 'true', description: '启用分享链接复用(相同原始链接不再重复转存,直接复用之前的分享链接)' }, { key: 'cleanup_last_run', value: '', description: '上次自动清理时间' }, { key: 'cleanup_last_stats', value: '', description: '上次清理结果统计(JSON)' }, ]; const insert = db.prepare( 'INSERT OR IGNORE INTO system_configs (key, value, description) VALUES (?, ?, ?)' ); for (const entry of defaults) { insert.run(entry.key, entry.value, entry.description); } } /** 清理 60 天前的转存记录 */ function cleanupOldSaveRecords(db: Database.Database): void { const cutoff = formatLocalDateTime(new Date(Date.now() - 60 * 24 * 60 * 60 * 1000)); const deleted = db.prepare('DELETE FROM save_records WHERE created_at < ?').run(cutoff); console.log(`[DB] Cleaned up ${deleted.changes} save records older than 60 days (before ${cutoff})`); } export default getDb;