From 8bcee46b8a53ab3f444d33b4763a06e2024a61b5 Mon Sep 17 00:00:00 2001 From: laoyang Date: Tue, 3 Nov 2026 09:54:12 +0800 Subject: [PATCH] =?UTF-8?q?=E9=A6=96=E9=A1=B5=E6=94=B9=E4=BB=8B=E7=BB=8D?= =?UTF-8?q?=E9=A1=B5=EF=BC=9B=E5=88=97=E8=A1=A8=E5=8F=AA=E6=9C=8D=E5=8A=A1?= =?UTF-8?q?=E5=BD=93=E5=89=8D=E8=BD=AF=E4=BB=B6=EF=BC=9B=E6=96=B0=E5=A2=9E?= =?UTF-8?q?UUID=E8=BF=BD=E8=B8=AA=E8=AE=A9=E6=8F=90=E4=BA=A4=E4=BA=BA?= =?UTF-8?q?=E7=9C=8B=E5=BE=97=E5=88=B0=E6=9C=AA=E5=AE=A1=E6=A0=B8=E5=8F=8D?= =?UTF-8?q?=E9=A6=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 用户提的两个核心问题: 一、首页不该列全部问题 场景:这套系统是给某个软件配反馈入口、把地址贴进那软件的「意见反馈」里。 访客总是从某个软件过来的,看别的软件的问题对他没意义。 ⇒ / 不带 sw 时渲染介绍页(特点介绍 + 「点右上角设置按钮选软件」+ 软件卡片) 带 sw 才是该软件的问题列表,检索/筛选/分类全在本软件范围内 二、提交后未审核,用户点不进去(真实缺陷) 邮件里给了「点击查看您刚才提交的反馈」,但 thread.php 只在 visible=1 时 才给看,未审核的一律 404 / 只看到「等待审核」静态页 —— 链接等于白给。 ⇒ threads 加 track_token(32位hex = 128位熵),提交时生成: - 邮件里所有链接一律带 ?t=(不再是裸 id) - 带对 token:可看完整详情、可回复,哪怕未公开/已关闭 - 没 token 或 token 错:对外一律 404,不泄露存在性 - 公开列表仍用 id 链接(公开内容谁都能看,用于避免重复提交) 实现细节: - threads.track_token + 唯一索引;幂等迁移,存量数据自动回填 - fb_track_new_token / fb_track_token_ok(hash_equals 防时序探测) / fb_thread_viewable / fb_thread_link(thread, personal) - thread.php 权限从 fb_thread_public 改为 fb_thread_viewable - 带 token 看自己未公开的反馈时,顶部给「私人跟踪链接」提示条 - 回复表单与回复后跳转都保留 token,否则回复完就失去访问权 - 提交完成后直接带 token 跳详情页,不再只给静态等待页 - close 类邮件补上「查看这条反馈」按钮(原来完全没链接) - 顶栏「全部问题」改为「问题列表」且只在选中软件后显示 - 详情/提交页「返回问题列表」按所属软件返回,不再回介绍页 - new.php 无 sw 时不再默认落到第一个软件,改为让用户先选 (默默替选会让反馈进错软件、通知发错人) 验证:首页 0 条问题卡片且未泄露任何标题、三个软件入口齐全; 未公开#4 无 token 404 / 错 token 404 / 正确 token 200 且显示完整详情 (不再卡在「等待审核」)、提示条与表单 token 都在; 已公开#2 无 token 可看;5 类邮件(回执/通过/回复/状态/关闭)链接全带 token; 两套回归各 30+ 项断言全过。 --- assets/style.css | 80 ++++++++++++++++++++++++++++++ index.php | 123 ++++++++++++++++++++++++++++++++++++++++++++-- lib/bootstrap.php | 2 +- lib/db.php | 26 +++++++++- lib/layout.php | 10 +++- lib/mailer.php | 13 ++++- lib/util.php | 78 ++++++++++++++++++++++++++++- new.php | 50 ++++++++++++++++--- thread.php | 61 ++++++++++++++++++----- 9 files changed, 411 insertions(+), 32 deletions(-) diff --git a/assets/style.css b/assets/style.css index dea9d37..84395ab 100644 --- a/assets/style.css +++ b/assets/style.css @@ -150,6 +150,75 @@ main.wrap{padding-top:24px; padding-bottom:40px; min-height:60vh} /* ---------- 页头 ---------- */ .page-head{display:flex; align-items:flex-start; gap:16px; margin:6px 0 18px} .page-head .btn{margin-left:auto; flex:none; margin-top:4px} + +/* ---------- 首页:轻反馈介绍页 ---------- + * 用户 2026-11-03:首页不列问题,只做介绍 + 指路。 + * 因为访客总是从某个软件过来的,他要的是「怎么进自己那个软件的反馈」, + * 而不是看别人的问题列表。 */ +.home{max-width:820px; margin:0 auto; padding:8px 0 40px} +.home-hero{text-align:center; padding:26px 0 30px} +.home-hero h1{ + font-size:30px; line-height:1.3; margin:0 0 12px; + letter-spacing:-.4px; +} +.home-lead{ + font-size:15.5px; line-height:1.75; color:var(--muted); + margin:0 auto; max-width:600px; +} + +.home-feats{ + display:grid; grid-template-columns:repeat(2, minmax(0,1fr)); + gap:12px; margin:0 0 34px; +} +@media (max-width:600px){ .home-feats{grid-template-columns:1fr} } +.home-feat{ + background:var(--card); border:1px solid var(--line); + border-radius:12px; padding:16px 18px; +} +.home-feat-ic{font-size:20px; line-height:1; margin-bottom:8px} +.home-feat h3{margin:0 0 6px; font-size:15px; font-weight:700} +.home-feat p{margin:0; font-size:13.5px; line-height:1.7; color:var(--muted)} + +.home-enter{ + background:var(--card); border:1px solid var(--line); + border-radius:14px; padding:22px 24px; +} +.home-enter h2{margin:0 0 10px; font-size:17px} +.home-enter-tip{ + font-size:14px; color:var(--muted); line-height:1.8; margin:0 0 16px; + display:flex; align-items:center; flex-wrap:wrap; gap:6px; +} +.home-enter-tip b{color:var(--fg); font-weight:600} +/* 小号三横线示意,让「右上角那个设置按钮」有具体形象可对照 */ +.home-menu-demo{ + display:inline-flex; flex-direction:column; justify-content:center; gap:2.5px; + width:22px; height:22px; padding:4px; box-sizing:border-box; + border:1px solid var(--line-strong); border-radius:6px; + background:var(--bg, #f6f8fb); +} +.home-menu-demo i{display:block; width:100%; height:1.5px; background:var(--fg); border-radius:2px} + +.home-sw-list{display:flex; flex-direction:column; gap:8px} +.home-sw{ + display:flex; align-items:center; gap:12px; + padding:11px 14px; border-radius:11px; + border:1px solid var(--line); background:var(--card); + color:var(--fg); +} +.home-sw:hover{ + text-decoration:none; color:var(--fg); + border-color:var(--swc); background:var(--bg, #f6f8fb); +} +.home-sw-ic{ + width:36px; height:36px; flex:none; border-radius:9px; + display:flex; align-items:center; justify-content:center; + font-size:22px; line-height:1; +} +.home-sw-ic img{width:36px; height:36px; object-fit:contain; display:block} +.home-sw-nm{font-size:15px; font-weight:600; color:var(--swc)} +.home-sw-go{margin-left:auto; font-size:13px; color:var(--muted); flex:none} +.home-sw:hover .home-sw-go{color:var(--swc)} +.home-enter-alt{margin:12px 0 0} .page-head h1{margin-bottom:4px} .page-head .muted{font-size:14px; margin:0} @@ -386,6 +455,17 @@ main.wrap{padding-top:24px; padding-bottom:40px; min-height:60vh} .item-meta{display:flex; flex-wrap:wrap; align-items:center; gap:6px; font-size:13px; color:var(--muted)} .item-meta .who{color:#3a424d; font-weight:600} +/* 提交人凭 token 看自己未公开反馈时的提示条 */ +.owner-note{ + display:flex; flex-direction:column; gap:4px; + margin:0 0 16px; padding:12px 14px; + background:var(--warn-soft, #fff8e6); + border:1px solid #f0d9a8; border-left:3px solid #d99b1f; + border-radius:10px; font-size:13.5px; line-height:1.65; + color:#6b5313; +} +.owner-note b{font-size:14px; color:#5a4610} + /* ---------- 后台「待审核」列表 ---------- * 列表式:一屏能看多条,右侧固定一个「通过并公开」。 * 详情/关闭说明留在管理页里填,列表页不塞表单。 */ diff --git a/index.php b/index.php index 51c610c..66d2f97 100644 --- a/index.php +++ b/index.php @@ -1,22 +1,44 @@ $s +
+
+

