[审计]FlintHub 四问题核查与解决方案

👑Lv.11 元老 🌞 骨干会员
2026-10-25 00:33:26

本文由 FlintHub 开发文档归档发布。原始文档日期见标题前缀,来源:WWW/docs/四问题核查与解决方案_2026-09-21.md。

FlintHub 四问题核查与解决方案

  • 核查日期:2026-09-21
  • 核查方式:静态代码审计 + 数据实测(PHP CLI 跑语言包比对脚本)
  • 状态:仅核查,未改动任何业务代码
  • 复现脚本(本次核查所用,只读):.workbuddy-ai/tmp/i18n_diff.php、i18n_js_refs.php、i18n_full.php、i18n_dyn.php
#问题是否定位到根因性质
1退出登录报 403 - CSRF 验证失败✅ 是(多因叠加)机制缺陷
2编辑器粘贴后光标丢失✅ 是确定性代码缺陷
3.1快捷键✅ 是(3 处)实现缺失 / 双轨冲突
3.2代码块与复制按钮✅ 是(2 处)调用缺失 / 功能缺失
3.3删除线双向互通✅ 是标签集合不一致
3.4i18n 英文回退✅ 是机制隐患(当前无实例)
4通知删除后徽标仍显示数量✅ 是缓存未失效


一、退出登录出现「403 - CSRF 验证失败」

1.1 触发链

退出登录按钮是全站导航里的一个 POST 表单(app/Views/layouts/main.php:247、:362、:444),

提交时带 csrf 隐藏域,服务端在 app/Controllers/AuthController.php:288 校验:

public function logout()
{
    Csrf::verifyOrDie($_POST['csrf'] ?? '');   // ← 失败即 403 + exit
    ...
}

Csrf::verifyOrDie() 失败时的输出就是用户看到的那一页(app/Helpers/Csrf.php:51-58):

http_response_code(403);
echo '

403 - CSRF 验证失败

'; exit;

所以问题不在表单漏带 token,而在提交时该 token 已经失效。

1.2 根因

根因 A(主因):CSRF 用的是「固定签发窗口」,不是「滑动窗口」

app/Helpers/Csrf.php:

private const TTL = 1800;                       // :14

public static function token(): string {
    if (empty($_SESSION['csrf']) || empty($_SESSION['csrf_time'])
        || time() - $_SESSION['csrf_time'] > self::TTL) {   // :19  只有"已超时"才轮换
        $_SESSION['csrf'] = bin2hex(random_bytes(32));
        $_SESSION['csrf_time'] = time();
    }
    return $_SESSION['csrf'];
}

public static function verify($token): bool {
    ...
    $issuedAt = (int)($_SESSION['csrf_time'] ?? 0);
    if ($issuedAt <= 0 || (time() - $issuedAt) > self::TTL) {   // :45  严格按"签发时刻"判死
        return false;
    }
    return hash_equals($_SESSION['csrf'], (string)$token);
}

这两段合起来的语义是:一个 token 的寿命上限 = 从它被生成那一刻起的 30 分钟,期间无论用户多么活跃都不续期。

推演(用户只是正常挂着页面):

时刻动作`csrf_time`结果
T0打开任意页面T0页面里写入 token A
T0+10m刷新页面T0(未超时→不轮换)页面里仍是 A
T0+20m刷新页面T0仍是 A
T0+31m点退出登录T031m > 30m → 403

而对比 Session 是滑动的(app/Core/SessionHandler.php:34 同为 1800s,但每次请求都续期 expires = time() + TTL,见 :104、:130),

所以用户"明明还在线"却"退出不了"——两者口径不一致是问题的核心。

根因 B(放大器):token 是会话级单值,任何一次页面渲染都可能把它换掉

app/Core/Controller.php:41 —— 每个 view() 都会调用 Csrf::token():

'csrfToken' => Csrf::token(),

于是多标签页场景必现:

  1. 标签页 A 打开(T0,token A)
  2. 标签页 B 打开或刷新(T0+31m)→ 触发轮换 → $_SESSION['csrf'] 变成 B
  3. 回到标签页 A 点「退出登录」→ 提交的仍是 A → hash_equals 失败 → 403

此外还有几处会主动作废 token:

