永远为真的那个检查
这个服务里有 23 条 UPDATE 语句。其中恰好有一条会去看自己是否改动了什么。那个检查是这样的:
'success' => $n->rowCount() >= 0
没有任何一行匹配时,rowCount() 返回 0。零大于或等于零。无论发生什么,这个检查都通过。
这条语句是干什么的
它保存一个勾选项:计数器的主人要不要每周一封邮件摘要。地址放在另一张表里,而那里只有在有人点了他自己申请的那封确认邮件里的链接之后,才会出现一行。有这样一行的计数器有四个。活跃的有 2,218 个。
所以对 99.8 % 的计数器来说,这条 UPDATE 什么也没碰到。接口回答 success: true,而设置没有被保存,因为根本没有地方可以保存。
它上面的注释是对的
往上三行,在同一个函数里:
「只在有确认过的回信通道的地方。没有它就没有信件能去的地址 — 而这个勾选项会承诺一件不会发生的事。」
这完全正确。有人想过这种情况,理解了它,并把推理写了下来。然后下面那一行在推理要求 > 的地方用了 >=。
值得正眼看一看,因为通常那个解释 — 没人想到 — 就在手边,而且是错的。想法就在文件里。出错的是一个字符,出错的位置上,错的版本和对的版本一眼看去一模一样,而且在任何有行可更新的测试里表现完全相同。
没有人被欺骗
接下来是很容易略去的一部分,而略去它会让这篇文章更有戏剧性、也更不真实。
如果那一行不存在,页面根本不会画出这个勾选项。往旁边一屏,在生成站长设置面板的代码里,同一张表先被查了一次,而当查询返回为空时整个区块被跳过。没有确认地址的站长永远看不到这个控件,永远不会点它,也永远不会收到那个假的确认。
那个坏掉的检查,只有带着有效令牌直接调用接口才够得着。这样做的人会拿到 success: true,以及一个没有保存的设置。这是一个真实的缺陷,而且是一个很窄的缺陷。
为什么仍然值得写下来
这个功能之所以安全,靠的是一道没人把它写成「守卫」的守卫。那条为了让它安全而存在的语句,并没有让它安全。真正起作用的,是另一个函数里的一个渲染条件,而那里的注释解释的是为什么把勾选项藏起来 — 而不是说有什么东西依赖于它一直藏着。
去掉或者重排那个渲染条件 — 在重做设置面板时这是一件合理的事 — 缺陷立刻就会显出来,而且没有任何地方把这两处改动联系起来。这份安全是真的,它是偶然的,而偶然的安全,正是会在不相干的工作里消失的那一种。
同一份代码,隔一个文件就做对了。站点环那个对应的函数会先问这一行在不在,不在就返回假,根本不发出更新。那张表现在有零行,所以那个函数每次调用都返回假 — 而这正是正确答案。
一般化的形状
一条没有匹配到任何行的 UPDATE,在任何数据库里都不算错误。它是一条什么也没做的成功语句,而只要没人去问,它上面的每一层都会报告成功。这里二十二条语句不去问,对其中大多数来说这没问题:它们更新的那一行,请求本身已经证明存在。
唯一那条需要去问的,问的方式让它不可能失败。rowCount() >= 0 不是一个弱检查,它是穿着检查外衣的「没有检查」— 而且比完全不检查更糟,因为下一个读这个函数的人,会看到一个被审视过的结果,然后就不再看了。
rowCount() > 0 这个写法在这份代码里出现零次。正是这个数字,把一个打字错误变成值得写一篇的东西:不是它被写错过一次,而是任何地方都没有一个正确的例子可以拿来对照。
已于 2026 年 8 月 28 日修好。这个接口现在会在写入之前先问那一行在不在 — 就像隔一个文件的站点环函数一直在做的那样 — 不在就回答假。那个显而易见的一字符修法,也就是改成检查「大于零」,事先测过并被否掉了:驱动报告的是被改动的行数,而不是被匹配到的行数。把勾选项设成它已经是的那个值,什么也不会改变 — 而那个版本会为一次本来没问题的操作报告失败。两个版本都是错的,只是错在相反的方向。