## 新增 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 文件语法全过。
356 lines
14 KiB
PHP
356 lines
14 KiB
PHP
<?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);
|
||
}
|