☰
SSM+JSP场馆预约管理系统:源码部署与并发防超卖实战解析
2026/10/7 18:20:48 网站建设 项目流程

简介:基于SSM+JSP构建的场馆预约管理系统提供完整源码与数据库,是面向计算机专业毕业设计的Java Web项目资源。系统以后端SpringMVC、Spring、MyBatis为框架,前端采用JSP与jQuery,按管理员、用户两类角色划分权限,集成场地管理、轮播图管理、通知管理、场馆预定等实用功能,可作为课程设计或毕业设计的参考原型。压缩包共1021个文件,包括68个Java源文件、67个JSP页面、239个JS脚本、CSS与字体资源、76个Jar依赖包,以及1个SQL数据库脚本;资源整体大小22.43MB,目录结构覆盖前端静态资源、后端业务逻辑、数据库初始化脚本与运行依赖,下载后可在IntelliJ IDEA 2021.3中结合MySQL 5.7.26、Tomcat 7.0.73、JDK 1.8导入运行。目前已有47人学习,读者可获得可直接部署的运行工程、清晰目录结构和完整数据表设计,便于对照源码理解SSM框架整合、角色权限控制与场馆预约核心流程,也能为后续功能扩展或论文撰写提供参考。

1. 为什么 SSM+JSP 的场馆预约管理系统到今天还有人做

一提“基于 SSM+JSP 的场馆预约管理系统(源码+数据库)”,很多新入行的同学第一反应是“JSP 不是过时了吗”。但实际交付场景里,这类项目恰恰是课程设计、毕业设计、中小型体育馆内部预约、实验室机房预约里最常见的形态:内网使用、用户量不大、权限简单、要快速交付源码加数据库脚本,SSM 加 JSP 这套组合依然是很多团队的第一选择。这个标题背后解决的是三类问题:场地时段怎么排、用户怎么预约、后台怎么确认订单,核心是把“一个场馆一天 24 小时分成哪些时段、谁约了哪段、状态怎么流转”用一张关系型数据库理清楚。本文适合手里刚拿到一套这类源码、或者在选型阶段想评估这套方案值不值得投入的人。

2. 拆开技术栈:SSM 三层、JSP 页面与 MySQL 表结构设计

2.1 SSM 三层在预约业务里如何分工:Controller、Service、Mapper 各管哪一段

这套系统的分层非常固定:SpringMVC 负责请求路由,Controller 层只接收参数、调用 Service、返回页面;Spring 负责对象管理和事务,Service 层写预约规则;MyBatis 把 Java 方法与 SQL 绑定,Mapper 层做数据访问。JSP 则承担视图渲染,用 EL 表达式和 JSTL 标签把后端放进 request/session 里的数据打印出来。

为什么这三层不能混着写?我在维护这类源码时最常见的问题就是某个同学把查询逻辑写在 Controller 里,SQL 拼在 Java 字符串里,结果是改一个预约状态要动四个文件,还要担心拼接引号出错。SSM 的价值是把“取参数”“算业务”“查数据库”三者隔开,排查问题时先看异常在哪一层:进来就报 400 是参数问题,业务方法里抛异常是规则问题,Mapper 层报 SQLException 才是数据库问题。

选型理由也要说清楚:这个标题里的系统是给“管理员维护场馆 + 普通用户按时间段抢约”这种中等复杂度业务用的。用户并发不高,场地数量几十个以内,不需要微服务,不需要 Redis 队列,MySQL 一张表加一个事务就够。JSP 在这个场景里的优势是自带 Session 操作,登录状态直接挂在内置 session 上,不需要写 Token 鉴权,内网系统完全够用。

2.2 数据库怎么建模:用户、场馆、时段、预约订单四张表的字段与约束

表设计是整个项目的地基,我一般先画四张表:用户表、场馆表、时段表、预约订单表。场馆和时段分开,而不是在订单表里直接存“周一下午 3 点到 4 点”这种字符串。原因是时段要允许管理员提前批量生成,同一个场馆同一天可以切出早中晚多个时段;分开存之后,改场馆信息不用动订单,查冲突也只需要对时段表做时间区间判断。

