SSM+JSP场馆预约系统:从表设计到并发预约的实战笔记

发布时间:2026/10/27 16:16:40
SSM+JSP场馆预约系统:从表设计到并发预约的实战笔记 简介基于SSM与JSP的场馆预约管理系统完整源码包聚焦场馆预约场景中的场地管理、轮播图管理、通知管理、场馆预定等核心业务模块后端采用Spring、SpringMVC与MyBatis整合方案前端使用JSP配合jQuery及通用UI样式同时区分管理员与用户双角色权限适合作为JavaWeb毕业设计、课程设计或SSM框架进阶实战项目。包内共1021个文件主要包含Java源码、JSP页面、JS/CSS前端资源、JAR依赖库、SQL初始化脚本和XML配置等压缩包约22.43MB建议在JDK1.8、MySQL5.7、Tomcat7环境下使用IDEA导入运行。已有47人学习通过该项目可快速理解SSM整合流程、JSP交互机制以及角色权限控制思路。整套源码附带数据库建表/初始化脚本与完整前后端代码目录结构清晰支持直接部署运行也可按需扩展场地管理、预约审核、通知维护等功能用于毕业设计答辩或项目演示非常合适。1. 为什么 SSMJSP 在场馆预约这种管理系统里还没过时如果你的需求是在两周内交付一个能演示、能答辩、逻辑透明的场馆预约管理系统SSMJSP 这个组合依然是最稳妥的选择之一。Spring 管对象、SpringMVC 收请求、MyBatis 读写数据库、JSP 出页面整套链路里没有黑匣子SQL 写在哪、事务加在哪、状态怎么流转都一眼能看穿。相比把前端拆成 Vue 再搞一套 token 鉴权这类“表单 状态机 管理后台”的业务用 JSP 做服务端渲染反而少掉一半联调成本。这篇笔记就按我实际搭这类项目的顺序从表结构、分层代码、预约并发到部署排错一条线讲完。2. 先把预约系统的骨架立起来四张表、三层包、一份最小 mybatis 配置2.1 场馆预约系统的四张核心表为什么预约单必须单独存一份场地快照场馆预约这个业务看着简单真正落表的时候容易想少。最常见的错误是只建两张表——用户表和预约表场馆信息全写在预约单里。刚跑通登录预约没问题等管理员改了一次场馆名称你会发现历史预约单上的名字跟着变了答辩时讲数据一致性根本讲不清楚。我一般会按四张核心表来建第一张是用户表字段包含用户名、密码、真实姓名、角色区分普通用户和管理员、手机号、创建时间。第二张是场馆表记录场馆名称、位置、可容纳人数、开放时间和状态。第三张是预约单表这是整个系统的业务核心。第四张是公告表用于在首页展示系统通知属于锦上添花但答辩很好讲的部分。预约单表这里有一个值得注意的设计习惯除了关联用户 ID 和场馆 ID 之外还要冗余一份用户姓名和场馆名称快照。也就是说提交预约的瞬间就把这两个名字字段复制进预约单之后改不改场馆名都与历史记录无关。预约单的状态我建议用整型数字维护0 待审核、1 已通过、2 已拒绝、3 已取消、4 已使用。用数字状态的好处是后续做统计和流转判断都很直接。表名核心职责关键字段t_user登录与会话id, username, password, real_name, rolet_venue场馆信息id, name, location, capacity, statust_reservation预约单与状态流转id, user_id, venue_id, reserve_date, start_time, end_time, statust_notice首页公告id, title, content, create_time之所以把时间和日期拆成reserve_date加start_time、end_time而不是一个 datetime 字段是因为后端的时段冲突判断会频繁用到“某天某场馆某时段是否空闲”拆开之后 SQL 写起来干净索引也更好建。如果你是照着已有源代码改拿到数据库脚本后第一步也是先看这几张表的字段和索引不要急着跑页面。2.2 SSM 分层工程怎么组织controller/service/mapper 各管什么拿到 SSM 源码工程后第一个要认清的是它的包结构。SSM 项目的分层非常固定看得懂一层就能顺藤摸瓜看懂全部。我按实际路径拆给你看src/main/java ├── com/example/venue │ ├── controller # LoginController, VenueController, ReservationController │ ├── service │ │ ├── impl # 业务逻辑和 Transactional 事务注解在这里 │ ├── mapper # 接口类方法名对应 XML 里的 statement id │ ├── entity # User, Venue, Reservation 等实体类 │ └── interceptor # 登录拦截器 src/main/resources ├── jdbc.properties # 数据库连接参数 ├── mybatis-config.xml # MyBatis 全局配置 ├── spring-mvc.xml # 控制器扫描、视图解析器、拦截器 ├── spring-mybatis.xml # 数据源、SqlSessionFactory、Mapper 扫描 └── mapper # UserMapper.xml, ReservationMapper.xml, VenueMapper.xml src/main/webapp └── WEB-INF ├── web.xml # DispatcherServlet 与过滤器 └── jsp # login.jsp, index.jsp, reservation_list.jspcontroller 层只做参数接收、调用 service、把结果放进 Model 然后返回视图名不要在里面写业务判断。service 层是重头所有的状态校验、冲突检测、事务控制都在这层做。mapper 层是接口加 XML一个方法对应一条 SQL查询条件多的时候 XML 比注解好维护得多这也是我建议保留 XML 文件而不是全改注解的原因。新手拿到源码最容易迷路的地方是“一个请求从点按钮到显示出页面到底经过了哪些文件”。以管理员审核预约为例点击“通过”按钮会提交表单到/reservation/auditSpringMVC 把这个 URL 分配给 ReservationController 里的 audit 方法controller 调用 service 的 audit 方法做状态校验和更新service 再调用 mapper 里的 update 方法执行 SQL最后返回一个 JSON 或重定向。JSP 页面本身不直接访问数据库它只负责渲染 controller 放进 Model 的数据。2.3 最小可跑的 spring-mybatis.xml数据源、SqlSessionFactory、Mapper 扫描三件套SSM 项目跑不起来八成问题出在 spring-mybatis.xml 和 jdbc.properties 这两份文件上。我最常看到的现象是项目导入 IDEA 后不报编译错一启动就报Invalid bound statement (not found)这就是 mapper 接口和 XML 没被扫描到。先看 spring-mybatis.xml也就是 Spring 整合 MyBatis 的最小配置bean iddataSource classcom.alibaba.druid.pool.DruidDataSource init-methodinit destroy-methodclose property namedriverClassName value${jdbc.driver}/ property nameurl value${jdbc.url}/ property nameusername value${jdbc.username}/ property namepassword value${jdbc.password}/ property nameinitialSize value5/ property namemaxActive value20/ /bean bean idsqlSessionFactory classorg.mybatis.spring.SqlSessionFactoryBean property namedataSource refdataSource/ property nameconfigLocation valueclasspath:mybatis-config.xml/ property namemapperLocations valueclasspath:mapper/*.xml/ /bean bean classorg.mybatis.spring.mapper.MapperScannerConfigurer property namebasePackage valuecom.example.venue.mapper/ property namesqlSessionFactoryBeanName valuesqlSessionFactory/ /bean数据源这里用 Druid 还是 dbcp 不重要关键是jdbc.properties里的参数别写错。initialSize是连接池启动时创建的连接数maxActive是最大连接数。对课程设计和管理系统这类低并发场景initialSize 设为 5、maxActive 设为 20 足够用了调大反而会增加数据库空闲连接的维护成本。mapperLocations和MapperScannerConfigurer是一对搭档前者告诉 MyBatis“SQL 的 XML 文件在哪个目录”后者告诉 Spring“mapper 接口在哪个包”。这两个路径任何一个配错都会出现接口方法能调用但找不到 SQL 的错误。如果你拿到手的源码用的是MapperScan注解而不是 XML 配置效果是一样的二选一即可。3. 把预约主流程写透登录会话、并发预约、管理员审核3.1 登录会话怎么管不用 spring-security 的轻量做法场馆预约管理系统没必要上 Spring Security它的权限模型只有“普通用户”和“管理员”两种角色用 session 存登录对象再加一个拦截器就足够了。登录的核心代码长这样Controller public class LoginController { Autowired private UserService userService; RequestMapping(/login) public String login(String username, String password, HttpSession session, Model model) { User user userService.login(username, password); if (user null) { model.addAttribute(msg, 用户名或密码错误); return login; } session.setAttribute(loginUser, user); return redirect:/reservation/list; } }userService.login内部做的事是把用户传来的密码做一次 MD5教学项目常用生产环境请换 BCrypt再按用户名和加密后的密码去查用户表。查到了就返回 User 对象查不到返回 null。密码加密这一步不要写在 controller 里我见过把加密逻辑写在 JSP 页面里的写法那属于把业务逻辑放错了层后期想换加密算法要满项目翻页面。会话保存进 session 后页面里用${sessionScope.loginUser.realName}就能显示当前登录人的姓名。但光存 session 不够所有需要登录的页面都要做拦截否则用户直接在地址栏输入/reservation/list就能绕过登录。拦截器这样写public class LoginInterceptor implements HandlerInterceptor { Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { Object loginUser request.getSession().getAttribute(loginUser); if (loginUser null) { response.sendRedirect(request.getContextPath() /login); return false; } return true; } }这个类需要实现 SpringMVC 的 HandlerInterceptor 接口并在 spring-mvc.xml 里注册拦截路径。注册时要把登录接口、静态资源目录排除掉否则 css、js、图片全被拦住页面样式丢光。排除项我会写成/login、/css/**、/js/**、/images/**。3.2 提交预约单同一时段重复预约是怎么堵住的场馆预约的核心功能是提交预约单但真正考验代码功底的是如何阻止两个人在同一时间预约同一个场地。先看正常的提交逻辑Transactional public Result addReservation(Reservation r) { ReservationQuery query new ReservationQuery(r.getVenueId(), r.getReserveDate(), r.getStartTime(), r.getEndTime()); int conflict reservationMapper.countConflict(query); if (conflict 0) { return Result.fail(该时段已被预约); } r.setStatus(0); // 待审核等待管理员确认 r.setCreateTime(new Date()); reservationMapper.insert(r); return Result.ok(); }Transactional注解很重要它保证“查冲突”和“插入预约单”这两个操作要么一起成功要么一起回滚。对应的 SQL 是时间段重叠判断的关键这也是整张预约单表最核心的一条查询select idcountConflict resultTypeint SELECT COUNT(*) FROM t_reservation WHERE venue_id #{venueId} AND reserve_date #{reserveDate} AND status IN (0, 1) AND #{startTime} lt; end_time AND start_time lt; #{endTime} /select这段 SQL 里的status IN (0, 1)表示“待审核和已通过的预约都算占用”已取消和已拒绝的记录不参与冲突判断。时间段重叠的判断条件是新预约开始时间小于已有预约结束时间且已有预约开始时间小于新预约结束时间。假想两个预约一个是 10:00-11:00、一个是 11:00-12:00第一条条件#{startTime}11:00 end_time11:00不成立所以不冲突这是对的。但上面的写法有一个隐患两个用户同时提交同一个时段都执行了 countConflict发现都是 0然后同时 insert结果两个都成功了。这就是并发超卖。要堵住这个问题常见做法是给预约表加一个唯一索引让数据库做最后的兜底ALTER TABLE t_reservation ADD UNIQUE KEY uk_venue_time (venue_id, reserve_date, start_time, end_time);加了唯一索引后两个并发请求里只有一个能插入成功另一个会抛 DuplicateKeyException事务回滚后提示“该时间段已被预约”。这是我在这类系统里最推荐的做法因为它在数据库层面兜底业务代码不用写复杂的锁逻辑。3.3 管理员审核与状态机流转待审核、已通过、已拒绝的边界处理管理员登录后看到的是所有待审核预约单列表点击通过或拒绝后台执行的是状态更新。但这里有个常见的翻车点管理员连续点两次“通过”如果代码不做状态判断预约单会被重复更新甚至把已拒绝的单子改成已通过。审核代码需要带前置状态校验Transactional public Result audit(Integer reservationId, Integer newStatus, String opinion) { Reservation r reservationMapper.selectByIdForUpdate(reservationId); if (r null) { return Result.fail(预约单不存在); } if (r.getStatus() ! 0) { return Result.fail(只有待审核的预约单才能审核); } r.setStatus(newStatus); r.setOpinion(opinion); reservationMapper.update(r); return Result.ok(); }selectByIdForUpdate是带行锁的查询SELECT ... FOR UPDATE 会把这条预约单记录锁住直到事务提交。在管理员并发审核同一条单子时后到的请求会被阻塞在查询上等前一个事务提交后它再读到新状态发现 status 已经不是 0就会返回“只有待审核的预约单才能审核”。这个写法在低并发的管理后台足够用了。状态机的完整流转边界我习惯这样约束待审核状态下可以流转到已通过或已拒绝已通过状态下用户可以取消变成已取消实际到场使用后由管理员或定时任务把状态改为已使用。已拒绝和已取消是终态不再允许任何流转。这些判断散落在 service 层各个方法里写的时候建议每个状态流转都做一次前置校验不要为了少写代码而跳过。4. 六个高频问题排查从中文乱码到并发超卖4.1 中文乱码三连POST 请求、数据库连接串、JSP 页面编码现象向数据库插入中文用户名或场馆名称存进去变成??页面上从数据库读出来的中文也是乱码。原因乱码从来不是单一原因造成的。最常见的是三个环节不一致POST 请求提交换成 UTF-8数据库连接串没加字符编码参数JSP 页面编码和项目不统一。只要其中一个环节断了中文就会在你没注意的地方变形。解决第一在 web.xml 里配置 Spring 提供的CharacterEncodingFilter并且把forceEncoding设为true保证请求和响应都强制使用 UTF-8。第二在连接串上加上useUnicodetruecharacterEncodingutf8。第三JSP 页面头部统一写pageEncodingUTF-8和contentTypetext/html; charsetUTF-8。这三处都改完之后把表删了重新导数据乱码基本能绝根。改完配置后记得重启 Tomcat改 JSP 可以不重启但改 web.xml 必须重启才生效。4.2 JSP 页面里图片和链接用相对路径一进二级路由就全挂现象登录页是http://localhost:8080/venue/login图片正常显示。跳转到列表页http://localhost:8080/venue/reservation/list后页面上的 logo 图片全碎了CSS 样式也没了。原因JSP 里写的是img srcimages/logo.png这样的相对路径。浏览器解析相对路径时会基于当前 URL 的目录层级去找资源。在/venue/login下它找的是/venue/images/logo.png在/venue/reservation/list下它找的是/venue/reservation/images/logo.png后者根本不存在。这就是处理相对路径常见的坑。解决不要写相对路径用 JSP 内置表达式拼绝对路径。项目里常见做法是img src${pageContext.request.contextPath}/images/logo.png或者更简洁一点在页面顶部用c:set varctx value${pageContext.request.contextPath}/后面所有资源引用都写成${ctx}/images/logo.png。改完之后不管 URL 有几级目录资源都能找对位置。4.3 IDEA 部署后 404 和 ClassNotFoundException现象Tomcat 正常启动了登录页也能打开但点开任何带业务功能的页面就 404或者干脆启动时报ClassNotFoundException: org.springframework.web.servlet.DispatcherServlet。原因IDEA 里用 Maven 管理依赖时Tomcat 启动时用的是 artifact 结构如果没有把项目的依赖 jar 打包进WEB-INF/lib运行时就找不到 Spring 相关的类。很多从网上下载的 SSM 源码依赖都是手动引入的 lib 目录导入 IDEA 后 artifact 里根本没有这些 jar自然启动即崩。解决打开项目结构进入 Artifacts 设置选中项目的 war exploded 包在 Output Layout 里找到WEB-INF/lib目录点击加号选择 Library Files把项目所有依赖加进去。部署方式选择 war exploded 而不是 war这样修改代码后重启速度快且热点替换对 JSP 页面友好。这条属于排查顺序里的必检项每次换电脑重新导入项目我都先看一眼 lib 有没有打进去。4.4 老项目不能直接换 Tomcat 10javax 和 jakarta 命名空间现象项目在别人电脑上跑得好好的你换了个新本子下载了最新的 Tomcat 10结果启动时一堆NoClassDefFoundError: javax/servlet/xxx。原因Tomcat 从 10.0 开始把 Java EE 的包名从javax.servlet迁移到了jakarta.servlet。而大部分 SSM 老项目是基于 Java EE 8 写的代码和依赖 jar 里引用的都是javax包名。Tomcat 10 里根本没有javax.servlet这些类所以全部报 NoClassDefFoundError。解决这类项目用 Tomcat 8.5 或 Tomcat 9 就对了。不要为了追新把 Tomcat 升到 10 然后去改几十个文件的 import 语句纯属自己找罪受。如果你同时维护好几个老项目建议统一用 Tomcat 9它在javax命名空间下比较稳定也不会像 8.5 那样有模块化上的小毛病。4.5 日期差八小时MyBatis 查当天预约记录查不到现象用户提交预约保存时看数据库里的 create_time 是对的但页面显示时间比实际慢了 8 小时。或者管理员按日期筛选预约记录查出来的数据是前一天晚上的。原因MySQL 8 和 JDBC 驱动之间的时区处理不一致。驱动默认按照 JVM 时区写时间而连接串里没有指定 serverTimezoneMySQL 会话时区与操作系统时区打架最终结果就是时间整体偏移 8 小时。解决在 jdbc.url 上显式加上serverTimezoneAsia/Shanghai同时连接串里再带useSSLfalse省去证书校验的告警。如果是查询某一天的预约记录SQL 里建议写成DATE(reserve_date) #{date}这样的形式不要用BETWEEN 2026-03-20 00:00:00 AND 2026-03-20 23:59:59后者在时间字段带时分秒的场景下容易漏掉边缘数据。4.6 连点两次提交按钮生成了两条一模一样的预约单现象用户提交预约后页面没有及时跳转快速连点了两次“提交”结果预约列表里出现两条完全相同的记录。管理员审核时只能手动删掉一条。原因前端没有做按钮防重复提交后端 countConflict 和 insert 之间也不是原子操作。两个请求几乎同时进入 servicecountConflict 返回都是 0之后两条记录都插入成功。这个问题在演示答辩时特别尴尬曾经有学生现场被老师连点两次按钮看翻车。解决前端在提交成功后立即禁用按钮是最直接的手段但后端必须有兜底。给表加上(venue_id, reserve_date, start_time, end_time)的唯一索引第二条 insert 会直接抛 DuplicateKeyException代码捕获后提示“请勿重复提交”。这样即便是恶意绕过前端脚本直接发请求数据库也会拦得住。这两个层面都做了这个坑才算真正填平。5. 本地跑通最小闭环导库、改三条配置、按验收清单点一遍5.1 建库与导入先用 mysql 命令把数据库脚本跑通拿到源码和数据库文件后第一步不是打开 IDEA而是先把数据库建出来。网上很多 SSM 项目自带 sql 文件命名类似venue_reserve.sql。导入命令如下mysql -u root -p CREATE DATABASE IF NOT EXISTS venue_reserve DEFAULT CHARSET utf8mb4; USE venue_reserve; SET NAMES utf8mb4; SOURCE /data/project/venue_reserve.sql;SET NAMES utf8mb4是为了让本次会话的客户端、连接、结果集都使用 utf8mb4 编码避免在导入过程中把中文注释和初始数据变成乱码。SOURCE后面写 sql 文件的绝对路径如果路径里有空格用引号把路径包起来。查看是否导入成功可以执行SHOW TABLES;看看有没有 t_user、t_venue、t_reservation 这几张表。导入失败最常见的原因有两个一个是 sql 文件里有中文注释但编码不是 UTF-8用文本编辑器打开另存为 UTF-8 后再导入另一个是表之间有外键依赖导入时FOREIGN_KEY_CHECKS没关导致建表顺序限制报错。如果是后者导入前先执行SET FOREIGN_KEY_CHECKS0;导入完再恢复为 1。5.2 只改三处配置数据库连接、视图目录、初始账号密码配好数据库后真正需要动手改的配置通常只有三处。第一处是jdbc.properties这是项目运行的生命线jdbc.drivercom.mysql.cj.jdbc.Driver jdbc.urljdbc:mysql://localhost:3306/venue_reserve?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/ShanghaiuseSSLfalse jdbc.usernameroot jdbc.password你的数据库密码这里3306是 MySQL 默认端口 被改成 3307 或其他端口时这个 URL 也要同步改。serverTimezoneAsia/Shanghai千万别删上一章刚说过删了它时间就会差 8 小时。连接串上的useUnicode和characterEncoding也不要精简精简掉任何一段都可能让中文写入数据库时变成问号。第二处是 spring-mvc.xml 里的视图解析器确认prefix指向 JSP 页面实际所在目录。很多项目源码会把视图目录放在/WEB-INF/jsp/或/WEB-INF/views/如果你的目录结构和源码不一致登录后跳转就会 404。第三处是初始账号密码sql 文件里一般会预置一个 admin 用户密码可能是明文也可能是 MD5 加密后的字符串。建议在数据库里直接用 SQL 改掉初始密码不要留空口密码在页面上试半天。5.3 启动与验收清单从登录到审核的全流程验证配置改完就可以启动 Tomcat 了。IDEA 里点击 Add Configuration添加 Tomcat Server Local 运行配置Deployment 里选 Artifact 的 war exploded 包Application context 设为/venue启动后访问http://localhost:8080/venue/login。第一次启动大概率不会一次通过我的经验是按顺序看三个信号Tomcat 控制台有没有打印出项目加载完成的日志数据库连接有没有报错访问登录页时页面样式是否完整。前两个信号过了登录页样式全了说明整个工程的基本链路已经通了接下来按验收清单逐项点步骤操作预期结果登录用预置账号登录跳转到预约列表页右上角显示真实姓名场馆浏览打开场馆列表主页正常图片路径均显示完整创建预约选一个未来日期和时段提交提示提交成功状态为待审核重复时段同场馆同时段再提交一次提示该时段已被预约不能重复提交管理员审核用管理员账号进入待审核列表能通过或拒绝状态与意见正确回显用户取消回到用户端取消已通过预约状态变为已取消时段释放可被新预约占用验收过程中遇到任何一步不符合预期回到对应章节排查。登录跳不过去查拦截器和视图解析器重复时段校验不生效查唯一索引有没有加上管理员列表看不到数据查 t_reservation 表关联查询的 user_id 和 venue_id 是否正确。6. 进阶并发验证、唯一索引兜底与 JSP 工程的可维护性项目能跑了只是开始答辩或上线前我还要做三件事。第一件事是验证并发场景拿 JMeter 建一个线程组20 个线程同时提交同一个场馆同一天的同一个时段跑完看数据库里成功插入了几条。正常结果应该只有一条如果插入了多条说明你的唯一索引没生效回去检查索引是否真正加到了表上。也可以用这条 SQL 快速体检当前库里有没有重复数据SELECT venue_id, reserve_date, start_time, end_time, COUNT(*) FROM t_reservation GROUP BY venue_id, reserve_date, start_time, end_time HAVING COUNT(*) 1;有输出说明库里存在重复预约没有输出说明索引兜底是正常的。第二件事是确认状态机边界没有被绕过去。我会手动构造几个异常请求直接调用审核接口审核一条已拒绝的单子、把已取消的单子改成已使用、用普通用户角色访问管理员接口。这些请求都应该被后端拦下来而不是返回成功。JSP 系统里最容易出现这类权限漏洞因为页面上的按钮可以隐藏但接口地址是固定的谁都能直接构造请求。第三件事是给 JSP 工程留一条维护后路。随着页面增多直接在 JSP 里写大段 Java 脚本片段会越来越难维护。我把原则定为“ JSP 里只放 JSTL 标签和 EL 表达式所有数据在 controller 里准备好放进 Model”。改造成本不高但半年后再回来改需求时不用在几百行 JSP 里翻找if和for这种轻松只有踩过坑的人才懂。这些项目里 SQL 慢查询也是个隐患出现页面卡顿先开启 MySQL 慢查询日志定位到具体 mapper 方法后用 EXPLAIN 看一眼索引命中情况比盲目加缓存更有效。我自己做这类项目时翻车最狠的一次是答辩当天发现数据库连的是测试库管理员审核的数据和页面展示的数据完全对不上。从那以后我养成了一个习惯启动项目前先看一眼连接串确认库里数据的更新时间是不是最近的。这种细节决定了演示是顺利收场还是当场翻车。做场馆预约管理系统关键不在于用了多新的技术而在于每个环节都稳定、每个状态都可解释、每个坑都提前填平这套思路一样适用于你接下来的其他管理类项目希望帮到你。本文还有配套的精品资源点击获取