☰
Java游泳馆管理系统:Spring Boot 2.7实战项目
2026/11/2 17:32:59 网站建设 项目流程

简介:这是一套面向高校数据库与Java课程设计初学者的游泳馆管理系统实战项目,聚焦中小型场馆日常运营痛点,提供会员管理、在线预约、场地调度与收费记账四大核心功能模块,助力学生将ER建模、SQL设计、Java Swing/FX界面开发及事务处理等理论知识落地为可运行系统。资源包共1286个文件,含28个核心Java源码文件(如VIP.java、ManagerDao.java)、29个编译后class文件、1132个htm页面构成完整前端界面,辅以sql数据库脚本、css/js样式逻辑及gif/png界面资源,整体3.81MB,结构清晰、模块边界明确,便于分层学习与调试。已有1537人学习下载,读者可直接导入运行,获取完整可执行工程、分模块源码注释、数据库表结构说明及典型业务场景(如预约冲突校验、会员等级计费)的实现逻辑,是理解B/S或C/S架构下业务系统开发流程的优质实践样本。

1. 游泳馆管理系统:一个能当天部署、次日上线的 Java Web 实战项目,专治「人多、场次乱、排班糊、账目不清」的中小型场馆运营痛点

某游泳馆老板在微信里发来一张截图:前台手写登记表密密麻麻,教练排班靠微信群接龙,会员续费靠Excel手动对账,月底结账花了三天还漏了7个私教课时。这不是段子,是真实踩坑现场。这个「游泳馆管理系统」就是为这类场景量身写的——它不堆高大上的微服务架构,不用 Docker/K8s 做包装,核心功能就四块:会员全生命周期管理(注册/充值/冻结/退卡)、课程与场次智能排程(含时段冲突校验)、教练排班与课时统计(自动关联课消与工资)、收银+财务流水+日结报表(支持现金/微信/支付宝三通道)。技术栈干净利落:Spring Boot 2.7 + MyBatis-Plus + Thymeleaf + Bootstrap 5 + H2(开发默认)+ MySQL(生产可切),JDK 11 编译,Maven 管理。整套源码包解压即跑,连数据库初始化脚本都封装进src/main/resources/sql/下,新手照着 README 里的三行命令就能看到登录页。它不是教学 Demo,而是某高校实验室团队为本地三家社区泳馆落地迭代过 5 个版本的真实系统——你拿到手的,是删掉客户定制模块、保留主干逻辑、加了完整注释和调试断点的「可交付精简版」。


2. 环境准备与一键启动:从 JDK 到浏览器登录页,全程 12 分钟内完成

2.1 开发环境最低要求与验证清单

这个项目对环境极其友好,但恰恰因为“太简单”,新手最容易栽在 JDK 版本或 Maven 配置上。我建议你先执行这三步验证,比直接mvn spring-boot:run更省时间:

提示:不要用 JDK 17 或更高版本!Spring Boot 2.7 官方仅支持到 JDK 11(LTS),JDK 17 会触发java.lang.NoSuchMethodError: javax.servlet.http.HttpServletRequest.getHttpServletMapping()这类玄学报错——这是 Spring Boot 2.x 和 Servlet 4.0+ 的兼容性黑匣子,不是你代码的问题。

# 1. 检查 JDK 版本(必须输出 11.x) java -version # 2. 检查 Maven(3.6.3+ 即可,推荐 3.8.6) mvn -v # 3. 检查本地是否已占用 8080 端口(项目默认端口) netstat -ano | findstr :8080 # Windows lsof -i :8080 # macOS/Linux

如果java -version输出的是17.0.1或21.0.2,请立刻切换 JDK 11。IntelliJ IDEA 用户可在 Project Structure → Project → Project SDK 中下拉选择;命令行用户需配置JAVA_HOME指向 JDK 11 根目录,并确保PATH中java命令指向它。别信网上“加-Dspring.mvc.servlet.path=/就能硬扛 JDK 17”的偏方——那是给 Spring Boot 3.x 准备的后悔药,对本项目无效。

2.2 数据库初始化:H2 内存库开箱即用,MySQL 切换只需改两行

