简介:在企业级应用开发中,B/S架构已成为信息管理系统的主流选择,而事务一致性与并发控制则是后端开发的核心挑战。以Spring Boot、MyBatis等主流框架为基础,通过合理的数据库表设计与原子更新操作,可以有效保障会员余额、上机计时等关键数据的准确性。此类技术方案广泛应用于网吧、网咖等场所的会员管理和计费场景,解决手工登记效率低、账目易出错等痛点。本文围绕一套完整的网吧会员管理系统,从数据库设计、后端接口开发到Layui前端页面实现,详细讲解上机下机自动计费、充值流水、营收统计等核心功能,并给出可直接落地的源码与部署避坑指南,为类似管理系统的开发提供切实参考。 先交代一下背景。去年接了个网吧老板的单子,需求就三句话:会员办卡充值要在电脑上搞定,上机下机能自动计时扣费,每天营收多少一眼能看清。听上去简单,实际动手做才发现,这里面牵扯到数据一致性、并发扣费、跨天计费这些硬骨头。这篇文章就围绕这套基于Java的网吧会员管理系统设计源码和前端实现,把我踩过的坑、验证过可行的方案,从头到尾讲一遍。如果你正准备做类似的Java课程设计、毕业设计,或者接了店里的小系统项目,可以直接照着我这套思路往下落。内容涉及数据库表设计、Spring Boot后端接口、Layui前端页面、本地部署四个大块,每一块我都会给出能直接用的代码和配置。
1. 项目整体定位与技术选型
1.1 网吧会员系统到底在解决什么问题
很多刚入门的同学一听“网吧会员管理系统”,第一反应是“这不是很简单的增删改查吗”。真做起来你会发现,增删改查只是外壳,核心难点全在业务规则上。过去网吧用手工登记,会员开卡就发一张卡,充值和消费全记在纸质本子上。老板最头疼三件事:账对不上、会员余额记不清、上机时间靠人肉盯。所以这套系统首先要解决的,是把“会员信息、账户余额、上机计费、充值流水、营收统计”这五块业务全部数字化,而且每一步都要有据可查。
基于这个目标,系统拆出来大概七个功能点:管理员登录、会员开卡/挂失/注销、会员充值、上机登记、下机结算、消费明细查询、今日营收统计。会员又分两种,一种是正式会员,用卡号关联账户;一种是临时卡,不用办卡,交押金直接开台。临时卡在结算时按实际上机时间扣费,剩下的押金退还。这两类用户的计费逻辑一样,但会员会关联余额账户,临时卡则是在结算时算差额,所以数据库层面要有区分字段。
1.2 技术选型:三套方案里我为什么选这套
先说我踩过的一个选择坑。最早我想用纯Java Swing做桌面端,毕竟网吧收银台就是固定一台电脑,桌面端似乎更直接。但后来想清楚一个问题:老板虽然只在店里看数据,但会员系统未来极可能要做微信端查询甚至连锁店统一管理,桌面端在扩展性上是死路。于是直接转向B/S架构,浏览器访问,后端用Java,前端单独做页面,这也是当前主流招聘简历上最常见的组合。
具体技术栈我定的是Spring Boot 2.7 + MyBatis + MySQL 8.0 + Layui。为什么选Spring Boot而不选传统SSM?因为SSM的XML配置太啰嗦,Spring Boot自动配置极大减少了环境搭建成本,开发期间不需要关心Tomcat部署,一个jar包直接跑起来。MyBatis则有很强的SQL可控性,像余额更新、报表聚合这类SQL我能精确控制,比Hibernate更适合这种偏业务计算的场景。前端没有用前后端分离的Vue,而是用Layui,原因很实际:项目本身页面复杂度不高,Layui自带表格、表单、弹层和日期组件,几行JS就能渲染出后台管理界面,省去构建Vue工程和跨域联调的环节。如果你们是多人协作、页面交互重,那可以换成Vue+Element UI,但当前这个体量,Layui性价比最高。
这里顺带说一句,选择技术栈先看场景复杂度,不要为了所谓“主流”强行上重框架。网吧会员系统是典型的内部管理系统,用户量就是店内几台收银机,并发极低,真正要关注的是业务正确性和开发效率。
2. 数据库设计:几张核心表奠定整个系统
2.1 从业务反推表结构
数据库设计绝对不能上来就建表,而是先走业务流程。我梳理了五个核心实体:管理员、会员、电脑、上机记录、充值记录,外加一张消费记录表。每一张表的字段我都坚持一个原则:能存原始数据就不要存计算结果,时间字段必须精确到秒,金额字段全部用DECIMAL,避免浮点数精度丢失。
会员表是核心,字段包括id、卡号card_no、姓名name、手机号phone、余额balance、状态status、创建时间create_time。卡号必须唯一,这是会员身份标识。状态字段用TINYINT,0代表正常,1代表挂失,2代表注销。这里有个细节:注销不是物理删除,因为会员的历史消费记录还要留底,物理删了流水就成孤儿数据了。余额字段我用DECIMAL(10,2),默认0,绝不用float。
上机记录表machine_record则关联了member_id和computer_id,还记录了开始时间start_time、结束时间end_time、时长duration_minutes和消费金额amount。表里专门加了一个is_temp字段区分临时卡与正式会员,这样统计正式会员消费和临时卡押金时就能直接筛选。电脑表computer单独维护机位信息,字段包括machine_no、area(普通区、电竞区、包间)、unit_price(区域单价,元/小时)和status(0空闲、1使用中、2维护)。区域单价放在电脑表是为了方便老板调价,不同区域价格可能不一样,同一区域所有机器共用单价,这也简化了计费逻辑。
下面是核心建表SQL,我精简了不相干的字段:
CREATE TABLE member ( id BIGINT PRIMARY KEY AUTO_INCREMENT, card_no VARCHAR(20) NOT NULL UNIQUE, name VARCHAR(50) NOT NULL, phone VARCHAR(11), balance DECIMAL(10,2) NOT NULL DEFAULT 0.00, status TINYINT NOT NULL DEFAULT 0 COMMENT '0正常 1挂失 2注销', create_time DATETIME DEFAULT CURRENT_TIMESTAMP ); CREATE TABLE computer ( id INT PRIMARY KEY AUTO_INCREMENT, machine_no VARCHAR(10) NOT NULL UNIQUE, area VARCHAR(20) COMMENT '普通区/电竞区/包间', unit_price DECIMAL(10,2) COMMENT '每小时单价', status TINYINT DEFAULT 0 COMMENT '0空闲 1使用中 2维护' ); CREATE TABLE machine_record ( id BIGINT PRIMARY KEY AUTO_INCREMENT, member_id BIGINT, computer_id INT, machine_no VARCHAR(10), start_time DATETIME, end_time DATETIME, duration_minutes INT, amount DECIMAL(10,2), is_temp TINYINT DEFAULT 0, status TINYINT DEFAULT 1 COMMENT '1上机中 2已下机', create_time DATETIME DEFAULT CURRENT_TIMESTAMP ); CREATE TABLE recharge_record ( id BIGINT PRIMARY KEY AUTO_INCREMENT, member_id BIGINT, amount DECIMAL(10,2), operator_id BIGINT, create_time DATETIME DEFAULT CURRENT_TIMESTAMP );这里我要特别说明为什么把消费流水单独拆一张consume_record表,而不是只靠上机记录去统计。因为网吧除了上网费,还有卖饮料、零食的消费场景,单一的上机记录承载不了商品消费。虽然不是第一版需求,但表结构上预留好,后续扩展就不需要大改。consume_record表字段设计为member_id、record_id(关联上机记录,可为空)、amount、create_time。
2.2 计费与流水表的设计细节
计费规则看起来简单,就是“每小时单价,按分钟计费”,但真落到SQL里有两个坑。
第一个坑是金额字段类型。如果用了FLOAT,下机结算时0.1+0.2这类计算会出现丑陋的浮点尾巴,导致账目对不上。我统一用DECIMAL(10,2),所有金额运算都在Java中以BigDecimal完成,特别是充值时,页面拿到String类型的金额,一定要用new BigDecimal(str)而不是Double.parseDouble,否则精度又会丢。实际开发中我吃过这个亏,10块钱充进去余额变成9.999999998,老板对账时怎么看怎么不对劲。
第二个坑是流水表和数据一致性之间的关系。每笔充值、每笔扣费,都必须对应一条流水记录,不能只改member表的balance字段。举例说明:会员充值时,我先往recharge_record插入一条数据,再更新member表balance,这两个操作必须放在同一个数据库事务里,否则插入成功但余额没加上,账就错了。我当时用的是Spring的@Transactional注解,默认遇到RuntimeException就回滚,但要注意MySQL的引擎必须是InnoDB,MyISAM不支持事务,建表时如果沿用旧习惯就会静默失效。
下机计费还有一个容易遗漏的点:电脑状态和时间。下机时不仅要更新上机记录的end_time和amount,还必须把computer表的状态改回0空闲,这两个动作同样要在事务里完成。如果只改了记录没改电脑状态,下一单上机时发现机器还在“使用中”,顾客就直接傻眼。我在实际开发过程中就反复遇到这类状态不同步问题,解决方案就是把状态变更和记录更新绑定在同一个Service方法里,谁也别想漏。
3. 后端核心逻辑实现
3.1 会员管理模块
后端整体按照三层结构走:Controller接收参数返回JSON,Service写业务逻辑,Mapper操作数据库。先看会员新增和分页查询,这是最常用的两个接口。
新增会员的Controller我这样写:
@RestController @RequestMapping("/api/member") public class MemberController { @Resource private MemberService memberService; @PostMapping("/add") public Result add(@RequestBody Member member) { // 卡号手动生成:前缀M + 时间戳后六位,避免用户自己乱填 String cardNo = "M" + System.currentTimeMillis() % 1000000; member.setCardNo(cardNo); return memberService.add(member); } @GetMapping("/page") public Result page(@RequestParam(defaultValue = "1") int pageNum, @RequestParam(defaultValue = "10") int pageSize, @RequestParam(required = false) String keyword) { return memberService.page(pageNum, pageSize, keyword); } }Service层有个细节:新增时手机号不是必填的,但卡号必须唯一。用户如果不小心重复提交,数据库唯一索引会兜底,但返回的异常信息用户看不懂,所以我在Service里捕获DuplicateKeyException,转换成友好提示“卡号重复,请重试”。分页查询则直接用MyBatis的PageHelper插件,一行代码搞定物理分页,不用手写LIMIT。
public Result page(int pageNum, int pageSize, String keyword) { PageHelper.startPage(pageNum, pageSize); List<Member> list = memberMapper.selectByKeyword(keyword); PageInfo<Member> info = new PageInfo<>(list); return Result.success(info); }这里我用了PageHelper,但生产环境要注意一下依赖版本和Spring Boot版本的兼容性,PageHelper 5.x对Spring Boot 2.x支持得不错。如果不用插件,手写LIMIT也完全可以,关键是SQL里要对keyword做一个LIKE匹配:
<select id="selectByKeyword" resultType="Member"> SELECT * FROM member <where> <if test="keyword != null and keyword != ''"> card_no LIKE CONCAT('%', #{keyword}, '%') OR name LIKE CONCAT('%', #{keyword}, '%') OR phone LIKE CONCAT('%', #{keyword}, '%') </if> </where> ORDER BY create_time DESC </select>3.2 上机、下机与充值扣费逻辑
上机动作的逻辑是:校验会员状态和余额,如果余额小于预设押金可以拒绝上机;如果电脑非空闲则报错。校验通过后,把电脑状态改为1使用中,插入一条machine_record,状态为1。下机逻辑则是结算重点:先查出上机记录,计算当前时间和start_time的分钟差,再根据电脑单价算出金额,更新记录状态和余额,最后把电脑状态改回空闲。
下机核心代码我写在这里,注释标注了关键点:
@Transactional public Result checkout(Long recordId) { MachineRecord record = recordMapper.selectById(recordId); if (record == null || record.getStatus() == 2) { return Result.error("上机记录不存在或已结算"); } Computer computer = computerMapper.selectById(record.getComputerId()); if (computer == null || computer.getStatus() != 1) { return Result.error("电脑状态异常"); } // 计算分钟差,向上取整,防止用户上机1秒扣0元 long minutes = Duration.between(record.getStartTime(), LocalDateTime.now()).toMinutes(); if (minutes < 1) minutes = 1; BigDecimal unitPrice = computer.getUnitPrice(); BigDecimal amount = unitPrice.multiply(BigDecimal.valueOf(minutes)) .divide(BigDecimal.valueOf(60), 2, RoundingMode.HALF_UP); // 更新上机记录 record.setEndTime(LocalDateTime.now()); record.setDurationMinutes((int) minutes); record.setAmount(amount); record.setStatus(2); recordMapper.updateById(record); // 正式会员从余额扣费,临时卡只记录不扣余额 if (record.getIsTemp() == 0) { memberMapper.decreaseBalance(record.getMemberId(), amount); } // 电脑释放 computer.setStatus(0); computerMapper.updateById(computer); return Result.success(amount); }这里有一个非常关键的并发扣费点。如果会员同时在两台机器上上机,或者收银员手一抖点了两下下机,可能产生重复扣费。解决方式我在Mapper里用了一条原子更新语句:
UPDATE member SET balance = balance - #{amount} WHERE id = #{memberId} AND balance >= #{amount}当返回受影响行数为0时,说明余额不足或账户不存在,上游方法直接抛出业务异常回滚事务,这样就能避免并发时余额被扣成负数。这条SQL其实就相当于数据库层面的乐观锁,简单又可靠。
充值逻辑相对简单,但同样要注意“先插流水再更新余额”的顺序,并且开启事务。存入充值流水时,我会记录操作员ID,方便老板事后审计哪个员工什么时候给哪个会员充了多少。这一步后来被老板专门表扬过,说终于能查到是不是有人私下乱改余额了。
3.3 当日报表统计
日报表是老板每天打开看的第一屏。我提供两个统计数据:今日营收(全部已下机的消费金额之和)、今日充值总额、当前在线人数。SQL用聚合函数直接算,比在Java内存里汇总效率高。
-- 今日营收,仅统计已下机的记录 SELECT COALESCE(SUM(amount), 0) FROM machine_record WHERE status = 2 AND DATE(end_time) = CURDATE(); -- 今日充值总额 SELECT COALESCE(SUM(amount), 0) FROM recharge_record WHERE DATE(create_time) = CURDATE(); -- 当前在线人数 SELECT COUNT(*) FROM machine_record WHERE status = 1;这里要注意一个坑:DATE(end_time) = CURDATE()这类写法在数据量大时无法走索引,全表扫描会越来越慢。对于网吧管理系统,单店一天几百条消费记录,这个写法完全没问题,但如果未来做连锁,就必须改成范围查询:end_time >= '2024-05-20 00:00:00' AND end_time < '2024-05-21 00:00:00'。我在代码注释里专门提醒了自己,也提醒后续看代码的人。
4. 前端实现:界面和交互怎么做
4.1 页面框架搭建
前端这块如果一上来就写CSS布局,纯属自我消耗。我选了Layui后,直接用它的后台布局脚手架。整体页面分三块:顶部是系统标题和当前管理员退出按钮,左侧是功能菜单,右侧是内容iframe区。菜单项就是会员管理、电脑管理、上机管理、充值与报表,这样老板和收银员用起来很直观,不用教。
登录页是最容易被忽视但用户最先接触的页面。我的做法是单独做一张背景干净的卡片式登录框,用户名密码输入框,加上一个简单的验证码调用后端接口生成,防止机器暴力尝试。验证码实现不复杂,后端生成4位随机数写入Session,前端用Canvas画出来。项目工期不紧的话建议加上这一步,效果十分实在。
主界面只需要一个index.html和若干子页面。我在index.html里把左侧菜单绑定好,点击菜单项时,iframe的src切换到对应子页面。这样写法很“古老”,但稳定可靠,而且Layui的官方文档就是这种风格,出问题好排查。
4.2 会员列表与上机面板的交互
会员列表页面是数据展示的核心,我用了Layui的table组件,加上分页条。数据源直接对接后端 /api/member/page接口,table组件会自己带上页码和每页条数参数。它的好处是渲染表格不用手写一堆tr/td,table.render一行JS就能搞定,而且支持表头排序、行点击事件,很适合这种内部管理系统。
上机面板是这家网吧最常用的操作界面。我给收银员设计了一个联动交互:左侧选电脑区域,右侧显示该区域电脑列表,绿色表示空闲,红色表示使用中。点击绿色电脑弹窗,输入会员卡号,后端校验通过后直接上机并刷新电脑状态。下机则更直接,点击红色电脑弹窗显示“已用时长、当前费用、确认下机”按钮,点确认后调后端的checkout接口。
这里的实现有个小技巧:电脑状态变化后,页面不能靠用户手动刷新,而是要在每次上机/下机操作成功后,重新请求一个查询所有电脑状态的接口,然后动态更新CSS样式。我用jQuery的$.getJSON配合Layui的layer.load做加载提示,操作反馈非常实时。
前端代码示例,上机时发送的AJAX请求:
function doOnMachine(machineId) { var cardNo = $('#cardNoInput').val().trim(); if (!cardNo) { layer.msg('请输入会员卡号'); return; } var loadIndex = layer.load(1); $.post('/api/machine/online', { machineId: machineId, cardNo: cardNo }, function (res) { layer.close(loadIndex); if (res.code === 200) { layer.msg('上机成功'); refreshMachineList(); // 刷新电脑状态 } else { layer.msg(res.msg); } }, 'json'); }上面这段代码最值得强调的是:后端接口返回的数据格式一定要统一。我强制所有接口返回{code: 200, msg: 'success', data: ...},前端只要判断code是否等于200,就统一处理成功和失败。如果每个接口返回格式都不一样,前端就要写一堆if判断,维护成本会直线上升。
4.3 前端细节体验优化
用户天天对着这个系统,体验细节不能糊弄。我在三个小地方做了优化,老板后来都很认可。第一个是金额输入框做了“只能输入数字和小数点后两位”的校验,从源头挡住非法字符。第二个是会员卡号输入后自动拉取会员信息,显示姓名和余额,收银员不用等后台查询结果就能确认是不是本人。第三个是下机确认弹窗中,用醒目的红色字体显示本次消费金额,避免顾客结账时产生纠纷。
关于页面刷新,我做了一个隐藏逻辑:电脑列表每30秒自动轮询一次状态,即使收银员没有操作,系统也会自动把突然掉线的电脑状态同步回来。这个设计出自一个实际场景,当时有台机器自己重启了,但页面还停留在上机状态,顾客下了机,系统却还扣着费,后来加了这个轮询才基本杜绝。
5. 源码结构、本地运行与避坑清单
5.1 项目目录结构与启动步骤
我把源码搞得比较规整,目录结构如下,方便大家对照:
netbar-member/ ├── src/main/java/com/example/netbar/ │ ├── controller/ │ │ ├── MemberController.java │ │ ├── MachineController.java │ │ └── RechargeController.java │ ├── service/ │ │ ├── MemberService.java │ │ ├── MachineService.java │ │ ├── RechargeService.java │ │ └── impl/ │ ├── mapper/ │ │ ├── MemberMapper.java │ │ ├── MachineMapper.java │ │ └── RecordMapper.java │ ├── entity/ │ │ ├── Member.java │ │ ├── Computer.java │ │ └── MachineRecord.java │ └── common/ │ └── Result.java ├── src/main/resources/ │ ├── mapper/ │ │ ├── MemberMapper.xml │ │ ├── MachineMapper.xml │ │ └── RecordMapper.xml │ └── application.yml ├── sql/ │ └── init.sql └── pom.xml运行前置环境三件套:JDK 8或11、Maven 3.6+、MySQL 5.7或8.0。先把sql/init.sql导入数据库,再修改application.yml里的数据库账号密码,最后在项目根目录执行mvn spring-boot:run。启动后用浏览器访问localhost:8080,默认管理员admin/admin123。
JDK版本这里提醒一句,Java 8和Spring Boot 2.7的搭配最稳妥,不要为了追新直接上Java 17,因为有些老依赖反射会报错。我之前被Java 17坑过,一个CGLIB代理问题排查了一下午,后来老老实实切回8。
5.2 新手最容易踩的五个坑
第一个坑是数据库连接配置。MySQL 8.0版本的驱动类名是com.mysql.cj.jdbc.Driver,不是旧的com.mysql.jdbc.Driver。同时URL里要加serverTimezone=Asia/Shanghai和useSSL=false,否则会报时区错误或者SSL握手失败。这些在application.yml里必须提前配好:
spring: datasource: url: jdbc:mysql://localhost:3306/netbar_db?serverTimezone=Asia/Shanghai&useSSL=false&characterEncoding=utf8 username: root password: 123456 driver-class-name: com.mysql.cj.jdbc.Driver第二个坑是中文乱码。如果你遇到的页面显示乱码,优先检查三处:数据库表是不是utf8mb4、connection的characterEncoding是不是utf8、前端页面的meta charset是不是utf-8。三处都要一致,缺一个都会出乱码。数据库建库语句我直接用:CREATE DATABASE netbar_db DEFAULT CHARACTER SET utf8mb4;
第三个坑是前端请求404。如果页面能打开但点击功能后网络请求404,多半是后端接口路径没对上。我建议前端所有请求都以/api开头,后端Controller类上全部标注@RequestMapping("/api/xxx"),这样路径一目了然。排错时打开浏览器F12看Network,比瞎猜高效得多。
第四个坑是跨天计费。有的机器晚上11点上机,凌晨1点下机,按时间差计算完全没问题,因为LocalDateTime计算就是毫秒差,不存在“日期跨天”这个概念。但如果你是按日期字符串截取来算的,就一定会算错。我这里用Duration.between()就是为了绕过这个坑。唯一要注意的是如果网吧设置通宵包时优惠,需要在计算前加一个判断:如果开始时间在22点后,则按通宵价计费。第一版我没做这个,被老板提了需求后补上的。
第五个坑是并发问题。虽然单店收银并发很低,但会员余额扣费这种操作绝不能忽略并发一致性。除了前面提到的原子UPDATE语句,我还统一收银台只放一台电脑操作,从物理上降低并发概率。如果以后做连锁,还需要引入Redis锁或者分布式事务,不过这就是另一个话题了。
做完这套系统,我最大的体会是:业务系统开发,代码本身没有多玄妙,真正拉开差距的是对业务细节的把控。会员充值会不会被重复提交,下机结算会不会因为异常导致电脑一直占着,报表数据是不是和前台收银记录对得上,这些才是老板真正在意的问题。你在参考这套源码时,不光是跑起来看效果,更建议把每个事务方法都读一遍,想清楚为什么充值、上机、下机都必须加@Transactional,理解透了,以后做任何管理系统都能触类旁通。
最后再分享一个小技巧:这套源码里前端的验证码接口、会员开卡时间生成、统计报表的SQL聚合,都是能直接复用到其他管理系统的通用模块。你做下一个项目时,把会员表换成客户表,把上机记录换成订单表,基本框架不用动,很快就能交付新需求。
本文还有配套的精品资源,点击获取