
先聊个实际的场景。台球厅老板最怕什么不是客流量少而是账对不上。开台靠喊、计费靠表、结账靠心算高峰期一桌客人打了四十分钟服务员转头去招呼另一桌回来发现时间记错了客人不认账老板只能自己贴钱。这种痛点在小球房尤其普遍稍微上点规模之后光是算账就能让店员焦头烂额。今天分享的这个东西就是基于JavaWeb的台球厅管理收费系统专门解决计时计费、会员管理和营业统计的问题。JavaWeb这个技术栈虽然年头不短但到现在依然是很多企业内部管理系统的主力方案用来做这类物业管理、场馆运营系统再合适不过。这篇文章不是只讲一个成品而是把从需求梳理、数据库建模到计费逻辑实现、本地部署运行的完整过程都拆开来讲适合正在做JavaWeb课程设计或者想给球房做个真实管理系统的开发者参考。整个系统的核心不是花哨的页面而是那套计费引擎——散客按小时、会员按折扣、晚上黄金时段加价、超时自动续费这些规则落到代码里怎么设计才是真正见功夫的地方。后面我会把数据库表结构、订单状态流转、并发扣费这些容易踩坑的点全部展开最后附上常见问题排查表保证你看完能直接在本地跑起来。1. 需求梳理与方案选型1.1 台球厅的收费场景到底有多复杂很多人一听台球厅收费系统第一反应是“不就是按时间算钱吗”真去做需求调研的时候才会发现规则远比想象中琐碎。首先是桌型差异普通花式台球桌和斯诺克桌的价格完全不一样有些球房还有VIP包间价格又高一个档次。其次是时段差异下午两点到六点是闲时晚上七点以后是黄金时段周末全天又是一个价。再叠加会员体系——会员卡有折扣储值卡有余额有的球房还会做“充500送100”这种活动结账时如果金额超过余额还要现场补收现金。散客和会员的开台流程也不一样。散客通常押个手机号或者押金就能开台会员直接刷卡扣费。开台之后还涉及换桌、加时、提前结账这些操作任何一种情况都要保证计费金额准确。高峰期客人多的时候同时有十几桌在计时如果靠人工记录接一个电话就乱了。所以这个系统的核心价值就是把“开台—计时—结账—充值—统计”这条链路全部数字化减少人为算错和扯皮的空间。这些需求听起来多拆解之后其实可以归纳成五个模块桌台管理、会员管理、计费规则配置、订单结算、营业报表。每个模块之间有关联但边界又足够清晰非常适合用JavaWeb的分层架构来做。1.2 技术栈选型为什么这次选SSM而不是SpringBoot经常有读者问我现在新项目不都用SpringBoot吗为什么还在做基于Servlet层的JavaWeb项目。这里要分场景看。如果是给生产环境从零做一个商用系统我大概率会选SpringBoot因为它内置Tomcat、自动配置、起步依赖开发效率确实高。但如果你是想搞懂JavaWeb的核心原理或者这是毕业设计、课程实训项目那用SpringMVC Spring MyBatis这套组合反而更合适——它能把请求从DispatcherServlet分发给Controller再经过Service层到Dao层的完整链路展现出来你会切切实实看到web.xml里配置了什么东西、Spring容器是怎么初始化的、事务是在哪个环节拦截的。我自己做这个台球厅项目时用的就是SpringMVC 4.3 Spring 4.3 MyBatis 3.4的组合没有用SpringBoot的自动装配。坦白说开发过程中确实多写了不少XML配置但好处是每个配置文件都能讲清楚作用读者跟起来不会有“黑盒”的感觉。JDK用的1.8数据库MySQL 5.7服务器Tomcat 8.5前端就是JSP加Bootstrap和jQuery没有上前后端分离。这套组合的经典程度放到今天依然是JavaWeb项目的教科书级搭配网上资料多遇到问题也容易搜到解决方案。1.3 功能模块划分与核心流程系统分为五个功能模块每个模块的业务边界要清楚。桌台管理负责维护球房里的所有桌台信息包括桌号、桌型、所在区域、当前状态空闲/使用中/锁定。会员管理负责会员卡的办理、充值、余额查询、消费记录。计费规则配置是系统的核心模块负责维护不同时段、不同桌型的基础价格以及会员折扣比例。订单结算模块负责开台、换桌、结账、加时等操作生成消费明细。营业报表模块按日/按周统计营业额、开台数、会员消费占比方便老板做经营决策。核心流程就一条链路客人到店 - 选择桌台 - 散客登记或会员刷卡 - 系统开台并开始记录开台时间 - 使用过程中可加时或换桌 - 结账时系统根据计费规则自动计算金额 - 生成账单 - 会员从余额扣款或散客现金/扫码支付 - 桌台状态刷新为空闲。后面所有的数据库设计和代码实现都是围绕这条链路来展开的。2. 数据库设计与计费规则建模2.1 表结构设计思路数据库设计是这类项目最容易出问题的环节很多新手上来就建一张大表把订单、会员、桌台信息全部揉在一起等做到后面就会发现关联查询写起来极其痛苦而且字段冗余会导致数据不一致。我采用的是标准的垂直拆分方案核心表一共六张下面用表格把关键字段列出来。表名关键字段说明memberid, card_no, name, phone, balance, level, status会员表card_no唯一索引table_infoid, table_no, table_type, area, price_per_hour, status桌台表status标记是否空闲billing_ruleid, rule_name, table_type, start_time, end_time, price, is_holiday计费规则表price是每30分钟的价格ordersid, order_no, table_id, member_id, start_time, end_time, total_minutes, total_amount, status订单表散客时member_id为空payment_recordid, order_id, pay_type, amount, create_time支付记录表记录扣款或现金流水userid, username, password, role系统登录账号区分管理员和收银员这里有一个设计细节值得强调orders表里同时存了start_time和total_minutes这两个字段看似冗余实际上是有意为之。total_minutes是每次定时任务刷新时计算的累计值服务重启后可以通过end_time减start_time重新计算校正两个字段互相校验能有效防止金额丢失。另外订单金额字段total_amount也必须存到表里不能只在页面上临时算完就展示因为报表模块需要按月汇总营业额如果每个订单的金额都没有落到库里统计会变得非常麻烦。2.2 计费规则如何做到灵活可配置台球厅的计费规则不是一成不变的很多球房会换季调价节假日和平时价格也不一样。如果这些价格是硬编码在Java类里的那每次调价都要改代码重新部署这种设计放到实际运营里是没法用的。所以我把计费规则抽成了一张独立表billing_rule让运营人员可以直接在后台页面维护。规则表里最关键的是时间段匹配和优先级判断。实际运营中不同桌型、不同时段的价格是组合匹配的比如“斯诺克桌在所有时段比花式桌贵10块钱”。前端页面维护规则时管理员选择桌型、选择时段、填写价格系统把这条记录写入billing_rule表。结账时计费引擎先拿订单里的桌型去过滤规则再拿开台时间去掉每条规则的日期段匹配命中之后再判断开台时间是否落在规则的start_time和end_time区间内。时段边界问题也在这里暴露出来。大部分球房的黄金时段是“晚上19:00到24:00”但客人的开台时间不会恰好是整点可能是20:14分开台。我的做法是时间段匹配采用“包含关系”也就是只要开台时间落在区间内就按该条规则计费然后每过一个计费周期30分钟再重新匹配一次规则如果一个订单跨了时段就分段计算。具体算法在第3章会详细展开。2.3 订单状态机的流转设计订单状态是整个系统的生命周期核心。我定义的状态有三个进行中0、待支付1、已完成2、已取消3。开台时创建订单状态设为进行中客人要求结账时系统计算金额并生成账单订单状态置为待支付支付完成后状态更新为已完成如果开台后客人觉得不满意要中途取消这种情况极少但逻辑上必须支持则走已取消状态。这种状态机设计的好处是每个状态变更都有明确的前置条件和后置动作不会出现“订单都结账了桌台还显示占用”这种数据不一致的问题。比如结账成功的动作里除了更新订单状态还要同时把table_info表里对应桌台的状态改为空闲。这两个操作必须放在同一个事务里要么都成功要么都失败我会在Service层加Transactional注解来保证。3. 核心计费逻辑的实现3.1 为什么计时器不能靠后台线程计数刚开始设计时我踩过一个典型的坑想用一个后台定时任务每秒钟对每个进行中的订单的累计秒数加1然后刷新页面上显示的金额。听起来没什么问题但仔细想就经不起推敲——如果服务器重启内存里所有订单的累计秒数全部丢失订单金额就乱了。这还不算完线程任务如果因为GC卡顿或者数据库连接池满了而延迟执行每一秒钟的偏移都会造成计费不准。正确做法是用数据库里的时间戳做时间差计算。orders表里存了start_time计算当前时长只需要拿当前时间减去start_time然后用时间差乘以对应时段的价格就能得到准确金额。定时任务需要做的唯一事情就是每隔一分钟扫描一次进行中的订单用这个时间差计算出当前应计金额然后更新到total_minutes和total_amount字段里。就算定时任务停了一个小时恢复后重新计算金额也是准确的因为数据源始终是数据库的时间戳而不是脆弱的JVM内存。这个方案背后的原理其实就是“状态可恢复性”任何计费系统都不能把唯一事实保存在内存里否则就失去了记账的意义。数据库在这里扮演的不是简单的存储角色而是整个系统的事实来源。3.2 金额计算的完整步骤与代码实现计费规则的核心是一个分段计算的算法。订单可能从下午四点半打到晚上八点跨越闲时和黄金时段两个计费区间这时候就不能用一个固定单价乘总时长来算得把时间切成两段分别计算再相加。我把这个逻辑封装在BillingService的calculateAmount方法里核心流程分四步第一步从数据库查出订单的开台时间startTime和结账时间endTime注意结账时间在定时任务里就是当前时间在用户主动结账时就是点击按钮那一刻的系统时间。第二步根据桌台类型查询所有匹配的计费规则按优先级排序。第三步遍历规则列表对每条规则计算它覆盖的时间区间和订单时间区间的交集长度用交集分钟数乘以该规则的单位价格。第四步累加所有分段金额得到订单总金额。如果计算时发现总金额为零说明没有命中任何计费规则这时候要抛异常提醒管理员去维护规则表。代码大致长这样public BigDecimal calculateAmount(Long orderId, Date endTime) { Orders order orderMapper.selectById(orderId); Date startTime order.getStartTime(); String tableType order.getTableType(); ListBillingRule rules billingRuleMapper.selectByTableType(tableType); BigDecimal total BigDecimal.ZERO; long totalMinutes 0; for (BillingRule rule : rules) { long overlapMinutes overlapMinutes(startTime, endTime, rule); if (overlapMinutes 0) { total total.add(rule.getPrice() .multiply(BigDecimal.valueOf(overlapMinutes / 30.0))); totalMinutes overlapMinutes; } } order.setTotalMinutes((int) totalMinutes); order.setTotalAmount(total); orderMapper.updateById(order); return total; }这里要特别提一下BigDecimal的使用。金额计算绝对不能使用double或float因为浮点数的二进制表示有精度误差0.1加0.2会得出0.30000000000000004。很多刚入行的同学在这上面翻过车计费系统里金额算错一分钱都是事故用BigDecimal配合字符串构造方法是最稳妥的。还有一个细节价格字段我存的是“每30分钟多少元”而不是“每小时多少元”这样在计算不足半小时的零头时会更灵活。绝大多数球房都是按半小时作为最小计费单元的这个设定更贴合实际运营。3.3 跨时段与高峰期的分段计算处理分段计算算法里最核心的其实是那个overlapMinutes方法它负责算两个时间段的交集。比如订单时间是从16:30到20:30计费规则里有两条一条是“闲时16:00-19:00价格15元/半小时”另一条是“黄金时段19:00-24:00价格25元/半小时”。计算第一条规则时取16:30到19:00的交集一共150分钟除以30得到5个计费单位金额75元。计算第二条规则时取19:00到20:30的交集一共90分钟3个计费单位金额75元。总金额150元。这个方法通用性强可以处理任意多个时间段的重叠计算跨天订单也不会出问题。比如客人从晚上23:00打到凌晨2:00规则表里配置一条“23:00-次日02:00夜间场”只要把结束时间设置成对应的Calendar时间代码逻辑完全不用改。在实现这个方法的时有一个边界陷阱要注意Java的Date取小时和分钟时如果用new Date().getHours()会遇到时区问题和弃用警告。我建议用LocalDateTime替代Date来做时间区间的startTime和endTime。LocalDateTime有完整的日期时间API比如plusMinutes、isBefore、isAfter这些方法语义清晰不会像Date那样容易写出歧义代码。如果你用MyBatis的默认TypeHandlerLocalDateTime可以直接映射到MySQL的datetime字段省去不少转换麻烦。3.4 并发结账与会员余额扣款的坑实际运营中会遇到一种高并发场景同一桌客人结账时收银员点了结账按钮但另一个操作员不小心又点了一次结账或者会员卡同时在另外一桌被使用余额扣完之后系统没有正确判断余额不足。如果代码是“先查余额再扣款”这种方式在高并发下会发生典型的“检查后再更新”问题——两个线程同时读到余额是100元分别扣了80元和50元最终结果都成功了余额变成了-30元显然是错的。解决方案是数据库层面的乐观锁控制。在orders表里加一个version字段更新订单状态时带上version条件如果update影响行数为0说明订单状态已经被其他请求修改过了当前请求直接返回“订单已处理”提示。会员余额扣款也是同样的思路扣款SQL写成“update member set balance balance - #{amount} where id #{memberId} and balance #{amount}”数据库自带的行锁机制会保证同一时刻只有一个扣款操作能成功且余额不会被扣成负数。这种方案比Java代码里加synchronized锁更可靠因为synchronized只对单机进程有效数据库的原子更新才是硬约束。4. 从零开始搭建并跑通整个项目4.1 开发环境准备与工具清单先列一下我用来跑通这个项目的最小环境清单。JDK必须用1.8版本高了可能在配置Tomcat的时候出兼容问题。Maven用3.6.3管理Spring和MyBatis的依赖。Tomcat用8.5.x这个版本对JavaWeb 3.1规范支持好也是SSM项目最常用的容器。MySQL用5.7如果你只有MySQL 8.0也可以用但要注意连接驱动的版本要升到8.0以上JDBC URL需要额外加serverTimezoneAsia/Shanghai参数。IDE这块我之前用Eclipse较多但这次实操我用了VSCode配合Java Extension Pack、Maven for Java和Community Server Connectors三个插件同样可以完成编码、构建和部署调试的完整流程。提示如果本地已经装了多个JDK版本记得在VSCode的java.jdt.ls.java.home配置项里显式指定JDK1.8的路径否则插件会自动挑一个版本编译时容易报source/target版本错误。4.2 用VSCode创建并配置JavaWeb项目VSCode创建JavaWeb项目的方式和IDEA、Eclipse不太一样它本身没有“新建Dynamic Web Project”的向导所以更规范的做法是先创建一个Maven项目然后通过插件把项目部署到Tomcat。具体操作分五步第一步打开VSCode命令面板输入Maven: Create Maven Project选择webapp原型骨架maven-archetype-webapp然后填groupId和artifactId第二步在pom.xml里添加Spring、SpringMVC、MyBatis、MySQL驱动、Jackson、jstl等依赖声明打包方式是war包第三步补全src/main/java和src/main/resources目录结构把Spring配置文件、MyBatis配置文件放好第四步在src/main/webapp/WEB-INF下创建web.xml配置DispatchServlet的映射和Spring的监听器第五步配置Tomcat在VSCode的Server面板里添加Tomcat 8.5的路径然后右键Run即可启动调试。因为pom文件内容比较多我在这里只列出关键的依赖部分properties spring.version4.3.18.RELEASE/spring.version /properties dependencies dependency groupIdorg.springframework/groupId artifactIdspring-context/artifactId version${spring.version}/version /dependency dependency groupIdorg.springframework/groupId artifactIdspring-webmvc/artifactId version${spring.version}/version /dependency dependency groupIdorg.mybatis/groupId artifactIdmybatis/artifactId version3.4.6/version /dependency dependency groupIdorg.mybatis/groupId artifactIdmybatis-spring/artifactId version1.3.2/version /dependency dependency groupIdmysql/groupId artifactIdmysql-connector-java/artifactId version8.0.28/version /dependency /dependenciesMaven会把所有这些依赖自动拉取到本地仓库省去一个个下载jar包的时间。这里也解释了为什么用Maven管理依赖而不是手动拷包——手动拷包在一个小项目里还行依赖一多就是灾难版本冲突能把人逼疯。4.3 初始化数据库与快速联调数据库表和索引定义完整之后我提供了一个初始化SQL脚本执行后即可生成所有表和测试数据。脚本里除了建表语句还要插入几条基础数据比如默认的管理员账号、默认的桌台记录、默认的计费规则。这样项目启动后不用先在后台页面造数据就能直接测试开台流程。初始化脚本里的计费规则我建议参考下面这个结构规则名称桌型时段价格每半小时备注工作日闲时花式桌10:00-19:0010.00周一至周五工作日黄金花式桌19:00-24:0020.00周一至周五周末闲时花式桌10:00-19:0015.00周六周日周末黄金花式桌19:00-24:0030.00周六周日联调时推荐按这个顺序测试先登录后台确认页面能显示桌台列表然后选一桌点“开台”看订单表是否新增了一条记录且桌台状态变为使用中再等几分钟让定时任务刷新一次观察total_minutes字段有没有变化最后点“结账”确认金额计算正确且桌台状态恢复为空闲。这条链路跑通了整个系统的主体功能就基本没问题了。5. 常见问题与排查技巧实录5.1 定时任务计费漂移或重启后金额错乱现象页面显示的累计金额和实际时间对应不上比如开台半小时了金额还是0。原因通常是后台定时任务没有启动成功或者包扫描配置漏了任务所在包。排查步骤先去数据库看orders表里该订单的total_minutes字段是否在更新如果隔一分钟查一次值在涨说明任务运行正常如果一直都是0检查springmvc.xml或applicationContext.xml里有没有配置task:annotation-driven/以及Scheduled注解所在类是否被Spring扫描到。这个问题的另一层原因是时区问题。如果MySQL连接串没有加serverTimezoneAsia/Shanghai数据库返回的时间可能和本地时间相差8个小时导致计算出来的时间差为负数金额就变成了0。解决办法就是在JDBC URL里显式声明时区。5.2 并发结账导致订单重复处理或余额扣超现象收银员连续快速点击两次结账按钮系统生成了两条支付记录会员余额多扣了一次。这种问题就是典型的并发控制缺失。解决办法分两层前端在点击结账按钮后立刻禁用按钮防止用户误操作后端在更新订单状态时使用乐观锁SQL写成update orders set status 2 where id #{id} and status 0当返回行数为0时说明订单已经处理过直接忽略后续请求。我建议这两层都要做前端控制的是体验后端控制的是数据安全缺一不可。5.3 VSCode部署时Tomcat启动缓慢或端口被占用现象启动Tomcat时报端口8080被占用或者启动后页面一直白屏。端口占用问题可以直接用netstat命令查到占用进程结束后台对应进程即可。启动慢的问题多数是因为VSCode的Java语言服务器在项目初始化时重新构建依赖第一次启动需要耐心等两到三分钟后面再启动就快了。如果一直卡住不动尝试删除项目里的target目录和workspace里的Java插件缓存然后重新导入项目。5.4 问题排查速查表把实际项目中容易遇到的问题整理成一张表方便以后直接对照排查。问题现象可能原因排查方向页面列表加载不出桌台Controller路径映射错误或MyBatis mapper扫描不到检查web.xml的servlet-mapping和spring配置里的mapper接口包路径开台后桌台状态不变事务未提交或更新错误检查Service方法有没有加Transactional事务里抛出的异常是否被吞掉结账金额与实际不符计费规则未匹配或时间计算边界不对先用SQL查出订单时间区间手动套规则表里的规则推导一遍中文乱码编码不一致统一所有文件使用UTF-8JSP页面加pageEncodingJDBC URL连接参数里也指定characterEncodingutf8会员余额正常但提示扣款失败乐观锁冲突检查更新语句的where条件是否包含balance amount查询此会员是否并发开了多桌5.5 项目扩展建议系统跑通之后如果想让它在实际运营中真正发挥作用还可以考虑几个扩展点。第一是支付对接目前只做了现金记账和余额扣款可以接着接入微信/支付宝的扫码支付业务流程就是提交订单 - 生成支付二维码 - 轮询支付结果 - 更新订单状态。第二是增加统计报表把营业数据按小时维度聚合成柱状图老板能直观看到每天的客流峰值在什么时段方便安排人手。第三是桌台状态的硬件联动理论上可以通过单片机或者门磁传感器让客人入座和离开时自动触发系统开台和结账但这个对硬件成本要求较高小球房不一定有必要。写在最后的一点感受开发这套系统给我最大的触动是计费系统从来不缺功能缺的是对业务细节的把控。数据库设计时多考虑的那一个冗余字段计算金额时坚持用BigDecimal的那一行代码更新状态时多写的那一个where条件可能在开发阶段看起来都是无关紧要的小事但到了真实运营环境它们就是保证账目准确、避免顾客纠纷的关键。我刚开始写第一个版本时也犯了不少傻——用double算过钱用内存计过时也没有给订单加状态机直到数据乱得没法收拾才回头一个一个补上。如果你也在为类似的项目折腾希望这篇文章能帮你少走弯路尤其是计费分段和并发扣款这两块照着上面的方案落地基本不会出大问题。