位置调用影响
AuthController.php:120Csrf::rotate() 登录后登录前打开的其他标签页 token 全废
AuthController.php:292Csrf::rotate() 登出后—
ThreadController.php:467、:490Csrf::consume()删帖等操作后 unset,下个请求重新生成 → 其他标签页 token 废
ProfileController.php:80Csrf::consume()改密码后同上
ApiController.php:114返回 csrf_token同上

根因 C(叠加):Session 过期后靠「记住我」重建,新会话没有 csrf

app/init.php:88 每请求都跑 RememberMe::check()。当 session 已过 30 分钟 TTL 被回收,

会话被重建时 $_SESSION['csrf'] 为空 → Csrf.php:42 直接 return false → 403。

用户此时页面看着还是"已登录"(导航栏有头像),于是困惑加倍。

根因 D(体验):403 是死路

verifyOrDie() 只 echo + exit,没有"会话已过期,请刷新重试"的引导,也没有自动重试,

用户只能手动回首页。

1.3 解决方案

方案 1(推荐,最小改动):verify() 通过后滑动续期

保留 30 分钟"不活跃即失效"的安全语义,但把窗口从"签发时刻"改为"最后活跃时刻"。

// app/Helpers/Csrf.php
public static function verify($token): bool
{
    if (!\is_scalar($token)) return false;
    if (empty($_SESSION['csrf'])) return false;

    $issuedAt = (int)($_SESSION['csrf_time'] ?? 0);
    if ($issuedAt <= 0 || (time() - $issuedAt) > self::TTL) {
        return false;
    }
    if (!hash_equals($_SESSION['csrf'], (string)$token)) {
        return false;
    }
    // ★ 校验通过 → 续期(滑动窗口),与 Session TTL 口径对齐
    $_SESSION['csrf_time'] = time();
    return true;
}

代价说明:被窃取的 token 重放窗口会随受害者活跃而延长。若需保留硬上限,

可再加 $_SESSION['csrf_born'],超过 8 小时强制轮换。

方案 2(推荐叠加):双 token 宽限,消除"轮换即打断其他页面"

轮换时把旧 token 存入 csrf_prev,verify() 同时接受两者(各自独立 TTL):

public static function token(): string
{
    if (empty($_SESSION['csrf']) || empty($_SESSION['csrf_time'])
        || time() - $_SESSION['csrf_time'] > self::TTL) {
        if (!empty($_SESSION['csrf'])) {
            $_SESSION['csrf_prev']      = $_SESSION['csrf'];
            $_SESSION['csrf_prev_time'] = $_SESSION['csrf_time'];
        }
        $_SESSION['csrf'] = bin2hex(random_bytes(32));
        $_SESSION['csrf_time'] = time();
    }
    return $_SESSION['csrf'];
}

public static function verify($token): bool
{
    if (!\is_scalar($token)) return false;
    $t = (string)$token;

    // 当前 token
    if (!empty($_SESSION['csrf'])
        && (time() - (int)($_SESSION['csrf_time'] ?? 0)) <= self::TTL
        && hash_equals($_SESSION['csrf'], $t)) {
        $_SESSION['csrf_time'] = time();
        return true;
    }
    // 上一枚 token(宽限期:给已打开的页面一个提交窗口)
    if (!empty($_SESSION['csrf_prev'])
        && (time() - (int)($_SESSION['csrf_prev_time'] ?? 0)) <= self::TTL
        && hash_equals($_SESSION['csrf_prev'], $t)) {
        return true;
    }
    return false;
}

rotate() 与 consume() 需同步维护 csrf_prev(或直接清空 prev,按安全策略取舍)。

方案 3(体验兜底,强烈建议一起做):登出失败不要白屏

登出是低风险动作(危害仅"被第三方登出"),完全不必用 403 白屏惩罚用户:

// app/Controllers/AuthController.php
public function logout()
{
    // 登出属低风险幂等动作:CSRF 不通过时仍执行登出,但记录审计
    if (!Csrf::verify($_POST['csrf'] ?? '')) {
        \app\Helpers\AuditLog::log('logout_csrf_miss', 'user',
            (int)($_SESSION['user_id'] ?? 0), '登出时 CSRF 校验未通过,已降级执行');
    }
    ... // 其余登出逻辑不变
}

若坚持严格校验,则至少把 verifyOrDie 换成友好页:提示"会话已过期,请刷新页面后重试" +

一个不带 CSRF 的强制登出链接(或直接跳 /login)。

