本文由 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.4 | i18n 英文回退 | ✅ 是 | 机制隐患(当前无实例) |
| 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 | 点退出登录 | T0 | 31m > 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(),于是多标签页场景必现:
- 标签页 A 打开(T0,token A)
- 标签页 B 打开或刷新(T0+31m)→ 触发轮换 →
$_SESSION['csrf']变成 B - 回到标签页 A 点「退出登录」→ 提交的仍是 A →
hash_equals失败 → 403
此外还有几处会主动作废 token:
| 位置 | 调用 | 影响 |
|---|---|---|
AuthController.php:120 | Csrf::rotate() 登录后 | 登录前打开的其他标签页 token 全废 |
AuthController.php:292 | Csrf::rotate() 登出后 | — |
ThreadController.php:467、:490 | Csrf::consume() | 删帖等操作后 unset,下个请求重新生成 → 其他标签页 token 废 |
ProfileController.php:80 | Csrf::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 验证方法
- 打开任意页面,不动它 31 分钟后点退出 → 现状必 403;修复后应正常登出。
- 双标签页:A 页停留 >30 分钟,B 页刷新一次,回 A 页点退出 → 现状 403;方案 2 后应成功。
- 用
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); // ← 永远执行不到
}关键点:
range.insertNode(frag)按 DOM 规范会把 DocumentFragment 的所有子节点移入文档,frag随后为空 →
frag.lastChild 为 null;
|| frag回退到 DocumentFragment 本身,而 DocumentFragment 的parentNode恒为null;Range.setStartAfter(node)规范明确:node.parentNode === null时抛InvalidNodeTypeError;- 异常发生在
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 无效。
修复建议
- 接管
Ctrl+Z / Ctrl+Y / Ctrl+Shift+Z,统一走COMMANDS.undo/redo,并对 contenteditable 关闭原生撤销:
editor.setAttribute('data-gramm', 'false') 无关;可在 keydown 中 e.preventDefault() 后调 COMMANDS.undo。
更彻底的做法是 document.execCommand('defaultParagraphSeparator', ...) 无法关闭原生栈,
因此推荐统一接管快捷键,避免双栈并存。
MD_KEYS补85的处理:要么给出 MD 语义(如插入<u></u>或忽略),要么在return前
e.preventDefault(),避免触发浏览器默认行为。
- 帮助面板按模式分行("可视化模式" / "Markdown 模式"两栏),消除歧义。
- 增加
var mod = e.ctrlKey || e.metaKey;(保留现有行为可加开关)。
3.2 代码块与复制按钮
缺陷 A(确认):新插入的代码块不会出现「复制」按钮
attachCopyButtons()(:1677-1708)全库只有 2 个调用点:
| 调用点 | 时机 |
|---|---|
:965 | 编辑器初始化时(仅处理已有内容) |
:1828 | Markdown → 可视化切换时 |
而用户点击工具栏插入代码块走的路径是:
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→HTML marked 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> —— 两边标签集合不一致。
后果(用户感知就是"双向不通"):
- 在 Markdown 里写
~~文字~~ → 切回可视化 → 光标放到"文字"上 → 工具栏删除线按钮不高亮(判定只认 s/strike); - 此时点删除线按钮,
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(含"键不存在")→ 两处口径分叉。
当前因三语键集合零差异而未暴露,但属于"等一个漏译就出问题"的定时炸弹。
修复建议
- 统一兜底口径,让
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;
}
}
}
- 明确并显式化回退策略(二选一,建议前者):
- A. 保留"回退中文",但在开发/预发环境对缺键打
error_log,并加 CI 校验; - B. 英文缺键时回退英文键名或留空,让问题在测试期就暴露。
- 加自动校验脚本(本次核查脚本可直接固化为 CI 一步):
三语键集合相等 + 键序一致 + js./lang. 覆盖 + 前端静态引用键全命中 + 动态前缀对称。
- 注意:
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 快捷键 需先与用户确认具体现象再定方案 keydownDispatchP2 问题 3.4 i18n 回退口径 当前无实例,属预防性加固 I18n.php 2 处 + CI 脚本
发布前回归清单
php -S 起本地服务,实测:挂机 31 分钟后登出、双标签页登出、粘贴富文本、插入代码块、MD↔可视化往返删除线;- 重跑既有套件:
test_p5_content_img.php、test_p8d_img_group.php、test_trash_preview.sh; - i18n 三语键集合/键序/前端引用键全量比对(本次 4 个脚本可直接复用);
- 按发布流程走「维护 → 清理缓存」(
data/runtime/pages/*.html 永久缓存,不按 TTL 过期)。
附:本次核查未改动任何业务代码
所有结论均来自静态审计 + 只读脚本实测。核查脚本位于 .workbuddy-ai/tmp/,可复跑复核。