1. 为什么突然想写这么一套计分系统
先说个背景。我经常跟几个朋友周末约线下棋牌局,玩的是本地规则的长牌和麻将。人凑齐容易,难点在于:一晚上打四五个小时,少说十几局,谁赢谁输、赢多少、哪一局是关键转折点,靠脑子根本记不过来。以前我们用纸笔记,每局打完记个正负数,结束以后拿计算器加总,经常出现三个人加出三个结果的乌龙。后来用过几款现成的计分App,要么规则不匹配,要么没法导出场次数据复盘,要么广告把计分页面挤得没法看。
后来事情升级了。有朋友组织小区里的月度友谊赛,十几个人分三桌打,打完要把每桌结果汇总、算排名、算积分兑换小奖品。我临时拉了个表格凑合处理,被数据校验折磨得够呛。那次以后我就下定决心:自己动手,写一套适用于室内牌类比赛的局内计分统计和局外结算复盘系统。
这篇文章把整个项目的设计思路、数据模型、局内计分逻辑、局外结算规则以及复盘功能的实现过程完整记录下来。重点不是贴一堆代码,而是讲清楚每一块为什么这样设计、踩了哪些坑、遇到边界情况怎么处理。如果你也在搞类似的东西——不管是自用计分工具、赛事管理系统,还是想给朋友局做一个清爽的结算助手,这篇都值得看一看。
2. 先想清楚:所谓"局内"和"局外"到底怎么划分
很多人在做这类系统时,第一步就搞混了概念。我花了两周时间梳理业务流程,最后把整套系统拆成三个核心域:牌局、场次、结算复盘。这三个词必须区分清楚。
牌局(Round)指的是单一的一局,比如一把麻将从发牌到胡牌的完整过程。场次(Session)指的一次完整的聚会或比赛,可能包含多局。结算复盘(Settlement & Review)是指场次结束后对整体成绩的分析。
局内计分统计,管的是"牌局"这一层。它的特点是高频、实时、局部。每打完一局,系统要立即算出这一局各家的得分数、是否涉及特殊规则(比如自摸加番、杠上开花、封顶等),然后立刻汇总到本场次的累计榜上。局外结算复盘,管的是"场次"这一层。它的特点是低频、全局、面向结果。一个晚上或一场比赛全部结束后,系统要综合所有牌局数据做最终结算。
这套划分看起来简单,但它决定了整个系统的数据模型和接口设计。所有程序里的地雷,几乎都埋在这两层之间的衔接上。比如:局内每一局结束时的"当前领先者",和整场结算后的"最终赢家",经常不是同一个人——中间存在翻盘。如果你的数据模型没有把每一局的结果快照保存下来,复盘时根本没法讲清楚"翻盘是怎么发生的"。
2.1 业务流程才是真正的起点
动手写代码前,我先把完整业务流程画了一遍。以一场4人麻将友赛为例:
- 赛前:建立场次,录入选手名单(4名或更多选手)。
- 赛中:每局结束,由记录员录入四位选手的本局得分(正数代表赢,负数代表输)。
- 局内统计:系统实时更新每位选手的累计分、胜局数、本场排名。
- 局外结算:全部局数打完,系统自动计算最终得分与名次,按照预设的积分规则换算成赛事积分。
- 复盘:系统生成本场次的完整牌局流水、排名曲线、单局得失分布,供选手和组办方回顾分析。
关键点在第2步和第5步。第2步要求录入流程足够流畅,否则大家打牌兴致正浓,没人愿意在手机上慢慢戳。第5步要求数据结构足够细,否则后面做复盘时什么都调不出来。这两点贯穿了整个设计过程。
3. 数据模型设计:先定四张表,再加两个快照
数据结构是整个项目的基石。这一节我把最终版本的表结构完整呈现,并且说明每个关键字段的设计原因。
3.1 场次表与参赛记录
场次表存的是"一次聚会/比赛"的元信息:
- 场次ID、场次名称、举办日期、地点、总牌局数、状态(进行中/已结束)。
- 规则集ID,指向一套计分规则配置。
- 备注字段,用来记录特殊情况,比如有人中途加入或退出。
参赛记录单独建表,不塞进场次表。为什么?因为一场比赛的人数可能中途变化。我们遇到过下午场3人先打,晚上又来1人,变成4人局。如果参赛信息是场次表的一列,这种变动会非常难处理。单独建一张"场次-选手关联表"就灵活得多,每行只记录:场次ID、选手ID、加入时间、离开时间、最终排名。
这个设计在结算时帮了大忙。算成绩时只需要扫描这个关联表,凡是"加入时间早于场次结束时间"的选手,都有资格参与结算——不需要在业务逻辑里写各种补丁处理中途增减人的情况。
3.2 牌局明细表:每局结果至少要存19个字段
牌局明细表是整个系统最核心的表。我设计它的时候坚持了一个原则:一条记录只对应一局,所有需要维度全部冗余存储,不依赖关联查询。为什么冗余?因为复盘场景对查表速度要求高,而关联查询在百局以上会明显变慢,特别是手机端上。
最初的表结构是这样的:
| 字段名 | 类型 | 说明 |
|---|---|---|
| 局ID | 自增主键 | 全局唯一 |
| 场次ID | 外键 | 关联场次表 |
| 局序号 | 整数 | 本场次内第几局 |
| 玩家1~4得分 | 整数 | 四家本局得分 |
| 本局最大赢家ID | 外键 | 得分最高者 |
| 本局最大输家ID | 外键 | 得分最低者 |
| 特殊规则标记 | 字符串 | 如"自摸""杠上花""封顶" |
| 录入时间 | 时间戳 | 系统自动填 |
| 录入人ID | 外键 | 谁录的这局 |
| 备注 | 文本 | 手动补充 |
你可能注意到:玩家1到4的得分字段是平铺的。这设计合理吗?在麻将和长牌场景下,每局固定4人参与,平铺比竖表读起来快得多,导出Excel也更直观。除了这四个得分字段,我还存了"本局最大赢家"和"本局最大输家"这两个冗余字段。目的是随时可以快速回答一个问题:本场次中,每局的转折点是什么时候。
但后来我意识到——上面这个表结构漏了一个致命的东西:牌局结束瞬间,各家的累计总分没有存。 | | |
这个"当时累计分"字段,是复盘功能的核心数据。没有它,画不出排名曲线。没有它,没法回答"第7局结束时他是领先还是落后"。我踩过这个坑,初版没有这个字段,后来做复盘功能时不得不跑一遍全量重算,耗时且容易出错。最终版本在牌局明细表里加上了两个关键字段:
- 本局结束后四家累计得分(冗余存储)
- 本局结束后四家排名快照
正是靠这两个字段,复盘模块的所有图表才稳稳当当跑了起来。
3.3 计分规则集:普通用户不用改,但你不能不做
每个地方的计分规则差异极大。我们玩的长牌规则里有"抓分""扛分""封顶",麻将规则里有"点炮""自摸""混一色""清一色"加成。不同规则组合,会导致同一局牌的结果完全不同。
一开始我想用一个规则引擎配置闯天下,后来想通了:大多数普通用户根本不需要改规则,他们只想输入几个数字、得到结果。但你的系统如果不支持规则配置,一旦有人拿着异规则需求来找你,你就得改代码。
折中方案是这样:
- 系统内置三套常用规则集:通用麻将计分、本地长牌计分、自由计分(完全手动)。
- 可以新建自定义规则集:只需要设置最高分上限(封顶值)、是否允许负分、是否按基数自动翻倍。
- 牌局录入时,系统根据规则集自动校验录入结果的合法性。
比如通用麻将规则集配置了"封顶值=100",录入员输入某家得分140,系统立刻报警提示分值与规则不符。这个校验功能帮我们挡下了很多手误。
3.4 一个关键设计取舍:为什么不把所有信息塞进一个JSON字段
现在流行一把梭,把所有信息塞进一个大JSON字段,读写都方便。我最初第一版也这样干过——但很快发现两个痛点:
- 痛点一:要按"特殊规则标记"做筛选统计,JSON字段做不到高效索引。
- 痛点二:给复盘页面传数据时,前端根本不需要后端的JSON全文,它只需要若干标量字段。塞JSON只会让传输体积暴涨。
最终的原则是:需要筛选的、需要排序的、需要统计的数据,一律独立字段;纯展示用的、永不参与计算的辅助信息,才允许用JSON或文本字段。比如"这一局的牌谱描述",我会存JSON;但"本局是否封顶"这种参与排名的标记,必须独立字段。
4. 局内计分:录入效率和技术实现的平衡
局内计分是这个系统里最影响用户体验的部分。打牌的人在等,录入的人不能掉链子。如何让一次录入在10秒内完成,是我反复琢磨的核心问题。
4.1 录入界面:数字键盘和"复制上一局"的巧思
我见过很多计分单据的录入界面,毛病往往出在让用户从列表里找人、再手动输入数字、还要点几个下拉框。打了一次牌之后,用户根本不想再打字。我做的录入界面是这样设计的:
- 默认按上一局的座位顺序排列玩家,不用重新挑选。
- 四个得分输入框并排,默认值为0,负数可以直接输入减号再输数字。
- 最下方是"本局特殊标记"的快速按钮:自摸、杠上开花、封顶等,点一下打上标记。
- 一个"确认录入"按钮,支持回车快捷键。
这里我要特别推荐一个细节:支持"复制上一局"。如果连续两局的结果一样——比如都是同一家赢、分数相同——录入员只需要点一下"复制上局结果",再视情况微调。实测下来,这个功能平均为每局节省了40%的录入时间。
4.2 实时校验:规则引擎挡住的那些手滑
刚才提过规则集用于校验录入值。这里展开说说校验的完整粒度,因为我见过不少系统只做了"非空校验",等于没做。
校验分三层次:
- 第一层:得分之和必须等于0(或等于规则集设定的"局分守恒值")。四人牌局中,如果有人赢了100分,其他三人-100、0、0,和值不是0,说明某个数填错了。这层校验能拦住80%的手误。
- 第二层:每位玩家得分不能超过规则集设置的单局上限/下限。如果你设置封顶60,任何一人的得分绝对值超过60都会被拦截。
- 第三层:逻辑自洽校验。比如标记了"自摸",那么得分最高者必须和标记所指的玩家是同一人。
第三层校验我是在实际使用中发现漏漏的。有一局麻将,A玩家点炮给B玩家,但录入员手滑选了"自摸"标记。得分本身没错,局分守恒也满足,但特殊标记与赢家对不上。如果不校验,复盘时数据分析就会被带偏。加了这层之后,基本杜绝了低级错误。
4.3 局内实时视图:当前排名和"本局点评"
录入完一局,系统立刻更新场次排名视图。这个视图不仅在结算时需要,在现场也很重要——大家打完一局都想知道现在的总排名。
实时视图包含的内容:
- 当前各家累计得分,按总分降序排列。
- 各家的胜局数、负局数、零分局数。
- 最近五局的得分趋势(用最简单的空间柱形图展示)。
- 本局点评:自动生成的简短分析,比如"第7局后,张三从第2名升至第1名,主要原因是第7局赢了120分"。
"本局点评"这功能是我后来加的。用户反馈说,光看数字看不出比赛进程的精彩之处,但配上简短点评,很多经典翻盘局一眼就能跳出来。这个点评不搞AI生成,就是用模板加拼装,比如拼上"第N局""赢家名字""得分绝对值""排名升降方向"几个变量。
5. 局外结算:看起来简单,其实暗坑都在边角
录制完最后一张牌局表,点一下"结算并复盘",系统走结算流程。这里没有复杂算法,但边界情况的处理决定了系统的稳定性和口碑。
5.1 结算标准流程与积分换算
结算流程三步走:
- 汇总所有牌局记录,校验局分守恒。如果存在不守恒的记录,返回错误并锁定该局等待修正。
- 按牌局明细里的"本局结束后累计得分快照",确定最终排名。这里有个关键决定:最终排名的依据是"最后一张牌局的累计得分快照",不是把所有牌局正负分加起来重算一遍。两者在数据上一致,但使用快照可以避免因"某局被撤销修改"导致排名错乱。
- 按预设的积分规则,把最终得分换算成赛事积分。常见的换算是分段线性:第一名加10分,第二名加6分,第三名加3分,第四名加1分。有的比赛还要乘上"参与系数"或"桌号权重",这些都可以在规则集里配。
积分换算这一块,我从线下跑步赛事那套"名次积分表"借鉴了思路。你不需要让系统理解比赛语义,只需要给它一张"名次->积分"的映射表,再加一个可选权重系数,就能覆盖绝大多数比赛场景。
5.2 中途有人退出怎么办
这是我在实际运营月度赛时真实遇到的场景。比赛进行到第9局,选手C临时有急事要走,剩下三家没法继续。如果直接结算,C的成绩保留已打9局的结果;如果直接取消本场次,A和B已经到手的领先优势就白打了。
我的处理方式是给场次增加一个"结算模式"字段:
- 完整结算:全员打到最后一局。
- 提前结算:某人中途退出,系统按已打局数正常结算,退出者成绩照算。
- 作废处理:场次作废,所有统计数据回滚到本场次起始状态。
撑腰这个设计背后的逻辑是:计分系统应该尊重现实世界的不完美,而不是假定所有比赛都按剧本完美完成。数据模型和结算流程必须支持"不完美的比赛"。
5.3 结算页面的"可追溯性"
结算页面不只是显示一张排名表,还要提供每一项排名的追溯依据。看到第2名和第3名只差2分时,观赛者肯定会问:这2分在哪一局拉开的?
所以结算页面每个名次后面都有一个"查看明细"入口,点击后展示该选手参与的全部牌局列表、关键翻盘局、和与前后名的分数差距来源。这个做法最初是受电商订单系统的"订单追溯"功能启发——每一项优惠扣减都能找到来源。放在计分场景里,可追溯性就是信任本身。
6. 复盘模块:数据到洞察,差着一个"对比"的距离
结算完成只是第一步。整个项目最有价值的增量在于"复盘"。复盘让用户不再只是看见"谁赢了",而是理解"为什么赢、怎么赢的"。
6.1 排名曲线的绘制逻辑
排名曲线是最直观的复盘图表,每局打完一个点,纵轴是排名,横轴是局序号,四家各一条线。
实现上存在一个容易被忽略的坑:排名本身是同一时刻的快照,不能一条一条叠加画,而是每一局扫描全部选手的累计分然后统一排序再落点。如果作为图表服务端的数据是按单条流水推送的,前端就会出现"第7局的张三排名还没更新但李四排名已更新"的错位。解决办法是我的数据模型已经存了快照字段,复盘查询时一次性取回整个场次的全部快照,再在内存中完成排序逻辑。
6.2 逐局得失分布:看运气还是看实力
用一组堆叠柱状图展示每人的单局得分分布,把胜局、平局、负局分别涂成不同颜色。这个图表的最大价值是快速区分"运气型领先"和"实力型领先"。
举个例子,A选手总分第一,但他的得分分布很散——三局大胜,其余全是小负;B选手总分第二,但局局小胜,只有一局失利。从竞技角度说,B的稳定性更值得学习。复盘页面会生成一段文字说明,把这个洞察直接点出来,而不是让用户自己对着一堆柱状图发愣。
6.3 关键转折局自动识别
这是复盘模块里最聪明的功能,也是花费心思最多的环节。实现思路不复杂:遍历每一局的快照字段,记录下"排名变化量"的分值。变化量绝对值最大的那一局,就是"关键转折局"。
比如整场12局比赛,第9局结束后,原本第4名的D直接跳到第1名,排名变化量为3,这就是绝对转折点。自动生成的分析文字:"第9局为本场关键转折局,D在该局净胜98分,排名从第4上升至第1。"我没有用任何复杂算法,只是抓住"排名变化的快照差"这一核心指标,就达到了很好的复盘效果。
6.4 导出与分享
复盘结果导出成图片,方便发到群聊。同时支持导出完整Excel,包含全部牌局流水、排名快照、关键转折局标记。Excel的格式我在细节上打磨了很久,表头固定、冻结首行、每列加筛选按钮,加粗突出总分列。这些细节让导出文件真正有人用,而不是导出来就躺在聊天记录里吃灰。
7. 部署与运行环境:不折腾的方案
做这个小系统,我没有上K8s也没有用微服务,而是选择了最简单的部署方式,因为使用场景决定了没必要复杂化。
7.1 技术栈选择与理由
技术栈如下:
- 后端:Python + FastAPI,数据库:SQLite(单文件模式),生产环境切到PostgreSQL。
- 前端:简单响应式页面,用原生HTML + 轻量JS框架,不做重型SPA。
- 部署:一台几百块钱的轻量服务器,或者直接用内网NAS跑Docker。
为什么选SQLite起步?因为几十人的比赛,读写压力极其有限,SQLite单文件模式备份方便——拷走文件就等于备份了全部数据。当数据量真正大起来时再切PostgreSQL,SQLAlchemy中间层让切换成本极低。没有一上来就在PostgreSQL上死磕,省下了大量运维精力。
7.2 权限设计:录入员和裁判的区分
录入员只能录牌局、修改已录但未结算的牌局;裁判(管理员)可以执行结算、作废场次、管理选手名单、修改规则集。权限机制用最简单的角色字段实现,没有上OAuth。
但这块有一个值得说的坑:录入员修改已录牌局时,系统必须同步刷新本场次所有牌局的快照数据。这就引出一个连锁校验方案——修改任何一局,都必须跑一遍从该局之后到结算前的快照重算,写入日志。否则容易看到"总分和排名对不上"的诡异现象。
7.3 数据备份与容灾
我的备份策略很朴素:每天自动打包数据库文件上传到NAS,每周手动导出一份完整的Excel至网盘。因为比赛数据只有那么几十MB,太复杂的备份方案反而是负担。但别忘了:一旦系统承接了别人家的月度赛、季度积分赛,数据就不仅是自己的了。备份要有,恢复演练也要有。我做过一次恢复测试,直接在另一台机器上起容器挂载备份文件,检查数据完整性,20分钟内搞定。这个操作简单,但建议每个搭建系统的人都做一遍。
8. 实测效果与真实反馈
系统做完以后,我组织了三次内部测试:一次自用长牌局、一次朋友麻将局、一次小区月度赛事。三场下来数据质量非常好,局分守恒校验一次没触发误报,复盘页面被夸得最多。
8.1 局内计分快了多少
老朋友用之前,录入一局平均耗时在40秒到1分钟——为什么这么慢?因为他们一边打一边掏出另一个App算分,还要对比纸上的记录,算完再手动录进去,反复核对,中间还可能聊两句。换这套系统后,录入一局平均12秒,熟练后能压到8秒。省下的时间,刚好够大家打完一局之后聊两句闲天,节奏舒服很多。
8.2 结算环节的意外发现
用户使用中有一个谁也没想到的高频操作:撤销最后一局。打牌时偶尔会出现"这局不算了"的情况——比如有人发现发错牌、有人中途接电话、有人想重打一局。如果系统不支持撤销最后一局,整个结算就会卡住。我把"撤销最后一局"做成了场次页面的独立按钮,带有二次确认弹窗。这个功能在三次实测中用了不下十次,如果没做,赛程会非常尴尬。
8.3 复盘带来的变化
月度赛主办人反馈,以前比赛结束,大家只知道名次,但谁也说不出第几局是转折点。有了复盘页面后,赛后讨论明显热闹了。A说"我从中场开始连续赢",B不服说"你看排名曲线,第9局那波才是关键"。连不会打牌的后勤人员,也能从图表里看懂比赛进程。
9. 整个过程最值钱的三条经验
项目做完后,我复盘了整个开发和试运行过程,有三条经验值得分享给正准备做同类系统的朋友。
第一,先划分"局内"和"局外"两层,再动数据库设计。很多人拿到需求直接建表,结果要么表结构太细碎,要么一把梭JSON。先明确局内是高频实时的小事务,局外是低频全局的大聚合,数据模型自然就能设计得清晰。
第二,冗余快照是复盘功能的命脉。如果当初没有在各局记录后面存下"当时累计分"和"当时排名",后期做复盘模块必然痛苦。复盘功能不是锦上添花,它是把这个计分工具从"记分本"升级成"比赛分析系统"的关键分水岭。设计的时候一定为未来的复盘留好数据。
第三,边界情况的处理比主流程更能打动用户。我花在处理"中途退出""撤销最后一局""局分不守恒报警"上的时间,比写主流程多一倍,但正是这些细节,让用户觉得"这个系统是真的被用过的"。一个没有任何边界处理逻辑的计分系统,更像玩具,不像工具。
10. 后续还能怎么扩展
目前这个系统已经稳定跑了两个月,积累了可观的场次数据。我接下来的规划有两个方向:
第一个方向是多人团队的赛季积分系统。现在的结算只支持单场次,赛季概念还没有引入——其实只需要加一张"赛季表",然后把场次归入赛季,同一赛季内做加权累计即可。这会让小区月度赛的年度总冠军评选变得非常顺畅。
第二个方向是移动端适配优化。现在的页面在手机上能跑,但录入界面在大屏幕下其实更顺手。后续计划做一套微信小程序版,专门把录入流程优化到极致——比如扫码进桌、语音输入分数。这个工程量比现在的系统大不少,但方向是明确的:把录入摩擦降到最低,把复盘的洞察做到最透。
如果你也在琢磨类似的线下比赛计分工具,我的建议很直接:别一上来就追求大而全,先把手动录入加棋盘记录加基础结算这三点跑通,再逐步叠加复盘和赛季功能。小步快跑,每加一个功能都真刀真枪拉人测一次,比憋一个大版本再发布要靠谱得多。