CREATE TABLE `user` ( `id` int(11) NOT NULL AUTO_INCREMENT, `username` varchar(32) NOT NULL COMMENT '登录名', `password` varchar(64) NOT NULL COMMENT '密码,建议存加盐后的哈希', `real_name` varchar(32) DEFAULT NULL COMMENT '真实姓名', `phone` varchar(16) DEFAULT NULL, `role` tinyint(4) NOT NULL DEFAULT '1' COMMENT '1=普通用户 2=管理员', PRIMARY KEY (`id`), UNIQUE KEY `uk_username` (`username`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户表'; CREATE TABLE `venue` ( `id` int(11) NOT NULL AUTO_INCREMENT, `name` varchar(64) NOT NULL, `location` varchar(128) DEFAULT NULL, `price_per_hour` decimal(10,2) DEFAULT NULL, `status` tinyint(4) NOT NULL DEFAULT '1' COMMENT '1=启用 0=停用', PRIMARY KEY (`id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='场馆表'; CREATE TABLE `slot` ( `id` int(11) NOT NULL AUTO_INCREMENT, `venue_id` int(11) NOT NULL, `start_time` datetime NOT NULL, `end_time` datetime NOT NULL, `status` tinyint(4) NOT NULL DEFAULT '0' COMMENT '0=空闲 1=已占用 2=关闭', PRIMARY KEY (`id`), UNIQUE KEY `uk_venue_time` (`venue_id`, `start_time`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='时段表'; CREATE TABLE `booking` ( `id` int(11) NOT NULL AUTO_INCREMENT, `user_id` int(11) NOT NULL, `venue_id` int(11) NOT NULL, `slot_id` int(11) NOT NULL, `status` tinyint(4) NOT NULL DEFAULT '0' COMMENT '0=待确认 1=已确认 2=已完成 3=已取消', `create_time` datetime NOT NULL, PRIMARY KEY (`id`), UNIQUE KEY `uk_user_slot` (`user_id`, `slot_id`), KEY `idx_slot` (`slot_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='预约订单表';

这套 SQL 里有几个点需要重点看:字符集用了 utf8mb4 而不是 utf8,因为用户填备注时可能带 emoji 或生僻字,utf8 在 MySQL 5.7 里存不了这四个字节的字符;时段的唯一键是venue_id + start_time,意思是同一个场馆同一开始时间只能有一个时段,防止管理员后台重复生成;订单表里放uk_user_slot唯一索引,这是拦截“同一用户反复抢同一时段”的兜底手段,后面第 6 章还会展开。字段类型方面,价格用 decimal(10,2) 而不是 float,float 算钱在累计金额时会有二进制误差。

表之间不建物理外键也是这类源码常见的做法。我说一下原因:物理外键会导致后续删场馆、改用户时被约束卡住,团队里如果有人直接操作数据库删数据,外键会变成一颗定时炸弹。逻辑外键靠应用层保证,代码里 SQL 多写一个 join 条件就行。

2.3 预约状态机:从可预约到已取消的流转与页面按钮联动

预约系统的业务复杂度基本都集中在状态上。时段表有“空闲、已占用、关闭”三个状态,订单表有“待确认、已确认、已完成、已取消”四个状态。正常的流转路径是:用户选定空闲时段提交订单,订单进入待确认;管理员在后台确认,时段从空闲变成已占用;使用时间结束后,管理员手动或定时任务把订单改为已完成,时段恢复空闲。

这个状态机必须前后端配合,JSP 页面里的按钮不是写死的。待确认状态要显示“取消预约”按钮,已确认状态要显示“确认到场/标记完成”,已取消和已完成状态则不能显示任何操作按钮。用 JSTL 的<c:if>判断状态值,比在 Java 代码里拼 HTML 干净得多。如果状态枚举值不一致,比如数据库里存 1 表示已确认,页面注释里写的是 0,排查起来会非常费劲,这是这个项目里最容易翻车的地方。

3. 把源码跑起来:从 IDEA 导入到 Tomcat 部署的最小步骤

3.1 先把运行环境对齐:JDK、Maven、Tomcat、MySQL 的版本组合

跑 SSM 老项目的第一关不是代码,是环境。很多同学卡在启动报错,其实是 JDK 版本太新或者 MySQL 驱动不匹配。我常用的版本组合是下表这套,拿到的源码如果 pom.xml 里指定了版本,以 pom 为准。

组件推荐版本说明
JDK1.8这类项目大多按 javac 8 编译,高版本可能遇到反射报错
Maven3.6.x3.8+ 对仓库下载限制更严,老仓库可能拉不下来
Tomcat8.5 或 9.0对应 Servlet 3.1 / 4.0,SSM 项目通用
MySQL5.7 或 8.0两个版本驱动类名不一样,后面有单独避坑
IDEACommunity 版即可导入 Maven 工程用不到收费功能

安装时注意:JDK 环境变量JAVA_HOME必须指向 JDK 而不是 JRE;Tomcat 不要解压到带空格的路径,比如C:\Program Files这种路径在 IDEA 里偶发部署失败,我一般放在D:\dev\apache-tomcat这种纯英文路径下,能少很多玄学问题。

3.2 初始化数据库:导入 SQL 脚本与修改 jdbc 配置两个必做动作

拿到手的源码包里一般会带一个db.sql或venue.sql文件,这就是标题里“数据库”那部分的实物。把这套源码跑起来之前,先建一个空库,再把 SQL 文件导入进去。库名要和配置里一致,否则后面改配置多一步。

mysql -uroot -p CREATE DATABASE venue_booking DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci; exit;
mysql -uroot -p venue_booking < db.sql

如果源码里已经带了建库语句,直接用mysql -uroot -p < db.sql也可以,但要注意 SQL 文件里是否写死了库名。导入后用mysql -uroot -p进去执行show tables;确认四张表都建出来了再说下一步。

接着改jdbc.properties,注意这个文件名可能是db.properties或application.properties,内容大同小异:

jdbc.driver=com.mysql.cj.jdbc.Driver jdbc.url=jdbc:mysql://localhost:3306/venue_booking?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&useSSL=false jdbc.username=root jdbc.password=root

密码改成你自己数据库的密码;serverTimezone=Asia/Shanghai在 MySQL 8.x 下不加必报错,5.7 可以不加;useSSL=false是避免本地连接时警告刷屏。如果数据库名不是venue_booking,这里对应改。这一步是整套系统的命门,配置写错启动时就会报数据库连接失败。

3.3 启动并验收:打包部署到 Tomcat 后的三条验收路径

导入 IDEA 的方式不多说:解压源码包,IDEA 里File -> Open选中根目录下的pom.xml,等 Maven 依赖下载完。启动方式有两种,一种是在 IDEA 里配置 Tomcat Server 添加war exploded运行,另一种是 Maven 打包后丢进 Tomcat 的 webapps 目录:

mvn clean package -DskipTests
cp target/venue-booking.war /path/to/tomcat/webapps/ cd /path/to/tomcat/bin ./startup.sh

打包后访问http://localhost:8080/venue-booking/,注意最后的上下文路径是 war 包名,改过包名这里也要改。验收动作按三条路径走:先看登录页面能不能打开,再用管理员账号登录后台,能新增场馆并生成时段;然后退出,用普通用户注册登录,选择一个空闲时段提交预约;最后回到后台确认订单,此时时段状态从空闲变成已占用。整条链路走通了,说明这套源码在你机器上真正活了。

4. 核心模块拆解:预约提交、冲突检测与用户权限控制

4.1 提交预约的核心逻辑:先查后插的并发漏洞与事务处理

预约系统的核心方法是“提交预约”,几乎所有安全性和并发问题都集中在这。最朴素的写法是先查询时段是否空闲,再插入订单,但仔细想一下:如果两个人同时提交同一个时段,两个查询都看到空闲,两个插入都成功,就超卖了一晚。源码项目里的做法一般是在事务里对时段行加锁查询,再更新状态和插入订单。

@Override @Transactional(rollbackFor = Exception.class) public boolean book(Integer userId, Integer slotId) { Slot slot = slotMapper.selectByIdForUpdate(slotId); if (slot == null || slot.getStatus() != 0) { return false; } slot.setStatus(1); slotMapper.updateStatus(slot); Booking booking = new Booking(); booking.setUserId(userId); booking.setSlotId(slotId); booking.setVenueId(slot.getVenueId()); booking.setStatus(0); booking.setCreateTime(new Date()); bookingMapper.insert(booking); return true; }

这段代码的关键在selectByIdForUpdate,对应的 Mapper XML 是SELECT * FROM slot WHERE id = #{id} FOR UPDATE。FOR UPDATE是悲观锁,事务开启后,这一行数据被当前事务锁住,其他事务的查询会阻塞到本事务提交或回滚。配合@Transactional(rollbackFor = Exception.class),如果中间任何一步抛异常,状态更新和订单插入一起回滚,不会出现时段标记占用但订单没生成的情况。

参数说明:rollbackFor里写Exception.class而不是默认的RuntimeException.class,是把受检异常也纳入回滚,比如主键冲突产生的DuplicateKeyException虽然是运行时异常,但显式声明这个参数更保险。slotMapper.updateStatus(slot)更新的是整个对象的所有字段,如果想防误改,可以在 XML 里只写UPDATE slot SET status = 1 WHERE id = #{id} AND status = 0,这样影响行数为 0 时就说明时段已被抢。

4.2 登录与权限拦截:Filter 如何挡住未登录访问

SSM+JSP 项目里权限控制不需要 Spring Security,一个 Filter 就够了。登录成功后把用户对象放进 session,之后每个请求进 Filter,检查 session 里有没有 user,没有就重定向到登录页。这个是 Web 工程里最经典的做法,好理解也好维护。

public class LoginFilter implements Filter { @Override public void doFilter(ServletRequest req, ServletResponse resp, FilterChain chain) throws IOException, ServletException { HttpServletRequest request = (HttpServletRequest) req; HttpServletResponse response = (HttpServletResponse) resp; HttpSession session = request.getSession(false); Object loginUser = session == null ? null : session.getAttribute("loginUser"); if (loginUser == null) { response.sendRedirect(request.getContextPath() + "/login.jsp"); return; } chain.doFilter(req, resp); } }

注意request.getSession(false)里的false,意思是拿不到 session 就返回 null,而不是新建一个。如果写成getSession(),每个未登录请求都会强制创建 session,相当于拦截器白写了。Filter 配置在 web.xml:

<filter> <filter-name>loginFilter</filter-name> <filter-class>com.venue.web.filter.LoginFilter</filter-class> </filter> <filter-mapping> <filter-name>loginFilter</filter-name> <url-pattern>/*</url-pattern> </filter-mapping>

/*会拦截所有请求,包括登录页、静态资源 JS/CSS 图片和登录接口。所以 Filter 内部要放行这些路径,常见做法是判断请求 URI:包含login.jsp、login.do、/static/、.css、.js、.png的直接放行。很多源码包已经写好了这部分,如果没有,你加上时一定要把静态资源排除掉,否则登录页的样式会全部加载不出来。

4.3 后台管理与个人信息页面:场馆时段维护和数据回显

后台管理的功能比较直白:管理员维护场馆列表,维护某场馆未来一段时间的时段,查看所有预约订单并变更状态。这类页面的难点不在 SQL,而在 JSP 数据回显。比如个人信息展示页面,就是典型的“把 user 表字段显示在 JSP 表格里”。以 jsp 个人信息展示页面为例,页面要显示当前登录用户的姓名、手机号、角色,那么 Controller 里把用户对象放到 session 里即可:

<table> <tr> <td>姓名</td> <td>${sessionScope.loginUser.realName}</td> </tr> <tr> <td>手机号</td> <td>${sessionScope.loginUser.phone}</td> </tr> <tr> <td>角色</td> <td>${sessionScope.loginUser.role == 2 ? '管理员' : '普通用户'}</td> </tr> </table>

${sessionScope.loginUser.realName}是 EL 表达式,直接从 session 作用域里取对象的属性,比 Java 代码里写session.getAttribute()再强转简洁得多。如果页面显示空白,先看 Controller 里放的是session还是request,用错了作用域数据就到不了页面。还有一种常见需求是在 JSP 页面上做图片坐标定位来点选场地,这类交互不要指望 JSP 模板去实现,JSP 只负责输出 HTML,点击定位应该交给前端 JavaScript 处理,后端接收坐标参数存库就行。

后台管理页面的表格会做得比较宽,场馆名称、时段起止、订单状态、操作按钮,我用<c:forEach>遍历列表输出,每行操作按钮里的id都是从列表对象里动态取出来的,比如onclick="confirmOrder(${booking.id})"。这种页面在 jQuery 时代很常见,放到现在依然能跑,前提是数据列表不能一次全查出来,需要接分页,先LIMIT offset, pageSize用着,数据量大再上 PageHelper。

5. SSM+JSP 场馆预约系统的 5 个高频踩坑记录

5.1 MySQL 8 连不上:驱动类名与时区参数的翻车现场

现象:Tomcat 启动时报ClassNotFoundException: com.mysql.jdbc.Driver,或者连接数据库时报Communications link failure,再或者报The server time zone value '�й���ʱ��' is unrecognized。

原因:拿到的源码包里 pom 依赖可能还是老旧的mysql-connector-java 5.x,MySQL 8.x 里驱动类名从com.mysql.jdbc.Driver换成了com.mysql.cj.jdbc.Driver,同时要求连接串带serverTimezone参数,否则驱动无法解析系统时区,报错信息里还会出现中文乱码。

解决:把 pom 里 MySQL 驱动版本升到 8.0.x,jdbc.properties里的jdbc.driver改为com.mysql.cj.jdbc.Driver,连接串末尾加上serverTimezone=Asia/Shanghai&useSSL=false。如果不想升驱动,也可以把 MySQL 库降级到 5.7,但新环境装 5.7 反而更麻烦,所以升级驱动才是更干净的路子。

5.2 JSP 页面用 EL 取不到数据:作用域和视图解析器的坑

现象:页面打开正常,表格也渲染出来了,但$(list}这种位置全是空,控制台没有报错。这是这套系统里最隐蔽的坑之一,问题不是数据没查到,而是数据放的位置和 JSP 取的位置不一致。

原因:Controller 返回 ModelAndView 时把数据放在了model里,这对应的是 request 作用域;如果拦截器或过滤器把请求转发到了另一个 JSP,数据还在原 request 里,转发后新页面拿不到旧 request 的属性。还有一种情况是 project 里装了isELIgnored配置,或者 web.xml 用的 Servlet 规范版本太低导致 EL 默认不启用。

解决:先看 JSP 顶部是否有<%@ page isELIgnored="false" %>;再检查 Controller 返回视图名时是否用redirect:,重定向会新建 request,需要把参数拼到 URL 后面;最后在 JSP 页面里临时用(request.getAttribute("list")打印一下,能确认是作用域问题还是 EL 开关问题。

5.3 MyBatis update 返回 0:状态改不动的三个常见原因

现象:后台点“确认订单”,页面提示成功,但刷新后订单状态还是待确认,数据库里 status 也没变。

原因:第一,Service 方法没有加@Transactional,MyBatis 的 SqlSession 自动提交被关闭时更新会回滚;第二,Mapper XML 里的 update 语句 where 条件写错,比如更新条件带了and status = 0,但当前订单状态不是 0;第三,Java 实体类属性名与表字段驼峰映射不上,realName映射成real_name失败导致更新语句的 set 片段为空。

解决:先看控制台有没有 SQL 日志,直接把 MyBatis 打印的 update 语句复制到数据库客户端里执行,能判断是 SQL 条件问题还是事务问题。然后在 Service 方法上显式加@Transactional,mybatis-config.xml里确认启用了map-underscore-to-camel-case,否则就要在 resultMap 里逐一映射字段名。

5.4 图片路径 404:换了项目名就找不到资源的血泪经验

现象:场馆列表的图片在上个项目里好好的,换个机器部署后全部裂开,控制台报 404,路径里显示的是/venue-booking/upload/xxx.jpg,但浏览器请求的是/upload/xxx.jpg。

原因:JSP 页面里写了相对路径src="upload/xxx.jpg",本地开发时项目上下文路径恰好是根路径,没暴露问题;部署时 war 包名带了前缀,页面 URL 变了,相对路径解析出来的根路径对不上,图片就全丢了。这是 JSP 页面里最容易犯的路径问题,改 Tomcat 端口或改项目名都会踩。

解决:页面上所有静态资源路径都用绝对路径拼接,src="\${pageContext.request.contextPath}/upload/xxx.jpg"。上传的图片要存在服务器磁盘目录而不是项目部署目录,因为 Tomcat 解压 war 后每次更新部署会清空 webapps 下的上传文件。常见的做法是单独配一个file.upload-dir,SpringMVC 里用addResourceHandlers把磁盘目录映射到/upload/**访问路径。另外有人想在 JSP 页面上做图片坐标定位来标记场馆座位,这种需求请交给前端用绝对定位或 canvas 实现,模板引擎不负责坐标计算,后端只存坐标值即可。

5.5 数据库时间从页面变成英文:Date 序列化与格式化处理

现象:场馆时段列表里的开始时间显示成Tue May 17 18:00:00 CST 2023,用户看得一头雾水。

原因:数据库字段是 datetime,MyBatis 查出后映射成java.util.Date,JSP 里直接输出对象调用的是toString(),输出就是一长串英文格式。

解决:JSP 页面用 JSTL 的fmt:formatDate标签格式化,<fmt:formatDate value="${slot.startTime}" pattern="yyyy-MM-dd HH:mm"/>,页头记得引入<%@ taglib prefix="fmt" uri="http://java.sun.com/jsp/jstl/fmt" %>。后端返回 JSON 接口时在实体类的 getter 上加@JsonFormat(pattern="yyyy-MM-dd HH:mm:ss", timezone="GMT+8"),避免前端拿到的是 UTC 时间。数据库里存 datetime 比 timestamp 更省心,因为 timestamp 有 2038 年上限且受数据库时区影响,排错要少一层。

6. 给预约系统加一道保命锁:唯一索引、乐观锁与事务怎么配合

这类系统真正上线后最容易出的事故不是功能缺失,而是并发预约把同一时段卖给了两个人。我在前面第 4 章里用了悲观锁FOR UPDATE解决冲突,但悲观锁在用户量上来后会有锁等待,所以更稳妥的做法是“索引兜底、乐观锁控制、事务回滚”三层配合。

第一层是数据库唯一索引,也就是 booking 表里那个uk_user_slot唯一键。这层是最终防线,即使代码逻辑全错了,数据库也会拦住同一个人对同一个时段的重复预约,插入时抛出DuplicateKeyException。第二层是乐观锁,在更新时段状态时带上条件WHERE id = #{slotId} AND status = 0,update 影响行数为 0 说明时段已经被人占了,Service 直接返回失败提示,不再继续插入订单。第三层是事务,把“更新时段状态”和“插入订单”放在同一个@Transactional方法里,任何一步失败都回滚。

验证方法很直观:启动项目后,用两个浏览器无痕窗口同时登录两个账号,对同一个时段毫秒级前后点击提交,最终库里只能出现一条 booking 记录,slot 状态为 1。想看压力效果可以写一段并发脚本,在命令行循环发起请求,然后去查slot和booking两张表确认没有状态漂移。如果不用锁,这个测试十次能复现两三次超卖。

我现在的习惯是接任何一套预约类源码,先翻它的 booking 表有没有唯一索引,再翻 Service 有没有事务注解,两个都没有就直接提醒对方上线前必须补,否则高峰期迟早出事。这个三层方案已经把血泪经验压进去了,希望帮到你。

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

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

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

立即咨询