我为 2026 年世界杯制作了一款足球预测游戏。历时一个月,104 场比赛,
最终决出一位胜者。产品正常运转,人们踊跃参与,没有出现任何问题。

随后我把同一套应用指向了国内联赛:法甲联赛,306 场比赛,34 个比赛日,
持续九个月。相同的数据模型、相同的计分规则、相同的模板。没有崩溃,没有抛异常,所有测试依然绿灯。

然而,应用内部几乎所有产品决策都突然失效了。

下面是其中四个关键问题:它们都不是技术问题,也不会在测试套件中暴露。

1. 累计排行榜在十月已成定局

在锦标赛中,总排名就是整个比赛。你玩四周,最终的积分榜就是故事。

在 34 个比赛日里,积分榜大约在第八周就不再是游戏。领先者已领先 60 分,十一月才加入的玩家在数学上已被淘汰,其他人则面对一个无法改变的计分板。产品依然能用,只是已无悬念。

解决方案不是更精良的算法,而是引入第二个时间单位:每比赛日排行榜。这样每个周末都有自己的赢家,另外再设赛季荣誉榜,统计每位玩家赢得的比赛日数量。积分和计分规则不变,只是切分方式不同。总排名第 14 的玩家仍有可能赢得本周末,这才是让他周五回来的动力。

值得一提的是我没有做的事:没有重置积分、没有让步、没有追分奖励。任何对当前进行中的计分系统的追溯性修改都会摧毁玩家对排名的信任,而排名就是全部资产。

2. 滑动 24 小时提醒会变成 100 封邮件

提醒任务原本是为锦标赛设计的:「若玩家有未预测且将在 24 小时内开赛的比赛,则发送邮件。」比赛每天零星开赛,因此每天一封邮件即可。

法甲一个比赛日从周五 20:45 延续至周日 20:45。同一任务若不改动,每个周末就会给同一玩家发送三封邮件:周五一封(一场比赛)、周六一封(三场比赛)、周日一封(五场比赛)。一个赛季每人将收到约 100 封邮件。那不是提醒,而是带步骤的垃圾邮件投诉。

邮件还会在首场比赛当天的早上送达,而联赛的自然做法恰恰相反:玩家一次性预测全部九场比赛,时间随意。

因此该任务现在在同一命令中包含两条互斥代码路径。锦标赛仍使用滑动窗口;联赛则改为每比赛日一封邮件,在该比赛日首场开赛前 24 至 48 小时触发,并列出本比赛日所有仍未预测的比赛。

我喜欢的一点:无需 reminder_sent 表。幂等性由时间窗口本身保证。「首场开赛时间在 24 至 48 小时之内」这一条件在日历上仅成立一天,而 cron 每天运行一次。唯一可能产生第二封邮件的情况是比赛延期导致首场开赛时间重新落入窗口,而日程变动正是需要再次提醒的时刻。

权衡写在 crontab 的注释里:若把 cron 频率翻倍,邮件数量也会翻倍。一条注释比一张表更省事,只要注释写在容易出错的地方即可。

3. 「即将开赛的比赛」≠「所有未来比赛」

仪表盘原本列出全部未来比赛。锦标赛最多只有几十张卡片,进度徽章显示「12/18 已预测」,感觉可以完成。

在联赛中则是 306 张卡片和「12/306」,感觉像作业。

现在列表仅限于下两个比赛日。微妙之处在于如何选取:我最初的版本取未进行比赛的 MIN(round_number)。这在任何真实联赛中都是错的,因为延期是常态:一场第三比赛日的比赛若延至十一月重赛,就会把仪表盘锁定在第三、四比赛日,并隐藏真正的周末。窗口现在遵循最近的开赛时间,而非轮次编号;延期比赛会在新时段到来时自动重新出现。

4. 锦标赛中总有事可做

世界杯的四周是持续关注的四周。九个月则不是。联赛赛季的大部分时间里,互动产品都在与用户遗忘作斗争。

我设计的两个时刻正是应用原本知道却丢弃的:刚刚赢得一个比赛日的玩家(每周一人,骄傲峰值)和刚刚完成对下一比赛日的预测的玩家(每人每赛季 34 次,投入峰值后连续数天空窗)。这两个时刻现在都提供分享功能:移动端原生分享面板,附带 WhatsApp 后备方案,并链接到公开竞赛页面,而非私人联赛。

最后一个细节值得花一分钟思考:通过移动分享面板分享联赛邀请码可能出现在公开帖子中,而包含陌生人的私人积分榜并非功能。

真正的教训

数据模型是正确的。EventGamePrediction,以及早已存在的 round_number 列。以上改动无需任何迁移。

错误的是关于节奏的所有假设:用户多久出现一次、一次竞赛单元持续多久、他们能提前看到多远、两次兴趣点之间要等待多久。这些假设几乎从不体现在 schema 中,而是散布在 cron 表达式、查询限制、邮件条件和空状态文案里——这正是大家在说「我们只需要支持新的竞赛形式」时不会去查看的地方。

如果你正准备把一个正常运转的产品复用到更长或更短的时间尺度上,请在代码库中搜索时间相关的关键词:每一个 24 hours、每一个 setMaxResults、每一个 "next"、"current" 和 "upcoming"。那才是你真正的差异。

该应用是一款供朋友和同事免费使用的预测游戏,不涉及投注或金钱,使用 Symfony 构建,通过 Mercure 实现 Turbo Streams 实时积分榜,并支持 16 种语言。网址:pronoarena.com,法甲赛季将于 8 月 21 日开幕。