发布于 更新于 约 8 分钟读完 · 3,146 字

本文目录 10 节
  1. 事实记录(不可修改区)
  2. 一句话总结
  3. 六条可复用 insight
  4. 1 · 同类中的异类才是缺陷藏身处 ⚠️首次
  5. 2 · 复核全票通过等于没复核 ⭐ 第六次现形,新亚型
  6. 3 · 占位数据必须一眼可辨为占位,假数据比空白更危险 ⚠️首次
  7. 4 · 保活信号不能搭业务轮询的便车 ⚠️首次
  8. 5 · 安全属性只做在渲染层等于没做 ⭐ 同族新变体
  9. 6 · 反代之后 socket 地址不代表用户;线下场景整场共用出口网 ⚠️首次
  10. 关联文档

自检 46 条零驳回 · 同类中的异类才是缺陷藏身处

入档:2026-08-19 来源:PB Arena(pb.tiaozhuxiansheng.com)「全项目自检一次,重点检查交互式 bug 及边缘问题」的整轮会话,两批修复共 11 文件 +1585/−184,后端用例 21 → 27,三次部署上线 状态:已上线并线上验证;insight 1、3、4、6 为本次首次发现 ⚠️;insight 2 是 全站文字截断体检_检测工具本身要先被证伪_v1 的第六次现形(新亚型);insight 5 是 公屏下线只删了界面_删界面不等于关链路_v1 的同族新变体 前情:公屏下线只删了界面_删界面不等于关链路_v1 · 零作品的比赛_界面演完不等于链路存在_v1 · 内测修复逐项审计闭环_v1(均同项目) 续篇(2026-08-20):零路由补路由_普查要能推翻已写的代码_v1 —— 本篇清单里最后一项、也是唯一的架构级缺口(全站零路由)的落地。那一轮又贡献四条新律:普查要能推翻已写的代码、探路实验是免费的 bug 探针、完备方案 ≠ 该做的方案、异步修复要问「在途期间会发生什么」;并把本篇 insight 2 的「觉得自己知道于是没查」记到人肉版第三次现形,改成了具体动作(写接口测试前先 grep 出字符串贴过来,不手打)

事实记录(不可修改区)

  • 触发:用户一句「全项目自检一次,重点检查交互式 bug 及边缘问题」,没有清单、没有指定范围
  • 做法两条腿:① 12 个方向并行静态排查,60 个 agent,逐条对抗式复核;② 在隔离实例(独立端口 + scratchpad 临时库)上跑真实用户路径
  • 静态排查交回 46 条,全部标记「已确认」,驳回 0 条
  • 实机另跑出 11 条,与静态排查重叠度低——实机独有的是「连接指示器恒显示连接正常」「断网切页静默失败」「79 个错误码里 42 个没汉化」,静态独有的是后端并发与状态机问题
  • 隔离性核对:全程独立端口(3300/3301/3302)+ scratchpad 临时库;收工时 git status 干净、真实开发库 pb-arena-server/data/pb-arena.sqlite mtime 未变

客观查证(不加解释)

  • 昵称 XSS:注册昵称填 <img src=x onerror=top.PWN=1>(29 字,未超 32 上限,后端当时只校验长度)→ 任意用户打开封神榜 → window.PWN === 1。/api/leaderboard 为公开接口,排行榜任意名次都触发

  • 结算页:三人真实对战,数据库 votes 表 2 票 / 1 票;同一时刻屏幕显示「本场无优胜 / #1 0 票 #2 0 票 #3 0 票 / 胜负积分 +0 / 不撒花」;按 F5 后退回 index.html 里写死的「本场优胜 Mori,作品获得 68% 的投票支持」

  • 房间唯一索引:A 建房 → A 又加入 B 的房间(服务端允许)→ B 掉线 90 秒 → 请求路径全员 500,随后进程退出,日志:

    Error: UNIQUE constraint failed: match_rooms.host_user_id
        at Object.expireWaitingRooms (database.js:2079)
        at Timeout._onTimeout (app.js:1860)
    [exited with code 1]

    app.js:1860 是三个 sweep 定时器里唯一没有 try/catch 的那个;deploy/pb-arena.service 有 Restart=always / RestartSec=3,重启后未复现崩溃循环

  • 频控:一名选手连错 11 次密码后 → 管理员用正确密码登录 429、另一名选手用正确密码 429、新用户注册 429。生产是 nginx 反代到 127.0.0.1,而频控 key 取 request.socket.remoteAddress;deploy/nginx-pb.conf 一直在传 X-Real-IP 与 X-Forwarded-For,后端没读

  • 房间心跳:建房后停在大厅 168 秒不动 → 全程只发出 1 次心跳(建房那一下)→ 服务端约 90 秒把房间置 closed、参与者清零 → 页面上那张卡片仍在、「已等待 1 秒」的时间戳冻住、计数器仍显示 1

  • 刷新即退房:建房后按一次 F5 → 房间 status=closed、房内人数 0

  • 匹配成功页:#matchedState 里写死「亚总 VS Mori」,全文 grep 无任何代码填充过它

  • 错误码:后端 79 个 HttpError 码,前端 authErrorMessage 映射表覆盖 37 个,其余 42 个走 error.message 兜底把英文原文弹给中文用户

