☰
基于SSM的微信小程序电影院订票选座系统设计与实现
2026/10/14 7:39:16 网站建设 项目流程

1. 项目概述与技术选型思考

“基于SSM的微信小程序电影院订票选座系统”,这个名字读起来没什么新鲜感,但真做起来,会碰到许多教科书里不会细讲的问题。它的核心价值在于:用一套轻量经典的Java后端架构,配一个当前流量最友好的前端容器,把影院售票中最容易出错的座位与订单逻辑完整落地。对于计算机专业的学生来说,这是课程设计和毕业设计里性价比极高的一类题目;对于想快速跑通一个轻量售票业务的小团队来说,这也是一套可以复用的原型。

我当时为什么要选这个题目?三点理由。第一,SSM框架(Spring、SpringMVC、MyBatis)是国内Java岗位面试中绕不过去的高频考点,做完这个项目,框架层面的知识基本能串成一条线;第二,微信小程序天然适合影院这种线下高频场景,用户扫码即用,不需要下载App,而且整个开发流程能让你同时掌握前端页面和后端接口对接的完整链路;第三,电影院订票选座比普通的增删改查系统多一层“座位状态和订单状态联动”的复杂度,这也是答辩时的加分点,能让系统从“管理着玩玩”变成“有真实业务逻辑的系统”。

谁适合参考这篇文章?如果你是正在做课程设计的学生,直接照着架构做,能省下大量摸索时间;如果你是有一定Java基础但没做过完整项目的初学者,也可以把它当作一个把SSM和小程序串起来的完整案例。文章中会给出我实际测试过的数据库表设计、接口设计、关键业务逻辑和部署细节,这些内容比大多数网盘里打包的源码更有价值,因为源码可以拷贝,但踩坑经验拷贝不了。

1.1 功能模块如何拆解

一个完整的影院订票系统,功能上分成两端:用户端和管理端。用户端是微信小程序,用户进入后可以查看正在上映的电影列表、电影详情、每日排片场次,点击某个场次后进入选座页面,选好座位生成待支付订单,然后模拟支付并查看自己的历史订单。管理端是后台管理系统,管理员登录后可以对电影信息、影厅信息、场次排片进行维护,也可以查看订单数据和每个场次的上座情况。

这里我想强调一个容易被忽略的点:很多人做课设只实现“下单”和“出票”,却忽略了“排片管理”和“场次状态管理”的联动。我设计的时候加入了一个排片(schedule)的概念,把电影、影厅、放映时间三个维度绑定在一起。影厅和电影都挂在排片下面,选座其实是针对“某一场排片”来选择座位,而不是针对“某个影厅”选择,这样逻辑更贴近真实影院,也自然引入了库存(该场次的剩余座位数)这个概念。

模块拆解之后,开发顺序也有讲究。我的建议是:先做数据库,再做后端接口,最后做小程序页面。很多同学习惯先写前端页面,结果接口定义反复改,前端页面也跟着改,项目越做越乱。反过来先把数据表设计清楚、接口返回结构定下来,前端页面只是数据渲染,开发效率会高很多。

1.2 为什么选SSM而不是SpringBoot

现在的新项目基本都直接用SpringBoot,为什么我这个项目还要强调SSM?一个现实原因是:不少高校的课程设计和毕业设计大纲里,明确要求体现SSM三件套的整合能力,使用SpringBoot会被认为“没体现框架整合过程”。另一个现实原因是,SSM的配置繁琐但原理透明,你能在一行行XML配置中理解DispatcherServlet、MyBatis的SqlSessionFactory、事务管理器到底是怎么装配起来的。一旦弄清楚这些,再用SpringBoot时就会觉得它只是把约定好的东西自动化了,不会有“不懂原理但能用”的黑盒感。

当然,你完全也可以用SpringBoot + MyBatis来实现同样功能,代码量会更少。但从学习价值来说,SSM的整合过程本身就是技能点。我在这篇文章里讲的代码和逻辑,无论是SSM还是SpringBoot都能用,区别只在配置方式。

