简介:这是一份面向高校计算机专业学生与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.javaresources下放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.sqlinit.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.DriverserverTimezone必须配,否则 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打一遍接口,确认参数格式和编码没问题,再联调前端。希望这些经验能帮到你,少走点弯路。
本文还有配套的精品资源,点击获取