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; } /** * 确保数据库目录存在防护文件(.htaccess 拒绝直读 + index.html 占位)。 * * 为什么不靠部署时上传:data/ 是可写目录,部署账号建它会让属主变成部署账号, * Web 用户反而写不进去。让 PHP 自己建 + 自己补防护,属主永远正确。 * 版本号机制与 uploads/ 一致:规则串里埋 `# fb-data-guard: vN`, * 低于当前版本就整份重写,避免老站点留着没防护的旧文件。 */ define('FB_DATA_GUARD_VER', 1); function fb_data_guard_rule() { return "# 数据库目录(由程序自动生成,请勿删除)\n" . "# fb-data-guard: v" . FB_DATA_GUARD_VER . "\n" . "# ★ 一律禁止 Web 直接访问:数据库文件绝不能被下载\n" . "Options -Indexes\n\n" . "\n Require all denied\n\n" . "\n Order allow,deny\n Deny from all\n\n\n" . "\n" . " \n Require all denied\n \n" . " \n Order allow,deny\n Deny from all\n \n" . "\n"; } function fb_db_ensure_guard($dir) { if (!is_dir($dir)) { return false; } $ht = $dir . '/.htaccess'; $need = true; if (is_file($ht)) { $head = @file_get_contents($ht, false, null, 0, 512); if ($head !== false && strpos($head, '# fb-data-guard: v' . FB_DATA_GUARD_VER) !== false) { $need = false; } } if ($need) { @file_put_contents($ht, fb_data_guard_rule()); @chmod($ht, 0644); } $idx = $dir . '/index.html'; if (!is_file($idx)) { @file_put_contents($idx, ''); @chmod($idx, 0644); } return true; } /** 便捷访问器 */ 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)'); fb_db_migrate_taxonomy($pdo); } /** * 软件 / 分类 / 状态 三张表(全部可在后台配置,不再写死在配置文件里) * * 结构:软件 → 分类(两级)。分类属于某个软件,不同软件可以有同名但互不相干的分类。 * 幂等:每次请求都会跑,靠 IF NOT EXISTS 与「空表才灌初始数据」保证重复执行结果一致。 */ function fb_db_migrate_taxonomy(PDO $pdo, $cfg = null) { /* ---- 软件 ---- */ $pdo->exec(<<<'SQL' CREATE TABLE IF NOT EXISTS software ( id INTEGER PRIMARY KEY AUTOINCREMENT, slug TEXT NOT NULL, name TEXT NOT NULL, note TEXT, sort_order INTEGER NOT NULL DEFAULT 0, enabled INTEGER NOT NULL DEFAULT 1, created_at INTEGER NOT NULL ); SQL ); $pdo->exec('CREATE UNIQUE INDEX IF NOT EXISTS idx_sw_slug ON software(slug)'); /* ---- 分类(属于某个软件)---- */ $pdo->exec(<<<'SQL' CREATE TABLE IF NOT EXISTS categories ( id INTEGER PRIMARY KEY AUTOINCREMENT, software_id INTEGER NOT NULL, slug TEXT NOT NULL, name TEXT NOT NULL, sort_order INTEGER NOT NULL DEFAULT 0, enabled INTEGER NOT NULL DEFAULT 1, created_at INTEGER NOT NULL, FOREIGN KEY(software_id) REFERENCES software(id) ON DELETE CASCADE ); SQL ); $pdo->exec('CREATE UNIQUE INDEX IF NOT EXISTS idx_cat_sw_slug ON categories(software_id, slug)'); /* ---- 状态 ---- */ // key_name 是 threads.status 里存的值;user_note 是发给用户看的解释(进邮件正文) $pdo->exec(<<<'SQL' CREATE TABLE IF NOT EXISTS statuses ( id INTEGER PRIMARY KEY AUTOINCREMENT, key_name TEXT NOT NULL, name TEXT NOT NULL, user_note TEXT, sort_order INTEGER NOT NULL DEFAULT 0, enabled INTEGER NOT NULL DEFAULT 1 ); SQL ); $pdo->exec('CREATE UNIQUE INDEX IF NOT EXISTS idx_st_key ON statuses(key_name)'); /* ---- threads 增加两列(老 category 文本列保留,不删)---- */ $cols = $pdo->query('PRAGMA table_info(threads)')->fetchAll(); $names = []; foreach ($cols as $c) { if (isset($c['name'])) { $names[(string)$c['name']] = true; } } if (!isset($names['software_id'])) { // 不用 REFERENCES:SQLite 加列时外键约束对既有行不友好,改由应用层保证 $pdo->exec('ALTER TABLE threads ADD COLUMN software_id INTEGER'); } if (!isset($names['category_id'])) { $pdo->exec('ALTER TABLE threads ADD COLUMN category_id INTEGER'); } $pdo->exec('CREATE INDEX IF NOT EXISTS idx_threads_sw ON threads(software_id)'); $pdo->exec('CREATE INDEX IF NOT EXISTS idx_threads_cid ON threads(category_id)'); /* ================= 初始数据(空表才灌,幂等) ================= */ $now = time(); $swId = 0; // 1) 软件:空表时建一个默认软件 if ((int)$pdo->query('SELECT COUNT(*) FROM software')->fetchColumn() === 0) { $siteName = ''; try { $siteName = (string)fb_cfg('site_name', ''); } catch (\Throwable $e) { $siteName = ''; } if (trim($siteName) === '') { $siteName = '我的软件'; } $st = $pdo->prepare('INSERT INTO software(slug, name, note, sort_order, enabled, created_at) VALUES(?,?,?,?,1,?)'); $st->execute(['default', $siteName, '默认软件,可在后台改名', 0, $now]); $swId = (int)$pdo->lastInsertId(); } else { $swId = (int)$pdo->query('SELECT id FROM software ORDER BY sort_order, id LIMIT 1')->fetchColumn(); } // 2) 分类:空表时把配置文件里的老 categories 原样搬进来(保住既有五个分类) if ((int)$pdo->query('SELECT COUNT(*) FROM categories')->fetchColumn() === 0 && $swId > 0) { $old = []; try { $old = (array)fb_cfg('categories', []); } catch (\Throwable $e) { $old = []; } if (!$old) { // 出厂分类:与改造前配置里的五个完全一致,一个都不能少 $old = [ 'usage' => '使用问题', 'bug' => 'Bug 反馈', 'feature' => '功能建议', 'sync' => '同步问题', 'other' => '其他', ]; } $i = 0; $st = $pdo->prepare('INSERT INTO categories(software_id, slug, name, sort_order, enabled, created_at) VALUES(?,?,?,?,1,?)'); foreach ($old as $k => $label) { $slug = fb_sw_slugify($k); if ($slug === '') { $slug = 'c' . $i; } $st->execute([$swId, $slug, (string)$label, $i, $now]); $i++; } } // 3) 状态:空表时灌 5 个(4 个原有 + 新增「不予考虑」) if ((int)$pdo->query('SELECT COUNT(*) FROM statuses')->fetchColumn() === 0) { $rows = [ ['open', '待处理', '您提交的反馈已收到,我们正在处理中。', 0], ['doing', '处理中', '我们正在跟进这个问题,有进展会在这里同步。', 1], ['resolved', '已解决', '这个问题已经处理好了,欢迎回来验证一下。', 2], ['closed', '已关闭', '这个反馈已关闭,如有新情况可以重新提交。', 3], ['wontfix', '不予考虑', '经评估这个反馈暂不处理,感谢你的建议。', 4], ]; $st = $pdo->prepare('INSERT INTO statuses(key_name, name, user_note, sort_order, enabled) VALUES(?,?,?,?,1)'); foreach ($rows as $r) { $st->execute($r); } } // 4) 回填老帖子:software_id / category_id(只填 NULL 的,重复执行无副作用) if ($swId > 0) { $pdo->prepare('UPDATE threads SET software_id = ? WHERE software_id IS NULL')->execute([$swId]); } $need = $pdo->prepare('SELECT id, category FROM threads WHERE category_id IS NULL AND software_id = ?'); $need->execute([$swId]); $rows = $need->fetchAll(); if ($rows) { $find = $pdo->prepare('SELECT id FROM categories WHERE software_id = ? AND slug = ? LIMIT 1'); $set = $pdo->prepare('UPDATE threads SET category_id = ? WHERE id = ?'); foreach ($rows as $r) { $slug = fb_sw_slugify((string)$r['category']); $find->execute([$swId, $slug]); $cid = $find->fetchColumn(); if ($cid !== false && $cid !== null) { $set->execute([(int)$cid, (int)$r['id']]); } } } } /** 生成 URL 友好的 slug(只留小写字母数字和连字符) */ /** * 生成 URL 友好的 slug(只留小写字母数字和连字符)。 * * ⚠️ 纯中文名(如「同步日历」)过滤后是空串。早期调用方拿到空串就报 * 「请填写英文标识」,但界面上写的是「留空自动生成」—— 自相矛盾。 * 所以这里再加一层兜底:拿不到 ASCII 就退化成 前缀+序号, * 保证「留空」真的能自动生成,不用用户去猜。 * * @param string $s 原始名称 * @param string $prefix 兜底前缀,如 'sw' / 'cat';传空串表示不兜底 * @param int $seq 兜底序号(调用方传"当前最大 id + 1") */ function fb_sw_slugify($s, $prefix = '', $seq = 1) { $s = strtolower(trim((string)$s)); $s = preg_replace('/[^a-z0-9_-]+/', '-', $s); $s = trim((string)$s, '-'); $s = $s === '' ? '' : substr($s, 0, 40); if ($s === '' && $prefix !== '') { $s = $prefix . max(1, (int)$seq); } return $s; }