原先分类写死在 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 警告/致命错误。
349 lines
14 KiB
PHP
349 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' => '功能建议', '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);
|
||
}
|