小程序端的选型理由更加直接:开发成本低、跨平台(安卓和iOS都能跑)、无需下载安装、后续如果要做会员体系、优惠券等功能,微信生态都有现成能力可以对接。相比起开发一个独立的安卓或iOS App,小程序的学习曲线平缓太多,适合一个人在有限时间内完成整个项目。

1.3 数据库设计:从ER到建表

数据库是这个系统的底座。如果表设计不好,后面业务逻辑会别别扭扭。我最终沉淀下来的是六张核心表:用户表、电影表、影厅表、排片表、座位表、订单表。

用户表负责存储微信用户的openid、昵称和头像;电影表存电影的基础信息,如片名、海报、简介、时长和上映日期;影厅表定义影厅名称、行数和列数;排片表把电影和影厅以及放映时间关联,同时记录该场次的基础票价;座位表按影厅生成所有座位,并记录每个座位在某一场次的实时状态;订单表记录用户购买行为,关联排片和若干个座位ID。

这几张表的最大设计难点在于“座位状态字段怎么关联场次”。我采用的方案是:seat表本身挂在hall_id下,但在查询某个场次的座位时,通过排片ID找到影厅,再读取该影厅下全部座位的状态,状态在业务过程中更新。这样做的优点是座位物理信息只存一份,不会随场次增加而膨胀;缺点是需要额外维护“当前场次下某个座位是否被占用”的状态。如果你把seat表直接挂在schedule_id下,每个场次都复制一份座位,逻辑会更直观,但数据冗余严重。课设层面我推荐前者,因为表数量更少,整体结构也更好讲。

关键还在于status字段的取值和变化。我定义0代表空闲、1代表已售、2代表锁定。新增的“锁定”状态是专门给“用户选座后、支付前”这个时间段设计的,没有它就没法防止两个用户同时抢到同一个座位,这是整个项目里最重要的一个设计点。

2. SSM后端核心实现

2.1 工程结构与环境搭建

我使用的开发环境是JDK1.8、Maven3.6、Tomcat8.5、MySQL5.7。IDE用IDEA。工程是传统的war包结构,用Maven管理依赖。pom.xml里引入的核心依赖包括:spring-webmvc、spring-jdbc、mybatis、mybatis-spring、mysql-connector-java、druid、jackson-databind、javax.servlet-api,以及lombok。Lombok能减少实体类的getter/setter代码,但我建议你在答辩前多看一眼生成的字节码,至少要知道它帮你做了什么。

工程目录按三层结构拆:controller、service、mapper(DAO),另外有entity和dto。entity是与数据库表一一对应的实体类,controller层的请求参数和返回结构统一用dto管理,不直接用entity接收前端数据。原因很简单:前端传入的参数往往只是实体类的一部分,直接用entity接收会造成字段冗余,也不够安全。

Web层需要配置web.xml里的DispatcherServlet和CharacterEncodingFilter。CharacterEncodingFilter一定要放在过滤器链最前面,否则中文乱码还是会冒出来。我遇到过不止一次:数据库里中文正常,页面接口返回中文乱码,排查半天后发现问题是MySQL连接URL没加characterEncoding=utf8。连接池配置里还要加上useSSL=false和serverTimezone=Asia/Shanghai,不然启动时会直接报时区错误。

Spring配置文件applicationContext.xml里主要配置数据源、SqlSessionFactory和事务管理器。如果用Druid连接池,配置项包括initialSize、minIdle、maxActive以及testWhileIdle等。SpringMVC配置文件spring-mvc.xml里配置包扫描、注解驱动和JSON消息转换器。许多教程把Spring和SpringMVC的配置文件混在一起,新手特别容易搞混,我习惯拆开,逻辑清晰,排错也快。

2.2 Mapper接口与SQL编写细节

MyBatis的Mapper层我采用接口加XML的方式。SeatMapper接口里定义几个核心方法:selectByScheduleId、batchUpdateStatusByIds、lockSeatsByIds等。对应的XML里写SQL时,最需要注意的是动态SQL的foreach标签和带条件的更新语句。

以“批量锁定座位”为例,SQL核心是这样:

<update id="lockSeatsByIds"> UPDATE seat SET status = 1 WHERE status = 0 AND id IN <foreach collection="list" item="item" open="(" separator="," close=")"> #{item} </foreach> </update>

