微信小程序电影票订票系统:Java+MySQL 实现高并发座位锁定
2026/9/19 15:10:49 网站建设 项目流程

简介:这是一份基于微信小程序的电影票订票系统毕业设计文档,面向计算机相关专业学生、毕业设计者以及移动应用开发初学者,可用于课程设计、毕业设计或实际项目参考。文档围绕线上购票全流程展开,涵盖用户管理、电影信息展示、影院信息、场次与座位选择、在线支付、订单管理和客户服务等模块,并给出基于Java后端、MySQL数据库和微信小程序前端的具体技术方案,以及接口设计、数据加密传输和防SQL注入等安全措施。压缩包内仅含1个docx文件,整体大小1.54MB,文档包含中英文摘要、关键词、系统架构说明、功能模块详解和未来发展趋势分析,目录结构简洁,便于按章节查阅。目前已有279人学习,适合需要快速了解微信小程序票务系统设计思路、撰写毕业设计文档或搭建同类项目的读者。

1. 从排队两小时到十秒锁定座位,这套小程序架构做了什么

电影票务系统和普通电商最大的差别在于:商品(座位)是强排他性的,同一个座位不能同时卖给两个人,而一部热映片开场前 40 分钟的退票和重新选座频率又极高。用传统的关系型数据库直接做“查余座 → 下单 → 扣座”三步走,在高并发下很容易出现超卖。本文拆解的是一个基于微信小程序的电影票订票系统的设计与实现,后端用 Java + MySQL,前端跑在微信小程序容器里,核心解决的是“电影信息展示、影院位置选择、座位锁定、订单生成”这条完整链路。适合正在做毕业设计、想从单体项目进阶到工程化分层的新手,也适合想对比小程序端状态管理与后端事务控制的同学。这套方案的设计思路是从真实营业场景倒推出来的,不是把 CRUD 堆完就收工。

2. 微信小程序端与 Java 后端的 B/S 架构选型

2.1 为什么要选 B/S 而不是 C/S

毕业设计里最常见的架构选择是 B/S 或 C/S,这套系统选 B/S 的原因在于:微信小程序本身就是一个“寄生”在微信容器里的浏览器环境,它的页面渲染、网络请求、本地存储都受微信框架约束,天然适合 B/S 模式。后端只需要提供一组 HTTP 接口,小程序端通过wx.request调用即可,不需要像 C/S 架构那样维护客户端更新和版本兼容。

实际开发中,很多项目会把业务逻辑全部堆在app.js里,页面直接操作全局 data。这种做法在小程序端看起来“能跑”,但一旦后端接口变动,前端所有页面都要跟着改。我一般会建议在小程序端做一个薄薄的 API 封装层,把请求地址、参数序列化、错误处理统一收敛到一个模块里,这样后端换接口路径时只需改一处。

// utils/api.js 小程序端请求封装 const BASE_URL = 'https://your-domain.com/api' function request(path, method = 'GET', data = {}) { return new Promise((resolve, reject) => { wx.request({ url: `${BASE_URL}${path}`, method: method, data: data, header: { 'Content-Type': 'application/json' }, success: (res) => { if (res.statusCode === 200 && res.data.code === 0) { resolve(res.data.data) } else { wx.showToast({ title: res.data.msg || '请求失败', icon: 'none' }) reject(res.data) } }, fail: (err) => reject(err) }) }) } module.exports = { request }

这段代码把wx.request包成了 Promise 风格的request函数,统一处理了 HTTP 状态码和业务状态码。实际使用中,BASE_URL需要按环境切换——开发环境用局域网 IP 加端口,生产环境必须换成 HTTPS 域名;header里还需要追加登录后的token,用于后端识别用户身份。

2.2 Java 后端的分层与 Servlet 路由设计

后端采用 Java 实现,这里要厘清一个常见误区:很多毕业生一上来就上 Spring Boot,但原项目描述里用的是 Eclipse + Servlet 的经典方案。其实对于课程设计和毕业设计来说,Servlet + JDBC 反而更适合展示对 HTTP 协议和数据库连接的理解;如果后续想扩展成 Spring Boot,只需要把 Controller 层换掉,Service 和 DAO 基本可以平移。

后端的标准分层是:Controller(接收请求、参数校验)→ Service(业务逻辑)→ DAO(数据库操作)。以“用户登录”为例,小程序端先通过wx.login拿到临时code,后端拿着code去微信接口换openid,再拿openid查用户表,查不到就自动注册,查到了就更新最近登录时间。

