修复:跨 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 = ?') $pdo->prepare('UPDATE threads SET visible = 1, status = ?, mod_note = NULL, moderated_at = ? WHERE id = ?')
->execute([$newStatus, time(), $id]); ->execute([$newStatus, time(), $id]);
fb_mod_log('approve', $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') { } elseif ($act === 'close') {
$note = trim(isset($_POST['note']) ? (string)$_POST['note'] : ''); $note = trim(isset($_POST['note']) ? (string)$_POST['note'] : '');
@@ -141,7 +148,61 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['act'])) {
} elseif ($act === 'hide') { } elseif ($act === 'hide') {
$pdo->prepare('UPDATE threads SET visible = 0, moderated_at = ? WHERE id = ?')->execute([time(), $id]); $pdo->prepare('UPDATE threads SET visible = 0, moderated_at = ? WHERE id = ?')->execute([time(), $id]);
fb_mod_log('hide', $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') { } elseif ($act === 'status') {
$statuses = fb_statuses(); $statuses = fb_statuses();
@@ -150,11 +211,29 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['act'])) {
$msg = '未知的状态。'; $msg = '未知的状态。';
$msgType = 'error'; $msgType = 'error';
} else { } 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 = ?') $pdo->prepare('UPDATE threads SET status = ?, visible = ?, updated_at = ? WHERE id = ?')
->execute([$new, $vis, time(), $id]); ->execute([$new, $vis, time(), $id]);
fb_mod_log('status', $id, '状态改为 ' . $statuses[$new] . ',公开=' . $vis); 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') { } elseif ($act === 'reply') {
@@ -268,16 +347,43 @@ function fb_admin_save_settings() {
return $def; 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 的出厂值,便于恢复默认。 // 留空 = 删除该设置项,回落到 config.php 的出厂值,便于恢复默认。
foreach (['site_name', 'site_desc'] as $k) { foreach (['site_name', 'site_desc'] as $k) {
if (!$has($k)) { continue; }
$v = trim((string)$post($k, '')); $v = trim((string)$post($k, ''));
if ($v === '') { fb_unset_setting($k); } if ($v === '') { fb_unset_setting($k); }
else { fb_set_setting($k, $v); } else { fb_set_setting($k, $v); }
} }
// 页脚:是否显示「由 FeedLite 驱动」这句版权,以及链接指向的开源仓库地址 // 页脚:是否显示「由 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', '')); $curl = trim((string)$post('footer_credit_url', ''));
// 只接受 http/https:这条会被直接渲染成 <a href>,不能让 javascript:/data: 混进来 // 只接受 http/https:这条会被直接渲染成 <a href>,不能让 javascript:/data: 混进来
if ($curl !== '' && !preg_match('~^https?://~i', $curl)) { if ($curl !== '' && !preg_match('~^https?://~i', $curl)) {
@@ -296,13 +402,14 @@ function fb_admin_save_settings() {
'upload.image_max_side' => [480, 4096], 'upload.image_max_side' => [480, 4096],
]; ];
foreach ($ints as $k => $range) { foreach ($ints as $k => $range) {
if (!$has($k)) { continue; }
$v = (int)$post($k, 0); $v = (int)$post($k, 0);
$v = max($range[0], min($range[1], $v)); $v = max($range[0], min($range[1], $v));
fb_set_setting($k, $v); fb_set_setting($k, $v);
} }
// 图片/视频上传总开关(0=不限个数后,开关必须独立存在) // 图片/视频上传总开关(0=不限个数后,开关必须独立存在)
fb_set_setting('upload.image_on', (int)(bool)$post('upload.image_on')); if ($has('upload_image_on')) { 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_video_on')) { fb_set_setting('upload.video_on', (int)(bool)$post('upload.video_on')); }
// MB → 字节 // MB → 字节
// 下限改 0 = 不限制(见 #4) // 下限改 0 = 不限制(见 #4)
@@ -312,6 +419,7 @@ function fb_admin_save_settings() {
'upload.total_cap_bytes' => [0, 1048576], 'upload.total_cap_bytes' => [0, 1048576],
]; ];
foreach ($mbs as $k => $range) { foreach ($mbs as $k => $range) {
if (!$has($k)) { continue; } // 没提交就不动(防跨 tab 清空)
$mb = (float)$post($k, 0); $mb = (float)$post($k, 0);
$mb = max($range[0], min($range[1], $mb)); $mb = max($range[0], min($range[1], $mb));
fb_set_setting($k, (int)round($mb * 1048576)); 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']; $texts = ['oss.endpoint', 'oss.bucket', 'oss.instance_role', 'oss.domain', 'oss.prefix', 'moderation.close_mail_subject'];
foreach ($texts as $k) { foreach ($texts as $k) {
if (!$has($k)) { continue; } // 没提交就不动
$v = trim((string)$post($k, '')); $v = trim((string)$post($k, ''));
// 这两个留空都有"出厂默认"兜底,必须删键而不是存空串: // 这两个留空都有"出厂默认"兜底,必须删键而不是存空串:
// - oss.prefix 留空会让对象键变成 "/2026/...",默认是 'fb' // - 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.verify', $post('oss.verify') ? 1 : 0);
fb_set_setting('oss.image_process', $post('oss.image_process') ? 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); 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']; $smtpText = ['smtp.host', 'smtp.user', 'smtp.from', 'smtp.from_name'];
foreach ($smtpText as $k) { foreach ($smtpText as $k) {
if (!$has($k)) { continue; } // 没提交就不动它
fb_set_setting($k, trim((string)$post($k, ''))); fb_set_setting($k, trim((string)$post($k, '')));
} }
// 口令留空 = 不修改(避免每次保存都得重填) // 口令留空 = 不修改(避免每次保存都得重填)
@@ -400,20 +526,32 @@ function fb_admin_save_settings() {
$msgType = 'error'; $msgType = 'error';
} }
/* ---------------- 通知开关 ---------------- */ /* ---------------- 通知开关 ----------------
fb_set_setting('notify.enabled', (int)(bool)$post('notify.enabled')); * 全是 checkbox:未提交会被当成"取消勾选"。
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')); * 就会把「有人回复时通知」这些开关全部关掉。
*/
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 是合法值(表示不限制), // 冷却秒数留空 = 用默认 300(config 里的出厂值)。注意 0 是合法值(表示不限制),
// 所以要用「空串」而不是「0」来区分"没填"和"填了 0" // 所以要用「空串」而不是「0」来区分"没填"和"填了 0"
$cdRaw = trim((string)$post('notify.cooldown_seconds', '')); if ($has('notify_cooldown_seconds')) {
if ($cdRaw === '') { $cdRaw = trim((string)$post('notify.cooldown_seconds', ''));
fb_unset_setting('notify.cooldown_seconds'); if ($cdRaw === '') {
} else { fb_unset_setting('notify.cooldown_seconds');
fb_set_setting('notify.cooldown_seconds', max(0, min(86400, (int)$cdRaw))); } 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); fb_mod_log('settings', null, '保存站点设置:存储=' . $storage);
return '设置已保存。'; return '设置已保存。';
@@ -602,6 +740,87 @@ fb_layout_head('管理后台', ['robots' => 'noindex,nofollow']);
</div> </div>
</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): ?> <?php foreach ($replies as $r): ?>
<div class="reply<?php echo ((int)$r['is_admin'] === 1) ? ' admin' : ''; ?>"> <div class="reply<?php echo ((int)$r['is_admin'] === 1) ? ' admin' : ''; ?>">
<div class="reply-head"> <div class="reply-head">
+27
View File
@@ -703,6 +703,32 @@
for (var k = 0; k < wraps.length; k++) { wraps[k].classList.remove('has-error'); } for (var k = 0; k < wraps.length; k++) { wraps[k].classList.remove('has-error'); }
} }
/* ---------- 后台「不予考虑」说明面板 ----------
* 为什么不用 window.prompt:
* prompt 不能多行、不能校验、样式不可控,而且移动端体验差。
* 这里展开一个内联面板,用户能看到「这封信会发给谁」,写得更认真。
*/
function bindWontfixPanel() {
var openBtn = document.querySelector('[data-wontfix-open]');
var panel = document.querySelector('[data-wontfix-panel]');
var closeBtn = document.querySelector('[data-wontfix-close]');
if (!openBtn || !panel) { return; }
openBtn.addEventListener('click', function () {
panel.hidden = false;
var ta = panel.querySelector('textarea');
if (ta) {
ta.focus();
ta.scrollIntoView({ block: 'center', behavior: 'smooth' });
}
});
if (closeBtn) {
closeBtn.addEventListener('click', function () {
panel.hidden = true;
});
}
}
/* ---------- 主题色选择器(后台软件编辑) ---------- */ /* ---------- 主题色选择器(后台软件编辑) ---------- */
function bindColorPicker() { function bindColorPicker() {
var rows = document.querySelectorAll('.color-row'); var rows = document.querySelectorAll('.color-row');
@@ -793,6 +819,7 @@
function boot() { function boot() {
bindCaptchaRefresh(); bindCaptchaRefresh();
bindAdminToggle(); bindAdminToggle();
bindWontfixPanel();
bindColorPicker(); bindColorPicker();
bindMailTest(); bindMailTest();
bindInlineValidate(); // ★ 必须在 bindNoDoubleSubmit 之前 bindInlineValidate(); // ★ 必须在 bindNoDoubleSubmit 之前
+35
View File
@@ -81,6 +81,41 @@ main.wrap{padding-top:24px; padding-bottom:40px; min-height:60vh}
.page-head h1{margin-bottom:4px} .page-head h1{margin-bottom:4px}
.page-head .muted{font-size:14px; margin:0} .page-head .muted{font-size:14px; margin:0}
/* ---------- 后台处理按钮栏 ---------- */
.act-bar{
margin:0 0 18px; padding:14px 16px;
background:var(--card); border:1px solid var(--line); border-radius:12px;
}
.act-bar-title{
font-size:12.5px; font-weight:700; letter-spacing:.5px;
color:var(--muted); text-transform:uppercase; margin-bottom:10px;
}
.act-bar-body{display:flex; flex-wrap:wrap; gap:8px; align-items:center}
.act-inline{display:inline}
.act-btn{white-space:nowrap}
.act-btn.is-cur{
opacity:.55; cursor:default;
box-shadow:none;
}
.act-btn.ok{border-color:#b7dfc2; color:#1e7b3c}
.act-btn.ok:hover:not(:disabled){background:#f0f9f2}
.act-btn.warn{border-color:#f0dfae; color:#8a6a15}
.act-btn.warn:hover:not(:disabled){background:#fff9ec}
.act-state.on{
display:inline-block; padding:6px 12px; border-radius:99px;
font-size:12.5px; font-weight:600;
background:#f0f9f2; color:#1e7b3c; border:1px solid #b7dfc2;
}
.act-hint{
margin-left:auto; font-size:12.5px; color:var(--muted);
}
@media (max-width:560px){
.act-hint{margin-left:0; flex-basis:100%}
}
.act-note{
margin-top:14px; padding-top:14px; border-top:1px dashed var(--line);
}
/* ---------- 主题色选择器 ---------- */ /* ---------- 主题色选择器 ---------- */
.sw-dot{ .sw-dot{
display:inline-block; width:10px; height:10px; border-radius:50%; display:inline-block; width:10px; height:10px; border-radius:50%;
+15 -1
View File
@@ -1,4 +1,8 @@
# 拒绝一切对数据目录的直接访问(数据库文件在此) # 数据库目录(由程序自动生成,请勿删除)
# fb-data-guard: v1
# ★ 一律禁止 Web 直接访问:数据库文件绝不能被下载
Options -Indexes
<IfModule mod_authz_core.c> <IfModule mod_authz_core.c>
Require all denied Require all denied
</IfModule> </IfModule>
@@ -6,3 +10,13 @@
Order allow,deny Order allow,deny
Deny from all Deny from all
</IfModule> </IfModule>
<FilesMatch "\.(db|db-wal|db-shm|sqlite|sqlite3|php|htaccess)$">
<IfModule mod_authz_core.c>
Require all denied
</IfModule>
<IfModule !mod_authz_core.c>
Order allow,deny
Deny from all
</IfModule>
</FilesMatch>
+1 -1
View File
@@ -8,7 +8,7 @@ if (!defined('FB_ROOT')) {
define('FB_ROOT', dirname(__DIR__)); define('FB_ROOT', dirname(__DIR__));
// 静态资源版本号:改 assets/ 下的 css/js 后把这个数字 +1, // 静态资源版本号:改 assets/ 下的 css/js 后把这个数字 +1,
// 让浏览器与 CDN 缓存失效。全站只此一处,别再往页面里写死数字。 // 让浏览器与 CDN 缓存失效。全站只此一处,别再往页面里写死数字。
if (!defined('FB_ASSET_V')) { define('FB_ASSET_V', 15); } if (!defined('FB_ASSET_V')) { define('FB_ASSET_V', 16); }
} }
/* ------------------------------------------------------------------ /* ------------------------------------------------------------------
+13 -1
View File
@@ -125,8 +125,9 @@ SQL
CREATE TABLE IF NOT EXISTS notify_log ( CREATE TABLE IF NOT EXISTS notify_log (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
thread_id INTEGER NOT NULL, thread_id INTEGER NOT NULL,
reply_id INTEGER NOT NULL, reply_id INTEGER,
to_email TEXT NOT NULL, to_email TEXT NOT NULL,
reason TEXT NOT NULL DEFAULT 'reply',
created_at INTEGER NOT NULL, created_at INTEGER NOT NULL,
ok INTEGER NOT NULL DEFAULT 0, ok INTEGER NOT NULL DEFAULT 0,
tries INTEGER NOT NULL DEFAULT 0, tries INTEGER NOT NULL DEFAULT 0,
@@ -305,6 +306,17 @@ SQL
); );
$pdo->exec('CREATE UNIQUE INDEX IF NOT EXISTS idx_st_key ON statuses(key_name)'); $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(); $swCols = $pdo->query('PRAGMA table_info(software)')->fetchAll();
$swNames = []; $swNames = [];
+741 -54
View File
@@ -113,6 +113,258 @@ function fb_smtp_send(array $s, $to, $subject, $html) {
} }
/** 渲染通知邮件正文 */ /** 渲染通知邮件正文 */
/**
* 统一的邮件模板引擎(工单 #2)
*
* 设计要点:
* 1. **表格布局 + 内联样式**。邮件客户端(Outlook / QQ 邮箱 / Gmail)对
* flex/grid/外部 CSS 的支持极不一致,只有 table + inline style 能稳定渲染。
* 2. **统一外壳**。此前 `fb_notify_html()` 和 `fb_notify_closed_html()` 各写一套
* 风格,颜色、间距、页脚都不一样。现在全部走 fb_mail_layout()。
* 3. **响应式**。桌面 600px,手机端靠 max-width:100% 自适应。
* 4. **暗色安全**。不用纯白背景配纯黑字,留出足够的对比度余量。
*
* 调用方式:
* echo fb_mail_layout([
* 'preheader' => '预览摘要(收件箱列表里显示的那行灰字)',
* 'badge' => ['text' => '处理中', 'color' => '#0a6cf5'],
* 'title' => '你的反馈已被受理',
* 'intro' => '老杨,你好:',
* 'blocks' => [ ... ], // 内容块,见下
* 'button' => ['text' => '查看详情', 'url' => 'https://...'],
* 'footer' => '此邮件由系统自动发送。',
* ]);
*/
if (!function_exists('fb_mail_esc')) {
/** 统一的 HTML 转义(模板里到处要用,短名字方便读) */
function fb_mail_esc($s) {
return htmlspecialchars((string)$s, ENT_QUOTES, 'UTF-8');
}
}
if (!function_exists('fb_mail_brand')) {
/** 取站点主色,用于邮件头与按钮。跟随「软件主题色」体系,但邮件里只用主色与浅底 */
function fb_mail_brand() {
$c = fb_color_norm((string)fb_cfg('mail_brand_color', ''));
if ($c === '') { $c = '#0057B8'; }
return $c;
}
}
if (!function_exists('fb_mail_block')) {
/**
* 渲染一个内容块。
*
* 支持类型:
* quote —— 引用块(回复内容、官方说明),左侧色条 + 浅底
* field —— 一组「标签 / 值」行(状态变更、时间等元信息)
* text —— 普通段落
* callout —— 带图标的提示条(成功=绿、提醒=黄、信息=蓝)
* raw —— 已转义好的 HTML,原样输出(谨慎使用)
*/
function fb_mail_block(array $b) {
$type = isset($b['type']) ? (string)$b['type'] : 'text';
$brand = fb_mail_brand();
if ($type === 'text') {
return '<p style="margin:0 0 14px;font-size:15px;line-height:1.75;color:#333b46">'
. (isset($b['html']) ? $b['html'] : fb_mail_esc(isset($b['text']) ? $b['text'] : ''))
. '</p>';
}
if ($type === 'quote') {
$who = isset($b['who']) ? (string)$b['who'] : '';
$body = nl2br(fb_mail_esc(isset($b['text']) ? $b['text'] : ''));
return '<table role="presentation" cellpadding="0" cellspacing="0" border="0" width="100%"'
. ' style="margin:0 0 16px;border-collapse:separate">'
. '<tr><td style="padding:14px 18px;background:#f6f8fb;'
. 'border-left:3px solid ' . fb_mail_esc($brand) . ';border-radius:6px">'
. ($who !== ''
? '<div style="font-size:12.5px;color:#8a93a0;margin-bottom:8px">'
. fb_mail_esc($who) . '</div>' : '')
. '<div style="font-size:14.5px;line-height:1.75;color:#2f3742">' . $body . '</div>'
. '</td></tr></table>';
}
if ($type === 'field') {
$rows = isset($b['rows']) ? (array)$b['rows'] : [];
$out = '<table role="presentation" cellpadding="0" cellspacing="0" border="0" width="100%"'
. ' style="margin:0 0 18px;border-collapse:collapse;'
. 'background:#fafbfc;border:1px solid #eceff3;border-radius:8px">';
$i = 0;
foreach ($rows as $label => $value) {
$pad = ($i === 0) ? '12px 16px 6px' : '6px 16px';
if ($i === count($rows) - 1) { $pad = '6px 16px 12px'; }
if (count($rows) === 1) { $pad = '12px 16px'; }
$out .= '<tr>'
. '<td style="padding:' . $pad . ';font-size:13px;color:#8a93a0;'
. 'width:88px;vertical-align:top;white-space:nowrap">' . fb_mail_esc($label) . '</td>'
. '<td style="padding:' . $pad . ';font-size:14px;color:#2f3742;'
. 'line-height:1.6;vertical-align:top">' . fb_mail_esc($value) . '</td>'
. '</tr>';
$i++;
}
return $out . '</table>';
}
if ($type === 'callout') {
$tone = isset($b['tone']) ? (string)$b['tone'] : 'info';
$map = [
'ok' => ['bg' => '#f0f9f2', 'line' => '#b7dfc2', 'icon' => '✓', 'color' => '#1e7b3c'],
'warn' => ['bg' => '#fff9ec', 'line' => '#f0dfae', 'icon' => '!', 'color' => '#8a6a15'],
'info' => ['bg' => '#f2f7ff', 'line' => '#c8dbf7', 'icon' => 'i', 'color' => '#1a5fb4'],
'muted' => ['bg' => '#f7f8fa', 'line' => '#e6e9ee', 'icon' => '·', 'color' => '#5c6572'],
];
$m = isset($map[$tone]) ? $map[$tone] : $map['info'];
$body = isset($b['html']) ? $b['html']
: nl2br(fb_mail_esc(isset($b['text']) ? $b['text'] : ''));
return '<table role="presentation" cellpadding="0" cellspacing="0" border="0" width="100%"'
. ' style="margin:0 0 18px;border-collapse:separate">'
. '<tr><td style="padding:12px 16px;background:' . $m['bg'] . ';'
. 'border:1px solid ' . $m['line'] . ';border-radius:8px;font-size:14px;'
. 'line-height:1.7;color:' . $m['color'] . '">' . $body . '</td></tr></table>';
}
if ($type === 'raw') {
return isset($b['html']) ? (string)$b['html'] : '';
}
return '';
}
}
if (!function_exists('fb_mail_layout')) {
/**
* 邮件统一外壳。所有通知都套这一个,保证视觉一致。
*
* @param array $o 见文件头注释
* @return string 完整 HTML(可直接作为 text/html 正文)
*/
function fb_mail_layout(array $o) {
$brand = fb_mail_brand();
$site = (string)fb_cfg('site_name', '轻反馈');
$base = fb_base_url();
$preheader = isset($o['preheader']) ? (string)$o['preheader'] : '';
$badge = isset($o['badge']) ? (array)$o['badge'] : null;
$title = isset($o['title']) ? (string)$o['title'] : '';
$intro = isset($o['intro']) ? (string)$o['intro'] : '';
$blocks = isset($o['blocks']) ? (array)$o['blocks'] : [];
$button = isset($o['button']) ? (array)$o['button'] : null;
$link = isset($o['link']) ? (string)$o['link'] : '';
$footer = isset($o['footer']) ? (string)$o['footer']
: '此邮件由系统自动发送,请勿直接回复。';
$recipient = isset($o['recipient']) ? (string)$o['recipient'] : '';
// ---- 内容块 ----
$body = '';
foreach ($blocks as $b) {
if (is_array($b)) { $body .= fb_mail_block($b); }
}
// ---- 徽标 ----
$badgeHtml = '';
if ($badge && !empty($badge['text'])) {
$bc = isset($badge['color']) ? (string)$badge['color'] : $brand;
$badgeHtml = '<div style="margin:0 0 10px">'
. '<span style="display:inline-block;padding:3px 11px;border-radius:99px;'
. 'background:' . fb_mail_esc($bc) . ';color:#ffffff;font-size:12px;'
. 'font-weight:600;letter-spacing:.3px">' . fb_mail_esc($badge['text']) . '</span>'
. '</div>';
}
// ---- 按钮 ----
$btnHtml = '';
if ($button && !empty($button['url']) && !empty($button['text'])) {
$url = (string)$button['url'];
$btnHtml = '<table role="presentation" cellpadding="0" cellspacing="0" border="0"'
. ' style="margin:6px 0 20px"><tr>'
. '<td style="background:' . fb_mail_esc($brand) . ';border-radius:8px">'
. '<a href="' . fb_mail_esc($url) . '" target="_blank"'
. ' style="display:inline-block;padding:12px 26px;font-size:15px;font-weight:600;'
. 'color:#ffffff;text-decoration:none;border-radius:8px">'
. fb_mail_esc($button['text']) . '</a></td></tr></table>';
}
// ---- 纯文本兜底链接 ----
$linkHtml = '';
if ($link !== '') {
$linkHtml = '<p style="margin:0 0 16px;font-size:12.5px;line-height:1.6;color:#9aa2ad">'
. '如果按钮无法点击,请复制下面的链接到浏览器打开:<br>'
. '<span style="color:' . fb_mail_esc($brand) . ';word-break:break-all">'
. fb_mail_esc($link) . '</span></p>';
}
// ---- 收件人问候 ----
$introHtml = '';
if ($intro !== '') {
$introHtml = '<p style="margin:0 0 16px;font-size:15px;line-height:1.75;color:#333b46">'
. fb_mail_esc($intro) . '</p>';
}
return '<!DOCTYPE html>'
. '<html lang="zh-CN"><head><meta charset="UTF-8">'
. '<meta name="viewport" content="width=device-width,initial-scale=1">'
. '<meta name="x-apple-disable-message-reformatting">'
. '<title>' . fb_mail_esc($title) . '</title></head>'
. '<body style="margin:0;padding:0;background:#eef1f5;'
. '-webkit-text-size-adjust:100%;-ms-text-size-adjust:100%">'
// 预处理器:收件箱里显示的那行摘要,兼带隐藏占位防止被正文抢先
. ($preheader !== ''
? '<div style="display:none;font-size:1px;color:#eef1f5;line-height:1px;'
. 'max-height:0;max-width:0;opacity:0;overflow:hidden">'
. fb_mail_esc($preheader) . '</div>' : '')
// 外层留白
. '<table role="presentation" cellpadding="0" cellspacing="0" border="0" width="100%"'
. ' style="background:#eef1f5"><tr><td align="center" style="padding:28px 14px">'
// 卡片
. '<table role="presentation" cellpadding="0" cellspacing="0" border="0" width="600"'
. ' style="width:100%;max-width:600px;background:#ffffff;border-radius:14px;'
. 'overflow:hidden;box-shadow:0 2px 10px rgba(20,30,50,.06)">'
// 顶部色条
. '<tr><td style="height:4px;background:' . fb_mail_esc($brand) . ';font-size:0;'
. 'line-height:0">&nbsp;</td></tr>'
// 站名
. '<tr><td style="padding:22px 32px 0">'
. '<div style="font-size:13px;font-weight:700;letter-spacing:.6px;'
. 'color:' . fb_mail_esc($brand) . ';text-transform:uppercase">'
. fb_mail_esc($site) . '</div></td></tr>'
// 标题
. '<tr><td style="padding:12px 32px 4px">' . $badgeHtml
. '<h1 style="margin:0;font-size:21px;line-height:1.45;font-weight:700;'
. 'color:#1b2430">' . fb_mail_esc($title) . '</h1></td></tr>'
// 正文
. '<tr><td style="padding:16px 32px 8px">'
. $introHtml . $body . $btnHtml . $linkHtml
. '</td></tr>'
// 页脚
. '<tr><td style="padding:20px 32px 26px;border-top:1px solid #eef0f3">'
. '<p style="margin:0 0 6px;font-size:12.5px;line-height:1.65;color:#9aa2ad">'
. fb_mail_esc($footer) . '</p>'
. ($recipient !== ''
? '<p style="margin:0;font-size:11.5px;line-height:1.6;color:#b3b9c2">'
. '这封邮件发给了 ' . fb_mail_esc($recipient) . '。'
. '如果这不是你,忽略即可。</p>' : '')
. '<p style="margin:8px 0 0;font-size:11.5px;color:#b3b9c2">'
. '<a href="' . fb_mail_esc($base) . '" target="_blank"'
. ' style="color:#9aa2ad;text-decoration:none">'
. fb_mail_esc($base) . '</a></p>'
. '</td></tr>'
. '</table>'
. '</td></tr></table>'
. '</body></html>';
}
}
function fb_notify_html($thread, $reply, $isAdmin) { function fb_notify_html($thread, $reply, $isAdmin) {
$base = fb_base_url(); $base = fb_base_url();
$url = $base . '/thread.php?id=' . (int)$thread['id']; $url = $base . '/thread.php?id=' . (int)$thread['id'];
@@ -144,40 +396,11 @@ function fb_notify_html($thread, $reply, $isAdmin) {
* @return string 空串=未发送/已发送成功;非空=失败原因 * @return string 空串=未发送/已发送成功;非空=失败原因
*/ */
function fb_notify_reply(PDO $pdo, array $thread, array $reply, $isAdmin) { function fb_notify_reply(PDO $pdo, array $thread, array $reply, $isAdmin) {
$cfg = fb_cfg(); // 转调统一入口(工单 #2):旧调用点保持不改,但行为、模板、日志全部统一。
$nt = isset($cfg['notify']) ? (array)$cfg['notify'] : []; list($ok, $err) = fb_notify_event($pdo, 'reply', $thread, [
if (empty($nt['enabled'])) { return ''; } 'reply' => $reply,
if (empty($nt['on_every_reply']) && !$isAdmin) { return ''; } 'is_admin' => (bool)$isAdmin,
]);
$to = trim((string)$thread['author_email']);
if ($to === '' || !filter_var($to, FILTER_VALIDATE_EMAIL)) { return ''; }
// 自己回复自己:不发
if (!$isAdmin && !empty($reply['author_email'])
&& strcasecmp(trim((string)$reply['author_email']), $to) === 0) {
return '';
}
// 节流:同一帖同一收件人 N 秒内不重复发
$cd = (int)(isset($nt['cooldown_seconds']) ? $nt['cooldown_seconds'] : 0);
if ($cd > 0) {
$st = $pdo->prepare('SELECT created_at FROM notify_log WHERE thread_id=? AND to_email=? ORDER BY id DESC LIMIT 1');
$st->execute([(int)$thread['id'], $to]);
$last = $st->fetchColumn();
if ($last !== false && (time() - (int)$last) < $cd) { return ''; }
}
$smtp = isset($cfg['smtp']) ? (array)$cfg['smtp'] : [];
if (empty($smtp['enabled'])) {
fb_notify_log($pdo, $thread, $reply, $to, 0, 'SMTP 未启用');
return 'SMTP 未启用';
}
$subject = '【' . $cfg['site_name'] . '】有人回复了你的问题:' . fb_excerpt($thread['title'], 40);
$html = fb_notify_html($thread, $reply, $isAdmin);
list($ok, $err) = fb_smtp_send($smtp, $to, $subject, $html);
fb_notify_log($pdo, $thread, $reply, $to, $ok ? 1 : 0, $ok ? null : $err);
return $ok ? '' : $err; return $ok ? '' : $err;
} }
@@ -226,31 +449,495 @@ function fb_notify_closed_html(array $thread, string $note) {
* @return array [bool 是否发送成功, string 失败原因(空串=成功或未启用)] * @return array [bool 是否发送成功, string 失败原因(空串=成功或未启用)]
*/ */
function fb_notify_closed(PDO $pdo, array $thread, string $note) { function fb_notify_closed(PDO $pdo, array $thread, string $note) {
$cfg = fb_cfg(); // 转调统一入口(工单 #2)
$sm = isset($cfg['smtp']) ? (array)$cfg['smtp'] : []; return fb_notify_event($pdo, 'close', $thread, ['note' => $note]);
if (empty($sm['enabled'])) { }
// 仅记录,不算失败
function fb_notify_log2(PDO $pdo, array $thread, $to, $reason, $ok, $err) {
// ⚠️ 老库的 notify_log.reply_id 是 NOT NULL,而「新反馈通知」没有回复 id。
// SQLite 不能 ALTER COLUMN 放宽约束,只能重建表 —— 为了不动用户已有数据,
// 这里用 reply_id=0 表示「非回复事件」,而不是 null。
try {
$pdo->prepare('INSERT INTO notify_log(thread_id, reply_id, to_email, reason, ok, err, created_at) '
. 'VALUES(?,?,?,?,?,?,?)')
->execute([
(int)$thread['id'],
0,
(string)$to,
(string)$reason,
(int)$ok,
$err === null ? null : (string)$err,
time(),
]);
} catch (Throwable $e) {
// reason 列可能不存在(老库未迁移),退化成原结构
try { try {
$st = $pdo->prepare('INSERT INTO notify_log(thread_id, reply_id, to_email, created_at, ok, err) VALUES(?,?,?,?,?,?)'); $pdo->prepare('INSERT INTO notify_log(thread_id, reply_id, to_email, ok, err, created_at) '
$st->execute([(int)$thread['id'], 0, (string)$thread['author_email'], time(), 0, 'SMTP 未启用,关闭通知未发送']); . 'VALUES(?,?,?,?,?,?)')
} catch (Exception $ex) { /* 静默 */ } ->execute([(int)$thread['id'], 0, (string)$to, (int)$ok,
return [false, 'SMTP 未启用,邮件未发送(已在配置里关闭)']; $err === null ? null : (string)$err, time()]);
} catch (Throwable $e2) {
// 实在记不了就算了,不能因为记日志失败让用户的请求失败
}
} }
}/**
* 各事件的邮件渲染(工单 #2)
*
* 每个事件返回 [主题, HTML]。
* 全部套 fb_mail_layout(),视觉统一;文案按事件差异化。
*/
$to = (string)$thread['author_email']; /**
if (!filter_var($to, FILTER_VALIDATE_EMAIL)) { * 邮件通知事件(工单 #2)
return [false, '提问人邮箱不可用,未发送']; *
* 全部事件:
* reply 有人回复了你的反馈 → 发帖人
* status 处理状态变了 → 发帖人
* approve 审核通过、已公开 → 发帖人
* hide 转为不公开 → 发帖人
* close 判定不是问题,关闭 → 发帖人(带说明)
* new 有新反馈提交 → 管理员
*
* 设计原则:
* 1. **一个总入口**。所有事件都走 fb_notify_event(),统一处理
* 「开关判断 → 收件人解析 → 节流 → 发信 → 记日志」,避免每个事件
* 各写一遍导致行为不一致(此前 close 和 reply 就是两套)。
* 2. **失败必留痕**。任何情况下都写 notify_log,便于后台排查
* 「为什么用户说没收到信」。
* 3. **不抛异常**。发信失败绝不能让用户的操作失败 —— 反馈已经存库了,
* 邮件只是附加动作。
*/
if (!function_exists('fb_notify_reason_label')) {
/** 事件的中文名,用于日志与模板徽标 */
function fb_notify_reason_label($reason) {
$map = [
'reply' => '有新回复',
'status' => '状态更新',
'approve' => '已发布',
'hide' => '转为不公开',
'close' => '已关闭',
'new' => '新反馈',
];
return isset($map[$reason]) ? $map[$reason] : '通知';
} }
}
if (!function_exists('fb_notify_enabled_for')) {
/**
* 这个事件在当前配置下要不要发?
*
* 注意区别:
* - notify.enabled 是「给提交人发信」的总开关
* - notify.admin_new_thread 只管「有新反馈时通知管理员」
* 它独立于前者 —— 就算关了给用户的信,管理员也可以想收通知
*/
function fb_notify_enabled_for($reason) {
if ($reason === 'new') {
return (bool)fb_cfg('notify.admin_new_thread', false);
}
return (bool)fb_cfg('notify.enabled', true);
}
}
if (!function_exists('fb_notify_throttled')) {
/**
* 节流:同一帖 + 同一收件人 + 同一原因,在冷却窗口内只发一次。
*
* ⚠️ 只对 reply / status 这类「可能短时间内反复触发」的事件生效;
* close / new 是一次性动作,不节流(否则用户会完全收不到)。
*
* @return bool true = 应该跳过(正在冷却中)
*/
function fb_notify_throttled(PDO $pdo, $threadId, $to, $reason) {
if (!in_array($reason, ['reply', 'status'], true)) { return false; }
$cd = (int)fb_cfg('notify.cooldown_seconds', 300);
if ($cd <= 0) { return false; }
$st = $pdo->prepare(
'SELECT created_at FROM notify_log WHERE thread_id=? AND to_email=? AND reason=? '
. 'ORDER BY id DESC LIMIT 1');
$st->execute([(int)$threadId, (string)$to, (string)$reason]);
$last = $st->fetchColumn();
if ($last === false) { return false; }
return (time() - (int)$last) < $cd;
}
}
if (!function_exists('fb_notify_event')) {
/**
* 发送一个通知事件。
*
* @param PDO $pdo
* @param string $reason reply|status|approve|hide|close|new
* @param array $thread 帖子行
* @param array $extra 附加信息:
* - reply 回复行(reply 事件用)
* - is_admin 是否官方操作
* - old_status / new_status (status 事件用)
* - note 说明文字(close / status 用)
* @return array [bool 是否发出, string 失败原因]
*/
function fb_notify_event(PDO $pdo, $reason, array $thread, array $extra = []) {
$reason = (string)$reason;
// ---- 1. 开关 ----
if (!fb_notify_enabled_for($reason)) {
return [false, '通知未启用'];
}
// ---- 2. 收件人 ----
$isAdminNotice = ($reason === 'new');
if ($isAdminNotice) {
$to = trim((string)fb_cfg('admin_email', ''));
if ($to === '') { $to = trim((string)fb_cfg('admin.email', '')); }
} else {
$to = trim((string)(isset($thread['author_email']) ? $thread['author_email'] : ''));
}
if ($to === '' || !filter_var($to, FILTER_VALIDATE_EMAIL)) {
return [false, '收件邮箱无效'];
}
// ---- 3. 自己通知自己就别发了 ----
if (!$isAdminNotice && !empty($extra['is_admin']) === false
&& !empty($extra['reply']['author_email'])) {
if (strcasecmp(trim((string)$extra['reply']['author_email']), $to) === 0) {
return [false, '自己回复自己,不发'];
}
}
// ---- 4. 节流 ----
if (fb_notify_throttled($pdo, (int)$thread['id'], $to, $reason)) {
return [false, '冷却中,跳过'];
}
// ---- 5. SMTP 就绪吗 ----
$smtp = (array)fb_cfg('smtp', []);
if (empty($smtp['enabled']) || trim((string)$smtp['host']) === '') {
fb_notify_log2($pdo, $thread, $to, $reason, 0, 'SMTP 未启用或未配置服务器');
return [false, 'SMTP 未启用或未配置服务器'];
}
// ---- 6. 组装主题与正文 ----
list($subject, $html) = fb_notify_render($reason, $thread, $extra, $to);
// ---- 7. 发送 ----
list($ok, $err) = fb_smtp_send($smtp, $to, $subject, $html);
fb_notify_log2($pdo, $thread, $to, $reason, $ok ? 1 : 0, $ok ? null : $err);
return [$ok, $ok ? '' : (string)$err];
}
}
if (!function_exists('fb_notify_log2')) {
/**
* 记一条发信日志(带 reason)。
*
* 老表可能没有 reason 列 —— 用 try/catch 包一下,
* 万一列不存在就退化成不带 reason 的插入,绝不让记日志失败影响主流程。
*/
function fb_notify_log2(PDO $pdo, array $thread, $to, $reason, $ok, $err) {
try {
$pdo->prepare('INSERT INTO notify_log(thread_id, reply_id, to_email, reason, ok, err, created_at) '
. 'VALUES(?,?,?,?,?,?,?)')
->execute([
(int)$thread['id'],
null,
(string)$to,
(string)$reason,
(int)$ok,
$err === null ? null : (string)$err,
time(),
]);
} catch (Throwable $e) {
// 列可能不存在(老库未迁移),退化成原结构
try {
$pdo->prepare('INSERT INTO notify_log(thread_id, reply_id, to_email, ok, err, created_at) '
. 'VALUES(?,?,?,?,?,?)')
->execute([(int)$thread['id'], null, (string)$to, (int)$ok,
$err === null ? null : (string)$err, time()]);
} catch (Throwable $e2) {
// 实在记不了就算了,不能因此让用户的请求失败
}
}
}
}
if (!function_exists('fb_notify_thread_url')) {
function fb_notify_thread_url(array $thread) {
return fb_base_url() . '/thread.php?id=' . (int)$thread['id'];
}
}
if (!function_exists('fb_notify_status_meta')) {
/**
* 各处理状态对应的「邮件语气」。
*
* 为什么单独抽出来:用户明确要求不同状态给不同文案 ——
* 「不予考虑」要礼貌说明、不让人觉得被敷衍;
* 「处理中」要让人知道在跟进;
* 「已解决」要引导他来验证。
*/
function fb_notify_status_meta($key) {
$map = [
'open' => [
'badge' => '待处理', 'color' => '#5c6572', 'tone' => 'muted',
'title' => '你的反馈已收到,正在等待处理',
'intro' => '我们已经收到你的反馈,会尽快安排处理。',
'tip' => null,
],
'doing' => [
'badge' => '处理中', 'color' => '#0a6cf5', 'tone' => 'info',
'title' => '你反馈的问题正在处理中',
'intro' => '我们已经开始跟进你反馈的问题,有进展会第一时间告诉你。',
'tip' => '感谢你的耐心等待,暂时不需要你做任何操作。',
],
'resolved' => [
'badge' => '已解决', 'color' => '#1e7b3c', 'tone' => 'ok',
'title' => '你反馈的问题已解决',
'intro' => '好消息,你反馈的问题我们已经处理完成。',
'tip' => '如果问题仍然存在,直接在本页回复即可,我们会继续跟进。',
],
'closed' => [
'badge' => '已关闭', 'color' => '#8a6a15', 'tone' => 'warn',
'title' => '你反馈的问题已关闭',
'intro' => '你反馈的问题已经关闭。',
'tip' => null,
],
'wontfix' => [
'badge' => '不予考虑', 'color' => '#b4553c', 'tone' => 'warn',
'title' => '关于你反馈的问题,我们的处理意见',
'intro' => '感谢你的反馈。经核实,这条反馈我们暂时不会改动,原因如下。',
'tip' => '如果你认为这个判断有误,或者有补充信息,直接在本页回复即可。',
],
];
return isset($map[$key]) ? $map[$key] : $map['open'];
}
}
if (!function_exists('fb_notify_render')) {
/**
* 渲染某个事件的 [主题, HTML]。
*
* @param string $reason
* @param array $thread
* @param array $extra
* @param string $to 收件人(用于页脚「这封邮件发给了…」)
* @return array [string subject, string html]
*/
function fb_notify_render($reason, array $thread, array $extra, $to = '') {
$site = (string)fb_cfg('site_name', '轻反馈');
$url = fb_notify_thread_url($thread);
$title = (string)$thread['title'];
$name = (string)(isset($thread['author_name']) ? $thread['author_name'] : '');
$short = fb_excerpt($title, 42);
$isAdmin = !empty($extra['is_admin']);
$subject = '';
$layout = [];
$tpl = (string)fb_cfg('moderation.close_mail_subject', '关于你反馈的问题《%s》'); /* ---------------- 1. 新回复 ---------------- */
$subject = sprintf($tpl, (string)$thread['title']); if ($reason === 'reply') {
$html = fb_notify_closed_html($thread, $note); $reply = (array)(isset($extra['reply']) ? $extra['reply'] : []);
$rname = $isAdmin ? '官方' : (string)(isset($reply['author_name']) ? $reply['author_name'] : '');
$rbody = (string)(isset($reply['body']) ? $reply['body'] : '');
$isOff = $isAdmin ? '官方回复' : '新回复';
list($ok, $err) = fb_smtp_send($sm, $to, $subject, $html); $subject = '【' . $site . '】' . $isOff . ':' . $short;
try {
$st = $pdo->prepare('INSERT INTO notify_log(thread_id, reply_id, to_email, created_at, ok, err) VALUES(?,?,?,?,?,?)'); $blocks = [
$st->execute([(int)$thread['id'], 0, $to, time(), $ok ? 1 : 0, $ok ? null : $err]); ['type' => 'text', 'text' => '你在「' . $site . '」提交的反馈有新的回复。'],
} catch (Exception $ex) { /* 静默 */ } ['type' => 'field', 'rows' => ['反馈标题' => $title]],
['type' => 'quote', 'who' => $rname . ' 回复:', 'text' => $rbody],
];
if ($isAdmin) {
$blocks[] = ['type' => 'callout', 'tone' => 'info',
'text' => '这是官方回复。如果还有疑问,直接在本页继续回复即可。'];
}
$layout = [
'preheader' => $rname . ':' . fb_excerpt($rbody, 60),
'badge' => ['text' => $isOff, 'color' => $isAdmin ? '#0057B8' : '#5c6572'],
'title' => $isAdmin ? '官方回复了你的反馈' : '有人回复了你的反馈',
'intro' => ($name !== '' ? $name . ',你好:' : '你好:'),
'blocks' => $blocks,
'button' => ['text' => '查看并回复', 'url' => $url],
'link' => $url,
'recipient' => $to,
];
}
/* ---------------- 2. 状态变更 ---------------- */
elseif ($reason === 'status') {
$newKey = (string)(isset($extra['new_status']) ? $extra['new_status'] : '');
$oldKey = (string)(isset($extra['old_status']) ? $extra['old_status'] : '');
$note = trim((string)(isset($extra['note']) ? $extra['note'] : ''));
$meta = fb_notify_status_meta($newKey);
$label = fb_status_label($newKey);
if ($label === '') { $label = $meta['badge']; }
$subject = '【' . $site . '】' . $short . ' —— ' . $label;
$rows = ['反馈标题' => $title];
if ($oldKey !== '' && $oldKey !== $newKey) {
$oldLabel = fb_status_label($oldKey);
if ($oldLabel !== '') { $rows['原状态'] = $oldLabel; }
}
$rows['当前状态'] = $label;
$blocks = [
['type' => 'text', 'text' => $meta['intro']],
['type' => 'field', 'rows' => $rows],
];
if ($note !== '') {
$blocks[] = ['type' => 'quote', 'who' => '官方说明:', 'text' => $note];
}
if (!empty($meta['tip'])) {
$blocks[] = ['type' => 'callout', 'tone' => $meta['tone'], 'text' => $meta['tip']];
}
$layout = [
'preheader' => '状态更新为「' . $label . '」',
'badge' => ['text' => $meta['badge'], 'color' => $meta['color']],
'title' => $meta['title'],
'intro' => ($name !== '' ? $name . ',你好:' : '你好:'),
'blocks' => $blocks,
'button' => ['text' => '查看详情', 'url' => $url],
'link' => $url,
'recipient' => $to,
];
}
/* ---------------- 3. 审核通过 ---------------- */
elseif ($reason === 'approve') {
$subject = '【' . $site . '】你的反馈已发布:' . $short;
$layout = [
'preheader' => '你的反馈已经公开可见',
'badge' => ['text' => '已发布', 'color' => '#1e7b3c'],
'title' => '你的反馈已通过审核并公开',
'intro' => ($name !== '' ? $name . ',你好:' : '你好:'),
'blocks' => [
['type' => 'text', 'text' => '你提交的反馈已通过审核,现在可以在公开列表中看到了。'],
['type' => 'field', 'rows' => ['反馈标题' => $title]],
['type' => 'callout', 'tone' => 'info',
'text' => '后续的处理进展与回复,我们会通过邮件通知你。'],
],
'button' => ['text' => '查看这条反馈', 'url' => $url],
'link' => $url,
'recipient' => $to,
];
}
/* ---------------- 4. 转为不公开 ---------------- */
elseif ($reason === 'hide') {
$note = trim((string)(isset($extra['note']) ? $extra['note'] : ''));
$subject = '【' . $site . '】你的反馈已转为不公开:' . $short;
$blocks = [
['type' => 'text', 'text' => '你提交的反馈已从公开列表中撤下,不再对其他人可见。'],
['type' => 'field', 'rows' => ['反馈标题' => $title]],
];
if ($note !== '') {
$blocks[] = ['type' => 'quote', 'who' => '官方说明:', 'text' => $note];
}
$blocks[] = ['type' => 'callout', 'tone' => 'muted',
'text' => '这条反馈本身仍然保留,你依然可以通过原链接访问和处理进展。'];
$layout = [
'preheader' => '这条反馈已不对其他人可见',
'badge' => ['text' => '不公开', 'color' => '#5c6572'],
'title' => '你的反馈已转为不公开',
'intro' => ($name !== '' ? $name . ',你好:' : '你好:'),
'blocks' => $blocks,
'button' => ['text' => '查看这条反馈', 'url' => $url],
'link' => $url,
'recipient' => $to,
];
}
/* ---------------- 5. 关闭(判定不是问题) ---------------- */
elseif ($reason === 'close') {
$note = trim((string)(isset($extra['note']) ? $extra['note'] : ''));
$body = (string)$thread['body'];
$mail = trim((string)fb_cfg('admin_email', ''));
$subject = '【' . $site . '】关于你反馈的问题《' . $short . '》';
$blocks = [
['type' => 'text',
'text' => '感谢你的反馈。我们已经核实过,这个情况并不是应用本身的问题,因此这条反馈已关闭(不会公开显示)。'],
['type' => 'field', 'rows' => ['反馈标题' => $title]],
];
if ($note !== '') {
$blocks[] = ['type' => 'quote', 'who' => '官方说明:', 'text' => $note];
}
$blocks[] = ['type' => 'quote', 'who' => '你当时反馈的内容:', 'text' => $body];
$blocks[] = [
'type' => 'callout', 'tone' => 'info',
'text' => ($mail !== ''
? '如果上面的说明没能解决你的疑问,直接回复本邮件即可,我们继续跟进;也可以发到 ' . $mail . '。'
: '如果上面的说明没能解决你的疑问,直接回复本邮件即可,我们继续跟进。'),
];
$layout = [
'preheader' => '关于你反馈的问题,我们的说明',
'badge' => ['text' => '已关闭', 'color' => '#8a6a15'],
'title' => '关于你反馈的问题,我们的说明',
'intro' => ($name !== '' ? $name . ',你好:' : '你好:'),
'blocks' => $blocks,
'recipient' => $to,
];
}
/* ---------------- 6. 新反馈(发给管理员) ---------------- */
elseif ($reason === 'new') {
$body = (string)$thread['body'];
$mail = (string)(isset($thread['author_email']) ? $thread['author_email'] : '');
$phone = trim((string)(isset($thread['author_phone']) ? $thread['author_phone'] : ''));
$swName = (string)(isset($extra['software_name']) ? $extra['software_name'] : '');
$subject = '【' . $site . '】新反馈:' . $short;
$rows = ['标题' => $title];
if ($swName !== '') { $rows['所属软件'] = $swName; }
$rows['提交人'] = $name !== '' ? $name : '(未留名)';
if ($mail !== '') { $rows['邮箱'] = $mail; }
if ($phone !== '') { $rows['电话'] = $phone; }
$blocks = [
['type' => 'text', 'text' => '有新的反馈提交,请及时处理。'],
['type' => 'field', 'rows' => $rows],
['type' => 'quote', 'who' => '反馈内容:', 'text' => $body],
['type' => 'callout', 'tone' => 'warn',
'text' => '这条反馈还需要审核,审核通过后才会公开显示。'],
];
$layout = [
'preheader' => $name . ':' . fb_excerpt($body, 60),
'badge' => ['text' => '新反馈', 'color' => '#b4553c'],
'title' => '收到一条新反馈',
'intro' => '你好:',
'blocks' => $blocks,
'button' => ['text' => '去后台处理', 'url' => fb_base_url() . '/admin.php?tab=thread&id=' . (int)$thread['id']],
'link' => fb_base_url() . '/admin.php?tab=thread&id=' . (int)$thread['id'],
'recipient' => $to,
'footer' => '此邮件由系统自动发送。想调整接收规则,请到后台「邮件通知」页修改。',
];
}
/* ---------------- 兜底 ---------------- */
else {
$subject = '【' . $site . '】' . $short;
$layout = [
'preheader' => '你有一条新的通知',
'title' => '你有一条新的通知',
'intro' => ($name !== '' ? $name . ',你好:' : '你好:'),
'blocks' => [['type' => 'text', 'text' => '你关注的反馈有更新。']],
'button' => ['text' => '查看详情', 'url' => $url],
'link' => $url,
'recipient' => $to,
];
}
return [$ok, $ok ? '' : $err]; return [$subject, fb_mail_layout($layout)];
}
} }
+21
View File
@@ -104,6 +104,27 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$newId = (int)$pdo->lastInsertId(); $newId = (int)$pdo->lastInsertId();
fb_attach_save($pdo, $newId, null, $vList); fb_attach_save($pdo, $newId, null, $vList);
fb_rate_hit('thread'); fb_rate_hit('thread');
// 通知管理员「有新反馈」(工单 #2)
// 此前 notify.admin_new_thread 这个设置项是**假的** ——
// 白名单里有、后台有勾选框,但没有任何代码实现它。
// 用户反馈「提交了反馈却收不到邮件」正是这个原因。
try {
$stNt = $pdo->prepare('SELECT * FROM threads WHERE id = ?');
$stNt->execute([$newId]);
$newThread = $stNt->fetch(PDO::FETCH_ASSOC);
if ($newThread) {
$swName = '';
if (!empty($swId)) {
$swRow = fb_software((int)$swId);
if ($swRow) { $swName = (string)$swRow['name']; }
}
fb_notify_event($pdo, 'new', $newThread, ['software_name' => $swName]);
}
} catch (Throwable $e) {
// 发信失败绝不能让用户提交失败 —— 反馈已经存库了
}
fb_redirect('thread.php?id=' . $newId . '&submitted=1'); fb_redirect('thread.php?id=' . $newId . '&submitted=1');
} }
} }