如果ids集合为空,foreach生成的条件会是IN (),这是非法SQL,所以调用前必须判空。这是我在联调时踩过的坑。另外尽量别用WHERE 1=1这种方式拼条件,既不优雅也可能影响索引使用,MyBatis提供了 标签,可以自动处理多余的AND和OR。

在写SQL时还要注意一个细节:不要用SELECT *,哪怕课设项目也要养成指定字段的习惯。一方面是为了防止返回字段顺序变化引发问题,另一方面是前端不需要的数据传回去只会增加网络开销。座位表如果多查了一个无用字段,小程序端做渲染也会多一步筛选。

2.3 Service层:业务规则与事务控制

Service层是业务逻辑的核心。用户下单购票的流程在Service里要调用多个Mapper方法,涉及多张表更新,因此必须加事务。以创建订单为例,核心代码大致是这样:

@Service public class OrderServiceImpl implements OrderService { @Autowired private SeatMapper seatMapper; @Autowired private ScheduleMapper scheduleMapper; @Autowired private OrderMapper orderMapper; @Transactional(rollbackFor = Exception.class) public Order createOrder(Integer userId, Integer scheduleId, List<Integer> seatIds) { // 1. 校验座位是否可售 List<Seat> seats = seatMapper.selectByIds(seatIds); for (Seat seat : seats) { if (seat.getStatus() != 0) { throw new BusinessException("座位已售或已锁定"); } } // 2. 锁定座位 int rows = seatMapper.lockSeatsByIds(seatIds); if (rows != seatIds.size()) { throw new BusinessException("部分座位已被抢占"); } // 3. 扣减排片余票 int updated = scheduleMapper.decreaseRemainingSeats(scheduleId, seatIds.size()); if (updated == 0) { throw new BusinessException("余票不足"); } // 4. 创建订单 Schedule schedule = scheduleMapper.selectById(scheduleId); Order order = new Order(); order.setOrderNo(generateOrderNo()); order.setUserId(userId); order.setScheduleId(scheduleId); order.setSeatIds(seatIds.stream().map(String::valueOf).collect(Collectors.joining(","))); order.setTotalPrice(schedule.getPrice() * seatIds.size()); order.setStatus(0); orderMapper.insert(order); return order; } }

这里有几个细节值得展开说。第一,扣减余票用的update语句要带条件remaining_seats >= #{count},如果返回行数是0说明余票不足,必须在事务里回滚之前锁定的座位。第二,锁座用的是带status=0条件的更新,返回的行数如果和传入的ids数量不一致,说明部分座位已经被别人锁走了,同样要回滚。第三,生成订单编号时我用时间戳加随机数,课设场景够用;如果追求更严格,可以用雪花算法或Redis自增。

@Transactional的原理值得弄清楚:Spring通过AOP生成Service类的代理对象,方法执行前开启事务,成功后提交,异常时回滚。但如果方法内部调用同类中的另一个方法,实际调用的是this对象而不是代理对象,事务注解就不会生效。这个问题非常隐蔽,我在后面的常见问题里会再展开。

2.4 Controller设计与统一返回结构

前后端分离时,所有Controller方法都返回JSON。为了联调方便,我定义了一个统一的Result类结构:

public class Result<T> { private Integer code; private String msg; private T data; }

code为200表示成功,400表示参数错误,500表示服务端异常,501表示座位被占用,502表示订单超时等。业务异常通过自定义BusinessException抛出,再由全局异常处理器捕获后封装成对应code的JSON返回给小程序端。这样做的好处是:前端不需要解析五花八门的错误提示,只需判断code,然后展示msg即可。

Controller层的接口要尽量RESTful,但又不必过度设计。我实际用的接口列表大致是:

