☰
Java+Spring Boot电影在线订票系统源码:权限、锁座与订单状态机
2026/11/6 2:25:14 网站建设 项目流程

简介:面向具备一定Java基础、希望学习或从事在线票务系统开发的技术人员,这份文档围绕基于Java与Spring Boot的电影在线订票选座系统展开,重点解决用户、会员与管理员三类角色的功能设计及实现思路,涉及注册登录、影片浏览、订单管理、论坛交流、会员折扣和后台数据维护等业务。内容结合IDEA开发环境,梳理JPA、Thymeleaf、MySQL等技术栈的协作方式,并给出开发步骤、编译运行流程与核心代码模块示例,便于读者理解数据模型、业务流程和系统落地路径。资源包为1个docx文档,大小约34KB,适合作为课程设计、毕业项目或企业票务平台原型开发的参考资料。已有315人学习,读者可从中获取完整功能清单、后台管理模块、代码片段与项目总结等线索,并据此扩展个性化推荐、性能优化和移动端适配等方向。

1. 一套 Java + Spring Boot 电影在线订票系统源码,能省下的到底是哪几天

多数人拿到一份 Java + Spring Boot 写的电影在线订票系统源码,第一步是双击压缩包,第二步是找application.properties改密码,第三步就卡在启动报错上。这套东西真正的价值不在“有登录有列表”,而在于它把票务系统里最容易返工的三件事先摆好了:三种角色的权限边界、场次与座位的一致性、订单和折扣的计算位置。技术栈是 Spring Boot 起服务、Spring Data JPA 管持久层、Thymeleaf 渲染页面、MySQL 存数据,IDE 侧按 IntelliJ IDEA 的 Maven 工程组织,拿来就能调试。适合两类人:Java 语法还熟、想完整走一遍业务链路的;以及手上有票务类需求、需要先做个可运行底座验证流程的。往下按“结构怎么读、环境怎么搭、核心逻辑在哪、坑埋在哪”的顺序拆,每一步都能落到命令和文件上。

2. 读懂项目骨架:Spring Boot + JPA + Thymeleaf 的三层边界与五张核心表

从压缩包解出来的那一刻,很多人会先翻 controller,看注册登录怎么写。这个顺序其实反了。先看pom.xml和目录分层,能省掉后面一大半“这个类该放哪”的犹豫;再看实体和表关系,才能判断这份代码是能往上加业务,还是一动就塌。下面按目录、数据模型、权限三块拆,拆完之后你对这个项目的改动成本会有一个大致估计。

2.1 Maven 目录与分层边界:controller / service / repository 各管什么

标准 Maven 结构下,源码在src/main/java,配置和模板在src/main/resources。国内项目普遍会在java下再按controller、service、repository、entity、config、common分包,页面放在resources/templates,静态资源放resources/static。分层不是形式主义,它决定了你改一个功能要动几个文件。

controller 只做三件事:接参数、做基础校验、把结果包成统一响应。真正的业务判断放在 service,比如“这个用户是不是会员”“这个座位还能不能锁”。repository 继承JpaRepository,只负责查询,不写业务条件拼接。常见做法是 service 层的方法加@Transactional,controller 保持无状态,这样后面接接口、换模板都不会互相牵连。

// 统一响应体,放在 common 包下,避免每个 controller 各自拼 Map public class R<T> { private int code; // 0 成功,非 0 为业务错误码 private String msg; private T data; public static <T> R<T> ok(T data) { R<T> r = new R<>(); r.code = 0; r.msg = "ok"; r.data = data; return r; } public static <T> R<T> fail(String msg) { R<T> r = new R<>(); r.code = 500; r.msg = msg; return r; } // getter / setter 省略 }

这个类看着简单,作用是让前端和模板的取值方式统一,后面加全局异常处理器时,只改一处就能把错误码规范回来。参数说明上,code建议按业务域分段,比如 1xxx 用户、2xxx 影片、3xxx 订单,排查日志时一眼能定位模块。

pom.xml里值得先确认的有三项:Spring Boot 的 parent 版本、MySQL 驱动依赖、Thymeleaf 依赖。Spring Boot 主版本决定了 JDK 下限,2.7 及以下通常配 JDK 8 或 11,3.x 要求 JDK 17 起。如果你的机器上是 JDK 8,而pom.xml的 parent 是 3.x,编译期就会报 class 文件版本不匹配,这类错误信息不明显,容易误判成 IDEA 配置问题。

2.2 数据模型:五张核心表怎么连,价格挂在哪一层

