Files
FeedLite/lib/db.php
T
laoyang 59f7466ae4 多软件归属:软件→分类两级,配置全部入库 (#1)
原先分类写死在 config.example.php,后台改不了;也没有「软件」概念。
现在改为数据库驱动的两级结构,后台可配(管理界面待做)。

## 数据层(lib/db.php)
- 新增 software / categories / statuses 三张表
- categories.software_id 外键:每个软件有自己的分类集合,可同名互不干扰
- threads 加 software_id / category_id;老 category 文本列保留不删(可回退)
- fb_db_migrate_taxonomy() 幂等迁移:建表 → 空表灌初始数据 → 回填老帖子

## 迁移实测
- 老 5 个分类(使用问题/Bug 反馈/功能建议/同步问题/其他)原样落进默认软件
- 老帖子按 category slug 回填 category_id,匹配不到的留 NULL

## 状态(5 个)
待处理 / 处理中 / 已解决 / 已关闭 / 不予考虑(wontfix)
每个状态带 user_note「给用户看的话」,后台可改,将进邮件正文。
待处理 = 刚提交:您提交的反馈已收到,我们正在处理中。

## 前台
- index.php:软件切换条 + 分类 chip(一级软件、二级分类)
- new.php:选软件、分类下拉随软件变、服务端校验分类归属
- URL:?sw=<slug>&cat=<slug>;老链接 ?cat=xxx 解析不到时退回全部,200 不 404

## 界面一致性
按用户要求去掉「单软件时隐藏软件选择器」的逻辑:
所有界面一致,配一个就列一个,配多个就列多个。

## 兼容
fb_categories() 现在必须传软件 id;fb_statuses() 返回行数组,
另有 fb_status_keys() 取 key=>name 映射。

验证:WSL 内临时实例实测——单软件显示 1 个软件条;两软件各显示
自己的分类集合与帖子,互不串;无 PHP 警告/致命错误。
2026-11-01 11:35:55 +08:00

349 lines
14 KiB
PHP
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.
<?php
/**
* SQLite 数据库初始化与迁移
*/
if (!defined('FB_ROOT')) { http_response_code(403); exit('forbidden'); }
/** 打开(或创建)数据库连接,单例 */
function fb_db_init($cfg) {
static $pdo = null;
if ($pdo instanceof PDO) { return $pdo; }
$path = (string)$cfg['db_path'];
$dir = dirname($path);
if (!is_dir($dir)) { @mkdir($dir, 0755, true); }
$fresh = !file_exists($path);
$pdo = new PDO('sqlite:' . $path, null, null, [
PDO::ATTR_ERRMODE => 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)');
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' => '功能建议', '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(只留小写字母数字和连字符) */
function fb_sw_slugify($s) {
$s = strtolower(trim((string)$s));
$s = preg_replace('/[^a-z0-9_-]+/', '-', $s);
$s = trim((string)$s, '-');
return $s === '' ? '' : substr($s, 0, 40);
}