const Database = require('better-sqlite3') const fs = require('fs') const path = require('path') const dataDir = path.join(process.cwd(), 'data') if (!fs.existsSync(dataDir)) fs.mkdirSync(dataDir, { recursive: true }) const dbPath = path.join(dataDir, 'toolbox.db') const db = new Database(dbPath) db.exec( `CREATE TABLE IF NOT EXISTS posts ( id INTEGER PRIMARY KEY AUTOINCREMENT, uid TEXT NOT NULL, mid TEXT NOT NULL UNIQUE, screen_name TEXT, created_at TEXT, text TEXT, text_plain TEXT, url TEXT, reposts_count INTEGER, comments_count INTEGER, attitudes_count INTEGER, pics_json TEXT, fetched_at TEXT );` ) // migrations: add retweeted_* columns if missing const cols = db.prepare(`PRAGMA table_info(posts)`).all().map(r => r.name) const addCol = name => db.exec(`ALTER TABLE posts ADD COLUMN ${name} TEXT`) if (!cols.includes('retweeted_mid')) addCol('retweeted_mid') if (!cols.includes('retweeted_screen_name')) addCol('retweeted_screen_name') if (!cols.includes('retweeted_text')) addCol('retweeted_text') if (!cols.includes('retweeted_text_plain')) addCol('retweeted_text_plain') if (!cols.includes('retweeted_url')) addCol('retweeted_url') if (!cols.includes('retweeted_pics_json')) addCol('retweeted_pics_json') db.exec( `CREATE TABLE IF NOT EXISTS keywords ( id INTEGER PRIMARY KEY AUTOINCREMENT, keyword TEXT NOT NULL UNIQUE, created_at TEXT DEFAULT (datetime('now')) );` ) db.exec( `CREATE TABLE IF NOT EXISTS tracked_users ( id INTEGER PRIMARY KEY AUTOINCREMENT, uid TEXT NOT NULL UNIQUE, screen_name TEXT, added_at TEXT DEFAULT (datetime('now')) );` ) db.exec( `CREATE TABLE IF NOT EXISTS settings_history ( id INTEGER PRIMARY KEY AUTOINCREMENT, tool TEXT NOT NULL, payload TEXT NOT NULL, saved_at TEXT DEFAULT (datetime('now')) );` ) db.exec( `CREATE TABLE IF NOT EXISTS archives ( id INTEGER PRIMARY KEY AUTOINCREMENT, uid TEXT NOT NULL, week_start TEXT NOT NULL, week_end TEXT NOT NULL, html TEXT NOT NULL, created_at TEXT DEFAULT (datetime('now')), updated_at TEXT DEFAULT (datetime('now')), UNIQUE(uid, week_start) );` ) db.exec( `CREATE TABLE IF NOT EXISTS market_archives ( id INTEGER PRIMARY KEY AUTOINCREMENT, day TEXT NOT NULL UNIQUE, html TEXT NOT NULL, created_at TEXT DEFAULT (datetime('now')), updated_at TEXT DEFAULT (datetime('now')) );` ) db.exec( `CREATE TABLE IF NOT EXISTS alert_history ( id INTEGER PRIMARY KEY AUTOINCREMENT, skill TEXT NOT NULL, alert_id TEXT NOT NULL, slot_key TEXT NOT NULL, updated_at TEXT DEFAULT (datetime('now')), UNIQUE(skill, alert_id) );` ) db.exec( `CREATE TABLE IF NOT EXISTS market_state ( key TEXT PRIMARY KEY, value TEXT, updated_at TEXT DEFAULT (datetime('now')) );` ) db.exec( `CREATE TABLE IF NOT EXISTS alert_email_log ( id INTEGER PRIMARY KEY AUTOINCREMENT, skill TEXT NOT NULL, alert_id TEXT NOT NULL, subject TEXT, status TEXT NOT NULL DEFAULT 'pending', error_message TEXT, created_at TEXT DEFAULT (datetime('now')) );` ) // 唯一索引:同一个告警事件同一种状态不重复记录 try { db.exec(`CREATE UNIQUE INDEX IF NOT EXISTS idx_alert_email_log_uniq ON alert_email_log(skill, alert_id, status)`) } catch {} const insertPost = db.prepare( `INSERT OR IGNORE INTO posts ( uid, mid, screen_name, created_at, text, text_plain, url, reposts_count, comments_count, attitudes_count, pics_json, fetched_at ) VALUES ( @uid, @mid, @screen_name, @created_at, @text, @text_plain, @url, @reposts_count, @comments_count, @attitudes_count, @pics_json, @fetched_at )` ) const updateRetweeted = db.prepare( `UPDATE posts SET retweeted_mid = @retweeted_mid, retweeted_screen_name = @retweeted_screen_name, retweeted_text = @retweeted_text, retweeted_text_plain = @retweeted_text_plain, retweeted_url = @retweeted_url, retweeted_pics_json = @retweeted_pics_json, text_plain = @text_plain WHERE mid = @mid` ) const queryPostsAny = (uid, terms, limit) => { const likeClauses = terms.map(() => 'text_plain LIKE ?').join(' OR ') const args = terms.map(t => `%${t}%`) const stmt = db.prepare( `SELECT * FROM posts WHERE uid = ? AND (${likeClauses}) ORDER BY fetched_at DESC, id ASC LIMIT ?` ) return stmt.all(uid, ...args, limit) } const queryPostsAll = (uid, terms, limit) => { const likeClauses = terms.map(() => 'text_plain LIKE ?').join(' AND ') const args = terms.map(t => `%${t}%`) const stmt = db.prepare( `SELECT * FROM posts WHERE uid = ? AND (${likeClauses}) ORDER BY fetched_at DESC, id ASC LIMIT ?` ) return stmt.all(uid, ...args, limit) } const insertKeyword = db.prepare( `INSERT OR IGNORE INTO keywords (keyword) VALUES (?)` ) const insertTrackedUser = db.prepare( `INSERT OR IGNORE INTO tracked_users (uid, screen_name) VALUES (?, ?)` ) const getSettings = () => { const keywords = db.prepare(`SELECT keyword FROM keywords ORDER BY id DESC`).all().map(r => r.keyword) const users = db.prepare(`SELECT uid, screen_name FROM tracked_users ORDER BY id DESC`).all() return { keywords, users } } const insertSettingHistory = db.prepare( `INSERT INTO settings_history (tool, payload) VALUES (@tool, @payload)` ) const upsertArchive = db.prepare( `INSERT INTO archives (uid, week_start, week_end, html, created_at, updated_at) VALUES (@uid, @week_start, @week_end, @html, datetime('now'), datetime('now')) ON CONFLICT(uid, week_start) DO UPDATE SET week_end = excluded.week_end, html = excluded.html, updated_at = datetime('now')` ) const getArchive = db.prepare( `SELECT * FROM archives WHERE uid = ? AND week_start = ?` ) const upsertMarketArchive = db.prepare( `INSERT INTO market_archives (day, html, created_at, updated_at) VALUES (@day, @html, datetime('now'), datetime('now')) ON CONFLICT(day) DO UPDATE SET html = excluded.html, updated_at = datetime('now')` ) const getMarketArchive = db.prepare( `SELECT * FROM market_archives WHERE day = ?` ) module.exports = { db, insertPost, updateRetweeted, queryPostsAny, queryPostsAll, insertKeyword, insertTrackedUser, getSettings , upsertArchive , getArchive , insertSettingHistory , upsertMarketArchive , getMarketArchive }