1. 先想清楚:这个购票系统到底要做什么
如果只是一口气把代码敲完,那它可能只是个“CRUD练习”,但既然项目标题里写了在线电影购票,又基于Spring Boot,背后其实是一整套完整的电商交易闭环。我在帮别人梳理这类项目时,第一件事永远是拉出业务链路:用户从注册登录到选座下单,再到支付出票,管理员从排片到统计票房,中间每一步都是可以展示能力的技术点。
这个系统的受众很明确:正在准备毕业设计的学生、想找Java后端实习的开发者,或者纯粹想用Spring Boot练手的人。它不是什么高不可攀的架构,但做好了,足够体现你对业务的理解深度和对并发、事务、缓存这些核心概念的掌握程度。
先说清楚,一个标准在线电影购票系统,通常包含两条主链路。
用户侧:注册登录、浏览影片列表、查看影片详情、按日期/影院筛选场次、选择座位、提交订单、模拟支付、查看历史订单信息。管理员侧:维护影片信息、管理影厅和场次、处理订单状态、查看基础统计数据。别小看这些模块,把每一个都做到能稳定跑、能演示、能回答追问,难度并不低。
我见过最多的失败案例不是代码写不出来,而是“做了个玩具”——页面能打开,但并发下单直接超卖,数据库表设计得改三轮,答辩时被评委问一句“你这个座位状态怎么保持一致性”就卡住。这篇内容我尽量把从设计到部署的整个链路讲透,每个环节都给到可以直接照抄的思路和参数,而不是泛泛而谈。
2. 数据库设计:一张订单表怎么支撑整套购票逻辑
2.1 核心表结构与字段细节
建表是整个项目的基石,表结构一旦定错,后面代码写再多都是白费。我做这类系统时,核心表基本固定在五张:用户表、影片表、影厅表、场次表、订单表。有些项目会把影厅和场次合并,我建议拆开,因为一个影厅一天会排多个场次,合并容易产生数据冗余。
用户表字段比较常规:主键id、用户名、密码、昵称、手机号、角色标识、创建时间。密码字段必须用加密存储,我推荐用加密算法处理密码,而不是标准MD5或明文。有人会问,为什么不能直接MD5?MD5加固定盐也可以,但在真实项目中,带自适应成本的加密算法更稳妥,因为现代的图形处理器算MD5太快了,暴力碰撞成本极低。Spring Security自带的BCryptPasswordEncoder几行就接入,没必要自己造轮子。
影片表要注意的字段包括:影片标题、海报URL、导演、主演、时长、上映日期、影片简介、状态。海报这个字段很容易被忽略,很多新手把它设计成上传图片文件本身,存到数据库里,这是典型的坏味道。正确做法是:上传的图片保存到服务器某个目录或对象存储,数据库里只存访问路径URL字符串。否则随着影片数量增加,数据库体积会膨胀得很夸张。
影厅表和场次表是绑定关系。影厅表存储名称、座位行数、座位列数。场次表是业务核心,字段包括:所属影片id、所属影厅id、开始时间、结束时间、票价、座位状态快照。这里就暴露了第一个设计分歧:座位状态是动态计算还是快照存储。我的经验是不要为每个座位建一张几千行的大表,而是采用场次表中的“座位状态快照”字段,用JSON字符串存储,例如初始化时生成“A1-0,A2-0,A3-1”这样的结构,0代表可售,1代表已售或者锁定。这样查询场次时一次拿回座位矩阵,非常快,下单时再进行行级更新。
订单表是交易的最终落点:订单号、用户id、场次id、座位信息、订单金额、状态、下单时间、支付时间。这里有一条铁的纪律:金额字段必须用DECIMAL类型,比如DECIMAL(10,2),千万别用double或float。因为浮点数在二进制里表示不精确,涉及金额累计计算时会出现0.1+0.2不等于0.3的尴尬情况,这在金融场景是绝对忌讳的。
我整理了一个最小建表清单,大家在设计阶段可以参考这些字段和类型:
| 表名 | 核心字段 | 类型建议 | 备注 |
|---|---|---|---|
| 用户表 | 主键id、用户名、密码 | BIGINT、VARCHAR(50)、VARCHAR(100) | 用户名唯一索引 |
| 影片表 | 主键id、标题、海报、时长 | BIGINT、VARCHAR(100)、VARCHAR(255)、INT | 状态字段区分上映和下架 |
| 影厅表 | 主键id、名称、座位行/列数 | BIGINT、VARCHAR(50)、INT、INT | 行和列决定总座位数 |
| 场次表 | 主键id、影片id、影厅id、开始时间、票价、座位快照 | BIGINT、BIGINT、BIGINT、DATETIME、DECIMAL(10,2)、TEXT | 座位快照需要设计锁定标记 |
| 订单表 | 主键id、订单号、用户id、场次id、座位信息、金额、状态 | BIGINT、BIGINT、BIGINT、BIGINT、VARCHAR(255)、DECIMAL(10,2)、TINYINT | 订单号加唯一索引 |
2.2 字段设计里那些说不出口的坑
表结构里藏着不少细节,是开发中后期才冒出来的坑。
第一个坑,字符集排序规则。建库时一定要用utf8mb4,而不是utf8。为什么?因为utf8mb4才是真正的四字节编码,支持所有Unicode字符,包括生僻字和emoji。虽然我们开发中不建议使用emoji,但界面上的特殊符号、影片名里的特殊字符都有可能触发存储异常。MySQL里utf8mb4的默认排序规则utf8mb4_general_ci或者utf8mb4_unicode_ci都可以,前者更快,后者更准确,量级不大时选哪个都不影响。
第二个坑,时间字段的时区问题。如果你用MyBatis或MyBatis-Plus,数据库连接串必须在末尾追加serverTimezone=Asia/Shanghai,否则返回的时间比本地时间少8个小时。这个坑我记不清踩了多少次:本地开发时区的配置前缀配好了,一部署到云服务器,数据库在另一台机器上,时区不一致,订单创建时间全乱了。
第三个坑,订单号的生成策略。我有段时间图省事直接用了时间戳,结果同一秒内两个订单撞了唯一索引。虽然概率低,但撞一次就是一次线上事故。推荐做法是:时间戳加随机数拼接,或者干脆引入雪花算法。对单体项目来讲,用Java的UUID去掉横杠再截取一段,配合订单ID回查,基本够用。但要注意,订单号是需要展示给用户的,纯UUID不友好,所以通常是把时间格式化成yyyyMMddHHmmss,再拼一个随机数。
第四个坑,逻辑外键还是物理外键。对于毕业设计级别的项目,我倾向不建物理外键约束。原因很现实:物理外键在增删数据时会产生强约束检查,影响写入性能;同时在校验“某场次已被删除但订单还在”这类场景时,逻辑外键更灵活。建表时只加普通索引,业务层从代码上保证引用完整性即可。面试被问到,就讲得清楚:单体项目里逻辑外键加事务控制,比物理外键更利于性能和解耦。
2.3 初始化数据的组织方式
很多同学的数据库脚本就是一堆建表语句,里面没有任何初始数据。但这会直接影响演示体验。你想想答辩当天,评委要求你登录管理员账号看数据统计,你慌慌张张现场注册一个管理员账号,再手动插入几条影片记录,观感立刻掉一个档次。
所以数据库脚本里至少要有三块内容:第一,建表语句;第二,内嵌的影片、影厅、场次初始数据,比如安排三部上映影片、两个影厅、未来三天的场次;第三,一个管理员账号和一个普通用户账号,密码都要是加密后的密文,而不是明文。这样导入数据库后,系统可以直接演示,不用现造数据。
另外我习惯把数据库脚本拆成三份:schema.sql(建表)、data.sql(初始数据)、demo.sql(演示数据)。这样做的好处是,出问题时可以单独重置某一层,而不是全库重导。脚本里最后还要加上按顺序删除外键依赖的DROP语句,保证可以重复执行。
3. 核心功能实现:从登录到下单的完整链路
3.1 用户认证:JWT方案还是Session方案
从前端页面跑到后端接口,第一个要解决的就是登录认证。这个项目用Spring Boot实现,绕不开两个主流方案:Session和JWT。我自己在这个项目里选了JWT,主要原因是前端通常是独立页面,不跟后端部署在同一台服务器上,前后端分离后Session的跨域处理很麻烦:要配Cookie的SameSite属性,要处理跨域携带凭证,还要考虑分布式环境的Session共享。
JWT的思路是,用户登录成功后,服务端生成一个包含用户身份信息的令牌字符串返回给前端,前端每次请求都在请求头里带上这个令牌,后端通过拦截器解析认证。这套流程无状态、天然支持跨域、也方便后续做分布式扩展。实操时我用的依赖组合是spring-boot-starter-security或者自己写一个拦截器。为了不让项目复杂度太高,我倾向于不用Security全家桶,而是手写一个WebMvcConfigurer注册拦截器。核心代码如下:
public class AuthInterceptor implements HandlerInterceptor { @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { String token = request.getHeader("Authorization"); if (token == null || !token.startsWith("Bearer ")) { response.setStatus(401); return false; } // 解析JWT,验证签名和过期时间 Claims claims = JwtUtil.parseToken(token.substring(7)); if (claims == null) { response.setStatus(401); return false; } request.setAttribute("userId", claims.get("userId")); return true; } }JWT有个必须处理的点:密钥不能硬编码在类里,至少要放到application.yml配置文件里。过期时间我一般设置成24小时,太短用户频繁登录很烦,太长又增加令牌被盗后的风险。如果做“记住我”功能,可以单独签一个长过期时间的令牌,并用Redis保存当前有效令牌的版本号,支持主动踢人下线。
3.2 电影与场次查询:分页、缓存与参数校验
用户打开首页后,第一件事是看影片列表。正常情况下数据量不会太大,但查询接口要做得规范,最基础的是分页查询。用MyBatis-Plus时,分页很好做:引入分页插件,然后调用Page对象传入当前页和每页条数。
有一个细节我单独提醒一下,MyBatis-Plus的分页插件必须通过@Configuration配置类注入,版本不同配置位置也不同。很多人踩过这个坑:分页方法调用后返回的总条数是0,或者查询出的数据是全量而不是分页数据,然后翻Starter源码才发现漏了配置,其实异常信息已经提示“Please configure page interceptor”。只要加一个配置类,注册MybatisPlusInterceptor,添加PaginationInnerInterceptor即可。
查询接口一般会有两个场景:影片列表和场次列表。影片列表可以加一个简单的Redis缓存,key设计成movie:list:page:1,value是JSON字符串,过期时间设60秒。为什么要60秒而不是更长?因为管理员可能会修改影片状态,过长的缓存会带来数据不一致。同时把缓存过期时间设置成一个合理的均衡值,既减轻数据库压力,又不至于让修改长期不生效。极端情况下的正确姿势是“先更新数据库,再删除对应缓存key”,也就是经典的Cache Aside Pattern。
场次查询要按日期筛选。数据库里存的是datetime类型,前端传过来“2025-05-01”,后端查询时不能直接等值比较,而是用BETWEEN。注意MySQL的日期边界问题:BETWEEN '2025-05-01 00:00:00' AND '2025-05-01 23:59:59',如果不拼时间,会漏掉当天的部分场次。我的做法是构造一个LocalDate对象,再转换成当天的开始时间和结束时间,一个边界都不漏。
3.3 选座与下单:并发不超卖的底层逻辑
这是整个项目技术含量最高的部分,也是答辩时评委最爱追问的地方。问题核心是:两个用户同时点同一个场次的同一个座位,系统怎么保证不会都下单成功?
业务逻辑先从最简单的方案说起。用户提交订单时,无非是带上场次id和座位号,后端要做三件事:第一步,从场次表的座位快照字段里解析出当前座位状态;第二步,判断目标座位是否可用;第三步,状态改为锁定,生成订单。如果这三步不加任何并发控制,两个人同时读到“座位可售”状态,就会同时生成订单,造成超卖。
怎么解决?我给三个层次的方案,从易到难。
第一个方案是数据库行锁。查询场次记录时直接加上SELECT ... FOR UPDATE,强制锁定这一行。事务提交前,别人读同一行都会被阻塞。这是最简单、最可靠的方式,但代价是同一场次的所有座位都被锁住,即使买的是A1和B1两个不同座位,也会串行。对本项目的并发量来说,完全够用,而且没有安全问题。
第二个方案是乐观锁。在场次表新增一个version字段,更新座位快照时检查WHERE version = #{oldVersion},如果更新行数为0,说明版本已被别人改过,事务回滚并提示用户重新选座。这个方案不锁行,性能和并发能力都更好,但需要开发者在上层增加重试机制,否则用户操作体验不好。
第三个方案是Redis预占。把座位状态直接维护在Redis里,用Lua脚本原子性完成“判断可售+标记锁定”。这适合真正的互联网级并发,但项目复杂度会明显上升,需要一个分布式锁和缓存同步的完整方案。
我个人的推荐,在这个项目里用第一方案最简单、最稳。看看实际代码:
@Transactional public String createOrder(Long scheduleId, String seatInfo) { Schedule schedule = scheduleMapper.selectByIdForUpdate(scheduleId); // 解析seatInfo,比如 "A1" String[] seatList = seatInfo.split(","); for (String seat : seatList) { if (schedule.isSeatLocked(seat)) { throw new BizException("座位已被锁定"); } } // 生成座位快照并更新 schedule.lockSeats(seatList); scheduleMapper.updateById(schedule); // 创建订单,状态为待支付 Order order = Order.createPendingOrder(...); orderMapper.insert(order); return order.getOrderNo(); }还有一个容易被忽略的点:事务边界。@Transactional注解不能加在private方法上,Spring的AOP代理对其无效,这是最常见的失效场景。另外在同一个类内部,方法自调用也不会走代理,所以如果你写了一个普通方法调用带事务的私有方法,事务依然不会生效。解决办法是分成两个类,或者用注入自身的方式调用。
订单状态的设计,我建议这样管理:待支付、已支付、已取消、已退票。用户下单后,如果有15分钟的支付超时限制,可以用Spring的延时任务或者Redis的过期监听实现,也可以简单在查询时判断下单时间超过15分钟就自动置为已取消。考虑到项目复杂度,用查询时判断加定时扫描表兜底,是性价比最高的方案。
3.4 后台管理:角色权限与数据看板
管理员的入口通常单独做一套页面。权限控制不用上复杂的权限框架,登录返回的用户角色字段就够了。后端对管理接口加一个拦截器,判断当前请求用户的角色是否等于管理员,不等于直接返回403。
数据统计这块,最简单的“今日票房”SQL是统计今日已支付订单的金额总和。要注意状态过滤:只有状态为“已支付”的订单才算收入,待支付和已取消的都不算。同一张票,从待支付到已支付是状态机迁移,而统计是在状态机末端进行。
管理端还需要一个“放映计划”功能,也就是给某部影片安排场次。这里有个业务校验很容易遗漏:同一影厅在同一时间段不能排两个场次,否则座位状态会冲突。后端在创建场次时必须加上时间重叠校验,SQL大概是查询同一影厅下开始时间小于新场次结束时间且结束时间大于新场次开始时间的记录数量,数量大于0就拒绝创建。
4. 实操排坑实录:运行、部署中的典型问题与解决思路
4.1 项目启动异常与依赖问题
Spring Boot项目最常见的启动失败原因有三个。第一是端口被占用,默认8080被其他进程抢了,改server.port设为8081即可。第二是数据库连接失败,检查application.yml里的URL、用户名、密码是否匹配,以及数据库是否已经启动。第三是Maven依赖下载缓慢或者失败,可以在settings.xml里配置一个国内镜像源,这个对新手尤其友好,否则卡在下载依赖环节一整天都进不了项目。
有个很隐蔽的问题,启动时明确报错说缺少某个类的定义,比如ClassNotFoundError,多半是某个依赖没有引入进来,或者版本冲突。排查办法最好用:打开IDEA的终端,执行mvn dependency:tree,看依赖树里有没有冲突。Spring Boot项目最容易出问题的是Lombok版本和Java版本不匹配,如果遇到注解不生效,优先查这个。
4.2 跨域问题:前后端分离时的CORS配置
我把前端页面放在独立端口,后端口又是另一个端口时,前端发起请求会报CORS错误。解决办法是后端配置一个WebMvcConfigurer,实现addCorsMappings方法,允许指定来源、指定请求头和方法。千万别图方便用@CrossOrigin加在单个Controller上,那只能解决单个接口的跨域,还有更多接口要被逐一处理。
正确做法是全局CORS配置:
@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); } }注意,使用allowCredentials(true)时,allowedOrigin不能用号,要用allowedOriginPatterns("")。这是我单独试出来的坑,很多资料没提到。
4.3 上传图书海报后访问不到的问题
很多Spring Boot新手上传海报后,文件确实保存到了本地目录,但浏览器访问却返回404。原因是没有配置静态资源映射。Spring Boot默认只映射classpath下的/static目录,你上传的图片保存在了项目运行目录下的某个文件夹,不在静态资源扫描范围内。
解决办法有两个。最简单的,把上传目录直接放在项目的static/upload下面,重启后就能访问。但生产环境不推荐,因为每次重新部署可能覆盖掉。更专业的做法是配置一个资源映射,将/upload/**路径映射到本机磁盘实际目录:
@Override public void addResourceHandlers(ResourceHandlerRegistry registry) { registry.addResourceHandler("/upload/**") .addResourceLocations("file:" + uploadPath + "/"); }这个坑尤其影响演示体验,一定要在部署前测一遍。
4.4 数据库事务与MyBatis-Plus分页的连带问题
我碰到过一个很典型的现象:开启分页插件后,事务方法里执行了两次查询,第二次查询结果串到了第一次查询的分页数据集上。原因是MyBatis-Plus分页插件的ThreadLocal在处理嵌套查询时产生了上下文污染。解决方法并不复杂:确保分页查询在最外层先执行,避免在循环里查询分页数据,同时使用Page对象的泛型明确指定实体类型。
还有一个高频问题:@Transactional事务不回滚。常见原因是方法内try...catch捕获了异常,导致异常没有传播到事务管理器。记住,事务回滚依赖于异常向外抛出,像RuntimeException或Error才会被默认捕获,捕获吃掉异常等于告诉Spring“一切正常”。
5. 文档撰写与答辩准备:源码之外的另一半分数
5.1 项目文档的章节安排与写作要点
系统有了、代码能跑,这只能保证你及格。文档写得清楚、答辩答得上,才是拉开差距的地方。我辅导过几次毕业设计,很多人的文档是把代码注释复制粘贴过去,读起来跟记流水账一样,评委翻两页就不想看了。
一份好的项目文档,从需求分析开始就要讲清楚“为什么要做这个系统、目标用户是谁、核心业务流程是什么”。接下来是总体设计,用文字加简单的架构分层描述,比如表现层、业务层、数据访问层的关系。详细设计阶段,每个模块要按“输入、处理、输出”三段式写清楚。数据库设计部分,把每张表的字段说明列成表格,重点说明外键关系和索引设计。最后是测试,不要只写“功能正常”,要写测试用例设计:正常流程、异常流程、边界值。
文档里还有一类内容是很多同学漏掉的:非功能需求。比如系统响应时间、并发能力、安全性要求。这些看起来虚,但评委很吃这一套,说明你有工程意识,而不只是写了几个接口。
我的建议是文档和代码开发同步进行,不要等项目快交付才开始写。今天做了登录模块,晚上就把这一章搞定;明天做了下单模块,晚上就把该模块的时序和流程细节补全。否则到时候连续熬夜赶出来的文档,质量和对项目的理解深度一定跟不上代码。
5.2 答辩现场的高频问题与应对思路
答辩最常见的追问,第一个就是“为什么用Spring Boot而不是其他框架?”别急着背网上的标准答案——“Spring Boot简化配置”——要说得更具体:它内置Tomcat,支持自动装配,生态成熟,可以快速搭建独立运行的微服务应用。重点突出“开发效率”和“生态完整”。
第二个高频问题“你怎么解决超卖问题?”如果前面我把数据库行锁方案做扎实了就很容易答:通过SELECT ... FOR UPDATE锁住场次记录,在事务内完成座位状态检查和更新,确保并发下单时只有一个事务能成功修改座位状态。评委如果不满意,你再补充乐观锁和Redis方案的扩展思路,更显得思考层次丰富。
第三个高频问题“数据库为什么这样设计?”答逻辑要从业务出发,比如拆出场次表和影厅表是为了排片灵活,订单表独立是为了支持状态变更与数据统计,金额用DECIMAL是避免浮点误差。只要从业务需求反推设计,评委就能感受到这是你自己思考过的项目,而不是抄的。
第四个问题是“项目有什么不足?”,很多人直接被问懵。一定要准备几条真正的不足,而不是说“没有不足”。比如:支付模块是模拟实现的,没接入真实支付渠道;系统目前只支持一个普通用户端和一个管理端,影厅和院线的多级管理没有扩展;缓存只做到了单机版Redis,没有处理缓存一致性问题。这个回答反而会给评委留下“他知道自己在做什么”的印象。
5.3 部署演示时的软件环境与操作顺序
如果答辩需要现场演示,我的建议是提前准备好一个打包号版本的jar包,并且在本地和云服务器上各跑一遍。用Maven执行mvn clean package -DskipTests打包,然后启动:java -jar xxx.jar。前提是数据库已经初始化好,application.yml里的连接信息指向可用的MySQL实例。
演示操作顺序也很重要。首先打开首页展示影片列表,点击一部影片查看详情,然后切换日期查看场次,选择座位,下单,模拟支付,在“我的订单”里看到已支付状态的订单。紧接着切到管理员账号,展示影片管理页面,新增一部电影,发布新场次,最后打开数据统计页展示订单数和金额变化。整个过程自然流畅,别在没人的时候手忙脚乱地调数据库。
答辩前至少自己完整走两遍流程,把每个按钮的位置、每段跳转的路径都记熟。如果现场网络不好,可以提前把截图放进PPT里作为备用方案,不依赖实时演示。
我个人在实际辅导项目时反复叮嘱过一句话:这个项目做完,你收获最大的绝不是那套CRUD代码,而是“如何把一个相对复杂的业务流程拆解成可落地的模块,再通过技术手段解决其中真实存在的问题”。超卖问题的并发控制、缓存策略的取舍、数据库设计里的字段与索引权衡,这些问题想明白一个,比记住十个框架注解都管用。在线电影购票系统后续还能往上加不少东西,比如基于用户行为的影片推荐、基于场次成交数据的动态定价、更多的订单统计维度,哪怕是一个简单的协同过滤算法,都会让这个项目在同类作品里明显亮眼。真到了那个阶段,你也就不会再去纠结“这个项目值不值得做”这种问题了。