1. 为什么我要做这个台球厅计费系统:课时费背后的真实痛点
做这个项目之前,我泡过好几个台球厅。不是为了打球,是真去观察他们的收银台和记单流程。大多数中小型台球厅至今仍停留在"手写开台单+人工掐秒表+下班统一算账"的阶段,老板最怕的不是客人少,而是账对不上、漏单、跑单以及员工交接班时的糊涂账。
基于JavaWeb的台球厅管理收费系统,本质上就是把这套手工流程用Web应用重新实现一遍。它要解决的核心问题,并不是"把信息存进电脑"这么简单,而是三个字:计费准。台球厅的收入模型非常特殊——收入按分钟产生,开台即计费,换台要迁移计时,结账要按峰谷时段切换单价。一旦计时口径出错,差个几分钟,一个月累积下来就是几千块的损失。
这个项目适合的人群也很明确:正在做JavaWeb课程设计或毕业设计的计算机专业学生,想练手完整Web项目(前端+后端+数据库+部署)的初级开发,以及确实想给自己球房搞一套内部管理工具的个体经营者。技术栈是经典的Servlet+JSP+MySQL,没有过度封装,每个环节都能看清来龙去脉,对于学习阶段来说这反而是优点。
我最终实现的系统包含桌台管理、开台与换台、自动计费、会员储值、商品销售、订单结算、营业统计和权限登录八个核心模块。整套代码量不大,但把台球厅运营中最容易出问题的业务节点都覆盖了。下面我按从需求梳理到功能落地的顺序,把整个设计和实现过程完整拆解一遍。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 台球厅计费业务建模:为什么计费规则是系统的灵魂而非附属功能
2.1 时计费与阶梯计费的规则梳理
在写任何一行代码之前,第一件事是去现场搞清楚计费规则。我调研了本地四家球房,发现规则惊人地一致:非会员散客按小时计费或按分钟实时累计;会员享受折扣价,但折扣通常只针对台费,不打折商品;峰谷时段价格不同,一般19点到24点算黄金时段,价格上浮;超时未结账自动续费。真实场景比网上课程设计里的"固定单价"复杂得多。
所以我把计费设计成一个独立的规则引擎类,而不是把价格硬编码在JSP页面里。规则参数全部存数据库的system_config表:
| 参数项 | 示例值 | 说明 |
|---|---|---|
| normal_price | 20.00 | 普通时段每小时单价 |
| peak_price | 35.00 | 高峰时段每小时单价 |
| peak_start | 19:00 | 高峰开始时间 |
| peak_end | 00:00 | 高峰结束时间 |
| member_discount | 0.8 | 会员台费折扣率 |
| min_billing_unit | 30 | 最短计费分钟数(不足30分钟按30分钟) |
| billing_round | 1 | 超过最短时间后按多少分钟向上取整 |
计费逻辑的核心判断,是在任意时刻给定一个时间点,算出当前应该按哪个单价走。这个判断用TimeUtils工具类实现,传入LocalTime,和配置表中的峰谷区间比对,返回当前单价。坑点在于跨天——高峰结束时间是00:00,意味着23:50开台,前10分钟是高峰价,到00:00后自动切到普通价。这个跨天切换如果不在设计阶段考虑清楚,后期改代码非常痛苦。
真正让计费复杂的是超时自动续费。客人打完球不结账,台子空着但系统还在跑时间。我的处理方案是:后台定时任务每5分钟扫描一次所有启用状态的桌台,用当前时间减去开始计费时间得到已计费分钟数,然后和订单表里的预付费时长对比,超出的部分自动生成补缴记录。这样前台收银员只需按系统提示收款,不需要自己心算"超了多久"。
2.2 一桌多时段的价格归属算法
阶梯计费再加峰谷切换,会引出一个实际问题:一局球从20点半打到21点半,横跨了普通时段和高峰时段,怎么算钱?
两种方案摆在我面前。方案一是分段结算:把开场到结束的时间段按峰谷切分,分别累计分钟数,再乘以各自单价相加。方案二化整为零:以当前计费时刻的单价为准,整段时长统一按这个单价算,等下次续费或结账时再刷新单价。
方案一对客人公平,对系统复杂——每次结账都要遍历时段切分点,还要处理跨天和多次峰谷切换的边界。方案二符合大多数球房的实际操作——大多数球房本来就按"结账那一刻的单价"算,客人也基本认同。但方案二有个漏洞:客人如果跨峰谷长时间打球不结账,费用会失真。
最终我采用了折中复杂度可控的方案:按小时整段切换而非按分钟精确切分。具体规则是——按开始计费时刻锁定初始单价;每个整点重新判断当前是否跨时段,若跨时段则从跨入时刻起按新单价计费;计费时长按分钟累加,单价按"时长归属区间"分两段累计。这个逻辑放在BillingEngine.calculate()方法里,传入开台时间、当前时间和会员标识,返回总费用。
光这一段算法,我写了三个版本才稳定。第一版没有处理跨天,第二版处理了跨天但没处理"开台时间/当前时间/峰谷区间"三者叠加的多次切换。最终版本用一条时间线遍历法:把[开台时间, 当前时间]这个区间,按峰谷切换点切割成若干子区间,每个子区间内部单价恒定,累加即可。这也是这个项目里我最满意的一段代码。
2.3 换台与并台:最容易泄漏收入的业务流程
换台是我在需求调研阶段差点漏掉的业务。客人从普通区换到VIP区,台费单价变了,计时却必须无缝衔接。如果只是简单地把桌台A状态改为空闲、桌台B改为使用中,那么之前在A台的时间就全丢了,收银员只能手工重新估算——这就是收入泄漏点。
我的换台实现是操作订单而非操作桌台。订单表设计上,一个订单关联桌台ID,换台时新建一条桌台变更记录,同时把原订单的结束时间打上,费用按变更前的时间段结算到原台,再以原订单剩余预付费为基础,开一个新订单挂在目标桌台下。这样每一次换台都会产生一条可追溯的对账记录,下班汇总时能清楚看到"3号台换到5号台,金额无断裂"。
并台则更少用但必须支持——客人本来开了一个斯诺克台,后来说要换到旁边的大台一起打,两个台子的费用要合并。实现上我选择"先结旧单、再开新单"的策略:把旧台订单做强制结账,生成费用记录;然后新台以实际开台时间倒推补录开台记录;剩余预付费手动转入新订单。这个过程全程有操作日志,避免收银员私下操作。
3. 技术选型与项目搭建:JavaWeb项目在2023年到底该怎么起手
3.1 选型思路:不追新,但求稳定可控
技术选型这块我不打算标新立异。这个项目定位是实用型管理系统,面向的是课设、毕设以及小型球房落地,所以选型原则就三条:资料多、好调试、部署简单。
- 后端:Java 8 + Servlet 4.0 + JSP。这组合看起来老,但生态成熟,遇到问题随便一搜就有答案。更重要的是它能让你理解HTTP请求从Tomcat到Servlet再到JSP渲染的完整链路。用Spring Boot虽然开发快,但对学习Web本质来说反而是黑盒。
- 前端:JSP + JSTL + Bootstrap 5 + jQuery + Ajax。不做前后端分离——这个体量的项目分离架构只会增加部署成本和CORS之类的麻烦。Bootstrap 5负责界面,Ajax负责局部刷新,比如开台操作不需要整页跳转。
- 数据库:MySQL 8.0,InnoDB引擎,UTF-8mb4字符集。涉及金额的字段全部用DECIMAL(10,2)而不是float——float的精度问题在金额计算里是绝对不能碰的红线。
- 服务器:Tomcat 9.x。注意和Servlet版本的兼容性,Tomcat 9对应Servlet 4.0,够用且稳定。
IDE方面,我用的是IDEA 2023版本。新版的IDEA创建JavaWeb项目和以前不太一样,没有直接的"Java Enterprise"向导入口那么直观了,需要在Project Structure里手动添加Web模块和Artifact。这个后面细说,因为光是"IDEA 2023创建JavaWeb项目"这个步骤就能劝退一批新手。
3.2 用IDEA 2023从零创建一个可运行的JavaWeb项目
IDEA 2023里创建一个最基础的JavaWeb项目,步骤和细节如下:
- 新建Project,选择Maven作为构建工具,JDK选1.8。注意不要勾选"从原型创建",直接生成标准Maven骨架即可。
- 在pom.xml里显式声明打包方式为war:
<packaging>war</packaging>。JavaWeb项目最终要部署到Tomcat的webapps目录,必须是war包。这一步漏掉的话,IDEA不会自动帮你打war。 - 关键一步:在Project Structure(Ctrl+Shift+Alt+S)中,选中Modules下面的当前模块,点击加号,添加"Web"模块支持。这样IDEA 2023才会生成webapp目录和web.xml。很多教程里说"右键项目直接Add Framework Support"——在新版IDEA里如果项目是通过Maven骨架创建的,直接右键确实能找到,但你知道它是怎么工作的吗?它其实就是在模块配置里加了Web facet。知道这一点,以后遇到任何奇怪的IDE问题都能从根源排查。
- 配置Artifacts。把项目Artifact选为"Web Application: Exploded",这是开发调试模式,后面只需把它加到Tomcat的Deployment里即可。
- 配置Tomcat。Run → Edit Configurations → 添加Tomcat Server → Local。注意Application server这里要选择Tomcat安装目录。在Deployment标签页里,把上一步的Exploded Artifact加进去,Application context填
/billiards。 - 配置好之后,给web.xml加上welcome-file,指向login.jsp。启动Tomcat,浏览器访问
http://localhost:8080/billiards能看到登录页,项目骨架就通了。
踩坑提醒:IDEA 2023的Tomcat集成有时候会报"Error during artifact deployment"——这是因为Artifact输出目录下的classes没有更新,解决方案是Build → Rebuild Project。另外一个经典问题是JDK版本不匹配,Tomcat 9要求JDK8+,但如果你本机装了JDK17,编译方式没设置成对应版本,Tomcat会报UnsupportedClassVersionError。
3.3 Maven依赖管理:只用该用的
这个项目用Maven做依赖管理,但在依赖的数量上我极其克制。核心依赖只有这些:
- javax.servlet-api(编译期,provided作用域)
- javax.servlet.jsp-api(编译期,provided作用域)
- jstl(JSP标准标签库,模板渲染要用)
- mysql-connector-java(JDBC驱动)
- commons-dbutils(封装JDBC,简化结果集映射,比直接写PreparedStatement省一半代码,又比MyBatis轻很多)
- druid(阿里连接池,带监控页面,对学习阶段排查连接泄漏很有帮助)
- gson(Ajax接口返回JSON用)
- commons-fileupload(商品图片上传)
刻意不用的是Spring、MyBatis这些重量级框架。不是它们不好,而是这个项目如果引入Spring,你的注意力会被Bean生命周期和AOP概念分散掉,而计费规则引擎本身才是这个系统的重点。手工管理Servlet实例和Service层依赖,对于几百行代码的项目完全够用,还能让你对"对象由谁创建、什么时候销毁"保持清醒。
4. 数据库表结构设计:面向计费场景而非面向菜单导航
4.1 核心表设计:订单、桌台与账务流水
数据库设计是我第二个花了大精力的模块。我见过太多课设项目的数据库表是照着页面菜单建的——有一个页面就建一张表,结果"会员管理"就是一张会员表存用户名密码,完全没有业务建模。这种表结构做出来的系统,只能叫"数据录入界面",不能叫"管理系统"。
这个项目的核心表一共有7张,我按业务关联逻辑整理如下:
code复制user(系统用户)
id, username, password, real_name, role, create_time
table_info(桌台)
id, table_no, type(普通区/VIP区/斯诺克区), price_per_hour, status(1空闲/2使用中/3维护), remark
member(会员)
id, card_no, name, phone, balance, points, level, create_time
orders(订单——最关键的表)
id, order_no, table_id, member_id, start_time, end_time, total_minutes, total_amount, pay_status, extra_info
order_time_detail(计费明细——记录每次价格切换的明细段)
id, order_id, start_time, end_time, price, minutes, amount
payment_record(账务流水)
id, order_id, member_id, pay_type(现金/微信/支付宝/余额), amount, create_time, operator_id
operation_log(操作日志)
id, operator_id, content, create_time
订单表orders是整个系统的中枢,它跟桌台是多对一关系——一个桌台在一段时间内产生一条主订单,但换台时会新开订单并把old_order_id关联上。pay_status字段我用了四个状态:0未支付、1部分支付、2已支付、3已退款。为什么要"部分支付"?因为台球厅经常出现先压100块开台、打完再补差价的情况,预付费和实际费用之间允许有差额存在。
计费明细表order_time_detail是我自认为设计得最有价值的一张表。它把BillingEngine每次计算的时段切分结果原样存下来,等于给每一笔订单拍了X光片——这张订单为什么是87块而不是80块,打开明细表一看就知道,97分钟里有58分钟按高峰价35算、39分钟按平时价20算。这个设计在排查计费纠纷时起了巨大作用,有客人投诉多收了钱,调出明细来,时间、单价、分钟数摆在那里,沟通成本直线下降。
4.2 为什么不用外键和触发器
我设计数据库时特意没用外键约束,也没用触发器。这不是疏忽,是有意为之。逻辑外键+Service层控制关联,在这个项目里比物理外键更好用。原因有三:其一,换台和并台这种操作涉及多表联动,物理外键会阻塞中间态的保存——比如先插入订单记录,此时桌台ID还没换到新台,如果外键强制一致就写不进去。其二,触发器写复杂业务逻辑会让Bug排查非常困难——你很难从Java代码的调用链追踪到数据库层面的隐式操作,对学习项目来说这是灾难。其三,后续如果要分库分表,物理外键就是最大的绊脚石。
我在DATABASE.sql初始化脚本里把所有外键关系写成了索引+注释,Service层负责完整性校验。比如关闭桌台操作,Service层会先开事务:更新table_info状态 → 写orders → 写payment_record → 如果涉及会员余额,还要扣减member.balance并写流水。这个事务提交失败时整体回滚,由Connection的setAutoCommit(false)和commit/rollback来控制。
4.3 初始化数据设计:让项目拿起来就能跑
一个能让评委或老板眼前一亮的小细节,是初始化数据。很多课设项目交付后要自己手动插入几十条数据才能看到效果,体验极差。我在database目录下放了三份SQL脚本,分别是chema.sql(建库建表)、init_data.sql(基础配置+演示数据)、test_data.sql(压测性能用的百万条订单模拟数据)。
init_data里我准备了:4个系统用户(管理员/收银员/经理各角色)、12张桌台覆盖三种类型、8个会员(包含余额充足的和余额不足的)、以及最近三天的模拟营业订单。这样项目部署完,登录进去立刻就能看到统计图表有数据,不用费劲造数据。test_data.sql里我用存储过程生成100万条订单历史,用来测试后台统计报表的SQL性能——这一步在后面优化报表查询时帮了大忙。
5. 后台核心功能实现细节:开台、结账、续费与换台的完整链路
5.1 开台与桌台状态机的流转
开台是整个系统最基础的操作。桌台有四个状态:空闲、使用中、维护、已预订。状态流转在TableService里统一管理:
- 开台时:空闲 → 使用中。事务内更新桌台表status=2,插入orders(pay_status=0),预收押金则顺便生成payment_record。
- 换台时:旧台 使用中 → 空闲,新台 空闲 → 使用中,同时旧订单结单、新订单开单。
- 结账时:使用中 → 空闲,订单pay_status改为2。
- 维护时:任何状态 → 维护,但使用中不能直接转维护,必须先结账。
状态机的约束我用枚举类TableStatus集中在Java层控制,而不是散落在各个Servlet里。这样将来想加"保留"状态或者"计时暂停"状态,只需改这一个地方。
开台的Ajax逻辑是这样的:前台点击"开台",弹窗选择桌台(下拉框数据来自table_info,过滤status=1)、输入手机号(用于自动识别会员)、选择台型,提交后TableServlet通过action=open分发,内部调用TableService.openTable()。整个流程响应式刷新右侧桌台面板,球桌从绿色变红色并开始走计时。前端计时器每10秒向后端拉取一次该桌台的实时费用,更新显示。这个设计让收银员和客人都能看着屏幕上金额跳动,心理上有一种"明明白白消费"的信任感。
5.2 BillingEngine核心计费算法:时间线切割的完整代码
下面这段BillingEngine的代码,是整个项目含金量最高的部分。它接收订单开始时间和当前时间,以及会员折扣标识,返回计费结果对象:
java复制public class BillingEngine {
private final SystemConfig config;
public BillingEngine(SystemConfig config) {
this.config = config;
}
public BillingResult calculate(LocalDateTime startTime, LocalDateTime endTime, boolean isMember) {
if (startTime.isAfter(endTime)) {
throw new IllegalArgumentException("开始时间不能晚于结束时间");
}
BillingResult result = new BillingResult();
// 总分钟数(核心计费元数据)
long totalMinutes = ChronoUnit.MINUTES.between(startTime, endTime);
result.setTotalMinutes(totalMinutes);
// 按分钟向上取整,数据库存的是分钟,金额在最后统一计算
long billableMinutes = totalMinutes;
long minUnit = config.getMinBillingUnit(); // 最短计费分钟,比如30
if (totalMinutes < minUnit) {
billableMinutes = minUnit;
}
// 时间线切割:把[startTime, endTime]按峰谷切换点切分
List<TimeSlice> slices = splitTimeSlices(startTime, endTime);
BigDecimal totalAmount = BigDecimal.ZERO;
for (TimeSlice slice : slices) {
BigDecimal unitPrice = getPriceForTime(slice.getStart());
BigDecimal sliceMinutes = BigDecimal.valueOf(slice.getMinutes());
BigDecimal sliceAmount = unitPrice.multiply(sliceMinutes)
.divide(BigDecimal.valueOf(60), 2, RoundingMode.HALF_UP);
result.addDetail(new TimeSliceDetail(
slice.getStart(), slice.getEnd(), unitPrice.doubleValue(),
slice.getMinutes(), sliceAmount));
totalAmount = totalAmount.add(sliceAmount);
}
if (isMember) {
totalAmount = totalAmount.multiply(config.getMemberDiscount())
.setScale(2, RoundingMode.HALF_UP);
}
result.setTotalAmount(totalAmount);
result.setBillableMinutes(billableMinutes);
return result;
}
private List<TimeSlice> splitTimeSlices(LocalDateTime start, LocalDateTime end) {
List<TimeSlice> slices = new ArrayList<>();
LocalDateTime cursor = start;
while (cursor.isBefore(end)) {
// 找下一个峰谷切换点
LocalDateTime nextBoundary = findNextBoundary(cursor);
LocalDateTime sliceEnd = nextBoundary.isBefore(end) ? nextBoundary : end;
slices.add(new TimeSlice(cursor, sliceEnd));
cursor = sliceEnd;
}
return slices;
}
private LocalDateTime findNextBoundary(LocalDateTime time) {
LocalTime peakStart = config.getPeakStart();
LocalTime peakEnd = config.getPeakEnd();
LocalDate date = time.toLocalDate();
// 高峰从今天的 peakStart 开始
LocalDateTime todayPeakStart = LocalDateTime.of(date, peakStart);
// 高峰结束可能是跨天(例如 00:00 算次日凌晨)
LocalDateTime peakFinish = peakEnd.isAfter(peakStart)
? LocalDateTime.of(date, peakEnd)
: LocalDateTime.of(date.plusDays(1), peakEnd);
// 当前时间不在任何区间内?找离当前最近的下一个边界
LocalDateTime next;
if (time.isBefore(todayPeakStart)) {
next = todayPeakStart;
} else if (time.isBefore(peakFinish)) {
next = peakFinish;
} else {
next = todayPeakStart.plusDays(1);
}
return next;
}
private BigDecimal getPriceForTime(LocalDateTime time) {
LocalTime t = time.toLocalTime();
if (isPeakTime(t)) {
return config.getPeakPrice();
}
return config.getNormalPrice();
}
private boolean isPeakTime(LocalTime time) {
LocalTime peakStart = config.getPeakStart();
LocalTime peakEnd = config.getPeakEnd();
if (peakEnd.isAfter(peakStart)) {
return !time.isBefore(peakStart) && time.isBefore(peakEnd);
} else {
// 跨天区间:例如 22:00 到 02:00
return !time.isBefore(peakStart) || time.isBefore(peakEnd);
}
}
}
这个算法的核心在两个点。第一,价格和时长完全解耦——先按时间线切割得到"纯时长切片",每个切片的单价由该切片起点时间独立判定。切片的时长用分钟数表示,最后统一转成小时数乘以单价,避免了浮点数累计误差。第二,所有金额计算全部用BigDecimal并指定RoundingMode.HALF_UP,这是金额计算绝对不能被妥协的红线——float算0.1+0.2会得到0.30000000000000004,这在计费系统里是不可接受的。
测试时我发现一个隐蔽Bug:如果开台时间恰好是19:00:00的零秒,findNextBoundary会把它归入高峰区间,但如果开台时间是18:59:59,那第一分钟按普通价算,19:00之后全部按高峰价。边界的纳秒级差异可能导致1分钱误差,用BigDecimal精确到分之后这个误差被消除了。这提醒我:所有时间边界判断必须用>=和<,绝对不能两边都用>=或者两边都用<,否则会出现重叠或空洞。
5.3 结账与找零:会员余额、微信支付宝、现金混合支付的实现
台球厅的结账场景比普通电商复杂在"多方式组合支付"。客人可能是会员,卡里余额不够,还得补微信;也可能不打球只买了两瓶饮料,走商品销售通道。所以我设计支付环节不是单一支付接口,而是一个"支付组合器"。
OrderServlet的settle接口流程是这样的:先调BillingEngine计算截止当前时刻的应收金额;再检测该订单是否已有预付款(开台时的押金),押金自动抵扣;剩余金额允许组合支付——先扣会员余额(如果勾选了使用余额),余额不足的部分选择现金/微信/支付宝。每笔支付都生成独立的payment_record记录,pay_type字段区分,这样对账的时候,现金收了多少钱、微信收了多少钱一目了然。
前端结账弹窗里,我用Ajax实时展示费用明细表:普通时段时长、高峰时段时长、单价、小计、折扣、应收、押金、待付。每项都对应数据库里的order_time_detail记录,客人在手机上能看到、能拍照留存,这算是这套系统在"信任设计"上的一步妙棋。
5.4 续费预付费:从账户余额自动抵扣的联动
散客开台一般只压50块押金,打超了怎么办?系统在定时任务里发现费用达到预付金额的80%时,自动向桌台面板推送一条提醒——"3号台费用已达45元,预付费50元即将耗尽"。收银台看到提醒后可以主动联系客人续费,避免打完才扯皮。
续费操作本身很简单:收银员选择订单,输入续费金额,系统调用RechargeServlet生成payment_record的同时更新这个订单的"预付费总额"。我特意不把续费金额直接加到orders表的total_amount字段上,因为total_amount是应收金额,不是预收金额,两者混淆是账目混乱的根源。我额外用字段deposit_amount记录预收总额,结算时用total_amount减去deposit_amount得到待收金额。如果待收金额为负(续费多了),结账时自动原路退回余额或者现金找零。
这个"预收/应收分离"的设计,也是我从真实球房老板那里学到的。手写单据时代,押金和消费款常常混在一个格子里,月底根本分不清哪笔是押金哪笔是消费,所以我把它作为一个核心设计原则刻在了数据库里。
6. 会员体系和商品销售:台球厅的副业收入同样需要管理
6.1 会员储值、积分和等级折扣的联动逻辑
会员储值这块,我参考了连锁球房的通用玩法:充500送50,充1000送150,充值金额进入balance字段,赠送金额单独用bonus字段记录,结账优先扣赠送金额还是优先扣本金,会直接影响财务报表里"充值赠送负债"的计算。我的实现是结账时先扣bonus,再扣balance,这样本金永远是客人的钱,赠送部分才是球房的实际营销成本。
积分规则是每消费1元积1分。积分有两个用途:积分抵现(100积分抵1元)和积分兑换商品。这个逻辑放PaymentService里,结账事务里完成了余额扣减、积分增加和等级刷新。会员等级按月消费额自动升降级——月度消费超2000升金卡,金卡折扣从8.8折降到8折。升降级的触发放在定时任务里,每天凌晨跑一次,扫上个月每个会员的消费汇总,更新level字段。
6.2 商品售卖与桌台订单的挂账合并
球房的小卖部收入往往能占营业额的25%以上,但很多课设项目完全没做商品模块。我加了商品类别、商品库存、进货价、售价、售卖记录这几张表,并支持把商品消费挂到桌台订单下——客人打球时点了两瓶可乐一包烟,结账时和台费一起结算,出一张单。
实现的难点在库存扣减时机。我选择"下单即扣库存、结账失败则回滚"的事务策略,而不是"结账成功才扣库存",因为球房小卖部的商品都是即买即取,不允许超卖。商品数据存在goods表,售卖记录存在sale_item表,挂账时把order_id写入sale_item,结账主事务里同时更新台费支付状态和商品销售状态,保证一份订单要么全部成功要么全部回滚。
6.3 会员卡挂失与余额冻结——一个让人头疼的细节
还有一个容易被忽略但实际运营中一定会遇到的功能:会员卡挂失。客人卡丢了,如果不冻结余额,别人捡到就能盗刷。我实现了挂失操作:member表的status字段从1正常改为2挂失,挂失状态下结账接口直接拒绝,提示收银员引导客人办理补卡。补卡操作会生成一张新卡号(用UUID截取),旧卡余额整体平移,并记录一条operation_log用于审计。
这个功能技术含量不高,但业务意义很大。我在系统演示时专门给球房老板演示了挂失流程,他能直观感觉到这套系统不是简单记录数据,是真的站在经营者角度做的工具。
7. 报表统计与数据可视化:老板真正关心的不是功能而是钱
7.1 日结报表:让交接班不再扯皮
台球厅一般两班倒,早班收银员和晚班收银员交接时最怕账目对不上。我的日结报表页面,默认展示当天的:现金收入、微信收入、支付宝收入、会员余额消费、总单数、总台时数、高峰台时数、普通台时数。数据源是payment_record表按pay_type分组聚合,加上orders表按时间汇总。核心SQL大概长这样:
sql复制SELECT pay_type, COUNT(*) AS cnt, SUM(amount) AS total_amount
FROM payment_record
WHERE create_time >= ? AND create_time < ?
GROUP BY pay_type;
但这里有个坑:直接查当天日结,会因为订单跨天而产生归属误差——凌晨0点05分结账的订单,台费是晚上打的,这笔收入算前一天还是当天?我的选择是:收入按支付时间归入当天,台时消耗按开台时间归入当天。虽然这样会导致某一天"台时多但收入少"的错位,但总比把跨天订单拉扯来拉扯去清晰得多。报表页面顶部加了明确的统计口径说明,收银员一看就懂。
7.2 营收趋势图:用原生ECharts而不是引入重量级框架
趋势图页面我选了ECharts,理由很简单:饼图、柱状图、折线图开箱即用,中文文档完善,通过CDN引入即可。我在dashboard.jsp里放了一个近7日营收折线图和一个当日台时分布柱状图,数据由ChartServlet返回JSON格式,前端Ajax拉取后setOption渲染。
统计性能上,初期我直接从orders表全表SUM,数据量到几十万条后明显变慢。后来加了针对create_time和pay_status的联合索引,速度从1.8秒降到80毫秒。再往后的百万级数据,我在test_data里验证了按月分表或加汇总表的需求迫切性——但作为单店管理系统,索引优化已经够用。
7.3 桌台利用率分析:找出球房的"僵尸时段"
报表模块里我觉得最出彩的是桌台利用率分析。它按小时统计一周内每个时间段的平均上座率,呈现方式是一个7×24的热力表格。这个分析看上去只是SELECT count加GROUP BY,但它能回答经营者一个非常重要的问题:周一到周四下午的桌台空闲率是多少?是否需要推出"下午场特价"来拉动流量?
实现思路:先把orders表里的订单记录按开台时间归属到具体的星期几和小时槽位,然后统计每个槽位的占用桌台时长,除以该时段的总可用桌台时长,得到利用率。总可用桌台时长 = 桌台数 × 7天。数据库用一条SQL join就出来了,不需要复杂的OLAP工具。
8. 权限与安全:收银员只能碰收银台,老板才能看报表
8.1 基于Filter的登录拦截和角色权限控制
权限这块我用的是最经典的三层角色模型:管理员、经理、收银员。收银员能操作用户端核心功能(开台、结账、商品销售),经理在收银员基础上多了报表查看和会员管理权限,管理员有全部权限,包括系统配置和用户管理。
实现是定义一个PermissionFilter拦截所有/admin/*请求,从Session里取当前登录用户,根据用户角色和请求路径的前缀做鉴权。路径前缀我定了三组:/admin/report/*(经理及以上)、/admin/system/*(仅管理员)、/admin/user/*(仅管理员),其余操作收银员都能访问。
这里有个容易被忽略的小坑:Ajax请求被Filter拦截时,如果直接重定向到登录页,前端收到的是200但内容是HTML登录页,JSON解析会报错。正确的做法是Filter里判断请求头X-Requested-With是否为XMLHttpRequest,是则返回401状态码,由前端统一拦截跳转到登录页。
8.2 密码加密和SQL注入预防:课设项目里也要有的底线意识
密码存储我绝不会用明文,这是底线。项目里用了SHA-256加盐的哈希存储,盐值用UUID随机生成,存到password_salt字段。虽然Spring Security里有更成熟的BCrypt实现,但为了不引入框架,自研一个加密工具类也就20行代码。这个类值得放出来:
java复制public class PasswordUtil {
public static String generateSalt() {
return UUID.randomUUID().toString().replaceAll("-", "");
}
public static String hashPassword(String rawPassword, String salt) {
String salted = salt + rawPassword + salt;
return DigestUtils.sha256Hex(salted);
}
public static boolean verify(String rawPassword, String salt, String storedHash) {
return storedHash.equals(hashPassword(rawPassword, salt));
}
}
SQL注入预防的关键是禁止字符串拼接SQL。项目里所有动态条件查询都用了PreparedStatement,参数占位符?传值。但有一点很容易忽视:like查询不能直接like '%?%',需要在Java层先拼接"%"+keyword+"%"再作为参数传入,否则PreparedStatement的占位符语法会报错。另外,commons-dbutils的QueryRunner在传参时已经帮我们处理了防注入,但自定义SQL拼接场景仍然要自己注意。
8.3 敏感操作留痕:所有钱相关的动作都要有操作日志
操作日志是一个管理系统和"玩具系统"之间最明显的分界线。球房里,员工操作收银系统时发生争议是非常常见的事——"我没给他打折""我没退过这笔款"。没有日志,任何争议都无从还原。
我的实现是:在OrderServlet、RechargeServlet、MemberServlet的关键方法里,调用LogUtil.write(operatorId, content),把操作时间、操作人、操作内容、关联订单号写进operation_log表。为了防止误操作,退款和删除会员这类高敏操作,前端还加了一层管理员密码二次确认——弹窗输入当前管理员密码,后端验证通过才继续执行。
9. 部署方案与性能优化:从本地跑通到真正上线
9.1 本地部署:Tomcat点击启动 vs 命令行部署
开发阶段用IDEA里的Tomcat集成最方便。到部署阶段,我建议还是走命令行,原因很简单:IDEA集成方式依赖IDE进程,停止项目IDE就断了,而生产环境不可能开着IDE。我整理了一份部署脚本:
bash复制# 1. 打包 war
mvn clean package -DskipTests
# 2. 复制至 Tomcat webapps
cp target/billiards.war $TOMCAT_HOME/webapps/
# 3. 如果有正在运行的旧版本,先停掉
$TOMCAT_HOME/bin/shutdown.sh
# 4. 启动 Tomcat(会自动解压战争包)
$TOMCAT_HOME/bin/startup.sh
# 5. 确认日志正常
tail -f $TOMCAT_HOME/logs/catalina.out
Linux服务器上部署需要注意文件权限:webapps目录下的项目文件属主必须是启动Tomcat的用户,否则上传war后解压会报权限错误。另外MySQL连接串里的时区参数要显式设置:jdbc:mysql://ip:3306/billiards?serverTimezone=Asia/Shanghai,不设的话会报连接超时或时间错乱。
如果是Windows服务器,不需要装完整Tomcat服务,直接用tomcat9.exe启动即可。资源紧张的话,Tomcat的JVM参数可以调一下:-Xms256m -Xmx512m,这个体量的项目足够跑得动。
9.2 静态资源缓存与Ajax轮询的流量优化
因为前端用了大量Ajax定时轮询(每10秒拉一次实时费用),流量和数据库压力需要控制。我做了两件事优化:
第一,静态资源(JS、CSS、图片)在web.xml里配置了ExpiresFilter设置缓存时间,浏览器端大量CSS/JS文件就不会每次请求都重新下载。ECharts的echarts.min.js有1MB左右,不缓存的话很影响加载速度。
第二,实时费用查询接口走的是一个轻量级Servlet,只查orders表、config表和order_time_detail的聚合结果,SQL经过优化,索引覆盖到位。实测在50个并发轮询下,Tomcat默认线程池200个线程能轻松扛住,数据库连接池Druid配置了最大连接数20,足够。
9.3 数据备份策略:定时任务+mysqldump双保险
球房老板对IT系统的要求往往很简单:不能丢数据。所以我把备份功能直接做进了系统后台——管理员可以一键备份,系统调用mysqldump把数据库导出成SQL文件存到服务器备份目录,同时在本地文件系统保留最近30天的备份副本。
mysqldump命令注意要用绝对路径,并且要把密码通过-p参数传入,但命令行里直接写密码会记录到shell历史里。我的解决方式是写一个backup.sh脚本,MySQL密码在脚本中通过环境变量引用,并设置600权限。生产环境强烈建议另外配一个每天凌晨的crontab定时备份,双保险。这个细节虽然不复杂,但能把系统从"课设级别"拉高到"可商用级别"。
10. 实测手记:这三处易错点最难排查,我帮你提前踩过了
10.1 时分秒精度对计费结果的隐藏影响
前期自测时,我遇到了一个很诡异的问题:同一笔订单,前后两次查询,费用不一样。排查了一下午,最后发现是时间精度问题——同一个下单时间,在订单列表页被格式化成"yyyy-MM-dd HH:mm"展示(秒被截掉),但在计费计算时用的是完整的"yyyy-MM-dd HH:mm:ss"。比如客人20:30:58开台,列表显示20:30,结账时计算用的却是20:30:58,产生了58秒的计费差,如果这58秒横跨了峰谷切换点,差额可能更大。
解决方式是统一口径:进入BillingEngine之前,所有时间统统转换为"分钟精度"——秒和纳秒直接置零,开台时间统一向下取整到分钟,结账时间统一向上取整到分钟。这样保证数据库存的值、页面显示的值、计算结果使用的值三者完全一致,消除了因时间格式化不一致引发的计费纠纷。
10.2 并发开台导致的桌台超卖
这是高并发场景下的经典问题:两个收银员几乎同时点击开台,都看到3号桌空闲,都成功开了台,3号桌被占用了两次。单机Tomcat还好,一旦上了多线程,不处理就会出问题。
解决思路很简单粗暴:在桌台开台事务里加SELECT ... FOR UPDATE行锁,锁住table_info表里该id的行,再判断status是否为空闲,是才更新。这样两个并发事务里,后到的那个会阻塞直到第一个提交,然后读到最新的status=2,发现不是空闲,直接返回"桌台已被占用"。另外我在桌台前端面板加了一层本地标志,点击后立即变灰,防止用户重复请求。双保险之后,压测没有再出现过超卖。
10.3 Tomcat 9和JDK版本配合不当导致的部署失败
这是初学者最容易踩但不能马上意识到的坑。Tomcat 9本身要求JDK8+,但如果你的IDEA项目编译级别设成了1.8,而本机JDK是17,那么编译产生的class文件是17版本的字节码,Tomcat 9加载会直接报UnsupportedClassVersionError。解决方法是:Project Structure → Modules → 把Language Level设为8,同时Maven的compiler插件显式指定<source>1.8</source><target>1.8</target>,这样不管本机装了什么版本JDK,编译产物都是Java 8兼容的。
同理,MySQL连接驱动版本也要和MySQL服务版本兼容,mysql-connector-java 8.x连接MySQL 5.7会出现认证插件不兼容的报错,需要改用8.0.30以上版本或调整MySQL的default_authentication_plugin参数。这类版本错配问题尤其隐蔽,我的建议是搭建环境时就把版本矩阵明确列出来,避免临到部署才排查。
11. 这套系统还能怎么延伸:从课设/毕设到商用部署的三个扩展方向
11.1 扩展方向一:硬件联动(闸机、智能灯控、自助终端)
如果真正落地商用,第一个要接的硬件就是门禁闸机——客人扫码开台后自动放行到对应台区,结账后闸机关闭。实现思路是在开台/结账的Service方法里增加一个硬件控制接口回调,通过TCP或HTTP调用闸机控制器。计费底层逻辑完全不用改,只是多了一个事件出口。
再往前一步是智能灯控。斯诺克区的灯光在客人开台时自动点亮,结账后延迟3分钟自动熄灭,避免"人走了灯还亮着"的电费浪费。这个对球房运营来说是真金白银的节省。改造点在TableService的open/settle方法,增加对灯控设备的socket指令。
11.2 扩展方向二:小程序端和移动端自适应
传统B/S系统在PC上体验不错,但球房收银台空间有限,很多老板希望平板或手机也能操作。我给前端用的是Bootstrap 5,天然支持响应式布局,所以页面在平板上基本都能用。真正需要开发的是微信小程序端——客人扫码小程序自助开台、自助续费、结账离场,全程不需要找收银员。底层API只需要在现有Servlet基础上加一套JSON接口,小程序端调这些接口即可。
需要注意的坑是跨域:小程序请求不经过浏览器,不存在CORS限制,但域名必须备案且是HTTPS。小程序的session管理使用token而非Cookie,需要改造登录逻辑。这套扩展做完,球房的人力成本能显著下降——高峰期甚至一个店员就能盯全场。
11.3 扩展方向三:多店连锁与云端部署
一个店做完了,老板多半会问:我第二家店能不能也用这个系统?答案是可以,但要做改造。核心是把单店数据库拆成"中心数据库+分店数据库"的结构,每个分店一套本地部署,每晚向中心库同步营业数据。报表模块从单店查询改成跨店聚合,新增分店维度。
如果不想每家店都买服务器,也可以直接把系统部署到云服务器,所有分店连同一个数据库,通过网络访问。这样部署最简单,但对网络稳定性要求高——球房收银断网就等于瘫痪。折中方案是本地局域网部署+云端定时同步,这也是目前市面上商业化棋牌室系统的常见架构。
