const Database = require('better-sqlite3'); const fs = require('fs'); const path = require('path'); const bcrypt = require('bcryptjs'); const DB_PATH = path.join(__dirname, 'data', 'rainweb.db'); const DATA_DIR = path.dirname(DB_PATH); let db = null; function getDb() { if (db) return db; // Ensure data directory exists if (!fs.existsSync(DATA_DIR)) { fs.mkdirSync(DATA_DIR, { recursive: true }); } db = new Database(DB_PATH); initTables(); migrateSchema(); seedAdmin(); seedDefaults(); seedSampleData(); return db; } function initTables() { db.exec(`CREATE TABLE IF NOT EXISTS users ( id INTEGER PRIMARY KEY AUTOINCREMENT, username TEXT UNIQUE NOT NULL, password TEXT NOT NULL, email TEXT DEFAULT '', email_verified INTEGER DEFAULT 0, role TEXT DEFAULT 'user', avatar TEXT DEFAULT '', nickname TEXT DEFAULT '', title TEXT DEFAULT '', title_color TEXT DEFAULT '', website TEXT DEFAULT '', bio TEXT DEFAULT '', last_active_at TEXT DEFAULT '', created_at DATETIME DEFAULT (datetime('now')))`); db.exec(`CREATE TABLE IF NOT EXISTS pending_users ( id INTEGER PRIMARY KEY AUTOINCREMENT, username TEXT NOT NULL, password TEXT NOT NULL, email TEXT NOT NULL, token TEXT UNIQUE NOT NULL, created_at DATETIME DEFAULT (datetime('now')))`); // M2:启动时清理超 15 分钟的 pending 注册记录(验证码过期,防表无限增长 / 验证码长期间可试) db.exec("DELETE FROM pending_users WHERE created_at < datetime('now','-15 minutes')"); db.exec(`CREATE TABLE IF NOT EXISTS admin_links ( id INTEGER PRIMARY KEY AUTOINCREMENT, title TEXT NOT NULL, url TEXT NOT NULL, embed_url TEXT DEFAULT '', description TEXT DEFAULT '', icon TEXT DEFAULT '', category TEXT DEFAULT '默认', sort_order INTEGER DEFAULT 0, use_proxy INTEGER DEFAULT 0, version TEXT DEFAULT '', proxy_headers TEXT DEFAULT '', proxy_skip_tls_verify INTEGER DEFAULT 0, permissions TEXT DEFAULT '', scale REAL DEFAULT 1.0, trusted INTEGER DEFAULT 1, slug TEXT DEFAULT '', created_at DATETIME DEFAULT (datetime('now')))`); // Migration: add columns if missing try { db.exec('ALTER TABLE admin_links ADD COLUMN use_proxy INTEGER DEFAULT 0'); } catch {} try { db.exec('ALTER TABLE admin_links ADD COLUMN version TEXT DEFAULT ""'); } catch {} db.exec(`CREATE TABLE IF NOT EXISTS announcements ( id INTEGER PRIMARY KEY AUTOINCREMENT, title TEXT DEFAULT '', content TEXT NOT NULL, active INTEGER DEFAULT 1, created_at DATETIME DEFAULT (datetime('now')), updated_at DATETIME DEFAULT (datetime('now')))`); db.exec(`CREATE TABLE IF NOT EXISTS forum_categories ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT UNIQUE NOT NULL, description TEXT DEFAULT '', sort_order INTEGER DEFAULT 0, announcement TEXT DEFAULT '', sub_categories TEXT DEFAULT '')`); try { db.exec('ALTER TABLE forum_categories ADD COLUMN announcement TEXT DEFAULT ""'); } catch {} try { db.exec('ALTER TABLE forum_categories ADD COLUMN sub_categories TEXT DEFAULT ""'); } catch {} db.exec(`CREATE TABLE IF NOT EXISTS forum_posts ( id INTEGER PRIMARY KEY AUTOINCREMENT, category_id INTEGER NOT NULL, title TEXT NOT NULL, content TEXT NOT NULL, author_id INTEGER NOT NULL, use_markdown INTEGER DEFAULT 1, sub_category TEXT DEFAULT '', created_at DATETIME DEFAULT (datetime('now')), updated_at DATETIME DEFAULT (datetime('now')), FOREIGN KEY (category_id) REFERENCES forum_categories(id), FOREIGN KEY (author_id) REFERENCES users(id))`); try { db.exec('ALTER TABLE forum_posts ADD COLUMN use_markdown INTEGER DEFAULT 1'); } catch {} try { db.exec('ALTER TABLE forum_posts ADD COLUMN sub_category TEXT DEFAULT ""'); } catch {} try { db.exec('ALTER TABLE forum_posts ADD COLUMN tags TEXT DEFAULT ""'); } catch {} db.exec(`CREATE TABLE IF NOT EXISTS blog_comments ( id INTEGER PRIMARY KEY AUTOINCREMENT, post_id INTEGER NOT NULL, content TEXT NOT NULL, author_id INTEGER, author_name TEXT DEFAULT '', parent_id INTEGER DEFAULT 0, status TEXT DEFAULT 'approved', created_at DATETIME DEFAULT (datetime('now')), FOREIGN KEY (post_id) REFERENCES blog_posts(id))`); db.exec(`CREATE TABLE IF NOT EXISTS forum_replies ( id INTEGER PRIMARY KEY AUTOINCREMENT, post_id INTEGER NOT NULL, content TEXT NOT NULL, author_id INTEGER NOT NULL, created_at DATETIME DEFAULT (datetime('now')), FOREIGN KEY (post_id) REFERENCES forum_posts(id), FOREIGN KEY (author_id) REFERENCES users(id))`); db.exec(`CREATE TABLE IF NOT EXISTS blog_posts ( id INTEGER PRIMARY KEY AUTOINCREMENT, title TEXT NOT NULL, content TEXT NOT NULL, excerpt TEXT DEFAULT '', author_id INTEGER NOT NULL, published INTEGER DEFAULT 1, use_markdown INTEGER DEFAULT 1, tags TEXT DEFAULT '', views INTEGER DEFAULT 0, created_at DATETIME DEFAULT (datetime('now')), updated_at DATETIME DEFAULT (datetime('now')), FOREIGN KEY (author_id) REFERENCES users(id))`); db.exec(`CREATE TABLE IF NOT EXISTS post_likes ( post_id INTEGER NOT NULL, user_id INTEGER NOT NULL, created_at DATETIME DEFAULT (datetime('now')), PRIMARY KEY (post_id, user_id))`); db.exec(`CREATE TABLE IF NOT EXISTS user_settings ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER UNIQUE NOT NULL, pin_hash TEXT DEFAULT '', kdf_salt TEXT DEFAULT '', FOREIGN KEY (user_id) REFERENCES users(id))`); db.exec(`CREATE TABLE IF NOT EXISTS password_entries ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL, title TEXT NOT NULL, username TEXT DEFAULT '', encrypted_password TEXT NOT NULL, url TEXT DEFAULT '', notes TEXT DEFAULT '', created_at DATETIME DEFAULT (datetime('now')), updated_at DATETIME DEFAULT (datetime('now')), FOREIGN KEY (user_id) REFERENCES users(id))`); db.exec(`CREATE TABLE IF NOT EXISTS attachments ( id INTEGER PRIMARY KEY AUTOINCREMENT, filename TEXT NOT NULL, original_name TEXT NOT NULL, size INTEGER NOT NULL, mime_type TEXT DEFAULT '', user_id INTEGER NOT NULL, ref_type TEXT DEFAULT '', ref_id INTEGER DEFAULT 0, created_at DATETIME DEFAULT (datetime('now')), FOREIGN KEY (user_id) REFERENCES users(id))`); db.exec(`CREATE TABLE IF NOT EXISTS site_settings ( key TEXT PRIMARY KEY, value TEXT DEFAULT '')`); // 审计日志表(终端会话/密码失败等安全事件落库) db.exec(`CREATE TABLE IF NOT EXISTS audit_logs ( id INTEGER PRIMARY KEY AUTOINCREMENT, action TEXT NOT NULL, user_id INTEGER DEFAULT 0, username TEXT DEFAULT '', detail TEXT DEFAULT '', ip TEXT DEFAULT '', created_at DATETIME DEFAULT (datetime('now')))`); db.exec('CREATE INDEX IF NOT EXISTS idx_audit_logs_created ON audit_logs(created_at)'); // 审计保留策略:启动时惰性清理 90 天前的记录,防止无限增长 //(写入路径上 terminal.js 每 50 条也会触发一次同规则清理) db.exec("DELETE FROM audit_logs WHERE created_at < datetime('now','-90 day')"); // Indexes for performance db.exec('CREATE INDEX IF NOT EXISTS idx_blog_published ON blog_posts(published)'); db.exec('CREATE INDEX IF NOT EXISTS idx_blog_comments_post ON blog_comments(post_id)'); db.exec('CREATE INDEX IF NOT EXISTS idx_forum_posts_category ON forum_posts(category_id)'); db.exec('CREATE INDEX IF NOT EXISTS idx_forum_replies_post ON forum_replies(post_id)'); db.exec('CREATE INDEX IF NOT EXISTS idx_password_user ON password_entries(user_id)'); db.exec('CREATE INDEX IF NOT EXISTS idx_attachments_ref ON attachments(ref_type, ref_id)'); // Legacy migrations for old database compatibility try { db.exec("ALTER TABLE users ADD COLUMN email TEXT DEFAULT ''"); } catch {} try { db.exec('ALTER TABLE users ADD COLUMN email_verified INTEGER DEFAULT 0'); } catch {} try { db.exec("ALTER TABLE users ADD COLUMN avatar TEXT DEFAULT ''"); } catch {} try { db.exec('ALTER TABLE blog_posts ADD COLUMN use_markdown INTEGER DEFAULT 1'); } catch {} } // 版本化迁移框架:PRAGMA user_version 记录当前 schema 版本, // migrations 数组按 version 升序执行,仅当 current < m.version 时运行并推进版本号。 // 现有 try/catch ALTER 保留作为"确保列存在"的幂等层,未来新列迁移写入 migrations。 function migrateSchema() { const current = db.pragma('user_version', { simple: true }); const migrations = [ // v1: 密码管理器 user_settings 增加 pin_iter 列(对应 routes/passwords.js 的 ensureSchema 逻辑, // 默认 100000:存量用户保持旧迭代数,旧密文仍可解密) { version: 1, up: () => { try { db.exec("ALTER TABLE user_settings ADD COLUMN pin_iter INTEGER DEFAULT 100000"); } catch {} } }, // v2: 博客增强——博文标签/阅读量、评论嵌套与审核、点赞表 { version: 2, up: () => { try { db.exec("ALTER TABLE blog_posts ADD COLUMN tags TEXT DEFAULT ''"); } catch {} try { db.exec("ALTER TABLE blog_posts ADD COLUMN views INTEGER DEFAULT 0"); } catch {} try { db.exec("ALTER TABLE blog_comments ADD COLUMN parent_id INTEGER DEFAULT 0"); } catch {} try { db.exec("ALTER TABLE blog_comments ADD COLUMN status TEXT DEFAULT 'approved'"); } catch {} try { db.exec("CREATE TABLE IF NOT EXISTS post_likes (post_id INTEGER NOT NULL, user_id INTEGER NOT NULL, created_at DATETIME DEFAULT (datetime('now')), PRIMARY KEY (post_id, user_id))"); } catch {} } }, // v3: RainID 单点登录——影子账号绑定键(sub)。部分唯一索引:空串(本地账号)不参与唯一约束 { version: 3, up: () => { try { db.exec("ALTER TABLE users ADD COLUMN rainid_user_id TEXT DEFAULT ''"); } catch {} try { db.exec("CREATE UNIQUE INDEX IF NOT EXISTS idx_users_rainid ON users(rainid_user_id) WHERE rainid_user_id <> ''"); } catch {} } }, // v4: 论坛贴吧升级——置顶/加精/版主/图标 { version: 4, up: () => { try { db.exec("ALTER TABLE forum_posts ADD COLUMN is_pinned INTEGER DEFAULT 0"); } catch {} try { db.exec("ALTER TABLE forum_posts ADD COLUMN is_essence INTEGER DEFAULT 0"); } catch {} try { db.exec("ALTER TABLE forum_categories ADD COLUMN icon TEXT DEFAULT ''"); } catch {} try { db.exec("ALTER TABLE forum_categories ADD COLUMN icon_color TEXT DEFAULT ''"); } catch {} try { db.exec(`CREATE TABLE IF NOT EXISTS forum_moderators ( category_id INTEGER NOT NULL, user_id INTEGER NOT NULL, PRIMARY KEY (category_id, user_id), FOREIGN KEY (category_id) REFERENCES forum_categories(id) ON DELETE CASCADE, FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE)`); } catch {} try { db.exec("CREATE INDEX IF NOT EXISTS idx_forum_posts_cat ON forum_posts(category_id, is_pinned DESC, created_at DESC)"); } catch {} } }, // v5: 论坛版块级禁言 { version: 5, up: () => { try { db.exec(`CREATE TABLE IF NOT EXISTS forum_mutes ( category_id INTEGER NOT NULL, user_id INTEGER NOT NULL, muted_until DATETIME, -- NULL = 永久 created_at DATETIME DEFAULT (datetime('now')), created_by INTEGER NOT NULL, PRIMARY KEY (category_id, user_id), FOREIGN KEY (category_id) REFERENCES forum_categories(id) ON DELETE CASCADE, FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE)`); } catch {} try { db.exec("CREATE INDEX IF NOT EXISTS idx_forum_mutes_cat ON forum_mutes(category_id)"); } catch {} } }, // v6: RSS 版块级订阅——版块 feed 开关 { version: 6, up: () => { try { db.exec("ALTER TABLE forum_categories ADD COLUMN feed_enabled INTEGER DEFAULT 1"); } catch {} } }, // v7: 论坛帖子二次编辑计数 { version: 7, up: () => { try { db.exec("ALTER TABLE forum_posts ADD COLUMN edit_count INTEGER DEFAULT 0"); } catch {} } }, // v8: markdown [lock:] 锁定——博客/论坛帖子存锁定元数据 JSON([{type, hash}],不含块原文) { version: 8, up: () => { try { db.exec("ALTER TABLE blog_posts ADD COLUMN locks TEXT DEFAULT '[]'"); } catch {} try { db.exec("ALTER TABLE forum_posts ADD COLUMN locks TEXT DEFAULT '[]'"); } catch {} } }, // v9: 用户 QQ 号字段(头像优先级:自传 > QQ 头像 > RainID 头像) { version: 9, up: () => { try { db.exec("ALTER TABLE users ADD COLUMN qq TEXT DEFAULT ''"); } catch {} } }, // v10: 公开个人主页——用户个性签名 { version: 10, up: () => { try { db.exec("ALTER TABLE users ADD COLUMN bio TEXT DEFAULT ''"); } catch {} } }, // v11: 公开个人主页——最后活跃时间(datetime 字符串,跨天更新) { version: 11, up: () => { try { db.exec("ALTER TABLE users ADD COLUMN last_active_at TEXT DEFAULT ''"); } catch {} } }, // v12: 用户对外昵称(显示名:nickname||username) { version: 12, up: () => { try { db.exec("ALTER TABLE users ADD COLUMN nickname TEXT DEFAULT ''"); } catch {} } }, // v13: 自定义头衔 + 头衔颜色(帖子/评论作者位展示) { version: 13, up: () => { try { db.exec("ALTER TABLE users ADD COLUMN title TEXT DEFAULT ''"); } catch {} try { db.exec("ALTER TABLE users ADD COLUMN title_color TEXT DEFAULT ''"); } catch {} } }, // v14: 个人博客链接(公开主页展示) { version: 14, up: () => { try { db.exec("ALTER TABLE users ADD COLUMN website TEXT DEFAULT ''"); } catch {} } }, // v15: 工作台 SPA 代理——面板链接加代理配置列 // (proxy_headers=转发自定义头 JSON、proxy_skip_tls_verify、permissions=iframe 委托权限 JSON 数组、 // scale=嵌入缩放、trusted=信任模型(1 保留 allow-same-origin)、slug=唯一标识) { version: 15, up: () => { try { db.exec("ALTER TABLE admin_links ADD COLUMN proxy_headers TEXT DEFAULT ''"); } catch {} try { db.exec("ALTER TABLE admin_links ADD COLUMN proxy_skip_tls_verify INTEGER DEFAULT 0"); } catch {} try { db.exec("ALTER TABLE admin_links ADD COLUMN permissions TEXT DEFAULT ''"); } catch {} try { db.exec("ALTER TABLE admin_links ADD COLUMN scale REAL DEFAULT 1.0"); } catch {} try { db.exec("ALTER TABLE admin_links ADD COLUMN trusted INTEGER DEFAULT 1"); } catch {} try { db.exec("ALTER TABLE admin_links ADD COLUMN slug TEXT DEFAULT ''"); } catch {} } }, // v16: 站内工单——用户反馈、公开回复、内部备注及业务事件 { version: 16, up: () => { db.exec(`CREATE TABLE IF NOT EXISTS tickets ( id INTEGER PRIMARY KEY AUTOINCREMENT, ticket_no TEXT NOT NULL UNIQUE, requester_id INTEGER, subject TEXT NOT NULL, description TEXT NOT NULL, category TEXT NOT NULL DEFAULT 'other', priority TEXT NOT NULL DEFAULT 'normal', status TEXT NOT NULL DEFAULT 'open', source TEXT NOT NULL DEFAULT 'site', source_url TEXT DEFAULT '', source_type TEXT DEFAULT '', source_id INTEGER DEFAULT 0, browser_info TEXT DEFAULT '', assignee_id INTEGER, created_at DATETIME DEFAULT (datetime('now')), updated_at DATETIME DEFAULT (datetime('now')), last_reply_at DATETIME DEFAULT NULL, first_response_at DATETIME, resolved_at DATETIME, closed_at DATETIME, revision INTEGER NOT NULL DEFAULT 0, FOREIGN KEY (requester_id) REFERENCES users(id) ON DELETE SET NULL, FOREIGN KEY (assignee_id) REFERENCES users(id) ON DELETE SET NULL )`); db.exec(`CREATE TABLE IF NOT EXISTS ticket_messages ( id INTEGER PRIMARY KEY AUTOINCREMENT, ticket_id INTEGER NOT NULL, author_id INTEGER, content TEXT NOT NULL, is_internal INTEGER NOT NULL DEFAULT 0, created_at DATETIME DEFAULT (datetime('now')), updated_at DATETIME DEFAULT (datetime('now')), FOREIGN KEY (ticket_id) REFERENCES tickets(id) ON DELETE CASCADE, FOREIGN KEY (author_id) REFERENCES users(id) ON DELETE SET NULL )`); db.exec(`CREATE TABLE IF NOT EXISTS ticket_events ( id INTEGER PRIMARY KEY AUTOINCREMENT, ticket_id INTEGER NOT NULL, actor_id INTEGER, event_type TEXT NOT NULL, field_name TEXT DEFAULT '', old_value TEXT DEFAULT '', new_value TEXT DEFAULT '', detail TEXT DEFAULT '', created_at DATETIME DEFAULT (datetime('now')), FOREIGN KEY (ticket_id) REFERENCES tickets(id) ON DELETE CASCADE, FOREIGN KEY (actor_id) REFERENCES users(id) ON DELETE SET NULL )`); try { db.exec('CREATE INDEX IF NOT EXISTS idx_tickets_requester ON tickets(requester_id, updated_at DESC)'); } catch {} try { db.exec('CREATE INDEX IF NOT EXISTS idx_tickets_status_updated ON tickets(status, updated_at DESC)'); } catch {} try { db.exec('CREATE INDEX IF NOT EXISTS idx_tickets_assignee ON tickets(assignee_id, status, updated_at DESC)'); } catch {} try { db.exec('CREATE INDEX IF NOT EXISTS idx_tickets_category ON tickets(category, updated_at DESC)'); } catch {} try { db.exec('CREATE INDEX IF NOT EXISTS idx_ticket_messages_ticket ON ticket_messages(ticket_id, created_at ASC)'); } catch {} try { db.exec('CREATE INDEX IF NOT EXISTS idx_ticket_events_ticket ON ticket_events(ticket_id, created_at ASC)'); } catch {} } }, // v17: 工单并发版本与回复时间修正;为已存在的部分工单表补齐字段 { version: 17, up: () => { db.exec(`CREATE TABLE IF NOT EXISTS tickets ( id INTEGER PRIMARY KEY AUTOINCREMENT, ticket_no TEXT NOT NULL UNIQUE, requester_id INTEGER, subject TEXT NOT NULL, description TEXT NOT NULL, category TEXT NOT NULL DEFAULT 'other', priority TEXT NOT NULL DEFAULT 'normal', status TEXT NOT NULL DEFAULT 'open', source TEXT NOT NULL DEFAULT 'site', source_url TEXT DEFAULT '', source_type TEXT DEFAULT '', source_id INTEGER DEFAULT 0, browser_info TEXT DEFAULT '', assignee_id INTEGER, created_at DATETIME DEFAULT (datetime('now')), updated_at DATETIME DEFAULT (datetime('now')), last_reply_at DATETIME DEFAULT NULL, first_response_at DATETIME, resolved_at DATETIME, closed_at DATETIME, revision INTEGER NOT NULL DEFAULT 0, FOREIGN KEY (requester_id) REFERENCES users(id) ON DELETE SET NULL, FOREIGN KEY (assignee_id) REFERENCES users(id) ON DELETE SET NULL )`); db.exec(`CREATE TABLE IF NOT EXISTS ticket_messages ( id INTEGER PRIMARY KEY AUTOINCREMENT, ticket_id INTEGER NOT NULL, author_id INTEGER, content TEXT NOT NULL, is_internal INTEGER NOT NULL DEFAULT 0, created_at DATETIME DEFAULT (datetime('now')), updated_at DATETIME DEFAULT (datetime('now')), FOREIGN KEY (ticket_id) REFERENCES tickets(id) ON DELETE CASCADE, FOREIGN KEY (author_id) REFERENCES users(id) ON DELETE SET NULL )`); db.exec(`CREATE TABLE IF NOT EXISTS ticket_events ( id INTEGER PRIMARY KEY AUTOINCREMENT, ticket_id INTEGER NOT NULL, actor_id INTEGER, event_type TEXT NOT NULL, field_name TEXT DEFAULT '', old_value TEXT DEFAULT '', new_value TEXT DEFAULT '', detail TEXT DEFAULT '', created_at DATETIME DEFAULT (datetime('now')), FOREIGN KEY (ticket_id) REFERENCES tickets(id) ON DELETE CASCADE, FOREIGN KEY (actor_id) REFERENCES users(id) ON DELETE SET NULL )`); const ensureColumns = (table, columns) => { const existing = new Set(db.prepare(`PRAGMA table_info(${table})`).all().map((column) => column.name)); for (const [name, definition] of columns) { if (!existing.has(name)) { try { db.exec(`ALTER TABLE ${table} ADD COLUMN ${name} ${definition}`); } catch {} } } }; ensureColumns('tickets', [ ['ticket_no', "TEXT NOT NULL DEFAULT ''"], ['requester_id', 'INTEGER'], ['subject', "TEXT NOT NULL DEFAULT ''"], ['description', "TEXT NOT NULL DEFAULT ''"], ['category', "TEXT NOT NULL DEFAULT 'other'"], ['priority', "TEXT NOT NULL DEFAULT 'normal'"], ['status', "TEXT NOT NULL DEFAULT 'open'"], ['source', "TEXT NOT NULL DEFAULT 'site'"], ['source_url', "TEXT DEFAULT ''"], ['source_type', "TEXT DEFAULT ''"], ['source_id', 'INTEGER DEFAULT 0'], ['browser_info', "TEXT DEFAULT ''"], ['assignee_id', 'INTEGER'], ['created_at', 'DATETIME DEFAULT NULL'], ['updated_at', 'DATETIME DEFAULT NULL'], ['last_reply_at', 'DATETIME DEFAULT NULL'], ['first_response_at', 'DATETIME DEFAULT NULL'], ['resolved_at', 'DATETIME DEFAULT NULL'], ['closed_at', 'DATETIME DEFAULT NULL'], ['revision', 'INTEGER NOT NULL DEFAULT 0'], ]); ensureColumns('ticket_messages', [ ['ticket_id', 'INTEGER NOT NULL DEFAULT 0'], ['author_id', 'INTEGER'], ['content', "TEXT NOT NULL DEFAULT ''"], ['is_internal', 'INTEGER NOT NULL DEFAULT 0'], ['created_at', 'DATETIME DEFAULT NULL'], ['updated_at', 'DATETIME DEFAULT NULL'], ]); ensureColumns('ticket_events', [ ['ticket_id', 'INTEGER NOT NULL DEFAULT 0'], ['actor_id', 'INTEGER'], ['event_type', "TEXT NOT NULL DEFAULT ''"], ['field_name', "TEXT DEFAULT ''"], ['old_value', "TEXT DEFAULT ''"], ['new_value', "TEXT DEFAULT ''"], ['detail', "TEXT DEFAULT ''"], ['created_at', 'DATETIME DEFAULT NULL'], ]); db.exec(`UPDATE tickets SET last_reply_at = NULL WHERE NOT EXISTS (SELECT 1 FROM ticket_messages WHERE ticket_messages.ticket_id = tickets.id)`); db.exec('CREATE INDEX IF NOT EXISTS idx_tickets_requester ON tickets(requester_id, updated_at DESC)'); db.exec('CREATE INDEX IF NOT EXISTS idx_tickets_status_updated ON tickets(status, updated_at DESC)'); db.exec('CREATE INDEX IF NOT EXISTS idx_tickets_assignee ON tickets(assignee_id, status, updated_at DESC)'); db.exec('CREATE INDEX IF NOT EXISTS idx_tickets_category ON tickets(category, updated_at DESC)'); db.exec('CREATE INDEX IF NOT EXISTS idx_ticket_messages_ticket ON ticket_messages(ticket_id, created_at ASC)'); db.exec('CREATE INDEX IF NOT EXISTS idx_ticket_events_ticket ON ticket_events(ticket_id, created_at ASC)'); } }, ]; for (const m of migrations) { if (current < m.version) { m.up(); db.exec('PRAGMA user_version = ' + m.version); } } } function seedAdmin() { if (!get('SELECT id FROM users WHERE username = ?', ['admin'])) { const hash = bcrypt.hashSync('admin123', 10); run('INSERT INTO users (username, password, role) VALUES (?, ?, ?)', ['admin', hash, 'admin']); console.log('Admin: admin / admin123'); } } function seedDefaults() { const defaults = { site_name: 'RainWeb', site_description: '个人云平台', site_url: '', primary_color: '#6750a4', recaptcha_site_key: '', recaptcha_secret_key: '', turnstile_site_key: '', turnstile_secret_key: '', smtp_host: '', smtp_port: '587', smtp_user: '', smtp_pass: '', smtp_from_email: '', smtp_from_name: 'RainWeb', theme_wallpaper: '', theme_wallpaper_scale: 'cover', theme_wallpaper_enabled: '1', nav_style: 'default', card_style: 'default', glass_blur: '20', glass_opacity: '0.6', captcha_login: '0', captcha_register: '0', captcha_forum: '0', captcha_type: 'builtin', rainid_enabled: '0', rainid_client_id: '', rainid_discovery_url: 'https://rainid.rainnya.asia/oauth', rainid_register_redirect: '0', site_favicon: 'data:image/svg+xml,🌧', music_embed_enabled: '0', music_embed_code: '', music_embed_position: 'right', music_embed_autohide: '0', music_embed_idle_timeout: '10', footer_style: 'classic', footer_copyright: '© 2026 Rainnya Blog All rights reserved', footer_powered: 'RainWeb Engine 强力驱动', footer_desc: '', // 页脚栏目自定义:JSON 字符串 [{title, links:[{label,url}]}];空串时前台回退现有硬编码栏目 footer_columns: '', comment_moderate: '0', comment_notify: '0', // 评论等次要作者位是否显示 UID 后缀('1'=显示,'0'=隐藏) show_uid_in_comments: '1', // 论坛游客可见开关:'1'=游客可浏览论坛;'0'=游客需登录(读接口 401 + SSR 404,SEO 权衡) forum_guest_visible: '1', // RSS 订阅源:feed_forum_enabled='1' 才对外暴露论坛 feed(默认关,用户没开时不暴露); // feed_show_full='1'=全文 / '0'=摘要;feed_max_items 为每源条数上限 feed_forum_enabled: '0', feed_show_full: '0', feed_max_items: '20', }; for (const [k, v] of Object.entries(defaults)) { if (!get('SELECT value FROM site_settings WHERE key = ?', [k])) { run('INSERT INTO site_settings (key, value) VALUES (?, ?)', [k, v]); } } } function run(sql, params = []) { try { const info = db.prepare(sql).run(...params); return Number(info.lastInsertRowid) || 0; } catch (e) { console.error('SQL run error:', e.message, 'SQL:', sql.substring(0, 80)); return 0; } } function get(sql, params = []) { try { const row = db.prepare(sql).get(...params); return row || null; } catch (e) { console.error('SQL get error:', e.message, 'SQL:', sql.substring(0, 80)); return null; } } function all(sql, params = []) { try { return db.prepare(sql).all(...params); } catch (e) { console.error('SQL all error:', e.message, 'SQL:', sql.substring(0, 80)); return []; } } function getSetting(key) { const r = get('SELECT value FROM site_settings WHERE key = ?', [key]); return r ? r.value : ''; } function setSetting(key, value) { const existing = get('SELECT key FROM site_settings WHERE key = ?', [key]); if (existing) run('UPDATE site_settings SET value = ? WHERE key = ?', [value, key]); else run('INSERT INTO site_settings (key, value) VALUES (?, ?)', [key, value]); } // 事务辅助:多步写操作原子执行(better-sqlite3 原生事务;失败自动 ROLLBACK) // 供 L8 改 PIN 重加密、以及任何需要"要么全成要么全不"的批量写场景使用。 function transaction(fn) { if (!db) throw new Error('Database not initialized'); return db.transaction(fn)(); } function seedSampleData() { const existing = get("SELECT COUNT(*) as c FROM forum_categories"); if (existing && existing.c > 0) return; // Forum categories run('INSERT INTO forum_categories (name, description, sort_order) VALUES (?, ?, ?)', ['技术讨论', '技术相关话题交流', 1]); run('INSERT INTO forum_categories (name, description, sort_order) VALUES (?, ?, ?)', ['闲聊灌水', '自由讨论区域', 2]); run('INSERT INTO forum_categories (name, description, sort_order) VALUES (?, ?, ?)', ['问题求助', '寻求帮助和解答', 3]); const adminUser = get('SELECT id FROM users WHERE username = ?', ['admin']); if (adminUser) { const aid = adminUser.id; // Forum posts run('INSERT INTO forum_posts (category_id, title, content, author_id, sub_category) VALUES (?, ?, ?, ?, ?)', [1, '欢迎来到论坛', '这是论坛的第一篇帖子!欢迎大家交流讨论。', aid, '分享']); run('INSERT INTO forum_posts (category_id, title, content, author_id, sub_category) VALUES (?, ?, ?, ?, ?)', [2, '今天天气真不错', '大家今天过得怎么样?来聊聊吧!', aid, '讨论']); // Blog posts const blogMarkdown = `## 欢迎使用 RainWeb\n\nRainWeb 是一个多功能的个人云平台,集成了 **博客、论坛、密码管理器** 等功能。\n\n- 🎨 Material Design 3 风格\n- 🌓 深色/浅色主题切换\n- 📧 邮箱验证注册\n- 🔒 密码管理器 (AES-256-GCM 加密)\n\n### 快速开始\n\n1. 点击右上角「登录」使用默认账号 \`admin / admin123\`\n2. 在管理后台配置 SMTP 邮件和 reCAPTCHA\n3. 在「面板链接」中添加你的各个管理后台\n4. 发布你的第一篇博客文章!`; run('INSERT INTO blog_posts (title, content, excerpt, author_id, use_markdown) VALUES (?, ?, ?, ?, ?)', ['欢迎使用 RainWeb', blogMarkdown, 'RainWeb 功能简介与快速开始', aid, 1]); const md2 = `## 自定义主题\n\n你可以在管理后台的「站点设置」中自定义:\n\n- **网站名称** - 显示在导航栏\n- **主题色** - 整个站点的主题颜色\n- **reCAPTCHA V2** - 注册验证\n- **SMTP 邮件** - 发送验证邮件`; run('INSERT INTO blog_posts (title, content, excerpt, author_id, use_markdown) VALUES (?, ?, ?, ?, ?)', ['自定义主题与配置', md2, '站点设置与个性化配置说明', aid, 1]); // Admin panel links run('INSERT INTO admin_links (title, url, embed_url, description, icon, category, sort_order) VALUES (?, ?, ?, ?, ?, ?, ?)', ['RainWeb 管理后台', '/admin.html', '', '系统管理配置', 'settings', '系统管理', 1]); run('INSERT INTO admin_links (title, url, embed_url, description, icon, category, sort_order) VALUES (?, ?, ?, ?, ?, ?, ?)', ['示例面板', 'https://example.com', 'https://example.com', '外部面板示例(可替换)', 'dashboard', '外部面板', 2]); } } module.exports = { getDb, run, get, all, getSetting, setSetting, transaction };