项目默认使用 H2 内存数据库,好处是零安装、零配置,适合快速验证业务逻辑。所有建表语句、初始数据(如管理员账号admin/123456、测试会员、默认泳道信息)都封装在src/main/resources/sql/schema-h2.sql和># 第一步:清理旧编译产物(尤其当你之前用错 JDK 编译过) mvn clean # 第二步:编译并跳过测试(测试用例未覆盖全部,跳过不影响功能验证) mvn compile -Dmaven.test.skip=true # 第三步:启动(默认激活 h2 配置) mvn spring-boot:run

等待控制台输出类似Tomcat started on port(s): 8080 (http)后,在浏览器打开http://localhost:8080/login。你应该看到一个蓝白配色、带泳镜图标的登录页。输入默认账号admin/123456,点击登录——如果跳转到「仪表盘」页面,顶部显示「欢迎回来,管理员」,且左侧菜单栏有「会员管理」「课程排程」「教练排班」「财务管理」四个主项,恭喜,你的系统已活了。

注意:首次登录后,系统会自动跳转至/dashboard,该页面调用DashboardController.index(),它会查询今日预约数、未处理订单、教练出勤率三个聚合指标。如果页面空白或报 500 错,大概率是数据库连接失败,请回看 2.2 节检查application.yml配置。


3. 核心模块拆解:从会员注册到课时核销,一条请求的完整链路

3.1 会员管理模块:注册、充值、状态机驱动的生命周期控制

会员是整个系统的起点。MemberController是入口,但真正决定业务边界的,是MemberService中的状态机设计。它没用复杂的状态模式框架,而是用枚举 + switch 实现轻量级状态流转:

// src/main/java/com/swim/system/service/impl/MemberServiceImpl.java public boolean recharge(Long memberId, BigDecimal amount) { Member member = memberMapper.selectById(memberId); if (member == null || !MemberStatus.ACTIVE.equals(member.getStatus())) { throw new BusinessException("会员不存在或已冻结"); } // 关键:余额不能为负,且单次充值上限 5000 元(防误操作) if (amount.compareTo(BigDecimal.ZERO) <= 0 || amount.compareTo(new BigDecimal("5000")) > 0) { throw new BusinessException("充值金额需大于0且不超过5000元"); } BigDecimal newBalance = member.getBalance().add(amount); member.setBalance(newBalance); member.setUpdatedAt(LocalDateTime.now()); return memberMapper.updateById(member) > 0; }

逻辑说明:

  • member.getStatus()返回MemberStatus枚举,包含ACTIVE(正常)、FROZEN(冻结)、CLOSED(销户)三种状态;
  • recharge()方法强制校验状态,冻结会员无法充值,这是业务强约束;
  • 金额校验用compareTo()而非==,避免 BigDecimal 精度陷阱;
  • updatedAt字段由代码显式更新,而非依赖数据库ON UPDATE CURRENT_TIMESTAMP,保证 Java 层可审计。

参数说明:

  • memberId:路径变量,来自@PathVariable("id"),前端路由/member/recharge/{id};
  • amount:请求体 JSON 字段{ "amount": 300.00 },经@RequestBody绑定;
  • 抛出BusinessException会被全局异常处理器GlobalExceptionHandler捕获,返回统一格式{ "code": 400, "msg": "充值金额需大于0且不超过5000元", "data": null }。

3.2 课程排程模块:时段冲突检测与泳道资源锁定

「课程排程」是高频并发场景。用户选「7月10日 19:00-20:00 儿童蛙泳班」时,系统必须同时校验:① 该时段该泳道是否已被其他课程占用;② 教练在该时段是否有空档;③ 班级人数是否超限。核心逻辑在CourseScheduleService.checkConflict():

// src/main/java/com/swim/system/service/impl/CourseScheduleServiceImpl.java public boolean checkConflict(LocalDateTime start, LocalDateTime end, Long poolLaneId, Long coachId) { // 1. 查该泳道在 [start, end) 区间内是否有其他课程 QueryWrapper<CourseSchedule> laneQw = new QueryWrapper<>(); laneQw.eq("pool_lane_id", poolLaneId) .ge("start_time", start) .lt("end_time", end) .ne("status", ScheduleStatus.CANCELLED); // 忽略已取消的排程 if (courseScheduleMapper.selectCount(laneQw) > 0) { return false; // 冲突 } // 2. 查该教练在 [start, end) 区间内是否有其他课程(含待确认) QueryWrapper<CourseSchedule> coachQw = new QueryWrapper<>(); coachQw.eq("coach_id", coachId) .ge("start_time", start) .lt("end_time", end) .ne("status", ScheduleStatus.CANCELLED); return courseScheduleMapper.selectCount(coachQw) == 0; }

