ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

知识竞赛系统开发实战:从题型建模到WebSocket实时同步

知识竞赛系统开发实战:从题型建模到WebSocket实时同步 简介知识竞赛系统是一套基于C/MFC开发的可运行在线答题平台面向高校学生、竞赛组织者及需要快速搭建答题场景的开发者覆盖试题维护、参赛者管理、限时答题和自动排名等核心需求。压缩包共61个文件约7.68MB文件类型以cpp/h源码为主另有exe可执行程序、mdf/ldf数据库文件、ico/bmp图标资源和rc资源脚本既能通过源码学习MFC界面设计与交互逻辑也可直接运行查看系统全貌。项目内置试题分类添加、删除与修改支持基于答题正确数量和限时速度的多种排名维度计时器、自动评分与答题进度记录均已实现数据库脚本和关键源码一一对应还可学习MFC界面、数据库及榜单排名的常见实现直接部署替换题库即可用于正式比赛或课堂测试。已有391人学习尤其适合毕业设计、课程实践或希望快速落地答题系统的开发者。1. 知识竞赛系统到底在解决什么问题从一张计分表到一场完整的现场竞赛很多人在没办过现场抢答赛之前会觉得一套知识竞赛系统不过是个“在线答题小程序”。真正上场后才发现最大的难点不是题库容量而是整场比赛的秩序感谁能抢答、倒计时还剩多少、裁判改判后比分怎么恢复、大屏和计分台是不是同一份数据。知识竞赛系统要做的就是把题型、赛制、计时、计分和仲裁串成一条有状态的流水线让主持人只专心问下一题裁判只判断答案对错选手不用反复追问“我刚才是不是抢到了”。这篇文章面向想自己搭建这套系统的开发者和组织者用一套前后端分离的常见方案从题型建模讲到状态机从抢答并发讲到断线重连最后落在一份可复查的事件流存档上。2. 拆解知识竞赛系统的核心模型题型、赛制与状态机2.1 题型与计分规则必答题、抢答题、风险题怎么建模现场赛制看起来五花八门但拆开之后核心要素就三样题型、分值、答错策略。必答题通常按队伍顺序轮流作答答对加分答错不扣分或者扣分抢答题则是“先抢到先答”为了保证竞争性答错基本都会扣掉同等分值风险题更特殊同一道题可以在 10、20、30 分之间选择答对加所选分值答错扣所选分值。这三类题型对计分模块的要求完全不同如果设计阶段就把分值写死在题目字段里后面每换一次赛制就要改一遍代码。我一般会先定义一个题型枚举和一个计分规则对象让所有题目都按规则驱动。这里用 Python 展示最核心的结构from enum import Enum from dataclasses import dataclass class QuestionType(str, Enum): REQUIRED required # 必答题按队伍顺序轮流作答 BUZZER buzzer # 抢答题先抢到先答 RISK risk # 风险题自选分值 dataclass class ScoringRule: question_type: QuestionType base_score: int # 必答/抢答的固定分值 correct_change: int # 答对后的分值变化 wrong_change: int # 答错后的分值变化负数表示扣分 allow_pass: bool # 是否允许弃权不扣分逻辑说明把“题型”和“计分规则”拆成两个概念是为了让程序不关心具体赛制只关心题型。风险题的 base_score 在这里只是默认值真正分值会由选手在开题时选择然后覆盖 correct_change 和 wrong_change 的绝对值。allow_pass 这个字段很实用很多必答题允许队伍说“过”一旦把它设成 False抢答后不许弃权否则按答错扣分。参数说明correct_change 和 wrong_change 虽然大多数时候正好是正负 base_score但留下一对独立字段后就能支持“答对加 20、答错只扣 5”这类偏向鼓励参与的赛制。落库时我会把规则展开成一张 match_rules 表字段包括 id、match_id、question_type、base_score、correct_change、wrong_change、allow_pass。开题时服务端根据题型读取规则再把实际分值合并进下发到前端的消息里。这样现场临时改赛制后台配置改完就能生效不用发布新代码。2.2 赛制流程的状态机从候场到颁奖系统该记住哪些状态现场执行时最怕的不是题目难而是“不知道该做什么”。主持人按下开题键大屏要进读题读完题要进抢答有人按钮后要进作答答完要计分计完分还要问“下一题吗”。这一连串动作如果全靠人记一定会乱。我把整场流程抽象成一个状态机后端所有接口只允许合法流转发生。状态转移关系先写在代码里例如STATUS_FLOW { CREATED: [ACTIVE], ACTIVE: [QUESTION_OPEN], # 比赛开始进入开题 QUESTION_OPEN: [BUZZER_OPEN, ANSWERING], # 抢答题打开抢答窗口必答题直接转作答 BUZZER_OPEN: [ANSWERING, QUESTION_OPEN], # 有人抢到或超时无人抢 ANSWERING: [SCORING], # 作答结束进入计分 SCORING: [QUESTION_OPEN, FINISHED], # 下一题或结束比赛 } def can_transition(current: str, target: str) - bool: return target in STATUS_FLOW.get(current, []) assert can_transition(BUZZER_OPEN, ANSWERING)逻辑说明这个函数看起来简单却是整套系统的骨架。后台控制台每一个按钮都要先调用 can_transition 校验再真正修改状态。比如在 BUZZER_OPEN 阶段如果有人抢到调用方只能往 ANSWERING 走如果倒计时结束还没人抢则回到 QUESTION_OPEN重新读题或直接跳过。前端大屏按钮是否可点也由当前状态决定前后端共用同一套逻辑能挡住大部分误操作。状态本身要存在哪里我一般把 Redis 作为热状态存储用match:{match_id}:status、match:{match_id}:current_question_no、match:{match_id}:current_team_id三个键记录当前比赛位置。不用数据库的原因是大屏端会频繁查询状态每个抢答、倒计时都要读数据库扛不住这种频率。但 Redis 必须能恢复每次开赛前把 match_sessions 表里的 status 重置成 CREATED服务器重启后从表里读回状态再根据 answer_records 里最后一条已经 SCORED 的记录回填 current_question_no 和 current_team_id。恢复时回到“上一题已评分、下一题未开”的位置主持人继续往下走就行。参数说明状态值建议用英文大写不要用中文。日志、WebSocket 消息、埋点事件全用同一套枚举排查问题时 grep 一下就能对齐。2.3 选型理由为什么是 WebSocket 而不是 HTTP 轮询知识竞赛系统的实时性要求和普通后台不一样。倒计时要每秒更新抢答要在几十毫秒内广播比分变化要同时打到所有屏幕上。如果大屏用 HTTP 轮询每 1 秒请求一次遇到现场 Wi-Fi 抖动就会出现倒计时卡顿抢答更矛盾轮询间隔越短服务器压力越大间隔太长又抢不准。所以比赛信令必须走 WebSocket长连接建立后服务端主动推送客户端不需要反复发起请求。对比项HTTP 轮询WebSocket倒计时精度受请求间隔限制误差 1 秒以上服务端按状态推送误差可控在毫秒级服务端压力无变化也要周期请求有事件才推送空闲无流量抢答反馈至少一个请求往返一次广播覆盖所有端实现复杂度低需要处理连接管理和断线重连我选择 FastAPI WebSocket 做信令层原因是 Python 的 async 模型适合大量长连接代码也容易读。下面是最小可运行的 WebSocket 广播服务from fastapi import FastAPI, WebSocket, WebSocketDisconnect app FastAPI() class ConnectionManager: def __init__(self): self.connections: list[WebSocket] [] async def connect(self, ws: WebSocket): await ws.accept() self.connections.append(ws) async def broadcast(self, message: dict): for ws in self.connections[:]: try: await ws.send_json(message) except Exception: self.connections.remove(ws) manager ConnectionManager() app.websocket(/ws/match) async def match_socket(ws: WebSocket): await manager.connect(ws) try: while True: data await ws.receive_json() # 先收消息统一交给业务处理器避免客户端直接改状态 await manager.broadcast({event: ack, payload: data}) except WebSocketDisconnect: manager.connections.remove(ws)逻辑说明这是一个广播中枢所有连进来的大屏、主持人平板、裁判端都会收到同一份消息。receive_json 拿到消息后没有直接执行业务动作而是把消息交给后端的 match service 统一处理处理完再由 manager.broadcast 把结果推送出去。这样权限校验能集中起来客户端伪造指令也无法直接改状态。参数说明实际项目中 WebSocket 路径要带上 match_id比如/ws/match/23这样一台服务器才能同时跑多场竞赛。ConnectionManager 里也应按 match_id 分组把广播范围限制在同一场比赛内。如果用的是 Spring Boot对应的就是 WebSocketHandler 加会话管理用 Node.js 则用 ws 库。选型不必纠结框架关键是消息协议和连接管理方式。3. 用 FastAPI PostgreSQL 落地知识竞赛系统后端答题与计分的核心实现3.1 数据库表设计题库、场次、答题记录最少要这几张表理解完状态机就可以建表。先讲一个常见误区把全量题库当成某一场比赛的题目表结果比赛过程中题库被其他人编辑现场题目跟着变。正确做法是“场次隔离”题目要么复制进场次要么通过关联表引用但引用时必须锁定版本。下面是最小可用的一套表结构。CREATE TABLE teams ( id SERIAL PRIMARY KEY, match_id INT NOT NULL REFERENCES match_sessions(id), name VARCHAR(64) NOT NULL, sort_no INT NOT NULL ); CREATE TABLE questions ( id SERIAL PRIMARY KEY, match_id INT NOT NULL REFERENCES match_sessions(id), qtype VARCHAR(16) NOT NULL, -- required / buzzer / risk content TEXT NOT NULL, options JSONB, -- 选择题选项可空 correct_answer TEXT, risk_scores INT[] DEFAULT {} -- 风险题可选分值如 {10,20,30} ); CREATE TABLE match_sessions ( id SERIAL PRIMARY KEY, title VARCHAR(128) NOT NULL, status VARCHAR(16) NOT NULL DEFAULT CREATED, current_question_no INT NOT NULL DEFAULT 0, started_at TIMESTAMPTZ ); CREATE TABLE answer_records ( id SERIAL PRIMARY KEY, match_id INT NOT NULL, question_id INT NOT NULL, team_id INT, score_change INT NOT NULL DEFAULT 0, status VARCHAR(16) NOT NULL, -- SCORED / REJECTED / APPEALED reason TEXT, created_at TIMESTAMPTZ NOT NULL DEFAULT now() );逻辑说明teams 表挂了 match_id一场比赛一组队伍questions 表同样挂 match_id保证每场比赛的题目是独立副本。match_sessions 表里的 status 和 current_question_no 会在 Redis 里缓存热数据这里保留一份用于赛后归档和异常恢复。answer_records 是计分流水score_change 允许负数status 区分正常计分、无效抢答和申诉记录。参数说明risk_scores 字段用 PostgreSQL 数组保存比如{10,20,30}风险题一般不会超过 10 档用数组最简单。如果题目是选择题options 用 JSONB 存选项数组判断时再用 correct_answer 去比对。如果答案要支持图片建议不要存二进制把图片放到对象存储字段里存 URL。如果要支持多次复用同一题库我建议再加一个 question_bank 表和一个 match_questions 关联表比赛创建时批量把 question_bank 里的题目复制到场次。复制时保留 source_question_id这样现场临时加题能快速定位原题又不影响已经开始的比赛。千万别让引擎在运行时去实时读题库赛题在开赛那一刻就应该被“冻结”在比赛场次里。3.2 抢答模块的并发控制Redis SETNX 与数据库兜底抢答是知识竞赛系统里最不能出错的模块。两个选手同时按下按钮网络请求到达后端的先后可能有细微差别但最后只能有一个队伍抢到。初学者的做法是“先查询 current_team_id 是否为空再更新”这个操作在并发下必然翻车两个请求都查到空然后都执行更新结果后到的覆盖先到的。常见做法是用 Redis 的原子写入实现“只有第一次写才成功”。import redis r redis.Redis(hostlocalhost, port6379, decode_responsesTrue) def try_buzz(match_id: int, team_id: int) - bool: key fmatch:{match_id}:buzzer # SET NX 保证 key 不存在时才能写入EX 5 是兜底防止主持人没及时处理锁死 ok r.set(key, team_id, nxTrue, ex5) return bool(ok)逻辑说明第一个请求执行 SET 时 key 不存在Redis 写入成功并返回 True第二个请求执行时 key 已经存在哪怕请求只晚 1 毫秒也会返回 False。这就是“先到先得”的可靠实现。EX 5 设置了过期时间防止抢答成功后主持人没有在 5 秒内切换到作答阶段导致下一道题无法抢答。具体秒数可以调整常见是 3 到 5 秒。如果没有 Redis也可以用数据库行锁做兜底UPDATE match_sessions SET current_buzzer_team_id 1 WHERE id 123 AND current_buzzer_team_id IS NULL RETURNING id;说明这条语句利用 PostgreSQL 的行锁只有一个事务能把 current_buzzer_team_id 从 NULL 变成非 NULL。但它的缺点是所有未抢到的请求都要等锁判断可能产生阻塞。所以生产环境建议 Redis 做入口数据库只保存最终结果。抢答成功后系统把 team_id 写进 answer_records并把状态从 BUZZER_OPEN 推成 ANSWERING。另外要强调抢答锁不代表答题有效。现场经常有选手抢早、误触所以 try_buzz 只是抢答资格裁判随后还要在控制台点“有效”或“无效”。如果无效要做两步清 Redis 锁并把这条抢答事件标记为 REJECTED。如果直接清锁可能导致同一道题下两个队都看到自己“抢到了”大屏很混乱。正确做法是保留“已抢到但无效”的状态下一道题再重新开锁。注意锁的过期时间不要设置太长现场一旦出现“选手抢到了但主持人没看见”的争议最有效的处理方式是立刻由裁判手动复位而不是等锁自动过期。3.3 计分服务把裁判动作变成可追溯的事件流计分模块最容易做的就是“把某个队伍总分加 10 分”看似简单实际后患无穷。比如裁判发现加错了要把总分减回去直接改总分字段那这个分数是怎么来的就说不清了。正确的做法是所有比分变化都追加一条事件记录总分由事件流聚合出来。下面是一个最小的计分服务实现。def score_answer(match_id: int, question_id: int, team_id: int, score_change: int, reason: str) - int: # 1. 写入计分流水 insert_answer_record(match_id, question_id, team_id, score_change, SCORED, reason) # 2. 聚合该队当前总分 total get_team_score(match_id, team_id) # 3. 更新展示用的分数缓存 update_team_score_cache(match_id, team_id, total) return total def get_team_score(match_id: int, team_id: int) - int: return db.query( SELECT COALESCE(SUM(score_change), 0) FROM answer_records WHERE match_id %s AND team_id %s AND status SCORED, (match_id, team_id) )逻辑说明score_answer 第一步写流水第二步聚合第三步更新缓存。这样改判时不需要修改任何历史记录只需插入一条 score_change 为负数的记录比如之前加 10 分现在要改成不加分就插入 score_change -10reason 写“改判重复计分”。总分自动回到正确值现场观众也能从事件流里看到每一次变化。参数说明status 字段要区分 SCORED 和 REJECTED。裁判判定抢答无效时不改变分数但事件流要保持记录。REJECTED 记录不会进入 SUM 聚合但也会被写入流水方便复盘“为什么这道题没有计分”。reason 字段不允许为空裁判填中文不要紧关键是强制填写避免事后无法追溯。为什么不用 Redis 直接给队伍加分因为 Redis 缓存可能丢失、可能过期一旦与数据库不一致现场的分歧无法裁决。Redis 只作为热数据展示最终分数永远以数据库聚合结果为准。当大屏比分和裁判手里的纸面记录不一致时以 answer_records 为准这是底线。4. 大屏端与选手端的实时同步WebSocket 消息格式与断线重连4.1 消息协议设计这 5 类消息就够覆盖整场竞赛前后端通过 WebSocket 传递消息时最怕的是没有格式约定前端拼错一个字段后端就解析不到。我一般只定义 5 类核心消息复杂场景尽量在 payload 里扩展避免每个功能都发明一种消息结构。event触发方作用match_status后端推送通知所有端当前赛制阶段比如进入抢答窗口question_open后端推送下发当前题目内容大屏展示题干buzzer_result后端推送广播哪个队抢到附带服务端毫秒时间戳score_update后端推送广播某个队伍总分变化timer_sync后端定时推送校正大屏本地倒计时下面是一条 buzzer_result 的完整消息{ event: buzzer_result, payload: { match_id: 23, question_no: 4, team_id: 3, server_ts: 1720000000123, elapsed_ms: 287 } }逻辑说明server_ts 是后端收到抢答请求那一刻的 Unix 毫秒时间戳elapsed_ms 是选手端从看到题目到按下按钮的本地耗时仅作为现场展示参考不作为仲裁依据。前端大屏收到消息后把 team_id 的队伍显示在抢答第一位同时播放一声短蜂鸣现场才看得清晰。参数说明server_ts 必须由服务端生成不能信任客户端上报。因为手机端时间可能慢几十毫秒如果按客户端时间排序就会发生“先按的排到后面去”。question_no 用于前端确认当前题目编号防止消息乱序导致大题号对应错。match_status 和 timer_sync 可以合并但分开更好排错。现场主持人说“大屏没进抢答”你一看日志只有 question_open 没有 match_status基本就是后端状态机没切过去。4.2 大屏端渲染逻辑倒计时、抢答状态、得分动画大屏端不应该自己维护一套比赛状态它只负责“收到消息后更新界面”。把所有 event 放进一个 socket.onmessage 回调里按类型分发。下面用 Vue 3 的组合式写法示意import { reactive } from vue const state reactive({ phase: WAIT, // WAIT / READ_QUESTION / BUZZER / ANSWERING / SCORING currentQuestion: null, countdown: 0, teams: [], lastBuzzer: null }) socket.onmessage (e) { const msg JSON.parse(e.data) if (msg.event match_status) { state.phase msg.payload.phase } else if (msg.event question_open) { state.currentQuestion msg.payload state.phase READ_QUESTION } else if (msg.event buzzer_result) { state.lastBuzzer msg.payload state.phase ANSWERING } else if (msg.event score_update) { const team state.teams.find(t t.id msg.payload.team_id) if (team) team.score msg.payload.score } else if (msg.event timer_sync) { state.countdown msg.payload.seconds } }逻辑说明phase 是大屏 UI 的总开关。开题后大屏收到 question_open切到读题 phase后端广播 match_status 进入 BUZZER大屏才显示抢答按钮状态有人抢到buzzer_result 把它置为 ANSWERING此时秒表停止开始读选手的答案。所有状态都以服务端推送为准大屏本地不产生任何比赛状态。这里有一个必须注意的点倒计时不要只靠 timer_sync 一条条推送。因为网络延迟会导致倒计时看起来卡顿。常见做法是客户端本地每秒减 1但每 30 秒或每次进入新阶段时用服务端推送的 timer_sync 校准一次。如果本地剩余秒数和 timer_sync 偏差超过 2 秒直接以服务端为准。参数说明score_update 推送的是总分不是增量。为什么这样设计因为断线重连后大屏拉取快照需要的是绝对值增量会让重连后的页面出现重复累计。虽然推送总分消息稍大但几支队伍的 JSON 体量可以忽略换来的是状态的一致性。4.3 断线重连与对时避免“我说抢到了你没显示”的翻车现场网络不会永远稳定主办方的 Wi-Fi 通常是一屋子手机抢带宽。客户端一旦断线重连成功后必须第一时间和服务端对齐状态否则会出现主持人平板已经显示 A 队抢到大屏还停在抢答界面。我的做法是让客户端重连后先发一条 join 消息服务端把 snapshot 推给客户端。{ event: snapshot, payload: { match_id: 23, status: ANSWERING, current_question_no: 4, current_team_id: 2, countdown: 12, scores: {1: 120, 2: 80, 3: 95} } }逻辑说明snapshot 里的 status 和 current_team_id 能让刚连上的大屏立刻回到正确画面。如果客户端重连时抢答锁已经被 Redis 占用current_team_id 不为空大屏就不会出现“所有人都可以抢”的错误状态。countdown 是当前阶段的剩余秒数防止重连后从头倒计时。关于对时最干净的方案是所有客户端不做时钟仲裁只显示服务端给的时间戳。抢答按钮按下客户端上报一个 local_ts但服务端广播的是 server_ts。裁判判断“是否抢跑”时只看 server_ts 与开题 question_open 消息里的 ts 差值。如果差值小于设定阈值比如 500 毫秒视为抢跑由裁判人工裁定是否取消资格。这个阈值要在配置里可调不同主持人的节奏不一样。5. 知识竞赛系统现场执行中的常见问题排查这 5 个坑我基本每次都遇到5.1 抢答明明是先到的计分的却是后队时钟偏差现象比赛现场最常见的一幕A 队选手手速明显更快大屏显示的却是 B 队抢到现场观众立刻起哄。原因系统用客户端本机时间来判断先后就会出这种问题。两台手机的系统时间不经过同步可能相差几十毫秒甚至数百毫秒而这个量级足以颠倒抢答顺序。另一个隐藏原因是前端按钮做了重试同一个选手快速按了两下第二次请求因为网络重发反而先到达后端。解决后端收到抢答请求时立即打上服务端时间戳 server_ts所有判断都以这个时间为准。客户端上报的时间只存在事件里不作排序依据。同时给抢答按钮加“请求中”状态在服务端响应前禁止第二次点击如果确实因为网络重发导致重复请求后端通过 Redis 锁的 SET NX 直接过滤掉第二次。这样即使网络乱序结果也能稳定。5.2 风险题分值改完不生效缓存与权限边界现象主持人把某个风险题的分值从 20 分改成 30 分选手答对后界面还是加了 20 分后台日志里也找不到这次修改记录。原因风险题的分值被配置在了“题型全局规则”里而不是“本道题”上。ScoringRule.base_score 只是全局默认值选手开题时选择的 30 分没有传进计分服务或者计分服务优先读取了全局规则。解决风险题的分值应该存到 questions.risk_scores 里开题时前端提交 selected_score后端校验这个值在 risk_scores 数组中再把它作为 score_change 的绝对值传给计分服务。修改分值时要走“场次题目更新”接口同时刷新 Redis 缓存里的题目快照不能只改数据库。每次修改都写入操作日志现场一查就能知道是配置层还是展示层出了问题。5.3 大屏卡死但后台正常前端循环里做异步请求现象倒计时进行到一半大屏停住不动但主持人平板还能正常切题后台也能看到答案记录。原因大屏组件里写了一个 setInterval每秒钟请求一次比分接口同时 WebSocket 消息也在更新同一个 state。频繁的异步请求会让浏览器不断进入渲染阻塞尤其是一次请求还没返回下一次又发出界面就会越卡越死。另一个常见原因是 score_update 和 timer_sync 同时修改同一个对象触发了 Vue 的无限循环更新。解决大屏原则上只使用 WebSocket 一条数据通道不在 setInterval 里轮询 REST 接口。倒计时本地更新每 30 秒校准一次如果业务上确实需要轮询频率不要低于 5 秒并且用 requestIdleCallback 避开渲染高峰期。把所有 UI 状态统一放进一个 state 对象避免多个回调各改各的字段造成响应式依赖混乱。5.4 导出成绩乱码CSV 的编码问题现象比赛结束后导出成绩主办方用 Excel 打开全是乱码中文变成“”现场领导看着很尴尬。原因后端把 CSV 文件写成了 UTF-8 无 BOM。Excel 对 CSV 编码判断很弱默认按系统区域编码解析在 Windows 上最常见就是乱码。解决写 CSV 文件时使用带 BOM 的 UTF-8 编码。Python 里直接用open(path, w, encodingutf-8-sig)Node.js 里在文件开头写入\ufeff。还有一个顺手就能做的调整字段间的分隔符统一用逗号如果字段内容包含逗号用双引号包裹并转义内部引号。导出后先自己在 Excel 里打开检查一遍再发给主办方这个习惯能帮你躲过大部分低级投诉。5.5 现场临时加题题库与场次的关联设计现象比赛快开始前一小时主办方发来 10 道新题导进去之后前排大屏显示新题主持人后台却还是旧题开赛后题目顺序也乱了。原因题目数据同时存在于全局题库和场次题目表导入时只写了题库表没有同步场次题目表或者同步了但场次里的 current_question_no 指针没有校准导致题目列表重排后正在答的那一题被推到后面。解决临时加题必须统一走“场次题目导入”接口导入后校验场次题目列表并把所有重复题目的 question_id 也更新干净。如果场次已经进行到一半重置题目顺序时要记录当时的 current_question_no 在原顺序中的题目 ID导入完成后把指针重新定位到同一个题目 ID。加题前最好先暂停比赛状态机加完再恢复避免导入过程触发状态流转。6. 进阶技巧把整场竞赛变成可复盘的“事件流”存档前面所有模块都在强调事件记录其实它们本该是同一套东西。从比赛开始到结束每一次开题、抢答、计分、改判都是一个不可变的事件。把同一场比赛的全部事件按全局序号存下来赛后就能完整回放这是知识竞赛系统最有价值的进阶能力。我习惯在数据库加一张 match_events 表字段很简单seq、match_id、type、payload、server_ts。seq 是这场比赛里的全局递增序号不依赖数据库自增主键因为回放时要按它排序。payload 直接用 JSONB把抢答、计分、状态迁移的细节都塞进去。举个例子一次抢答事件是下面这个样子{ seq: 17, type: buzzer_press, server_ts: 1720000000123, match_id: 23, team_id: 3, payload: {question_no: 4, valid: true} }回放逻辑很简单从 seq0 的空状态开始按顺序把每一条事件应用到一个和线上完全相同的状态机里就能还原出当时每一个后台页面的显示。谁在第几题抢答、裁判有没有改判、比分在哪个时间点变化全部可以逐条核对。这个能力平时用不上一旦主办方赛后质疑“最后一轮分值怎么不对”它就是唯一的后悔药。我在每次比赛结束后会导出一份 JSONL 存档文件名带上比赛 ID 和日期和成绩表放在同一个目录。这个习惯救过我很多次也让我越来越相信知识竞赛系统真正值钱的不是炫酷的大屏动画而是让每个现场决策都有据可查。把事件流归档变成标准动作比多写几个功能模块更值得投入。希望帮到你。本文还有配套的精品资源点击获取
返回列表