Spring Boot电影订票系统:从零跑通到会员优先选座实战
简介本资源是一份面向Java初学者与Spring Boot入门开发者的电影在线订票系统完整实践文档聚焦于企业级票务平台的功能设计与技术落地。内容覆盖普通用户、会员及管理员三类角色的完整业务功能如注册登录、选座购票、积分折扣、影视管理、论坛监督等并结合IntelliJ IDEA开发环境、Spring BootSpring Data JPA后端架构、Thymeleaf前端渲染与MySQL数据存储详解开发流程、配置要点、核心代码实现及运行部署步骤。资源为1个34KB的docx文档结构清晰含13个实用章节从功能清单、系统截图、技术栈解析到代码示例、视频演示指引与未来优化方向特别包含用户/电影/订单三大模块的RESTful接口实现片段及实体类定义。目前已有314人学习下载适合希望掌握全栈Web开发流程、积累毕业设计或小型商用项目经验的开发者快速上手与复现。1. 一个能当天跑通、次日就能改出“会员优先选座”功能的电影订票系统上周帮某高校实验室搭教学演示系统导师提了个硬需求“学生得在两小时内看到登录页、电影列表、下单流程全链路跑通不能卡在数据库连不上或者 Thymeleaf 模板找不到。”——我直接甩出这个 Spring Boot 电影订票项目从解压到首页弹出实测 6 分 23 秒。它不是玩具 Demo真实包含用户注册/登录、电影 CRUD、论坛发帖、订单生成、管理员后台、会员积分与折扣逻辑甚至预留了“观影记录分析”接口。所有模块都用标准 Spring Boot JPA Thymeleaf 实现没套壳、没魔改、不依赖任何黑匣子中间件。适合两类人一是 Java 初学者想拿个“有业务感”的完整项目练手比 TodoList 强十倍二是中小团队快速搭建票务原型——你改三处配置、补两张表、调两个 service 方法就能把“普通用户抢不到首映场”变成“会员自动解锁前排座位”。这不是教科书里的理想模型是我在 IntelliJ IDEA 里真刀真枪 debug 过 17 次登录态失效、修过 5 版 MySQL 字符集乱码、替换了 3 轮 Thymeleaf 表单绑定写法后沉淀下来的可交付工程。2. 技术栈落地真相为什么选 Spring Boot 而不是 Spring MVCThymeleaf 真的比 Vue 更适合教学原型2.1 Spring Boot 的“零配置”其实是精密预设application.properties 里藏着 4 类关键开关这个项目没用 Spring Cloud 或微服务但它的application.properties文件绝不是简单配个数据库地址。我拆开看过里面实际控制着四类运行时行为数据库连接层spring.datasource.urljdbc:mysql://localhost:3306/movie_db?useSSLfalseserverTimezoneAsia/ShanghaiallowPublicKeyRetrievaltrue—— 注意serverTimezone和allowPublicKeyRetrievalMySQL 8 驱动强制要求漏掉一个就报Could not create connectionJPA 行为开关spring.jpa.hibernate.ddl-autoupdate是开发期便利开关但上线必须改成validate否则每次启动都偷偷改表结构血泪经验Thymeleaf 渲染控制spring.thymeleaf.cachefalse和spring.thymeleaf.enabledtrue必须同时存在前者让模板修改实时生效后者才是开启引擎的总闸安全兜底配置server.port8080和spring.servlet.context-path/movie组合确保所有请求路径统一前缀避免前端 AJAX 调用/api/user/login时因上下文路径错位返回 404。提示别信“Spring Boot 自动配置万能论”。我见过太多人把ddl-autoupdate丢进生产环境结果某次更新实体类字段类型JPA 直接把整张user表锁死两小时。这项目里它只存在于application-dev.properties主配置文件里是空的。2.2 Thymeleaf 不是“Java 版 Vue”它是服务端模板的务实选择很多人吐槽 Thymeleaf 写法啰嗦比如登录表单要这么写form th:action{/login} th:object${loginForm} methodpost input typetext th:field*{username} placeholder用户名 / input typepassword th:field*{password} placeholder密码 / button typesubmit登录/button /form表面看不如 Vue 的v-model直观但它解决的是真实问题服务端校验失败后如何把错误信息和原输入值一起塞回页面Spring Boot 的Valid校验器配合 Thymeleaf 的*{}语法能自动把BindingResult里的rejectValue(username, not.empty)映射成span th:if${#fields.hasErrors(username)} th:errors*{username}用户名不能为空/span。Vue 做不到这点——它得自己写 JS 拿 API 返回的错误码去匹配 DOM 元素而 Thymeleaf 在渲染阶段就完成了绑定。对教学原型和内部管理系统这种“一次写完、无需前后端联调错误提示”的能力比语法简洁重要十倍。2.3 MySQL 表结构设计为什么user表里role用字符串枚举而非外键关联项目实体类里User的角色字段是这样定义的Enumerated(EnumType.STRING) private Role role; // USER, MEMBER, ADMIN对应数据库字段是varchar(20)。有人会问为什么不建role表用外键关联答案很现实这个系统里角色权限是静态且极少变更的。Role枚举只有三个值未来加新角色比如VIP_MEMBER只需改 Java 枚举类、加一行case VIP_MEMBER:到权限判断逻辑里不用动数据库、不用写 migration SQL、不影响现有数据。而如果用外键表每次加角色就得执行INSERT INTO role (name) VALUES (VIP_MEMBER);还得确保所有环境同步执行——教学环境里学生手抖少点一个分号整个登录鉴权就崩了。这是典型“用空间换运维确定性”的设计不是偷懒是踩过坑后的妥协。2.4 IntelliJ IDEA 里的真实开发流Maven 依赖冲突怎么一眼定位项目用 Maven 管理依赖pom.xml里核心依赖是dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-data-jpa/artifactId /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-thymeleaf/artifactId /dependency dependency groupIdmysql/groupId artifactIdmysql-connector-java/artifactId scoperuntime/scope /dependency但实际开发中IntelliJ IDEA 的 Maven 工具窗口View → Tool Windows → Maven比pom.xml更有用。当你遇到ClassNotFoundException: javax.servlet.http.HttpServletRequest这类玄学报错别急着百度点开 Maven 面板里的Dependencies树展开spring-boot-starter-web看它拉进来的tomcat-embed-core版本是不是和spring-boot-starter-thymeleaf依赖的spring-webmvc版本打架。我常做的操作是右键spring-boot-starter-web→Exclude再手动指定版本比改pom.xml里exclusions标签快得多。这是 IDE 带来的生产力红利不是黑魔法。3. 从零启动5 步跑通系统避开 90% 新手卡点3.1 环境准备JDK 11 是唯一安全选项别碰 JDK 17项目pom.xml里明确写着java.version11/java.version这意味着它用的是 Spring Boot 2.7.xSpring Boot 3.x 要求 JDK 17。如果你装了 JDK 17mvn clean compile会直接报错Unsupported class file major version 61。解决方案只有两个要么卸载 JDK 17装 JDK 11要么在 IntelliJ IDEA 里强制指定项目 SDKFile → Project Structure → Project → Project SDK → 选已安装的 JDK 11。别信网上“改maven-compiler-plugin版本就能兼容”的说法Spring Boot 2.7 的底层字节码就是 JDK 11 编译的强扭必翻车。3.2 数据库初始化MySQL 8 必须执行的 3 条初始化命令项目没提供 SQL 初始化脚本但application.properties里ddl-autoupdate会自动生成表。然而MySQL 8 默认启用caching_sha2_password认证插件而老版mysql-connector-java驱动不支持。所以创建数据库后必须连上 MySQL 执行-- 创建数据库字符集必须是 utf8mb4否则中文存不进去 CREATE DATABASE movie_db CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; -- 创建用户并授权假设本地开发用 root CREATE USER movie_userlocalhost IDENTIFIED BY movie_pass; -- 关键切换认证插件为 mysql_native_password ALTER USER movie_userlocalhost IDENTIFIED WITH mysql_native_password BY movie_pass; GRANT ALL PRIVILEGES ON movie_db.* TO movie_userlocalhost; FLUSH PRIVILEGES;漏掉ALTER USER ... IDENTIFIED WITH mysql_native_password这一行启动时就会卡在Access denied for user日志里还找不到具体原因。3.3 Maven 编译跳过测试是合法操作但得知道代价项目里UserServiceImplTest等单元测试用的是 H2 内存数据库而主程序连的是 MySQL。如果你只想快速看到页面mvn clean compile -Dmaven.test.skiptrue是最快路径。但要注意跳过测试意味着你无法验证UserService.register()是否真能把用户写进 MySQL只能靠浏览器注册后查数据库确认。我一般只在首次启动时跳过一旦首页出来立刻删掉-Dmaven.test.skiptrue跑一遍mvn test因为MovieServiceTest里有个关键断言assertThat(movieService.findAll()).isNotEmpty();—— 它会触发 JPA 加载初始电影数据而这些数据是data.sql文件里写的跳过测试就等于跳过初始数据注入。3.4 启动与验证MovieTicketSystemApplication不是摆设它是入口守门员在 IntelliJ IDEA 里右键MovieTicketSystemApplication.java→Run MovieTicketSystemApplication.main()控制台输出Tomcat started on port(s): 8080后别急着开浏览器。先验证三件事API 层是否活curl http://localhost:8080/movie/api/movies/应该返回 JSON 数组哪怕空数组证明 Controller 和 JPA 链路通模板层是否活curl http://localhost:8080/movie/应该返回 HTML 源码且包含title电影在线订票系统/title证明 Thymeleaf 渲染正常静态资源是否活curl http://localhost:8080/movie/css/style.css应该返回 CSS 内容证明src/main/resources/static/路径被正确映射。这三步比直接开浏览器更有诊断价值。我见过太多人浏览器打不开首页一查发现是style.css404根源是spring.servlet.context-path/movie写成了/movieticket导致静态资源路径错位。3.5 首页访问路径必须带 context-path这是新手最大盲区浏览器地址栏必须输http://localhost:8080/movie/注意末尾斜杠。如果输成http://localhost:8080/会看到白屏或 404输成http://localhost:8080/movie没斜杠Thymeleaf 模板里link th:href{/css/style.css}会解析成/css/style.css但实际资源在/movie/css/style.css导致样式丢失。这个细节在application.properties里spring.servlet.context-path/movie定义但文档里从不强调——它不像 Spring MVC 那样需要在RequestMapping里显式写/movie/api/xxx而是全局影响所有路径解析。从那以后我每次新建 Spring Boot 项目第一件事就是打开浏览器开发者工具 Network 标签页看第一个请求的 URL 是什么再反推 context-path 是否配对。4. 核心模块拆解一个请求从进入到返回的完整链路以用户注册为例4.1 前端表单Thymeleaf 如何把 HTML 变成“可绑定的数据容器”用户注册页register.html的关键片段form th:action{/register} th:object${userForm} methodpost div classform-group label用户名/label input typetext th:field*{username} classform-control / span th:if${#fields.hasErrors(username)} th:errors*{username}用户名错误/span /div div classform-group label密码/label input typepassword th:field*{password} classform-control / /div button typesubmit classbtn btn-primary注册/button /form这里th:object${userForm}告诉 Thymeleaf这个表单绑定的对象叫userForm它来自 Controller 的Model。th:field*{username}不是简单赋值它会生成input nameusername value同时把username字段名注入到 Spring MVC 的DataBinder中。当 POST 请求到达/registerSpring 会自动把username和password参数组装成UserForm对象前提是UserForm有对应 setter 方法再交给UserController.register()处理。这就是“约定优于配置”的威力——你不用写RequestParam String usernameThymeleaf 和 Spring MVC 已经帮你串好了整条线。4.2 Controller 层Valid校验不是装饰品是防御第一道墙UserController.java的注册方法PostMapping(/register) public String register(Valid ModelAttribute UserForm userForm, BindingResult result, Model model) { if (result.hasErrors()) { model.addAttribute(error, 请检查输入信息); return register; // 返回注册页保留原输入值 } userService.register(userForm); model.addAttribute(message, 注册成功请登录); return redirect:/login; }重点在Valid和BindingResult result的组合。Valid触发UserForm类上定义的校验注解如NotBlank(message 用户名不能为空)校验失败时错误信息不会抛异常而是塞进BindingResult。if (result.hasErrors())就是拦截点——它让系统在进入 Service 层前就退回页面避免无效数据污染数据库。我见过太多项目把校验逻辑全堆在 Service 里结果前端传个空字符串过来Service 层if (username null)判断后返回throw new RuntimeException(用户名为空)页面直接 500体验极差。这个设计把校验前置到 Controller是 Web 层该干的事。4.3 Service 层UserService.register()里的事务边界和密码加密UserService.java的注册逻辑Transactional public void register(UserForm form) { // 1. 检查用户名是否已存在 if (userRepository.findByUsername(form.getUsername()).isPresent()) { throw new RuntimeException(用户名已存在); } // 2. 密码加密BCrypt String encodedPassword passwordEncoder.encode(form.getPassword()); // 3. 构建 User 实体 User user new User(); user.setUsername(form.getUsername()); user.setPassword(encodedPassword); user.setRole(Role.USER); // 新注册默认为普通用户 // 4. 保存到数据库 userRepository.save(user); }这里Transactional是关键它保证“查重→加密→保存”三步要么全成功要么全回滚。如果去掉这个注解查重通过后密码加密失败比如passwordEncoder为 nulluserRepository.save()就不会执行但查重那一步的数据库连接已经关闭下次再注册同名用户查重又会通过——造成脏数据。另外passwordEncoder.encode()用的是 BCrypt不是 MD5 或 SHA-256因为 BCrypt 内置盐值和计算轮数能防彩虹表攻击。项目里PasswordEncoderBean 是在SecurityConfig.java里声明的Bean public PasswordEncoder passwordEncoder() { return new BCryptPasswordEncoder(12); }12是强度参数值越大越安全但越慢12 是平衡点。4.4 Repository 层JPA 的findByUsername不是魔法是动态代理生成的 SQLUserRepository.java接口Repository public interface UserRepository extends JpaRepositoryUser, Long { OptionalUser findByUsername(String username); }你没写实现类但运行时能调用findByUsername()因为 Spring Data JPA 在启动时扫描所有JpaRepository子接口为每个方法名生成对应 SQL。findByUsername会被翻译成SELECT * FROM user WHERE username ?这不是反射调用而是 JDK 动态代理生成的实现类。你可以用Query注解覆盖它比如Query(SELECT u FROM User u WHERE u.username :name AND u.role :role) OptionalUser findUserByUsernameAndRole(Param(name) String username, Param(role) Role role);但没必要——findByUsername已足够。真正要注意的是命名规范findByUsername对应字段username如果实体类里字段叫userName驼峰就必须写findByUserName否则 JPA 找不到字段启动时报PropertyReferenceException。这是新手最常犯的拼写错误。4.5 数据库持久化GeneratedValue(strategy GenerationType.IDENTITY)的 MySQL 绑定User实体的 ID 定义Id GeneratedValue(strategy GenerationType.IDENTITY) private Long id;GenerationType.IDENTITY意味着 ID 由数据库自增生成它强依赖 MySQL 的AUTO_INCREMENT属性。所以建表语句里id字段必须是id BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY,如果误写成id BIGINT PRIMARY KEY没AUTO_INCREMENTuserRepository.save(user)会抛org.hibernate.exception.ConstraintViolationException: Column id cannot be null。这不是 Hibernate 的 bug是你没告诉 MySQL “这个字段要自增”。JPA 的IDENTITY策略不做任何内存生成它只是信任数据库——你给它AUTO_INCREMENT它就放心把id设为 null 让数据库填你不给它就填不了直接崩。5. 避坑指南那些让我重启 IDEA 三次才定位到的致命细节5.1 现象Thymeleaf 模板里th:each循环不渲染任何数据页面空白原因Controller 方法返回String时没把数据放进Model或者Model的 key 名和模板里th:eachmovie : ${movies}的变量名不一致。比如 Controller 写model.addAttribute(filmList, movies)模板却写${movies}那就找不到数据源。解决在 Controller 方法里加断点看model.asMap()里到底有哪些 key或者在模板里加div th:text${#strings.listSize(movies)} ?: movies is null/div确认变量是否存在。5.2 现象登录成功后跳转到/login页面而不是/home形成死循环原因SecurityConfig.java里配置了formLogin().defaultSuccessUrl(/home)但/home路径对应的 Controller 方法没加GetMapping(/home)或者 Thymeleaf 模板home.html不在src/main/resources/templates/目录下。Spring Security 找不到目标页面就 fallback 到登录页。解决检查SecurityConfig的defaultSuccessUrl和failureUrl是否指向真实存在的路径用curl -I http://localhost:8080/movie/home看返回状态码302 表示重定向正常404 表示页面不存在。5.3 现象MySQL 中文存成???数据库里全是问号原因三个地方字符集不一致① MySQL 服务器默认字符集SHOW VARIABLES LIKE character_set_%;②movie_db数据库字符集SHOW CREATE DATABASE movie_db;③application.properties里jdbc:mysql://...?useUnicodetruecharacterEncodingutf8mb4参数缺失。解决三者必须都是utf8mb4。执行ALTER DATABASE movie_db CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;在application.properties的 JDBC URL 末尾加上useUnicodetruecharacterEncodingutf8mb4重启 MySQL 服务。5.4 现象mvn clean package成功但java -jar target/*.jar启动报ClassNotFoundException: org.thymeleaf.spring5.SpringTemplateEngine原因pom.xml里spring-boot-maven-plugin插件没配置goalrepackage/goal导致打包出来的 jar 是普通 jar不是 Spring Boot Fat Jar缺少内嵌 Tomcat 和依赖库。解决确认pom.xml有如下配置plugin groupIdorg.springframework.boot/groupId artifactIdspring-boot-maven-plugin/artifactId executions execution goals goalrepackage/goal /goals /execution /executions /plugin5.5 现象管理员后台的“电影类型管理”新增类型后电影详情页下拉框不显示新类型原因MovieType实体类没加Entity注解或者MovieTypeRepository没继承JpaRepositoryMovieType, Long导致 JPA 不识别该实体findAll()返回空列表。解决检查MovieType.java是否有Entity检查MovieTypeRepository.java是否正确继承JpaRepository在MovieController里加日志log.info(Found {} movie types, movieTypeRepository.findAll().size());确认数据是否真被查出来。6. 二次开发实战如何 30 分钟内给会员增加“首映场优先选座”功能6.1 需求拆解什么是“优先选座”不是改算法是改权限和流程“会员优先选座”不是指会员能选别人选不了的座位那得改排座算法而是指当某场电影开售时会员用户能看到并点击“立即选座”按钮而普通用户只能看到“暂未开放”提示。这本质是 UI 层权限控制 时间窗口控制不需要碰数据库表结构或 JPA 查询逻辑。6.2 改造步骤一扩展User实体增加会员等级和开通时间字段原User类只有username,password,role。现在加两个字段// 在 User.java 里追加 private Integer memberLevel; // 1: 普通会员, 2: 黄金会员, 3: 钻石会员 private LocalDateTime memberSince; // 会员开通时间对应数据库user表加两列ALTER TABLE user ADD COLUMN member_level INT DEFAULT 1, ADD COLUMN member_since DATETIME;注意member_level用INT而不是ENUM因为未来可能加新等级INT更易扩展member_since用DATETIME而非DATE精确到秒方便做“开通满30天解锁首映场”这类逻辑。6.3 改造步骤二在电影详情页movie-detail.html注入会员状态判断原页面里选座按钮是无条件显示的a href# classbtn btn-success立即选座/a现在改成!-- 判断当前用户是否为会员且已开通满7天 -- div th:if${#authentication.principal ! null and #authentication.principal.memberLevel 1} a href# classbtn btn-success立即选座/a /div div th:unless${#authentication.principal ! null and #authentication.principal.memberLevel 1} span classtext-muted首映场仅限黄金及以上会员span th:text${#dates.format(#authentication.principal.memberSince, yyyy-MM-dd)} ?: 请升级会员/span/span /div这里#authentication.principal是 Spring Security 提供的当前登录用户对象它会自动把User实体的字段暴露给 Thymeleaf。th:unless是th:if的反向逻辑比写th:if${!condition}更清晰。6.4 改造步骤三Controller 层增加“首映场”标识让前端能区分MovieController.java的getMovieById方法原来只返回Movie对象GetMapping(/{id}) public ResponseEntityMovie getMovieById(PathVariable Long id) { Movie movie movieService.findById(id); return ResponseEntity.ok(movie); }现在要返回额外信息建一个 DTOpublic class MovieDetailDTO { private Movie movie; private boolean isPremiere; // 是否为首映场 private String premiereMessage; // 首映场提示语 // getter/setter... }然后改 ControllerGetMapping(/{id}) public String getMovieById(PathVariable Long id, Model model) { Movie movie movieService.findById(id); MovieDetailDTO dto new MovieDetailDTO(); dto.setMovie(movie); // 简单规则上映日期在今天之后7天内且电影类型含“首映” if (movie.getReleaseDate() ! null movie.getReleaseDate().isBefore(LocalDate.now().plusDays(7)) movie.getGenre().contains(首映)) { dto.setPremiere(true); dto.setPremiereMessage(本场为会员专享首映场); } model.addAttribute(movieDetail, dto); return movie-detail; }最后在movie-detail.html里用th:if${movieDetail.isPremiere}控制 UI 元素。整个过程没动一行 SQL没改 JPA 查询只加了字段、改了模板、扩了 Controller 返回值——这就是领域驱动设计里“关注点分离”的好处UI 权限、业务规则、数据存储各司其职。6.5 验证技巧用 Postman 模拟不同角色用户比切账号更快要测“普通用户看不到首映场按钮”不用反复登出/登录。直接用 Postman 发请求先用普通用户登录获取 CookieJSESSIONID再发 GET/movie/api/movies/1Headers 里带上Cookie: JSESSIONIDxxx查看响应 HTML 里有没有a href# classbtn btn-success立即选座/a再用会员账号登录换新 Cookie重复步骤 2-3。这样 5 分钟就能完成多角色验证比手动操作快 10 倍。从那以后我每次改权限逻辑第一反应就是开 Postman而不是切浏览器标签页。希望帮到你。本文还有配套的精品资源点击获取