轻量级反馈系统

+

给每个软件一个独立的反馈入口:用户不用注册,填个昵称和邮箱就能提问题; + 所有进展都会自动发邮件通知他。

+
+ +
+
+
📮
+

不用注册

+

填昵称和常用邮箱即可提交。后续每一条进展都会发邮件通知,不用反复回来刷。

+
+
+
🔗
+

私人追踪链接

+

提交后会收到一条专属链接,凭它随时查看进度——哪怕这条反馈还没审核通过。

+
+
+
🗂
+

按软件分开

+

每个软件一套独立的问题列表和分类,互不干扰。同一软件的其他人也能看到已解决的问题。

+
+
+
💬
+

公开讨论

+

已公开的问题任何人都能查看和回复。提交前先搜一下,避免重复提同样的问题。

+
+
+ +
+

进入某个软件的反馈系统

+ +

管理员还没有配置软件。

+ +

+ 点击右上角的 + 设置按钮,从列表里选择您要反馈的软件即可。 +

+ +

+ 也可以直接点上方的软件卡片进入。 +

+ +
+
+ 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)'); + /* ---- threads.track_token:提交人的「私人追踪钥匙」 ---- + * 因为本站不做用户登录,没办法认人;但每个人都必须能跟踪自己提交的反馈, + * 哪怕是还没审核通过的。做法:提交时生成一串随机 token 只发到他的邮箱, + * 链接带 ?t= 就能看 / 能回复,别人不知道这串东西就看不到。 + * + * 🔴 不要用 id 代替:id 是自增的,任何人改个数字就能翻别人的私有反馈。 + * token 必须是不可猜的随机值(这里用 32 位 hex = 128 位熵)。 */ + $thCols = $pdo->query('PRAGMA table_info(threads)')->fetchAll(); + $thNames = []; + foreach ($thCols as $c4) { if (isset($c4['name'])) { $thNames[(string)$c4['name']] = true; } } + if (!isset($thNames['track_token'])) { + $pdo->exec("ALTER TABLE threads ADD COLUMN track_token TEXT NOT NULL DEFAULT ''"); + } + // 存量回填:给还没有 token 的帖子补一个,保证老数据的邮件链接也能用 + $need = $pdo->query("SELECT id FROM threads WHERE track_token IS NULL OR track_token = ''")->fetchAll(); + if ($need) { + $upd = $pdo->prepare('UPDATE threads SET track_token = ? WHERE id = ?'); + foreach ($need as $r) { $upd->execute([bin2hex(random_bytes(16)), (int)$r['id']]); } + } + $pdo->exec('CREATE UNIQUE INDEX IF NOT EXISTS idx_threads_token ON threads(track_token)'); + $pdo->exec(<<<'SQL' CREATE TABLE IF NOT EXISTS replies ( id INTEGER PRIMARY KEY AUTOINCREMENT, diff --git a/lib/layout.php b/lib/layout.php index 33f6fef..9d34701 100644 --- a/lib/layout.php +++ b/lib/layout.php @@ -83,9 +83,15 @@ function fb_layout_head($title, array $opt = []) {
- 全部问题 + + 问题列表 + 管理后台 - 我要反馈 + 我要反馈 1): ?> [r,g,b],非法返回 null */ function fb_hex_to_rgb($hex) { $h = ltrim((string)$hex, '#'); - if (strlen($h) === 3) { $h = $h[0].$h[0].$h[1].$h[1].$h[2].$h[2]; } + // 三位简写只接受纯十六进制字符(#abc -> #aabbcc); + // 先做字符校验再展开,否则 "bad" 这种非色值会被当成 #bbaadd + if (strlen($h) === 3 && preg_match('/^[0-9a-fA-F]{3}$/', $h)) { + $h = $h[0].$h[0].$h[1].$h[1].$h[2].$h[2]; + } if (!preg_match('/^[0-9a-fA-F]{6}$/', $h)) { return null; } return [hexdec(substr($h, 0, 2)), hexdec(substr($h, 2, 2)), hexdec(substr($h, 4, 2))]; } @@ -628,6 +632,78 @@ function fb_thread_public(array $thread) { return fb_is_admin(); } +/** + * 生成一个新的追踪 token(提交反馈时调用)。 + * 32 位 hex = 128 位熵,猜不出来;这个值只存在自己库里、只发给提问人邮箱。 + */ +function fb_track_new_token() { + return bin2hex(random_bytes(16)); +} + +/** 这个请求带来的 token 是否与帖子的匹配(常数时间比较,防时序探测) */ +function fb_track_token_ok(array $thread, $given) { + $given = trim((string)$given); + if ($given === '') { return false; } + $real = isset($thread['track_token']) ? trim((string)$thread['track_token']) : ''; + if ($real === '') { return false; } + return hash_equals($real, $given); +} + +/** + * 当前访问者是否「有权跟踪」这条帖子 —— 即带对了 token。 + * 审核后台的管理员也算有权(不然审核时看不了)。 + */ +function fb_thread_trackable(array $thread, $given = null) { + if ($given === null) { $given = isset($_GET['t']) ? (string)$_GET['t'] : ''; } + if (fb_track_token_ok($thread, $given)) { return true; } + return fb_is_admin(); +} + +/** + * 当前访问者能否【查看】这条帖子。 + * + * 公开的(visible=1)所有人可看; + * 未公开的只有两种人能看:审核后台管理员、拿着正确 token 的提交人。 + * 这就是「不登录也能跟踪自己那条」的实现方式,不靠任何登录态。 + */ +function fb_thread_viewable(array $thread, $given = null) { + if (fb_thread_public($thread)) { return true; } + return fb_thread_trackable($thread, $given); +} + +/** + * 回「某个软件的问题列表」的链接。 + * + * 首页现在是介绍页,不带 sw 的回首页等于把用户扔回介绍页 —— + * 从详情/提交页点「返回问题列表」应该回到**当前软件**的列表。 + */ +function fb_software_list_url($sw) { + if (!is_array($sw) || empty($sw['slug'])) { return 'index.php'; } + return 'index.php?sw=' . urlencode((string)$sw['slug']); +} + +/** + * 这条帖子的对外链接。 + * + * 🔴 用 token 不用 id: + * - 邮件里发给提问人的链接一律带 ?t=,他才能看未公开的那条; + * - 公开列表里给所有人看的链接照旧用 id(公开内容本来就谁都能看)。 + * 早期所有链接都是 thread.php?id=N,导致「未审核就点不进去」—— + * id 是自增的,用它当权限等于没有权限。 + * + * @param array $thread 帖子行 + * @param bool $personal true = 带 token 的私人链接;false = 公开链接 + */ +function fb_thread_link(array $thread, $personal = false) { + $id = (int)(isset($thread['id']) ? $thread['id'] : 0); + $url = fb_base_url() . '/thread.php?id=' . $id; + if ($personal) { + $tok = isset($thread['track_token']) ? trim((string)$thread['track_token']) : ''; + if ($tok !== '') { $url .= '&t=' . rawurlencode($tok); } + } + return $url; +} + /* ---------------- 安装状态 ---------------- */ /** FeedLite 开源仓库地址(页脚版权链接的默认值,可在后台改) */ diff --git a/new.php b/new.php index 1340971..ce76bd9 100644 --- a/new.php +++ b/new.php @@ -8,7 +8,11 @@ $pdo = fb_db(); $err = ''; $atts = []; // 校验失败时保留已上传的附件 -/* ---- 软件:URL 带 sw 就用它,否则落到默认软件 ---- */ +/* ---- 软件:URL 带 sw 就用它 ---- + * 🔴 不再「没带 sw 就默认落到第一个软件」: + * 首页现在是介绍页,直接访问 new.php 没有软件上下文, + * 默默替他选一个软件会让反馈进错地方、发错通知邮件。 + * 没有软件时给一句引导,让用户先选软件。 */ $swList = fb_softwares(true); $sw = null; $swKey = isset($_GET['sw']) ? (string)$_GET['sw'] : ''; @@ -16,8 +20,32 @@ if ($swKey !== '') { $sw = fb_software($swKey); if (!$sw || (int)$sw['enabled'] !== 1) { $sw = null; } } -if ($sw === null && $swList) { $sw = $swList[0]; } -$swId = $sw ? (int)$sw['id'] : 0; +if ($sw === null) { + fb_layout_head('请先选择软件', ['robots' => 'noindex,follow']); + ?> +
+

请先选择要给哪个软件提反馈

+

不同软件的问题列表是分开的,请先选一个。

+ +

返回首页

+
+ prepare('INSERT INTO threads(title, body, category, category_id, software_id, author_name, author_email, author_phone, status, visible, reply_count, created_at, updated_at, ip_hash) VALUES(?,?,?,?,?,?,?,?,?,?,0,?,?,?)'); + /* track_token:提交人的私人追踪钥匙,只发到他的邮箱。 + * 有了它,即使这条还没审核通过,他也能凭链接看到进度。 */ + $trackToken = fb_track_new_token(); + $ins = $pdo->prepare('INSERT INTO threads(title, body, category, category_id, software_id, author_name, author_email, author_phone, status, visible, reply_count, created_at, updated_at, ip_hash, track_token) VALUES(?,?,?,?,?,?,?,?,?,?,0,?,?,?,?)'); $ins->execute([ $form['title'], $form['body'], $curCat ? (string)$curCat['slug'] : 'other', // 老列留个值,便于回退与检索 @@ -105,7 +136,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') { $swId > 0 ? $swId : null, $form['name'], $form['email'], $form['phone'] !== '' ? $form['phone'] : null, - $initStatus, $visible, $now, $now, fb_ip_hash(), + $initStatus, $visible, $now, $now, fb_ip_hash(), $trackToken, ]); $newId = (int)$pdo->lastInsertId(); fb_attach_save($pdo, $newId, null, $vList); @@ -138,7 +169,12 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') { // 发信失败绝不能让用户提交失败 —— 反馈已经存库了 } - fb_redirect('thread.php?id=' . $newId . '&submitted=1'); + /* 提交完成后直接带 token 跳到详情页: + * 这样用户当场就能看到自己刚提交的那条(哪怕还没审核), + * 而不是只看到一个「等待审核」的静态提示页。 */ + fb_redirect('thread.php?id=' . $newId + . '&t=' . rawurlencode($trackToken) + . '&submitted=1'); } } } @@ -146,7 +182,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') { fb_layout_head('我要反馈', ['robots' => 'noindex,follow', 'software' => $sw]); ?> - +

我要反馈

diff --git a/thread.php b/thread.php index ee36629..0c7f209 100644 --- a/thread.php +++ b/thread.php @@ -11,16 +11,34 @@ if ($id <= 0) { fb_redirect('index.php'); } $st = $pdo->prepare('SELECT * FROM threads WHERE id = ?'); $st->execute([$id]); $thread = $st->fetch(); + +/* 这条帖子所属的软件 —— 「返回问题列表」要回到它的列表, + * 而不是回到首页(首页现在是介绍页)。 */ +$backUrl = fb_software_list_url( + $thread && !empty($thread['software_id']) ? fb_software((int)$thread['software_id']) : null +); if (!$thread) { http_response_code(404); fb_layout_head('问题不存在'); - echo '

该问题不存在或已被删除。

返回问题列表

'; + echo '

该问题不存在或已被删除。

返回问题列表

'; fb_layout_foot(); exit; } -// 审核(先审后显):未通过审核的问题对访客不可见 -if (!fb_thread_public($thread)) { +/* 审核(先审后显)+ 提交人追踪: + * + * 可见性三档 —— + * ① 已公开:所有人可看 + * ② 未公开 + 带对 token:提交人本人可看(这是他的私人进度页) + * ③ 未公开 + 没 token:对外一律装作「不存在」,不泄露标题也不泄露状态 + * + * 🔴 关键:带对 token 时**绝不能**再走「提交成功,等待审核」那个提示页。 + * 用户之前就卡在这里 —— 邮件里给了链接让他看进度,点进来却只看到 + * 「等待审核」,等于链接白给。带 token 进来就应该看到完整详情。 */ +$hasToken = isset($_GET['t']) ? trim((string)$_GET['t']) : ''; +$isOwnerView = $hasToken !== '' && fb_track_token_ok($thread, $hasToken); + +if (!fb_thread_viewable($thread)) { $justSubmitted = (isset($_GET['submitted']) && (string)$_GET['submitted'] === '1'); if ($justSubmitted) { fb_layout_head('提交成功', ['robots' => 'noindex,nofollow']); @@ -29,15 +47,16 @@ if (!fb_thread_public($thread)) { . '

您的反馈已收到(编号 #' . (int)$thread['id'] . '),我们会先确认它是否为应用问题:

' . '

· 确认为问题 —— 审核通过后会公开显示,其他人也能查看与回复;
' . '· 确认不是问题 —— 我们会关闭它,并把结论与说明发送到您留下的邮箱。

' - . '

审核通常在 1~2 个工作日内完成,请留意邮箱。

' - . '

返回问题列表

' + . '

审核通常在 1~2 个工作日内完成。' + . '我们会发一封邮件给您,里面的链接可以随时查看这条反馈的处理进度。

' + . '

返回问题列表

' . ''; fb_layout_foot(); exit; } http_response_code(404); fb_layout_head('问题不存在'); - echo '

该问题不存在或已被删除。

返回问题列表

'; + echo '

该问题不存在或已被删除。

返回问题列表

'; fb_layout_foot(); exit; } @@ -49,11 +68,12 @@ $form = ['name' => '', 'email' => '', 'body' => '', 'captcha' => '']; /* ---------------- 处理回复提交 ---------------- */ if ($_SERVER['REQUEST_METHOD'] === 'POST') { - // 审核中(未公开)的问题不允许回复 - if (!fb_thread_public($thread)) { + /* 未公开的帖子,只有带对 token 的提交人(或管理员)能回复。 + * 别人连看都看不到,自然也不该能回。 */ + if (!fb_thread_public($thread) && !$isOwnerView) { http_response_code(403); fb_layout_head('暂不可回复'); - echo '

该问题还在审核中,暂时不能回复。

返回问题列表

'; + echo '

该问题还在审核中,暂时不能回复。

返回问题列表

'; fb_layout_foot(); exit; } @@ -131,7 +151,10 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') { ]; fb_notify_reply($pdo, $thread, $replyRow, $isAdmin); - fb_redirect('thread.php?id=' . $id . '#r' . $replyId); + /* 回复后跳回去时要带上 token,否则提交人对未公开反馈的访问权会当场丢掉 */ + fb_redirect('thread.php?id=' . $id + . ($isOwnerView ? '&t=' . rawurlencode($hasToken) : '') + . '#r' . $replyId); } } } @@ -153,7 +176,18 @@ $thSwForColor = !empty($thread['software_id']) ? fb_software((int)$thread['softw fb_layout_head(fb_excerpt($thread['title'], 50), ['software' => $thSwForColor]); ?> - + + + + +
+ 这是您的私人跟踪链接 + 这条反馈还没通过审核,其他人看不到。请保留本页链接(或邮件里的链接), + 审核结果与后续进展都能在这里查看。 +
+
@@ -206,8 +240,11 @@ fb_layout_head(fb_excerpt($thread['title'], 50), ['software' => $thSwForColor]);

我要回复

-
+ + +