  • POST /user/login,传入wx.login获取的code,后端换取openid并完成注册登录。
  • GET /movie/list,查询所有上映的电影。
  • GET /movie/detail?id={id},查询电影详情和排片列表。
  • GET /schedule/list?movieId={id},查询场次。
  • GET /seat/list?scheduleId={id},查询某场次的座位图。
  • POST /order/create,提交选座订单。
  • POST /order/pay,模拟支付并确认订单。

现在写接口文档也不建议非要引入Swagger,但至少要把接口路径、入参、出参写在项目README里。很多同学到最后答辩时说不清自己系统有哪些接口,就是因为缺少文档。README写清楚了,复盘和答辩都轻松很多。

3. 微信小程序端开发

3.1 小程序页面架构

小程序端我规划了四个主页面:首页、影片、订单、我的。用微信小程序的tabBar来切换。首页放推荐电影入口;影片页做分类筛选;订单页展示当前用户的历史订单列表,区分待支付、已支付、已完成状态;我的页面放头像、昵称和设置项。

每个页面由wxml、wxss、js、json四个文件组成。wxml负责模板渲染,wxss负责样式,js负责页面逻辑和数据请求,json负责页面配置。公共的部分可以抽成自定义组件,比如电影卡片、场次列表、座位格子,这些会在多个页面复用。

在使用小程序自定义组件时需要注意:组件内部数据变化不会自动通知父页面。我采用的方案是:组件触发事件,父页面通过事件回调刷新数据。微信官方也推荐这种单向数据流的方式,比在组件里直接调用父页面方法更清晰。如果你要做一个可复用的选座组件,这个设计就特别重要。

3.2 登录态与request封装

小程序端登录走的是wx.login流程。用户点击登录后,前端调用wx.login获取临时code,再调用后端/login接口,后端用code向微信服务器换取openid和session_key,并生成一个自定义的登录标识返回给前端。前端把这个标识存到本地Storage中,后续请求都带上它。

请求封装上,我写了一个request.js工具模块,把所有接口请求统一走这个封装。它做三件事:一是合并公共参数(比如用户token);二是对返回结果统一判断code,code不是200时弹出Toast提示并reject;三是抽离加载状态,请求发起时如果配置showLoading则自动弹出加载动画。这个封装在多页面使用后,每个页面请求接口的代码能减少三分之一,排查问题时也能在函数入口加日志统一观察。

封装request.js的代码大致是:

const request = (url, method, data, showLoading = true) => { if (showLoading) { wx.showLoading({ title: '加载中' }); } return new Promise((resolve, reject) => { wx.request({ url: BASE_URL + url, method: method, data: data, header: { 'Content-Type': 'application/json', 'Authorization': wx.getStorageSync('token') || '' }, success: (res) => { if (res.data.code === 200) { resolve(res.data.data); } else { wx.showToast({ title: res.data.msg, icon: 'none' }); reject(res.data); } }, fail: (err) => { wx.showToast({ title: '网络异常', icon: 'none' }); reject(err); }, complete: () => { if (showLoading) { wx.hideLoading(); } } }); }); };

3.3 选座页面的数据与渲染

选座页是这个系统最核心的页面。页面加载时,根据当前场次ID调用座位查询接口,拿到该场次所有座位的状态。数据格式可以设计成:

{ "rows": 8, "cols": 10, "seats": [ {"seatId": 1, "row": 1, "col": 1, "status": 0}, {"seatId": 2, "row": 1, "col": 2, "status": 1} ] }

页面wxml里,我用两层循环渲染座位网格。外层循环行,内层循环列,每一格根据座位状态设置不同的css类,分别对应可售、已售、锁定、选中几种状态。点击逻辑上,只有可售座位可以点击,点击后座位状态改为选中,并把座位ID加入待选数组;再次点击取消选中。

这里的实现要点是:前端必须维护一个按行和列映射的座位对象结构,方便点击时快速定位,而不是每次点击都遍历整个座位数组。虽然座位数量不大,但从小程序渲染性能角度看,10行20列以内用双层循环完全没有问题;即使超过这个规模,也可以在wxml里用wx:key优化列表渲染。

选座页面在交互上还要处理几个细节:用户已选数量限制(通常一个订单最多选5张票);底部栏实时显示总价;确认选座按钮在未选择任何座位时置灰禁用。这些交互虽然简单,但体现的是项目完成度,答辩时观感差异很大。

3.4 订单与支付页面的细节

选座完成后,用户点击“确认选座”进入订单确认页。确认页展示电影名、场次时间、影厅座位号、总价等信息。这里要特别注意:总价必须由后端根据排片表的价格和座位数量计算,而不是小程序端计算后传给后端。否则用户只要改一下前端参数,就能用错误金额下单。这个安全点很容易被忽略,但答辩时老师极可能问到。

支付环节我做了模拟支付。用户点击支付后,前端调用后端/pay接口,后端先把订单状态改为已支付,再把对应座位状态改为已售。如果未来接入真实微信支付,流程是:后端生成预支付单,小程序调用wx.requestPayment拉起支付面板,支付成功后微信服务器回调后端接口,后端再更新订单状态。核心逻辑一致,只是多了一步微信支付API对接。

订单创建后,后端设置15分钟支付有效期。小程序端展示倒计时,倒计时结束后自动取消订单并释放座位。实现上,后端用定时任务扫描超时未支付的订单,把对应座位的状态重置为可售,把订单状态改为已取消。前端倒计时只是辅助展示,最终数据一致性以后端为准,这个原则要记牢。

4. 核心业务逻辑:座位状态机与订单流转

4.1 座位的状态机设计

座位状态变化是一次完整的售票闭环。初始状态是空闲,用户选座后从空闲变为锁定,支付成功后从锁定变为已售;如果用户支付超时或主动取消订单,锁定状态回到空闲;如果后台执行退票,已售状态回到空闲。这些状态之间不是随意切换的,必须由后端在对应业务节点触发。

我建议把状态机画在纸上再动手写代码。不夸张地说,把状态机搞清楚,整个项目的代码量能减去不少,因为许多判断都变成简单的状态校验。座位状态机可以这样描述:

  • 空闲(0):可以被用户选中。
  • 锁定(1):用户已选但未支付,不可被其他人选中,超时自动释放。
  • 已售(2):支付完成,座位不可再选。
  • 从已售恢复空闲,只发生在退票场景。

再强调一次:为什么不能用boolean加“已售/未售”两个状态?因为用户支付前有一个时间窗口,座位必须被“临时占用”,但尚未真正售出。没有锁定态,就无法阻止两个用户同时看到同一个座位都显示可售,也就无法防住超卖。这个设计点面试和答辩都爱问,大家要能讲清楚。

4.2 防超卖与原子更新

并发场景下,两个用户同时提交同一个座位的订单,后端如果“先查询再更新”,两个请求都会读到同样的空闲状态,然后都进入下单流程,导致超卖。解决办法就是使用带条件的SQL原子更新:

UPDATE seat SET status = 1 WHERE id = #{seatId} AND status = 0

这行SQL由数据库的行级锁保证:多个并发请求同时执行时,只有第一个能成功更新,其他请求受影响行数为0,据此判断该座位已被抢占。不需要分布式锁,不需要消息队列,在单库单表的课设场景下,这是最高效也最正确的方案。

类似地,扣减排片余票也用带条件的原子更新:UPDATE schedule SET remaining_seats = remaining_seats - #{count} WHERE id = #{scheduleId} AND remaining_seats >= #{count}。如果更新行数为0,说明余票不足,整个下单流程回滚。

这里我想多讲一句:很多课程设计项目用同步锁(synchronized)或数据库表锁来解决并发问题,虽然能跑通,但本质上是不了解数据库并发控制。用条件更新配合行锁,既高效又能讲出原理,是更优的选择。

4.3 订单状态机与超时释放

订单表的状态我定义为:0待支付、1已支付、2已取消、3已完成。待支付状态下用户可以主动取消,也可以等待超时自动取消;已支付状态下用户可以选择退票,退票后座位恢复空闲、订单变为已取消;已完成状态用于标记观影结束后或核销后的订单。

超时释放的实现有两种常见方案:定时任务扫描或延迟队列。课设项目我推荐用最简单的定时任务:每30秒扫描一次订单表,把创建时间超过15分钟且状态为待支付的订单改为已取消,同时对应座位释放。这个定时任务用Spring的@Scheduled注解实现即可。

但要注意:如果项目部署在多实例上,定时任务会重复执行。不过因为有原子更新兜底,重复执行最多只是更新0行,不会造成数据错误。课程设计阶段部署单实例,问题不大。

订单超时清理的核心逻辑:

@Scheduled(fixedRate = 30000) @Transactional(rollbackFor = Exception.class) public void cancelTimeoutOrders() { LocalDateTime deadline = LocalDateTime.now().minusMinutes(15); List<Order> timeoutOrders = orderMapper.selectTimeoutOrders(deadline); for (Order order : timeoutOrders) { orderMapper.updateStatusById(order.getId(), 2); seatMapper.releaseSeatsByOrderSeatIds(order.getSeatIds()); } }

4.4 选座、下单、支付全流程串讲

把整个流程串起来看:

用户在小程序浏览影片和场次后进入选座页,选座页从后端拉取座位状态并渲染。用户选中若干座位后点击确认下单,前端把排片ID和座位ID数组传给后端。后端进入createOrder方法,在一个事务里完成座位校验、锁座、扣余票、生成订单。返回给前端订单ID和待支付状态。前端跳转支付页,点击支付后调用后端/pay接口,后端在同一事务里更新订单状态为已支付、更新座位状态为已售。若15分钟内未支付,定时任务将订单取消、座位释放。

这个过程在每个环节都保持数据一致。我在开发时也踩过“先更新座位再更新订单”的坑,回滚不完整导致数据不一致,后来用@Transactional(rollbackFor = Exception.class)解决,同时在关键更新语句处检查受影响行数。回滚这个点,别忘了默认情况下RuntimeException才触发回滚,受检异常不会,所以rollbackFor = Exception.class更安全。

5. 部署、联调与常见问题排查

5.1 本地启动与联调环境配置

本地跑通整个项目的步骤是这样的:先启动MySQL并导入SQL脚本,然后修改数据库连接配置;再启动Tomcat或使用IDEA内置的Tomcat运行后端项目;后端启动后,在微信开发者工具中导入小程序端代码,修改request.js中的baseURL为http://localhost:8080/你的项目名;最后在开发者工具中勾选“不校验合法域名”,就能直接在模拟器里体验完整流程。

需要注意一个痛点:模拟器里用localhost可以访问本机服务,但手机真机调试时必须使用电脑的局域网IP,比如http://192.168.x.x:8080/项目名。前后端联调时,一定要保证手机和电脑在同一个局域网内,否则请求会被拒绝。我第一次真机调试时忘了这一条,折腾了一下午才发现是IP的问题。

5.2 小程序域名校验问题

开发阶段可以在开发者工具里勾选“不校验合法域名”,但在真机预览和上传体验版时,微信会强制校验域名。课程设计阶段一般没有独立域名和HTTPS证书,最可行的方案是用开发者工具的“预览”功能,通过手机扫码在开发版里运行,配合“不校验域名”的选项,真机也能调通本地接口。

如果未来要正式上线,必须准备备案域名,并给后端配置HTTPS证书。小程序后台还需要把域名添加到request合法域名白名单中。这个过程需要时间,建议提前准备,不要临近答辩才开始弄。

5.3 Druid连接池空闲连接失效问题

这个问题我实际遇到过:系统跑了一晚上,第二天早上第一条请求总是报数据库连接失败,重启后又恢复正常。排查后确认是MySQL的wait_timeout默认8小时,连接空闲后会被服务端断开,而Druid连接池没有及时清理和重建失效连接。

解决办法是配置连接池的testWhileIdle和validationQuery:

minIdle: 5 maxActive: 20 testWhileIdle: true validationQuery: SELECT 1 timeBetweenEvictionRunsMillis: 30000

这样连接池会在空闲连接被取出前发一个SELECT 1验证,如果连接已失效就自动重建。这个问题非常隐蔽,分享出来是希望大家少走弯路。

5.4 前端缓存导致座位状态不一致

一次联调中我发现:座位在后台已经显示已售,但小程序选座页刷新后依然显示可售。排查后原因是前端在查询场次座位后把数据写到了全局缓存,选座页再次进入时没有拉取新接口,而是直接读了旧数据。

解决方法是给接口加一个timestamp或随机数参数,强制每次进入选座页都发起新请求;或者在用户执行下单、支付等操作后主动清除对应场次的缓存。这个问题的教训是:缓存策略必须与数据的实时性需求匹配,影院座位这种高实时性数据,不适合做长期缓存。

5.5 微信登录code2session的常见坑

wx.login获取的code有效期只有5分钟,且只能使用一次。如果后端在换取openid时因为网络波动或参数错误失败了,拿同一个code再重试,微信会返回错误码。正确做法是:前端获取到code后立即传给后端做一次交换;失败则重新执行wx.login获取新code,再走一遍流程。

另外,微信会返回session_key,这是会话密钥,不要把它返回给前端,也不要在服务端日志里打印。课程设计可能没有严格要求,但从安全习惯培养的角度,这一步值得留意。

5.6 事务失效的几种诱因

@Transactional失效是我在答疑群里见过最多的问题。常见诱因有三个。

第一个是Service类没加@Service或者没被包扫描扫到,Spring根本没有创建代理对象。第二个是同类内部方法调用,比如this.cancelOrder()调用另一个加了@Transactional的方法,此时事务控制不生效,解决办法是把涉及事务的逻辑拆到不同Service里,或者通过AopContext.currentProxy()获取代理对象再调用。第三个是Spring事务管理器配置的数据源与MyBatis使用的不是同一个Druid数据源,事务管理器监控不到连接,自然无法回滚。

遇到事务不生效时,最快的排查方法是在方法入口打印日志输出当前代理类名,看看是不是CGLIB代理类。如果是原始类,说明代理没有生成,事务肯定不生效。

5.7 跨域与JSON序列化问题

虽然微信小程序不存在传统浏览器同源策略的跨域限制,但如果有人在浏览器控制台里调试后端接口,还是可能遇到跨域。解决方法是后端加一个CorsFilter或在SpringMVC里配置CorsRegistry,允许指定域名的跨域请求。这个配置不影响小程序请求,但能让浏览器联调顺畅。

JSON序列化方面,前端如果接收到日期字符串,需要手动格式化。我的建议是后端把日期字段统一格式化为字符串返回,例如yyyy-MM-dd HH:mm:ss,前端直接展示,省去解析。常用的做法是在DTO里直接用String类型接收时间,或配置Jackson的date-format,让序列化时自动转换。

5.8 管理端的权限控制思路

用户端小程序和管理端后台共用同一套后端接口,权限如何区分?我的做法比较朴素:用户端请求使用openid标识用户,管理端接口额外校验管理员token,管理员登录后由后端生成token并存在请求头中。后端通过拦截器统一校验,没有token或token失效的请求直接返回401。

这个设计比引入一整套Spring Security或Shiro轻量得多,对课程设计完全够用。如果后续要扩展,再把Shiro整合进行,业务代码几乎不用改。答辩时如果老师问权限设计,这个思路答起来有理有据。

6. 项目扩展方向与个人心得

个人做完这个项目最大的体会是:“订票选座”这个需求看着简单,但把它做到业务闭环完整、数据一致,需要很强的逻辑能力。很多初学者拿到源码就急着跑起来,却不理解每一处设计背后的原因,结果换个场景就不会做了。我强烈建议在动手前先自己画一遍数据库表关系图和状态流转图,再对照已经跑通的代码验证理解。这个过程花费的时间不多,但收益是全方位的。

扩展方向上,比较有实际价值的三个方向是:接入真实微信支付实现商业化闭环;增加后台统计报表(上座率、热门时段、影片热度排行);增加营销能力(优惠券、会员积分、拼团购票)。这三个方向任选一个深入,都能把课设项目提升到接近工程化的水平。

最后分享一个调试小技巧:给后端写一个简单的操作日志切面,记录每个HTTP请求的路径、参数、返回码和耗时。小程序端接口联调时,出现问题看日志就能定位到是前端传参错误还是后端逻辑错误,比在代码里到处打debug断点高效得多。我曾经靠这段日志,在十分钟内定位了一个“座位被重复锁定”的并发问题,节省了大量排查时间。

我在实际交付这个项目时也体会到,把README写清楚同样重要。环境版本、启动步骤、默认账号、接口列表、数据库脚本说明,这五部分写完整,既是答辩时的演示补充,也是日后复盘项目的重要资料。整体做下来,你对Spring容器、MyBatis会话管理、微信小程序生命周期,以及事务和并发控制的理解都会明显加深。这套经验放到任何Java相关岗位上都用得着。

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

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

立即咨询