平台工程

自检 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 出字符串贴过来,不手打)

事实记录(不可修改区)

客观查证(不加解释)

两条 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 · 占位数据必须一眼可辨为占位,假数据比空白更危险 ⚠️首次

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

第二个尤其说明问题:它不是逻辑错误,是「静态 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-IPX-Forwarded-For,只是后端没读。配置和代码各自都「对」,接缝处没人管。

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

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

关联文档

类型/平台工程