news 2026/10/20 3:48:47

基于Spring Boot的游泳馆管理系统:扫码入场与会员核销全链路实现

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
基于Spring Boot的游泳馆管理系统:扫码入场与会员核销全链路实现

简介:这是一份面向高校计算机专业学生与Java初学者的数据库课程设计完整源码,以游泳馆日常运营为业务背景,帮助读者理解如何将数据库原理与Java编程落地为可运行的管理系统。项目围绕会员管理、预约管理、场地管理与收费管理四大模块展开,涉及会员等级设置、预约冲突处理、场地状态调度、多方式支付记账及财务报表统计等典型业务场景,并遵循ER模型完成实体与关系设计,是练习需求分析、系统设计与编码测试全流程的合适素材。资源包共1286个文件,以1132个htm页面、63个gif素材、28个java源码、29个class文件及js、css、sql、mdf等为主,另含少量jar与dll依赖,压缩包约3.81MB,目录结构便于按模块查阅。目前已有1535人学习下载,适合需要参考完整课设方案、梳理数据库设计思路与Java界面实现的学习者。

1. 游泳馆管理系统:从手写台账到扫码入场,一套 Java 项目把日常运营串起来了

夏天一到,前台排队的场景我见过太多次:家长带着孩子挤在窗口,工作人员翻着纸质登记本找会员卡号,手边还压着一沓现金和零钱。这套游泳馆管理系统要解决的,就是这种“人一多就乱”的日常。它用 Java 写成,把会员办卡、充值扣次、入场核销、泳池区域管理和营业统计收进一个后台,前台扫码或输手机号就能完成一次入场。适合谁?一是中小型游泳馆、健身中心里负责信息化改造的开发,二是拿它当 Java Web 课程设计或毕业设计的同学。项目结构不复杂,但业务闭环完整,跑起来就能看到一条从办卡到入场的真实链路。

2. 技术选型与目录结构:为什么用 Spring Boot 而不是裸 Servlet

2.1 技术栈拆解:一套典型的 Java Web 组合

这个项目没有走微服务路线,选的是单体分层架构,对游泳馆这种并发量不大、业务相对固定的场景来说,单体反而更好维护。核心依赖如下:

层次技术作用
控制层Spring MVC接收前端请求,参数校验
业务层Spring Service会员、入场、计费逻辑
持久层MyBatis-Plus单表 CRUD 与条件查询
数据库MySQL 8会员、订单、入场记录存储
前端Thymeleaf + Bootstrap后台管理页面渲染
构建Maven依赖管理与打包

为什么用 MyBatis-Plus 而不是 JPA?我一般会看项目里“条件查询”多不多。游泳馆系统里查会员要按姓名、手机号、卡状态、到期时间组合筛选,MyBatis-Plus 的LambdaQueryWrapper写起来比 JPA 的 Specification 直观,改起来也快。Spring Boot 版本选 2.7.x 是常见做法,JDK 用 8 或 11 都能跑,新手不用折腾新版本兼容问题。

2.2 目录结构:包名就是业务地图

项目根目录下是标准的 Maven 结构,src/main/java里按功能分包:

com.swim ├── config # 拦截器、跨域、MyBatis 配置 ├── controller # MemberController、EntryController、OrderController ├── service │ ├── impl # 业务实现 ├── mapper # 继承 BaseMapper 的接口 ├── entity # Member、EntryRecord、RechargeOrder ├── dto # 前端传参与返回封装 ├── common # Result、异常处理、常量 └── SwimApplication.java

