ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Spring Boot电影订票系统:从零跑通到会员优先选座实战

Spring Boot电影订票系统:从零跑通到会员优先选座实战

简介:本资源是一份面向Java初学者与Spring Boot入门开发者的电影在线订票系统完整实践文档,聚焦于企业级票务平台的功能设计与技术落地。内容覆盖普通用户、会员及管理员三类角色的完整业务功能(如注册登录、选座购票、积分折扣、影视管理、论坛监督等),并结合IntelliJ IDEA开发环境、Spring Boot+Spring 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 MVC?Thymeleaf 真的比 Vue 更适合教学原型?

2.1 Spring Boot 的“零配置”其实是精密预设:application.properties 里藏着 4 类关键开关

这个项目没用 Spring Cloud 或微服务,但它的application.properties文件绝不是简单配个数据库地址。我拆开看过,里面实际控制着四类运行时行为:

  • 数据库连接层:spring.datasource.url=jdbc:mysql://localhost:3306/movie_db?useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true—— 注意serverTimezone和allowPublicKeyRetrieval,MySQL 8+ 驱动强制要求,漏掉一个就报Could not create connection;
  • JPA 行为开关:spring.jpa.hibernate.ddl-auto=update是开发期便利开关,但上线必须改成validate,否则每次启动都偷偷改表结构,血泪经验;
  • Thymeleaf 渲染控制:spring.thymeleaf.cache=false和spring.thymeleaf.enabled=true必须同时存在,前者让模板修改实时生效,后者才是开启引擎的总闸;
  • 安全兜底配置:server.port=8080和spring.servlet.context-path=/movie组合,确保所有请求路径统一前缀,避免前端 AJAX 调用/api/user/login时因上下文路径错位返回 404。

提示:别信“Spring Boot 自动配置万能论”。我见过太多人把ddl-auto=update丢进生产环境,结果某次更新实体类字段类型,JPA 直接把整张user表锁死两小时。这项目里它只存在于application-dev.properties,主配置文件里是空的。

2.2 Thymeleaf 不是“Java 版 Vue”,它是服务端模板的务实选择

很多人吐槽 Thymeleaf 写法啰嗦,比如登录表单要这么写:

<form th:action="@{/login}" th:object="${loginForm}" method="post"> <input type="text" th:field="*{username}" placeholder="用户名" /> <input type="password" th:field="*{password}" placeholder="密码" /> <button type="submit">登录</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> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-jpa</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-thymeleaf</artifactId> </dependency> <dependency> <groupId>mysql</groupId> <artifactId>mysql-connector-java</artifactId> <scope>runtime</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.version>11</java.version>

这意味着它用的是 Spring Boot 2.7.x(Spring Boot 3.x 要求 JDK 17+)。如果你装了 JDK 17,mvn clean compile会直接报错Unsupported class file major version 61。解决方案只有两个:要么卸载 JDK 17,装 JDK 11;要么在 IntelliJ IDEA 里强制指定项目 SDK:File → 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-auto=update会自动生成表。然而,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_user'@'localhost' IDENTIFIED BY 'movie_pass'; -- 关键!切换认证插件为 mysql_native_password ALTER USER 'movie_user'@'localhost' IDENTIFIED WITH mysql_native_password BY 'movie_pass'; GRANT ALL PRIVILEGES ON movie_db.* TO 'movie_user'@'localhost'; FLUSH PRIVILEGES;

漏掉ALTER USER ... IDENTIFIED WITH mysql_native_password这一行,启动时就会卡在Access denied for user,日志里还找不到具体原因。

3.3 Maven 编译:跳过测试是合法操作,但得知道代价

