PDO::ERRMODE_EXCEPTION, PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC, ]); $pdo->exec('PRAGMA journal_mode = WAL'); $pdo->exec('PRAGMA foreign_keys = ON'); $pdo->exec('PRAGMA busy_timeout = 5000'); fb_db_migrate($pdo); if ($fresh) { @chmod($path, 0640); } return $pdo; } /** 便捷访问器 */ function fb_db() { return fb_db_init(fb_cfg()); } /** 建表(幂等) */ function fb_db_migrate(PDO $pdo) { $pdo->exec(<<<'SQL' CREATE TABLE IF NOT EXISTS threads ( id INTEGER PRIMARY KEY AUTOINCREMENT, title TEXT NOT NULL, body TEXT NOT NULL, category TEXT NOT NULL DEFAULT 'other', author_name TEXT NOT NULL, author_email TEXT NOT NULL, author_phone TEXT, status TEXT NOT NULL DEFAULT 'open', reply_count INTEGER NOT NULL DEFAULT 0, created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL, ip_hash TEXT ); SQL ); $pdo->exec('CREATE INDEX IF NOT EXISTS idx_threads_updated ON threads(updated_at DESC)'); $pdo->exec('CREATE INDEX IF NOT EXISTS idx_threads_cat ON threads(category)'); $pdo->exec(<<<'SQL' CREATE TABLE IF NOT EXISTS replies ( id INTEGER PRIMARY KEY AUTOINCREMENT, thread_id INTEGER NOT NULL, body TEXT NOT NULL, author_name TEXT NOT NULL, author_email TEXT, is_admin INTEGER NOT NULL DEFAULT 0, created_at INTEGER NOT NULL, ip_hash TEXT, FOREIGN KEY(thread_id) REFERENCES threads(id) ON DELETE CASCADE ); SQL ); $pdo->exec('CREATE INDEX IF NOT EXISTS idx_replies_thread ON replies(thread_id, created_at)'); $pdo->exec(<<<'SQL' CREATE TABLE IF NOT EXISTS notify_log ( id INTEGER PRIMARY KEY AUTOINCREMENT, thread_id INTEGER NOT NULL, reply_id INTEGER NOT NULL, to_email TEXT NOT NULL, created_at INTEGER NOT NULL, ok INTEGER NOT NULL DEFAULT 0, tries INTEGER NOT NULL DEFAULT 0, err TEXT ); SQL ); $pdo->exec('CREATE INDEX IF NOT EXISTS idx_notify_thread ON notify_log(thread_id, to_email)'); // 兼容早期建库:确保 notify_log.tries 存在 $cols = $pdo->query('PRAGMA table_info(notify_log)')->fetchAll(); $has = false; foreach ($cols as $c) { if (isset($c['name']) && $c['name'] === 'tries') { $has = true; break; } } if (!$has) { $pdo->exec('ALTER TABLE notify_log ADD COLUMN tries INTEGER NOT NULL DEFAULT 0'); } $pdo->exec(<<<'SQL' CREATE TABLE IF NOT EXISTS attachments ( id INTEGER PRIMARY KEY AUTOINCREMENT, thread_id INTEGER NOT NULL, reply_id INTEGER, kind TEXT NOT NULL DEFAULT 'image', oss_key TEXT NOT NULL, mime TEXT, size INTEGER NOT NULL DEFAULT 0, width INTEGER, height INTEGER, orig_name TEXT, seq INTEGER NOT NULL DEFAULT 0, created_at INTEGER NOT NULL, FOREIGN KEY(thread_id) REFERENCES threads(id) ON DELETE CASCADE, FOREIGN KEY(reply_id) REFERENCES replies(id) ON DELETE CASCADE ); SQL ); $pdo->exec('CREATE INDEX IF NOT EXISTS idx_att_thread ON attachments(thread_id, seq)'); $pdo->exec('CREATE INDEX IF NOT EXISTS idx_att_reply ON attachments(reply_id)'); $pdo->exec('CREATE INDEX IF NOT EXISTS idx_att_age ON attachments(created_at)'); // 代理读取要按对象键反查(media.php 的鉴权),必须有索引否则每次读图都全表扫描 $pdo->exec('CREATE INDEX IF NOT EXISTS idx_att_key ON attachments(oss_key)'); /* ---- 待入库附件 ---- * 私有桶 + 内网 Endpoint 下,附件一律经 media.php 代理输出,而代理必须鉴权。 * 但「上传成功」发生在「表单提交入库」之前 —— 前端上传完就要立刻显示缩略图, * 此时 attachments 表里还没有这条记录。若只认 attachments 表,缩略图必然 403。 * 所以上传成功时先在这张表登记一条,media.php 认「已入库 或 本人刚上传的待入库」, * 提交表单后由 fb_attach_save() 转正,过期条目由 cron.php 清理。 */ $pdo->exec(<<<'SQL' CREATE TABLE IF NOT EXISTS pending_attachments ( id INTEGER PRIMARY KEY AUTOINCREMENT, oss_key TEXT NOT NULL, kind TEXT NOT NULL DEFAULT 'image', mime TEXT, size INTEGER NOT NULL DEFAULT 0, ip_hash TEXT, created_at INTEGER NOT NULL ); SQL ); $pdo->exec('CREATE INDEX IF NOT EXISTS idx_patt_key ON pending_attachments(oss_key)'); $pdo->exec('CREATE INDEX IF NOT EXISTS idx_patt_age ON pending_attachments(created_at)'); $pdo->exec(<<<'SQL' CREATE TABLE IF NOT EXISTS rate_limit ( id INTEGER PRIMARY KEY AUTOINCREMENT, ip_hash TEXT NOT NULL, action TEXT NOT NULL, created_at INTEGER NOT NULL ); SQL ); $pdo->exec('CREATE INDEX IF NOT EXISTS idx_rl ON rate_limit(ip_hash, action, created_at)'); /* ---- 站点设置(后台可改,覆盖 config.php 的同名项)---- * 只存「后台允许修改」的键,见 util.php 的 fb_setting_keys()。 * value 存 JSON,读取时自动还原类型。 */ $pdo->exec(<<<'SQL' CREATE TABLE IF NOT EXISTS settings ( key TEXT PRIMARY KEY, value TEXT, updated_at INTEGER ); SQL ); /* ---- 管理员操作日志 ---- */ $pdo->exec(<<<'SQL' CREATE TABLE IF NOT EXISTS mod_log ( id INTEGER PRIMARY KEY AUTOINCREMENT, thread_id INTEGER, action TEXT NOT NULL, detail TEXT, created_at INTEGER NOT NULL, ip_hash TEXT ); SQL ); $pdo->exec('CREATE INDEX IF NOT EXISTS idx_modlog_thread ON mod_log(thread_id, created_at DESC)'); $pdo->exec('CREATE INDEX IF NOT EXISTS idx_modlog_time ON mod_log(created_at DESC)'); // ---- 审核(先审后显)---- // visible:0=待审核/未公开,1=审核通过已公开 // mod_note:审核说明(关闭时写给提问人的解释,会随邮件发出) // moderated_at:审核时间 $tcols = $pdo->query('PRAGMA table_info(threads)')->fetchAll(); $tnames = []; foreach ($tcols as $c) { if (isset($c['name'])) { $tnames[(string)$c['name']] = true; } } if (!isset($tnames['visible'])) { $pdo->exec('ALTER TABLE threads ADD COLUMN visible INTEGER NOT NULL DEFAULT 0'); // 功能上线前已存在的内容视为"已审核通过",保持原来的可见性 $pdo->exec('UPDATE threads SET visible = 1'); } if (!isset($tnames['mod_note'])) { $pdo->exec('ALTER TABLE threads ADD COLUMN mod_note TEXT'); } if (!isset($tnames['moderated_at'])) { $pdo->exec('ALTER TABLE threads ADD COLUMN moderated_at INTEGER'); } $pdo->exec('CREATE INDEX IF NOT EXISTS idx_threads_visible ON threads(visible, created_at DESC)'); }