资讯动态

无人台球室系统实战:Java+Spring Boot实现自助开台、计费与组局

发布时间:2026/10/20 7:01:16 来源:尧图企业网站定制
做无人台球室系统这个项目起因挺有意思。我此前帮朋友打听过台球厅的运营成本发现人工是三班倒的大头夜班不能关又不可能每天都让店员通宵盯场。后来接了一个模拟项目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 用户表与桌台表设计用户表重点要考虑虚拟身份与真实身份分离。台球室的社交场景涉及交友必须保证安全合规但又不希望强制用户上传身份证。我的做法是用户表里存基础虚拟资料另建一张实名认证表用户发起组局或被人邀约前必须完成实名实名信息与微信号、手机号绑定。用户表核心字段字段类型说明idbigint主键phonevarchar手机号登录账号openidvarchar微信小程序openid用于支付和消息nicknamevarchar昵称avatarvarchar头像URLgendertinyint性别skill_leveltinyint台球水平等级1新手 2进阶 3高手 4大神play_styletinyint打法偏好进攻型/防守型/全能型locationvarchar常驻位置城市或商圈用于同城推荐statustinyint用户状态正常/拉黑/封禁桌台表相对简单但状态字段必须设计成可扩展的枚举。桌台状态不是非空即忙还存在待打扫设备故障锁定等中间态。例如用户结账离场后桌台不能立刻设为空闲供下一个人开台需要进入待打扫状态由保洁或晚班管理员确认后才恢复空闲。自动打扫预留时间可以配置比如十分钟。桌台表核心字段字段类型说明idbigint主键table_novarchar桌号展示用table_typetinyint类型1中式八球 2斯诺克 3九球price_per_hourdecimal每小时价格单位元positionvarchar位置描述如A区01号statustinyint桌台状态枚举power_switchtinyint电源控制状态0断 1通is_deletetinyint逻辑删除标志这里有个设计教训桌台状态一定不能用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、statusstatus区分待验证/已通过/已拉黑。查询好友列表时只要查select * from friend_relation where user_id ? or friend_user_id ?再排除自己即可。这张表不复杂但必须加唯一索引防重复申请。组局表是社交与业务结合的纽带。用户发起组局时可以选择一个桌台或仅发布需求不带桌台。组局表核心字段包括字段类型说明idbigint主键inviter_idbigint发起人用户IDtable_idbigint关联桌台ID可为空target_timedatetime期望开始时间duration_minutesint期望时长max_playerstinyint最大参与人数current_playersint当前已加入人数requirementvarchar对球友的要求描述statustinyint招募中/已成局/已取消/已过期组局成局后由发起人一键落为正式订单所有参与者作为副桌人关联。这个设计的好处是把交友和开台打通组局既能促成社交又直接带动开台。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 17Spring Boot 3.x依赖选择 Web、MyBatis-Plus手动引入、MySQL Driver、Redis、Lombok、Validation。考虑到国内开发环境和微信支付的SDK依赖还需要引入wechatpay-java相关依赖。公共配置里我最先做的是统一返回结构。这个看起来老生常谈但项目里大量接口返回结构不统一会非常痛苦尤其是前端对接和后端排查都费劲。统一返回对象Data public class ResultT { private int code; private String msg; private T data; public static T ResultT ok(T data) { ResultT r new Result(); r.code 0; r.msg success; r.data data; return r; } public static T ResultT fail(int code, String msg) { ResultT 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 ListUserVO recommendFriends(Long userId, int limit) { User current userMapper.selectById(userId); // 基础候选同城或曾在同一台球室消费过的用户 ListUser candidates userMapper.selectRecentActivatedUsers(current.getCity(), 100); // 排除自己和已拉黑用户 candidates.removeIf(u - u.getId().equals(userId)); // 加权打分 ListScoredUser 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摄像头做安全监控和客流分析交友模块可以扩展球友圈动态发布和战绩记录功能增强用户粘性。但无论怎么扩展核心的原则不变先把稳定可靠的开台计费流程做好再谈社交和增长。个人做下来的体会是无人自助类系统的复杂度远远超过表面看起来的扫码付款四个字。计费公平、支付安全、异常兜底、消息可靠性每一个环节都要深度考虑。以后再做类似项目我一定会先把订单状态机和计费策略画清楚再动手写代码能少走不少弯路。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