平台工程

零作品的比赛 · 界面演完不等于链路存在

入档:2026-07-25 来源:PB Arena(pb.tiaozhuxiansheng.com)第 2 次内测反馈的修复会话,1 个提交(3dcbfbf,7 文件 +936/−101)+ 1 篇文档,已部署上线并封版 v2026.07.25.2 状态:已上线并线上验证;五条 insight 均为本次首次发现 ⚠️,尚未跨项目二次验证 前情:[赛事状态机到点不切换_写入方不等于推进方_v1]——本篇部分修正了那一篇的结论

事实记录(不可修改区)

一句话总结

界面把流程演完了,不等于这条链路真的存在——验收要问「这个功能跑完,数据库里应该多出什么」,然后去查那张表;SELECT COUNT(*) FROM submissions = 0 比任何界面截图都有说服力。

五条可复用 insight

1. 补自动推进之前,先确认触发条件真的是时间 ⚠️首次

上一会话把「状态字段没人推进」当成缺陷修掉了,技术上完全正确。但它顺手把一个产品语义错误固化了:比赛到点自动进入出图阶段,等于要求运营在开赛那一秒之前就把题目写进系统;而真实的运营流程是「人到齐了、题目现场公布」。系统替人做了决定,人就失去了准备的机会。

上一篇的结论「时间驱动功能的验收要写成不碰按钮等它自己变」没有错,错在默认了这个状态转移就是时间驱动的。补自动化时真正该先问的是:这一步该由时钟触发,还是该由某个人的动作触发? 两者的差别不在实现难度,在谁为这一步负责

判据:当你为一个「没人推进状态」的缺陷补上自动推进,先把这个转移读成一句人话——「时间到了,所以 X 发生」。如果这句话里那个「所以」需要有人先准备点什么,它就不是时间驱动的。

本次的正解是把「到点」和「开始」拆开:到点只让赛事进入进行中并停在「等待发布题目」(无倒计时),运营发布本轮题目,该轮才从这一刻起算时长。自动化保留在「不需要人准备」的那一段(阶段流转、跨轮、完赛),把需要人准备的那一步交还给人。

2. 展示层齐全会掩盖链路缺失,用「该多出什么数据」验收 ⚠️首次

官方赛事有:报名按钮、报名人数、出图/上传/投票三段进度条、秒级倒计时、当前轮次、赛制说明。看起来完整得不能再完整,后台也能控制阶段。但整条链路上没有任何地方产生过一件作品。

原因是阶段条和倒计时只依赖 official_events 一张表,而真正的参赛需要 battles 及其下游(submissions / votes / 结算 / 历史)。前者齐了,界面就能把「比赛正在进行」演得非常像;后者压根不存在,报名者点进去只看到一句「已报名 · 等待本轮对战分配」——一句永远不会兑现的文案。

界面完整度是最会骗人的证据,因为 UI 是照着”比赛应该长什么样”写的,不是照着”数据怎么流”写的。可靠的验收只有一句:这个功能跑完,数据库里应该多出什么?去查那张表。 线上三张表 0 行,是这次唯一一眼就能定性的证据。

同族但更前置:云端定时内容生产连环坑复盘_全绿不等于已发_v1 是流程跑完了但产物没发出去;这次是流程演完了,产物压根没有过

3. 新链路复用既有实体,改的是「阶段从哪来」而不是整条链 ⚠️首次

官方赛事要加「每轮提交 + 投票」,最直觉的做法是给轮次新建 round_submissions / round_votes 两张表。那样要连带重写:提交校验、投票去重、不能投自己、结算、积分流水、历史图库、观战权限——一整条下游。

实际做法是让每一轮生成一场既有的 battles 记录(加 source='official'event_id / event_round_id 两个外键列),报名者写进 battle_participants。于是上面那条下游一行都不用改,前端的上传区和投票红心也直接复用。

代价只有一处:battles 原来的阶段是「从 created_at 按固定 8/2/2 分钟推算」的,官方赛事必须跟赛事时钟走。解法是在 phaseForBattle() 里加一条分支——官方赛事的阶段改成读取权威字段(battles.status),由赛事状态机写入,而不是给官方赛事另写一套阶段逻辑。

判据:新功能和既有功能的下游(结算 / 统计 / 展示)如果一样,就复用实体加判别列;只有上游的触发方式不同时,要改的是「阶段/状态从哪来」这一个点,不是复制一整条链。

4. 权威方从客户端搬到服务端,原本被本地自算顺带触发的副作用会失灵 ⚠️首次

房间对战的阶段一直是前端自己按 8/2/2 分钟算的,服务端只是记录。官方赛事的阶段必须由服务端决定。改完之后出现了一个很迷惑的现象:

浏览器标签页标题已经是「上传作品」,页面上的阶段面板还停在「限时出图」。

数据是新阶段,界面没跟上。根因是 refreshBattleState() 只更新状态、不重画阶段面板——这个缺口一直存在,只是房间对战的本地计时器每 250ms 会自己把阶段翻过去、顺手重画了,本地的「自算」一直在替真正的渲染入口打掩护。权威方一换成服务端,本地不再自算,缺口立刻现形。

可迁移:当一个值的权威方从 A 换到 B,要把所有「原本由 A 顺带触发」的副作用挨个找出来重新挂。 找法很直接——把本地自算逻辑关掉,看还有什么不工作了

5. 修复会被调用方绕过:判据依赖进入函数时的状态,调用方却提前把它改掉了 ⚠️首次

「跨局作品残留」是本项目文档里明确记过已修复的老问题。修法是在 initializeBattle() 里比较新旧 battleId,不同就清空上传状态和已选文件。代码确实在,逻辑也对。

但三个调用点都写成这样:

state.battle = result.battle;      // 先赋值
initializeBattle(result.battle);   // 再进函数

函数内部读到的「旧 battleId」已经是新的了,判据恒等,清理分支永远不执行。于是上一局的「已提交」按钮和预览图会原样留到下一局——这次官方赛事第二轮开局时又完整复现了一遍。

这类缺陷的通用形态:防护逻辑依赖「进入函数那一刻的状态」,而调用方在进入之前就修改了那个状态。 它特别难被发现,因为修复代码在、单测(如果只测函数本身)也过,但线上照样复现。

排查手法:不要只看修复函数,要 grep 所有调用点,看有没有人在调用前动了判据依赖的字段。 更根本的修法是让这个函数成为该字段的唯一赋值入口——本次就是把三处调用方的提前赋值直接删掉。

顺手教训

下次改进

关联文档

类型/平台工程