两条 agent 描述偏差(抽验后纠正,偏差都指向「比报告说的更糟」)

agent 报告实测
结算页出问题后「F5 刷新就正确」刷新更糟:接口对已结束对战返回 null,面板退回写死的演示数据,大屏打出一个不存在的选手名
房间唯一索引冲突让「大厅接口永久 500」是整个进程被打挂(全站而非只有大厅);但 systemd 3 秒拉起,且无崩溃循环

自己的失误:新增回归测试第一次跑挂两条,均因未先查接口就凭印象写——提前结束对战的 action 是 finished(写成了 finish)、接受好友申请走 /api/friend-requests/:id/respond(写成了 POST 到本体)

一句话总结

一堆同类代码里唯一不一致的那一处,就是缺陷的藏身处——三处最严重的问题(打挂进程的定时器、能偷管理员令牌的 XSS、漏转义的赛事标题)全是「两个包了 try/catch 第三个没包」「所有渲染函数都过 escapeHtml 只有这两行没过」「紧挨着的字段转义了它没转义」这种形状;排查先找异类,比逐行读快一个量级。

六条可复用 insight

1 · 同类中的异类才是缺陷藏身处 ⚠️首次

这次三个最严重的问题,定位方式完全一样:

缺陷同类异类
进程被打挂三个 sweep 定时器只有一个没包 try/catch
存储型 XSS,可偷管理员令牌全文十几个渲染函数都 escapeHtml只有 loadLeaderboard 那两行没过
赛事页注入同一模板里 round.name 转义了紧挨着的 event.title 没转义

方法:不要逐行读代码找 bug。先把「做同一件事的那批代码」枚举出来(grep 同一个函数名、同一个模式),然后看谁跟别人不一样。人写代码时会把一批同类的写成同一个样子,写漏的那处天然是异类。

这条和 全站文字截断体检_检测工具本身要先被证伪_v1 里的「找同类问题按写法搜不按页面搜」是一对:那条讲怎么找全,这条讲找全之后看哪个不对。

2 · 复核全票通过等于没复核 ⭐ 第六次现形,新亚型

「检测工具本身要先被证伪」这次的载体不是脚本,是一支被明确要求「尽力反驳」的 agent 复核团——46 条提案,驳回 0 条。

前五次的亚型是「工具算错了」和「我把工具输出读成了别的意思」。这次是新的第三种:工具的判断机制本身有偏——复核 agent 拿到一段看起来合理的分析文本,倾向于确认而不是去跑一遍。光在 prompt 里写「默认怀疑」「证据不足判 false」不够。

判据:复核结果的驳回率是可信度指标。全过或全不过都不可信,必须抽最严重的几条自己验。这次抽验了,纠正了两条最严重的描述偏差,而这两条纠正直接改变了修法(一条要顺手清生产 HTML 里的占位数据,一条的止血点是那个定时器而不是大厅接口)——照单全收会修错地方。

下次改法:复核 agent 不给原始分析全文(避免锚定),只给「位置 + 一句话结论」要求独立重新推导;或强制要求附「我实际执行了哪些命令、看到什么输出」,没有执行记录的判定不算数。