方案 4(辅助):TTL 与 Session 对齐

把 Csrf::TTL 提到与 Session 相同或更长(例如都 7200s),可显著降低发生率,但不解决根因 B。

1.4 验证方法

  1. 打开任意页面,不动它 31 分钟后点退出 → 现状必 403;修复后应正常登出。
  2. 双标签页:A 页停留 >30 分钟,B 页刷新一次,回 A 页点退出 → 现状 403;方案 2 后应成功。
  3. 用 cli 侧直接构造会话(手写 sessions 行)验证 verify() 续期后 csrf_time 是否更新。

二、编辑器粘贴内容后光标消失

2.1 根因(确定性代码缺陷)

assets/js/editor.js:383-405:

function insertAtCursor(html) {
    html = sanitizeHTML(html);
    var range = getAnyRange();
    if (!range) return;

    range.deleteContents();
    var temp = document.createElement('div');
    temp.innerHTML = html;
    var frag = document.createDocumentFragment();
    while (temp.firstChild) {
        frag.appendChild(temp.firstChild);      // frag 装满
    }
    range.insertNode(frag);                     // ← 把 frag 的子节点全部搬进文档,frag 变空

    var sel = window.getSelection();
    sel.removeAllRanges();                      // ← 选区已清空
    var newRange = document.createRange();
    newRange.setStartAfter(frag.lastChild || frag);   // ★ BUG:frag.lastChild === null → 回退到 frag
    newRange.collapse(true);
    sel.addRange(newRange);                     // ← 永远执行不到
}

关键点:

  1. range.insertNode(frag) 按 DOM 规范会把 DocumentFragment 的所有子节点移入文档,frag 随后为空 →

frag.lastChild 为 null;

  1. || frag 回退到 DocumentFragment 本身,而 DocumentFragment 的 parentNode 恒为 null;
  2. Range.setStartAfter(node) 规范明确:node.parentNode === null 时抛 InvalidNodeTypeError;
  3. 异常发生在 sel.removeAllRanges() 之后 → 选区被清空且没有重建 → 光标消失。

内容本身已经插入成功(insertNode 在抛错前完成),所以现象正是用户描述的

"内容进来了,但光标没了,必须重新点一下才能继续打字"。

2.2 连带影响(容易被忽略)

同一个 paste 处理器里,异常会中断后续语句(editor.js:1119):

if (html) {
    insertAtCursor(sanitizeHTML(html));   // ← 这里抛错
} else {
    insertAtCursor(sanitizeHTML(escapeHtml(text)));
}
processExternalImages(editor);            // ← 永远执行不到

→ 粘贴含外链图片的富文本时,"外链图片自动本地化"功能从未生效(processExternalImages 定义在 :2038)。

浏览器控制台会同时出现 InvalidNodeTypeError。

2.3 修复方案

function insertAtCursor(html) {
    html = sanitizeHTML(html);
    var range = getAnyRange();
    if (!range) return;

    range.deleteContents();
    var temp = document.createElement('div');
    temp.innerHTML = html;
    var frag = document.createDocumentFragment();
    while (temp.firstChild) {
        frag.appendChild(temp.firstChild);
    }

    // ★ 插入前先记住最后一个子节点(insertNode 之后 frag 会被搬空)
    var lastNode = frag.lastChild;
    range.insertNode(frag);

    var sel = window.getSelection();
    sel.removeAllRanges();
    var newRange = document.createRange();
    if (lastNode) {
        newRange.setStartAfter(lastNode);
    } else {
        // 空内容兜底:退化到插入点自身(避免任何非法节点)
        newRange.setStart(range.startContainer, Math.min(range.startOffset, range.startContainer.length || 0));
    }
    newRange.collapse(true);
    sel.addRange(newRange);
}

备选极简写法:insertNode() 后按规范 range 的 start/end 都指向插入内容之后,

可直接 range.collapse(false); sel.addRange(range);。但 lastNode 记录法兼容性更稳。

同类问题自查:insertTextNode()(:410-423)用的是真实文本节点 setStartAfter(node),合法,无需改;

COMMANDS.codeblock(:843-846)用 setStartAfter(br)(br 已入文档),合法。

2.4 验证方法

粘贴一段多行富文本 → 光标应停在插入内容末尾且可直接继续输入;

粘贴含 https://… 图片的富文本 → 图片应被本地化,控制台无 InvalidNodeTypeError。


