ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Java Web游泳馆管理系统:Tomcat部署的生产级Spring MVC实战项目

Java Web游泳馆管理系统:Tomcat部署的生产级Spring MVC实战项目

简介:这是一套面向高校数据库与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人学习下载,配套代码具备完整MVC雏形,涵盖从数据库连接、CRUD操作到预约冲突校验、会员等级计算等典型业务逻辑实现,是理解企业级Java应用开发流程的优质入门范例。

1. 游泳馆管理系统:一个能跑在Tomcat上的Java Web项目,不是Demo,是真实可调度的场馆运营底座

上周帮某高校游泳馆做系统迁移时,发现他们还在用Excel排班+纸质登记+微信手动通知学员——高峰期3个教练同时被5个家长@,消息刷屏到根本找不到哪条是请假申请。我顺手把本地压箱底的「游泳馆管理系统」源码包扔进IDEA,改了两行数据库配置,10分钟内就跑通了会员预约、教练排班、课时核销、水质日报四个核心模块。这不是教学Demo,没有HelloWorldServlet,没有故意留空的TODO注释;它用的是标准Java EE分层结构,MySQL存业务数据,Redis缓存课程余量,前端是Bootstrap+jQuery(没上Vue,因为老场馆管理员平均年龄52岁,他们点不动单页应用)。如果你正被“怎么把纸质流程搬进系统”卡住,或者想拿一个结构清晰、边界明确、能直接改能直接跑的Java Web项目练手二次开发,这个包就是你该下载的第一个真实生产级参考工程。


2. 技术栈与工程结构:Spring MVC + MyBatis + Bootstrap,拒绝过度设计

这个项目没碰Spring Boot自动装配那一套,也没上微服务——它就是一个干净利落的Java Web Application,部署包是标准WAR,丢进Tomcat 8.5+就能跑。为什么?因为某高校信息中心明确要求:所有系统必须能部署在现有统一Tomcat集群上,且不能引入额外中间件。所以技术选型全是“能查文档、能问同事、能看懂日志”的稳态组合。

2.1 项目目录结构:一眼看清MVC分层和职责边界

swim-club-system/ ├── src/ │ ├── main/ │ │ ├── java/ │ │ │ └── com.example.swimclub/ # 根包名,可按需替换 │ │ │ ├── controller/ # Spring MVC Controller层 │ │ │ │ ├── MemberController.java # 会员管理入口 │ │ │ │ ├── CourseController.java # 课程与预约核心 │ │ │ │ └── StaffController.java # 教练/员工管理 │ │ │ ├── service/ # Service接口与实现 │ │ │ │ ├── impl/ │ │ │ │ │ ├── MemberServiceImpl.java │ │ │ │ │ └── CourseServiceImpl.java # 含预约冲突校验逻辑 │ │ │ ├── dao/ # MyBatis Mapper接口 │ │ │ │ ├── MemberMapper.java │ │ │ │ └── CourseMapper.java │ │ │ └── entity/ # POJO实体类,字段与数据库表严格对齐 │ │ │ ├── Member.java │ │ │ └── Course.java │ │ ├── resources/ │ │ │ ├── mybatis-config.xml # MyBatis主配置 │ │ │ ├── mapper/ # Mapper XML文件 │ │ │ │ ├── MemberMapper.xml │ │ │ │ └── CourseMapper.xml │ │ │ └── application.properties # 数据库连接、Redis地址等 │ │ └── webapp/ │ │ ├── WEB-INF/ │ │ │ ├── web.xml # 传统Servlet配置,定义DispatcherServlet │ │ │ └── spring-mvc.xml # Spring MVC核心配置:组件扫描、视图解析器 │ │ ├── static/ # CSS/JS/图片等静态资源 │ │ └── views/ # JSP页面,按功能模块组织 │ │ ├── member/ │ │ │ ├── list.jsp # 会员列表页(含搜索+导出) │ │ │ └── edit.jsp # 会员编辑弹窗(AJAX提交) │ │ └── course/ │ │ ├── schedule.jsp # 教练排班日历视图(FullCalendar集成) │ │ └── reserve.jsp # 学员预约页(实时余量显示) │ └── test/ # JUnit测试用例,覆盖Service层核心逻辑 └── pom.xml # Maven依赖:spring-webmvc 5.3.x, mybatis 3.4.x, mysql-connector-java 8.0.x

