
简介这是一份基于微信小程序的电影票订票系统毕业设计文档面向计算机相关专业学生、毕业设计者以及移动应用开发初学者可用于课程设计、毕业设计或实际项目参考。文档围绕线上购票全流程展开涵盖用户管理、电影信息展示、影院信息、场次与座位选择、在线支付、订单管理和客户服务等模块并给出基于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_typeauthorization_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) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; 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) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;这两张建表语句里的关键点是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) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;这里最关键的是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, ListSeat 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 层拼接动态 SQLpublic ListMovie queryMovieList(String keyword, String type, int page, int limit) { StringBuilder sql new StringBuilder(SELECT * FROM movie WHERE 11 ); ListObject 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 11只是为了后续条件拼接方便不是性能问题二是所有参数都必须通过?占位符传入严禁直接拼接字符串否则用户输入 OR 11这类内容会构成 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;" />