三、编辑器四项

3.1 快捷键

实现集中在 assets/js/editor.js:1602-1663(keydownDispatch),键位表:

var VISUAL_KEYS = { 66:'bold', 73:'italic', 85:'underline', 75:'link' };
var MD_KEYS     = { 66:'bold', 73:'italic', 85:null,         75:'link' };
var VISUAL_SHIFT_KEYS = { 55:'insertOrderedList', 56:'insertUnorderedList', 57:'blockquote', 67:'code', 75:'codeblock' };
var MD_SHIFT_KEYS     = { 55:'list', 56:'list', 57:'quote', 67:'code', 75:'codeblock' };

缺陷 A(较严重):Ctrl+Z / Ctrl+Y 未被接管,与编辑器自有撤销栈双轨冲突

  • keydownDispatch 只映射 B/I/U/K 与 Ctrl+Shift+*,没有 Z/Y 分支 → 浏览器原生撤销直接作用于 contenteditable;
  • 而编辑器另有一套独立历史栈(createHistory() :18-61,push 于 :1069、:1074、:1713),

工具栏的撤销/重做按钮走的是这套栈(COMMANDS.undo/redo :879-896);

  • 两条撤销链互不知情 → 用户按 Ctrl+Z 后点「重做」按钮,会出现内容跳变或回退到意外状态。

缺陷 B:Markdown 模式下 Ctrl+U 不拦截,且帮助面板未标注

  • MD_KEYS[85] = null → if (!cmd) return; 早于 preventDefault(),Ctrl+U 落到浏览器默认

(Firefox 为"查看网页源代码");

  • 而帮助面板 :1566 把 Ctrl + U → 下划线 列在通用位置,未说明"仅可视化模式有效"。

缺陷 C:无 macOS 分支

:1624 注释明示"仅 Ctrl,不加 Cmd 分支",macOS 用户按 ⌘B 无效。

修复建议

  1. 接管 Ctrl+Z / Ctrl+Y / Ctrl+Shift+Z,统一走 COMMANDS.undo/redo,并对 contenteditable 关闭原生撤销:

editor.setAttribute('data-gramm', 'false') 无关;可在 keydown 中 e.preventDefault() 后调 COMMANDS.undo。

更彻底的做法是 document.execCommand('defaultParagraphSeparator', ...) 无法关闭原生栈,

因此推荐统一接管快捷键,避免双栈并存。

  1. MD_KEYS 补 85 的处理:要么给出 MD 语义(如插入 <u></u> 或忽略),要么在 return 前

e.preventDefault(),避免触发浏览器默认行为。

  1. 帮助面板按模式分行("可视化模式" / "Markdown 模式"两栏),消除歧义。
  2. 增加 var mod = e.ctrlKey || e.metaKey;(保留现有行为可加开关)。

3.2 代码块与复制按钮

缺陷 A(确认):新插入的代码块不会出现「复制」按钮

attachCopyButtons()(:1677-1708)全库只有 2 个调用点:

调用点时机
:965编辑器初始化时(仅处理已有内容)
:1828Markdown → 可视化切换时

而用户点击工具栏插入代码块走的路径是:

keydown/toolbar → handleCommand('codeblock')  (:1498-1502)
                 → COMMANDS.codeblock()       (:828-850)  ← 插入 
,未挂按钮
                 → afterEdit(editorId)        (:1710-1716) ← 只做 history/字数/按钮态

afterEdit() 内容如下,不含 attachCopyButtons:

function afterEdit(editorId) {
    var editorData = ACTIVE_EDITORS[editorId];
    if (!editorData) return;
    editorData.history.push(editorData.editor.innerHTML);
    updateWordCount(editorId);
    updateBtnStates(editorId);
}

→ 结论:用户自己插入的代码块没有复制按钮(页面加载时已存在的才有)。

同理,粘贴进来的代码块、撤销/重做后新出现的代码块也都没有。

修复建议:把挂按钮收进 afterEdit()(幂等,已有按钮会被 :1682 的判重跳过):

function afterEdit(editorId) {
    var editorData = ACTIVE_EDITORS[editorId];
    if (!editorData) return;
    attachCopyButtons(editorData.editor);   // ★ 新增
    editorData.history.push(editorData.editor.innerHTML);
    updateWordCount(editorId);
    updateBtnStates(editorId);
}

