🏐 Eunomia 技术导览

校园球类赛事 · 微信小程序 · 一仓三端一后端

一次点击「+1 分」之后,
这个系统里发生了什么?

这是 Eunomia——一个覆盖「建赛事 → 排签表 → 横屏记分 → 自动晋级 → 战报归档」闭环的 校园排球/羽毛球记分小程序。本页把它拆开给你看:架构是什么、为什么这么选、每条链路怎么走。 所有节点都可以点。

3+1三端一后端
26小程序页面
29版数据库迁移
1000+场混沌仿真

⏱ 60 秒版本(开口就能讲的那个)

  1. 它是什么:给校园球赛用的记分小程序——组织者建赛事排签表,裁判用手机横屏记分,观众看对阵图和战报。
  2. 分几层:小程序端(uni-app / Vue 3)+ PC 管理端(Vue 3)→ Nginx → 一个 Spring Boot 单体后端 → MySQL,全部跑在一台 Ubuntu 服务器上,没有 Docker、没有微服务。
  3. 最特别的一点:排球那些复杂规则(轮转、自由人、换边、40 步撤销)是在裁判手机上本地算的,后端只老老实实存「事件流水」,不做二次校验。
  4. 「实时」的真相:没有 WebSocket。观众端回到页面时重新拉一次数据(拉模式),对校园赛场景完全够用。
  5. 靠什么不出错:一场比赛一把「执裁锁」防两人同时记分;完赛结算在数据库行锁事务里一口气完成比分、晋级、父场结算。

第一章 · 核心架构

全景架构图:点任何一个节点,看它是什么、为什么是它

数据自上而下流:你在手机上的每次操作,最终都变成 MySQL 里的一行记录;观众看到的一切,都是从这台服务器「拉」回来的。

谁在用
HTTPS 请求(手机/电脑 → 云)
客户端层 · 跑在用户设备上

⚠️ 排球规则状态机就在小程序端本地跑——这是全项目最大胆的设计,为什么?

