Files
CloudSearch/source_clean/src/database/database.ts

371 lines
19 KiB
TypeScript
Executable File
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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,
cookie_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 push_users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
account TEXT NOT NULL UNIQUE,
notify_config TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now','localtime')),
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' },
{ col: 'config_id', def: 'INTEGER' },
{ col: 'promotion_account', 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,
cookie_uid TEXT DEFAULT NULL,
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 IDapihz.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;