缺陷 B(功能缺失):正文/详情页的代码块没有复制按钮

全库检索 pre > code / .editor-copy-btn 仅命中 assets/js/editor.js(及旧副本 editor.php),

app/Views/thread/show.php 与其它阅读端视图均无相关逻辑,也没有全局 JS 补挂。

→ 若期望阅读端也能一键复制代码块,属功能缺失,需要新增一段一次性事件委托脚本(含 document.body 级 click 代理)。

附带确认(无问题):按钮文本不会污染存储——stripCopyButtons()(:623-629)在表单提交

(:1045)与 HTML→MD(:535)两处都做了剥离。

3.3 删除线双向互通

核查结论:转换链路是通的,但「交互层」不认 <del>,造成用户感知的"不通"。

先说没问题的部分(已逐项实测/读源确认):

环节结论
HTML→MD 规则注册顺序turndown.js:276 的 Rules.add 是 array.unshift(rule) → 自定义 strikethrough 优先于 gfm 内置,输出 ~~ ✅
MD→HTMLmarked v15.0.12,默认 gfm: true → ~~x~~ → <del> ✅
服务端净化app/Helpers/Content.php:46 白名单含 's', 'del' ✅
前端净化sanitizeHTML()(:574-609)只删危险标签/属性,不删 s/del ✅

缺陷:<del> 被排除在「删除线判定」之外

// :642
COMMANDS.strikeThrough = function() { toggleWrap('s'); };   // 只会包裹/取消 

// :227-233
function toggleWrap(tagName, attrs) {
    if (isSelectionInTag(tagName)) {   // ← 只查 's'
        unwrapSelection(tagName);
    } else {
        wrapSelection(tagName, attrs);
    }
}

// :1757  按钮激活态
else if (cmd === 'strikeThrough') active = isSelectionInTag('s') || isSelectionInTag('strike');

而 HTML→MD 的规则 filter(:551)明确包含 del,marked 产出的也是 <del> —— 两边标签集合不一致。

后果(用户感知就是"双向不通"):

  1. 在 Markdown 里写 ~~文字~~ → 切回可视化 → 光标放到"文字"上 → 工具栏删除线按钮不高亮(判定只认 s/strike);
  2. 此时点删除线按钮,isSelectionInTag('s') 为 false → 走 wrapSelection 分支 →

不是取消删除线,而是再套一层,得到 <s><del>文字</del></s>;反复点击会不断嵌套。

修复建议:抽出统一的"删除线标签集合"常量,判定/取消/激活态共用。

var STRIKE_TAGS = ['s', 'del', 'strike'];

function isSelectionInAnyTag(tags) {
    var sel = window.getSelection();
    if (!sel || !sel.rangeCount) return false;
    var node = sel.getRangeAt(0).commonAncestorContainer;
    if (node.nodeType === 3) node = node.parentElement;
    if (!node || node === node.ownerDocument.body) return false;
    for (var i = 0; i < tags.length; i++) {
        if (node.closest(tags[i])) return true;
    }
    return false;
}

COMMANDS.strikeThrough = function() {
    if (isSelectionInAnyTag(STRIKE_TAGS)) {
        // 取消:依次 unwrap 命中的标签
        STRIKE_TAGS.forEach(function(t) { if (isSelectionInTag(t)) unwrapSelection(t); });
    } else {
        wrapSelection('s');
    }
};
// updateBtnStates: active = isSelectionInAnyTag(STRIKE_TAGS);

3.4 i18n 英文回退

实测数据(本次核查全部为脚本实测,非推断)

检查项结果
核心三语键数zh 1340 / en 1340 / zh_tw 1340
键集合差异0(三语互相零差异)
键序一致性一致(en、zh_tw 与 zh 的公共键顺序相同)
插件语言包26 个插件,zh/en 键数逐一对齐(如 mod_system 119=119、user_level 138=138)
前端 __t/_t 静态引用键48 个,全部命中(唯一"缺失"是正则误抓的拼接前缀 js.emoji.)
PHP 侧静态引用键1970 个,未命中 10 个,全部是动态拼接前缀(如 plugin.user_level.lv + 序号)
10 组动态前缀的中英对称性完全对称(如 plugin.user_level.lv zh 8 / en 8)
en 空值键仅 thread.count_unit 1 个(工作记忆记载为"有意留空")

