
做毕设选了这个“基于Spring Boot的电影票网上购票系统”一头扎进去搞了大概三周从数据库设计到部署上线全程踩了一遍最后论文、答辩、部署文档都齐了。这篇就把这个项目的完整设计思路、核心代码、部署流程和踩坑实录全部拆开讲给后面选同类题目的同学一份能直接照着做的参考。1. 系统整体设计与技术栈选型1.1 为什么这个题目值得做每年毕业设计题目里电商类的系统永远是最多的图书商城、二手交易、校园超市基本是一个模子刻出来的。相比之下电影票购票系统有个天然优势业务逻辑比普通CRUD要深一层它天然带着“场次-座位-订单”这条完整链路有并发选座的冲突场景有订单超时未支付的释放机制有退款和改签的状态流转。这些点拿出来写论文的时候可以落地的技术亮点远比“增删改查”要多答辩时也不容易被问倒。从工作量角度说这个系统也不至于失控。用户端加管理端核心表控制在七八张左右配合Spring Boot自带的生态一个人三到四周完全能做完。我实际开发用到的技术栈是Spring Boot 2.7.x作为后端框架MyBatis Plus做数据持久层MySQL 8.0存数据前端用的Vue 2加Element UI做的管理后台用户端则是Thymeleaf服务端渲染。这样搭配的好处是既有前后端分离的部分体现现代开发模式又有服务端渲染的部分降低工作量论文里写“混合架构”也说得通。1.2 系统整体功能架构整个系统的功能划分我在一开始就定死了所有模块围绕“影片-场次-座位-订单”这条主线展开。用户端要有的功能注册登录手机号加密码JWT签发token拦截器校验登录状态影片浏览与搜索按类型、上映状态筛选支持按关键词模糊搜索影片详情查看演职员表、预告片、剧情简介、用户评分场次选择按影院和日期查看排片不同场次关联不同放映厅在线选座可视化座位图已售座位置灰不可选订单确认与支付生成订单后模拟支付流程支付超时自动释放座位订单管理待支付、已支付、已退款、已完成四种状态支持在线退票个人中心资料维护、订单历史、观影偏好管理端要有的功能管理员登录与权限校验影片管理新增、上下架、修改影片信息影厅管理维护影厅类型IMAX、普通厅等和座位布局场次管理排片管理设置影片对应的放映厅和开播时间订单管理查看所有订单处理退款请求核对金额数据统计按日期维度统计票房、观影人次这套功能设计覆盖了“用户-内容-交易”三个维度论文里既可以按照角色拆业务也可以按照“前后台模块”拆设计结构很清晰。整个系统的架构图用一句话概括就是前端发起请求经过Controller层做参数校验和路由Service层处理业务逻辑Mapper层操作MySQL认证信息通过JWT在请求头中传递。2. 数据库设计与核心表结构解析2.1 七张核心表的建模思路数据库设计是整个系统开发的第一步也是后面写论文时“系统设计”章节的核心内容。我最终落地的表结构如下user用户表id、手机号、密码BCrypt加密存储、昵称、头像、注册时间film影片表id、片名、海报URL、类型、导演、主演、剧情简介、时长、上映日期、下映日期、状态上映中/已下架cinema影厅表id、影厅名称、类型普通/IMAX、座位总行数、座位总列数session场次表id、场次关联的影片id、影厅id、放映时间、票价、剩余座位数seat座位表id、影厅id、排数、列数、座位状态可用/锁定/已售orders订单表id、订单编号、用户id、场次id、座位id组合、总金额、状态待支付/已支付/已退款/已关闭、创建时间、支付时间、支付方式film_comment评论表可选扩展id、用户id、影片id、评分、评论内容、评论时间表的数量控制在七张整套系统的数据关系没有靠外键强制约束而是通过逻辑外键关联查询时显式使用JOIN或者两次查询。这样做的原因是MyBatis Plus在关联查询上本身就不是强项而且论文里用ER图表达关系也方便。2.2 一张精心设计的orders表订单表是整个系统最核心的表它的设计直接决定了困难业务逻辑能不能写得清晰。CREATE TABLE orders ( id bigint(20) NOT NULL AUTO_INCREMENT, order_no varchar(32) NOT NULL COMMENT 订单编号, user_id bigint(20) NOT NULL COMMENT 用户ID, session_id bigint(20) NOT NULL COMMENT 场次ID, seat_ids varchar(100) NOT NULL COMMENT 座位ID多个用逗号分隔, total_amount decimal(10,2) NOT NULL COMMENT 订单总金额, status tinyint(4) NOT NULL DEFAULT 0 COMMENT 0待支付 1已支付 2已退款 3已关闭, create_time datetime NOT NULL, pay_time datetime DEFAULT NULL, cancel_time datetime DEFAULT NULL, PRIMARY KEY (id), UNIQUE KEY uk_order_no (order_no), KEY idx_user_id (user_id), KEY idx_session_id (session_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT订单表;订单编号用时间戳加随机数的形式生成yyyyMMddHHmmss 三位随机数再加用户ID后四位唯一索引保证不冲突。订单状态一开始用字符串定义过但后面发现写判断的时候容易拼错导致bug所以改成了tinyint枚举值在代码里用常量类定义好状态值这样既省空间又不容易出错。2.3 座位数据在锁座流程中的作用座位设计是这个系统区别于普通商品系统的核心。每个影厅的座位在创建影厅时必须初始化我选择的做法是新增影厅时根据影厅的行列数循环生成座位记录每一条座位记录属于一个影厅然后每个场次引用这部分座位数据。但这里有个业务细节必须处理不同场次同一影厅座位占用状态是独立的。比如A场次的3排5座被占了B场次的3排5座还是可以选的。如果只把座位状态存在seat表里就会造成座位状态互相干扰。我的做法是通过订单状态来判断座位的实际占用情况选中一个座位时查该场次下所有已支付和待支付订单的seat_ids如果包含当前座位就视为不可选。实测下来这个方案的代码量最少而且不存在状态同步问题。3. 核心业务逻辑实现与关键代码拆解3.1 登录认证与接口安全登录模块我用了JWT方案。用户输入手机号密码登录成功后后端签发一个有效期为24小时的token前端把token存进localStorage每次请求在header里带上Authorization: Bearer token后端用一个拦截器解析token并放行。生成JWT的核心代码Component public class JwtUtil { Value(${jwt.secret}) private String secret; Value(${jwt.expire}) private Long expire; public String generateToken(Long userId, String phone) { Date now new Date(); Date expireDate new Date(now.getTime() expire * 1000); return Jwts.builder() .setHeaderParam(typ, JWT) .setSubject(String.valueOf(userId)) .claim(phone, phone) .setIssuedAt(now) .setExpiration(expireDate) .signWith(SignatureAlgorithm.HS256, secret) .compact(); } public Claims parseToken(String token) { return Jwts.parser() .setSigningKey(secret) .parseClaimsJws(token) .getBody(); } }拦截器里放行登录接口、影片查询这些公开接口其他的统一校验Component public class LoginInterceptor implements HandlerInterceptor { Autowired private JwtUtil jwtUtil; Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { if (!(handler instanceof HandlerMethod)) { return true; } String token request.getHeader(Authorization); if (token ! null token.startsWith(Bearer )) { token token.substring(7); try { Claims claims jwtUtil.parseToken(token); request.setAttribute(userId, Long.parseLong(claims.getSubject())); return true; } catch (Exception e) { // token解析失败走未登录逻辑 } } response.setStatus(401); response.setContentType(application/json;charsetUTF-8); response.getWriter().write({\code\:401,\message\:\未登录或登录已过期\}); return false; } }这里有个容易忽略的坑token过期时间不能设置太长不然安全性差也不能太短用户的观影选座过程可能要几分钟。我调了几次24小时比较平衡用户一天内重复登录一次就够了。3.2 选座与锁座的并发控制选座是整个系统技术上最有含金量的部分也最值得在论文里作为创新点展开。用户从前端选了多个座位点击“立即购买”后端需要做三件事校验座位是否已被其他人锁定或购买将选中座位标记为“锁定”状态生成待支付订单给用户付款时间窗口我设置为15分钟这里的并发问题在于如果两个用户同时点击购买同一批座位后提交的人必须看到“座位已被购买”的提示而不是两个订单各自创建成功。我用Redis来实现座位锁public boolean lockSeats(Long sessionId, ListLong seatIds, Long userId) { String lockKey seat:lock: sessionId; String lockValue userId : System.currentTimeMillis(); // 1. 使用Redis分布式锁给整批座位锁上 boolean locked redisTemplate.opsForValue() .setIfAbsent(lockKey, lockValue, 10, TimeUnit.SECONDS); if (!locked) { // 锁获取失败说明有其他请求正在处理这批座位 throw new ServiceException(系统繁忙请稍后重试); } try { // 2. 查当前所有已锁定/已售座位 ListOrder existingOrders orderMapper.selectBySessionId(sessionId); SetLong occupiedSeatIds new HashSet(); for (Order order : existingOrders) { if (order.getStatus() 0 || order.getStatus() 1) { String[] ids order.getSeatIds().split(,); for (String id : ids) { occupiedSeatIds.add(Long.parseLong(id)); } } } // 3. 判断当前选座是否与已有座位冲突 for (Long seatId : seatIds) { if (occupiedSeatIds.contains(seatId)) { throw new ServiceException(座位已被选走请刷新后重试); } } // 4. 创建订单并生成支付二维码 Order order new Order(); order.setOrderNo(generateOrderNo()); order.setUserId(userId); order.setSessionId(sessionId); order.setSeatIds(seatIds.stream().map(String::valueOf).collect(Collectors.joining(,))); order.setTotalAmount(calculateAmount(sessionId, seatIds.size())); order.setStatus(0); order.setCreateTime(new Date()); orderMapper.insert(order); return true; } finally { redisTemplate.delete(lockKey); } }这个方案在普通毕业设计场景下完全够用。如果要进一步优化可以用Redisson的分布式锁替代手写Redis锁但在论文里解释清楚“为什么不引入重量级中间件”也是一种设计能力。3.3 订单超时未支付自动关闭订单一经创建座位就处于锁定状态如果用户一直不付款座位必须被释放否则会造成座位资源大量浪费。Spring Boot里实现定时任务最轻量的方案就是Scheduled注解。Component public class OrderTimeoutTask { Autowired private OrderMapper orderMapper; Scheduled(fixedDelay 60000) public void closeExpiredOrders() { // 查询所有超过15分钟未支付的待支付订单 LocalDateTime expireTime LocalDateTime.now().minusMinutes(15); ListOrder expiredOrders orderMapper.selectByStatusAndCreateTime(0, expireTime); for (Order order : expiredOrders) { order.setStatus(3); // 已关闭 order.setCancelTime(new Date()); orderMapper.updateById(order); } } }实现上要注意的一点定时任务必须加EnableScheduling注解启用这个很多人会漏掉。其次是轮询间隔不能太短也不能太长1分钟比较合理对业务体量不大的系统来说完全够用。论文里可以在“系统可靠性设计”一节讨论这个方案与消息队列延迟消息的对比说明当前方案的优势在简单可控、不引入额外依赖。3.4 电影票订单金额的计算规则票价我设置的规则很简单同一场次的票价是固定的不做浮动定价。所以订单金额只和场次票价与座位数有关。private BigDecimal calculateAmount(Long sessionId, int seatCount) { Session session sessionMapper.selectById(sessionId); BigDecimal price session.getPrice(); return price.multiply(new BigDecimal(seatCount)); }如果要加“优惠券”功能就在此基础上扩展一张coupon表在计算金额时判断用券条件这个可以作为系统的扩展点在论文的“未来展望”里提出。我实际开发时没做优惠券因为时间有限而且加进来以后整个订单流转逻辑至少要多个三分之一的判断分支。3.5 管理端排片与影厅管理的实现管理端的排片功能本质就是给“影片影厅放映时间”建立关联。需要注意两个事务性问题同一个影厅同一时间段不能排两个场次需要在新增场次时检查时间冲突影片下架后已存在的未开始场次要能正常展示和购买不能被级联删除public void addSession(Session session) { // 校验同一影厅同一时间段冲突 ListSession sessions sessionMapper.selectByCinemaId(session.getCinemaId()); for (Session s : sessions) { // 新场次开始时间在已有场次放映时间范围内 LocalDateTime newStart session.getStartTime(); LocalDateTime newEnd newStart.plusMinutes(session.getFilm().getDuration() 15); LocalDateTime oldStart s.getStartTime(); LocalDateTime oldEnd oldStart.plusMinutes(s.getFilm().getDuration() 15); if (newStart.isBefore(oldEnd) newEnd.isAfter(oldStart)) { throw new ServiceException(该影厅在所选时间段已有排片); } } sessionMapper.insert(session); }这里的代码里场次结束时间我加了15分钟的散场间隔是实际业务经验——没有间隔的话上一场还没散完下一场观众就进场了运营上会出问题。4. 部署安装与全流程上线实战4.1 本地开发环境准备动手开发之前先把环境准备好。我列一下我用到的软件和版本这些是经过验证的组合照着装不会出问题软件版本用途JDK1.8Spring Boot 2.7最低要求别用太高版本Maven3.8依赖管理打包构建MySQL8.0数据库Redis6.x座位锁缓存Node.js14前端Vue项目构建IDEA2023.x后端开发IDE安装细节说两个JDK安装时要注意配置JAVA_HOME环境变量Maven要配阿里云镜像源不然首次拉依赖会等到怀疑人生。IDEA里用Lombok的话要装插件否则代码里Data注解不生效编译直接报错。4.2 后端项目初始化和配置Spring Boot项目的初始化我用的Spring InitializrGroup填com.exampleArtifact填cinema-ticket依赖勾选Spring Web、MyBatis Framework、MySQL Driver、Spring Data Redis。生成后的项目结构是标准的controller包、service包、mapper包、entity包、config包、common包。application.yml最关键的几个配置项server: port: 8080 spring: datasource: driver-class-name: com.mysql.cj.jdbc.Driver url: jdbc:mysql://localhost:3306/cinema?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/Shanghai username: root password: 123456 redis: host: localhost port: 6379 database: 0 mybatis-plus: mapper-locations: classpath:mapper/*.xml configuration: log-impl: org.apache.ibatis.logging.stdout.StdOutImpl map-underscore-to-camel-case: true jwt: secret: cinema-secret-key-2024 expire: 86400map-underscore-to-camel-case这个配置必须开不然数据库的create_time字段映射不到实体的createTime属性。这个坑我在开发第二天就踩到了查出来的数据全部是null排查了半小时。4.3 数据库初始化与MySQL启动数据库初始化我用了一个init.sql脚本里面按照第二章的表结构全部建好。然后还需要预置管理员账号和几条测试数据不然前端页面打开一片空白连截图都没法弄。MySQL在Windows上一般是注册成系统服务自启动。我个人的习惯是开发阶段直接用命令行启动方便看日志等部署阶段再注册为服务。初始化数据库的命令mysql -u root -p cinema.sql如果提示编码有问题记得建库的时候显式指定utf8mb4CREATE DATABASE IF NOT EXISTS cinema DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci;4.4 项目打包和部署开发完成后要打包部署到服务器流程是后端用Maven的package命令打成jar包前端npm run build生成dist目录然后用Nginx托管前端静态文件反向代理/api路径到后端8080端口。后端打包命令很简单mvn clean package -DskipTests打包完会生成target/cinema-ticket-0.0.1-SNAPSHOT.jar这个jar包可以直接用java -jar启动。启动命令nohup java -jar cinema-ticket-0.0.1-SNAPSHOT.jar --spring.profiles.activeprod logs/run.log 21 生产环境的配置我单独放在了application-prod.yml里区别是数据库密码、JWT密钥这些敏感信息用环境变量注入不直接写在配置文件里。Nginx配置server { listen 80; server_name your-domain.com; root /usr/share/nginx/html; index index.html; location / { try_files $uri $uri/ /index.html; } location /api/ { proxy_pass http://127.0.0.1:8080/; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; } }这个配置里try_files那行很关键因为Vue是前端路由刷新页面时Nginx得把请求统统转给index.html否则会404。4.5 Docker部署方案可选如果论文里想突出“容器化部署”可以写一套Docker Compose方案。下面是我项目里的docker-compose.ymlversion: 3 services: mysql: image: mysql:8.0 container_name: cinema-mysql environment: MYSQL_ROOT_PASSWORD: 123456 MYSQL_DATABASE: cinema ports: - 3306:3306 volumes: - ./mysql-data:/var/lib/mysql - ./init.sql:/docker-entrypoint-initdb.d/init.sql redis: image: redis:6 container_name: cinema-redis ports: - 6379:6379 backend: build: . container_name: cinema-backend depends_on: - mysql - redis ports: - 8080:8080 environment: SPRING_DATASOURCE_URL: jdbc:mysql://mysql:3306/cinema SPRING_REDIS_HOST: redis注意docker-compose里服务名mysql和redis会作为容器间通信的host后端配置里的数据库地址要写mysql而不是localhost这个细节很多人第一次写会漏导致后端连不上数据库。5. 开发过程中遇到的高频问题与排查技巧5.1 接口请求CORS跨域报错前端项目跑在8080端口后端API跑在8080端口开发时必然出现跨域问题。我遇到的报错是浏览器提示Access to XMLHttpRequest ... has been blocked by CORS policy。解决办法是在后端加一个全局跨域配置类Configuration public class CorsConfig implements WebMvcConfigurer { Override public void addCorsMappings(CorsRegistry registry) { registry.addMapping(/**) .allowedOriginPatterns(*) .allowedMethods(GET, POST, PUT, DELETE, OPTIONS) .allowedHeaders(*) .allowCredentials(true) .maxAge(3600); } }生产环境部署用Nginx同域部署后这个跨域问题就不存在了但开发时必须配置不然没法调联调。5.2 MyBatis Plus分页查询返回total为0用MyBatis Plus的分页插件时一个很容易踩的坑是没有配置分页拦截器导致分页查询实际返回了全量数据或者total字段一直是0。必须单独注册PaginationInnerInterceptorConfiguration public class MybatisPlusConfig { Bean public MybatisPlusInterceptor mybatisPlusInterceptor() { MybatisPlusInterceptor interceptor new MybatisPlusInterceptor(); interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL)); return interceptor; } }不配置这个拦截器而在页面里调用Page对象你会发现数据全回来了但total的数值是0页面分页组件就会异常显示。5.3 前端上传图片接口返回路径错误管理端上传电影海报时我最初的方案是上传到本地某个目录返回一个相对路径给前端直接显示。但发现刷新以后图片经常加载不出来排查了一天最后定位到是路径拼接问题。最终的解决方案上传接口把文件写到/upload/film/目录返回的路径是/files/film/xxx.jpg然后单独配置一个静态资源映射Configuration public class WebMvcConfig implements WebMvcConfigurer { Override public void addResourceHandlers(ResourceHandlerRegistry registry) { registry.addResourceHandler(/files/**) .addResourceLocations(file: System.getProperty(user.dir) /upload/); } }这样前端拿到的/files/film/xxx.jpg就能被正常映射。部署到服务器时如果不想让文件丢在jar包目录下可以在启动脚本里用环境变量指定绝对路径。5.4 Lombok注解在IDEA中不生效新装好的IDEA里跑Spring Boot项目如果出现大量找不到getter/setter方法的编译错误大概率是没装Lombok插件或者没有开启Annotation Processing。在IDEA的设置里搜索Annotation Processors把Enable annotation processing打勾问题立刻解决。5.5 高并发下座位超卖的模拟与验证写论文时需要一个数据来证明系统的并发控制是有效的我用JMeter做了一轮简单的压测模拟50个线程同时购买同一场次的同几个座位。实测结果中只有1个线程成功创建了订单其余49个线程都收到了“座位已被选走”的提示没有出现超卖。这组数据放在论文的“系统测试”章节非常有说服力。做这个压测时要先清空测试场次的所有订单数据还要确保Redis是启动状态并且没有残留的锁。另外注意JMeter的线程组要设置成“同步定时器”否则50个请求不是同一瞬间打进来的测试效果会大打折扣。6. 论文撰写的结构与要点提炼6.1 论文目录搭建建议如果你选了同一类题目论文结构可以直接按下面这个框架走字数控制在一万五千字左右比较合适第一章 绪论研究背景与意义、国内外研究现状、主要研究内容第二章 相关技术简介Spring Boot、MyBatis Plus、MySQL、Redis、Vue第三章 需求分析可行性分析、功能需求分析、非功能需求分析第四章 系统设计系统总体架构、功能模块设计、数据库设计第五章 系统实现各核心模块的实现过程与界面展示、核心代码列举第六章 系统测试测试环境、功能测试用例、性能测试结果第七章 总结与展望这个结构是标准的软件工程体系导师看到不容易挑剔而且每个章节的内容对应开发过程中产出的文档不用临时编素材。6.2 论文里值得展开的三个技术亮点答辩时评委看的是你有没有自己的思考而不是代码堆砌了多少。我觉得这个系统里最值得在论文里作为亮点展开的是第一个亮点订单状态机设计。用一张状态图描述待支付到已支付的流转、超时关闭、用户退款等路径说明状态变更的触发条件和操作权限。第二个亮点分布式锁解决并发选座问题。这是系统里含金量最高的部分除了写代码实现还需要在论文里画一个时序图展示两个用户同时选同一座位时的请求顺序和结果。第三个亮点动态定时任务关闭超时订单。解释清楚为什么选Scheduled而不是MQ延迟消息从业务体量和部署复杂度两个角度做技术选型分析。6.3 答辩时大概率会被问到的问题结合我答辩时的经验评委们问得最多的是这几类问题提前准备就不会慌JWT和传统Session的区别是什么为什么选JWT座位锁定的策略是怎么设计的如果有人恶意下单不付款怎么办数据库索引在哪些字段上建了为什么如果用户量达到几十万这个系统哪些地方会成为瓶颈这个系统相比美团猫眼还有什么不足每个问题都可以在论文里找到对应的设计依据关键是你要自己能讲清楚“为什么这么设计”而不是停留在“我用了什么技术”的层面。7. 写在最后的经验整个项目从需求分析到部署完成整个过程走下来最大的收获不是会用了Spring Boot而是明白了一个完整的业务系统是怎么从零到一落地的。数据库建模的时候要考虑业务链路写接口的时候要想清楚异常分支部署的时候要理解Linux的基本运维这些在课本上都不会教你但项目会逼着你学会。给后面做类似课题的同学三个建议。第一技术栈选自己熟悉的不要为了追求“高大上”硬上微服务和消息队列能用一个单体解决的事情不要引入两个中间件。第二提前准备好测试数据每个模块做完就截图保存等到写论文的时候你会发现截图素材根本不够用要回头补的体验非常痛苦。第三把部署流程在本地完整过一遍再写部署文档命令一个一个敲版本一个一个记这些细节在最后提交的时候能让你少掉很多头发。最后再分享一个小技巧整个系统开发过程中数据库连接、Redis配置、JWT密钥这类信息一定要一开始就统一管理不要临时改。我中途改过一次数据库密码结果漏改了寝室测试环境的配置文件导致部署后有一半的接口返回500排查到凌晨才找到问题。规范的配置管理真的能帮你在关键时刻保住睡眠。