2026-09-28 20:34:37 +08:00
|
|
|
|
<?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);
|
2026-11-01 15:07:53 +08:00
|
|
|
|
// 目录不存在就自己建:PHP 以 Web 用户身份运行,建出来的属主天然正确,
|
|
|
|
|
|
// 不需要部署账号预先建好、也不需要事后 chown。
|
2026-09-28 20:34:37 +08:00
|
|
|
|
if (!is_dir($dir)) { @mkdir($dir, 0755, true); }
|
2026-11-01 15:07:53 +08:00
|
|
|
|
// 每次进来都确保防护文件在位(函数内部有版本判断,命中后几乎零开销)
|
|
|
|
|
|
fb_db_ensure_guard($dir);
|
2026-09-28 20:34:37 +08:00
|
|
|
|
|
|
|
|
|
|
$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;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-11-01 15:07:53 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* 确保数据库目录存在防护文件(.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"
|
|
|
|
|
|
. "<IfModule mod_authz_core.c>\n Require all denied\n</IfModule>\n"
|
|
|
|
|
|
. "<IfModule !mod_authz_core.c>\n Order allow,deny\n Deny from all\n</IfModule>\n\n"
|
|
|
|
|
|
. "<FilesMatch \"\\.(db|db-wal|db-shm|sqlite|sqlite3|php|htaccess)$\">\n"
|
|
|
|
|
|
. " <IfModule mod_authz_core.c>\n Require all denied\n </IfModule>\n"
|
|
|
|
|
|
. " <IfModule !mod_authz_core.c>\n Order allow,deny\n Deny from all\n </IfModule>\n"
|
|
|
|
|
|
. "</FilesMatch>\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;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-28 20:34:37 +08:00
|
|
|
|
/** 便捷访问器 */
|
|
|
|
|
|
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)');
|
2026-09-28 22:12:28 +08:00
|
|
|
|
// 代理读取要按对象键反查(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)');
|
2026-09-28 20:34:37 +08:00
|
|
|
|
|
|
|
|
|
|
$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)');
|
2026-11-01 11:35:55 +08:00
|
|
|
|
|
|
|
|
|
|
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) {
|
2026-11-01 12:03:11 +08:00
|
|
|
|
// 出厂分类:与改造前配置里的五个完全一致,一个都不能少
|
|
|
|
|
|
$old = [
|
|
|
|
|
|
'usage' => '使用问题',
|
|
|
|
|
|
'bug' => 'Bug 反馈',
|
|
|
|
|
|
'feature' => '功能建议',
|
|
|
|
|
|
'sync' => '同步问题',
|
|
|
|
|
|
'other' => '其他',
|
|
|
|
|
|
];
|
2026-11-01 11:35:55 +08:00
|
|
|
|
}
|
|
|
|
|
|
$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);
|
2026-09-28 20:34:37 +08:00
|
|
|
|
}
|