修复:跨 tab 保存互相清空配置;补齐全流程邮件通知与统一模板 (#2)

## 一、用户报「我明明配过 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。
This commit is contained in:
2026-11-02 22:57:00 +08:00
parent 0365098fa4
commit 4033170ad7
8 changed files with 1090 additions and 75 deletions
+237 -18
View File
@@ -120,7 +120,14 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['act'])) {
$pdo->prepare('UPDATE threads SET visible = 1, status = ?, mod_note = NULL, moderated_at = ? WHERE id = ?')
->execute([$newStatus, time(), $id]);
fb_mod_log('approve', $id, '通过审核并公开');
$msg = '#' . $id . ' 已通过审核,现已公开显示。';
// 通知提交人「已发布」(工单 #2)
$errA = '';
try {
list($okA, $errA) = fb_notify_event($pdo, 'approve', $t);
} catch (Throwable $e) { $errA = '发信异常'; }
$msg = '#' . $id . ' 已通过审核,现已公开显示。'
. ($errA === '' ? '已发邮件通知提交人。' : '(邮件未发出:' . $errA . ')');
} elseif ($act === 'close') {
$note = trim(isset($_POST['note']) ? (string)$_POST['note'] : '');
@@ -141,7 +148,61 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['act'])) {
} elseif ($act === 'hide') {
$pdo->prepare('UPDATE threads SET visible = 0, moderated_at = ? WHERE id = ?')->execute([time(), $id]);
fb_mod_log('hide', $id, '撤下(转为不公开)');
$msg = '#' . $id . ' 已撤下(转为不公开)。';
$noteH = trim((string)(isset($_POST['note']) ? $_POST['note'] : ''));
$errH = '';
try {
list($okH, $errH) = fb_notify_event($pdo, 'hide', $t, ['note' => $noteH]);
} catch (Throwable $e) { $errH = '发信异常'; }
$msg = '#' . $id . ' 已撤下(转为不公开)。'
. ($errH === '' ? '已发邮件通知提交人。' : '(邮件未发出:' . $errH . ')');
} elseif ($act === 'setstatus') {
/* 问题详情页顶部那一排按钮走的动作。
*
* 与 status 的区别:
* - status 是「状态下拉框 + 公开勾选」的完整表单,需要手动选
* - setstatus 是「点一下立刻生效」,状态与可见性由按钮本身决定
*
* 「通过并公开」= 可见 + 状态保持;其余状态只改状态不动可见性,
* 避免误把已公开的问题撤下来(用户没这个意图)。
*/
$statuses = fb_statuses();
$new = (string)(isset($_POST['status']) ? $_POST['status'] : '');
$note = trim((string)(isset($_POST['note']) ? $_POST['note'] : ''));
$alsoShow = !empty($_POST['also_show']); // 「通过并公开」置 1
if (!isset($statuses[$new])) {
$msg = '未知的状态。';
$msgType = 'error';
} else {
$old = (string)$t['status'];
$vis = $alsoShow ? 1 : (int)$t['visible'];
$pdo->prepare('UPDATE threads SET status = ?, visible = ?, updated_at = ? WHERE id = ?')
->execute([$new, $vis, time(), $id]);
fb_mod_log('status', $id,
'一键改为 ' . $statuses[$new] . ($alsoShow ? ',并公开' : '')
. ($note !== '' ? ',说明:' . fb_excerpt($note, 60) : ''));
$errS = '';
if ($old !== $new || $alsoShow) {
try {
// 「通过并公开」用 approve 事件(文案是「已发布」)
$reason = $alsoShow ? 'approve' : 'status';
list($okS, $errS) = fb_notify_event($pdo, $reason, $t, [
'old_status' => $old,
'new_status' => $new,
'note' => $note,
]);
} catch (Throwable $e) { $errS = '发信异常'; }
}
$msg = '#' . $id . ' 已改为「' . $statuses[$new] . '」'
. ($alsoShow ? '并公开显示' : '') . '。'
. ($old !== $new || $alsoShow
? ($errS === '' ? '已发邮件通知提交人。' : '(邮件未发出:' . $errS . ')')
: '状态未变化,未发信。');
$msgType = $errS === '' ? 'ok' : 'error';
}
} elseif ($act === 'status') {
$statuses = fb_statuses();
@@ -150,11 +211,29 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['act'])) {
$msg = '未知的状态。';
$msgType = 'error';
} else {
$vis = isset($_POST['visible']) ? 1 : 0;
$vis = isset($_POST['visible']) ? 1 : 0;
$old = (string)$t['status'];
$note = trim((string)(isset($_POST['note']) ? $_POST['note'] : ''));
$pdo->prepare('UPDATE threads SET status = ?, visible = ?, updated_at = ? WHERE id = ?')
->execute([$new, $vis, time(), $id]);
fb_mod_log('status', $id, '状态改为 ' . $statuses[$new] . ',公开=' . $vis);
$msg = '#' . $id . ' 已更新:状态「' . $statuses[$new] . '」,' . ($vis ? '公开' : '不公开') . '。';
// 状态变了才发信(没变就别打扰用户)
$errS = '';
if ($old !== $new) {
try {
list($okS, $errS) = fb_notify_event($pdo, 'status', $t, [
'old_status' => $old,
'new_status' => $new,
'note' => $note,
]);
} catch (Throwable $e) { $errS = '发信异常'; }
}
$msg = '#' . $id . ' 已更新:状态「' . $statuses[$new] . '」,'
. ($vis ? '公开' : '不公开') . '。'
. ($old !== $new
? ($errS === '' ? '已发邮件通知提交人。' : '(邮件未发出:' . $errS . ')')
: '状态未变化,未发信。');
}
} elseif ($act === 'reply') {
@@ -268,16 +347,43 @@ function fb_admin_save_settings() {
return $def;
};
/**
* 这个字段本次请求到底有没有被提交?
*
* 🔴 为什么必须有这个:后台有多个 tab(站点设置 / 邮件通知),
* 但它们**共用同一个保存处理**(都提交 act=settings)。
* 如果在站点设置页点保存,该表单里根本没有 smtp_* 字段,
* `$post('smtp.host', '')` 会取到空串,然后被写进数据库 ——
* **把用户之前填好的邮件配置清空**。
*
* 同类事故还会发生在 checkbox 上:没提交 = 视为取消勾选,
* 于是另一个 tab 保存一次就把通知开关全关掉。
*
* 所以写库前一律先问 has():没提交就跳过,绝不写空值。
*/
$has = function ($k) {
if (array_key_exists($k, $_POST)) { return true; }
return array_key_exists(str_replace('.', '_', $k), $_POST);
};
// ============ 站点设置组(含站点信息/页脚/附件/OSS)============
// 🔴 整组包在 has() 判断里:邮件页只提交邮件字段,没有 site_*/oss_*/upload_*,
// 无条件写入会把它们全清空。反过来也一样。
$isSiteForm = $has('oss_endpoint') || $has('site_name')
|| $has('attachment_storage') || $has('site_desc');
if ($isSiteForm) {
// 站点信息(对外展示,用于说明「本系统为哪个软件提供反馈服务」)
// 留空 = 删除该设置项,回落到 config.php 的出厂值,便于恢复默认。
foreach (['site_name', 'site_desc'] as $k) {
if (!$has($k)) { continue; }
$v = trim((string)$post($k, ''));
if ($v === '') { fb_unset_setting($k); }
else { fb_set_setting($k, $v); }
}
// 页脚:是否显示「由 FeedLite 驱动」这句版权,以及链接指向的开源仓库地址
fb_set_setting('footer.show_credit', $post('footer_show_credit') ? 1 : 0);
if ($has('footer_show_credit')) { fb_set_setting('footer.show_credit', $post('footer_show_credit') ? 1 : 0); }
$curl = trim((string)$post('footer_credit_url', ''));
// 只接受 http/https:这条会被直接渲染成 <a href>,不能让 javascript:/data: 混进来
if ($curl !== '' && !preg_match('~^https?://~i', $curl)) {
@@ -296,13 +402,14 @@ function fb_admin_save_settings() {
'upload.image_max_side' => [480, 4096],
];
foreach ($ints as $k => $range) {
if (!$has($k)) { continue; }
$v = (int)$post($k, 0);
$v = max($range[0], min($range[1], $v));
fb_set_setting($k, $v);
}
// 图片/视频上传总开关(0=不限个数后,开关必须独立存在)
fb_set_setting('upload.image_on', (int)(bool)$post('upload.image_on'));
fb_set_setting('upload.video_on', (int)(bool)$post('upload.video_on'));
if ($has('upload_image_on')) { fb_set_setting('upload.image_on', (int)(bool)$post('upload.image_on')); }
if ($has('upload_video_on')) { fb_set_setting('upload.video_on', (int)(bool)$post('upload.video_on')); }
// MB → 字节
// 下限改 0 = 不限制(见 #4)
@@ -312,6 +419,7 @@ function fb_admin_save_settings() {
'upload.total_cap_bytes' => [0, 1048576],
];
foreach ($mbs as $k => $range) {
if (!$has($k)) { continue; } // 没提交就不动(防跨 tab 清空)
$mb = (float)$post($k, 0);
$mb = max($range[0], min($range[1], $mb));
fb_set_setting($k, (int)round($mb * 1048576));
@@ -325,6 +433,7 @@ function fb_admin_save_settings() {
$texts = ['oss.endpoint', 'oss.bucket', 'oss.instance_role', 'oss.domain', 'oss.prefix', 'moderation.close_mail_subject'];
foreach ($texts as $k) {
if (!$has($k)) { continue; } // 没提交就不动
$v = trim((string)$post($k, ''));
// 这两个留空都有"出厂默认"兜底,必须删键而不是存空串:
// - oss.prefix 留空会让对象键变成 "/2026/...",默认是 'fb'
@@ -354,9 +463,26 @@ function fb_admin_save_settings() {
fb_set_setting('oss.verify', $post('oss.verify') ? 1 : 0);
fb_set_setting('oss.image_process', $post('oss.image_process') ? 1 : 0);
fb_set_setting('oss.use_internal_endpoint', $post('oss.use_internal_endpoint') ? 1 : 0);
/* ---------------- 邮件通知(SMTP) ---------------- */
} // ← end if ($isSiteForm)
/* ---------------- 邮件通知(SMTP) ----------------
*
* 🔴🔴 必须整段包在「本页到底有没有提交邮件表单」的判断里。
*
* 事故:站点设置页和邮件通知页共用 act=settings。在站点设置页点保存时,
* 该表单里没有 smtp_* 字段,$post() 取到空串,被无条件写进数据库 ——
* **把用户已经填好的 SMTP 服务器/账号/发件地址全清空了**。
* 用户看到的现象就是「我明明配过邮件,怎么全没了、信也发不出」。
*
* 判据用 smtp_enabled:它是邮件表单里的总开关,一定存在。
* 站点设置页没有这个字段 ⇒ 整段跳过,一个键都不动。
*/
$isMailForm = $has('smtp_enabled') || $has('smtp.host') || $has('smtp_host');
if ($isMailForm) {
$smtpText = ['smtp.host', 'smtp.user', 'smtp.from', 'smtp.from_name'];
foreach ($smtpText as $k) {
if (!$has($k)) { continue; } // 没提交就不动它
fb_set_setting($k, trim((string)$post($k, '')));
}
// 口令留空 = 不修改(避免每次保存都得重填)
@@ -400,20 +526,32 @@ function fb_admin_save_settings() {
$msgType = 'error';
}
/* ---------------- 通知开关 ---------------- */
fb_set_setting('notify.enabled', (int)(bool)$post('notify.enabled'));
fb_set_setting('notify.on_every_reply', (int)(bool)$post('notify.on_every_reply'));
fb_set_setting('notify.admin_new_thread', (int)(bool)$post('notify.admin_new_thread'));
/* ---------------- 通知开关 ----------------
* 全是 checkbox:未提交会被当成"取消勾选"。
* 所以只有本页确实提交了邮件表单时才写,否则在站点设置页保存一次
* 就会把「有人回复时通知」这些开关全部关掉。
*/
if ($has('notify_enabled')) { fb_set_setting('notify.enabled', (int)(bool)$post('notify.enabled')); }
if ($has('notify_on_every_reply')) { fb_set_setting('notify.on_every_reply', (int)(bool)$post('notify.on_every_reply')); }
if ($has('notify_admin_new_thread')) { fb_set_setting('notify.admin_new_thread', (int)(bool)$post('notify.admin_new_thread')); }
// 冷却秒数留空 = 用默认 300(config 里的出厂值)。注意 0 是合法值(表示不限制),
// 所以要用「空串」而不是「0」来区分"没填"和"填了 0"
$cdRaw = trim((string)$post('notify.cooldown_seconds', ''));
if ($cdRaw === '') {
fb_unset_setting('notify.cooldown_seconds');
} else {
fb_set_setting('notify.cooldown_seconds', max(0, min(86400, (int)$cdRaw)));
if ($has('notify_cooldown_seconds')) {
$cdRaw = trim((string)$post('notify.cooldown_seconds', ''));
if ($cdRaw === '') {
fb_unset_setting('notify.cooldown_seconds');
} else {
fb_set_setting('notify.cooldown_seconds', max(0, min(86400, (int)$cdRaw)));
}
}
fb_set_setting('moderation.enabled', $post('moderation.enabled') ? 1 : 0);
} // ← end if ($isMailForm)
// 审核开关属于「站点设置」,只在站点设置页提交时才写
if ($has('moderation_enabled')) {
fb_set_setting('moderation.enabled', $post('moderation.enabled') ? 1 : 0);
}
fb_mod_log('settings', null, '保存站点设置:存储=' . $storage);
return '设置已保存。';
@@ -602,6 +740,87 @@ fb_layout_head('管理后台', ['robots' => 'noindex,nofollow']);
</div>
</div>
<?php
/* ---------- 处理动作:一排按钮,点一下立刻生效 ----------
* 用户原话:「第一个就是通过并显示…另外一个再加一个按钮,就是说现在正在处理中。
* 还有比方说不处理,都是在那个问题后边列几个按钮不就行了吗?」
*
* 设计取舍:
* - 「通过并公开」单独成一类(它改的是可见性,不只是状态)
* - 「不予考虑」必须填说明(会随邮件发出,不能发一封没理由的信)
* - 其余状态点一下就走,不弹框 —— 高频操作不该被对话框拖慢
*/
$curStatus = (string)$t['status'];
$isVisible = ((int)$t['visible'] === 1);
// 按钮表:key=状态, label=按钮文字, cls=样式
$quickBtns = [
['key' => 'doing', 'label' => '处理中', 'cls' => ''],
['key' => 'resolved', 'label' => '已解决', 'cls' => 'ok'],
['key' => 'wontfix', 'label' => '不予考虑', 'cls' => 'warn'],
];
?>
<div class="act-bar">
<div class="act-bar-title">处理</div>
<div class="act-bar-body">
<?php if (!$isVisible): ?>
<form method="post" action="admin.php?tab=thread&amp;id=<?php echo (int)$id; ?>" class="act-inline">
<input type="hidden" name="csrf" value="<?php echo e(fb_csrf_token()); ?>">
<input type="hidden" name="id" value="<?php echo (int)$id; ?>">
<input type="hidden" name="status" value="<?php echo e($curStatus === 'closed' ? 'open' : $curStatus); ?>">
<input type="hidden" name="also_show" value="1">
<button class="btn primary act-btn" type="submit" name="act" value="setstatus">通过并公开</button>
</form>
<?php else: ?>
<span class="act-state on">已公开显示</span>
<?php endif; ?>
<?php foreach ($quickBtns as $qb):
$isCur = ($curStatus === $qb['key']);
if ($qb['key'] === 'wontfix'): ?>
<button type="button" class="btn act-btn <?php echo e($qb['cls']); ?><?php echo $isCur ? ' is-cur' : ''; ?>"
data-wontfix-open <?php echo $isCur ? 'disabled' : ''; ?>><?php echo e($qb['label']); ?></button>
<?php else: ?>
<form method="post" action="admin.php?tab=thread&amp;id=<?php echo (int)$id; ?>" class="act-inline">
<input type="hidden" name="csrf" value="<?php echo e(fb_csrf_token()); ?>">
<input type="hidden" name="id" value="<?php echo (int)$id; ?>">
<input type="hidden" name="status" value="<?php echo e($qb['key']); ?>">
<button class="btn act-btn <?php echo e($qb['cls']); ?><?php echo $isCur ? ' is-cur' : ''; ?>"
type="submit" name="act" value="setstatus"<?php echo $isCur ? ' disabled' : ''; ?>><?php echo e($qb['label']); ?></button>
</form>
<?php endif; ?>
<?php endforeach; ?>
<?php if ($isVisible): ?>
<form method="post" action="admin.php?tab=thread&amp;id=<?php echo (int)$id; ?>" class="act-inline"
onsubmit="return confirm('确定把这条件反馈从公开列表中撤下?');">
<input type="hidden" name="csrf" value="<?php echo e(fb_csrf_token()); ?>">
<input type="hidden" name="id" value="<?php echo (int)$id; ?>">
<button class="btn act-btn" type="submit" name="act" value="hide">撤下</button>
</form>
<?php endif; ?>
<span class="act-hint">点一下立刻生效并自动发邮件通知提交人</span>
</div>
<?php /* 不予考虑:需要填说明,展开一个小面板而不是弹 window.prompt */ ?>
<form method="post" action="admin.php?tab=thread&amp;id=<?php echo (int)$id; ?>" class="act-note" data-wontfix-panel hidden>
<input type="hidden" name="csrf" value="<?php echo e(fb_csrf_token()); ?>">
<input type="hidden" name="id" value="<?php echo (int)$id; ?>">
<input type="hidden" name="status" value="wontfix">
<label class="field">
<span>为什么不处理 <b class="req">*</b>
<i class="hint">会作为邮件正文发给 <?php echo e((string)$t['author_email']); ?>,请写清楚原因</i></span>
<textarea name="note" rows="3" maxlength="2000" required
placeholder="例如:这个现象来自服务器端的 CalDAV 限制,并非应用本身的问题;建议在客户端调整同步间隔。"></textarea>
</label>
<div class="form-actions">
<button class="btn warn" type="submit" name="act" value="setstatus">确认不予考虑并邮件告知</button>
<button class="btn" type="button" data-wontfix-close>取消</button>
</div>
</form>
</div>
<?php foreach ($replies as $r): ?>
<div class="reply<?php echo ((int)$r['is_admin'] === 1) ? ' admin' : ''; ?>">
<div class="reply-head">