// LoginServlet.java 核心逻辑 protected void doPost(HttpServletRequest req, HttpServletResponse resp) { String code = req.getParameter("code"); if (code == null || code.isEmpty()) { writeJson(resp, 400, "code不能为空"); return; } // 调用微信接口换取 openid String url = "https://api.weixin.qq.com/sns/jscode2session" + "?appid=" + APP_ID + "&secret=" + APP_SECRET + "&js_code=" + code + "&grant_type=authorization_code"; String result = HttpUtil.get(url); JSONObject json = JSONObject.parseObject(result); String openid = json.getString("openid"); UserDao dao = new UserDao(); User user = dao.findByOpenId(openid); if (user == null) { user = new User(openid); dao.insert(user); } String token = UUID.randomUUID().toString().replace("-", ""); RedisUtil.set("token:" + token, String.valueOf(user.getId()), 7200); writeJson(resp, 0, token); }

这段代码里有个容易被忽略的点:微信接口返回的openid是用户在小程序维度的唯一标识,不要把session_key也存进数据库,它只在解密手机号等敏感数据时用一次。RedisUtil.set的第三个参数是过期时间(秒),这里设成 7200 秒是为了让 token 两小时后失效,避免用户长期不操作导致的安全隐患;如果项目里没引入 Redis,可以用内存 Map 加定时清理线程顶替,但生产环境不推荐。

3. MySQL 表结构设计与订单状态机

3.1 从 E-R 图落到建表 SQL

数据库设计环节,原稿里的 E-R 图覆盖了管理员、用户、电影、公告等实体,但实操中真正决定系统能不能跑通的是:电影表、场次表、订单表。这三张表的关系是——一部电影对应多个场次,一个场次对应多个座位,一个座位在同一时刻只能被一个订单锁定。设计时如果漏掉场次表,把时间信息直接挂在电影表上,就无法支持同一个影厅一天排多场。

表结构设计有个原则:冗余字段可以留,但不能留出数据不一致的冗余。比如用户表里的phone字段,注册时可以不强制填写,但下单时必须校验非空,否则取票时通知不到人。下面是电影表和场次表的精简设计:

CREATE TABLE `movie` ( `id` int(11) NOT NULL AUTO_INCREMENT, `name` varchar(100) NOT NULL COMMENT '电影名称', `movie_type` varchar(50) DEFAULT NULL COMMENT '类型:动作/爱情/科幻', `director` varchar(50) DEFAULT NULL COMMENT '导演', `duration` int(11) DEFAULT NULL COMMENT '时长(分钟)', `poster_url` varchar(255) DEFAULT NULL COMMENT '海报地址', `status` tinyint(4) DEFAULT '1' COMMENT '1上架 0下架', PRIMARY KEY (`id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE `schedule` ( `id` int(11) NOT NULL AUTO_INCREMENT, `movie_id` int(11) NOT NULL COMMENT '电影ID', `cinema_id` int(11) NOT NULL COMMENT '影院ID', `hall_name` varchar(20) DEFAULT NULL COMMENT '影厅名', `show_time` datetime NOT NULL COMMENT '开场时间', `price` decimal(10,2) NOT NULL COMMENT '票价', `remaining_seats` int(11) NOT NULL COMMENT '余座数', PRIMARY KEY (`id`), KEY `idx_movie_id` (`movie_id`), KEY `idx_show_time` (`show_time`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

这两张建表语句里的关键点是:movie表的poster_url只存相对路径或 CDN 地址,不要把图片存成 Base64 塞进数据库,否则表体积会迅速膨胀;schedule表的remaining_seats是一个“冗余计数”,正常范式下可以通过订单表统计出来,但列表页每次实时统计会导致 SQL 很慢,保留这个字段是为了查询性能。代价是下单和退票时必须同步更新这个数字,事务里要保证这两个操作原子性。

3.2 订单表的状态设计与事务边界

订单表是整个系统里最容易出问题的地方。状态字段我建议用tinyint而不是字符串——0已取消、1待支付、2已支付、3已使用、4已退款。字符串'PENDING'虽然可读性好,但写错大小写查不出数据,对业务没有实际帮助。

CREATE TABLE `orders` ( `id` int(11) NOT NULL AUTO_INCREMENT, `order_no` varchar(32) NOT NULL COMMENT '订单号', `user_id` int(11) NOT NULL, `schedule_id` int(11) NOT NULL, `seat_row` varchar(5) NOT NULL COMMENT '排号', `seat_col` varchar(5) NOT NULL COMMENT '列号', `amount` decimal(10,2) NOT NULL, `status` tinyint(4) NOT NULL DEFAULT '1', `create_time` datetime DEFAULT CURRENT_TIMESTAMP, `pay_time` datetime DEFAULT NULL, PRIMARY KEY (`id`), UNIQUE KEY `uk_schedule_seat` (`schedule_id`,`seat_row`,`seat_col`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

这里最关键的是uk_schedule_seat这个联合唯一索引,它从数据库层面保证了同一个场次的同一个座位只能出现一条订单记录。前面提到“查余座 → 下单 → 扣座”在高并发下会超卖,就是因为在“查”和“写”之间有时间差。有了这个唯一索引,两个人同时提交同一个座位时,后提交的那个人会直接报主键冲突,业务层捕获这个异常后提示“该座位已被选择”即可,不需要显式加锁。

下单事务的边界应该是:插入订单记录 → 扣减场次余座。这两个操作必须在同一个数据库事务里,否则会出现订单创建成功但余座没减,或者余座减了订单失败的脏数据。在使用 JDBC 时,setAutoCommit(false)之后执行这两条 SQL,最后统一commit(),任何一条失败则rollback()

4. 购票流程、座位锁定与后台管理的核心实现

4.1 小程序选座页与座位渲染

小程序选座页是用户直接操作的界面,核心交互是:加载场次 → 展示座位图 → 点选座位 → 提交订单。座位图可以用canvas绘制,也可以用view组件加 CSS Grid 布局。对于影厅规模不超过 200 座的场景,view方案更简单、更容易控制点击态。

// pages/selectSeat.js 座位选择逻辑 Page({ data: { seatMap: [], // 2D数组,0空位 1已售 2选中 selectedSeats: [], maxSelect: 5 }, onLoad(options) { this.scheduleId = options.id this.loadSeatMap() }, loadSeatMap() { api.request(`/schedule/${this.scheduleId}/seats`, 'GET').then(seatData => { this.setData({ seatMap: seatData }) }) }, toggleSeat(e) { const { row, col } = e.currentTarget.dataset const seatMap = this.data.seatMap if (seatMap[row][col] === 1) return // 已售不可点 const key = `${row}-${col}` if (seatMap[row][col] === 2) { seatMap[row][col] = 0 this.setData({ seatMap }) return } if (this.data.selectedSeats.length >= this.data.maxSelect) { wx.showToast({ title: '最多选5个座位', icon: 'none' }) return } seatMap[row][col] = 2 this.setData({ seatMap }) } })

这段代码里toggleSeat的流程要点是:先判断座位状态,已售的直接返回;已选中的再点一次取消选中;新选中的要判断是否超过maxSelect上限。seatMap的二维数组下标对应影厅的排和列,后端返回的数据结构是[[0,1,0],[1,0,0]]这种嵌套数组,前端渲染时用wx:for外层遍历排、内层遍历列。注意,这里的数据状态仅存在于内存中,用户离开页面重新进入时,选中的座位会被清空,所以要在onShow里重新拉取座位状态。

4.2 创建订单的 Java 实现与异常处理

选完座位点击购买,前端把scheduleId和座位列表 POST 到后端的/order/create接口。后端处理逻辑是:解析参数 → 查询场次信息 → 检查票价计算金额 → 逐个插入订单记录 → 更新余座。其中任何一步失败都要回滚。

public boolean createOrder(int userId, int scheduleId, List<Seat> seats) { Connection conn = null; try { conn = DBUtil.getConnection(); conn.setAutoCommit(false); ScheduleDao scheduleDao = new ScheduleDao(); Schedule s = scheduleDao.findById(scheduleId, conn); if (s == null || s.getRemainingSeats() < seats.size()) { throw new BusinessException("余座不足"); } OrderDao orderDao = new OrderDao(); for (Seat seat : seats) { Order order = new Order(); order.setOrderNo(OrderNoGenerator.generate()); order.setUserId(userId); order.setScheduleId(scheduleId); order.setSeatRow(seat.getRow()); order.setSeatCol(seat.getCol()); order.setAmount(s.getPrice()); order.setStatus(1); // 唯一索引冲突会在这里抛出 DuplicateKeyException orderDao.insert(order, conn); } scheduleDao.decreaseRemainingSeats(scheduleId, seats.size(), conn); conn.commit(); return true; } catch (DuplicateKeyException e) { conn.rollback(); throw new BusinessException("座位已被占用"); } catch (Exception e) { conn.rollback(); throw new BusinessException("下单失败,请重试"); } finally { DBUtil.close(conn); } }

这段代码把座位插入和余座扣减放到同一个conn事务里,核心是靠数据库唯一索引兜底并发。需要注意的坑是:seats.size()不能超过maxSelect,虽然前端限制了 5 个,但后端必须校验,否则有人绕过小程序直接调接口批量下单。另外订单号生成器建议用“时间戳 + 用户ID + 随机数”拼成 32 位以内字符串,不要直接拿数据库自增 ID 当订单号对外暴露,那样会泄露系统每天的订单量。

4.3 后台管理的模糊搜索与分类过滤

后台管理端是给电影院运营人员用的,核心诉求是快速找到目标数据。影院模块和电影模块都支持“模糊搜索 + 类别筛选”,实现方式是在 Service 层拼接动态 SQL:

public List<Movie> queryMovieList(String keyword, String type, int page, int limit) { StringBuilder sql = new StringBuilder("SELECT * FROM movie WHERE 1=1 "); List<Object> params = new ArrayList<>(); if (keyword != null && !keyword.isEmpty()) { sql.append("AND name LIKE ? "); params.add("%" + keyword + "%"); } if (type != null && !type.isEmpty()) { sql.append("AND movie_type = ? "); params.add(type); } sql.append("ORDER BY id DESC LIMIT ?, ?"); params.add((page - 1) * limit); params.add(limit); return movieDao.queryList(sql.toString(), params); }

这里的 SQL 拼接方式要特别注意两点:一是WHERE 1=1只是为了后续条件拼接方便,不是性能问题;二是所有参数都必须通过?占位符传入,严禁直接拼接字符串,否则用户输入' OR '1'='1这类内容会构成 SQL 注入。LIKE查询在数据量超过十万条后性能会下降,但毕业设计的数据规模通常没有这个瓶颈;如果以后要优化,可以换成全文索引或引入 Elasticsearch,现在不用过度设计。

5. 并发优化、开放数据校验与真机调试排错

5.1 高并发下的乐观锁与缓存降级

座位抢购场景有一个经典问题:热映电影开场前 30 分钟,大量用户同时刷新余座、同时提交订单。此时 MySQL 的行锁竞争会很激烈。除了前面说的唯一索引兜底,还可以在schedule表加一个version字段做乐观锁:

UPDATE schedule SET remaining_seats = remaining_seats - 1, version = version + 1 WHERE id = ? AND remaining_seats > 0 AND version = ?

这条 SQL 的含义是:更新前检查version是否为当初查询到的值,是则更新并version + 1,否则影响行数为 0。业务层判断executeUpdate()的返回值,等于 0 就说明有其他人先改了数据,提示用户刷新重试。相比SELECT ... FOR UPDATE的悲观锁,乐观锁在读多写少的场景下对数据库压力更小,适合座位这种“大多数人只看不买”的场景。

压测时如果发现数据库响应变慢,常见做法是把场次信息和余座数缓存到 Redis,前端请求先打缓存,下单时再回源数据库。缓存和数据库的一致性用“先更新数据库,再删除缓存”的顺序,缓存淘汰后下次请求自然回源。这个方案不完美,但够用。

5.2 微信开发者工具与真机调试的常见断点

开发小程序时高频出现的坑有几个,整理成表格方便对照排查:

现象常见原因处理方式
真机请求无法到达后端开发者工具不校验域名,真机强制要求 HTTPS 且配置合法证书在 mp 后台配置 request 合法域名,或开发阶段开启“不校验合法域名”
数据请求返回 404路径写错,app.json里 page 注册缺失检查pages数组和api.js的路径拼接
setData后页面不刷新修改了this.data直接赋值,未通过setData改用this.setData({ key: value })
座位点选无响应e.currentTarget.dataset取到的是undefined确认style="width:16px;margin-left:4px;vertical-align:text-bottom;cursor:text;" />

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

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

立即咨询