唯一的入口 ↓
接入层 · 跑在服务器上
/api/* 转发到本机 8080 端口 ↓
应用层 · 一个 Spring Boot 单体(一个 jar 进程)
→ → →
SQL ↓
数据层
地基

第二章 · 技术栈详解

七个关键选型:每张卡都能切换「它是谁 / 为什么是它 / 换别人会怎样」

技术选型没有标准答案,只有「跟你的场景配不配」。这套选型的场景是:校园比赛、用户量中等、一个开发者、要快速上线还要稳定。

第三章 · 主链路 ①

记分核心链路:从裁判按下「+1」到观众看到比分

这是整个系统的心脏。点每一步,看发生了什么、数据此刻长什么样、为什么这么设计。

共 6 步 · 点步骤卡片查看详解

真实代码时刻 ① / 3 · 第 2 步的底气

排球状态机:一个纯函数,管住所有复杂规则

排球的规则又多又绕:谁发球、轮转到几号位、自由人能不能上场、换边怎么办……这些全部在裁判手机本地由一个纯函数状态机推演,不联网也能记分。下面是它的真实骨架(简化自 frontend/src/pages/volleyball/match-state.js):

// 输入:当前状态 + 一个动作 → 输出:全新状态(不修改旧状态,所以叫"纯函数")
function applyAction(state, action) {
  switch (action.type) {
    case 'SCORE':        // 得分:先判断是不是该换发球权(排球每得一分都可能换边发球)
      return rotateIfNeeded(setScore(state, action.side));
    case 'SUBSTITUTE':   // 换人:检查自由人规则、轮次位置是否合法
      return validateLineup(applySubstitution(state, action));
    case 'UNDO':         // 撤销:状态快照压进一个最多 40 层的栈里,随时弹回来
      return popUndoStack(state);
  }
}

💡 给学生的翻译:就像下棋时每走一步都拍一张照,悔棋就是撕掉最近那张照片重新走。「纯函数」保证了同样的局面 + 同样的动作,永远得到同样的结果——这让后面讲的千场混沌测试成为可能。

真实代码时刻 ② / 3 · 第 4~5 步的保险栓

完赛结算:数据库行锁里的「一锤子买卖」

比赛结束的一瞬间,后端要连做好几件事:写比分、把赢家写进下一轮对阵、可能还要结算团体赛父场。如果写到一半崩了会怎样?答案是:整个事务回滚,像什么都没发生过。真实骨架(简化自 backend/.../service/match/MatchSettlementService.java,877 行):

@Transactional  // 下面的所有操作,要么全成功,要么全不算数
public void finishMatch(Long matchId, MatchResult result) {
  Match match = mapper.selectByIdForUpdate(matchId); // 行锁:别的事务碰这场比赛得排队
  match.finish(result.winner(), result.score());     // 写比分、改状态
  bracketService.propagateWinner(match);             // 把赢家写进下一轮的空位
  teamSettlement.trySettleParent(match);             // 团体赛:子场全踢完才算父场赢
  reportAssembler.assemble(match);                   // 生成战报 JSON
}  // 方法结束 → 事务提交,观众从此能查到结果

💡 给学生的翻译:行锁就像洗手间门锁——保证同一时刻只有一个人在里面;事务就像「全做完才出门」,做一半系统崩了,锁会自动打开、一切恢复原样。

真实代码时刻 ④ / 4 · 撤销的代价:幽灵事件补偿

裁判点了撤销,已上报的「幽灵事件」去哪了?

排球裁判手滑点了个换人、上报了,随后点撤销——但那条换人事件已经进了服务器的 match_event 表。怎么办?答案不是删除,而是「流水账不撕页,只写一行作废」(真实骨架,简化自 useScoreboard.js 的撤销上报 + MatchDetailAssembler 的读取裁决):

// 前端:撤销时追加一条"水位快照"事件(不是删旧事件)
events.push({
  seq: nextSeq(),                       // 递增序号,对应库里的唯一键
  type: "score_snapshot",
  payload: { reason: "undo", revertToSeq: targetSeq }
});
// 后端:读取详情时,把 seq > revertToSeq 的换人/暂停/队长变更
// 全部标记为"已撤销"——记录页与战报从此不再显示它们

💡 这些被撤销却留在流水里的记录,工程师叫它们「幽灵事件」。不删的好处是审计价值拉满:任何时候都能还原「当时发生了什么、后来撤了什么」——毕竟撤销本身也是比赛的一部分。当初「带轮空的签表没法重排」「重开后窗口永久卡死」两个 bug,教会我们的正是同一件事:「发生过」的判定标准,必须全团队一字不差。

第四章 · 主链路 ②

赛事编排链路:一场比赛是怎么被「组织」出来的

记分之前,先得有比赛。这条链路从空白开始:创建 → 录队 → 抽签 → 小组循环 → 淘汰赛,最后接入第三章的记分链路。同样每一步都能点。

🔍 值得注意的取舍:「报名」其实没有报名系统

这个项目里,参赛队伍不是用户自己点「我要报名」加入的,而是组织者手动录入队伍和队员。为什么?校园赛的报名往往是「班长把名单发你」,本来就有人工整理这一步;做一个自助报名系统要处理审核、退出、资格校验等一大堆问题——收益配不上复杂度。识别「不必做的功能」和识别「必须做的功能」一样重要。

✍️ 专题链路 ① 手写签表 / 手写分组:算法给起点,人保留裁量权

PC 端 5 个页面里有两个专职于此(手写签表、手写分组)。这是「自动编排」的镜像面:人可以精修算法的结果,但精修必须守规矩——同样每一步都能点。

🔍 两个值得讲的 bug 故事:「能不能改」的判定标准是怎么长出来的

故事一「带轮空的签表没法重排」:轮空坍缩在创建签表时就会写入 winner_id 和 status,而前端的「可编辑」判定把这两个字段当成「已开赛」——契约不一致,带轮空的签表永远不可编辑。修复后立下新约定:「开赛与否」只由后端痕迹守卫说了算(比分、局分、退赛标记、执裁锁、事件流水才算痕迹)。

故事二「重开后签表窗口永久卡死」:重开比赛曾把局分清成 0 而不是 NULL,痕迹守卫一看「有局分」→ 已开赛 → 编辑窗口关闭,而且再也打不开。V29 迁移专门清洗了这批脏数据。两条教训:「没有」要用 NULL 表达,0 是一个值不是空;判定标准必须前后端一字不差。

📏 专题链路 ② 分段规则:一条规则从创建到生效

「半决赛决胜局打 15 分、决赛还是 21 分」这种需求怎么零代码实现?答案是:规则不写死在代码里,落成配置表,开赛前由一个 110 行的小类解析。这条链路把「规则配置」的完整生命周期讲清楚。

第五章 · 主链路 ③

登录鉴权:你是谁,凭什么记分

这个项目没引 Spring Security 这种「重武器」,而是自研了一套轻量 JWT 方案——以及一个学生最爱问的「扫码登录」是怎么做到的。

📱 小程序端:微信登录

🖥️ PC 管理端:扫码登录

真实代码时刻 ③ / 3 · 每个 API 请求的第一道关卡

JWT 拦截器:三行代码验明正身

登录后,小程序把后端发的 JWT(一串带签名的令牌)存在本地;之后每个请求都自动带上它。后端拦截器在业务代码运行之前先验明正身(简化自 backend/.../security/AuthInterceptor.java):

public boolean preHandle(HttpServletRequest request, ...) {
  String token = request.getHeader("Authorization");        // ① 取出头里的 "Bearer eyJhbGci..."
  DecodedJWT jwt = JWT.require(algorithm).build().verify(token); // ② 验签名+有效期,伪造的直接拒绝
  AuthContext.setUserId(Long.parseLong(jwt.getSubject()));  // ③ 把"当前用户"放进 ThreadLocal
  return true;  // 之后任何业务代码都能拿到"这是谁",却不用层层传参
}

💡 为什么不用 Spring Security?它功能强大但配置繁重,这个项目只需要「微信登录 + JWT 校验 + 两个接口限流」三件事。自研版总共几百行、完全可控。框架是拿来解决问题的,不是拿来供着的。配套有限流防刷:生产配置登录 20 次/分、写接口 60 次/分(代码默认 30/120,可按环境调整),超限一律返回 429;全项目统一「HTTP 状态码 = 业务 code」的错误约定。

第六章 · 工程化

工程化三件套:不只是「能跑」,还要「管得住自己」

🚀 部署发布

没有 Docker,没有 K8s,一台 Ubuntu 云服务器搞定一切:

  • 后端是一个 jar,由 systemd 托管——进程崩了自动拉起,还设了 1.5GB 内存上限防失控
  • 发布用「时间戳目录 + 软链切换」:新版本部署到新目录,一条 ln -sfn 切流量,出问题秒回滚
  • Nginx 负责 HTTPS 证书、三个域名分流:api.(后端)、www.(管理端)、product.(官网)
  • 发布脚本每次生成一次性 SSH 密钥,用完即毁

🗄️ 数据库演进

表结构改了 29 版,靠 Flyway 管理——可以理解为数据库结构的 Git:

  • 每个版本是一个 SQL 文件:V15__加赛段规则.sql、V20__加会话锁.sql……
  • 后端启动时自动检查并补跑未执行的迁移,代码和库结构永远配对
  • V25 专门做了一次索引优化,V26 统一时间类型与排序规则,V29 清洗了一处重开残留数据

没有 Flyway 的世界:同事数据库结构和你不一样,功能跑起来就是玄学。

🧪 混沌测试

排球规则状态机有几千行、组合爆炸,手写用例测不完,于是让机器自己跟自己打一千场:

  • 无头仿真:不开界面,纯逻辑层随机生成动作流,一晚上跑 1000+ 场
  • 断言不变量:总分 = 两队分之和、每局必有胜负、撤销后状态一致……违反即抓到 bug
  • 支持分块跑 + 断点续跑,还有夜间值守 runner
  • 后端另有 300+ 单元/集成测试把业务规则焊死

第七章 · 类与数据地图

类怎么接力、表怎么关联:点开每一个名字

类不用全背、21 张表不用都记。看懂两条协作链、记住 6 张核心表,这个系统 80% 的设计意图就通了。

🤝 后端协作链:一场比赛「完赛」时,类是这样接力的

主链 · 从 HTTP 请求到数据库
→ → → →
结算心脏的三位贴身协作者
每个请求先过的「安检门」(详见第五章)
→ ←

📱 前端记分三件套:规则在这边跑

裁判手机上的接力
→ → →

🗄️ 数据库地图:21 张表,记住 6 张就懂 80%

主链四张表构成「赛事 → 组别 → 比赛 → 事件」的骨架,每一段关系都是 1:N。点表名看关键字段和设计意图。

1 : N
一个赛事多个组别
1 : N
一个组别多场比赛
1 : N
一场比赛多条事件
N : 1
match_record 的 left / right / winner 都指向它

🔍 值得讲的故事:执裁锁根本不是一张表

V20 迁移的全文只是一条 ALTER TABLE match_record:给比赛表加上 locked_by_user_id(谁锁的)、lock_token(锁令牌)、lock_expire_time(何时过期)三列。抢锁/续期/释放全部用条件 UPDATE 原子完成——为一把锁建一张独立表?单机场景下不需要,三列就够。

第八章 · 架构取舍与未来演进

为什么它现在长这样?什么时候必须改?

好的架构不是「用了多新的技术」,而是「每个决定都跟规模匹配」。点开看每个决定的边界。

现状:全项目没有任何长连接。观众看到的「最新比分」来自页面回到前台时(onShow)重新拉一次接口;管理端扫码登录用的也只是 1.5 秒一次的普通轮询。

为什么成立:校园赛的观众是「打开看一眼对阵图」的节奏,不是盯着大屏数秒级刷新;而且拉模式实现简单、服务器零长连接开销、天然抗并发。

什么时候必须升级:要做「现场大屏实时刷分」或观众量上来后数据库扛不住高频轮询时。升级路径也现成:Spring Boot 加 STOMP 的 WebSocket,或者更轻的 SSE 推送,前端把 onShow 拉取改成订阅。

现状:排球几千行规则逻辑跑在裁判手机上;后端只接收「事件流水」存表,不校验站位合法性。

收益:记分零延迟(本地推演)、断网也能继续记分(Storage 兜底 + 事件队列补传)、服务器不用背最重的计算。

代价与边界:后端信任了前端的事件流。如果将来要「观众端实时围观正在进行中的比赛」或防作弊,就需要把关键规则校验下沉到后端、或引入服务端重放(表结构已预留 match_runtime_state)。

现状:一个 jar、一台服务器、systemd 拉起。Controller 只有 3 个,但 Service 分层清晰:门面 → 协作者服务 → 纯算法 Engine(BracketEngine 等不依赖数据库,可独立单测)。

为什么成立:微服务解决的是「几十个团队协作」的问题,这里是一个人开发。单体意味着一个事务锁得住完整业务、一次部署全量生效、排查问题不用跨服务追日志。Docker 同理——发布脚本 + 软链回滚已经够快。

什么时候必须拆:多团队并行开发互相阻塞、或某模块(如事件存储)需要独立扩容时。好在 Engine 层已保持纯函数化,真要拆,边界是现成的。

现状:auth0 java-jwt 库 + 一个拦截器 + ThreadLocal,总共几百行;401 时前端静默重登一次再重发请求,用户无感。

判断标准:自研的范围被死死限制在「验签 + 取用户」,密码学本身交给成熟库。真正危险的自研是「自己发明加密算法」,而不是「自己组装成熟的轮子」。

什么时候该换:需要 OAuth2 第三方授权、复杂角色权限树(RBAC)、SSO 单点登录时,直接上 Spring Security,别硬撑。

按「用户真的需要」排序,而不是按「技术时髦」排序:

  • ① 实时推送(WebSocket/SSE):现场大屏、观众围观进行中的比赛——最打动用户的升级。
  • ② 服务端规则校验:赛事奖金变重后,防作弊需求出现,关键结算逻辑下沉。
  • ③ 多租户/运营后台:如果对外开放给更多学校,需要赛事审核、数据看板。
  • ④ 容器化:只有当部署频率高到软链脚本都嫌麻烦时才值得。

终章 · 你真的懂了吗

十道自测题:全对才算「了如指掌」

答错不要紧——每道题都会告诉你错在哪,并送你回到对应章节再看一眼。