~ / tech / projects / guotiao2026
guotiao2026/index.mdx
cat index.mdx
📱

观澜 · 第十届上海高校国学挑战赛

给国学社做的线上初赛系统 + 决赛交互式 slides engine + 实时计分。初赛开赛第一分钟修了个 race condition,决赛从 spec 到上线 4 天。

status: ● Active
date: 2026-05
category: app
React Astro FastAPI Interactive Slides Live Scoring AI-assisted Dev
README.md

这个项目是什么

第十届上海高校国学挑战赛的全套技术系统,分两个阶段:

初赛(5月10日):一个线上答题系统。FastAPI + SQLite 后端,Astro + React 前端。邮箱验证码登录,20 道限时单选题,自动评分。37 人注册,35 人提交。

决赛(5月23日):一个 browser-based 的交互式演示系统。八所高校、约四十位选手、四个环节、五十多个 slides、一套实时计分系统——全部跑在一个 React 单页应用里,投到报告厅的大屏幕上。

它不只是一个 PPT 替代品。它是计分板、计时器、答题判定器、命题博弈结算引擎、对联录入系统、「谁是卧底」状态机的集合体。比赛全程由主持人用键盘操控,左右箭头翻页,S 键打开计分面板,L 键打开音乐控制台。

为什么做这个

2023 年的国挑用的是 PowerPoint。PPT 的问题不是做不了,而是做好太难——排版依赖个人审美,多媒体控件嵌入复杂,交互逻辑只能靠动画堆叠。那年比赛来不及彩排,现场交互出了很多岔子。

另一个痛点是计分。往届由人工后台算分,人手不够不说,黑盒计算还引发过争议。选手看不到实时分数,观众也没有代入感。

今年我接手技术这块,有两个核心诉求:一是所有 UI/UX 必须不言自明——主持人不需要培训就能操作;二是计分全程前台透明——每一分的变动都实时显示、可追溯、可撤回。

做到这两点,PPT 就不够用了。

我做了什么

初赛:开赛第一分钟的 race condition

初赛系统是 FastAPI + SQLite + React,部署在 Vercel Serverless。功能很标准:邮箱验证码登录、20 道限时单选题、自动评分、成绩查看。

5 月 10 日 16:30 开赛。16:30:39,后台开始报 500——69 次,集中在开赛后的前 50 秒。

根因是验证码发送的限流逻辑有一个经典的 TOCTOU 竞态条件:开赛瞬间大量用户同时请求验证码,Vercel 边缘节点对同一请求有重试机制,导致同一邮箱在毫秒级内收到多个并发写入。限流查询用了 scalar_one_or_none(),期望 0 或 1 条记录,但并发写入后查到了 2 条,直接炸了。

修复只改了一行:.limit(1).scalar()——只关心「是否存在」,不关心具体几条。

50 秒后高峰过去,系统自行恢复。所有受影响的用户刷新重试后均成功参赛,无人因此无法答题。但这 50 秒让我出了一身冷汗。事后写了一份完整的 postmortem,核心教训是:压测要覆盖完整用户流程,而且要在 Production 环境跑,因为 Preview 的边缘节点行为不一样。

决赛:四天从 spec 到上线

5 月 19 日拿到赛制 spec,5 月 23 日下午比赛。中间四天,一个人,全程用 Claude 实现。

我的角色是 PM + 质检:上游(本届总负责人)给我赛制文档,我把它翻译成 Claude 能理解的需求描述,Claude 写代码,我验收视觉效果和交互逻辑。遇到设计决策——比如计分面板放左边还是右边、排序题用什么颜色系统——我做判断,Claude 执行。

技术栈是 Astro + React + Tailwind,纯前端,部署在 Vercel。决赛系统刻意没有后端——这是一个安全考虑:以主持人面前那台电脑的浏览器实例为唯一可信实例。所有状态都在内存里,不经过网络,不存在被篡改或被观众端干扰的可能。刷新页面会清零,但对于一场三小时的线下比赛来说,这反而是优点:没有需要清理的历史数据,每次打开都是干净的。

