零作品的比赛 · 界面演完不等于链路存在
入档:2026-07-25 来源:PB Arena(pb.tiaozhuxiansheng.com)第 2 次内测反馈的修复会话,1 个提交(
3dcbfbf,7 文件 +936/−101)+ 1 篇文档,已部署上线并封版v2026.07.25.2状态:已上线并线上验证;五条 insight 均为本次首次发现 ⚠️,尚未跨项目二次验证 前情:[赛事状态机到点不切换_写入方不等于推进方_v1]——本篇部分修正了那一篇的结论
事实记录(不可修改区)
- 项目:PB Arena 官方赛事(报名 → 出图 → 上传 → 投票 → 结算)
- 反馈来源:作者转达的两条,原文——「1. 比赛创建后不应该直接开始计时,而是在管理员发布每一个题目后才开始。2. 目前没有每一轮提交图片的功能按钮,即用户无法做到提交图片参与比赛,所以更无法验证投票系统是否正常。」
- 上一会话刚交付的东西:赛事状态机(到点自动开赛、三阶段按赛制时长自动切换、自动完赛)。那次验收标准是「不碰任何按钮,等它自己变」,并且线上真的自证通过了
- 线上部署前的客观查证(2026-07-25 23:52,不加解释):
official_events7 场全部finished,official_event_rounds25 条,official_event_registrations17 条报名,users20 人;而battles、submissions、votes三张表都是 0 行 - 也就是说:办过 7 场比赛、17 人次报名,平台从来没有产生过一件参赛作品
- 本次实际结果:符合预期。出题即开赛、每轮生成对战、提交、投票、结算、历史入库全部跑通
- 关键数据来源:本地便携 Node 跑
node --test(9/9,新增 1 组覆盖完整赛事对战链路);本地起服务用 4 个账号(rootadmin / alice / bob / carol)走完整链路;老库迁移单独演练;线上部署后读 sqlite 与/api/official-events核对 - 数据库备份:
/var/backups/pb-arena/pre-round-battles-20260725-235238.sqlite(integrity_check= ok)
一句话总结
界面把流程演完了,不等于这条链路真的存在——验收要问「这个功能跑完,数据库里应该多出什么」,然后去查那张表;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 所有调用点,看有没有人在调用前动了判据依赖的字段。 更根本的修法是让这个函数成为该字段的唯一赋值入口——本次就是把三处调用方的提前赋值直接删掉。
顺手教训
- 验证前端修复的第一步,是证明浏览器跑的确实是新代码。 改完在浏览器里复验,现象一模一样没变,我一度开始怀疑逻辑——实际是内置浏览器缓存了旧
app.js。注意:fetch('/app.js')拿到的内容里有新代码不算数(那是重新请求的),要看已加载的函数体:refreshBattleState.toString()一眼就看出跑的是旧版。这和上一篇的「静态资源版本号要跟着改」是同一族的两面——那篇是没 bust 到用户,这篇是没 bust 到自己。 - 复用既有表,要把它身上所有 CHECK / UNIQUE / 默认值按新用途重算一遍。
battle_participants.seat CHECK (seat BETWEEN 1 AND 16)是给房间对战写的(房间上限 16 人),官方赛事容量默认 32、可以更大,复用就撞上限。约束是给当时的用途写的,换用途必须重新过。改完还要单独演练老库迁移(带旧约束的表重建 → 约束放宽 → 原记录保留 → 新上限可写),不能只测新建库。 - 上一篇的教训这次生效了:开工前先
git status -sb,收尾前再看一次 ahead/behind。上次是本地领先远程 4 个提交到封版才发现,这次全程只有 1 个待推提交,推完立刻核对。 - 状态机语义变更也要先查存量再部署。 这次运气好——线上 7 场赛事全部已结束,新语义不会静默改写任何进行中的数据;但这个结论是查出来的,不是假设的。
下次改进
- 补自动化前先把状态转移读成人话,确认「所以」的前面不需要有人先准备什么;是人触发的就别让时钟代劳。
- 验收一个功能时,先写下「这功能跑完数据库该多出什么」,再去查那张表;界面走到哪一步不作数。
- 修完一个依赖「进入时状态」的防护逻辑,顺手 grep 所有调用点,确认没人在调用前把判据抹平。
- 前端复验前先
fn.toString()确认加载的是新代码,再开始怀疑逻辑。
关联文档
- ⚠️ 赛事状态机到点不切换_写入方不等于推进方_v1 —— 直接前情与部分修正:那篇把「没人推进状态」修成了自动推进并通过验收,本篇发现该转移本就不该由时钟触发;两篇合起来才完整——先问「谁在到点改它」,再问「到点该不该发生这件事」
- 内测反馈渠道上线_匿名兜底与422探针验证_v1 —— 同项目更早一环:反馈渠道是这两轮问题的来源入口
- ⭐ 交付前实测证伪律_v1 —— 再验(2026-07-25)变体:界面完整度是最会骗人的「应该能行」——它不是待验证的假设,是已经长得像成功的幻觉;对应探针是「这功能跑完该多出什么数据」+ 直接查表
- ⚠️ 云端定时内容生产连环坑复盘_全绿不等于已发_v1 —— 同族更后一段:那篇是流程跑完但产物没发出去,本篇是流程演完但产物压根没有过
- 站内新版块五件套模式_三次复用与起刊两坑_v1 —— 状态坑家族第三形态:那篇是 enum 扩容时取反式过滤静默吞掉新状态,前篇是没有推进方,本篇是推进方对了但触发源选错
- ⚠️ 全站文字截断体检_检测工具本身要先被证伪_v1 —— 同项目次日续篇:本篇是「别用界面验收功能,要用数据」,那篇是**「用来验收的工具也要先被验收」**——自制体检判据把 8 个正常元素判成缺陷
- ⚠️ 美工改稿全站落地_跨稿重复才是规范_v1 —— 同项目后续会话:本篇是「界面演完不等于链路存在」,那篇 insight 3 是「本地演示全绿不等于生产拿得到」——派生字段只加在演示数据路径上,而后端分支故意绕开那个函数;同一句话的两种形态
- ⭐⭐ 成稿链接存进库没人读_写入方不等于展示方_v1 —— 本篇的镜像(蛛网之上,2026-07-30):那次是界面全在而表 0 行,这次是表里有值而页面不问它——成稿链接三条写入路径、零条读路径;两篇合起来才是完整的验收问句:既问「这功能跑完数据库该多出什么」,也问「库里已经有的,页面读了吗」
- 复盘事实先行原则 —— 本篇顶部事实冻结区按它执行
- 09_平台工程索引 —— 平台工程区入口