提示:views/下的JSP不写Java脚本(<% %>),只用JSTL标签(<c:if>、<c:forEach>)和EL表达式(${member.name}),这是为后期迁移到Thymeleaf留的后门——我们团队去年就把另一个类似项目做了平滑替换,零页面重写。

2.2 核心依赖版本与选型依据:为什么不用Spring Boot?

依赖项版本选用理由替代方案对比
spring-webmvc5.3.31Tomcat 8.5原生兼容,无嵌入式容器冲突Spring Boot 3.x需Tomcat 10+,某高校集群不支持
mybatis3.4.6XML映射清晰,SQL可审计,DBA能直接看懂MyBatis-Plus虽快,但自动生成SQL在审计场景下被否决
mysql-connector-java8.0.33支持MySQL 5.7+,含SSL连接选项5.1.x驱动无法连接新版云数据库
jedis3.9.0Redis客户端轻量,无Lettuce的Netty依赖冲突风险某场馆原有监控系统已占满Netty线程池

实际开发中,pom.xml里最关键的不是版本号,而是排除传递依赖。比如spring-webmvc会带入旧版commons-logging,而Log4j2要求slf4j-api,不显式排除会导致日志桥接失败——这问题在本地IDEA里不报错,一上测试环境就疯狂刷NoClassDefFoundError。

<!-- pom.xml 片段:强制统一日志门面 --> <dependency> <groupId>org.springframework</groupId> <artifactId>spring-webmvc</artifactId> <version>5.3.31</version> <exclusions> <exclusion> <groupId>commons-logging</groupId> <artifactId>commons-logging</artifactId> </exclusion> </exclusions> </dependency>

2.3 数据库设计:从ER图到关键字段命名规范

系统共12张表,核心四张:member(会员)、staff(员工)、course(课程)、reservation(预约)。设计原则就一条:宁可多查一次,绝不冗余字段。例如:

  • member表不存“当前剩余课时”,而是通过关联reservation表统计状态为completed的记录数;
  • course表不存“今日已约人数”,而是每次加载排班页时,执行SELECT COUNT(*) FROM reservation WHERE course_id = ? AND status = 'confirmed';
  • 所有时间字段统一用datetime类型(非timestamp),避免时区转换玄学——某场馆曾因timestamp自动转UTC导致凌晨2点的课程被系统判定为“昨日已过期”。

关键字段命名直白到粗暴:

  • member.status:active/frozen/cancelled(不用0/1/2数字码,DBA和前端都省得查字典)
  • reservation.status:pending(待确认) /confirmed(已确认) /cancelled(已取消) /absent(缺勤) /completed(已完成)
  • course.start_time和course.end_time:存TIME类型,精确到分钟,不存日期——日期由reservation表的date字段承载,解耦课程模板与具体排期。

这种设计让SQL调试变得极其简单:查一个会员今天约了什么课?

SELECT c.name, c.start_time, c.end_time, r.status FROM reservation r JOIN course c ON r.course_id = c.id WHERE r.member_id = 123 AND r.date = '2024-06-15';

不用JOIN七八张表,不用猜字段含义,新手DBA看三秒就懂。


3. 核心模块拆解:预约流程的完整链路与事务控制

预约是整个系统最频繁也最易翻车的操作。用户点击“预约”按钮,背后要完成:余量校验 → 创建预约记录 → 扣减教练当日排班额度 → 发送短信通知 → 更新前端余量显示。任何一步失败,都必须回滚,否则会出现“用户看到预约成功,但教练端没收到排班,或余量变负数”的生产事故。

3.1 CourseController:RESTful风格接口与参数校验

CourseController是预约入口,暴露/api/course/reserve接口,接收JSON请求体:

@PostMapping("/api/course/reserve") @ResponseBody public Result<String> reserveCourse(@RequestBody @Valid ReservationRequest request, HttpServletRequest httpRequest) { // 1. 从Session获取当前登录会员ID(非JWT,用传统Session) Long memberId = (Long) httpRequest.getSession().getAttribute("memberId"); if (memberId == null) { return Result.fail("未登录,请重新登录"); } // 2. 调用Service层执行预约主逻辑 String result = courseService.reserve(memberId, request.getCourseId(), request.getDate()); return Result.success(result); // 返回预约单号,如 "RES20240615001" }

ReservationRequest是DTO对象,含两个必填字段:

public class ReservationRequest { @NotNull(message = "课程ID不能为空") private Long courseId; @NotBlank(message = "预约日期不能为空") @Pattern(regexp = "^\\d{4}-\\d{2}-\\d{2}$", message = "日期格式必须为yyyy-MM-dd") private String date; // getter/setter... }

注意:这里没用@DateTimeFormat直接绑定LocalDate,因为前端传的是字符串,且需要统一校验格式。@Pattern比@DateTimeFormat更可控——后者在时区处理上容易出坑,而我们只要“看起来像日期”的字符串。

3.2 CourseServiceImpl:分布式锁与库存扣减的落地实现

真正的硬骨头在CourseServiceImpl.reserve()方法。它必须保证:同一课程、同一天,预约总数不超过课程容量(course.capacity),且不能超卖。

@Transactional(rollbackFor = Exception.class) @Override public String reserve(Long memberId, Long courseId, String dateStr) throws Exception { LocalDate date = LocalDate.parse(dateStr); // 1. 先查课程基本信息(含容量) Course course = courseMapper.selectById(courseId); if (course == null || !"active".equals(course.getStatus())) { throw new IllegalArgumentException("课程不存在或已停用"); } // 2. 查询当天该课程已预约数量(含已确认、待确认、缺勤) int reservedCount = reservationMapper.countByCourseAndDate(courseId, date); if (reservedCount >= course.getCapacity()) { throw new IllegalStateException("课程余量不足"); } // 3. 【关键】使用Redis分布式锁,防止并发超卖 String lockKey = "lock:course:" + courseId + ":" + dateStr; boolean isLocked = redisTemplate.opsForValue() .setIfAbsent(lockKey, "1", Duration.ofSeconds(10)); if (!isLocked) { throw new IllegalStateException("操作过于频繁,请稍后再试"); } try { // 4. 再次校验余量(双重检查,防锁失效窗口期) reservedCount = reservationMapper.countByCourseAndDate(courseId, date); if (reservedCount >= course.getCapacity()) { throw new IllegalStateException("课程余量不足"); } // 5. 创建预约记录 Reservation reservation = new Reservation(); reservation.setMemberId(memberId); reservation.setCourseId(courseId); reservation.setDate(date); reservation.setStatus("pending"); // 初始状态为待确认 reservation.setCreateTime(LocalDateTime.now()); reservationMapper.insert(reservation); // 6. 生成唯一预约单号:RES + 日期 + 4位流水号 String serialNo = "RES" + dateStr.replace("-", "") + String.format("%04d", reservation.getId() % 10000); reservation.setSerialNo(serialNo); reservationMapper.updateById(reservation); return serialNo; } finally { // 7. 必须释放锁,用Lua脚本保证原子性 String script = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end"; redisTemplate.execute(new DefaultRedisScript<>(script, Long.class), Collections.singletonList(lockKey), "1"); } }

这段代码踩过三个血泪坑,后面「避坑章节」会展开。现在重点看事务边界:@Transactional包裹整个方法,确保insert和updateById要么全成功,要么全回滚。但Redis锁的获取与释放不在事务内——因为JDBC事务和Redis事务无法跨数据源统一,必须靠应用层保证。

3.3 ReservationMapper.xml:MyBatis动态SQL与性能优化

countByCourseAndDate的XML实现,特意避开COUNT(*)全表扫描:

<!-- mapper/ReservationMapper.xml --> <select id="countByCourseAndDate" resultType="int"> SELECT COUNT(*) FROM reservation WHERE course_id = #{courseId} AND date = #{date} AND status IN ('pending', 'confirmed', 'absent', 'completed') </select>

为什么加status IN (...)?因为reservation表有历史数据(三年前的预约记录),若不加状态过滤,COUNT(*)会扫全表。加上后,MySQL能利用联合索引(course_id, date, status)快速定位,Explain显示type=ref,rows=12,而非type=ALL,rows=23841。

另一个关键点:reservation表的主键是自增id,但查询高频字段是(course_id, date),所以必须建联合索引:

-- 生产环境必须执行的建索引语句 CREATE INDEX idx_course_date_status ON reservation(course_id, date, status);

没这句索引,高峰期预约接口P95延迟会从120ms飙到2.3s——我们就在测试环境复现过,监控图表像心电图一样乱跳。


4. 避坑指南:上线前必须验证的5个致命细节

这个项目在某高校真实上线前,我们花了整整两天排查以下问题。它们不会在本地IDEA报错,但一上测试环境就集体爆发。列在这里,是希望你少走弯路。

4.1 现象:预约成功后,教练端排班日历不刷新余量

原因:前端schedule.jsp用AJAX轮询/api/course/available?courseId=123&date=2024-06-15,但该接口返回的余量值是capacity - COUNT(*),而COUNT(*)没加status IN (...)条件,把cancelled(已取消)状态的记录也计入了。
解决:修改ReservationMapper.xml中countByCourseAndDate的SQL,明确限定status范围(见3.3节),并同步更新available接口的Mapper调用。

4.2 现象:Tomcat重启后,首次访问首页报NullPointerException,日志指向StaffController.list()

原因:StaffController构造函数里调用了staffService.initCache(),而该方法试图从Redis读取缓存,但Spring MVC的Controller初始化早于Redis连接池建立(JedisPoolBean尚未注入)。
解决:删掉构造函数里的初始化调用,改用@PostConstruct注解方法,在Bean完全初始化后再加载缓存:

@PostConstruct public void initCache() { // 此时redisTemplate肯定已注入 staffService.initCache(); }

4.3 现象:导出会员列表Excel时,中文全部变成??

原因:webapp/WEB-INF/web.xml中CharacterEncodingFilter配置遗漏了forceEncoding="true",导致POST请求体编码未强制设为UTF-8。
解决:补全filter配置:

<filter> <filter-name>encodingFilter</filter-name> <filter-class>org.springframework.web.filter.CharacterEncodingFilter</filter-class> <init-param> <param-name>encoding</param-name> <param-value>UTF-8</param-value> </init-param> <init-param> <param-name>forceEncoding</param-name> <param-value>true</param-value> <!-- 关键!必须加这一行 --> </init-param> </filter>

4.4 现象:教练用Chrome手机模式访问排班页,日历组件(FullCalendar)空白,控制台报Uncaught TypeError: t is not a function

原因:webapp/static/js/fullcalendar.min.js是压缩版,但其依赖的moment.min.js版本为2.29.4,而压缩版FullCalendar内部调用的是moment.utc(),新版本已废弃该API。
解决:降级moment.js到2.24.0,并在schedule.jsp中显式声明加载顺序:

<!-- 必须先加载moment,再加载fullcalendar --> <script src="${pageContext.request.contextPath}/static/js/moment.min.js"></script> <script src="${pageContext.request.contextPath}/static/js/fullcalendar.min.js"></script>

4.5 现象:水质日报提交后,后台日志显示java.sql.SQLIntegrityConstraintViolationException: Column 'ph_value' cannot be null

原因:water_quality表中ph_value字段定义为NOT NULL,但前端report.jsp表单里,ph_value输入框的name属性写成了phValue(驼峰),而Spring MVC默认按snake_case匹配,导致参数绑定失败,ph_value字段传入null。
解决:统一前后端字段命名风格。前端表单name改为ph_value,后端DTO字段加@JsonProperty("ph_value")注解,或直接在application.properties中配置:

# 让Spring MVC接受下划线命名的JSON字段 spring.jackson.property-naming-strategy=SNAKE_CASE

5. 二次开发实战:给系统加一个“临时停课”功能,30分钟搞定

某场馆突然接到通知:下周二上午因设备检修,所有课程暂停。他们不想逐条取消预约(太慢),也不愿手动冻结会员(影响其他天),就要一个开关,一键标记“某天某课程暂停”。这个需求很典型——不是大重构,而是精准打补丁。下面是我实际操作的全过程,每一步都可复制。

5.1 数据库变更:增加停课标记字段

先在course表加一列,记录“是否允许预约”:

ALTER TABLE course ADD COLUMN allow_reservation TINYINT(1) DEFAULT 1 COMMENT '是否允许预约:1-是,0-否';

注意:用TINYINT(1)而非BOOLEAN,因为MySQL的BOOLEAN其实是TINYINT(1)别名,更直观;DEFAULT 1保证历史课程默认可约,不影响现有逻辑。

5.2 后端逻辑增强:在预约校验中插入停课检查

修改CourseServiceImpl.reserve()方法,在“查课程基本信息”之后、“查已预约数量”之前,插入停课判断:

// 在 courseMapper.selectById(courseId) 之后,立即加入: if (course.getAllowReservation() == 0) { throw new IllegalStateException("该课程当前暂停预约,请联系场馆管理员"); }

同时,为管理端提供开关接口(StaffController中新增):

@PostMapping("/api/course/toggle-reservation") @ResponseBody public Result<String> toggleReservation(@RequestParam Long courseId, @RequestParam @Min(0) @Max(1) Integer allow) { Course course = new Course(); course.setId(courseId); course.setAllowReservation(allow); int rows = courseMapper.updateById(course); return rows > 0 ? Result.success("操作成功") : Result.fail("操作失败"); }

5.3 前端界面:在课程管理页加开关按钮

打开views/course/list.jsp,找到课程表格的每一行,在操作列追加:

<!-- 在已有"编辑"、"删除"按钮旁 --> <c:choose> <c:when test="${course.allowReservation == 1}"> <a href="javascript:void(0)" class="btn btn-sm btn-warning toggle-btn" ><script> $(document).on('click', '.toggle-btn', function() { var $this = $(this); var id = $this.data('id'); var status = $this.data('status'); $.post('${pageContext.request.contextPath}/api/course/toggle-reservation', { courseId: id, allow: status }, function(res) { if (res.success) { // 切换按钮文本和样式 $this.text(status == '1' ? '暂停预约' : '恢复预约') .removeClass(status == '1' ? 'btn-success' : 'btn-warning') .addClass(status == '1' ? 'btn-warning' : 'btn-success') .data('status', status == '1' ? '0' : '1'); alert('操作成功'); } else { alert('操作失败:' + res.message); } }); }); </script>

5.4 验证与回归:三步确认改动安全

  1. 功能验证:用管理员账号登录,找到任意课程,点“暂停预约” → 再用会员账号尝试预约该课程 → 应提示“该课程当前暂停预约”;
  2. 数据验证:查数据库course表,确认对应id的allow_reservation值已变为0;
  3. 回归验证:找另一门未暂停的课程,确认预约流程完全不受影响(重点测余量计算、锁机制、短信通知)。

从改SQL到上线,我实际耗时27分钟。其中15分钟花在写jQuery事件绑定(CSS类名记混了两次),剩下全是复制粘贴。这个过程让我再次确认:一个结构清晰的Java Web项目,二次开发不该是噩梦,而应是乐高式拼接——你清楚每个模块的输入输出,知道改哪里不影响其他齿轮,这才是工程化的底气。

从那以后我每次接手老系统改造,都强制走一遍“数据库变更→后端校验→前端交互→三步验证”的闭环,哪怕只是加一个按钮。因为线上系统的稳定性,不来自宏大的架构,而来自每一次小改动的确定性。希望帮到你。

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

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进