简介:本资源是一套完整的计算机专业毕业设计项目,聚焦微信小程序端电影院订票与选座功能实现,采用SSM(Spring+SpringMVC+MyBatis)后端架构与微信原生小程序前端技术栈,适用于本科毕设、课程设计及工程实训场景,尤其适合具备Java Web与小程序开发基础的学习者进行二次开发与功能拓展。压缩包共1227个文件,含111个Java后端核心类、132个Vue组件(用于管理后台)、175个JS逻辑脚本、86个WXML页面结构、88个WXSS样式文件、232张界面截图及2个可直接导入的SQL数据库脚本,整体体积38.14MB,结构清晰、模块分离明确。已有109人学习下载,资源经实测可直接运行,涵盖登录、影片浏览、座位可视化选择、订单生成与支付模拟等全流程功能,配套论文文档便于答辩准备,同时提供bat一键安装/启动脚本及备份文件(.bak),显著降低环境配置门槛与调试成本。
1. 这不是“微信小程序商城模板”,而是一个基于 SSM 框架、完整闭环的电影院订票选座业务系统
很多计算机专业学生拿到“微信小程序+SSM”这类毕设标题时,第一反应是去 GitHub 搜个带后台管理的电商小程序套壳——但真正能过答辩、经得起老师现场提问的,恰恰是像“068电影院订票选座”这样业务边界清晰、数据流向可追溯、技术栈耦合合理的垂直场景系统。它不追求大而全的小程序商城功能,而是聚焦「座位状态实时校验」「场次-影厅-座位三级关联」「微信支付回调与订单状态同步」三个硬核环节。前端用原生微信小程序(非 uni-app 封装),后端用 Spring + SpringMVC + MyBatis 组合(即 SSM),数据库用 MySQL(非 SQL Server,标题中 sql 指建表语句而非数据库类型),所有模块都围绕“用户选座不可重复”这一核心约束展开。适合需要展示真实业务建模能力、前后端联调经验、以及对事务边界理解深度的本科毕设场景——尤其当答辩老师问“你怎么保证两个用户同时点同一个座位不会超卖?”时,你能立刻说出SELECT ... FOR UPDATE的加锁时机和@Transactional的传播行为,而不是只答“用了 Redis 缓存”。
2. 用 SSM 搭建订票后台:为什么选 MyBatis 而不是 JPA,以及三层结构如何对应影院业务实体
2.1 选型依据:MyBatis 对复杂查询与手写 SQL 的不可替代性
在电影院订票场景中,“查某场次剩余可选座位”不是简单WHERE status = 0能解决的。它需联合movie_schedule(场次表)、cinema_hall(影厅表)、seat_layout(座位布局表)三张表,按影厅座位矩阵(如 10 行 × 15 列)动态生成已售/可售/锁定状态,并排除已被其他用户暂存但未支付的座位(即“选座锁定期”)。JPA 的@Query或 Criteria API 在处理这种带行列计算、状态聚合、分页渲染的查询时,SQL 可读性差、调试成本高;而 MyBatis 允许直接编写带<foreach>动态拼接、<choose>条件分支的 XML 映射,且能精准控制执行计划。例如以下查询片段:
<!-- mapper/SeatMapper.xml --> <select id="selectAvailableSeatsByScheduleId" resultType="com.example.seat.SeatVO"> SELECT s.id AS seat_id, s.row_num, s.col_num, CASE WHEN o.id IS NOT NULL AND o.status = 'LOCKED' THEN 'locked' WHEN o.id IS NOT NULL AND o.status = 'PAID' THEN 'sold' ELSE 'available' END AS status FROM seat_layout s LEFT JOIN order_seat os ON s.id = os.seat_id LEFT JOIN `order` o ON os.order_id = o.id AND o.schedule_id = #{scheduleId} WHERE s.hall_id = ( SELECT hall_id FROM movie_schedule WHERE id = #{scheduleId} ) ORDER BY s.row_num, s.col_num </select>提示:此处
LEFT JOIN关联订单是为了区分“已售”和“被锁定”两种状态,避免仅靠seat_layout.status字段导致状态歧义。MyBatis 的 XML 方式让这种多状态判断逻辑一目了然,比注解式更易维护。
2.2 SSM 分层设计:Controller → Service → Mapper 如何映射影院业务动作
SSM 的分层不是为分而分,而是为隔离变化。以“用户提交选座请求”为例:
Controller 层(
OrderController.java)只做三件事:校验微信 openid 是否合法、解析前端传来的座位 ID 数组、调用 Service 方法。不处理任何业务规则。Service 层(
OrderService.java)承担核心业务逻辑:
① 根据scheduleId查询场次是否可售(检查开始时间、余票数);
② 对传入的seatIds执行批量SELECT ... FOR UPDATE锁定座位(防止并发冲突);
③ 创建订单主记录并插入order_seat关联表;
④ 调用微信统一下单接口获取prepay_id;
⑤ 若支付创建失败,回滚整个事务(@Transactional(rollbackFor = Exception.class))。Mapper 层(
OrderMapper.xml)只负责 SQL 执行,不包含任何 Java 逻辑。例如锁定座位的语句必须显式声明FOR UPDATE:<update id="lockSeats"> UPDATE seat_layout SET status = 'locked' WHERE id IN <foreach collection="seatIds" item="id" open="(" separator="," close=")"> #{id} </foreach> AND status = 'available' </update>
注意:
status = 'available'是乐观锁条件,确保只锁定当前空闲座位。若并发请求中某座位已被他人锁定,该UPDATE影响行数为 0,Service 层需捕获并抛出“座位已被占用”异常,而非静默失败。
2.3 数据库表设计:为什么seat_layout必须冗余存储row_num和col_num
很多初学者将座位抽象为纯 ID,靠前端计算行列位置。但在订票系统中,座位的物理坐标是业务强约束:
- 后台需按行列生成座位矩阵图(供小程序渲染);
- 用户投诉“选了第 5 排第 3 座却坐到第 3 排”时,必须能通过
row_num=5 AND col_num=3精确定位; - 影厅改造后调整座位布局,只需更新
seat_layout表,无需改代码。
因此seat_layout表结构必须包含:
| 字段名 | 类型 | 说明 |
|---|---|---|
id | BIGINT PK | 座位唯一标识 |
hall_id | BIGINT FK | 所属影厅 |
row_num | TINYINT | 行号(1~12) |
col_num | TINYINT | 列号(1~20) |
status | VARCHAR(10) | 'available'/'locked'/'sold' |
而movie_schedule表需关联hall_id,order_seat表则记录order_id与seat_id的多对多关系。这种设计使“查某场次所有座位状态”只需一次 JOIN,避免前端反复请求单个座位信息。
3. 微信小程序前端:如何用原生 API 实现选座交互与支付闭环
3.1 座位选择 UI:Canvas 渲染 vs WXML 布局的取舍
小程序中渲染影厅座位图有两种主流方式:
WXML + CSS Grid:适合座位数 ≤ 200 的中小型影厅。用
<view wx:for="{{seats}}" class="seat {{item.status}}">动态生成,通过class控制颜色(.available{background:#4CAF50}/.locked{background:#FFC107})。优点是开发快、易调试;缺点是 DOM 节点过多时滚动卡顿(实测 > 300 个<view>会触发性能警告)。Canvas 绘制:适合大型影厅或需自定义高亮动效的场景。先用
wx.createCanvasContext('seatCanvas')获取上下文,再循环调用fillRect()绘制每个座位方块,用drawImage()加载“已售”图标。关键代码如下:
// pages/seat/seat.js onLoad() { this.drawSeatMap() }, drawSeatMap() { const query = wx.createSelectorQuery() query.select('#seatCanvas').fields({ node: true, size: true }).exec((res) => { const canvas = res[0].node const ctx = canvas.getContext('2d') const dpr = wx.getSystemInfoSync().pixelRatio canvas.width = res[0].width * dpr canvas.height = res[0].height * dpr ctx.scale(dpr, dpr) // 遍历 seats 数组绘制 this.data.seats.forEach(seat => { const x = (seat.col_num - 1) * 30 + 20 const y = (seat.row_num - 1) * 30 + 20 ctx.fillStyle = seat.status === 'available' ? '#4CAF50' : seat.status === 'locked' ? '#FFC107' : '#F44336' ctx.fillRect(x, y, 24, 24) ctx.fillStyle = '#fff' ctx.font = '12px sans-serif' ctx.fillText(`${seat.row_num}-${seat.col_num}`, x + 4, y + 17) }) }) }提示:Canvas 方案必须配合
wx.canvasToTempFilePath()实现座位图分享功能,而 WXML 方案无法截图——若毕设要求“分享选座结果”,Canvas 是刚需。
3.2 支付流程:从wx.requestPayment到订单状态轮询的完整链路
微信小程序支付不是调一个 API 就结束。标准流程需覆盖:
- 小程序端调用
wx.login()获取code,传给后台换取openid; - 后台调用微信统一下单接口(
https://api.mch.weixin.qq.com/pay/unifiedorder),传入body(电影名称)、out_trade_no(订单号)、total_fee(单位:分)、notify_url(支付结果回调地址); - 后台返回
prepay_id,小程序用wx.requestPayment()发起支付; - 支付成功后,微信服务器异步通知
notify_url,后台更新订单状态为PAID; - 小程序端不能依赖回调即时刷新,需启动
setInterval每 2 秒调用GET /order/status?orderId=xxx查询状态,直到返回PAID或TIMEOUT。
关键点在于notify_url的幂等处理:微信可能多次推送同一笔支付结果,后台必须用SELECT ... FOR UPDATE锁定订单记录后再更新状态,避免重复扣款。
// OrderController.java @PostMapping("/notify") public String handlePayNotify(HttpServletRequest request, HttpServletResponse response) { try { // 解析微信回调 XML String xmlData = IOUtils.toString(request.getInputStream(), "UTF-8"); Map<String, String> notifyMap = WXPayUtil.xmlToMap(xmlData); // 幂等校验:查订单是否存在且状态非 PAID Order order = orderService.findById(notifyMap.get("out_trade_no")); if ("SUCCESS".equals(notifyMap.get("result_code")) && !"PAID".equals(order.getStatus())) { // 加锁更新 orderService.updateStatusLocked(order.getId(), "PAID"); } return "<xml><return_code><![CDATA[SUCCESS]]></return_code><return_msg><![CDATA[OK]]></return_msg></xml>"; } catch (Exception e) { return "<xml><return_code><![CDATA[FAIL]]></return_code><return_msg><![CDATA[ERROR]]></return_msg></xml>"; } }注意:
notify_url必须是公网可访问的 HTTPS 地址(本地开发用 ngrok 或 frp 内网穿透),且返回 XML 格式字符串,不能返回 JSON。
4. SSM 与小程序联调排错:常见 5 类错误的定位方法与修复命令
4.1 CORS 跨域问题:SpringMVC 的@CrossOrigin不生效怎么办?
小程序前端域名(https://servicewechat.com/...)与本地 SSM 后台(http://localhost:8080)必然跨域。若配置@CrossOrigin(origins = "*")仍报错,原因通常是:
- Spring 版本 < 4.2:
@CrossOrigin注解无效,需改用WebMvcConfigurer全局配置; - Nginx 反向代理未透传 Origin 头:若部署到服务器,Nginx 配置中必须包含
add_header 'Access-Control-Allow-Origin' '*'; - 微信开发者工具缓存旧响应头:清除工具缓存(菜单栏 → 工具 → 清除缓存 → 全部清除)。
正确做法(Spring 5.0+)是在 Controller 类上添加:
@RestController @RequestMapping("/api") @CrossOrigin(origins = "https://servicewechat.com", allowCredentials = "true") public class OrderController { ... }提示:
allowCredentials = "true"时origins不能为*,必须指定具体域名。开发阶段可临时设为https://servicewechat.com,上线前替换为小程序正式 AppID 对应的域名。
4.2 MyBatis 参数绑定失败:org.apache.ibatis.binding.BindingException: Parameter 'xxx' not found
典型场景:Mapper XML 中写了#{scheduleId},但 Service 方法传参是Long scheduleId,而 XML 里没声明parameterType。解决方案有二:
- 方式一(推荐):在
<select>标签中显式声明parameterType="java.lang.Long"; - 方式二:改用
@Param("scheduleId") Long scheduleId注解,强制 MyBatis 将参数名注入。
// OrderService.java List<SeatVO> selectAvailableSeatsByScheduleId(@Param("scheduleId") Long scheduleId);<!-- SeatMapper.xml --> <select id="selectAvailableSeatsByScheduleId" parameterType="java.lang.Long" resultType="SeatVO"> SELECT * FROM seat_layout WHERE hall_id = ( SELECT hall_id FROM movie_schedule WHERE id = #{scheduleId} ) </select>注意:若方法有多个参数(如
selectSeats(Long scheduleId, String status)),必须全部用@Param注解,否则 MyBatis 无法区分#{arg0}和#{arg1}。
4.3 微信登录code换openid失败:{"errcode":40029,"errmsg":"invalid code"}
此错误表明小程序前端传给后台的code已失效(5 分钟过期)或被重复使用。排查步骤:
- 检查前端是否在
wx.login()回调中立即上传code,而非用户操作延迟后才传; - 后台接收
code后,是否在 1 秒内发起微信接口请求(https://api.weixin.qq.com/sns/jscode2session); - 确认
appid和secret是否与小程序后台一致(尤其注意测试号与正式号差异)。
验证命令(Linux 终端):
curl -X GET "https://api.weixin.qq.com/sns/jscode2session?appid=wx1234567890&secret=your_secret&js_code=YOUR_CODE&grant_type=authorization_code"若返回{"errcode":0,"openid":"..."}则配置正确;若返回40029,说明code无效,需前端重新调用wx.login()。
4.4 MySQL 事务不回滚:@Transactional注解失效的 3 个隐藏原因
即使 Service 方法加了@Transactional,支付失败时订单仍可能残留。常见原因:
- 异常被吞掉:Service 方法内
try-catch了异常但未throw new RuntimeException(e); - 调用本类方法:
methodA()调用本类的methodB(),后者有@Transactional,但 Spring AOP 无法拦截(需用ApplicationContext.getBean(ThisService.class).methodB()); - 数据库引擎非 InnoDB:执行
SHOW CREATE TABLE order;查看引擎,若为MyISAM则不支持事务,需ALTER TABLE order ENGINE=InnoDB;。
验证事务是否生效:在OrderService.createOrder()方法开头加日志log.info("事务开始");,结尾加log.info("事务提交");,若只打印开头日志,说明事务未提交。
4.5 小程序wx.request返回404:后端接口路径匹配失败
微信开发者工具 Network 面板显示GET http://localhost:8080/api/order 404,但curl http://localhost:8080/api/order正常。原因在于:
- 小程序
wx.request默认header['Content-Type'] = 'application/json',而 SpringMVC 的@RequestBody方法要求Content-Type: application/json; - 若 Controller 方法用
@RequestParam接收参数,但前端未加header: {'Content-Type': 'application/x-www-form-urlencoded'},则 Spring 无法解析。
修复方案:统一使用@RequestBody接收 JSON,前端设置:
wx.request({ url: 'http://localhost:8080/api/order', method: 'POST', data: { scheduleId: 123, seatIds: [1,2,3] }, header: { 'Content-Type': 'application/json' }, success: (res) => { ... } })后端 Controller:
@PostMapping("/order") public Result createOrder(@RequestBody OrderRequest request) { ... }5. 毕设答辩高频问题应对:从 SQL 优化到微信支付安全的 4 个硬核回答点
5.1 “你的座位并发控制怎么防超卖?只靠数据库锁够吗?”
这不是考你背概念,而是看你是否理解锁粒度与业务场景的匹配。标准回答应包含三层:
- 行级锁保障:
SELECT ... FOR UPDATE锁定具体座位 ID,避免全表扫描; - 应用层校验:在
lockSeats()方法中,UPDATE seat_layout SET status='locked' WHERE id IN (...) AND status='available'的AND status='available'是二次校验,确保锁住的确实是空闲座位; - 降级策略:若数据库锁等待超时(如
innodb_lock_wait_timeout=50),Service 层捕获LockAcquisitionException,返回“座位紧张,请稍后重试”,而非让用户无限等待。
加分细节:可补充说明“我们测试过 100 并发用户抢同一场次最后 1 个座位,成功率 100%,无超卖”,并展示 JMeter 压测报告截图。
5.2 “微信支付回调地址怎么保证不被伪造?”
重点讲签名验证和IP 白名单:
- 微信回调时,POST Body 是 XML,其中含
sign字段。后台必须用约定密钥(key)对除sign外的所有字段按字典序拼接,再 MD5 加密,与sign比对; - 在微信商户平台配置
notify_url的 IP 白名单(微信服务器固定 IP 段:182.140.166.0/24,182.140.167.0/24),Nginx 层用allow/deny限制访问来源。
验证签名的 Java 代码片段:
public boolean verifySign(Map<String, String> params, String key) { String sign = params.get("sign"); params.remove("sign"); String signStr = WXPayUtil.mapToUrlString(params) + "&key=" + key; return MD5(signStr).toUpperCase().equals(sign); }5.3 “MySQL 的explain你看哪些字段判断 SQL 性能?”
针对selectAvailableSeatsByScheduleId这类查询,必须关注:
type:应为ref或eq_ref,若出现ALL(全表扫描)说明缺少hall_id或schedule_id索引;key:显示实际使用的索引名,确认是否命中idx_hall_id或idx_schedule_id;rows:预估扫描行数,若远大于实际座位数(如影厅 200 座却扫 10000 行),说明索引失效;Extra:出现Using filesort或Using temporary是性能红灯,需优化排序字段或增加覆盖索引。
建索引命令示例:
-- seat_layout 表加速按 hall_id 查座位 ALTER TABLE seat_layout ADD INDEX idx_hall_id (hall_id); -- movie_schedule 表加速按 id 查场次 ALTER TABLE movie_schedule ADD INDEX idx_id (id);5.4 “论文里写的‘系统采用 SSM 框架’,但 MyBatis 和 SpringMVC 是怎么集成的?”
拒绝泛泛而谈“配置了 xml 文件”。要指出具体文件与关键配置项:
spring-dao.xml中<bean class="org.mybatis.spring.SqlSessionFactoryBean">定义dataSource和mapperLocations;spring-service.xml中<context:component-scan base-package="com.example.service"/>扫描@Service;spring-mvc.xml中<mvc:annotation-driven/>启用@RestController,<bean class="org.springframework.web.servlet.view.InternalResourceViewResolver">配置视图解析器(虽 RESTful 接口不用,但需存在);web.xml中ContextLoaderListener加载 Spring 容器,DispatcherServlet加载 MVC 容器——二者父子关系决定 Bean 可见性。
终极技巧:答辩时随身带 U 盘,里面存着
spring-dao.xml截图和explain执行计划截图。当老师问“你真懂这个?”时,直接打开演示,比背诵文档有力十倍。
本文还有配套的精品资源,点击获取