逻辑说明:

  • 使用ge(>=)和lt(<)构建左闭右开区间[start, end),这是时间冲突检测的标准做法,避免19:00-20:00和20:00-21:00被误判为冲突;
  • ne("status", ScheduleStatus.CANCELLED)过滤掉已取消的排程,它们不占用资源;
  • 两次selectCount()是原子操作,但未加数据库锁——因为本系统预设并发量 < 50 QPS,用应用层校验 + 乐观锁(version字段)足够;若你接入高并发场馆,需在insert时加SELECT ... FOR UPDATE。

参数说明:

  • start/end:LocalDateTime类型,来自前端传入的字符串"2024-07-10T19:00",经 Spring MVC 自动转换;
  • poolLaneId:泳道 ID,对应pool_lane表主键;
  • coachId:教练 ID,来自coach表。

3.3 教练排班模块:课时统计与自动出勤标记

教练端最关心「我今天上了几节课?多少钱?」。CoachAttendanceService每日凌晨 2 点触发一次定时任务,扫描昨日所有status=CONFIRMED的课程,生成出勤记录:

// src/main/java/com/swim/system/service/impl/CoachAttendanceServiceImpl.java @Scheduled(cron = "0 0 2 * * ?") // 每天凌晨2点执行 public void generateYesterdayAttendance() { LocalDate yesterday = LocalDate.now().minusDays(1); LocalDateTime start = yesterday.atStartOfDay(); LocalDateTime end = yesterday.plusDays(1).atStartOfDay(); // 查询昨日所有已确认课程 QueryWrapper<CourseSchedule> qw = new QueryWrapper<>(); qw.between("start_time", start, end) .eq("status", ScheduleStatus.CONFIRMED); List<CourseSchedule> confirmedCourses = courseScheduleMapper.selectList(qw); for (CourseSchedule course : confirmedCourses) { // 检查是否已存在出勤记录(防重复执行) if (attendanceMapper.selectOne( new QueryWrapper<CoachAttendance>() .eq("coach_id", course.getCoachId()) .eq("schedule_id", course.getId()) ) == null) { CoachAttendance attendance = new CoachAttendance(); attendance.setCoachId(course.getCoachId()); attendance.setScheduleId(course.getId()); attendance.setDate(yesterday); attendance.setStatus(AttendanceStatus.PRESENT); attendance.setCreatedAt(LocalDateTime.now()); attendanceMapper.insert(attendance); } } }

逻辑说明:

  • @Scheduled注解启用 Spring 定时任务,无需 Quartz;
  • between查询昨日 00:00:00 到今日 00:00:00 的课程,覆盖所有跨午夜的晚班;
  • 插入前selectOne检查,是典型的「先查后插」防重逻辑,比INSERT IGNORE更可控;
  • AttendanceStatus.PRESENT是默认状态,教练可登录后手动改为ABSENT或LEAVE。

参数说明:

  • yesterday:LocalDate,用于生成date字段(日期类型,非时间戳);
  • schedule_id:关联course_schedule表,实现课时与出勤强绑定;
  • createdAt:记录系统生成时间,用于审计。

4. 避坑指南:那些让我重启三次 IDE 才定位到的典型问题

4.1 现象:登录页 CSS 加载失败,页面纯白无样式

原因:Thymeleaf 默认开启缓存(spring.thymeleaf.cache=true),开发时修改templates/layout.html后未重启,浏览器仍加载旧版本,导致<link th:href="@{/css/app.css}">解析为 404。
解决:在application-dev.yml中添加spring.thymeleaf.cache=false,并确保启动时激活devprofile(mvn spring-boot:run -Dspring.profiles.active=dev)。

4.2 现象:会员充值成功,但余额未更新,数据库字段仍为 NULL

原因:Member实体类中balance字段用了BigDecimal,但数据库member表对应列为DECIMAL(10,2),而 MyBatis-Plus 默认的 TypeHandler 对null值处理异常——当balance为null时,updateById()会忽略该字段,不执行SET balance = ?。
解决:在Member实体类的balance字段上加@TableField(updateStrategy = FieldStrategy.NOT_NULL),强制非空才更新;或初始化时设private BigDecimal balance = BigDecimal.ZERO;。