同一毛病也发生在我自己身上:写回归测试时凭印象写 action 名和路由路径,挂了两条。觉得自己知道,于是没去查——和复核 agent 是同一种失败。

3 · 占位数据必须一眼可辨为占位,假数据比空白更危险 ⚠️首次

一轮里撞到两个实例,是同一失败模式:

  • 结算面板的 resultWinner 写死「Mori」+「作品获得 68% 的投票支持」——接口返回 null 时它原样留在屏幕上;
  • #matchedState 写死「亚总 VS Mori」——全文没有任何代码填充过它,每个匹配成功的人看到的都是这两个不存在的选手。

第二个尤其说明问题:它不是逻辑错误,是「静态 HTML 里的假数据从来没被替换过」,静态排查和实机路径都抓不到,是部署后核对线上 HTML 才发现的。

律:生产 HTML 里的占位内容必须一眼可辨为占位(—、空字符串、「正在读取…」),不能是像模像样的假数据。空白至少能看出坏了,假数据看起来一切正常——尤其当它会被投到大屏上给一屋子人看。

推论:设计稿转代码时塞进去的示例姓名/数字,是这类问题的天然来源。交付前应该 grep 一遍设计稿里的人名。

4 · 保活信号不能搭业务轮询的便车 ⚠️首次

房间心跳原来写在 loadWaitingRooms() 里,而这个函数只被 2 秒轮询调用。后来做 WebSocket 优化,加了「WS 连着时轮询整体空转」——心跳跟着一起被停掉了。

代码里那句 if (window.pbRealtime?.connected) return; 旁边还有注释说明这是有意设计。设计本身没错,错在心跳搭了业务轮询的便车:优化的人只在想「要不要拉列表」,不会想到这条路上还挂着一个保活信号。

后果是完整的一条现场故事:房间被服务端悄悄关掉 → 界面卡着不动(没人推送、也没人轮询)→ 人的第一反应是刷新 → 而 beforeunload 又会主动退房 → 房间真的没了。

律:保活/心跳是独立关注点,必须有自己的生命周期,不能寄生在任何「可能被优化掉」的业务循环里。判据很简单——问一句「如果有人把这个循环关了会怎样」。

5 · 安全属性只做在渲染层等于没做 ⭐ 同族新变体

「匿名投票」界面上老老实实显示「作品 A / B / C」,但接口无条件下发 submissions[].userId,同一个响应体里的 players[] 又是 id→昵称的对照表。打开 DevTools 的 Network 面板就能把每件作品对上具体的人。

这是 公屏下线只删了界面_删界面不等于关链路_v1 的同族——那条是「删界面不等于关链路」,这条是「匿名只做在渲染层不等于真的匿名」。同一个家族还有「权限门禁只做 button.hidden = true」。

判据:任何以「用户看不到」为前提的安全属性(匿名、脱敏、权限),验收方式都是绕过界面直接打接口,看它还给不给。给 → 没做。

修法上有个通用形状:前端需要那个字段往往只是为了判断「这是不是我自己的」,换成一个布尔位就够了,不需要暴露全表身份。

6 · 反代之后 socket 地址不代表用户;线下场景整场共用出口网 ⚠️首次

频控按 request.socket.remoteAddress 计数。生产是 nginx 反代到 127.0.0.1,这个值对所有外部用户恒为 127.0.0.1——全站共用一个桶。一个人连错 11 次密码,管理员用正确密码也登不上,现场当场失去控场能力。

讽刺的是 nginx 配置里一直在传 X-Real-IP 和 X-Forwarded-For,只是后端没读。配置和代码各自都「对」,接缝处没人管。

更要紧的一层:就算读对了真实 IP,线下赛整个会场共用一条出口网——按 IP 卡死照样是全场连坐。所以频控必须以账号为主维度(严,10 次),IP 维度只用来兜住明显的批量扫描(松,60 次),并且只在真正失败时计数(原来在入口无条件 +1,成功的登录也在吃配额)。

推论:任何「按来源限流」的设计,先问两句——① 反代之后这个来源还是用户吗?② 目标用户会不会成批共用同一个来源?线下活动、学校、公司网络全都会。

关联文档

类型/平台工程