做无人台球室系统这个项目,起因挺有意思。我此前帮朋友打听过台球厅的运营成本,发现人工是三班倒的大头,夜班不能关,又不可能每天都让店员通宵盯场。后来接了一个模拟项目X,目标很直接:把前台收银、计时、计费、核销这一套重复劳动全部干掉,让用户扫码就能开台,到点自动扣费结账。顺便,台球室本来就是强社交场景,散客打完一局经常想找人再开一局,所以这套系统里还要带上"交友组局"的轻社交能力。整套东西用 Java 技术栈落地,既是完整的商业项目原型,也是练手无人值守业态的绝佳样例。
这篇文章我会从项目需求拆解讲起,把系统架构、数据库设计、自助开台流程、交友模块、核心代码实现和几个真实踩坑的排查过程全部过一遍。适合正在做毕业设计或课程设计的学生参考,也适合想转行做新零售、共享经济、自助服务类业务的后端开发者阅读,甚至开台球室的老板也可以看看这套系统的流程设计,拿去和软件方谈需求时心里更有底。
1. 项目拆解:无人台球室到底需要哪些模块
1.1 传统台球室的痛点与无人化改造方向
台球室和健身房、自习室类似,核心营收靠场地时长,但业务流程比自习室复杂不少。传统模式下,用户进店找前台开台,前台登记桌号、押金、开始时间,用户打完再找前台结账,按小时算钱,超时另算。这个流程看着简单,实际运营里有几个很要命的痛点:
人力成本压不下去。一个小型台球室至少需要两人倒班,24小时营业的话至少三到四人的班次成本,一年下来二十万起步。夜班时段可能一晚上只有两三桌客人,但店员必须守着,成本摊下来非常高。
高峰期容易出错。生意好的时候,前台同时处理开台、结账、买饮料,经常出现漏记时长、押金纠纷、桌台状态混乱的问题。客人说打了四十分钟,系统记录一小时,扯皮成本极高。
闲时桌台利用率低。散客到店后发现想玩的桌台有人,或者找不到搭子,往往转头就走。传统台球室缺少桌台在线状态和用户之间的连接能力。
无人化改造的方向,不是简单省掉前台,而是要把一整条"选台-开台-计费-结账-售后"的链路数字化,同时把台球室的社交属性在产品上补起来。这套系统要同时服务C端用户和管理端运营者,还得能对接支付、小程序等外部平台。
1.2 系统功能全景:自助开台 + 交友 + 管理后台
系统功能按角色划分比较清晰。用户端(小程序)承担选台、开台、支付、计时、结账、组局、交友的操作;管理端(Web或后台)承担桌台管理、订单管理、营收统计、用户管理和审核功能。
具体拆开看,核心功能模块有这些:
- 桌台实时状态管理:每张台球桌有唯一编号、位置、类型(中式八球、斯诺克、九球等)、单价、状态(空闲、使用中、待清理、维护中)。用户打开小程序就能看到实时空闲情况,不用到店才知道有没有位置。
- 自助开台流程:扫码进入小程序、选择桌台、设置使用时长、支付押金和预付费用、开台成功、倒计时自动运行。
- 动态计费引擎:按分钟计费,支持按时长预付、超时补扣、优惠券抵扣、会员折扣等策略。核心是能准确计算"用了多久、应该扣多少、还剩多少押金"。
- 支付与退款体系:对接微信支付,处理下单支付、押金冻结、订单结算、余额退款,以及最关键的异步回调通知。
- 交友与组局模块:用户设置台球偏好、水平、在线状态,可发起或加入"组局"邀约,组局成功后可以直接开台。
- 消息通知:开台成功、超时提醒、组局邀请、结算通知等场景,通过小程序订阅消息或短信触达用户。
- 后台管理:管理员维护桌台、查看实时单、处理售后、查看营收报表、审核举报和实名信息。
1.3 目标用户与典型使用场景
这套系统的目标用户有两层。终端用户是台球爱好者,尤其是夜间活跃的年轻人,他们的核心诉求是"到店即打、不用等人、打完就走、还能找到球友"。运营端是台球室老板和店长,他们关注的是成本降低、营收透明、桌台利用率提升。
典型场景我在项目调试时反复模拟过。晚上十点半,用户A在微信小程序里看到附近某台球室还剩两张空桌,选了一张中式八球桌,预付两小时共56元,另付20元押金,系统自动开台。打到第四十八分钟,A想续时,直接在手机上点了"续时1小时",补扣费用后计费顺延。两个多小时后A离场,系统按实际使用时长2小时15分结算,因超过预付时长,从押金中补扣超时费用,剩余押金自动原路退回。与此同时,用户B在"找球友"里看到A在线且台球等级相近,申请加入,A确认后B的预约信息关联到A的桌台,B到店后前台扫码确认或直接由A在后台添加副桌人。
这个链路走完,用户全程没和店员说过一句话。
2. 技术选型与整体架构:为什么用这套Java技术栈
2.1 后端技术栈:Spring Boot + MyBatis-Plus + MySQL + Redis
后端选用 Java + Spring Boot 是稳妥的选择。Spring Boot 生态成熟,招人容易,资料多,适合快速搭建业务系统。持久层用 MyBatis-Plus,单表 CRUD 基本不用写 SQL,复杂的统计查询自行写 XML 或注解 SQL,开发效率比纯 MyBatis 高不少。
存储层用 MySQL + Redis 配合。MySQL 负责订单、用户、桌台、支付流水等核心业务数据的持久化,事务能力强,适合财务相关数据的落库。Redis 承担两类职责:缓存热点数据,比如桌台状态、用户会话;充当分布式锁和计数器。开台、续时、抢占这类高并发写操作,必须依赖 Redis 的原子性来保证数据一致。
缓存与持久化的边界要划清楚。桌台的实时状态、订单的剩余时长、押金冻结标记等高频变更数据,优先操作 Redis,再异步同步到 MySQL;支付流水、订单完成态这类具备法律效力和对账价值的终态数据,必须直接落 MySQL。这样既保证了性能,也保证了最终一致性。
2.2 前端与客户端选型:小程序优先
客户端我选了微信小程序而非独立App,原因很实在:台球室的用户场景是低频但刚需,没人愿意为一个偶尔去的球厅单独装一个App。小程序即扫即用,天然适配"扫码开台"这个入口,同时微信生态的支付能力、订阅消息能力和用户身份体系都现成,省去注册登录的摩擦成本。
管理后台用 Vue3 + Element Plus 搭建,单独部署给店长用。后台不涉及C端高并发,简单做下权限控制即可。
另外还要讲一下硬件侧。无人台球室的"无人"不能全靠软件,现场需要智能电源控制器控制球桌灯的开关,扫码开台成功后自动通电,结账后自动断电。系统通过HTTP接口或MQTT协议控制电源硬件。这块我在模拟环境中用了一个虚拟电源桩来联调,接口设计上预留了足够的扩展空间,真实部署时换成对应厂商的SDK就行。
2.3 系统架构与模块划分
从架构图上讲,项目是标准的单体应用加模块化分包,没有一开始就上微服务。原因很简单:项目规模和团队体量下,微服务的拆分成本远大于收益,单体应用只要包结构清晰,后续拆服务也非常容易。
模块划分如下:
controller层:接收小程序端和管理端请求,做参数校验和协议转换。service层:业务逻辑核心,包括开台服务、计费服务、支付服务、组局服务等。mapper层:数据访问层,对应 MyBatis-Plus 的 Mapper 接口。entity层:数据库实体映射。common层:公共工具、全局异常处理、统一返回结构、枚举定义。config层:Redis、MyBatis-Plus、微信支付等配置类。task层:定时任务,处理超时未支付、超时未离场、退款状态查询等。mq层:如果有需要异步处理的业务,引入消息队列。实际项目中订单量不大,我用 Redis Stream 临时充当轻量MQ,没有直接上 RabbitMQ 或 Kafka。
这种模块划分的好处是,任何人拿到源码都能快速定位到某个功能对应的分层位置,对学习者友好。
3. 数据库设计:把台球室业务模型一次捋清
3.1 核心实体关系概览
数据库设计是这类系统的地基,表设计得好不好,直接影响后续开发的难易程度。围绕业务流程,核心实体包括:用户、桌台、订单、支付流水、押金流水、退款流水、交友关系、组局、消息通知、操作日志。实体之间的关系可以这样理:
- 用户与订单:一对多,一个用户可以有多个历史订单。
- 桌台与订单:一对多,一张桌台被多次开台,但同一时刻只能有一个进行中的订单。
- 订单与支付流水:一对多,一个订单可能包含预付款、补扣款、押金等多笔支付记录。
- 用户与好友关系:多对多,通过中间表关联。
- 组局与订单:一对一或多对一,一个组局最终落在一个桌台和一个订单上。
- 用户与桌台:"副桌人"概念,允许一桌多人,主开台人为订单主用户,其他人通过组局或邀请关联到订单。
3.2 用户表与桌台表设计
用户表重点要考虑"虚拟身份与真实身份分离"。台球室的社交场景涉及交友,必须保证安全合规,但又不希望强制用户上传身份证。我的做法是用户表里存基础虚拟资料,另建一张实名认证表,用户发起组局或被人邀约前必须完成实名,实名信息与微信号、手机号绑定。
用户表核心字段:
| 字段 | 类型 | 说明 |
|---|---|---|
| id | bigint | 主键 |
| phone | varchar | 手机号,登录账号 |
| openid | varchar | 微信小程序openid,用于支付和消息 |
| nickname | varchar | 昵称 |
| avatar | varchar | 头像URL |
| gender | tinyint | 性别 |
| skill_level | tinyint | 台球水平等级,1新手 2进阶 3高手 4大神 |
| play_style | tinyint | 打法偏好,进攻型/防守型/全能型 |
| location | varchar | 常驻位置(城市或商圈),用于同城推荐 |
| status | tinyint | 用户状态,正常/拉黑/封禁 |
桌台表相对简单,但状态字段必须设计成可扩展的枚举。桌台状态不是非空即忙,还存在"待打扫""设备故障""锁定"等中间态。例如用户结账离场后,桌台不能立刻设为空闲供下一个人开台,需要进入"待打扫"状态,由保洁或晚班管理员确认后才恢复空闲。自动打扫预留时间可以配置,比如十分钟。
桌台表核心字段:
| 字段 | 类型 | 说明 |
|---|---|---|
| id | bigint | 主键 |
| table_no | varchar | 桌号,展示用 |
| table_type | tinyint | 类型,1中式八球 2斯诺克 3九球 |
| price_per_hour | decimal | 每小时价格,单位元 |
| position | varchar | 位置描述,如A区01号 |
| status | tinyint | 桌台状态枚举 |
| power_switch | tinyint | 电源控制状态,0断 1通 |
| is_delete | tinyint | 逻辑删除标志 |
这里有个设计教训:桌台状态一定不能用varchar存中文,一定要用tinyint枚举。后续如果要加状态,只需要在枚举类里加一个值,改数据库成本更低,否则极容易飘数据。
3.3 订单表与支付流水表设计
订单表是整个系统的核心,承载开台、计时、计费、结算全链路。状态机设计必须周密,任何一次状态跳转都要能对账。
我的订单状态枚举是:
CREATED:下单待支付,系统预留桌台,倒计时5分钟未支付自动释放。PAID:已支付,开台成功,计时中。PENDING_SETTLE:用户发起结账或系统判定超时,等待结算。SETTLED:已结算,计算实际费用,多退少补。CANCELLED:用户主动取消,桌台释放。REFUNDED:已退款,订单终态。CLOSED:订单关闭,终态(未支付超时关闭或异常关闭)。
这张表我额外加了几个容易被忽略的字段:prepaid_minutes(预付时长)、actual_start_time(实际开始时间)、actual_end_time(实际结束时间)、timeout_flag(是否超时标识)、platform_coupon_id(优惠券)。这些字段在计费和统计时非常关键,强烈建议不要省略。
再就是金额字段的精度问题。订单金额使用 decimal(10,2),数据库中一律以分为单位存储还是以元为单位存储都能做,但必须统一约定。我最后选择统一以分为单位存储整形bigint,展示层再转成元。这样可以避免浮点数计算误差,也方便对接支付接口的金额参数。
支付流水表记录每一笔资金的流向,包括用户支付预付款、补缴超时费、支付押金、系统退款。表中要有biz_order_no和out_trade_no两个核心业务号:前者是内部订单号,后者是微信支付商户订单号。回调处理时靠这两个号完成幂等校验。同时加上pay_type(预付、押金、补扣、退款)、channel(微信支付等渠道)、status(支付中、成功、失败、异常)。
3.4 交友关系与组局表设计
交友关系表是对称关系表,核心字段是user_id、friend_user_id、status,status区分待验证/已通过/已拉黑。查询好友列表时只要查:select * from friend_relation where user_id = ? or friend_user_id = ?,再排除自己即可。这张表不复杂,但必须加唯一索引防重复申请。
组局表是社交与业务结合的纽带。用户发起组局时,可以选择一个桌台或仅发布需求(不带桌台)。组局表核心字段包括:
| 字段 | 类型 | 说明 |
|---|---|---|
| id | bigint | 主键 |
| inviter_id | bigint | 发起人用户ID |
| table_id | bigint | 关联桌台ID,可为空 |
| target_time | datetime | 期望开始时间 |
| duration_minutes | int | 期望时长 |
| max_players | tinyint | 最大参与人数 |
| current_players | int | 当前已加入人数 |
| requirement | varchar | 对球友的要求描述 |
| status | tinyint | 招募中/已成局/已取消/已过期 |
组局成局后,由发起人一键落为正式订单,所有参与者作为副桌人关联。这个设计的好处是把"交友"和"开台"打通,组局既能促成社交,又直接带动开台。
4. 自助开台核心流程:从扫码到计时计费
4.1 自助开台的完整链路
用户扫码后到达小程序首页,第一眼看到的是桌台状态列表。列表展示每张桌的类型、位置、状态和单价,空闲桌可以选,忙碌桌直接置灰。选桌后进入开台确认页,这里有几个关键交互:
- 选择预计使用时长,默认1小时,用户可根据需求加时。
- 系统实时计算总费用:预付时长费用 + 押金金额。
- 展示计费规则说明:超时费用、押金退回条件。
用户确认并支付后,订单状态从CREATED变为PAID,系统调用电源控制接口给桌台通电,同时启动计时引擎。开台成功页展示桌号、开台时间、计划结束时间、倒计时状态。
这个链路做个接口拆解:
1. GET /api/table/list -> 获取可用桌台列表 2. GET /api/table/detail -> 获取桌台详情与计费规则 3. POST /api/order/create -> 创建待支付订单(含预付费用与押金) 4. POST /api/order/pay -> 发起微信支付 5. POST /api/pay/callback -> 支付成功回调(幂等处理) 6. POST /api/order/start -> 开台生效,更新桌台状态与电源状态 7. GET /api/order/detail -> 实时查询剩余时间与费用4.2 计费策略设计与实现
计费是这类系统的灵魂。台球室计费的特殊性在于"低单价、高频次、长时长",分钟级精度才够公平,用户才愿意长时间使用。如果用小时整段计费,用户打了45分钟却要付1小时的钱,体验差、争议多。但我又不能直接松到"按分钟从开始结算到结束",因为用户可能会买个奶茶、抽根烟磨掉很长时间,商家亏。
最终采用的策略是"预付时长 + 超时补扣 + 宽限期"。
具体规则如下:
- 开台时用户选择预付时长,按分钟计算,预扣对应费用。
- 实际使用时间小于等于预付时长:按实际时长计费,剩余预付款退回用户余额,或者留作下次使用(这里我选择了退回原路,更符合用户预期)。
- 实际使用时间超过预付时长:超时部分按分钟单价从押金中扣除,扣完押金仍不够的进入"欠费挂账"状态。
- 结账时设置一个15分钟宽限期,超时15分钟内不算超时费,超过15分钟后才开始按分钟补扣,目的是规避用户在结算流程中花了额外的时间。
计费实现的载体是 Redis 中的一个order:{orderId}:left_minutes键,开台时初始化值为剩余秒数,一个后台守护线程会定期将其递减。倒计时归零后,如果用户还未结账,系统自动发起结算流程。这种"反向倒计时"设计比每次查询当前时间再做差更方便推送提醒,也方便控制电源开关。
真实项目中要注意的是,扣费不应该放在回调或定时任务里一把梭。我的建议是设计一个独立的计费引擎服务,统一处理开台扣费、超时扣费、结算退款,并且所有金额计算都基于actual_start_time和actual_end_time做重算,不能信任前端传来的时长数字。
4.3 押金管理、续时与结账退费
押金管理有几种方案:实扣押金、冻结额度、免押金。实扣押金最稳妥,用户支付一笔可退押金,结账时多退少补。缺点是用户心理负担重,尤其是新用户,可能因为押金门槛放弃使用。冻结额度需要支付能力配合,微信支付在小程序端不太好做预授权冻结,实际操作复杂。免押金方案最友好,但对商家风险大,用户打完就跑或损坏球杆损失无法追回。
我做的是分级押金策略:用户信用良好(历史订单无欠费、无投诉),押金减半或免押;新用户正常收取。信用评分规则在后台可配置,通过一套简单的加减分机制动态调整。
续时逻辑比较简单。用户点击续时,后端校验当前桌台状态和剩余时间,生成补缴订单,用户支付成功后更新订单的prepaid_minutes并刷新 Redis 倒计时。这里有个并发问题,如果用户同时打开续时页面和结算页面,后端必须做状态校验,防止续时后立刻结算导致数据错乱。我的处理是在开台订单上加了一个乐观锁版本号version,每次修改先比较版本号再更新。
结账退费的流程如下:
- 用户点击结账,或系统判定超时触发自动结账。
- 计算实际使用时长。
- 计算预付费用与实际费用的差额。
- 若有剩余预付款,原路退回。
- 若有超时费,从押金中扣除。
- 剩余押金原路退回。
- 订单标记为
SETTLED,桌台状态改为"待打扫"。 - 调用电源控制接口断电。
4.4 异常场景处理:超时、断电、换桌
异常处理是无人系统最考验细节的地方。
超时场景前面提到过,主要是自动结算能不能兜底。用户离场后没点结账,系统必须自动扣费并关闭桌台。我实现了一个定时任务,每分钟扫描一次 Redis 中余额为0但状态还是PAID的订单,触发自动结算。同时考虑用户体验,决定不直接扣款,而是先发一条"即将自动结账"的订阅消息,用户在5分钟内不取消,系统才执行扣费。
断电场景指的是无人台球室如果现场有人蓄意切掉智能电源,桌台断电后系统失去联控能力。针对这一点,我加了一个心跳监测机制:桌台的电源控制器每15秒回传一次心跳,如果心跳中断超过2分钟,系统判定该桌台离线,自动将订单标记为休眠态,并通知管理员现场检查。休眠态不会产生超时费用,避免因设备故障让用户蒙受损失。
换桌场景在传统台球室很常见。用户从A桌换到B桌,涉及订单迁移、价格差异、时间衔接。系统实现时保留原订单记录,注销原桌台状态,同时新建订单并扣除换桌手续费(可配置,默认0元)。换桌要求当前订单无超时欠费、新桌台处于空闲状态,否则不允许操作。
5. 交友系统实现:以球会友的轻社交设计
5.1 交友系统定位与安全边界
做交友系统之前必须想清楚一个问题:台球室的交友和陌生人社交App的交友完全是两回事。这里的社交必须有明确场景锚点——"找个人一起打球"。脱离了台球这个场景去做泛社交,既偏离业务目标,也容易踩安全合规的坑。
因此,交友模块的定位是"以球会友、组局开台",不做私密聊天、不展示真实地理位置、不下发用户手机号。用户之间只能通过站内会话或组局邀请互动。所有用户生成内容(个人简介、组局宣言)必须经过关键词过滤和后台人工审核,审核不通过的内容直接屏蔽。
安全边界上,实名认证是硬性的。发起组局、加入他人组局、私信交流,三个动作都要求先完成实名认证。认证信息由合作的身份核验服务完成,平台只存核验结果,不存证件原件。
5.2 球友匹配与组局邀约逻辑
球友匹配采用"标签加权 + 在线优先 + 距离邻近"的策略。
标签包括台球水平、打法偏好、性别、常驻城市。用户设置"找球友"偏好之后,系统每次刷新推荐列表时按以下顺序排列:
- 当前在线(有桌台使用中或开台预约中)的用户优先;
- 台球水平等级差小于等于1的用户优先;
- 打法偏好相同的用户优先;
- 常驻城市相同的用户优先;
- 最近一周有活跃记录的用户优先。
实际匹配 SQL 并不复杂,甚至不用上搜索引擎级别的算法。核心是加分排序,我直接按判定条件给每个候选用户算一个综合分,order by score desc 取前20名即可。
组局邀约是另一个核心交互。用户A发起组局,设定时间、桌型、期望人数,系统向符合条件的用户推送招募信息。用户B看到后申请加入,A确认后,B的微信收到加入成功通知。组局人数满员后自动成局,发起人可以一键开台。
5.3 与开台业务联动的"组局开台"
前面提到组局成局后可以直接开台,这个联动设计是系统的亮点。
普通开台流程是"选台-支付-开台";组局开台流程是"发起组局-招募球友-成局-选台-主发起人支付-副桌人确认消费-开台"。主发起人承担全部预付费用,副桌人上车后可以按AA制在线分摊,也可以线下私下结算。这个功能我做成了可配置项,由台球室老板在后台决定是否开启"AA制"按钮。
副桌人确认消费这一步很重要。主发起人开台后,副桌人的账号里会生成一条待确认记录,用户需点击确认,才能算作真正到店使用。这个确认动作既保证了订单归属清晰,也为后续遇到纠纷(比如副桌人赖账称不在场)提供依据。
5.4 消息通知:订阅消息与站内信
消息通知分为两类:强通知走微信订阅消息,弱通知走站内信。
强通知包括开台成功提醒、超时即将自动结账提醒、押金退回提醒、组局被确认提醒。这些消息直接影响用户的钱包体验,必须保证触达。微信订阅消息有现成的模板,关键是一次订阅只能发一条消息,多个场景要分别申请模板。比如用户开台时订阅"开台成功"模板,超时提醒要提前单独订阅。
站内信主要用于非即时消息,比如球友加入申请、系统公告、和管理员的沟通记录。站内信不需要推送资源,落库后在小程序里有图标提示即可。
6. 实操过程与核心代码实现
6.1 项目骨架搭建与公共配置
项目骨架直接用 Spring Initializr 生成,Java 17,Spring Boot 3.x,依赖选择 Web、MyBatis-Plus(手动引入)、MySQL Driver、Redis、Lombok、Validation。考虑到国内开发环境和微信支付的SDK依赖,还需要引入wechatpay-java相关依赖。
公共配置里我最先做的是统一返回结构。这个看起来老生常谈,但项目里大量接口返回结构不统一会非常痛苦,尤其是前端对接和后端排查都费劲。统一返回对象:
@Data public class Result<T> { private int code; private String msg; private T data; public static <T> Result<T> ok(T data) { Result<T> r = new Result<>(); r.code = 0; r.msg = "success"; r.data = data; return r; } public static <T> Result<T> fail(int code, String msg) { Result<T> r = new Result<>(); r.code = code; r.msg = msg; return r; } }全局异常处理器负责把业务异常、参数校验异常、系统异常分别转化为对应的返回码。业务异常统一抛出BizException,带错误码和错误信息,这样排查问题时可以直接通过日志堆栈定位到具体业务位置。
6.2 开台接口与分布式锁实现
开台接口是整个系统的核心里程碑。两个核心问题:一个是并发抢占同一张桌台,另一个是支付回调与开台的原子性。
并发抢占的经典实现是 Redis 分布式锁加数据库唯一约束双保险。分布式锁用StringRedisTemplate的opsForValue().setIfAbsent()实现,设置过期时间防止死锁。
// 开台接口关键逻辑 public OpenTableResult openTable(OpenTableRequest request) { String lockKey = "table:lock:" + request.getTableId(); // 尝试获取锁,等待时间3秒,自动释放时间10秒 boolean locked = stringRedisTemplate.opsForValue() .setIfAbsent(lockKey, UUID.randomUUID().toString(), Duration.ofSeconds(10)); if (!locked) { throw new BizException(ErrorCode.TABLE_BUSY, "桌台繁忙,请稍后再试"); } try { // 双重检查桌台状态 BilliardsTable table = tableMapper.selectById(request.getTableId()); if (table.getStatus() != TableStatus.FREE.getCode()) { throw new BizException(ErrorCode.TABLE_NOT_FREE, "桌台不可用"); } // 创建订单 Order order = buildOrder(request, table); orderMapper.insert(order); // 乐观锁更新桌台状态为占用 int updateCount = tableMapper.compareAndSetStatus( table.getId(), TableStatus.FREE.getCode(), TableStatus.LOCKED.getCode()); if (updateCount == 0) { throw new BizException(ErrorCode.TABLE_CONCURRENT_ERROR, "桌台状态已变更"); } // 记录Redis倒计时 stringRedisTemplate.opsForValue().set( "order:remaining:" + order.getId(), String.valueOf(request.getPrepaidMinutes() * 60), Duration.ofMinutes(request.getPrepaidMinutes())); return OpenTableResult.of(order); } finally { stringRedisTemplate.delete(lockKey); } }这段代码里有几个实操要点。锁的粒度是桌台ID,不是全局锁,这样不同桌台可以并发开台,同一桌台的多请求会被串行化。锁的过期时间不能太短,否则慢事务会把锁提前释放导致并发问题;也不宜太长,否则系统异常时锁释放延迟会影响体验。我用10秒作为默认值,考虑到开台接口的核心流程只有数据库操作和缓存写入,10秒足够。
数据库层的compareAndSetStatus是乐观锁实现,用一个带WHERE status = ?条件的update语句完成比较加更新,这比先查询再更新少了语义空隙。
支付回调的流程更要注意幂等。微信支付回调同一笔订单可能通知多次,必须确保只有第一次通知能推进订单状态。我在数据库给订单号加了唯一索引,并在回调处理里先走一遍select for update锁定订单行,再判断状态是否已经是终态,如果是就直接返回成功,不再重复处理。
6.3 计费引擎的实现细节
计费引擎没有放到任何接口里,而是作为一个独立的Service被定时任务和用户操作共同调用。核心方法就是结算结算:
public SettleResult settleOrder(Long orderId) { // 1. 查订单并锁定 Order order = orderMapper.selectByIdForUpdate(orderId); if (order == null || order.getStatus() == OrderStatus.SETTLED.getCode()) { return SettleResult.alreadySettled(); } // 2. 计算实际使用时长(分钟) long actualMinutes = Duration.between(order.getActualStartTime(), LocalDateTime.now()).toMinutes(); // 3. 计算应付费用 BigDecimal cost = calculateCost(order, actualMinutes); // 4. 计算预付费用与实际费用的差额 BigDecimal prepaidAmount = order.getPrepaidAmount(); BigDecimal balance = prepaidAmount.subtract(cost); // 5. 处理差额:多退少补,不足从押金扣 BigDecimal deposit = order.getDepositAmount(); if (balance.compareTo(BigDecimal.ZERO) >= 0) { refund(prepaidAmount); // 原路退回预付费用 } else { BigDecimal supplement = balance.abs(); if (deposit.compareTo(supplement) >= 0) { // 押金足够,扣除超时费后剩余押金退回 refund(supplement); refund(deposit.subtract(supplement)); } else { // 押金不足,进入挂账 createDebtRecord(orderId, supplement.subtract(deposit)); refund(deposit); } } // 6. 更新订单状态 order.setActualEndTime(LocalDateTime.now()); order.setStatus(OrderStatus.SETTLED.getCode()); // 7. 更新桌台状态为待打扫 // 8. 断电 return SettleResult.success(); }计费引擎的细节在于"用时间维度而非事件维度计算"。例如用户点了续时,不能只在当前时间基础上加时长,还要检查是否已经产生了超时费用,如果超时了要先把超时费结算掉再续时。这需要引入expectedEndTime(预计结束时间)字段,每次续时或结账前优先计算这个字段与实际时间的差值。
6.4 球友匹配查询实现
球友匹配查询不用跨表大搜,只要把候选人圈定在合理的范围内。我用了一次查询配合内存排序。
public List<UserVO> recommendFriends(Long userId, int limit) { User current = userMapper.selectById(userId); // 基础候选:同城或曾在同一台球室消费过的用户 List<User> candidates = userMapper.selectRecentActivatedUsers(current.getCity(), 100); // 排除自己和已拉黑用户 candidates.removeIf(u -> u.getId().equals(userId)); // 加权打分 List<ScoredUser> scored = candidates.stream() .map(u -> new ScoredUser(computeScore(current, u), u)) .sorted(Comparator.comparingInt(ScoredUser::getScore).reversed()) .collect(Collectors.toList()); // 取前limit个 return scored.subList(0, Math.min(limit, scored.size())) .stream().map(UserVO::from).collect(Collectors.toList()); } private int computeScore(User current, User target) { int score = 0; // 在线优先 if (target.getOnLineFlag() == 1) score += 50; // 等级差小于等于1 if (Math.abs(current.getSkillLevel() - target.getSkillLevel()) <= 1) score += 30; // 打法偏好相同 if (current.getPlayStyle().equals(target.getPlayStyle())) score += 20; // 最近活跃 if (target.getLastActiveTime() != null && target.getLastActiveTime().isAfter(LocalDateTime.now().minusDays(7))) { score += 10; } return score; }这个实现不追求算法复杂度,重点是把业务规则表达清楚。候选用户过滤掉已经拉黑的、已经是好友的、还有未完成的组局冲突的,剩下的人才能进入排序池。
7. 常见问题与排查技巧实录
7.1 并发场景下桌台被重复开台
这是刚从单体CRUD转向交易系统时必须踩的坑。问题现象是两台手机在同一时间先后扫码,都看到了桌台空闲,同时提交开台,结果两张订单都创建成功,桌台被开了两次。根因有两个,一个是没加锁,另一个是加了锁但锁粒度太粗或释放过早。
排查过程:先开 MySQL 慢日志,看同一时刻的 insert 和 update 顺序,发现第二个请求的 update 影响行数为0,但此时订单已经插入,说明事务没包裹住整个开台流程。后来我把开台接口的@Transactional加上,并将tableMapper.compareAndSetStatus放在订单插入之后、事务提交之前,从数据库层兜底拦截。
分布式锁一定要配合事务提交的时机。事务提交之前不能释放锁,否则另一个线程看到锁已释放,去查数据库时事务还未提交,还是读到旧状态。处理手段是把分布式锁的释放放在事务提交后或TransactionSynchronization回调里执行。
7.2 支付回调丢失导致的订单悬挂
实际生产环境里微信支付回调偶尔会有丢失或延迟的情况,尤其是网络抖动时。如果回调真丢了,用户付了钱但系统不知道,订单一直处于CREATED状态,桌台没有通电,用户催单,客服后台又查不到支付记录,体验极差。
排查思路是主动对账。我实现了两个兜底机制:第一,用户提交支付后,前端5秒内轮询订单状态,如果发现订单仍是待支付,向后端发起主动查单接口,后端调微信支付查询订单状态;第二,后台定时任务每隔10分钟扫描一次CREATED状态且创建时间超过5分钟但未关闭的订单,主动向微信支付发查询请求,如果查询到已支付,就推进订单状态为PAID。
这个机制上线后,"订单悬挂"基本消失了。核心原则是:不要把支付结果完全押在被动的回调上,主动轮询和定时对账才是最可靠的双保险。
7.3 押金退款失败与对账问题
押金退款走微信支付退款接口,但偶尔会遇到退款失败的情况,比如用户微信支付账号异常、原路退款受限等。如果退款失败后只是把错误记到日志里,用户会一直看到"押金退还中",过几天没到账就来投诉。
我的处理是建了一张refund_record表,每次退款都会生成一条记录,状态为PROCESSING,调用退款接口成功后置为SUCCESS,失败则置为FAILED并记录失败原因。后台管理员可以一键重试退款,重试时的核心逻辑是查微信支付退款单号,如果之前已经退款成功了就不能重复退。重试前必须做一次状态征询,防止重复退款。
还有对账问题。每天的营收报表,不能只统计订单的totalAmount,还要把支付流水、退款流水、押金冻结与解冻的字段全部拉出来对一遍。我开发了一个日终对账任务,每天凌晨自动核对"当日订单应收"与"当日支付流水实付"金额是否一致,不一致时生成告警发送给管理员。
7.4 消息丢失与延迟通知
消息通知依赖微信订阅消息,订阅消息的送达率和时效性不可控。实际测试中,订阅消息在用户不打开小程序时可能延迟很久才送达,开台成功后的超时提醒如果迟到了半小时,用户产生的超时费一定会引发投诉。
我的策略是在关键节点同时发送订阅消息和站内信。站内信虽然不能主动弹窗,但用户打开小程序时立即看到"倒计时提醒"和"待处理事项"。同时,在超时扣费前,我不仅发订阅消息,还尝试通过服务号模板消息和小程序内的悬浮提醒双通道触达。这样至少能保证用户打开小程序时看到提醒,而不是被悄悄扣费。
7.5 性能优化与后续扩展
系统上线一段时间后,数据库中最热的三张表是订单表、支付流水表和组局表。单表数据量达到百万级后,查询速度明显下降,尤其是后台的订单列表筛选和营收报表统计。
针对这个问题,我做了两个优化。第一,订单表按月份进行分区,历史订单自动归档到归档表,运营查询只查近三个月的数据;第二,统计报表类查询改用MySQL的汇总表,每天凌晨由定时任务把前一天的数据按台球室、按时段聚合到统计表,前台展示只查统计表,不再全表扫描订单。
后续扩展的方向也比较明确。桌台硬件侧可以接入更多传感器,比如球落入袋检测、杆位检测,用于自动识别"是否真正在使用",进一步提升计费公平性;管理端可以加入AI摄像头做安全监控和客流分析;交友模块可以扩展"球友圈"动态发布和"战绩记录"功能,增强用户粘性。但无论怎么扩展,核心的原则不变:先把稳定可靠的开台计费流程做好,再谈社交和增长。
个人做下来的体会是,无人自助类系统的复杂度远远超过表面看起来的"扫码付款"四个字。计费公平、支付安全、异常兜底、消息可靠性,每一个环节都要深度考虑。以后再做类似项目,我一定会先把订单状态机和计费策略画清楚,再动手写代码,能少走不少弯路。