简介一份围绕 Java 与 Spring Boot 的电影在线订票选座系统设计文档面向具备一定 Java 基础、希望学习或从事电影票务管理系统开发的人员也可为企业或创业者搭建线上票务平台提供方案参考。文档以单份 docx 交付压缩包约 34KB共 1 个文件内容按功能介绍、系统截图、IntelliJ IDEA 开发工具、Spring Boot 文件解析、技术介绍、开发步骤及编译运行、核心代码展示、未来改进方向、注意事项与项目总结等章节组织便于按模块查阅。其价值在于把普通用户、会员和管理员三类角色的功能边界拆解清楚覆盖会员折扣、积分兑换、优先预约、论坛监督和数据分析等业务点并围绕 Spring Data JPA、Thymeleaf、MySQL 等技术栈说明关键组件与业务流程附有用户注册登录、电影信息查询等核心代码片段可帮助读者理解数据模型构建、权限划分和系统实现思路。目前已有 314 人学习适合用作课程设计、毕业项目或票务平台原型开发时的参考材料。1. 电影票务系统跑通第一步从一份 Spring Boot 源码看三个角色的权限边界拿到一份电影在线订票系统的源码包多数人第一反应是打开 IDEA 点运行然后卡在数据库连接上——这几乎是我见过最典型的翻车现场。这份基于 Java 与 Spring Boot 的项目本质是一个三端合一的票务管理系统普通用户看电影、下单、发帖评论会员多一层折扣与积分管理员管片库、管用户、管论坛。技术栈是 Spring Boot Spring Data JPA Thymeleaf MySQL标准 Maven 结构适合想在本地跑起一套完整业务闭环的开发者也适合拿来当毕业设计或接单模板改。它解决的问题不是“能不能下单”而是把用户、影片、订单、论坛、公告这几张表之间的关联关系用一套可复制的 Controller-Service-Repository 链路串起来让你能看到一次下单从页面提交到库存扣减的完整路径。往下读我会把环境搭建、实体建模、权限分层、座位并发这几块拆开讲坑基本都在后面。2. 环境与工程结构把 Maven 骨架和 MySQL 连接先钉死2.1 版本选型的现实考量Spring Boot 版本不用追新。这份源码常见的搭配是 Spring Boot 2.7.x 配 JDK 8 或 JDK 11原因很直接Spring Data JPA 在这个区间稳定Thymeleaf 模板渲染行为没有大改网上能查到的踩坑记录也最多。JDK 17 以上不是不能跑但一旦遇到javax.persistence还是jakarta.persistence的包名切换新人很容易在这里懵半天。我的建议是先按 JDK 8 跑通跑通之后再考虑往上升。Maven 用 3.6 以上即可。MySQL 推荐 5.7 或 8.0两者在时区、驱动类名上略有差异下面配置里会点出来。IntelliJ IDEA 打开项目时认准根目录下的pom.xml作为 Maven 工程导入不要用普通 Java 工程打开否则依赖不下载、注解全红这是第一道坎。2.2 目录结构与配置文件的落点标准 Maven 结构下源码分两块src/main/java放 Controller、Service、Repository、Entitysrc/main/resources放application.properties、templates下的 Thymeleaf 页面、static下的静态资源。实体类一般集中在entity包Repository 接口在repository包业务逻辑在service对外接口在controller。这个分层不是摆设后面对权限做拦截时拦截器就挂在 Controller 层前面。数据库连接配置是第一个必须改对的地方# application.properties server.port8080 # MySQL 8.0 用 com.mysql.cj.jdbc.Driver5.7 可用 com.mysql.jdbc.Driver spring.datasource.urljdbc:mysql://localhost:3306/movie_ticket?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/ShanghaiuseSSLfalse spring.datasource.usernameroot spring.datasource.password你的密码 spring.datasource.driver-class-namecom.mysql.cj.jdbc.Driver # JPA 配置首次运行用 update 自动建表生产环境务必改成 validate 或 none spring.jpa.hibernate.ddl-autoupdate spring.jpa.show-sqltrue spring.jpa.properties.hibernate.format_sqltrue spring.jpa.database-platformorg.hibernate.dialect.MySQL8Dialect # Thymeleaf 关闭缓存改页面不用重启 spring.thymeleaf.cachefalse spring.thymeleaf.prefixclasspath:/templates/ spring.thymeleaf.suffix.html这段配置里最容易被忽略的是serverTimezone。不写这一项MySQL 8.0 下经常报The server time zone value is unrecognized本质是驱动和数据库时区对不上加上Asia/Shanghai就好。ddl-autoupdate是新手友好项启动时 JPA 会根据实体类自动建表或补字段但它的副作用是会悄悄改表结构所以生产环境我会强制改成validate宁可启动报错也不让它动数据。show-sql打开后控制台能看到实际执行的 SQL调关联查询时非常有用。2.3 从零建库到启动的完整命令数据库要手工建一次字符集选utf8mb4否则影片简介里的表情符号存不进去# 登录 MySQL 后执行 CREATE DATABASE movie_ticket DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci;建完库回到项目根目录编译运行# 编译并跳过测试首次跑建议跳过避免测试类连库失败 mvn clean package -DskipTests # 直接运行 jar java -jar target/movie-ticket-system-0.0.1-SNAPSHOT.jar # 或者在项目目录用插件启动改代码后热重启更方便 mvn spring-boot:run-DskipTests这个参数不是偷懒。很多源码包里的测试类写得比较随意依赖真实库连接一跑测试就中断打包。先用它把 jar 打出来确认主流程能启动再回头单独处理测试。mvn spring-boot:run适合开发期改动 Java 代码后重新触发比反复打包快得多。启动成功的标志是控制台出现 Tomcat 端口监听日志和Started ...Application in x seconds此时浏览器访问http://localhost:8080应该能看到首页。若启动报Table movie_ticket.xxx doesnt exist说明ddl-auto没生效或实体类没被扫描到检查主启动类的SpringBootApplication是否在包的最顶层子包要能被它扫到。这个包路径问题会让一大批 Bean 注册失败症状是启动时一堆NoSuchBeanDefinitionException看着吓人实际就是目录放错了位置。3. 实体建模与 JPA 映射用户、影片、订单三张表怎么连3.1 角色枚举与用户表设计用户、会员、管理员三类角色最省事的做法是在 User 实体里用一个枚举字段区分而不是建三张表。枚举存字符串而不是序号方便数据库里直接看懂Entity Table(name sys_user) public class User { Id GeneratedValue(strategy GenerationType.IDENTITY) private Long id; Column(unique true, nullable false, length 50) private String username; Column(nullable false) private String password; // 实际落地必须存 BCrypt 哈希不能明文 Enumerated(EnumType.STRING) Column(length 20) private Role role; // USER / MEMBER / ADMIN private Integer points 0; // 会员积分 private BigDecimal discount; // 会员折扣率普通用户为 1.0 // getter / setter 省略 }Enumerated(EnumType.STRING)是关键。默认行为是存枚举的序号ORDINAL一旦以后在枚举中间插入一个新角色历史数据的含义就整体错位这种数据事故排查起来极其痛苦。存 STRING 虽然占点空间但自解释、可迁移属于血泪经验换来的默认选择。password字段注释里写明要存哈希是因为这类教学源码十有八九是明文比对直接上线就是安全事故改造方式在最后一章讲。3.2 影片与场次的关联影片和场次是典型的一对多。Movie 存影片元信息Schedule场次存放映时间、影厅、票价外键指回 MovieEntity public class Movie { Id GeneratedValue(strategy GenerationType.IDENTITY) private Long id; private String title; Column(length 2000) private String description; private LocalDate releaseDate; private String posterUrl; // 海报地址 private Integer duration; // 时长分钟 OneToMany(mappedBy movie, cascade CascadeType.ALL, fetch FetchType.LAZY) private ListSchedule schedules new ArrayList(); } Entity public class Schedule { Id GeneratedValue(strategy GenerationType.IDENTITY) private Long id; ManyToOne(fetch FetchType.LAZY) JoinColumn(name movie_id) private Movie movie; private LocalDateTime startTime; private String hallName; // 影厅名 private BigDecimal price; private Integer totalSeats; // 总座位数 }fetch FetchType.LAZY在两端都要显式写上。JPA 的OneToMany默认是 LAZY但ManyToOne默认是 EAGER很多人只写注解不写属性结果查一个场次顺带把整部影片和它的所有场次全查出来列表页一卡就是几十条 SQL。显式声明 LAZY再在需要的地方用JOIN FETCH或EntityGraph按需加载是控制查询次数的基本手法。cascade CascadeType.ALL让删除影片时级联删场次但注意这会连带删掉关联订单实际业务里应该改成逻辑删除或先校验有无未完成订单。3.3 Repository 层与查询方法命名Spring Data JPA 的价值在 Repository 接口方法名即查询Repository public interface MovieRepository extends JpaRepositoryMovie, Long { // 按标题模糊查询用于搜索框 ListMovie findByTitleContaining(String keyword); // 按上映日期倒序取最新若干部 ListMovie findTop10ByOrderByReleaseDateDesc(); } Repository public interface ScheduleRepository extends JpaRepositorySchedule, Long { // 查某部影片在某天之后的场次 ListSchedule findByMovieIdAndStartTimeAfter(Long movieId, LocalDateTime time); // 联表抓取影片避免 N1 Query(select s from Schedule s join fetch s.movie where s.movie.id :movieId) ListSchedule findWithMovie(Param(movieId) Long movieId); }findByTitleContaining生成的是like %keyword%注意它不会自动加索引影片量大时全表扫描影片少则无所谓。findWithMovie用join fetch一次把影片带出来专门治列表页的 N1 问题——现象是页面能出来但日志里刷了几十条 select每条对应一个场次的影片数量一多响应就肉眼可见地变慢。这套命名规则和Query混用是常态简单条件走方法名复杂联表走 JPQL别硬凑方法名把自己绕进去。4. 三个角色的功能落点Controller 到 Service 的调用链4.1 用户注册登录与拦截器注册登录的 Controller 是入口但真正决定安全的是拦截器。先看接口RestController RequestMapping(/api/user) public class UserController { Autowired private UserService userService; PostMapping(/register) public ResponseEntityString register(RequestBody User user) { // 用户名重复校验放在 ServiceController 只做参数接收 if (userService.existsByUsername(user.getUsername())) { return ResponseEntity.badRequest().body(用户名已存在); } userService.register(user); return ResponseEntity.ok(注册成功); } PostMapping(/login) public ResponseEntityString login(RequestBody LoginRequest req, HttpSession session) { User user userService.login(req.getUsername(), req.getPassword()); if (user null) { return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body(登录失败); } // 登录态写进 session后续拦截器靠它判断 session.setAttribute(LOGIN_USER, user); return ResponseEntity.ok(登录成功); } }登录成功后把用户对象塞进HttpSession这是 Thymeleaf 项目里最直接的做法。真正拦人的是HandlerInterceptorComponent public class AuthInterceptor implements HandlerInterceptor { Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { HttpSession session request.getSession(); User user (User) session.getAttribute(LOGIN_USER); if (user null) { response.sendRedirect(/login); // 未登录跳登录页 return false; } // 管理端路径额外校验角色 if (request.getRequestURI().startsWith(/admin) user.getRole() ! Role.ADMIN) { response.sendError(403, 无权限); return false; } return true; } }preHandle返回 false 就中断请求这是拦截器的核心语义。管理端路径用startsWith(/admin)粗筛配合角色枚举判断避免普通用户直接敲 URL 进后台。这种写法比在每个 Controller 方法上贴注解省事缺点是路径规则一多容易漏所以路径前缀要在项目里统一约定别一会儿/admin一会儿/manage。注册时到WebMvcConfigurer里把这个拦截器注册上并排除登录、注册、静态资源这几条路径否则登录页自己都被拦直接死循环。4.2 会员折扣与积分结算会员功能的落地点在订单结算。下单时单价不是直接用Schedule.price而是乘以用户折扣Service public class OrderService { Autowired private ScheduleRepository scheduleRepository; Autowired private OrderRepository orderRepository; Transactional public Order createOrder(Long scheduleId, User user, ListString seatNos) { Schedule schedule scheduleRepository.findById(scheduleId) .orElseThrow(() - new RuntimeException(场次不存在)); // 会员折扣普通用户 discount 为 1.0 BigDecimal rate user.getDiscount() null ? BigDecimal.ONE : user.getDiscount(); BigDecimal unitPrice schedule.getPrice().multiply(rate); Order order new Order(); order.setUser(user); order.setSchedule(schedule); order.setSeats(String.join(,, seatNos)); order.setAmount(unitPrice.multiply(BigDecimal.valueOf(seatNos.size()))); order.setStatus(OrderStatus.UNPAID); orderRepository.save(order); // 会员按消费额累积分1 元 1 分 if (user.getRole() Role.MEMBER) { user.setPoints(user.getPoints() order.getAmount().intValue()); } return order; } }金额全程用BigDecimal不要用 double。票价乘折扣再乘张数double 的精度误差在累计订单里会放大成对不上账的玄学问题财务侧一核对就露馅。Transactional保证订单落库和积分更新在同一个事务里中途抛异常一起回滚。折扣率存在用户表而不是代码里写死是为了让运营能按会员等级配不同力度普通用户discount为 1.0 相当于不打折这样结算逻辑只有一条路径不需要 if 分支判断是不是会员。4.3 管理端的数据看板与论坛监督管理员侧的功能看着杂实际就两类CRUD 和统计。影片、类型、公告、留言的增删改查都是标准 JpaRepository 调用没有特殊逻辑重点在统计看板。看板通常用聚合查询出每日订单量和票房再交给 Thymeleaf 渲染成表格或简单图表Repository public interface OrderRepository extends JpaRepositoryOrder, Long { // 按日期分组统计订单数与总金额用于后台看板 Query(select function(date_format, o.createTime, %Y-%m-%d) as d, count(o), sum(o.amount) from Order o group by function(date_format, o.createTime, %Y-%m-%d) order by d desc) ListObject[] statDailyOrders(); }function(date_format, ...)是 JPQL 调用数据库原生函数的写法返回的是Object[]列表取的时候按下标拿容易写错列序。数据量不大时这么干最省事如果后面要换数据库这段就得重写属于可移植性差但开发快的取舍。论坛监督本质是留言表的状态字段管理员把status从正常改成隐藏前台查询时带上status 1条件即可不用真删数据留痕也方便追溯。5. 座位并发与下单链路四个必踩的坑5.1 坑一选座超卖两人抢到同一个座现象是压测或手动双开浏览器时同一场次同一排的座位被两个订单同时占住。原因是座位状态存成逗号字符串后查和改之间没有锁两个请求都读到“空”都写回“占”。解决办法是给场次行加悲观锁把冲突串行化Repository public interface ScheduleRepository extends JpaRepositorySchedule, Long { // 悲观锁锁定该场次记录直到事务结束 Lock(LockModeType.PESSIMISTIC_WRITE) Query(select s from Schedule s where s.id :id) Schedule findByIdForUpdate(Param(id) Long id); }在createOrder里改用findByIdForUpdate同一场次的并发下单会排队第二个请求等到第一个提交后才读到已占座位。代价是并发度下降热门场次会有等待但票务场景本来就允许短暂排队正确性优先。更彻底的方案是把座位拆成独立表每座一行加唯一索引这里不展开。5.2 坑二JPA 懒加载在 Thymeleaf 里抛 LazyInitializationException现象是列表页渲染到一半报could not initialize proxy - no Session。原因是 Controller 返回实体后事务已关闭Thymeleaf 在视图层访问movie.schedules这类懒加载集合时 session 没了。解决方式有两种在 Repository 查询时用join fetch一次性抓齐需要的关联或者在application.properties里开spring.jpa.open-in-viewtrue默认就是 true但很多人为了性能关了它。我的做法是保持 open-in-view 关闭全部用join fetch或 DTO 投影逼自己在查询层想清楚要哪些字段视图层就不会意外触发查询。5.3 坑三中文乱码影片名存进去变问号现象是新增影片后数据库里是???或者页面显示乱码。链路有三段要查数据库字符集、连接串字符集、页面编码。数据库建库时用utf8mb4连接串带characterEncodingutf8Thymeleaf 模板头部声明meta charsetUTF-8三者缺一不可。还有一个隐蔽点如果表是先按 latin1 建的后面改库字符集不会自动改已有表得对表执行ALTER TABLE movie CONVERT TO CHARACTER SET utf8mb4。这种问题排查起来像捉迷藏按链路顺序逐个排除最快。5.4 坑四静态资源 404CSS 全丢现象是页面能打开但没样式F12 看static/css/xxx.css全 404。原因通常是拦截器把静态资源也拦了或者 Thymeleaf 里引用路径写成绝对路径/css/xxx.css但资源实际放在static/css/下却没被 Spring Boot 的默认资源映射接住。检查WebMvcConfigurer里拦截器的excludePathPatterns是否包含/css/**、/js/**、/images/**同时页面引用统一用th:href{/css/app.css}让 Thymeleaf 处理上下文路径。这两个点对上了样式基本就回来了。6. 把教学源码改成能上线的样子密码哈希与订单超时回收教学源码最容易出事的两个点一个是密码明文一个是未支付订单永久占座。先说密码。原始代码里userService.login多半是user.getPassword().equals(input)改造成 BCrypt 只需要三步引入spring-security-crypto依赖注册时passwordEncoder.encode(raw)存哈希登录时passwordEncoder.matches(raw, stored)比对。这个库很轻不用引入整套 Spring Security改造面小但安全性提升是数量级的。我一般还会顺手在用户表加一列salt的历史兼容处理不过 BCrypt 自带盐新项目直接单列存哈希即可。Bean public PasswordEncoder passwordEncoder() { return new BCryptPasswordEncoder(); } // 注册 user.setPassword(passwordEncoder.encode(rawPassword)); // 登录 boolean ok passwordEncoder.matches(rawPassword, user.getPassword());订单超时回收解决的是另一个问题用户下单不付款座位一直被占热门场次直接卖不动。常见做法是给订单加createTime和status再用一个定时任务扫超过 15 分钟仍未支付的订单把状态置为CANCELLED并释放座位。Spring Boot 里用Scheduled就够Component public class OrderTimeoutTask { Autowired private OrderRepository orderRepository; // 每 5 分钟扫一次超 15 分钟未支付则取消 Scheduled(cron 0 0/5 * * * ?) Transactional public void cancelTimeoutOrders() { LocalDateTime deadline LocalDateTime.now().minusMinutes(15); ListOrder expired orderRepository .findByStatusAndCreateTimeBefore(OrderStatus.UNPAID, deadline); for (Order o : expired) { o.setStatus(OrderStatus.CANCELLED); // 座位释放逻辑从场次已占座位中移除该订单占用的座位 } orderRepository.saveAll(expired); } }别忘了在主启动类加EnableScheduling否则定时任务静默不生效这个坑我踩过一次日志里什么提示都没有排查了半天才发现注解没开。cron 0 0/5 * * * ?表示每 5 分钟的第 0 秒触发改成0 */5 * * * ?也对两种写法在不同 Spring 版本里兼容性略有差异跑之前先看日志确认触发。座位释放那一步要和第 5 章的悲观锁配合避免取消订单和用户下单同时改座位状态。验证改造是否生效我习惯走一遍固定流程注册一个新号看库里密码是不是$2a$开头的哈希登录错密码确认返回 401下单不付款把定时任务 cron 临时改成每分钟等两分钟看订单状态是否变CANCELLED、座位是否回到可选状态。这套动作每次改完认证或订单逻辑我都会强制走一遍因为这两个模块出问题不会立刻报错而是等到用户投诉才发现。从那以后我每次拿到这类票务源码都先看密码存储和订单回收这两处再决定要不要往深里改。希望帮到你。本文还有配套的精品资源点击获取
阅读完成 · 觉得有帮助?