基于SSM的电影院订票系统前后台开发实战与避坑指南
简介这是一套基于SpringSpringMVCMybatis的电影院订票系统前后台源码面向JavaWeb学习者、毕业设计或课程设计人群可支撑SSM整合开发与MVC分层实践。系统前台涵盖登录注册、影片预览、档期查询、订票与影评等模块后台提供用户、影厅、影片、档期计划、订票、影评及系统管理功能链路完整。压缩包共1156个文件以JSP、Java、XML、SQL为主并包含CSS、JavaScript、HTML等前端资源及JAR依赖整体大小17.77MB目录按前后台模块划分便于直接导入Eclipse或IDEA运行调试。其中还提供数据库脚本与核心Java类包含Controller、Service、Mapper等分层代码可帮助理解面向接口编程、事务处理和Excel导出等实现细节。已有1532人下载学习适合需要快速搭建SSM项目或参考完整业务逻辑的开发者。1. 先用一张“票”说清这套SSM前后台到底解决什么问题在电影院的日常里“卖一张票”从来不只是一个动作顾客要看上映列表、选场次、选座位、下单、支付运营人员要维护电影、排片、调价、处理未支付订单。若前后台各做一套系统数据同步和权限管理很快会变成灾难。基于 SpringSpringMVCMyBatis 开发电影院订票系统前后台就是在同一个工程、同一套数据库里同时实现交易链路和运营链路靠路径前缀和拦截器做用户隔离。适合做课设、毕设或想彻底搞懂 SSM 从请求到 SQL 全链路的人。读完这篇文章你可以从建表一路做到选座下单、后台排片并避开最常见的几个坑。2. 选型与设计Spring、SpringMVC、MyBatis在订票系统里各自扛什么活很多人一听到“SSM”先皱眉现在不是都玩 SpringBoot 了吗我的回答是如果你只想要“能跑”SpringBoot 确实快但你要做的是一个前后台都有完整业务的订票系统SSM 反而更适合练手和理清脉络。因为在 SSM 里每一个“自动发生”的动作都需要你手动配置这就逼着你看清楚请求是怎么被 DispatcherServlet 接住、Service 是怎么被 Spring 管理、SQL 是怎么被 MyBatis 执行的。这套系统做完你对 Java Web 的理解和直接玩 SpringBoot 完全不一样。2.1 为什么是SSM而不是SpringBoot先分清“会做”和“懂原理”先说结论SpringBoot 把 Spring 生态的自动配置打包好让你少写配置SSM 则是把 Spring、SpringMVC、MyBatis 三个框架的配置文件摊开由开发者自己装配。电影院订票系统的核心是“下单”和“排片”这两个动作都不是单表操作需要精确控制事务边界和 SQL 逻辑。在 SSM 里事务通过注解或tx:advice显式地挂在 Service 方法上你能直观地看到哪个方法进入事务、哪个方法不会在 MyBatis 里多表联查需要自己写 resultMap 和 SQL这也是订票系统里“场次 座位 订单”三表联动时最需要的控制力。另外很多单位里仍维护着不少 SSM 老项目直接上手 SpringBoot 的人第一次碰到这类项目常常一脸懵。花一个项目的时间把 SSM 的装配方式过一遍回头再看 SpringBoot 源码会有一种“原来它只是替我把这些做掉了”的感觉。所以如果你时间允许我建议不要跳过 SSM 这一步。2.2 三个框架的职责边界在订票系统里三个框架的分工可以简化成一句话Spring 管对象SpringMVC 管请求MyBatis 管 SQL。下面这张表把各自的活拆开看框架核心组件在订票系统里的职责SpringIoC 容器、声明式事务管理电影、场次、订单等 Service 和 DAO 对象给下单、退票、排片方法配置事务边界SpringMVCDispatcherServlet、HandlerMapping、拦截器接收前台 /front 和后台 /admin 的 HTTP 请求把 JSON 参数绑定到方法形参完成登录拦截MyBatisSqlSessionFactory、Mapper 接口、XML 映射封装增删改查 SQL处理电影和排片的多表联查通过 resultMap 映射下划线字段到驼峰属性这样拆的好处是出了问题你马上知道去哪一层找。比如选座页 500先看 Controller 有没有正常返回再看 Service 事务有没有回滚最后看 Mapper XML 里的 SQL 拼得对不对。责任边界清晰排错效率会高很多。2.3 电影院订票系统的业务模型前后台该拆成多少张表和多少个模块订票系统的业务模块要从两个视角来拆。前台视角用户注册登录、正在热映列表、电影详情、场次选择、座位选择、确认下单、订单列表、模拟支付、退票。后台视角电影管理上架/下架/修改介绍、场次排片定厅、定时间、定价、订单查询与退款、用户管理、座位状态监控。先画成功能清单再去设计表比建了表再回头补功能靠谱得多。常见的数据库设计是六张核心表用户表、电影表、场次表、座位表、订单表、订单明细表。这里有一个特别容易想歪的点座位表一定要按“场次”来生成而不是做成永久固定的影院座位图。因为每个场次的售卖状态是独立的A 场次的 3 排 5 座被占了不代表 B 场次同位置不可卖。另外建议订单表里冗余保存电影名、场次开始时间、影厅号。虽然从数据库范式看不完美但订单打印、验票时不需要多次联查体验会好很多。这就是订票系统里非常典型的“以查询换冗余”的取舍。把模块和数据表对应上之后整个工程的规模就清楚了前端四个 Controller后台四个 Controller再加一个公共的登录和文件上传工作量完全可控。3. 从零跑起来环境、依赖和数据库准备这一章的目标是让你在本地把一个空工程变成能启动的 SSM 项目再把数据库架子搭好。我不会只贴一堆配置每个文件都说明它解决什么问题以及改参数时要注意什么。3.1 环境清单与标准依赖Maven坐标我常用的环境组合是JDK 1.8 或 11Maven 3.6Tomcat 8.5 或 9.0MySQL 5.7 或 8.0。不建议一上来就上 JDK 17因为一些老版本 mybatis-spring 和 Tomcat 对高版本 JDK 的兼容性会让你多花很多冤枉时间。项目用 Maven 管理依赖pom.xml 里的依赖不能瞎抄版本之间要互相匹配。下面是我验证过的一套组合dependencies !-- Spring MVC同时会带入 spring-context、spring-core 等基础包 -- dependency groupIdorg.springframework/groupId artifactIdspring-webmvc/artifactId version5.3.24/version /dependency !-- Spring JDBC提供 DataSourceTransactionManager 事务管理器 -- dependency groupIdorg.springframework/groupId artifactIdspring-jdbc/artifactId version5.3.24/version /dependency !-- MyBatis 主库 -- dependency groupIdorg.mybatis/groupId artifactIdmybatis/artifactId version3.5.13/version /dependency !-- mybatis-spring 负责把 MyBatis 的 SqlSessionFactory 交给 Spring 管理 -- dependency groupIdorg.mybatis/groupId artifactIdmybatis-spring/artifactId version2.0.7/version /dependency !-- MySQL 驱动8.x 对应 MySQL 8注意 serverTimezone -- dependency groupIdmysql/groupId artifactIdmysql-connector-java/artifactId version8.0.33/version /dependency !-- Jackson 用于把 JSON 转成对象接口交互离不开它 -- dependency groupIdcom.fasterxml.jackson.core/groupId artifactIdjackson-databind/artifactId version2.13.5/version /dependency !-- JSTLJSP 页面里做循环和条件判断用 -- dependency groupIdjstl/groupId artifactIdjstl/artifactId version1.2/version /dependency /dependencies两个容易踩的版本点一是 Spring 5.3.x 需要 JDK 8 起步如果你还在用 JDK 7就必须换 Spring 4.x整个配置思路要回退二是 mysql-connector-java 8.x 对时区敏感连接串里不带serverTimezoneAsia/Shanghai会直接报异常后面避坑章节我会专门说。3.2 建表SQL与字段设计电影、场次、座位、订单、用户数据库是这套系统里最值得花时间的部分。我一般先把表建好再回过去写代码。下面是经过简化的建表脚本字段都加了注释-- 用户表前台顾客和后台管理员共用一张表用 role 区分 CREATE TABLE user ( id INT PRIMARY KEY AUTO_INCREMENT, username VARCHAR(50) NOT NULL COMMENT 登录名, password VARCHAR(64) NOT NULL COMMENT 密文密码不要存明文, phone VARCHAR(20) COMMENT 手机号取票通知用, role TINYINT DEFAULT 1 COMMENT 1-前台用户 2-后台管理员, create_time DATETIME DEFAULT CURRENT_TIMESTAMP ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; -- 电影表 CREATE TABLE movie ( id INT PRIMARY KEY AUTO_INCREMENT, title VARCHAR(100) NOT NULL COMMENT 片名, genre VARCHAR(50) COMMENT 类型如动作/喜剧, duration INT COMMENT 片长单位分钟, poster VARCHAR(255) COMMENT 海报图片路径, description TEXT COMMENT 简介, status TINYINT DEFAULT 1 COMMENT 1-上映中 0-已下架 ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; -- 场次表一部电影在某个影厅某时间点的一场放映 CREATE TABLE schedule ( id INT PRIMARY KEY AUTO_INCREMENT, movie_id INT NOT NULL COMMENT 关联电影表, hall VARCHAR(50) NOT NULL COMMENT 影厅名如 1号厅, start_time DATETIME NOT NULL COMMENT 开演时间, price DECIMAL(10,2) NOT NULL COMMENT 本场票价下单时直接用它, status TINYINT DEFAULT 1 COMMENT 1-可售 0-已停售 ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; -- 座位表按场次生成每场一轮 CREATE TABLE seat ( id INT PRIMARY KEY AUTO_INCREMENT, schedule_id INT NOT NULL COMMENT 关联场次, row_no INT NOT NULL COMMENT 排号, col_no INT NOT NULL COMMENT 列号, status TINYINT DEFAULT 0 COMMENT 0-可售 1-已售 2-锁定, UNIQUE KEY uk_schedule_row_col (schedule_id, row_no, col_no) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; -- 订单表 CREATE TABLE orders ( id INT PRIMARY KEY AUTO_INCREMENT, order_no VARCHAR(32) NOT NULL COMMENT 业务订单号建议加唯一索引, user_id INT NOT NULL, schedule_id INT NOT NULL, amount DECIMAL(10,2) NOT NULL COMMENT 订单总价, status TINYINT DEFAULT 0 COMMENT 0-待支付 1-已支付 2-已退票 3-已失效, create_time DATETIME DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_order_no (order_no) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; -- 订单明细表一单可能有多张票所以拆出来 CREATE TABLE order_item ( id INT PRIMARY KEY AUTO_INCREMENT, order_id INT NOT NULL, seat_id INT NOT NULL, seat_label VARCHAR(10) COMMENT 冗余记录如 3排5座 ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;几个字段设计上的取舍写代码前最好先想清楚第一schedule表里冗余price而不是只留电影表的票价。原因是同一个影院常有早场优惠、会员价、特殊厅加价价格属于“某一场次”而非“某一部电影”。下单时直接用场次价格后续调整电影票价也不会影响已经生成的订单。第二seat表的唯一约束uk_schedule_row_col是防超卖的第二道防线这条很重要第 5 章会细说。第三orders.status用数字枚举而不是直接删除记录因为订单数据后续要做统计和退款物理删除会丢历史。3.3 工程分包与 Spring/MyBatis 配置包结构不要按“三层”随便堆而是先按端拆再按职责拆。这样前后台代码不会混在一起src/main/java/com/example/cinema/ ├── common/ // 统一返回 Result、业务异常 BizException ├── config/ // 拦截器、全局异常处理 ├── controller/ │ ├── front/ // 前台MovieController、OrderController │ └── admin/ // 后台ScheduleAdminController、MovieAdminController ├── service/ // 业务接口 实现 ├── mapper/ // MyBatis Mapper 接口 └── pojo/ // 实体类、VO 对象 src/main/resources/ ├── mapper/ // 与 Mapper 接口对应的 XML 文件 ├── spring/ │ ├── spring.xml // 根容器数据源、SqlSessionFactory、事务 │ └── spring-mvc.xml // Web 容器Controller 扫描、视图解析、拦截器 ├── jdbc.properties // 数据库连接信息 └── log4j.propertiesspring.xml 管理 Service、DAO 和事务spring-mvc.xml 只扫描 Controller两个容器通过 Spring 的父子容器机制关联。这样划分的好处是事务配置不会污染 Web 层你也不会在 Controller 里看到一堆事务注解。spring.xml 的关键配置context:property-placeholder locationclasspath:jdbc.properties/ bean iddataSource classorg.apache.commons.dbcp2.BasicDataSource property namedriverClassName value${jdbc.driver}/ property nameurl value${jdbc.url}/ property nameusername value${jdbc.username}/ property namepassword value${jdbc.password}/ /bean bean idsqlSessionFactory classorg.mybatis.spring.SqlSessionFactoryBean property namedataSource refdataSource/ property namemapperLocations valueclasspath:mapper/*.xml/ property nametypeAliasesPackage valuecom.example.cinema.pojo/ /bean mybatis:scan base-packagecom.example.cinema.mapper/ bean idtxManager classorg.springframework.jdbc.datasource.DataSourceTransactionManager property namedataSource refdataSource/ /bean tx:annotation-driven transaction-managertxManager/spring-mvc.xml 里关键的是注解驱动、静态资源放行、视图前缀和拦截器注册mvc:annotation-driven/ !-- 静态资源放行否则 css/js 会被 DispatcherServlet 拦截 -- mvc:resources mapping/static/** location/static// context:component-scan base-packagecom.example.cinema.controller/ bean classorg.springframework.web.servlet.view.InternalResourceViewResolver property nameprefix value/WEB-INF/views// property namesuffix value.jsp/ /bean最后在 web.xml 里注册 DispatcherServlet并加上 UTF-8 过滤器这一步漏了整个项目的中文都会乱filter filter-nameencoding/filter-name filter-classorg.springframework.web.filter.CharacterEncodingFilter/filter-class init-param param-nameencoding/param-name param-valueUTF-8/param-value /init-param /filter filter-mapping filter-nameencoding/filter-name url-pattern/*/url-pattern /filter-mapping servlet servlet-namedispatcher/servlet-name servlet-classorg.springframework.web.servlet.DispatcherServlet/servlet-class init-param param-namecontextConfigLocation/param-name param-valueclasspath:spring/spring-mvc.xml/param-value /init-param load-on-startup1/load-on-startup /servlet servlet-mapping servlet-namedispatcher/servlet-name url-pattern//url-pattern /servlet-mapping这里有个很容易忽略的细节如果 spring.xml 没有通过 ContextLoaderListener 加载事务和 MyBatis 的 bean 只在根容器里DispatcherServlet 就扫描不到 ServiceController 启动时会直接报“找不到 bean”。所以 web.xml 里还需要补一段context-param param-namecontextConfigLocation/param-name param-valueclasspath:spring/spring.xml/param-value /context-param listener listener-classorg.springframework.web.context.ContextLoaderListener/listener-class /listener到这一步项目已经能空跑起来。下面进入最核心的部分把一条真实的购票链路写通。4. 核心链路的代码实现从“选座页”到“订单落库”很多人搭好了框架却卡在“选座下单”这种链路上座位要先查、后锁、再更新中间任何一步失败都得回滚。这一章我按业务发生的顺序讲四条链路电影列表、下单事务、后台排片、登录拦截。4.1 前台电影列表与排片查询电影列表是所有操作的入口大多数项目会顺手接一个分页。我用 PageHelper 来简化分页但它有一个使用铁律startPage必须在查询方法之前调用并且只对紧接着的第一条 SQL 生效。Controller RequestMapping(/front/movie) public class MovieController { Autowired private MovieService movieService; GetMapping(/list) public String list(RequestParam(defaultValue 1) Integer page, RequestParam(defaultValue 12) Integer pageSize, Model model) { // PageHelper.startPage 之后下一次查询自动带上 limit PageHelper.startPage(page, pageSize); ListMovie movies movieService.findOnSaleMovies(); PageInfoMovie pageInfo new PageInfo(movies); model.addAttribute(pageInfo, pageInfo); return front/movieList; } }对应的 Mapper XML 没什么花哨的但状态过滤别漏select idfindOnSaleMovies resultTypecom.example.cinema.pojo.Movie SELECT id, title, genre, duration, poster, description, status FROM movie WHERE status 1 ORDER BY id DESC /select要说明的是pageSize不要给用户任意传最好在 Controller 里做上限限制比如超过 30 就按 30 算。否则别人拿这个接口疯狂翻页很容易把数据库拖慢。PageInfo里已经带了总页数、当前页、总条数页面上直接渲染即可。4.2 前台选座与下单的事务控制下单是这套系统的心脏也是最容易写错的地方。一个订单要同时做三件事查场次算价格、插订单、把座位状态改成已售。任何一个步骤失败前面做的都要回滚。常见的错误是“先插订单再更新座位更新失败就 throws 一个新异常”但如果没有事务订单已经落库了。所以在 Service 实现方法上必须加Transactional。下面是下单方法的骨架我特意把座位查询加上了悲观锁Service public class OrderService { Autowired private SeatMapper seatMapper; Autowired private OrderMapper orderMapper; Autowired private ScheduleMapper scheduleMapper; /** * 创建订单事务边界是整个方法 * seatIds 是用户在前端勾选的一批座位 */ Transactional(rollbackFor Exception.class) public Order createOrder(Integer userId, Integer scheduleId, ListInteger seatIds) { // 1. 逐行加锁查询座位防止同一座位被并发下单 for (Integer seatId : seatIds) { Seat seat seatMapper.selectSeatForUpdate(scheduleId, seatId); if (seat null || seat.getStatus() ! 0) { throw new BizException(座位不可售或已被选中); } } // 2. 查询场次并计算总价 Schedule schedule scheduleMapper.selectById(scheduleId); if (schedule null || schedule.getStatus() ! 1) { throw new BizException(场次已停售); } BigDecimal amount schedule.getPrice().multiply(new BigDecimal(seatIds.size())); // 3. 生成唯一订单号并插入订单 Order order new Order(); order.setOrderNo(generateOrderNo()); order.setUserId(userId); order.setScheduleId(scheduleId); order.setAmount(amount); order.setStatus(0); orderMapper.insert(order); // 4. 更新座位状态并写明细 for (Integer seatId : seatIds) { seatMapper.updateStatus(seatId, 2); // 先锁定支付成功后改成已售 orderItemMapper.insert(order.getId(), seatId); } return order; } }对应的座位查询 SQL 是select idselectSeatForUpdate resultTypecom.example.cinema.pojo.Seat SELECT id, schedule_id, row_no, col_no, status FROM seat WHERE schedule_id #{scheduleId} AND id #{seatId} FOR UPDATE /select解释一下为什么必须用FOR UPDATE两个用户同时点“购买”不加锁时两个请求都读到status0然后各自往下执行最后两边都成功座位就超卖了。加了FOR UPDATE后第一个事务锁定这一行第二个事务的SELECT ... FOR UPDATE会一直阻塞直到前一个事务提交或回滚。FOR UPDATE一定要在事务内才生效所以这个查询必须写在createOrder方法内部不能拆到另一个没有事务的 Service 方法里。第 5 章我会再讲一道靠数据库唯一约束兜底的方案。4.3 后台排片管理与多表关联更新后台排片比前台逻辑简单但有一个经常被忽视的校验同一影厅同一时间不能排两场。不然观众进 1 号厅看 14:30 的场次上一场还没散场这票卖出去就是给自己找麻烦。后台接口我一般用RestController返回 JSON方便前后端分离式开发RestController RequestMapping(/admin/schedule) public class ScheduleAdminController { Autowired private ScheduleService scheduleService; PostMapping(/add) public Result add(RequestBody ScheduleVO vo) { scheduleService.addSchedule(vo); return Result.success(); } }Service 里做两件事先查冲突再插场次、批量生成座位Service public class ScheduleService { Autowired private ScheduleMapper scheduleMapper; Autowired private SeatMapper seatMapper; Transactional(rollbackFor Exception.class) public void addSchedule(ScheduleVO vo) { // 校验同一影厅同一时间不能排两场 int count scheduleMapper.countConflict(vo.getHall(), vo.getStartTime()); if (count 0) { throw new BizException(该影厅在所选时间已有场次); } Schedule schedule new Schedule(); BeanUtils.copyProperties(vo, schedule); schedule.setStatus(1); scheduleMapper.insert(schedule); // 初始化座位按影厅规模批量生成 seatMapper.batchInsert(schedule.getId(), vo.getRowCount(), vo.getColCount()); } }这里的rowCount和colCount可以由后台的影厅配置表带出也可以让管理员在排片时手动选择。批量插入座位时建议用一条多值 INSERTinsert idbatchInsert INSERT INTO seat (schedule_id, row_no, col_no, status) VALUES foreach collectionseats items separator, (#{s.scheduleId}, #{s.rowNo}, #{s.colNo}, 0) /foreach /insert注意foreach的 list 别传空否则 SQL 直接语法错误。我在 Service 里会先做一个if (rowCount 0 || colCount 0)的参数校验这一层不做后面所有排片都会拿着脏数据生成座位表。后台的停用场次也值得提一句场次一旦有已支付订单就不能直接改成停售否则观众拿着票进场系统却显示停售客诉就来了。停用前要查订单表有未退票的订单就拒绝操作并给出提示。这就是上面说的“多表关联更新”里最典型的业务约束。4.4 登录鉴权与拦截器设置前后台共用同一套登录逻辑但角色不同、可访问的页面不同。用 SpringMVC 拦截器按路径区分是最简单可靠的做法。核心拦截器类public class AuthInterceptor extends HandlerInterceptorAdapter { Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { String uri request.getRequestURI(); if (uri.startsWith(/admin/)) { Object admin request.getSession().getAttribute(admin); if (admin null) { // AJAX 请求返回 401普通页面跳转登录页 if (XMLHttpRequest.equals(request.getHeader(X-Requested-With))) { response.setStatus(401); } else { response.sendRedirect(request.getContextPath() /admin/login); } return false; } } else if (uri.startsWith(/front/) uri.startsWith(/front/order)) { Object user request.getSession().getAttribute(user); if (user null) { response.sendRedirect(request.getContextPath() /front/login); return false; } } return true; } }注册拦截器时一定要把登录页和静态资源放行mvc:interceptors mvc:interceptor mvc:mapping path/**/ mvc:exclude-mapping path/static/**/ mvc:exclude-mapping path/front/login/ mvc:exclude-mapping path/admin/login/ bean classcom.example.cinema.config.AuthInterceptor/ /mvc:interceptor /mvc:interceptors这里有一个容易忽略的点拦截器判断用户是否登录依赖 Session 里的属性和路径前缀但如果你的前台页面也走 JSON 接口建议给接口单独加一个标识比如路径以/api/开头拦截器里统一处理。否则页面跳转和 AJAX 返回混在一起前端处理起来会非常别扭。5. 避坑专题SSM电影院订票系统最常见的几个坑这一章是我觉得全文最有价值的部分。下面这些坑不是理论推演而是这种项目里反复出现的翻车点。每条我都按“现象 → 原因 → 解决”来写你可以在本地复现时直接对照。5.1 日期时间格式化导致排片时间错乱现象后台录入的2024-05-20 14:30页面上显示成2024-05-20 02:30或者数据库里查出来少了 8 小时。原因有两层一是 SpringMVC 接收表单里的startTime字符串时不知道该按什么格式解析成Date二是 Jackson 在把LocalDateTime序列化成 JSON 时默认格式不是yyyy-MM-dd HH:mm:ss前端拿到的字符串和预期完全对不上。解决在 VO 的日期字段上加DateTimeFormat(patternyyyy-MM-dd HH:mm)同时在 spring.xml 里配置 Jackson 统一格式mvc:annotation-driven mvc:message-converters bean classorg.springframework.http.converter.json.MappingJackson2HttpMessageConverter property nameobjectMapper refjacksonObjectMapper/ /bean /mvc:message-converters /mvc:annotation-driven别忘了 MySQL 连接串上加serverTimezoneAsia/Shanghai否则驱动和数据库之间还会再差一次时区这三处不一致极难排查。5.2 MyBatis多表联查返回null现象查出订单列表时订单对象有值但嵌套的movieTitle、orderItemList全是 null。原因resultType只能映射扁平字段遇到对象嵌套和集合嵌套就无能为力了。很多人图省事把resultType写成订单实体多表 SELECT 查出来的字段塞进去但实体里的ListOrderItem永远无法被自动填充。解决写resultMap用association映射电影信息、用collection映射明细集合resultMap idOrderWithDetail typecom.example.cinema.pojo.Order id propertyid columnid/ result propertyorderNo columnorder_no/ result propertymovieTitle columnmovie_title/ association propertyschedule javaTypecom.example.cinema.pojo.Schedule id propertyid columnschedule_id/ result propertystartTime columnstart_time/ /association collection propertyitems ofTypecom.example.cinema.pojo.OrderItem id propertyid columnitem_id/ result propertyseatLabel columnseat_label/ /collection /resultMap另外如果表字段是下划线命名而实体是驼峰命名记住在 spring.xml 里开启mapUnderscoreToCamelCase或养成给查询列起别名的习惯不然这一个小配置能让你排查一小时。5.3 座位并发下单超卖现象同一个场次的 1 排 1 座两个用户同时点“立即购买”都提示成功后台订单里出现了两条包含同一座位的订单。原因没有加锁或锁没生效。最典型的错误是把SELECT ... FOR UPDATE写在了一个没有事务的 Mapper 调用里锁根本不会持有到更新完成或者更糟根本没加锁全靠应用层 if 判断。解决严格按 4.2 节的方式FOR UPDATE查询和座位更新放在同一个事务方法内。同时给order_item的seat_id加唯一索引即使逻辑层有漏网之鱼第二次插入也会因为主键/唯一索引冲突直接失败。这也叫“数据库兜底”比纯靠代码锁要稳。5.4 前端AJAX传JSONController接收全是null现象JS 里$.ajax({ url: /admin/schedule/add, data: JSON.stringify({hall:1号厅,startTime:...}), contentType:application/json })后端RequestBody ScheduleVO vo里所有字段都是 null或者直接报 400。原因要么漏了RequestBodySpring 会把 JSON 当普通表单参数解析要么项目里没有 Jackson 依赖导致消息转换器没注册要么前端contentType没写application/json。解决后端方法参数加RequestBody前端设置contentType: application/json并确认 pom 里有 jackson-databind。还有一个很隐蔽的点VO 里必须有默认构造方法字段必须是Integer/String包装类型而不是int否则 JSON 里缺字段时直接反序列化失败报 500 而不是给业务提示。5.5 静态资源404和中文乱码现象页面能打开但 CSS 全丢或者表单提交的中文变成问号。原因DispatcherServlet 的/映射把/static/css/xxx.css也拦截了或者 web.xml 里没配CharacterEncodingFilterPOST 参数用默认编码解析。解决spring-mvc.xml 里加mvc:resources mapping/static/** location/static//web.xml 里按 3.3 节的顺序配置 UTF-8 过滤器。这两个问题的特征非常明显看到 CSS 全丢先查映射看到中文乱码先查过滤器别一上来就怀疑业务代码。6. 上点硬功夫把订票系统的性能和安全拉满一个能跑的订票系统离能交付的系统还有距离下面这三个点是我做完后一定会加的“硬菜”改动不大收益却很直接。6.1 给热点查询加Redis缓存别让首页每次都打数据库电影院首页的“正在热映”列表和热门场次一天被读几千次写操作反而很少。常见做法是在 Service 层做两级缓存先查 Redis没有命中再查 MySQL并设置 5 到 10 分钟过期。注意缓存更新时机后台管理员修改或下架电影后主动删除对应缓存避免出现“后台改了前台还显示旧海报”的脏数据。用 Redis 的时候别缓存整个列表把每部电影的详情分开缓存更新单部电影时开销更小。6.2 用唯一订单号和幂等校验防重复下单下单接口被前端连续提交两次是最容易在演示时翻车的地方。前端按钮置灰只是第一层后端要在生成订单号时用“时间戳 用户ID 随机数”并给order_no加唯一索引。创建订单前先查一下同一 userId、同一 scheduleId 是否已有待支付订单存在就直接返回已有订单而不是重新创建一条。SQL 上可以给orders表加(user_id, schedule_id, status)的索引查询会很快。这就是最简单的“业务幂等”重复请求不会产生重复订单。6.3 部署时的几个检查点部署到 Tomcat 前有几个不起眼但很容易踩的点要过一遍MySQL 连接串必须带serverTimezoneAsia/ShanghaiJSP 里的路径全部用${pageContext.request.contextPath}拼接项目名改掉后静态资源才不会 404上传的电影海报要单独配置一个对外可访问的目录不要把图片直接塞进工程目录里否则 Tomcat 重新部署时文件会跟着丢失。说句实在话我第一次做这类系统时也是被这些不起眼的配置折磨到半夜。后来养成了一个习惯每接一个项目先花半天把运行环境、依赖版本、数据库连接、拦截器四个点全部确认一遍再写业务代码。这样下来后面大部分“莫名其妙的 bug”其实都是最初埋下的。这套订票系统浓缩了增删改查、多表联查、事务、拦截器、JSON 交互、分页等几乎所有 Java Web 必备技能你照着上面的步骤做下来得到的不是只能写进简历的 Demo而是一套能继续往上加功能的基础骨架。遇到问题时记住先看配置、再看 SQL、最后怀疑代码希望帮到你。本文还有配套的精品资源点击获取