交叉命题与伪装分机制

第一环节的赛制很有意思:八支队伍各出一套题,但比赛时不公开谁出了哪套题。每支队伍答自己出的题时,不按常规的「答对+3分」算,而是按一个正答率公式算命题得分——出题太简单(人人都对)或太难(没人答对)都拿不到高分,正答率在 70% 左右时收益最大。

赛制 spec 里说「命题方作答自己的题目不计分」,同时又要求「不透露真实命题方」。如果是后台计分,这没问题——后台默默把命题方的分数扣掉就行。但我把计分搬到了前台,大屏幕上所有人都看着,如果某支队伍答对了题却没有加分,等于直接暴露了谁是出题方。

所以我设计了伪装分机制:命题方答题时也正常加 3 分(和其他队伍一样),但这个分数只是「伪装」,在第一环节结束后的命题结算页面统一清算——扣掉伪装分,换成真实的命题公式得分。这样比赛过程中谁也看不出谁是出题方,直到公布环节才揭晓。

比赛当天的 hotfix

比赛当天上午还在改标点符号和题目措辞。下午比赛开始后,暖场音乐和颁奖音乐是赛前一小时才加上的——网易云外链播放器,塞进左侧控制面板,勉强能用。

最刺激的是谁是卧底环节。第二轮比赛中,一位选手在即将被投票出局的开票瞬间按下了自爆——但赛制 spec 里没有 cover「投票结果即将揭晓时是否还能自爆」这个 edge case。现场产生了大量争议。

最终是我拍板裁定的。系统的撤回功能在那一刻派上了用场——先撤回,重新结算,把分数给到了那位选手。

「观澜」的视觉统一

「观澜」是宣传组同学起的名字。其实整个视觉形象就两个人做的:宣传组的同学设计了微信推送和帆布袋,我从中提取设计元素——朱砂红配色、宋体/明朝体排版、纸质纹理背景——奠定了整个网站的 UI 基调。

这是国挑十年来第一届有统一视觉形象的:从公众号推送到决赛大屏到现场帆布袋,用的是同一套设计语言。往届没有主题,没有配色,PPT 随便做做就上了。

过程中的思考

这个项目让我重新理解了「线下活动的技术支持」这件事。

写代码只是其中一部分。比赛当天我的角色是:技术支持 + 现场控场 + 规则裁判。谁是卧底环节的流程耗时比预期长得多(每轮发言 30 秒 × O(N²) 的投票流程),一些队伍非常想参与,另一些急于结束比赛,现场有点紧张。系统兜住了计分和题目呈现——这两块没出任何问题——但规则的灰色地带只能靠人来裁。

我跟朋友开玩笑说,这个 oncall 的 forward deployment engineering 项目至少值两万块钱。但说实话,看到帆布袋上的「观澜」logo,看到八个学校的同学在大屏幕前争分夺秒,看到对联环节有人写出「无空无色尽尘寰」这样的句子——这些瞬间让你觉得,技术在这里做的事情是有温度的。

一些数字

  • 初赛:5 月 10 日线上,35 人参加,20 道单选题
  • 决赛:5 月 23 日线下,8 所高校,~40 位选手
  • 开发周期:4 天(5/19 → 5/23)
  • 比赛当天 Vercel 部署次数:6 次
  • 计分日志总条目:300+
  • 彩排用时:不到两小时一遍过,零阻塞问题

Q&A

Q: 全程 Claude 写代码——你怎么保证质量?

我的做法是:每一轮需求,我先用自然语言描述清楚我要什么(包括交互细节和边界情况),Claude 实现完之后我在浏览器里完整走一遍流程。视觉上不对的、交互逻辑有问题的,直接用一句话描述修改,再走一遍。