订票系统的表设计,翻车点几乎都集中在两处:价格挂错层、座位没做唯一约束。影片有基础票价,但同一部片子在普通厅和特效厅、工作日和周末的价格不一样,所以价格应该挂在场次上,不是影片上。座位也不是“订单表里存一个逗号分隔的字符串”就完事,那样查空座和防重都很难做。

表名关键字段设计要点
userid、username、password、role、points、member_level三种角色共用一张表,靠 role 区分,避免多表联查
movieid、title、type_id、duration、release_date、poster、status类型单独一张 movie_type,方便后台增删
scheduleid、movie_id、hall_id、start_time、price、total_seat价格和总座位数挂场次,同一影片可有多场
seat_lockid、schedule_id、seat_no、user_id、expire_at(schedule_id, seat_no)建唯一索引,防重复占用
ordersid、order_no、user_id、schedule_id、amount、pay_status、create_time金额落库快照,不随场次改价而变

订单和座位之间建议用明细表order_item(order_id, seat_no, price),而不是在订单表里塞字符串。这样退票时能精确释放某一个座位,统计上座率也能直接按order_item聚合。seat_lock和orders分开的意义在于:锁座是有时效的临时状态,订单是终态,两者生命周期不同,混在一张表里,过期释放逻辑会写得很别扭。

movie和schedule是一对多,schedule和seat_lock是一对多,user和orders是一对多。JPA 里建议在“多”的一方持有外键字段(比如schedule里存movieId),不要急着用@OneToMany双向关联,双向关联在序列化 JSON 时特别容易循环引用,还得靠@JsonIgnore补丁,属于自找麻烦。

2.3 角色权限的落点:USER、MEMBER、ADMIN 用拦截器怎么拦