→ 结论:当前不存在任何"英文界面显示中文 / 显示键名"的实例。

但机制上确有两个隐患,这才是"英文回退"的根子:

隐患 1:回退目标写死为中文

// app/Helpers/I18n.php:24
public const DEFAULT_LANG = 'zh';

// :148-150  get()  —— 当前语言缺键 → 回退中文
if ($text === null && $lang !== self::DEFAULT_LANG) {
    $text = self::load(self::DEFAULT_LANG)[$key] ?? null;
}

英文用户缺键时看到的是中文。这会让"英文包漏翻译"这类问题被中文兜底掩盖,很难在测试中发现——

一旦将来新增 en 键时漏掉某个,线上表现不是"显示键名"(一眼可见),而是"英文页面里冒出一句中文"(不易察觉)。

隐患 2:jsSubset() 的兜底口径与 get() 不一致

// :165-185
$dict = self::load($lang);
foreach ($dict as $k => $v) {
    if (strpos($k, 'js.') === 0 || strpos($k, 'lang.') === 0) $subset[$k] = $v;
}
if ($lang !== self::DEFAULT_LANG) {
    $zh = self::load(self::DEFAULT_LANG);
    foreach ($subset as $k => $v) {          // ★ 遍历的是 $subset(当前语言"已有"的键)
        if ($v === '' && isset($zh[$k])) {   // ★ 只处理"键存在但值为空"
            $subset[$k] = $zh[$k];
        }
    }
}

  • 只覆盖「键存在但值为空」,不覆盖「键根本不存在」;
  • 而 get() 的判定是 $text === null(含"键不存在")→ 两处口径分叉。

当前因三语键集合零差异而未暴露,但属于"等一个漏译就出问题"的定时炸弹。

修复建议

  1. 统一兜底口径,让 jsSubset() 与 get() 一致:
if ($lang !== self::DEFAULT_LANG) {
    $zh = self::load(self::DEFAULT_LANG);
    foreach ($zh as $k => $v) {
        if ((strpos($k, 'js.') === 0 || strpos($k, 'lang.') === 0)
            && (!isset($subset[$k]) || $subset[$k] === '')) {
            $subset[$k] = $v;
        }
    }
}

  1. 明确并显式化回退策略(二选一,建议前者):
  • A. 保留"回退中文",但在开发/预发环境对缺键打 error_log,并加 CI 校验;
  • B. 英文缺键时回退英文键名或留空,让问题在测试期就暴露。
  1. 加自动校验脚本(本次核查脚本可直接固化为 CI 一步):

三语键集合相等 + 键序一致 + js./lang. 覆盖 + 前端静态引用键全命中 + 动态前缀对称。

  1. 注意:jsSubset() 只注入 js. / lang.,其它前缀在 JS 端取不到——

新增前端文案时必须用 js. 前缀,否则 window.__t() 会吐键名(建议在脚本里一并断言)。


四、通知删除后,铃铛图标仍显示数量

4.1 根因:30 秒 Session 缓存无任何失效入口

app/Views/_components/notification_bell.php:16-28:

// 未读徽标(Session 缓存 30 秒),避免每页请求都查 COUNT
$cacheKey = '_notif_unread_' . $userId;
$unread = 0;
if (isset($_SESSION[$cacheKey]) && (time() - $_SESSION[$cacheKey]['time']) < 30) {
    $unread = $_SESSION[$cacheKey]['count'];        // ← 30 秒内直接吃旧值
} else {
    $unread = \app\Helpers\Notification::getUnreadCount($userId);
    $_SESSION[$cacheKey] = ['count' => $unread, 'time' => time()];
}

而全库检索 _notif_unread_ 只有 2 处命中:本组件自身 + flinthub1.0.23/ 旧副本。

→ 没有任何地方清除这个缓存。

删除通知的链路:

// app/Controllers/NotificationController.php:26-40
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
    Csrf::verifyOrDie($_POST['csrf'] ?? '');
    if (($_POST['action'] ?? '') === 'mark_all_read') {
        Notification::markAllRead($userId);
    } else {
        ...
        Notification::deleteByIds($userId, ...);   // ← 删完直接 redirect
    }
    $this->redirect('/notifications');             // ← 没有清 session 缓存
}

Notification::deleteByIds()(app/Helpers/Notification.php:158-173)内部也没有调

