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, cloud_type_uid TEXT DEFAULT NULL, 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); // Performance indexes on cloud_configs (after all columns exist) db.exec(` CREATE INDEX IF NOT EXISTS idx_cc_type_active ON cloud_configs(cloud_type, is_active); CREATE INDEX IF NOT EXISTS idx_cc_uid ON cloud_configs(cookie_uid); CREATE INDEX IF NOT EXISTS idx_cc_verification ON cloud_configs(verification_status); `); 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, cloud_type_uid TEXT DEFAULT NULL, 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'); } // Migration 3: Add cookie_uid column const hasCookieUid = db.prepare("SELECT sql FROM sqlite_master WHERE name='cloud_configs' AND sql LIKE '%cookie_uid%'").get(); if (!hasCookieUid) { db.exec("ALTER TABLE cloud_configs ADD COLUMN cookie_uid TEXT DEFAULT NULL"); console.log('[DB] cloud_configs migration: cookie_uid column added'); } // Migration 4: Add promotion_account column const hasPromotionAccount = db.prepare("SELECT sql FROM sqlite_master WHERE name='cloud_configs' AND sql LIKE '%promotion_account%'").get(); if (!hasPromotionAccount) { db.exec("ALTER TABLE cloud_configs ADD COLUMN promotion_account TEXT DEFAULT NULL"); console.log('[DB] cloud_configs migration: promotion_account column added'); // v0.3.5: notify_config for per-cloud push notification settings const hasNotifyConfig = db.prepare("SELECT sql FROM sqlite_master WHERE name='cloud_configs' AND sql LIKE '%notify_config%'").get(); if (!hasNotifyConfig) { db.exec("ALTER TABLE cloud_configs ADD COLUMN notify_config TEXT DEFAULT NULL"); console.log('[DB] cloud_configs migration: notify_config column added'); } // Migration 6: Add cloud_type_uid column const hasCloudTypeUid = db.prepare("SELECT sql FROM sqlite_master WHERE name='cloud_configs' AND sql LIKE '%cloud_type_uid%'").get(); if (!hasCloudTypeUid) { db.exec("ALTER TABLE cloud_configs ADD COLUMN cloud_type_uid TEXT DEFAULT NULL"); console.log('[DB] cloud_configs migration: cloud_type_uid 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: 'link_invalid_keywords', value: '', description: '链接失效关键词(一行一条,命中的链接判定为失效)' }, { key: 'cloud_type_quark_enabled', value: 'true', description: '夸克网盘' }, { key: 'cloud_type_baidu_enabled', value: 'true', description: '百度网盘' }, { key: 'cloud_type_aliyun_enabled', value: 'true', description: '阿里云盘' }, { key: 'cloud_type_115_enabled', value: 'true', description: '115 网盘' }, { key: 'cloud_type_tianyi_enabled', value: 'true', description: '天翼云盘' }, { key: 'cloud_type_123pan_enabled', value: 'true', description: '123 云盘' }, { key: 'cloud_type_uc_enabled', value: 'true', description: 'UC 网盘' }, { key: 'cloud_type_xunlei_enabled', value: 'true', description: '迅雷网盘' }, { key: 'cloud_type_pikpak_enabled', value: 'true', description: 'PikPak 网盘' }, { key: 'cloud_type_magnet_enabled', value: 'true', description: '磁力链接' }, { key: 'cloud_type_ed2k_enabled', value: 'true', description: '电驴链接' }, { key: 'cloud_type_others_enabled', 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: '', description: 'IP 归属地查询接口({ip} 会被替换为实际IP,留空则禁用)' }, { key: 'ip_geo_api_id', value: '', description: 'IP 归属地 API ID(apihz.cn 接口)' }, { 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)' }, { key: 'search_all_channels', value: 'false', description: '使用所有频道参与搜索(包含未启用频道)' }, { key: 'ip_geo_provider', value: 'apihz', description: 'IP 归属地查询接口提供商' }, { key: 'auto_update_enabled', value: 'false', description: '自动更新镜像(预留,暂未实现)' }, { key: 'cleanup_auto_refresh_storage', value: 'false', description: '自动刷新网盘空间信息(每天检查一次)' }, { key: 'cleanup_verify_enabled', value: 'false', description: '启用转存后自动验证链接有效性' }, { key: 'cleanup_verify_interval', value: '3600', description: '自动验证间隔(秒)' }, { key: 'cleanup_whitelist_dirs', value: '', description: '清理文件白名单目录(逗号分隔,保留不删)' }, { key: 'proxy_url', value: '', description: 'HTTP 代理地址(用于搜索请求代理)' }, { key: 'quark_ad_keywords', value: '', description: '夸克广告文件关键词(逗号分隔)' }, { key: 'quark_sus_extensions', value: '', description: '夸克可疑文件后缀(逗号分隔)' }, { key: 'quark_warning_folder_names', value: '', description: '夸克警示文件夹名称(逗号分隔)' }, { key: 'storage_refresh_interval', value: '86400', description: '空间信息刷新间隔(秒,默认24小时)' }, ]; 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;