如果你跟我一样经常被各种赛事计时表、Excel 成绩单、手动排名搞到半夜那你一定要试试 t3code 这套思路。t3code 最开始只是我给一个周末赛车活动写的三行脚本后来慢慢变成了一套三段式赛事编码协议练习、排位、正赛三段统一用代码表达成绩、罚时、分组、排行一次搞定。它能帮你省掉最烦的重复劳动也适合做主赛事计时工具的补充。无论你是组织卡丁车娱乐赛的电竞馆老板还是给校园编程赛做积分系统的开发者都可以从这套方案里找到直接能抄的代码和配置。1. 先从“t3code”说起这不只是一个项目代号1.1 三段式赛事到底在解决什么问题先解释一下我为什么叫它“t3code”。T3 在我这里有两层意思一是 Time Trial计时赛二是 Three-tier三级流程。计时赛大家都懂一个人一台车按时间说话谁快谁赢。三级流程则是比赛最常见的结构练习、排位、正赛。很多民间赛事、电竞比赛、校园活动其实都是这个节奏只不过主办方经常用三套完全不同的表格来管数据对不上是常态。t3code 最开始的痛点就来自这里。我给一个二十多人的小型计时赛做过一次志愿者现场用的是秒表和纸质记录表。练习赛成绩、排位赛成绩、正赛发车顺序全靠现场人工抄然后再往 Excel 里敲。那天比赛结束已经晚上十点主办方还要等我们把成绩单整理出来才能颁奖。有人在群里问“我的排位时间怎么比练习还慢”其实是抄错了一位小数。这种问题在正式赛事里是不可能被原谅的。所以 t3code 的设计目标非常明确把计时数据从采集到出榜的过程全部变成可校验、可追溯、可复现的代码流程。选手在赛道上的每一圈都能对应到一条结构化记录练习、排位、正赛三段共用同一套数据模型只是规则配置不同。换一种比赛不用改代码换配置文件就行。这正是“编码协议”比“写死逻辑”更有价值的地方。1.2 为什么选择“编码协议”而不是直接写死逻辑很多人拿到这类需求的第一反应是写一个函数专门算成绩比如calculateRanking(results)。新手阶段这么做没问题但赛事规则一变函数就要跟着改。今天主办方说排位赛取最快单圈明天说取平均速度后天说要加 3 秒维修区罚时。如果你每次都在主逻辑里硬塞条件代码很快就会变成一坨没人敢动的意大利面条。我的做法是把赛事规则抽象成“编码”。所谓 t3code其实就是一套带语义的字符串每一段成绩记录都带一个类型前缀比如LAP表示有效圈速PEN表示罚时DNS表示未发车DSQ表示取消资格。解析器只负责把字符串转成结构化数据具体的计分逻辑由规则引擎根据配置决定。这样赛道上的设备只需要输出简单的编码流后台系统负责解释和执行规则。用生活类比来说这就像快递面单。面单上写“省-市-区-街道”快递员只需要扫编码系统就知道往哪里派送不需要重新理解每一张单子的含义。赛事编码也是这个思路把“人话”翻译成“机器能稳定处理的短代码”从源头减少歧义和录入错误。1.3 适合谁来用如果你属于下面几类人t3code 的思路可以直接拿过去用。第一类是小型赛事主办方。卡丁车场、业余赛车俱乐部、飞盘比赛、电竞馆日常赛都需要处理计时和排名。买专业计时设备太贵用 Excel 又容易错t3code 可以做中间的轻量方案。第二类是学校或社团的技术负责人。校园编程赛、机器人循迹赛、定向越野赛这些场景的计分规则五花八门写一套通用系统太重每次单独写又太累。用编码协议加规则配置一场新比赛两小时就能上线。第三类是个人开发者。也许你只是想给跑步机上的自己记录训练数据或者在业余游戏联赛里做一个战绩统计工具。t3code 里的时间处理、并发写入、校验容错这些经验是可以复用到很多项目里的。我自己在后续的版本迭代中也把这套协议从赛车场扩展到了模拟器比赛和遥控车赛事基本没改核心代码只换了输入源和规则配置。这说明当初选中“编码协议”这个方向是对的。2. 核心设计数据模型、时间精度与规则引擎2.1 数据模型选手、批次、成绩、罚时任何比赛系统核心都离不开“人、场次、成绩、惩罚”四个要素。t3code 的数据模型也围绕这四块展开。选手表存基础信息包括选手ID、姓名、组别、车辆编号或战队ID。批次表存一场比赛的阶段信息比如练习赛、排位赛、第几轮正赛每一条记录对应一个batch_id。成绩表是最关键的表每行记录一个计时结果字段包括racer_id、batch_id、round_no、encoded_time、raw_time_ms、penalty_ms、status。罚时表单独存因为罚时可能是赛后追加的不应该直接覆盖原始成绩。我建议用这样的字段结构字段类型说明idTEXT主键UUID 或雪花IDbatch_idTEXT批次号例如 T3-20241221-Q1racer_idTEXT选手IDlap_numberINT第几圈raw_time_msINTEGER原始计时毫秒数penalty_msINTEGER罚时毫秒数默认 0statusTEXT有效/无效/取消资格sourceTEXT数据来源用于追溯created_atTEXT写入时间UTC 格式一开始我只存了“最终成绩”而没有存原始圈速后来发现这是个很大的错误。因为裁判经常需要回看“这一圈是否是有效圈”如果只存一个总分遇到争议根本没有证据。所以我把原始圈速和罚时分开存宁可多费一点存储空间也要保留完整证据链。这一点在比赛现场特别重要选手质疑成绩时你能拿出每一圈的时间点说服力完全不一样。2.2 时间精度与计时格式时间精度是计时系统里最容易出问题的地方。人用秒表习惯读“分:秒.厘秒”也就是百分之一秒但很多民间赛事设备实际上只能做到百分之二秒到百分之五秒的精度。为了不让自己误以为比设备更准我在 t3code 里统一使用毫秒整数作为存储单位。所有时间字段在数据库里都存INTEGER单位是毫秒。显示的时候再转换成分:秒.毫秒。这样做的第一个好处是排序非常快整数比较比字符串比较稳定得多第二个好处是加减罚时直接做整数运算不会出现0.1 0.2 ! 0.3这种浮点坑。输入端的格式则五花八门有的计时器输出00:12.345有的输出12.345有的输出0:00:12.345。所以我写了一个解析函数先把各种文本格式统一转成毫秒function parseTimeToMs(input) { const str String(input).trim(); const match str.match(/(?:(\d):)?(?:(\d):)?(\d)(?:[.:](\d{1,3}))?/); if (!match) return null; const hours match[1] ? parseInt(match[1], 10) : 0; const minutes match[2] ? parseInt(match[2], 10) : 0; const seconds parseInt(match[3], 10); const msPart match[4] ? parseInt(match[4].padEnd(3, 0), 10) : 0; return hours * 3600000 minutes * 60000 seconds * 1000 msPart; }解析完成后逻辑层统一操作毫秒整数只有到展示层才格式化成人类可读的字符串。这样从源头避免了“不同计时器格式不同”带来的混乱。2.3 规则引擎用配置代替改代码t3code 最核心的部分不是计时而是规则引擎。我的做法是把每场比赛的规则写成一个 JSON 文件系统启动时加载比赛进行中不修改。一个典型的规则配置长这样{ raceType: sprint, stages: [practice, qualifying, race], qualifying: { mode: best_lap, count: 1 }, race: { mode: total_time, laps: 3, penaltyAfterRace: true }, ranking: { sortBy: [totalTime, bestLap] } }mode字段决定成绩怎么算。best_lap表示取最快单圈total_time表示多圈总用时。sortBy表示排序优先级先比总用时总用时相同再比最快单圈。规则引擎读取这段配置自动决定应该用哪些字段、怎么排序、怎么处理 DNS 和 DSQ。这套设计的好处非常明显。三周后主办方说“正赛改成两场积分制积分相同看最快圈”我只需要改配置里的raceType和ranking重新启动服务不需要动一行代码。对于多次复用的项目来说省下来的时间非常可观。3. 从零搭建 t3code 的实操全过程3.1 环境准备与项目骨架我建议用 Node.js SQLite 作为第一版技术栈原因有三个环境容易装适合单机/小并发场景SQLite 不需要单独起数据库服务比赛现场一台笔记本就能跑JavaScript 生态里做 Web 前端也方便可以一套语言通吃。初始化项目很简单mkdir t3code cd t3code npm init -y npm install better-sqlite3 express mkdir src data为什么选better-sqlite3而不是其他 ORM因为这个库是同步 API写业务逻辑的时候非常直接不需要处理一堆 Promise 回调。在数据量只有几千条的赛事场景里同步查询的性能完全够用而且代码可读性高排查问题容易。目录结构我习惯按功能拆分而不是按文件类型拆分t3code/ ├── src/ │ ├── parser.js # 编码解析 │ ├── engine.js # 规则引擎 │ ├── storage.js # 数据读写 │ └── server.js # HTTP 服务 └── config/ └── race.t3.json # 赛事配置先建好数据库表CREATE TABLE IF NOT EXISTS racers ( id TEXT PRIMARY KEY, name TEXT NOT NULL, group_name TEXT, vehicle_no TEXT ); CREATE TABLE IF NOT EXISTS laps ( id TEXT PRIMARY KEY, batch_id TEXT NOT NULL, racer_id TEXT NOT NULL, lap_number INTEGER, raw_time_ms INTEGER, penalty_ms INTEGER DEFAULT 0, status TEXT DEFAULT valid, source TEXT, created_at TEXT ); CREATE INDEX IF NOT EXISTS idx_laps_batch ON laps(batch_id); CREATE INDEX IF NOT EXISTS idx_laps_racer ON laps(racer_id);早期我偷懒没建索引到几百条成绩的时候还不觉得慢等练习赛、排位赛、正赛三批数据累积到两三千条查询排行就开始卡了。加了batch_id和racer_id两个普通索引之后速度立马上来。所以哪怕数据量小索引也建议在第一天就建好。3.2 核心模块实现一行秒表数据如何变成正式成绩现在到最核心的环节把类似LAP|T3-001|1|00:12.345这样的原始文本变成数据库里的一条成绩记录。原始文本的格式我定义为类型|选手编号|圈数|时间比如LAP|T3-001|1|00:12.345解析器的核心代码如下const { v4: uuidv4 } require(uuid); const { parseTimeToMs } require(./parser); function ingestLap(line, source manual) { const parts line.split(|); if (parts.length 4) { throw new Error(格式错误: ${line}); } const [type, racerId, lapNumber, timeStr] parts; const rawTimeMs parseTimeToMs(timeStr); if (rawTimeMs null) { throw new Error(时间解析失败: ${timeStr}); } return { id: uuidv4(), batch_id: currentBatchId, racer_id: racerId, lap_number: parseInt(lapNumber, 10), raw_time_ms: rawTimeMs, penalty_ms: 0, status: valid, source, }; }这里我加了一个currentBatchId的全局变量用来表示当前录入的是哪个阶段的数据。比赛现场的操作流程是这样的练习赛开始前先创建一个batch_id T3-20241221-PRACTICE现场录入的所有圈速都归属于这个批次练习赛结束后再创建新的批次T3-20241221-Q1。这样统计的时候按batch_id过滤就行非常干净。规则引擎取排位赛最快单圈时SQL 是这样的SELECT racer_id, MIN(raw_time_ms penalty_ms) AS final_time FROM laps WHERE batch_id ? AND status valid GROUP BY racer_id ORDER BY final_time ASC;这里我把raw_time_ms和penalty_ms相加是因为有些赛事在排位赛阶段就会因为维修区超速加罚时。如果直接用raw_time_ms排序罚时效果就丢失了。别小看这一步我见过好几个比赛系统在这里写错导致被罚时的选手反而排到前面去了。3.3 前端展示与排行榜现场需要一个能让所有人看到的排行榜投屏。最简单的方案是用 Express 提供一个 JSON 接口前端用一个原生 HTML 页面轮询展示。后端接口大概这样app.get(/api/ranking, (req, res) { const { batchId } req.query; const rows db.prepare( SELECT racer_id, MIN(raw_time_ms penalty_ms) AS final_time FROM laps WHERE batch_id ? AND status valid GROUP BY racer_id ORDER BY final_time ASC ).all(batchId); res.json(rows); });前端的 HTML 里放一个table每 5 秒用fetch拉一次最新排行刷新时只更新 tbody 的内容避免整个页面闪烁。5 秒的刷新间隔对小型赛事足够服务器压力也小。如果现场网络不稳定可以考虑把刷新间隔提高到 10 秒但没有必要做 WebSocket 实时推送因为观众对名次变化的实时性要求没那么高偶尔多等几秒完全可以接受。为了现场工作人员方便我还在页面上加了一个录入表单支持手动粘贴多条编码记录。比如计时员从秒表软件里复制出几十行粘贴到文本框点击“导入”系统批量解析写入。这个功能看起来不起眼但实际使用频率非常高因为不是每次比赛都有自动计时设备接入。4. 实测中踩过的坑与排查速查表4.1 毫秒级误差现场设备比想象中更“粗糙”第一次实测活动我直接用手机秒表加人工按键采集数据结果发现同一个选手同一圈两个计时员按出的时间能差 300 毫秒左右。300 毫秒在赛车比赛里可能就是两三个车位的差距如果直接拿去排名争议会很大。后面调整了流程人工计时只作为备份主计时尽量用手动触发红外传感器的设备或者用视频帧数作为辅助证据。t3code 在这一层能做的事情其实有限但我把采集来源记录到了source字段里如果出现争议成绩可以回溯到原始记录知道它是人工按键还是设备触发不会稀里糊涂地吵。要注意解析函数里我默认把12.3当成12.300秒处理而不是当成 12 秒 3 毫秒。因为大多数手动计时器输出的最后一位是百分秒或千分秒的简化写法。如果你的设备输出习惯不同一定要在解析前确认单位否则会出现系统性偏差。4.2 夏令时与跨时区UTC 存储是救命稻草这个问题我从没想过会出现在一个本地赛事系统里直到有一次活动时间正好卡在夏令时切换附近。赛事现场的设备用的是本地时间写数据库的脚本却用了服务器时间两个时间相差一小时导致成绩录入后批次的先后顺序完全错乱。解决办法很简单所有时间戳统一存 UTC。比如记录一个圈速的产生时刻用2024-11-03T16:00:00.000Z而不是2024-11-03 10:00:00。显示给用户的时候再根据现场时区转换成本地时间。写进数据库的时间字段我建议直接用 ISO 8601 字符串因为 SQLite 没有原生的 DATETIME 类型字符串反而更容易处理和排序。但注意排序时要用T和Z齐备的完整格式比如2024-11-03T16:00:00.000Z如果省略了毫秒或时区按字母序排序就可能出错。4.3 并发写入导致成绩丢失比赛现场通常是几个计时员同时录入数据或者一个计时软件多个进程同时写库。头几次我用的 SQLite 默认配置连续读写时遇到了database is locked错误有几条成绩没有写进去。后来我把所有写操作收敛到同一个模块在写入的地方显式开启事务const insert db.transaction((rows) { const stmt db.prepare(INSERT INTO laps ...); for (const row of rows) { stmt.run(row); } }); insert(batchRows);better-sqlite3的事务默认是同步的多个写入会排队执行不会互相踩踏。我还在数据库连接时开启了 WAL 模式并发性一下子就够用了。对于几千条记录的小比赛这套方案非常稳完全不需要上 PostgreSQL 或 MySQL。4.4 数据校验的边界脏数据比想象中多现场录入时最容易出现的脏数据包括选手编号打错、圈数写成负数、时间格式多了一个冒号、罚时字段填了负数。系统如果照单全收排行榜上就会出现一个“-1圈”的记录看着很尴尬。我在解析层加了严格的边界校验选手ID必须匹配/^T3-\d{3}$/格式圈数必须大于等于 1时间解析结果必须大于 0罚时如果是负数直接拒绝写入。校验不通过的数据不会静默丢弃而是写入一条错误日志并在前端提示“某条记录格式错误请检查”。因为现场的人最怕的是数据莫名其妙消失而不是看到报错。报错至少知道问题在哪静默丢失才是真正致命的。4.5 排查速查表常见症状一句话定位我把实际运维中遇到的问题整理成一张速查表每次现场出状况都先对着这张表走一遍通常十分钟内能定位问题。症状可能原因排查方法解决方案排行榜缺人录入时选手ID打错查错误日志按 racer_id 模糊搜索严格执行格式校验成绩整体偏慢时间格式单位理解错抽查几条原始文本和解析结果确认秒表输出单位统一解析规则批次数序混乱时间没有用 UTC 存储查看 created_at 字段前后顺序所有时间戳强制 UTC写库报 locked多进程并发写 SQLite查看应用日志开启 WAL写操作走事务罚时没生效排序时忘了加 penalty_ms查看 SQL 排序字段使用raw_time_ms penalty_ms这张表我打印了一份放在比赛现场的操作台上比什么技术文档都有用。因为现场人一紧张根本没有心思去读系统设计说明只需要快速定位问题先把活动顺利进行下去。5. 扩展方向从赛事统计到训练复盘5.1 插件化自定义赛事类型做完基础版本之后我开始想怎么让 t3code 适应更多比赛类型而不只是赛车。后来我给规则引擎加了插件机制每一种赛事类型对应一个模块模块输出统一的成绩记录结构这样主流程不需要改只要按协议返回数据就能接入。比如定向越野赛需要使用“打卡点顺序”作为成绩依据而不是纯粹的计时。我就写一个orienteering插件解析打卡序列缺少某个点就直接标为无效。又比如电竞比赛需要算的是“队伍积分”我就把racer_id换成team_id规则引擎还是按同样的方式读取和排序。插件的接口约定很简单输入是原始编码流输出是统一的 lap/penalty/status 结构。只要遵守这个约定任何赛事都能挂进来。5.2 数据可视化从干巴巴的数字到可读的图表排行榜只是第一步后续可以加更多可视化能力。比如画一条时间曲线横轴是圈数纵轴是单圈时间能直接看出选手在第几圈开始体力下降。还可以做分段速度热力图把一条赛道的每个计时点数据标成色块一眼就能看出谁的弯心速度更差。我目前的做法是把 t3code 的成绩数据导出成 JSON再用前端图表库渲染。图表库的选择不关键Chart.js 画曲线够用ECharts 做热力图更方便看自己的熟悉程度就好。5.3 后续优化方向自动分组、异常检测与回放联动第二个版本我打算加入自动分组功能。很多娱乐赛事喜欢按玩家历史水平分成 A、B、C 组避免水平差距过大。t3code 可以把历史成绩导入按分位数自动划分组别主持人只需要确认结果就行。异常检测也值得做。比如某位选手连续五圈成绩都在 11.5 秒左右突然出现一圈 9.8 秒大概率是计时设备漏记或走了捷径。这类极端值可以做标记让裁判重点复查而不是直接当成新纪录发布。更远一点的规划是把录像回放和成绩联动起来。每一条成绩记录都带上视频时间戳点击排行榜上某一条记录就能跳到对应的比赛画面。这对线上电竞比赛尤其有用可以极大降低申诉处理成本。当然这个功能涉及视频文件的存储和管理复杂度会比现在高不少我会先把基础的数据联动做好再逐步推进。我自己用下来的体会是t3code 的核心价值不在于代码写得多漂亮而在于把“事后补录”变成了“现场自动”。如果你也经常组织小型比赛我建议你从最小的场景切入先只做计时串解析再加罚时再加分组别想一步到位。最后再分享一个小技巧日志里永远要带上比赛ID和批次号现场排查问题的时候这两行信息能救你的命。