Files
FeedLite/lib/db.php
T
laoyang 55d2dfa46b 后台管理界面:软件/分类/状态可配,反馈可改挂归属 (#1)
## 新增 lib/admin_taxonomy.php
- 软件:增删改、排序、启停;新增时自动预置 Bug/功能建议/其他 三个分类
- 分类:在选定软件下增删改、排序、启停
- 状态:改名、改「给用户看的话」、排序、启停;标识不可改(历史数据靠它对应)
- 反馈改挂:th_move,可把反馈移到别的软件/分类,跨软件的非法分类会被拒

## 安全与边界
- 所有动作走 CSRF 校验(沿用现有机制)
- 不能删最后一个软件 / 没有分类的软件至少要留一个启用分类
- 有反馈挂着的软件或分类不能删,提示改为停用(停用后前台消失、数据保留)
- th_move 加入 fb_admin_is_taxonomy_act 白名单,否则会被「问题不存在」静默吞掉

## admin.php 接线
- 引入模块 + 动作分发 + tab 白名单加 taxonomy/statuses + 导航两项
- 前置检查放行不依赖 $t 的动作(否则新增软件报「问题不存在」)
- 全部问题列表:加软件筛选、分类跟着软件走;修掉三处仍在用旧
  fb_statuses()/fb_categories() 的遍历(那会渲染出错)
- 详情页加「归属」表单,软件徽章可显示

## 其他文件
- api.php:状态/分类改数据库;新增 ?sw= / ?software_id= / ?cid= 过滤,
  老的 ?category=<slug> 仍可用(在所属软件内解析)
- thread.php:详情页显示软件与分类徽章
- config.example.php:移除 categories / statuses,改由数据库管理

## 修掉两个真实 bug
1. 移除配置后出厂分类兜底只剩 4 个,漏了「同步问题」—— 建库后核对数量才发现
2. th_move 不在白名单,POST 后返回 200 却静默不生效

验证:后台 e2e 29/29;改挂与联动 13/13;PHP 8.5 全部入口 0 弃用;
19 个 PHP 文件语法全过。
2026-11-01 12:03:11 +08:00

356 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' => '功能建议',
'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(只留小写字母数字和连字符) */
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);
}