Settings::clearUnreadBadgeCache()。

对照:markAllRead()(:145-152)有调 clearUnreadBadgeCache(),但那是请求级静态缓存

(Settings.php:632 的 private static ?array $unreadBadgeCache),不跨请求,

所以"全部标记已读"同样会残留最多 30 秒。

→ 结论:删除/已读后,铃铛徽标最多 30 秒内保持旧值,用户看到的就是"删了但数字还在"。

另需排除的一种情况:徽标 = 通知未读 + 私信未读(notification_bell.php:31-36 做了 UI 层聚合)。

若用户删除的是已读通知,数量本就不该变化——报障时需先确认删的是未读通知。

4.2 解决方案

方案 A(推荐):在 Notification 的写操作里统一失效

// app/Helpers/Notification.php
/**
 * 失效未读徽标的全部缓存层(Session 30s 缓存 + 请求级合并缓存)
 */
private static function invalidateUnreadCache(int $userId): void
{
    if ($userId > 0) {
        unset($_SESSION['_notif_unread_' . $userId]);
    }
    \app\Helpers\Settings::clearUnreadBadgeCache();
}

public static function markAllRead(int $userId): void
{
    try {
        self::db()->query('UPDATE notifications SET is_read = 1 WHERE user_id = :uid AND is_read = 0', [':uid' => $userId]);
        self::invalidateUnreadCache($userId);          // ★ 替换原来的 clearUnreadBadgeCache()
    } catch (\Throwable $e) {}
}

public static function deleteByIds(int $userId, array $ids): void
{
    if (empty($ids) || $userId <= 0) return;
    try {
        ... // 原删除逻辑不变
        self::invalidateUnreadCache($userId);          // ★ 新增
    } catch (\Throwable $e) {
        \error_log('Notification deleteByIds error: ' . $e->getMessage());
    }
}

同时建议把 add() 也接上(新增未读通知时立刻反映到徽标):

self::invalidateUnreadCache($userId);   // add() 成功后

方案 B(兜底):控制器层显式失效

在 NotificationController::index() 的 POST 分支里,处理完动作后

unset($_SESSION['_notif_unread_' . $userId]);,再 redirect()。

方案 C(体验升级):改为 AJAX 局部刷新

删除/标记已读走 AJAX,成功后服务端返回最新未读数,前端直接更新徽标 DOM,绕开缓存与整页刷新。

方案 D(可选):缩短或去掉 Session 缓存

把 30 秒改为"仅在 $_SESSION 无值时写一次 + 每次页面渲染都查 DB"(多一次 COUNT),

或用 settings._runtime_* 式的权威计数替代 COUNT。


五、修复优先级建议

优先级问题理由改动面
P0问题 2 粘贴光标丢失确定性崩溃级缺陷,且顺带让"外链图片本地化"永久失效1 个函数(insertAtCursor)
P0问题 1 退出登录 403高频、必现、用户无自救路径Csrf.php + logout()
P1问题 4 通知徽标残留用户可感知的"数据不一致"Notification.php 3 处
P1问题 3.2 代码块复制按钮新插入代码块功能不完整afterEdit() 1 行
P1问题 3.3 删除线 <del> 判定会造成标签嵌套堆积editor.js 3 处
P2问题 3.1 快捷键需先与用户确认具体现象再定方案keydownDispatch
P2问题 3.4 i18n 回退口径当前无实例,属预防性加固I18n.php 2 处 + CI 脚本

发布前回归清单

  1. php -S 起本地服务,实测:挂机 31 分钟后登出、双标签页登出、粘贴富文本、插入代码块、MD↔可视化往返删除线;
  2. 重跑既有套件:test_p5_content_img.php、test_p8d_img_group.php、test_trash_preview.sh;
  3. i18n 三语键集合/键序/前端引用键全量比对(本次 4 个脚本可直接复用);
  4. 按发布流程走「维护 → 清理缓存」(data/runtime/pages/*.html 永久缓存,不按 TTL 过期)。

附:本次核查未改动任何业务代码

所有结论均来自静态审计 + 只读脚本实测。核查脚本位于 .workbuddy-ai/tmp/,可复跑复核。

轻量级、高性能、零 MySQL 依赖的PHP社区系统。
| 瀏覽 4 次 | 回覆 0 次

全部回覆 (0)

暫無回覆

請 登入