「零碎百宝箱」微信小程序有个麻将计分的工具,四个人同时看着同一张计分表,其中两个人可能在一秒内提交不同结果。麻将计分看起来只是加减法,真正的难点却是共享状态:谁在房间、当前是第几局、谁有权修改,以及重复操作发生时相信谁。
一、为什么麻将不能照搬普通记录同步
倒数日和健康打卡可以采用"本地先写、稍后上云",因为冲突通常只涉及同一个人在不同设备上的修改。麻将房间则完全不同:
- 多名成员同时观察同一份成员列表和流水;
- 一次提交会改变所有人的总分;
- 两人重复提交同一局不能产生两条流水;
- 结算后所有客户端必须停止继续记分;
- 服务端必须验证总分,而不能相信某一台手机计算的最终结果。
因此麻将工具是项目中唯一的"联机优先"工具:服务端是事实来源,客户端只保存身份和展示状态,不在离线时伪造一个可稍后合并的房间。
在「零碎百宝箱」的一众轻量工具中,它也是最接近实时协作系统的一个。
二、房间模型:状态、流水与归档分离
服务端没有把整场牌局塞进一个 JSON 字段,而是拆成几类关系:
Round.settlement_id 为空代表进行中,结算时只需把这些局挂到新建的 Settlement,无需把数据从"当前表"搬到"历史表"。SettlementScore 保存结算时每个人的总分快照,后续个人战绩可以从 (openid, settlement_id) 索引直接查询。
这种"流水保留细节,结算保存快照"的结构兼顾了两种读取:牌桌页面按局展示,历史页面按场聚合。
三、扫码进房只是入口,身份仍来自 JWT
分享房间时,服务端调用微信 getwxacode 生成带房间路径的小程序码。微信 access token 缓存在进程内,并提前五分钟过期,避免每次生成二维码都重新换 token,也降低在过期边界使用旧 token 的概率。
二维码只携带房间入口,不携带可信用户身份。用户打开小程序后仍经过统一微信登录,服务端从 JWT 得到 openid。首次建档时系统从麻将牌名和称谓中随机生成昵称,例如"东风侠";之后用户可修改并持久化。
这条边界很重要:二维码适合传递"去哪个房间",JWT 才回答"你是谁"。如果把 openid 或成员权限直接放在二维码参数中,任何人都可以构造链接冒充成员。
四、低频实时:带版本号的轮询
牌局计分不需要每秒数十次更新,WebSocket 会引入连接管理、断线恢复和网关配置等额外成本。项目采用每隔一段时间拉取房间快照的方式,并用递增版本号减少无效传输。
客户端第一次请求:
http
GET /room/state?roomid=ABCD
服务端返回成员、流水、累计分和 version=12。下一次客户端带上版本:
http
GET /room/state?roomid=ABCD&v=12
如果房间没有变化,只返回:
json
{ "version": "12", "changed": false }
成员加入、离开、提交、撤销或结算时,服务端统一调用 _touch_room 增加版本。这样一次轮询就能判断是否需要替换整个页面状态,避免旧实现分别请求成员和流水。
这个方案非常适合"更新频率低、允许秒级延迟、状态快照不大"的协作场景。若未来要加入出牌过程、聊天或观战,再考虑 WebSocket 才有实际收益。
五、一次记分同时使用乐观检查和悲观锁
客户端提交一局时必须带上自己看到的房间版本:
json
{
"roomid": "ABCD",
"version": "12",
"scores": [20, -10, -5, -5]
}
服务端处理流程如下:
这里同时用了两种控制:
- 乐观版本发现客户端依据的状态已经过期,给出"房间状态已变动,请刷新";
- 数据库行锁把同一房间的写操作串行化,确保检查版本、生成局号和写入流水处于同一事务。
此外,(roomid, circle) 唯一约束是最后一道数据库防线。即使应用层出现遗漏,两个相同局号也无法同时落库。
只使用版本号而不加锁,会出现两个事务同时读到 12、都通过检查的问题;只使用行锁而不要求客户端版本,则第二个提交可能在不知情的情况下基于旧成员顺序写入。两者解决的不是同一个问题。
六、零和约束必须由服务端验证
每局的所有得分必须相加为零:
python
if len(scores) != len(members):
raise BusinessError('成员数量发生变化')
if sum(scores) != 0:
raise BusinessError('得分合计须为 0')
零和检查既是业务规则,也是数据完整性检查。没有它,少填一个负分就会凭空制造总分,历史统计将永久失真。
服务端把数组按当前座位映射为 RoundScore,而不是接受客户端传入任意 openid。这样客户端只能为当前房间成员填分,不能给房间外用户构造流水。
累计分直接由数据库聚合:
sql
SELECT openid, SUM(score)
FROM round_scores
JOIN rounds ON rounds.id = round_scores.round_id
WHERE roomid = ? AND settlement_id IS NULL
GROUP BY openid;
最终结算只接收倍率,不接收总分。服务端重新聚合进行中流水,乘以限制范围内的倍率后写入 SettlementScore。客户端即使篡改页面显示,也无法改变历史结算结果。
七、撤销和结算也必须面对并发
撤销接口只允许删除最后一局,并可携带客户端看到的 circle 做一致性校验:
- 服务端最后一局比参数小,说明别人已经撤销;
- 服务端最后一局比参数大,说明别人又提交了新局;
- 只有相等时才删除目标流水。
结算同样先锁住房间,并检查是否已经存在结算记录。它在一个事务内完成:
- 聚合所有未归档局;
- 创建结算与个人总分快照;
- 将进行中局的
settlement_id指向本次结算; - 清空成员房间关系;
- 增加房间版本并提交。
如果另一个成员同时点击结算,后获得锁的请求会看到房间已经结算并失败,不会产生两份历史。
"解散"和"结算"也被明确区分:没有任何记分时可以解散且不产生历史;已有流水时必须结算。这个限制避免误操作把已经发生的牌局直接删除。
八、从流水生成个人战绩
结算快照使"按人查战绩"变得简单。服务端可以计算总分、场次、胜场和对局天数,还能按同桌成员筛选。
项目还计算了 withGuys:当一场中有多个赢家或输家时,按照正分总额的占比,把自己的输赢近似分摊到对手身上,用于回答"我主要赢了谁、输给了谁"。这不是财务结算,只是基于零和得分的统计解释,因此使用整数四舍五入即可。
业务统计应从不可变的结算流水推导,而不是在每次操作时维护一堆容易漂移的累计字段。前者查询略复杂,却更容易校验和重算。
九、适用边界与演进方向
版本轮询不是实时协作的通用答案,但在牌桌计分中非常合适:
- 房间人数少;
- 写入频率低;
- 秒级同步可以接受;
- 每次状态快照体积有限。
后续若提高规模,可以继续演进:为长轮询增加 ETag 语义;把历史流水分页;给房间设置过期清理;对二维码生成做持久缓存;为冲突响应增加稳定错误码,让前端自动刷新后提示用户重新确认。
这套实现最值得复用的地方,是没有把"多人同步"简单理解为多发几次请求。共享状态必须定义事实来源、版本、事务边界和不变量。只要这四件事清晰,轮询也能构成可靠的协作系统。