项目里UserServiceImplTest等单元测试用的是 H2 内存数据库,而主程序连的是 MySQL。如果你只想快速看到页面,mvn clean compile -Dmaven.test.skip=true是最快路径。但要注意:跳过测试意味着你无法验证UserService.register()是否真能把用户写进 MySQL,只能靠浏览器注册后查数据库确认。我一般只在首次启动时跳过,一旦首页出来,立刻删掉-Dmaven.test.skip=true,跑一遍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后,别急着开浏览器。先验证三件事:

  1. API 层是否活:curl http://localhost:8080/movie/api/movies/应该返回 JSON 数组(哪怕空数组),证明 Controller 和 JPA 链路通;
  2. 模板层是否活:curl http://localhost:8080/movie/应该返回 HTML 源码,且包含<title>电影在线订票系统</title>,证明 Thymeleaf 渲染正常;
  3. 静态资源是否活: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}" method="post"> <div class="form-group"> <label>用户名:</label> <input type="text" th:field="*{username}" class="form-control" /> <span th:if="${#fields.hasErrors('username')}" th:errors="*{username}">用户名错误</span> </div> <div class="form-group"> <label>密码:</label> <input type="password" th:field="*{password}" class="form-control" /> </div> <button type="submit" class="btn btn-primary">注册</button> </form>

这里th:object="${userForm}"告诉 Thymeleaf:这个表单绑定的对象叫userForm,它来自 Controller 的Model。th:field="*{username}"不是简单赋值,它会生成<input name="username" value="">,同时把username字段名注入到 Spring MVC 的DataBinder中。当 POST 请求到达/register,Spring 会自动把username和password参数组装成UserForm对象(前提是UserForm有对应 setter 方法),再交给UserController.register()处理。这就是“约定优于配置”的威力——你不用写@RequestParam String username,Thymeleaf 和 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为 null),userRepository.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不是魔法,是动态代理生成的 SQL

UserRepository.java接口:

@Repository public interface UserRepository extends JpaRepository<User, Long> { Optional<User> 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") Optional<User> 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_INCREMENT),userRepository.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:each="movie : ${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://...?useUnicode=true&characterEncoding=utf8mb4参数缺失。
解决:三者必须都是utf8mb4。执行ALTER DATABASE movie_db CHARACTER SET = utf8mb4 COLLATE = utf8mb4_unicode_ci;;在application.properties的 JDBC URL 末尾加上&useUnicode=true&characterEncoding=utf8mb4;重启 MySQL 服务。

5.4 现象:mvn clean package成功,但java -jar target/*.jar启动报ClassNotFoundException: org.thymeleaf.spring5.SpringTemplateEngine

原因:pom.xml里spring-boot-maven-plugin插件没配置<goal>repackage</goal>,导致打包出来的 jar 是普通 jar,不是 Spring Boot Fat Jar,缺少内嵌 Tomcat 和依赖库。
解决:确认pom.xml有如下配置:

<plugin> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-maven-plugin</artifactId> <executions> <execution> <goals> <goal>repackage</goal> </goals> </execution> </executions> </plugin>

5.5 现象:管理员后台的“电影类型管理”新增类型后,电影详情页下拉框不显示新类型

原因:MovieType实体类没加@Entity注解,或者MovieTypeRepository没继承JpaRepository<MovieType, 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="#" class="btn btn-success">立即选座</a>

现在改成:

<!-- 判断当前用户是否为会员且已开通满7天 --> <div th:if="${#authentication.principal != null and #authentication.principal.memberLevel > 1}"> <a href="#" class="btn btn-success">立即选座</a> </div> <div th:unless="${#authentication.principal != null and #authentication.principal.memberLevel > 1}"> <span class="text-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 ResponseEntity<Movie> getMovieById(@PathVariable Long id) { Movie movie = movieService.findById(id); return ResponseEntity.ok(movie); }

现在要返回额外信息,建一个 DTO:

public class MovieDetailDTO { private Movie movie; private boolean isPremiere; // 是否为首映场 private String premiereMessage; // 首映场提示语 // getter/setter... }

然后改 Controller:

@GetMapping("/{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 发请求:

  1. 先用普通用户登录,获取 Cookie(JSESSIONID);
  2. 再发 GET/movie/api/movies/1,Headers 里带上Cookie: JSESSIONID=xxx;
  3. 查看响应 HTML 里有没有<a href="#" class="btn btn-success">立即选座</a>;
  4. 再用会员账号登录,换新 Cookie,重复步骤 2-3。

这样 5 分钟就能完成多角色验证,比手动操作快 10 倍。从那以后我每次改权限逻辑,第一反应就是开 Postman,而不是切浏览器标签页。希望帮到你。

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

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进