首页改介绍页;列表只服务当前软件;新增UUID追踪让提交人看得到未审核反馈

用户提的两个核心问题:

一、首页不该列全部问题
  场景:这套系统是给某个软件配反馈入口、把地址贴进那软件的「意见反馈」里。
  访客总是从某个软件过来的,看别的软件的问题对他没意义。
  ⇒ / 不带 sw 时渲染介绍页(特点介绍 + 「点右上角设置按钮选软件」+ 软件卡片)
     带 sw 才是该软件的问题列表,检索/筛选/分类全在本软件范围内

二、提交后未审核,用户点不进去(真实缺陷)
  邮件里给了「点击查看您刚才提交的反馈」,但 thread.php 只在 visible=1 时
  才给看,未审核的一律 404 / 只看到「等待审核」静态页 —— 链接等于白给。
  ⇒ threads 加 track_token(32位hex = 128位熵),提交时生成:
     - 邮件里所有链接一律带 ?t=<token>(不再是裸 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+ 项断言全过。
This commit is contained in:
2026-11-03 09:54:12 +08:00
parent 2f28b1e3a9
commit 8bcee46b8a
9 changed files with 411 additions and 32 deletions
+1 -1
View File
@@ -8,7 +8,7 @@ if (!defined('FB_ROOT')) {
define('FB_ROOT', dirname(__DIR__));
// 静态资源版本号:改 assets/ 下的 css/js 后把这个数字 +1,
// 让浏览器与 CDN 缓存失效。全站只此一处,别再往页面里写死数字。
if (!defined('FB_ASSET_V')) { define('FB_ASSET_V', 28); }
if (!defined('FB_ASSET_V')) { define('FB_ASSET_V', 30); }
}
/* ------------------------------------------------------------------
+24 -2
View File
@@ -94,17 +94,39 @@ CREATE TABLE IF NOT EXISTS threads (
author_name TEXT NOT NULL,
author_email TEXT NOT NULL,
author_phone TEXT,
status TEXT NOT NULL DEFAULT 'open',
status TEXT NOT NULL DEFAULT 'hidden',
reply_count INTEGER NOT NULL DEFAULT 0,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
ip_hash TEXT
ip_hash TEXT,
track_token TEXT NOT NULL DEFAULT ''
);
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)');
/* ---- threads.track_token:提交人的「私人追踪钥匙」 ----
* 因为本站不做用户登录,没办法认人;但每个人都必须能跟踪自己提交的反馈,
* 哪怕是还没审核通过的。做法:提交时生成一串随机 token 只发到他的邮箱,
* 链接带 ?t=<token> 就能看 / 能回复,别人不知道这串东西就看不到。
*
* 🔴 不要用 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,
+8 -2
View File
@@ -83,9 +83,15 @@ function fb_layout_head($title, array $opt = []) {
</a>
<?php endif; ?>
<div class="spacer"></div>
<a class="navlink hide-sm" href="./">全部问题</a>
<?php
/* 「问题列表」只在进入某个软件之后才有意义 ——
* 首页现在是介绍页,没有"全部问题"可回。 */
if ($curSw):
?>
<a class="navlink hide-sm" href="?sw=<?php echo e(urlencode((string)$curSw['slug'])); ?>">问题列表</a>
<?php endif; ?>
<?php if (fb_admin_logged_in()): ?><a class="navlink hide-sm" href="admin.php">管理后台</a><?php endif; ?>
<a class="btn-mini hide-sm" href="new.php">我要反馈</a>
<a class="btn-mini hide-sm" href="new.php<?php echo $curSw ? '?sw=' . e(urlencode((string)$curSw['slug'])) : ''; ?>">我要反馈</a>
<?php if (count($swList) > 1): ?>
<?php /* 右上角三横线:点开就是一个小下拉菜单。
+11 -2
View File
@@ -445,7 +445,8 @@ if (!function_exists('fb_mail_layout')) {
}
function fb_notify_html($thread, $reply, $isAdmin) {
$base = fb_base_url();
$url = $base . '/thread.php?id=' . (int)$thread['id'];
// 发给提问人的信,链接必须带 token,否则他看不了自己那条未公开的反馈
$url = fb_thread_link($thread, true);
$site = (string)fb_cfg('site_name');
$name = (string)$thread['author_name'];
$title = (string)$thread['title'];
@@ -770,8 +771,16 @@ if (!function_exists('fb_notify_log2')) {
}
}
if (!function_exists('fb_notify_thread_url')) {
/**
* 邮件里给提问人的链接 —— **一律带 token**,不带 id 裸链。
*
* 为什么:邮件只发给提问人自己,而 token 只有他和我们(库里)知道。
* 带上它,即使这条反馈还没审核通过、甚至最终被关闭,他点进去也能看到
* 完整详情与进度。早期给的是 thread.php?id=N,未公开时点进去只看到
* 「不存在」或「等待审核」,等于邮件里的链接是废的。
*/
function fb_notify_thread_url(array $thread) {
return fb_base_url() . '/thread.php?id=' . (int)$thread['id'];
return fb_thread_link($thread, true);
}
}
+77 -1
View File
@@ -447,7 +447,11 @@ function fb_color_line($hex) {
/** #rrggbb -> [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=<token>,他才能看未公开的那条;
* - 公开列表里给所有人看的链接照旧用 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 开源仓库地址(页脚版权链接的默认值,可在后台改) */