resources下放application.yml、mapper/*.xml(复杂 SQL 才用 XML)和templates页面。这种分包方式的好处是:拿到项目先看controller有哪些类,基本就知道系统能干什么。entity里的字段和数据库表一一对应,改字段时两边一起改,不容易漏。

2.3 数据库表设计:三张核心表撑起业务

建表 SQL 在resources/sql/init.sql里,核心三张表:

-- 会员表:一张卡对应一条记录 CREATE TABLE member ( id BIGINT PRIMARY KEY AUTO_INCREMENT, card_no VARCHAR(32) UNIQUE NOT NULL, -- 卡号,扫码用 name VARCHAR(50) NOT NULL, phone VARCHAR(20) NOT NULL, balance DECIMAL(10,2) DEFAULT 0, -- 余额,按次扣费时用 remain_times INT DEFAULT 0, -- 剩余次数 expire_date DATE, -- 到期日 status TINYINT DEFAULT 1 -- 1正常 0冻结 ); -- 入场记录:每次核销写一条 CREATE TABLE entry_record ( id BIGINT PRIMARY KEY AUTO_INCREMENT, member_id BIGINT NOT NULL, entry_time DATETIME DEFAULT CURRENT_TIMESTAMP, channel VARCHAR(20), -- 前台/闸机 cost_type TINYINT -- 1扣次 2扣费 ); -- 充值订单:记录每一笔钱 CREATE TABLE recharge_order ( id BIGINT PRIMARY KEY AUTO_INCREMENT, member_id BIGINT NOT NULL, amount DECIMAL(10,2) NOT NULL, pay_type TINYINT, -- 1现金 2扫码 create_time DATETIME DEFAULT CURRENT_TIMESTAMP );

card_no加唯一索引是为了扫码入场时能直接命中,不用全表扫。entry_record不存冗余的会员姓名,查的时候 join 一下就行,避免会员改名后历史记录对不上。这是我在实际项目里踩过坑之后养成的习惯:流水表只存 ID 和当时的状态,展示层再去关联。

3. 核心模块拆解:一次入场核销的完整链路

3.1 入场核销接口:从扫码到扣次

这是整个系统最核心的一段代码,前台扫码或输入卡号后调用:

@PostMapping("/entry/checkin") public Result<EntryVO> checkin(@RequestBody @Valid EntryDTO dto) { // 1. 按卡号查会员,查不到直接返回 Member member = memberService.getByCardNo(dto.getCardNo()); if (member == null) { return Result.fail("卡号不存在"); } // 2. 状态校验:冻结卡、过期卡不允许入场 if (member.getStatus() == 0) { return Result.fail("该卡已冻结,请联系前台"); } if (member.getExpireDate().before(new Date())) { return Result.fail("会员卡已过期"); } // 3. 扣次或扣费,二选一 if (member.getRemainTimes() > 0) { member.setRemainTimes(member.getRemainTimes() - 1); memberService.updateById(member); entryService.saveRecord(member.getId(), 1); } else if (member.getBalance().compareTo(BigDecimal.valueOf(30)) >= 0) { member.setBalance(member.getBalance().subtract(BigDecimal.valueOf(30))); memberService.updateById(member); entryService.saveRecord(member.getId(), 2); } else { return Result.fail("次数不足且余额不够,请先充值"); } return Result.ok(new EntryVO(member.getName(), member.getRemainTimes())); }

逻辑说明:先查会员,再做状态和有效期校验,最后按“优先扣次、次选扣费”的顺序处理。EntryDTO里只有cardNo一个必填字段,@Valid保证空值不会进业务。扣费金额 30 元是写死的常量,实际改造时应该抽到配置表或application.yml里,不同泳池定价不一样。entryService.saveRecord里会写一条entry_record,channel字段区分是前台操作还是闸机自动上报。

参数说明:cardNo是会员卡唯一编号,扫码枪扫出来的就是它;costType在记录表里区分扣次还是扣费,方便月底统计“有多少人是刷卡次数、多少人是临时付费”。

3.2 会员充值:余额和次数的双轨处理

充值逻辑比入场多一层:要同时更新会员余额和写订单。常见做法是用@Transactional包住:

@Transactional(rollbackFor = Exception.class) public void recharge(Long memberId, BigDecimal amount, Integer payType) { Member member = memberMapper.selectById(memberId); if (member == null) { throw new BizException("会员不存在"); } // 更新余额 member.setBalance(member.getBalance().add(amount)); memberMapper.updateById(member); // 写充值订单 RechargeOrder order = new RechargeOrder(); order.setMemberId(memberId); order.setAmount(amount); order.setPayType(payType); orderMapper.insert(order); }

rollbackFor = Exception.class是血泪经验:默认只回滚运行时异常,如果业务里抛了受检异常,订单写了但余额没加,账就对不上。加上这个参数,任何异常都回滚。payType区分现金和扫码,月底对账时按这个字段分组求和,和收银台的实际收款核对。

3.3 会员列表查询:多条件组合筛选

后台最常用的页面是会员列表,支持按姓名、手机号、卡状态筛选:

public Page<MemberVO> pageQuery(MemberQuery query) { LambdaQueryWrapper<Member> wrapper = new LambdaQueryWrapper<>(); wrapper.like(StringUtils.hasText(query.getName()), Member::getName, query.getName()) .like(StringUtils.hasText(query.getPhone()), Member::getPhone, query.getPhone()) .eq(query.getStatus() != null, Member::getStatus, query.getStatus()) .orderByDesc(Member::getId); Page<Member> page = memberMapper.selectPage(new Page<>(query.getPageNum(), query.getPageSize()), wrapper); return page.convert(this::toVO); }

like条件前加了StringUtils.hasText判断,参数为空时不拼进 SQL,避免like '%%'全表扫。orderByDesc按 ID 倒序,新会员排前面,前台找刚办卡的人方便。分页用 MyBatis-Plus 的Page,前端传pageNum和pageSize就行。

4. 本地跑起来:从建库到访问后台的完整步骤

4.1 环境准备与数据库初始化

先确认本机有 JDK 8+、Maven 3.6+、MySQL 8。建库语句:

mysql -u root -p -e "CREATE DATABASE swim_db DEFAULT CHARSET utf8mb4;" mysql -u root -p swim_db < src/main/resources/sql/init.sql

init.sql里除了建表,还会插入一条测试会员数据,卡号1001,剩余次数 10 次,方便跑通流程。字符集用utf8mb4是为了存中文姓名和可能的 emoji 备注,用utf8在某些客户端会乱码。

4.2 修改配置并启动

打开application.yml,改数据库连接:

spring: datasource: url: jdbc:mysql://localhost:3306/swim_db?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai username: root password: 你的密码 driver-class-name: com.mysql.cj.jdbc.Driver

serverTimezone必须配,否则 MySQL 8 的时区问题会让entry_time差 8 小时。改完在项目根目录执行:

mvn clean package -DskipTests java -jar target/swim-0.0.1-SNAPSHOT.jar

启动成功后访问http://localhost:8080/login,默认账号admin,密码123456(在init.sql的sys_user表里)。登录后左侧菜单能看到会员管理、入场核销、充值订单、营业统计四个模块。

4.3 验证核心流程

按这个顺序点一遍,能跑通说明环境没问题:先在“会员管理”里新增一个会员,卡号填2001,剩余次数填 5;然后进“入场核销”,输入2001点核销,提示“核销成功,剩余 4 次”;再回会员列表看剩余次数是不是变成 4;最后进“充值订单”,给这个会员充 100 元,看余额和订单记录是否都更新。这一套走完,说明数据库连接、事务、前后端交互都是通的。

提示:如果核销时报“卡号不存在”,先检查member表里有没有数据,再看card_no字段是不是被空格污染了,扫码枪有时会带换行符。

5. 避坑与排查:部署和二次开发时最容易翻车的几个点

5.1 时区导致入场时间差 8 小时

现象:核销后entry_record.entry_time比实际时间早 8 小时。原因:MySQL 8 默认时区是 UTC,JDBC 连接没指定serverTimezone。解决:在url后面加serverTimezone=Asia/Shanghai,同时确认 MySQL 服务器时区SELECT @@global.time_zone;返回SYSTEM或+08:00。

5.2 事务不生效导致余额和订单不一致

现象:充值后余额加了,但订单表没记录,或者反过来。原因:@Transactional方法被同类内部调用,Spring AOP 代理没拦住。解决:把充值方法放到独立的 Service 里,或者从 Controller 直接调 Service 的recharge方法,不要在一个 Service 里自己调自己。

5.3 扫码枪输入带换行符

现象:扫码核销提示卡号不存在,但手动输入同样的卡号能成功。原因:扫码枪默认在末尾追加回车换行,cardNo变成"2001\r\n"。解决:在EntryDTO的cardNo字段上加@Pattern(regexp = "^[0-9A-Za-z]+$")校验,或者在 Controller 里dto.setCardNo(dto.getCardNo().trim())先清洗。

5.4 分页查询总数不对

现象:会员列表显示 10 条,但分页控件显示总共 1 条。原因:MyBatis-Plus 分页插件没配,selectPage返回的total是 0 或当前页条数。解决:在config包下加一个MybatisPlusConfig,注册PaginationInnerInterceptor:

@Bean public MybatisPlusInterceptor mybatisPlusInterceptor() { MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor(); interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL)); return interceptor; }

5.5 前端页面 404 但接口能通

现象:访问http://localhost:8080/member/list返回 JSON 正常,但浏览器打开页面 404。原因:Thymeleaf 模板没放在src/main/resources/templates下,或者 Controller 返回的是@ResponseBody而不是视图名。解决:检查@Controller和@RestController的区别,页面跳转用@Controller返回"member/list"字符串,接口数据用@RestController。

6. 二次开发:把硬编码改成配置,把单机改成能对账

这个项目跑通之后,最值得动手改的地方是“定价”和“对账”。原代码里扣费金额 30 元写死在EntryController里,不同泳池、不同时段价格不一样,我一般会抽一张price_config表,字段包括id、biz_type(入场/储物柜/泳具租赁)、price、effective_time,然后在核销时按当前时间查生效价格。改完之后,运营改价不用重启服务,前台也不用记“今天多少钱”。

第二个改造点是营业统计。原项目只有entry_record和recharge_order两张流水表,月底对账要手动加。可以加一个定时任务,每天凌晨把前一天的入场次数、充值金额、扣次扣费金额汇总到daily_report表,字段包括report_date、entry_count、recharge_amount、consume_amount。用 Spring 的@Scheduled(cron = "0 0 1 * * ?")挂在 Service 上,跑完写日志。这样老板要看“这个月每天多少人”,直接查报表,不用扫全量流水。

第三个技巧是给核销接口加幂等。现在同一张卡连续点两次核销,会扣两次次数。常见做法是前端传一个requestId(可以用 UUID),后端在 Redis 里setnx一个 5 分钟过期的 key,key 是entry:{cardNo}:{requestId},设置成功才继续扣次,失败直接返回“请勿重复提交”。没有 Redis 的话,用数据库唯一索引也行:在entry_record上加(member_id, entry_time)唯一约束,但粒度太粗,还是 Redis 更灵活。

我印象最深的一次翻车,是给一个泳池做演示时,扫码枪扫了卡但页面没反应,查了半天发现是前端fetch请求没带Content-Type: application/json,后端@RequestBody解析失败直接 400。从那以后我每次接扫码枪或读卡器,都强制先用curl打一遍接口,确认参数格式和编码没问题,再联调前端。希望这些经验能帮到你,少走点弯路。

本文还有配套的精品资源,点击获取

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/20 3:48:38

联轴器拆卸前,到底要不要先做激光对中?VS5200实操标准答案

很多机修师傅日常维保都会纠结一个核心问题&#xff1a;联轴器拆检、换件、设备大修前&#xff0c;究竟要不要先做激光对中&#xff1f;行业内多数人惯性操作是&#xff1a;直接拆联轴器、拆设备&#xff0c;装完再统一对中。但这套老流程&#xff0c;经常导致返工对中、偏差超…

作者头像 李华
网站建设 2026/10/20 3:48:32

context-mode解析:多轮对话的上下文管理与Token优化实践

刚开始接触“context-mode”这个词时&#xff0c;我正在做一个多轮对话类的数据处理工具&#xff0c;当时遇到的问题很有代表性&#xff1a;只要一轮对话超过十几轮&#xff0c;系统给出的结果就开始答非所问&#xff0c;甚至把前面已经确认过的信息完全遗忘。我一度以为是模型…

作者头像 李华
网站建设 2026/10/20 3:47:56

全链路AI智能写作实战:从选题大纲到文献降重的学术写作流水线复盘

作为长期在学术写作工具链里摸爬滚打的从业者&#xff0c;我第一次完整跑通“虎贲等考 AI 智能写作”这套流程时&#xff0c;最大的感受不是“它帮我写了多少字”&#xff0c;而是它把过去散落在我草稿箱、文献管理器、格式模板和质检清单里的零散动作&#xff0c;重新串成了一…

作者头像 李华
网站建设 2026/10/20 3:47:22

C# Winform 连接 SQL Server 实战:环境配置与增删改查避坑指南

简介&#xff1a;这份资源面向C# Winform与SQL Server数据库开发的初学者&#xff0c;提供一套可直接运行的增删改查示例工程&#xff0c;帮助理解网络连接与本地连接两种数据库访问方式。压缩包共28个文件&#xff0c;约168KB&#xff0c;包含6个cs源码文件、1个sql脚本、2个r…

作者头像 李华
网站建设 2026/10/20 3:46:00

多输入输出LSTM时间序列预测:多变量多步工程实践与避坑指南

简介&#xff1a;面向时间序列预测与深度学习入门者&#xff0c;这份资源以 LSTM 多输入多输出建模为核心&#xff0c;覆盖从 CSV/Excel 数据读取、预处理到模型训练与预测的完整流程&#xff0c;适合需要处理多变量时序数据的开发者和研究人员。压缩包共 126 个文件&#xff0…

作者头像 李华
网站建设 2026/10/20 3:45:29

STM32智能小车四层架构设计与实战调试指南

简介&#xff1a;本资源是一套基于STM32F103C8T6的智能小车完整开发套件&#xff0c;面向嵌入式初学者与课程设计实践者&#xff0c;聚焦寻迹与避障两大核心功能&#xff0c;覆盖硬件原理图、底层驱动代码及工程调试全流程。压缩包共181个文件&#xff0c;含74个C源文件&#x…

作者头像 李华