校园球类赛事 · 微信小程序 · 一仓三端一后端
这是 Eunomia——一个覆盖「建赛事 → 排签表 → 横屏记分 → 自动晋级 → 战报归档」闭环的 校园排球/羽毛球记分小程序。本页把它拆开给你看:架构是什么、为什么这么选、每条链路怎么走。 所有节点都可以点。
第一章 · 核心架构
数据自上而下流:你在手机上的每次操作,最终都变成 MySQL 里的一行记录;观众看到的一切,都是从这台服务器「拉」回来的。
第二章 · 技术栈详解
技术选型没有标准答案,只有「跟你的场景配不配」。这套选型的场景是:校园比赛、用户量中等、一个开发者、要快速上线还要稳定。
第三章 · 主链路 ①
这是整个系统的心脏。点每一步,看发生了什么、数据此刻长什么样、为什么这么设计。
真实代码时刻 ① / 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 个页面里有两个专职于此(手写签表、手写分组)。这是「自动编排」的镜像面:人可以精修算法的结果,但精修必须守规矩——同样每一步都能点。
故事一「带轮空的签表没法重排」:轮空坍缩在创建签表时就会写入 winner_id 和 status,而前端的「可编辑」判定把这两个字段当成「已开赛」——契约不一致,带轮空的签表永远不可编辑。修复后立下新约定:「开赛与否」只由后端痕迹守卫说了算(比分、局分、退赛标记、执裁锁、事件流水才算痕迹)。
故事二「重开后签表窗口永久卡死」:重开比赛曾把局分清成 0 而不是 NULL,痕迹守卫一看「有局分」→ 已开赛 → 编辑窗口关闭,而且再也打不开。V29 迁移专门清洗了这批脏数据。两条教训:「没有」要用 NULL 表达,0 是一个值不是空;判定标准必须前后端一字不差。
「半决赛决胜局打 15 分、决赛还是 21 分」这种需求怎么零代码实现?答案是:规则不写死在代码里,落成配置表,开赛前由一个 110 行的小类解析。这条链路把「规则配置」的完整生命周期讲清楚。
第五章 · 主链路 ③
这个项目没引 Spring Security 这种「重武器」,而是自研了一套轻量 JWT 方案——以及一个学生最爱问的「扫码登录」是怎么做到的。
真实代码时刻 ③ / 3 · 每个 API 请求的第一道关卡
登录后,小程序把后端发的 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 云服务器搞定一切:
ln -sfn 切流量,出问题秒回滚api.(后端)、www.(管理端)、product.(官网)表结构改了 29 版,靠 Flyway 管理——可以理解为数据库结构的 Git:
V15__加赛段规则.sql、V20__加会话锁.sql……没有 Flyway 的世界:同事数据库结构和你不一样,功能跑起来就是玄学。
排球规则状态机有几千行、组合爆炸,手写用例测不完,于是让机器自己跟自己打一千场:
第七章 · 类与数据地图
类不用全背、21 张表不用都记。看懂两条协作链、记住 6 张核心表,这个系统 80% 的设计意图就通了。
主链四张表构成「赛事 → 组别 → 比赛 → 事件」的骨架,每一段关系都是 1:N。点表名看关键字段和设计意图。
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,别硬撑。
按「用户真的需要」排序,而不是按「技术时髦」排序:
终章 · 你真的懂了吗
答错不要紧——每道题都会告诉你错在哪,并送你回到对应章节再看一眼。