
简介这是一份基于微信小程序的电影院购票管理系统完整毕业设计资源包适合计算机相关专业学生用于课程设计、毕业设计或项目实战。系统前端采用Vue、ElementUI和小程序后端基于Spring Boot、Spring MVC、MyBatis等主流框架按管理员与用户双角色设计覆盖电影/影院/影厅管理、排片、订单、评论、小吃、广告配置以及小程序端电影浏览、购票、口碑分享等完整业务流程。资源包共2006个文件包括1413个Markdown文档含项目说明与笔记、411个JavaScript脚本前端逻辑、115个Java源码后端业务、48个JSON配置及数据库SQL和设计文档压缩包大小约65.25MB目录结构清晰便于查阅。已有30人学习浏览。结合源码、数据库和万字文档读者可快速理解前后端交互与小程序实现思路并在此基础上二次开发或直接用于论文撰写与答辩展示。1. 一套能跑通完整购票链路的小程序系统技术栈与毕设含金量从打开小程序选电影到选定座位、下单支付最后在“我的”里查到票码这条链路看着简单真要自己从零写一遍排队、锁座、撤单、排片、后台管理这些环节每一个都能让人折腾好几天。基于小程序的电影院购票管理系统正是把这条链路完整落地的一套毕设级源码包后端用 SpringBoot SpringMVC Spring MyBatis 的经典 SSM 组合前端提供 Vue ElementUI 的管理后台小程序端覆盖电影浏览、购票、评论、影院套餐、订单查询等完整业务闭环还附带数据库脚本和万字设计文档。如果你想在毕业设计里选一个“业务足够完整、且容易演示答辩”的方向这套系统的参考价值比单纯的教育管理系统、商城系统要高不少哪怕不是做毕设也可以用它的表结构和订单状态机设计补齐自己对小程序电商类项目的理解。全文我会按“架构与数据模型、管理员端核心功能、小程序端购票链路、常见坑位、进阶技巧”的顺序拆解务必让你拿到源码后能快速跑起来。2. 后端与数据模型SSM 搭建方式、MySQL 与 Redis 的角色分工2.1 为什么选择 SpringBoot SpringMVC MyBatis 这套组合这套系统后端用的是 Java 生态里非常成熟的方案。项目构建基于 MavenSpringBoot 负责自动装配和启动入口SpringMVC 处理 HTTP 层路由与参数绑定MyBatis 作为持久层框架操作 MySQL 5.7.26 数据库。Redis 3.0 在这里承担缓存和部分热点数据存储的职责比如首页轮播广告、电影列表、影厅座位状态之类的高频读取数据。很多同学看到“SpringBoot SpringMVC Spring MyBatis”会疑惑SpringBoot 不是已经把 SpringMVC 内置了吗为什么还要单独列出来原因是这套项目并非纯粹 SpringBoot 注解驱动的写法而是保留了传统 SSM 的 XML 或 JavaConfig 配置痕迹例如 SpringMVC 的拦截器配置、视图解析器配置、MyBatis 的 mapper 扫描配置仍然显式存在。这种写法在早期课程设计、毕设项目中非常普遍好处是你能直观看到 MVC 每一层是怎么串起来的而不是像纯 SpringBoot 项目那样“写个 Controller 就能跑”。这种偏传统的组合对毕设来说反而是优点答辩时老师问“请求怎么到达 Controller”“MyBatis 怎么处理参数映射”你可以在配置里找到真实对应内容而不是背概念。2.2 数据库表设计从排片到订单的关系链怎么拆从功能反推表结构这套系统至少包含以下核心数据表用户表、电影表、影院表、影厅表、排片表、订单表、订单明细表、评论表、小吃表、广告表。其中最关键的关系链是“影院——影厅——排片——订单”。影厅表需要单独设计一个座位字段常见做法是把座位信息存成 JSON 字符串或文本例如用seat_map字段保存行、列、座位编号。示例表结构如下CREATE TABLE tb_hall ( hall_id int(11) NOT NULL AUTO_INCREMENT, cinema_id int(11) NOT NULL COMMENT 所属影院ID, hall_name varchar(50) NOT NULL COMMENT 影厅名称如1号厅, seat_rows int(11) NOT NULL COMMENT 座位行数, seat_cols int(11) NOT NULL COMMENT 座位列数, seat_map text COMMENT 座位状态映射JSON存储, PRIMARY KEY (hall_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;这里把seat_map设计成 text 类型而不是单独建一张座位表是很多毕设项目的简化处理。好处是取影厅详情时一次查出来即可前端拿到 JSON 自己渲染座位图缺点是如果要做复杂的锁座和并发控制纯靠这个字段不够需要配合 Redis 做座位预占。后面第 4 章我会具体讲。排片表是整个系统的核心表关联电影、影院、影厅三个维度CREATE TABLE tb_schedule ( 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_id int(11) NOT NULL COMMENT 影厅ID, show_date date NOT NULL COMMENT 放映日期, show_time varchar(20) NOT NULL COMMENT 放映时间如18:30, price decimal(8,2) NOT NULL COMMENT 票价, status tinyint(4) NOT NULL DEFAULT 1 COMMENT 1-排片中 2-已撤销, PRIMARY KEY (schedule_id), KEY idx_movie_date (movie_id, show_date) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;status字段是排片撤销逻辑的关键。管理员撤销排片不是物理删除记录而是把状态从“排片中”改成“已撤销”这样已经产生的历史订单依然能查到排片信息不会因为删除导致关联数据出错。订单表也需要记录一个完整的状态流转从“待支付”到“已支付”再到“已退票/已撤单”。建表脚本在源码包里是完整可执行的你拿到后先按文档顺序建库、再导入数据即可。注意表名跟项目里 Mapper 的 resultMap 一一对应如果自己改字段名务必同步调整 XML 里的映射关系。2.3 项目分层与运行顺序从 IDEA 导入到启动后端工程建议直接用 IntelliJ IDEA 2021.3 打开等待 Maven 下载依赖。需要确认本机环境是 JDK 1.8、Maven 3.6 以上、MySQL 5.7.26 或兼容版本。导入后先改application.yml或.properties里的数据库账号密码、Redis 连接信息然后在 MySQL 中执行sql文件夹里的建库脚本。以下是一份典型的 application.yml 配置片段server: port: 8080 spring: datasource: driver-class-name: com.mysql.jdbc.Driver url: jdbc:mysql://localhost:3306/cinema_system?useUnicodetruecharacterEncodingutf8useSSLfalseserverTimezoneAsia/Shanghai username: root password: 123456 redis: host: localhost port: 6379 mybatis: mapper-locations: classpath:mapper/*.xml type-aliases-package: com.cinema.system.entity启动类直接运行main方法后后端服务会监听 8080 端口。但这里有一个毕设项目常见的坑小程序端不能直接访问http://localhost:8080因为微信小程序要求请求域名必须是 HTTPS 且已在后台配置合法域名。开发阶段最简单的处理办法是在微信开发者工具里勾选“不校验合法域名”这样 HTTP 接口也能调试通过。后台管理系统是 Vue ElementUI用 npm 安装依赖npm install npm run serve启动后访问本地 Vue 服务端口登录管理员账号即可看到用户管理、电影管理、排片管理、订单管理等菜单。如果 Vue 项目是通过 axios 请求后端接口还需要确认vue.config.js里的代理地址是否指向http://localhost:8080。这里我一般建议把代理配好前端保存后就能直接联调。3. 管理员端四个核心业务电影管理、排片、撤单与广告配置的代码落地3.1 电影管理与海报上传文件存储路径与静态资源映射电影管理的增删改查相对标准重点是海报上传。后端接收 MultipartFile 后需要把文件写到指定目录然后数据库里只保存访问路径。常见写法如下RequestMapping(/movie/upload) public Result upload(RequestParam(file) MultipartFile file) { if (file.isEmpty()) { return Result.error(上传文件为空); } // 原文件名后缀防止后缀伪造 String originalName file.getOriginalFilename(); String suffix originalName.substring(originalName.lastIndexOf(.)); // 新文件名用时间戳加随机数避免重名覆盖 String newFileName System.currentTimeMillis() _ new Random().nextInt(1000) suffix; String path uploadDir / newFileName; File dest new File(path); if (!dest.getParentFile().exists()) { dest.getParentFile().mkdirs(); } try { file.transferTo(dest); return Result.ok(/upload/ newFileName); } catch (IOException e) { e.printStackTrace(); return Result.error(上传失败); } }这里有几个关键点第一上传目录不要放在项目源码目录内否则打包部署时容易丢文件建议配置绝对路径例如/usr/local/cinema/upload第二映射静态资源的 WebMvcConfig 要把/upload/**指向这个目录否则前端图片无法回显第三毕设答辩时如果图片找不到八成是上传路径和访问路径没对上排查优先级最高。3.2 排片接口设计按电影和日期查场次的三级筛选排片模块是管理员端业务量最高的功能。添加排片时前端会先选择电影、再选择影院、再选择影厅然后设置日期、时间和票价。后端接口通常提供两个查询入口一个查“可选影院”根据电影 id 查哪些影院在排片另一个查“可选影厅”根据影院 id 查影厅列表。添加排片的 Service 核心逻辑如下public boolean addSchedule(Schedule schedule) { // 同一影厅同一时间不能存在两条排片这是基本业务约束 Schedule query new Schedule(); query.setCinemaId(schedule.getCinemaId()); query.setHallId(schedule.getHallId()); query.setShowDate(schedule.getShowDate()); query.setShowTime(schedule.getShowTime()); ListSchedule exist scheduleMapper.selectList(query); if (exist ! null exist.size() 0) { return false; } schedule.setStatus(1); return scheduleMapper.insert(schedule) 0; }这段代码体现了一个常见约束排片冲突检测。如果同一个影厅在同一日期的同一时间被安排了两部电影用户购票时就会出问题。更完善的做法是加时间段校验比如一场电影时长 120 分钟18:00 开场下一场最早 20:00 才能排避免开场时间重叠。源码包里如果只做了精确到分钟的去重你可以在此基础上手动补一个“结束时间”字段做增强。撤销排片的接口也在这里。撤销时要把排片状态从 1 改成 2同时需要处理已经产生的订单public boolean revokeSchedule(Integer scheduleId) { Schedule schedule scheduleMapper.selectById(scheduleId); if (schedule null) { return false; } schedule.setStatus(2); scheduleMapper.updateById(schedule); // 把该排片下的未完成订单标记为已撤单 orderService.cancelByScheduleId(scheduleId); return true; }注意这里有事务问题改排片状态和改订单状态应该放在同一个事务里否则可能出现“排片已撤销但订单仍显示待支付”的不一致状态。推荐在 Service 方法上加Transactional。3.3 订单管理后台查询、撤单处理与状态流转后台订单管理要展示用户在小程序下的所有购票订单。列表查询往往需要关联用户表查出用户名、关联排片表查出电影名称和场次时间所以 Mapper 里的查询一般是多表 join 或者用嵌套查询。关键状态字段orderStatus建议用整数定义例如 0-待支付、1-已支付、2-已出票、3-已退票、4-已撤单。撤单操作需要注意一个细节已支付订单撤单时需要判断当前时间是否已经超过电影开场时间。如果开场后就不能撤单否则会造成座位已占用但没有实际观演人的问题。这个判断用 SQL 或者 Java 都可以做我一般倾向在 Service 层用 Java 先判断if (schedule.getShowDate().before(new Date())) { return Result.error(电影已开场无法撤单); } // 判断是否开场前一定时间内不允许退票例如开场前30分钟 long diffMinutes (schedule.getShowDate().getTime() - System.currentTimeMillis()) / (60 * 1000); if (diffMinutes 30) { return Result.error(开场前30分钟内不可撤单); }这样既照顾用户体验也避免后续小额退款纠纷。源码包里如果只做了状态直接改成已撤单强烈建议你补上这个时间判断答辩时是非常好的加分点。3.4 广告管理与轮播图配置小程序首页的动态展示逻辑广告管理对应小程序首页的轮播图。后台维护一个广告列表每条记录包含图片地址、跳转链接、排序值、启停状态。小程序端首页加载时请求后台接口根据排序值拉取启用状态的广告列表。这块业务本身不难但有一个容易踩的坑轮播图图片链接如果存的是localhost或内网 IP小程序真机预览时就会加载失败必须保证图片可以通过公网或局域网访问。因此部署时要么把图片上传到对象存储要么在后台配置一个可被手机访问的静态资源域名。在本地开发阶段使用“不校验合法域名”的模式加上电脑 IP 访问可以临时解决但演示答辩前最好把资源放到可外网访问的位置。4. 小程序端购票链路登录鉴权、电影选座、下单支付与我的模块4.1 小程序登录与用户身份绑定小程序端用户第一次打开需要调用微信登录接口获取 code再传给后端换 openid。后端拿到 openid 后先查用户表是否存在不存在就自动注册存在就直接返回登录态。很多毕设项目在这里简化成“前端随便输入昵称即可登录”但如果你想做得完整下面的流程值得参考。小程序端登录核心代码wx.login({ success(res) { if (res.code) { wx.request({ url: app.globalData.baseUrl /user/login, data: { code: res.code }, success(response) { const data response.data; if (data.code 200) { wx.setStorageSync(token, data.data.token); wx.setStorageSync(userInfo, data.data.userInfo); } else { wx.showToast({ title: 登录失败, icon: none }); } } }); } } });后端 Controller 接收 code 后通过微信接口校验并换取 openid这一步在实际开发中必须配置小程序的 appid 和 secret。拿到 openid 后生成一个业务侧的 token可以是 UUID存 Redis 并设置过期时间小程序后续请求在 header 里带上这个 token。Redis 在这里的价值就是把“用户登录状态”变成可控制、可过期的会话而不是每次请求都查数据库校验 openid。4.2 排片查询与日期选择小程序端电影列表到购票页的数据流小程序首页展示电影列表可以按日期切换查找某一天的排片场次。这里的交互路径是电影详情页 - 选择日期 - 查看该电影在该日期的所有场次 - 选择场次 - 进入选座页。日期选择组件可以自己用原生picker实现也可以引入第三方日历组件。当初项目里用的是小程序自定义组件modedate就能满足日期选择需求。用户选择日期后请求后端接口wx.request({ url: app.globalData.baseUrl /schedule/listByMovieAndDate, data: { movieId: this.data.movieId, showDate: this.data.selectedDate }, success(res) { const list res.data.data || []; this.setData({ scheduleList: list }); } });这个接口返回的数据中每条排片包含影院名称、影厅名称、放映时间、票价、余座数量。余座数量怎么算简单方案是当前影厅总座位数减去该排片下已支付订单占用的座位数。但更准确的方案是基于 Redis 中的座位预占数据实时计算“可售座位数”。4.3 选座、下单与支付模拟座位状态锁定和订单创建进入选座页后小程序需要拿到当前排片下座位状态数据。以影厅表的seat_map为基础结合订单表里已售票座位列表动态生成每个座位的可售/已售状态。这里最核心的问题是防止“多人同时购买同一个座位”。后端生成订单的伪代码如下Transactional public Result createOrder(OrderRequest request) { // 1. 校验排片状态 Schedule schedule scheduleMapper.selectById(request.getScheduleId()); if (schedule null || schedule.getStatus() ! 1) { return Result.error(排片已失效); } // 2. 校验座位是否可售 ListString selectedSeats request.getSeatList(); // 用 Redis 原子性检查并预占 boolean success redisService.tryLockSeats(schedule.getScheduleId(), selectedSeats); if (!success) { return Result.error(座位已被选中请重新选择); } // 3. 生成订单并保存座位信息 Order order new Order(); order.setScheduleId(schedule.getScheduleId()); order.setSeatInfo(String.join(,, selectedSeats)); order.setTotalPrice(schedule.getPrice() * selectedSeats.size()); order.setStatus(0); // 待支付 orderMapper.insert(order); return Result.ok(order.getOrderId()); }tryLockSeats方法可以基于 Redis 的 SETNX 实现把一个座位的预占 key 写入 Redis并设置 5 到 15 分钟过期时间。过期后如果用户未支付座位自动释放。这套机制是商城系统里“锁库存”的标准做法虽然源码包里的实现可能比较简单直接用数据库查选更新但你在理解之后可以自行替换成 Redis 方案。支付模块这里是模拟支付。真实微信支付需要商户号、证书、回调地址等毕设项目通常用“直接点击支付按钮跳转成已支付”代替。但为了让自己答辩时能说清楚建议保留一个payType字段并预留回调接口的结构。用户支付成功后订单状态从“待支付”变为“已支付”。这一步在小程序端做请求时务必要传orderId而不是只传金额防止后端不知道支付的是哪个订单。正确写法类似wx.request({ url: app.globalData.baseUrl /order/pay, method: POST, data: { orderId: this.data.orderId }, success(res) { if (res.data.code 200) { wx.showToast({ title: 支付成功, icon: success }); } } });4.4 影院套餐与小吃抢购秒杀场景的简化实现影院模块除了展示影院详情还提供“超值套餐”抢购功能。套餐本质上是一种商品表结构可以复用小吃表或单独建套餐表字段包含套餐名称、原价、套餐价、库存、图片。抢购时后端要校验库存是否充足扣减库存时要防止超卖public boolean rushToBuy(Order order) { // 条件更新库存大于0才扣减利用数据库行锁 int result snackMapper.deductStock(order.getSnackId()); if (result 0) { return false; } // 生成小吃订单 snackOrderMapper.insert(order); return true; }这里的deductStock对应 SQLUPDATE tb_snack SET stock stock - 1 WHERE snack_id #{snackId} AND stock 0;这个写法比先查库存再更新要安全很多因为它把“库存校验”和“扣减”变成一条原子 SQL避免并发时超卖。毕设阶段不需要上消息队列这一招足够应对演示需求。5. 常见问题与避坑记录环境、事务、缓存、日期与微信登录5.1 微信小程序请求不到本地后端接口现象开发者工具里页面空白Network 面板显示请求失败报ERR_CERT_COMMON_NAME_INVALID或request:fail。原因小程序默认要求所有请求域名是 HTTPS 且经过合法域名校验。本地开发是 HTTP 协议自然被拦截。解决打开微信开发者工具点击右上角“详情” - “本地设置”勾选“不校验合法域名、web-view业务域名、TLS 版本以及 HTTPS 证书”。如果是在真机调试手机和电脑需要连同一个局域网并把请求地址从localhost改成电脑的局域网 IP。5.2 LocalDateTime 与 MySQL 日期字段映射报错现象请求参数里传日期字符串2025-04-15后端一接就 400或者数据库中show_date查出来是 Date但实体的LocalDate字段映射失败。原因SpringMVC 默认不能把字符串自动转成LocalDate/LocalDateTime需要配置全局日期转换器。MyBatis 对date类型字段映射到LocalDate有时也会因为类型处理器没注册而报错。解决在项目中增加一个全局生效的日期绑定配置Configuration public class WebConfig implements WebMvcConfigurer { Override public void addFormatters(FormatterRegistry registry) { registry.addConverter(new StringToLocalDateConverter()); } }同时数据库连接串一定要带serverTimezoneAsia/Shanghai没有这个参数 MySQL 驱动 8.0 以上版本经常报时区异常。源码里如果用旧版驱动、JDK 1.8也要把useSSLfalse加上避免握手警告。5.3 撤单或退票后座位没有释放现象用户退票后重新选座发现该座位仍显示“已售”。原因撤单操作只改了订单状态没有同步更新座位状态也没有清理 Redis 里的预占 key。解决在撤单和退票的 Service 方法里除了更新订单状态还要把订单对应的座位从已售集合中移除。如果使用了 Redis 预占座位还要显式删除预占 key。建议把座位释放的逻辑抽成一个独立方法比如releaseSeats(scheduleId, seatList)在退票、撤单、订单超时三种场景下统一调用避免漏改。5.4 Vue 后台打包后接口地址写死现象npm run build 之后部署到服务器接口请求全部 404或者开发环境正常生产环境登录失败。原因Vue 项目里的 axiosbaseURL指向http://localhost:8080本地开发没问题部署到服务器后这个地址不存在。解决不要在生产包写死 IP。正确做法是使用相对路径/api然后让 Nginx 把/api反向代理到后端服务。如果没有 Nginx也可以把 baseURL 配置成一个可独立修改的 JS 配置文件在部署时按环境改。毕设演示若只在本地跑这一步可以不处理但一旦要部署到云服务器这就是最先报错的位置。5.5 两个 Service 方法之间的 Transactional 失效现象订单创建成功后如果后面某个更新失败回滚不完整出现脏数据。原因Transactional默认只对 public 方法生效而且同类内部调用this.xxx()不会触发代理导致事务失效。解决事务方法要放到独立的 Service 类中并且从 Controller 或外部 Bean 调用不要在同一个类里直接this.method()调用另一个事务方法。另一个常见坑是 MySQL 表引擎不是 InnoDBMyISAM 不支持事务检查建表语句里是不是都写了ENGINEInnoDB。6. 更进一步把座位预占和防超卖做成可控的完整方案如果你打算把系统从“毕设能跑”提升到“生产级逻辑”或者想在答辩时被问到“高并发怎么办”时从容应答建议把座位预占方案补全。这里我讲一套比较完整、但又不至于让你无从下手的方案。先明确目标同一场次同一座位任何时间只能被一个人锁定并最终购买。实现分三层。第一层购买页发起下单请求时后端把用户选中的座位写入 Redis 的预占集合。代码如下public boolean preSeat(Integer scheduleId, ListString seatList, String userId) { String key schedule:seat:pre: scheduleId; // 遍历座位逐个尝试写入Redis并设置过期时间 for (String seat : seatList) { String seatKey key : seat; Boolean flag redisTemplate.opsForValue().setIfAbsent(seatKey, userId, 10, TimeUnit.MINUTES); if (flag null || !flag) { return false; } } return true; }setIfAbsent是原子操作如果 key 已存在说明被别人预占直接返回失败。10 分钟是锁的有效期这样用户即使不下单座位也能自动释放。Redis key 的命名统一加schedule:seat:pre:前缀方便后续批量清理。第二层用户支付成功后把座位状态改成真正已售。这一步操作 MySQL把订单表里保存的座位字符串解析后与 Redis 中的预占 key 做核对确认预占者是当前用户然后更新数据库中的座位状态。这样 Redis 只是临时锁数据库才是最终状态。第三层订单超时未支付的情况需要定时清理。最轻量的方案是用 Spring 的定时任务每分钟扫描一次超过 10 分钟未支付的订单Scheduled(fixedDelay 60000) public void autoCancelExpiredOrders() { // 查询状态为待支付且下单时间小于当前时间10分钟的订单 ListOrder expiredOrders orderMapper.selectExpiredOrders(10); for (Order order : expiredOrders) { // 释放Redis预占座位 releaseRedisSeat(order.getScheduleId(), order.getSeatInfo()); // 把订单状态置为已取消 orderMapper.updateStatus(order.getOrderId(), 4); } }注意定时任务方法要放在独立的配置类中并确保EnableScheduling已开启。座位释放完成后用户重新进入选座页就能看到位置空出来了。这套方案做完一来你能明确说出“Redis 管预占、MySQL 管最终状态、定时任务管超时释放”的分工二来也展示了分布式锁的基本思路。即便真实并发量不大这种设计在答辩时非常加分。除了预占我还建议你把后台的撤单、用户改签这类边界场景过一遍电影开场时间是判断能否退票的核心依据建议设置“开场前 30 分钟停止退票”并用一个常量配置在代码里不要散落多处。从那以后我每次处理订票类系统都会先画一遍完整的状态机把待支付、已支付、已出票、已退票、已撤单之间的转换条件列出来再动表结构。这个小习惯能帮你少填很多逻辑坑希望帮到你。本文还有配套的精品资源点击获取