Claude 做不了的事情主要是两类:一是视觉判断(「这个间距看着不舒服」「颜色太跳了」),二是赛制理解(比如伪装分机制,我得自己想清楚逻辑再告诉它)。但纯粹的代码实现——组件拆分、状态管理、CSS 布局、动画——Claude 的效率远超我自己写。

四天做完这个体量的东西,没有 AI 是不可能的。

Q: 为什么不用现有的演示工具,比如 Reveal.js 或 Slidev?

Reveal.js 和 Slidev 本质上还是线性的幻灯片工具,但国挑的流程是非线性的——有一个中心 hub 页面(环节目录),可以跳转到任意环节,每个环节内部又有自己的导航逻辑(比如选题页→答题→答案→回到选题页的循环)。更关键的是,我需要在 slides 上嵌入完整的计分系统、计时器、答题判定、对联录入等交互组件。这些东西在任何现有的 slides 框架里都会变成 hack。

从零搭一个 React 应用反而更干净——slides 只是 React 组件,导航是状态机,计分是 Context,想加什么交互直接写就是了。

Q: 比赛当天最紧张的一刻是什么?

谁是卧底第二轮的自爆争议。那位同学在投票结果即将公布的瞬间喊了自爆,而赛制里没有规定这个时间点能不能自爆。如果允许,其获得 +35 分(5×7)直接改变排名格局;如果不允许,其就是被投票出局,扣 5 分。

现场四十个人看着大屏幕等我的裁定。我最终给了自爆分数,理由是赛制没有明确禁止。但显然有队伍对此不满。事后复盘,我觉得应该在赛制 spec 阶段就把这类 edge case 定义清楚——但说实话,有些情况你不真正跑一遍比赛是想不到的。

Q: 如果给你更多时间,你会多做什么?

三件事。第一是暖场音乐——目前只塞了一首歌,我本来想做一个完整的歌单播放器,但赛前一小时才加上,来不及。第二是题目的多模态呈现——目前只有下半场第十题的鸟类匹配做了三栏图文交互(诗句:鸟的图片:描述),如果有余裕参与命题的话,我会出更多这种交互式题目。第三是题目解析——答案页现在只显示正确选项和正确率,没有详细解析,这块最终没做完。

不过反过来想,四天能做到现在这个程度,核心功能一个没缺,彩排两小时零阻塞——我觉得 scope 控制得还行。

Q: 这个项目有没有什么「副产品」?

有。第一个是完整的计分日志——300 多条记录,精确到每一题每一队的分数变动,包括撤回和重算。这在往届是不可能的,以前靠人工算分连最终成绩都可能有争议,现在每一分都可追溯。

第二个是「观澜」这个统一的视觉品牌。国挑办了十年,这是第一次从推送到网站到现场物料用同一套设计语言。这个东西的价值可能比系统本身更持久——明年换一届人,代码可以不用,但「国挑应该有自己的视觉形象」这个意识留下来了。

Q: 初赛那个 race condition,你是怎么发现的?

开赛后大概 40 秒,微信群里有选手说「收不到验证码」。我打开 Vercel 的日志面板,看到 500 错误在以每秒十几个的速度往上跳。定位到出错那行代码大概花了两分钟——scalar_one_or_none() 这个函数名本身就在告诉我问题出在哪里。改成 .limit(1).scalar() 推上去,重新部署,前后不到五分钟。

但那五分钟里我的心理状态其实很差——三十多个人在等着考试,群里不断有人说「验证码发不出来」,你知道问题在哪但 Vercel 的冷启动需要时间。好在 50 秒后并发高峰自然过去,系统自己就恢复了。最终没有人因此无法参赛,但那个体验让我后来在决赛系统设计时做了一个关键决策:决赛不用后端。以前台实例为唯一可信源,不经过网络,消灭一整类问题。

cd .. · ← back to tech
~ — press / to open terminal