4.3 现象:课程排程保存时报org.hibernate.exception.ConstraintViolationException

原因:course_schedule表有联合唯一索引uk_pool_coach_time(泳道+教练+开始时间),但前端传参时start_time是字符串"2024-07-10 19:00",后端未做格式化,MyBatis 插入时因时区或精度问题导致索引失效。
解决:在CourseScheduleController.save()中,用DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm")显式解析字符串,并存为LocalDateTime;同时在application.yml中加spring.jackson.date-format=yyyy-MM-dd HH:mm统一 JSON 时间格式。

4.4 现象:H2 控制台能连,但http://localhost:8080/h2-console页面空白

原因:Spring Boot 2.7 默认禁用 H2 控制台的 Web 资源(spring.h2.console.settings.web-allow-others=false),且 Chrome 98+ 对localhost的SameSite策略更严格。
解决:在application-h2.yml中添加spring.h2.console.settings.web-allow-others=true,并确保访问地址是http://localhost:8080/h2-console(不能少/)。

4.5 现象:MySQL 切换后,member表中文名显示为?

原因:MySQL 服务端字符集非utf8mb4,或 JDBC URL 缺少characterEncoding=utf8mb4&serverTimezone=Asia/Shanghai参数。
解决:检查 MySQL 配置my.cnf中collation-server = utf8mb4_unicode_ci和init-connect='SET NAMES utf8mb4';JDBC URL 改为jdbc:mysql://localhost:3306/swimdb?useUnicode=true&characterEncoding=utf8mb4&serverTimezone=Asia/Shanghai。


5. 二次开发实战:如何快速接入微信支付并生成对账单 PDF

5.1 微信支付接入:替换收银模块,三步嵌入现有流程

原系统收银只支持现金和模拟支付。要接入微信支付,你不需要重写整个PaymentController,只需聚焦三个点:统一下单、回调验签、状态同步。

第一步:在pom.xml引入微信官方 SDK(推荐com.github.wechatpay-apiv3):

<!-- 微信支付 v3 SDK --> <dependency> <groupId>com.github.wechatpay-apiv3</groupId> <artifactId>wechatpay-apache-httpclient</artifactId> <version>0.4.9</version> </dependency>

第二步:创建WechatPayService,封装统一下单逻辑。关键在于构造HttpClient时注入平台证书:

// src/main/java/com/swim/system/service/WechatPayService.java public String createOrder(Long orderId, BigDecimal amount, String description) { // 1. 构建 HttpClient(需提前加载平台证书) PrivateKey merchantPrivateKey = PemUtil.loadPrivateKey( new FileInputStream("cert/apiclient_key.pem")); AutoUpdateCertificatesVerifier verifier = new AutoUpdateCertificatesVerifier( new WechatPay2Credentials("your_mch_id", new PrivateKeySigner("your_serial_no", merchantPrivateKey)), new FileInputStream("cert/wechatpay_cert.pem")); CloseableHttpClient httpClient = WechatPayHttpClientBuilder.create() .withMerchant("your_mch_id", "your_serial_no", merchantPrivateKey) .withValidator(new WechatPay2Validator(verifier)) .build(); // 2. 构造下单请求体 JsonObject json = new JsonObject(); json.addProperty("appid", "wx1234567890abcdef"); json.addProperty("mchid", "your_mch_id"); json.addProperty("description", description); json.addProperty("out_trade_no", "SWIM" + orderId); json.addProperty("time_expire", LocalDateTime.now().plusMinutes(15).format(DateTimeFormatter.ISO_LOCAL_DATE_TIME)); json.add("amount", new JsonObject().addProperty("total", amount.multiply(new BigDecimal("100")).longValue()).addProperty("currency", "CNY")); json.add("payer", new JsonObject().addProperty("openid", "oABC1234567890abcdef")); // 3. 发起 POST 请求 HttpPost httpPost = new HttpPost("https://api.mch.weixin.qq.com/v3/pay/transactions/jsapi"); httpPost.setEntity(new StringEntity(json.toString(), "UTF-8")); httpPost.setHeader("Accept", "application/json"); try (CloseableHttpResponse response = httpClient.execute(httpPost)) { String body = EntityUtils.toString(response.getEntity()); JsonObject result = JsonParser.parseString(body).getAsJsonObject(); return result.getAsJsonObject("prepay_id").get("prepay_id").getAsString(); // 返回 prepay_id 供前端调起支付 } }