原文功能里区分了普通用户、会员、管理员,落到代码上最容易走偏的做法是每个方法里if (role == ADMIN)到处判断。可维护的写法是用一个HandlerInterceptor统一拦路径:/admin/**只放管理员,/api/order/**要求登录,登录注册接口放行。

@Component public class AuthInterceptor implements HandlerInterceptor { @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { HttpSession session = request.getSession(false); User user = (session == null) ? null : (User) session.getAttribute("LOGIN_USER"); String uri = request.getRequestURI(); if (uri.startsWith("/admin")) { if (user == null || user.getRole() != Role.ADMIN) { response.sendRedirect("/login"); // 后台页面走跳转 return false; } } if (uri.startsWith("/api/order") && user == null) { response.setStatus(401); // 接口走状态码 return false; } return true; } }

注册拦截器时把放行名单写清楚,别图省事用/**全拦再排除,排除项一多就容易漏。

@Configuration public class WebConfig implements WebMvcConfigurer { private final AuthInterceptor authInterceptor; public WebConfig(AuthInterceptor authInterceptor) { this.authInterceptor = authInterceptor; } @Override public void addInterceptors(InterceptorRegistry registry) { registry.addInterceptor(authInterceptor) .addPathPatterns("/admin/**", "/api/order/**") .excludePathPatterns("/api/user/login", "/api/user/register", "/static/**"); } }

会员等级不建议做成独立角色。会员本质是普通用户的一个属性,它的差异体现在折扣和积分上,而不是能访问哪些页面。如果把 MEMBER 也当角色,用户升级会员时就要改 role 字段,权限判断和历史数据全得跟着动。常见的做法是role只管 USER / ADMIN 两类,会员信息放member_level和points两个字段,折扣计算时读这两个值。

提示:拦截器拦的是路径,不拦方法内部逻辑。涉及“只能操作自己的订单”这类数据级权限,仍然要在 service 里比对当前登录用户 id,别指望路径拦截能兜住。

3. 把系统跑起来:JDK、MySQL 到首页可访问的七步操作链

启动失败是这类源码最劝退的环节,而且九成问题集中在三处:JDK 版本不匹配、数据库连不上、字符集不对。下面按顺序走,每一步都有可验证的结果,别跳步。跳过的代价是后面报错时你不知道是哪一层的问题。

3.1 环境准备:JDK、Maven、MySQL 的版本对齐

先确认三件事的版本能对上,再动手。命令行敲这三条,输出比 IDEA 里点来点去更可靠。

java -version mvn -v mysql --version

java -version看主版本,mvn -v会顺带打印它识别的 JAVA_HOME。如果 Maven 认到的 JDK 和你在 IDEA 里选的不是同一个,就会出现“IDE 里编译通过、命令行打包失败”的玄学现象。这种情况在装了多个 JDK 的机器上很常见,改系统环境变量JAVA_HOME指向目标版本,再重开终端。

MySQL 建议用 8.0 及以上,驱动类是com.mysql.cj.jdbc.Driver,5.x 的com.mysql.jdbc.Driver已经废弃,配错会直接抛ClassNotFoundException。IDEA 里导入项目时选“Maven 项目”,不要选“从已有源创建”,后者经常把src/main/resources标成普通目录,导致application.properties读不到。

3.2 application.properties 里必须改对的六项配置

配置文件在src/main/resources/application.properties,这个文件是全项目最该逐行看的地方。下面这份是可用的最小集合,注释标了每一项的作用和改法。

# 1. 数据库连接:库名、时区、字符集三件事一起配 spring.datasource.url=jdbc:mysql://localhost:3306/movie_ticket?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&useSSL=false&allowPublicKeyRetrieval=true # 2. 账号密码换成你本机的 spring.datasource.username=root spring.datasource.password=your_password # 3. MySQL 8 必须用 cj 驱动 spring.datasource.driver-class-name=com.mysql.cj.jdbc.Driver # 4. 开发期用 update 自动建表,上线前改成 validate spring.jpa.hibernate.ddl-auto=update spring.jpa.show-sql=true # 5. 关掉 open-in-view,避免懒加载在视图层才触发查询 spring.jpa.open-in-view=false # 6. 模板缓存关掉,改 HTML 不用重启 spring.thymeleaf.cache=false server.port=8080

allowPublicKeyRetrieval=true这一项在 MySQL 8 默认认证插件下不加会报Public Key Retrieval is not allowed,属于必踩的一步。serverTimezone不配或者配错,插入的时间会整体偏 8 小时,而且不报错,只是数据不对,排查起来特别费劲。ddl-auto=update只适合开发期,它不会删字段也不会改类型,你以为改了实体字段就生效,实际上表结构还停在旧版本,这个坑后面单独说。

3.3 建库、建表与第一条管理员数据

建库时把字符集显式指定成utf8mb4,别依赖默认值。有些环境默认是latin1,插中文直接变问号,而且改起来要动表、动字段、动连接三处。

CREATE DATABASE IF NOT EXISTS movie_ticket DEFAULT CHARACTER SET utf8mb4 DEFAULT COLLATE utf8mb4_general_ci; USE movie_ticket;

如果项目带了初始化 SQL,直接source导入;没带就靠 JPA 的ddl-auto=update先生成表,再手工补初始数据。管理员账号的密码字段是加密存储的,不要手工往表里插明文,正确做法是先在测试里跑一次PasswordEncoder.encode("你的密码"),把输出贴进 SQL。BCrypt 每次加密结果都不同,所以你不能靠“看起来像不像”判断对错,只能靠matches()验证。

-- 角色用字符串枚举,和实体里的 @Enumerated(EnumType.STRING) 对应 INSERT INTO user (username, password, role, points, member_level) VALUES ('admin', '$2a$10$这里替换成encode后的密文', 'ADMIN', 0, 0);

3.4 启动与接口自测:先用命令行验一遍再打开浏览器

启动有三种方式:IDEA 里跑主类、mvn spring-boot:run、打包后java -jar。调试阶段用第一种最方便,验证部署可行性用第三种最真实。

# 打包,跳过测试以免初始化数据干扰 mvn clean package -DskipTests # 运行打出来的 jar,文件名以 target 目录实际为准 java -jar target/movie-ticket-system-0.0.1-SNAPSHOT.jar # 另开一个终端验接口 curl -X POST http://localhost:8080/api/user/register \ -H "Content-Type: application/json" \ -d '{"username":"test01","password":"123456"}' curl "http://localhost:8080/api/movies/list?page=0&size=5"

第一条返回{"code":0,"msg":"注册成功"}说明数据库连通、JPA 映射正常、事务生效。第二条能返回分页数据,说明查询链路没问题。如果第一条就失败,看日志最下面那几行 Caused by,比看整段堆栈有用得多。浏览器访问http://localhost:8080能看到首页,才说明 Thymeleaf 模板路径是对的;接口通但页面 500,问题一定在模板解析,不在业务代码。

4. 核心链路拆解:注册登录、影片查询、选座下单的代码怎么写才不返工

功能列表看着长,真正决定这套代码能不能往上加业务的只有三条链路:身份、内容、交易。其余论坛、公告、收藏都是标准增删改查,照着抄就行。下面三段代码是改动频率最高的地方,重点看参数怎么传、异常怎么抛、事务边界划在哪。

4.1 注册登录:密码加密与统一错误提示

注册的核心不是save,是“用户名查重 + 密码加密 + 默认角色赋值”这三步要么全成要么全败。登录的核心也不是查库,是“用户不存在”和“密码错误”要给同一个提示,避免被人拿来撞库探测账号是否存在。

@RestController @RequestMapping("/api/user") public class UserController { private final UserService userService; // 构造器注入,别用字段 @Autowired,单元测试时好替换 public UserController(UserService userService) { this.userService = userService; } @PostMapping("/register") public R<Void> register(@RequestBody @Valid RegisterRequest req) { userService.register(req.getUsername(), req.getPassword()); return R.ok(null); } @PostMapping("/login") public R<Map<String, Object>> login(@RequestBody @Valid LoginRequest req, HttpSession session) { User user = userService.login(req.getUsername(), req.getPassword()); session.setAttribute("LOGIN_USER", user); Map<String, Object> data = new HashMap<>(); data.put("username", user.getUsername()); data.put("role", user.getRole().name()); return R.ok(data); } }

@Valid配合请求体里的@NotBlank、@Size(min = 6)能把空值和过短密码挡在 service 之前,减少一半无效判断。session 里存的是整个 User 对象,注意别把密码字段带出去,简单做法是登录成功后把password置空再放进去。

@Service public class UserService { private final UserRepository userRepository; private final PasswordEncoder passwordEncoder; public UserService(UserRepository userRepository, PasswordEncoder passwordEncoder) { this.userRepository = userRepository; this.passwordEncoder = passwordEncoder; } @Transactional public void register(String username, String rawPassword) { if (userRepository.findByUsername(username).isPresent()) { throw new BizException("用户名已被占用"); } User u = new User(); u.setUsername(username); u.setPassword(passwordEncoder.encode(rawPassword)); // BCrypt,每次结果不同 u.setRole(Role.USER); u.setPoints(0); userRepository.save(u); } public User login(String username, String rawPassword) { User u = userRepository.findByUsername(username) .orElseThrow(() -> new BizException("用户名或密码错误")); if (!passwordEncoder.matches(rawPassword, u.getPassword())) { throw new BizException("用户名或密码错误"); // 提示与上面保持一致 } return u; } }

passwordEncoder.matches()是唯一正确的比对方式,自己写equals一定失败。@Transactional加在 register 上而不是整个类上,是因为登录是只读操作,没必要开事务。异常统一抛自定义的BizException,再由@RestControllerAdvice转成R.fail(msg),这样前端拿到的结构始终一致。

4.2 影片与场次查询:分页参数和返回结构

查询接口最容易出的问题是返回结构不统一。JPA 的Page对象直接当返回值,不同版本序列化出来的字段名不一样,前端解析经常崩。稳妥做法是转成自定义的PageResult,只保留list、total、page、size四个字段。

@GetMapping("/list") public R<PageResult<Movie>> list( @RequestParam(defaultValue = "0") int page, @RequestParam(defaultValue = "12") int size, @RequestParam(required = false) Long typeId) { Pageable pageable = PageRequest.of(page, size, Sort.by(Sort.Direction.DESC, "releaseDate")); Page<Movie> result = (typeId == null) ? movieRepository.findAll(pageable) : movieRepository.findByTypeId(typeId, pageable); PageResult<Movie> vo = new PageResult<>(); vo.setList(result.getContent()); vo.setTotal(result.getTotalElements()); vo.setPage(page); vo.setSize(size); return R.ok(vo); }

page从 0 开始,这是PageRequest.of的约定,前端传 1 的话要减一,这个偏移量是最常见的“查出来少一条”的原因。size建议设上限,比如超过 50 就强制改成 50,否则有人传size=100000就是一次全表扫描。排序字段releaseDate要和实体字段名严格一致,写成数据库列名release_date会直接抛异常。

场次查询按movieId过滤,返回时带上剩余座位数。剩余座位不要实时count全表,可以在schedule表里维护一个remain_seat字段,锁座时减、释放时加,查询时直接读。代价是要保证加减和座位表操作在同一个事务里,否则会出现“显示有座但锁不上”的不一致。

4.3 选座下单:用唯一索引兜住并发,别只靠先查后插

这是整套系统里唯一真正涉及并发的地方。典型错误写法是:先findByScheduleIdAndSeatNo查一下有没有,没有再插入。两个请求同时进来,都查到没有,都插入成功,一个座位卖两次。加synchronized只能单机有效,多实例部署立刻失效。

靠得住的方案是把约束下沉到数据库:给seat_lock建唯一索引,让数据库来判重。

-- 一条 SQL 解决重复占用,注意是联合唯一,不是 seat_no 单列唯一 ALTER TABLE seat_lock ADD UNIQUE KEY uk_schedule_seat (schedule_id, seat_no);
@Transactional(rollbackFor = Exception.class) public String lockSeats(Long scheduleId, List<String> seats, Long userId) { for (String seat : seats) { SeatLock lock = new SeatLock(); lock.setScheduleId(scheduleId); lock.setSeatNo(seat); lock.setUserId(userId); lock.setExpireAt(LocalDateTime.now().plusMinutes(15)); try { // saveAndFlush 立即发 SQL,把唯一键冲突暴露在当前 try 里 seatLockRepository.saveAndFlush(lock); } catch (DataIntegrityViolationException e) { throw new BizException("座位 " + seat + " 已被占用,请重新选座"); } } return "OK"; }

saveAndFlush和save的区别是关键:save只是把对象放进持久化上下文,真正发 SQL 可能拖到事务提交时,那时候异常已经不在你的 try 里了,捕获不到。@Transactional(rollbackFor = Exception.class)保证多人选座时只要有一个座位冲突,整批全部回滚,不会出现“锁了 3 个成功 1 个失败”的脏数据。

锁座的过期时间是 15 分钟,配合一个定时任务清理expire_at < now()的记录。没有这个清理,用户下单不付款,座位会被永久占住,越跑空座越多。

4.4 会员折扣与积分:算在 service,不散落在 controller

折扣和积分的规则会变,所以一定要收在一个方法里。常见规则是会员按等级打 9 折到 8 折,消费 1 元积 1 分。写在 service 里,改规则只动一处;散在 controller 里,改的时候得全局搜。

public BigDecimal calcAmount(BigDecimal origin, int memberLevel) { BigDecimal rate = switch (memberLevel) { case 2 -> new BigDecimal("0.90"); case 3 -> new BigDecimal("0.80"); default -> BigDecimal.ONE; // 普通用户无折扣 }; // 金额保留两位,四舍五入,避免 0.1 + 0.2 那类精度问题 return origin.multiply(rate).setScale(2, RoundingMode.HALF_UP); }

金额一定用BigDecimal,用double算出来的订单金额会出现99.99999999这种值,落库和展示都对不上。折扣率从数据库配置表读,别硬编码在代码里,运营改活动时不用重新发版。

5. 踩坑排查:这套源码在我机器上翻车的五个地方

下面五条都是启动和联调阶段真实会遇到的,按“现象 → 原因 → 解决”写,遇到同类问题直接对号入座比通读日志快。

5.1 启动即退出,日志最后一行是 Port 8080 was already in use

现象是控制台刷了一屏 Spring 的 banner,然后进程直接结束,末尾一行Web server failed to start. Port 8080 was already in use。原因通常是上一次运行没停干净,或者 IDEA 里开了多个运行实例,也可能是本机装了别的东西占着 8080。

解决分两步:先确认占用者,再决定是杀进程还是换端口。

# macOS / Linux 查占用 lsof -i :8080 # Windows 用 netstat netstat -ano | findstr :8080

急着调试就在application.properties里加server.port=8081,别去系统设置里翻。长期方案是养成习惯:改完代码先点停止再点运行,IDEA 的“停止”有时杀不掉子进程,任务管理器里会残留一个java.exe。

5.2 数据库连不上:三种报错对应三种原因

现象是启动时抛Cannot determine embedded database driver class或Communications link failure。第一种说明配置根本没读到,检查application.properties是不是在src/main/resources根目录,文件名有没有拼错。第二种说明读到了但连不上,检查 MySQL 服务是否启动、端口是不是 3306、库名是否存在。

Access denied for user 'root'@'localhost'是密码错,或者该用户没有远程/本地权限。Public Key Retrieval is not allowed是 MySQL 8 的认证插件问题,在连接串后加allowPublicKeyRetrieval=true&useSSL=false即可。时间字段整体偏 8 小时则说明serverTimezone没配或配成了 UTC,改成Asia/Shanghai。

还有一种隐蔽情况:IDEA 的 Database 面板能连上,项目连不上。原因多半是连接串里用了localhost而系统解析到了 IPv6 地址,换127.0.0.1试试。

5.3 中文变问号或乱码:字符集要三处一致

现象是页面上的中文正常,但存进数据库变成???,或者反过来,库里正常页面上是乱码。原因是库、表、连接三处的字符集不一致,只改一处没用。

排查顺序是:先SHOW CREATE DATABASE movie_ticket;看库的字符集,再SHOW CREATE TABLE movie;看表的,最后看连接串里有没有characterEncoding=utf8。三处都必须是utf8mb4,utf8在 MySQL 里是utf8mb3的别名,存 emoji 和部分生僻字会失败。已经建好的表要改字段字符集,只改库不生效。

ALTER TABLE movie CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci;

改完重启应用再测,别在旧连接上验证,连接池里的连接字符集是建立时定的。

5.4 ddl-auto=update 与手写 SQL 打架,字段改了不生效

现象是你给实体加了一个字段,重启后数据库里没有;或者你把varchar(50)改成varchar(200),插入长文本仍然报Data too long。原因是ddl-auto=update只会新增表和新增列,不会修改已有列的类型和长度,更不会删除列。

这条坑的血泪经验是:开发期可以图省事用update,但只要动过表结构,就必须手工执行ALTER语句同步,或者干脆关掉自动建表,用一份schema.sql管理。上线前一定改成spring.jpa.hibernate.ddl-auto=validate,让应用启动时校验实体与表是否一致,不一致直接启动失败,比运行到一半报错强。

5.5 页面 500:template might not exist 与静态资源 404

现象是接口能通,浏览器打开首页返回 500,日志里是Error resolving template [index], template might not exist。原因有三种:模板文件没放在src/main/resources/templates下;controller 上用了@RestController而不是@Controller,返回值被当成 JSON 序列化;或者方法返回的字符串和文件名大小写不一致。

静态资源 404 则是路径问题,resources/static/css/app.css对应的访问路径是/css/app.css,不需要带static前缀。如果用了拦截器拦截/**,记得把/css/**、/js/**加进排除名单,否则样式会被拦成 302 跳转,页面上看着像“样式丢了”。

6. 二次开发验证:用并发请求和状态机把选座锁做稳

改完代码最该验的不是页面好不好看,是座位会不会被重复占用。单机手动点两下测不出来,得用并发打。

# 20 个请求同时抢 A1 座位,统计返回的状态码分布 seq 1 20 | xargs -I{} -P 20 curl -s -o /dev/null -w "%{http_code}\n" \ -X POST http://localhost:8080/api/order/lock \ -H "Content-Type: application/json" \ -d '{"scheduleId":1,"seats":["A1"],"userId":2}' | sort | uniq -c # 再看库里到底有几条记录,正确结果应该只有 1 mysql -uroot -p movie_ticket -e \ "select count(*) from seat_lock where schedule_id=1 and seat_no='A1';"

预期结果是状态码里只有一个 200,其余是 4xx,count等于 1。如果出现多个 200,说明唯一索引没建上,或者代码里用的是save而不是saveAndFlush加事务回滚。这一步验完,选座逻辑才算真正立住。

6.1 用状态机管订单,别用布尔值

订单状态用pay_status这种 0/1 布尔字段,后面加退款、超时取消时一定会返工。改成枚举:PENDING_PAY、PAID、CANCELLED、REFUNDED,每次变更校验前置状态,只允许合法流转。配一个每分钟跑一次的清理任务,把过期未支付的锁座记录和订单一起置为取消。

@Scheduled(fixedDelay = 60_000) public void releaseExpired() { // 先释放锁座,再关单,顺序反了会出现锁着座但单已关闭 int released = seatLockRepository.deleteByExpireAtBefore(LocalDateTime.now()); orderRepository.closeExpiredPending(LocalDateTime.now().minusMinutes(15)); log.info("本轮释放过期锁座 {} 条", released); }

记得在主类上加@EnableScheduling,否则这个方法永远不会执行,而且不报错,属于典型的黑匣子问题。

6.2 验证清单

验证项方法通过标准
座位不重复并发脚本抢同一位只有 1 个成功,库中 1 条记录
锁座会过期锁定后等 15 分钟再查记录被清理,座位可重新选
折扣正确会员下单比对金额与手算一致,保留两位小数
权限隔离普通用户访问 /admin跳转登录或返回 401

从那以后我每次接手这类票务系统,改完代码的第一件事不是打开浏览器看首页,而是先把并发脚本跑一遍、把唯一索引和状态字段确认一遍,再谈界面和运营功能。希望帮到你。

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

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询