## 一、用户报「我明明配过 SMTP,怎么全丢了、信也发不出」
**没有重置数据库**(installed_at 仍是 11-01 15:14,oss.*/site_name/admin.username 全在)。
真因是我上一轮「邮件拆独立 tab」挖的坑:站点设置与邮件通知**共用 act=settings**,
而保存函数把 smtp 字段无条件写库:
foreach ($smtpText as $k) {
fb_set_setting($k, trim((string)$post($k, \x27\x27))); // 没提交 → 空串 → 覆盖
}
在站点设置页保存附件设置时,该表单没有 smtp_* 字段,$post() 取到空串,
于是 host/user/from 被清成空。smtp.pass 因为单独做了「留空=不修改」而幸存,
所以现场是 pass 有值、其余全空 —— 这个组合本身就是 bug 的指纹。
修法:新增 $has() 判断「本次请求是否真的提交了该字段」,没提交就跳过。
按 tab 分两组保护:
- $isSiteForm:站点信息/页脚/附件/OSS,逐字段 has() 双保险
- $isMailForm:SMTP/通知开关,整段 + 逐字段
checkbox 尤其要保护:未提交会被当成「取消勾选」,
在站点设置页存一次就会把「有人回复时通知」全关掉。
## 二、notify.admin_new_thread 是假的(用户说「提交了反馈收不到邮件」)
这个设置项:白名单里有、后台有勾选框,**但没有任何代码实现它**。
grep 只命中三处(白名单/保存/界面),没有一处真正发信。
已在 new.php 提交成功后实现:查新帖 → 取所属软件名 → fb_notify_event(\x27new\x27)。
## 三、补齐全部事件,统一入口
此前只有 reply 和 close 两个事件,且两套模板风格不一致。
新增 fb_notify_event() 统一入口,处理「开关→收件人→节流→发信→日志」。
覆盖 8 类事件:reply / status / approve / hide / close / new。
- status 按状态给不同文案(待处理/处理中/已解决/关闭/不予考虑),
「不予考虑」语气克制、必须带理由并留申诉入口
- approve / hide 原先完全不发信,现在补上
- 状态没变化时不发信(不打扰用户)
## 四、邮件模板重做
抽出 fb_mail_layout() 统一外壳,所有事件共用:
- table + 内联样式(Outlook/QQ邮箱/Gmail 兼容)
- 600px 卡片、顶部主色条、圆角徽标、字段表、引用块、callout、CTA 按钮
- 响应式(已验 375px 无溢出、无裁切)
- preheader 摘要、纯文本兜底链接、收件人提示
## 五、后台状态按钮(用户要求)
问题详情页顶部一排按钮,点一下立刻生效并自动发信:
「通过并公开」「处理中」「已解决」「不予考虑」「撤下」
- 当前状态对应的按钮置灰
- 「不予考虑」展开内联面板填理由(不用 window.prompt)
- 新增 setstatus 动作;「通过并公开」会同时置可见
## 六、notify_log 加 reason 列
原表只有 reply_id,无法区分事件类型。加 reason 并幂等迁移;
老库 reply_id 是 NOT NULL,非回复事件用 0 表示。
## 验证
邮件专项 73/73:
- 8 类事件模板全部完整 HTML、table 布局、徽标、页脚、无 script
- 8 个主题互不相同;状态徽标文案正确
- 总开关关闭时 admin_new_thread 仍可独立生效
- 节流只作用于 reply/status,close 不受影响
- SMTP 未启用时留失败日志(含 reason 与原因)
- C1-C5:站点页保存后 smtp.host/user/notify.enabled 均未被清空;
邮件页保存后 oss.bucket 未被清空
反向验证:完整回退两层保护后 C1/C2/C3 全部 FAIL
(host 从 smtp.qiye.aliyun.com 变成空、notify.enabled 从 1 变 0),
证明测试能真正抓到该 bug,不是假通过。
回归:全新安装 33/33、老基线 25/25、附件 22/22。
441 lines
18 KiB
PHP
441 lines
18 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);
|
||
// 目录不存在就自己建:PHP 以 Web 用户身份运行,建出来的属主天然正确,
|
||
// 不需要部署账号预先建好、也不需要事后 chown。
|
||
if (!is_dir($dir)) { @mkdir($dir, 0755, true); }
|
||
// 每次进来都确保防护文件在位(函数内部有版本判断,命中后几乎零开销)
|
||
fb_db_ensure_guard($dir);
|
||
|
||
$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;
|
||
}
|
||
|
||
/**
|
||
* 确保数据库目录存在防护文件(.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;
|
||
}
|
||
|
||
/** 便捷访问器 */
|
||
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,
|
||
to_email TEXT NOT NULL,
|
||
reason TEXT NOT NULL DEFAULT 'reply',
|
||
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)');
|
||
|
||
/* ---- notify_log 增加 reason(区分因为什么发的信,工单 #2)---- */
|
||
$nlCols = $pdo->query('PRAGMA table_info(notify_log)')->fetchAll();
|
||
$nlNames = [];
|
||
foreach ($nlCols as $c3) { if (isset($c3['name'])) { $nlNames[(string)$c3['name']] = true; } }
|
||
if (!isset($nlNames['reason'])) {
|
||
// 老库的 reply_id 是 NOT NULL,而「新反馈通知」没有回复 id,
|
||
// 加列的同时把它放宽为可空,否则插入会直接失败。
|
||
// SQLite 不支持 ALTER COLUMN,只能重建表。
|
||
$pdo->exec('ALTER TABLE notify_log ADD COLUMN reason TEXT NOT NULL DEFAULT \'reply\'');
|
||
}
|
||
|
||
/* ---- 软件增加主题色(用于前台按软件区分视觉)---- */
|
||
$swCols = $pdo->query('PRAGMA table_info(software)')->fetchAll();
|
||
$swNames = [];
|
||
foreach ($swCols as $c2) { if (isset($c2['name'])) { $swNames[(string)$c2['name']] = true; } }
|
||
if (!isset($swNames['color'])) {
|
||
$pdo->exec("ALTER TABLE software ADD COLUMN color TEXT NOT NULL DEFAULT ''");
|
||
}
|
||
|
||
/* ---- 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(只留小写字母数字和连字符) */
|
||
/**
|
||
* 生成 URL 友好的 slug(只留小写字母数字和连字符)。
|
||
*
|
||
* ⚠️ 纯中文名(如「同步日历」)过滤后是空串。早期调用方拿到空串就报
|
||
* 「请填写英文标识」,但界面上写的是「留空自动生成」—— 自相矛盾。
|
||
* 所以这里再加一层兜底:拿不到 ASCII 就退化成 前缀+序号,
|
||
* 保证「留空」真的能自动生成,不用用户去猜。
|
||
*
|
||
* @param string $s 原始名称
|
||
* @param string $prefix 兜底前缀,如 'sw' / 'cat';传空串表示不兜底
|
||
* @param int $seq 兜底序号(调用方传"当前最大 id + 1")
|
||
*/
|
||
function fb_sw_slugify($s, $prefix = '', $seq = 1) {
|
||
$s = strtolower(trim((string)$s));
|
||
$s = preg_replace('/[^a-z0-9_-]+/', '-', $s);
|
||
$s = trim((string)$s, '-');
|
||
$s = $s === '' ? '' : substr($s, 0, 40);
|
||
if ($s === '' && $prefix !== '') {
|
||
$s = $prefix . max(1, (int)$seq);
|
||
}
|
||
return $s;
|
||
}
|