逻辑说明:

  • AutoUpdateCertificatesVerifier自动轮询更新微信平台证书,避免手动更新;
  • time_expire设为 15 分钟,符合微信支付超时规则;
  • amount.total必须为分单位整数,所以amount.multiply(100).longValue();
  • 返回prepay_id,前端调用wx.requestPayment()时传入。

第三步:在PaymentController.pay()中,根据支付方式路由:

@PostMapping("/pay") public Result pay(@RequestBody PaymentRequest request) { if ("wechat".equals(request.getPayType())) { String prepayId = wechatPayService.createOrder(request.getOrderId(), request.getAmount(), "游泳馆课程支付"); return Result.success(Map.of("prepayId", prepayId)); } else if ("cash".equals(request.getPayType())) { // 原有现金逻辑 } return Result.fail("不支持的支付方式"); }

5.2 对账单 PDF 生成:用 iText7 替代 JasperReports,轻量又可控

原系统财务报表只有 HTML 表格。要导出 PDF 对账单,我弃用重量级 JasperReports,改用 iText7 —— 因为它的 API 直接操作 PDF 元素,调试方便,且能精准控制每行高度、字体、边框。

在FinancialReportService中新增方法:

// src/main/java/com/swim/system/service/FinancialReportService.java public byte[] generateDailyReport(LocalDate date) throws IOException { ByteArrayOutputStream baos = new ByteArrayOutputStream(); PdfWriter writer = new PdfWriter(baos); PdfDocument pdfDoc = new PdfDocument(writer); Document document = new Document(pdfDoc); // 设置中文字体(需提前下载 simsun.ttc) PdfFont font = PdfFontFactory.createFont("fonts/simsun.ttc,0", PdfEncodings.IDENTITY_H); document.setFont(font).setFontSize(10f); // 标题 document.add(new Paragraph("游泳馆日结对账单 - " + date).setBold().setFontSize(14f).setTextAlignment(TextAlignment.CENTER)); // 表格:列宽按内容比例分配 Table table = new Table(UnitValue.createPercentArray(new float[]{1, 2, 1, 1, 1})); table.setWidth(UnitValue.createPercentValue(100)); table.addHeaderCell(new Cell().add(new Paragraph("序号").setBold())); table.addHeaderCell(new Cell().add(new Paragraph("会员姓名").setBold())); table.addHeaderCell(new Cell().add(new Paragraph("消费项目").setBold())); table.addHeaderCell(new Cell().add(new Paragraph("金额(元)").setBold())); table.addHeaderCell(new Cell().add(new Paragraph("支付方式").setBold())); // 查询当日流水(简化版,实际应分页) List<FinancialRecord> records = financialRecordMapper.selectList( new QueryWrapper<FinancialRecord>().eq("date", date) ); for (int i = 0; i < records.size(); i++) { FinancialRecord r = records.get(i); table.addCell(new Cell().add(new Paragraph(String.valueOf(i + 1)))); table.addCell(new Cell().add(new Paragraph(r.getMemberName()))); table.addCell(new Cell().add(new Paragraph(r.getItem()))); table.addCell(new Cell().add(new Paragraph(r.getAmount().toString()))); table.addCell(new Cell().add(new Paragraph(r.getPayType()))); } document.add(table); document.close(); return baos.toByteArray(); }

逻辑说明:

  • PdfFontFactory.createFont()加载宋体,解决中文乱码;
  • Table构造时用PercentArray动态分配列宽,比固定像素更适应不同长度文本;
  • document.add()直接追加元素,无模板引擎学习成本;
  • 返回byte[],Controller 中用ResponseEntity<byte[]>直接响应,前端可window.open(URL.createObjectURL(blob))预览。

参数说明:

  • date:LocalDate,来自@RequestParam("date");
  • FinancialRecord:财务流水实体,含memberName、item(如“儿童蛙泳班 1节”)、amount、payType;
  • fonts/simsun.ttc:需将字体文件放入src/main/resources/fonts/目录。

从那以后我每次接入第三方支付,都强制走一遍「沙箱环境全流程」:从下单 → 支付 → 回调 → 状态更新 → 通知会员,哪怕只是模拟,也要让日志里出现完整的SUCCESS链路。因为线上环境一旦漏掉验签或异步通知,钱就真进了黑洞。希望帮到你。

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

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询