简介:这是一份完整的微信小程序电影购票源码,面向有一定前端基础、希望掌握微信小程序完整开发流程的学习者。项目从影院列表、电影排期、选座到在线支付均有实现,能帮助读者理解WXML页面结构、WXSS样式布局、JavaScript业务逻辑三大核心模块的配合方式,以及如何调用微信API完成登录授权与订单处理。压缩包共92个文件,包含14个js逻辑文件、10个wxml页面结构、10个wxss样式、7个json配置文件,图标与界面素材为33个jpg和16个png,整体仅394KB,目录层次清晰易于检索。开源示例还附带名为atakeaway的外卖模块,可作为扩展功能参考,学习如何在小程序内集成多种服务并管理订单状态。已有6438人学习过这份源码,对想要快速上手小程序电商类项目、研究真实业务实现的开发者来说,是一份紧凑但完整的实战参考。
1. 微信小程序电影购票源码:这套代码到手后先看哪一层
市面上的“微信小程序源码电影购票”资源很多,但能导入开发者工具就跑通的并不算多。这套电影购票小程序源码,我拆过之后确认是完整的前后端结构:小程序前端负责页面交互,Node.js 后端提供影片、场次、座位、订单等接口,数据库脚本放在 sql 目录里,导入 MySQL、改三个配置项就能把本地购票链路跑通。适合两类人,一是拿它当课程设计或毕业设计底子的开发者,二是打算快速搭一套区域影院小程序做业务验证的从业者。刚拿到手别急着在开发者工具里点预览,先绕到后端把数据库和接口启动起来,再回头改前端的接口地址,这个顺序最稳。
2. 代码包结构拆解:前端页面、后端接口与数据库脚本各管哪一段
拿到源码包后第一件事不是直接拖进微信开发者工具,而是先把包解开看一眼目录。这套电影购票小程序的代码结构是典型的“原生小程序前端 + 后端服务 + 数据库脚本”三段式,不是云开发那套免后端的写法。它的好处很明显:业务逻辑在你自己的服务器上,后续接域名、改接口、加功能都灵活;坏处是环境配置比云开发多几个步骤,需要你先搞定 MySQL 和 Node.js。
2.1 小程序端页面文件:首页、详情、选座与订单页各自承担什么
小程序端用的是原生框架,目录结构大致如下:
project/ ├── pages/ │ ├── index/ 首页:正在热映、即将上映两个列表 │ ├── detail/ 电影详情:简介、演职员、场次列表 │ ├── seat/ 选座:座位图渲染与已售/锁定状态 │ ├── confirm/ 订单确认:选座结果、价格计算、优惠码 │ ├── order/ 订单列表与订单详情:状态展示、退改入口 │ └── user/ 个人中心:登录状态、我的订单入口 ├── components/ │ ├── movie-card/ 影片卡片:海报、评分、上映状态 │ └── seat-map/ 座位图组件:按场次数据和座位状态渲染 ├── utils/ │ ├── request.js 封装 wx.request,统一处理 token 和错误码 │ └── auth.js wx.login 登录和登录态持久化 └── app.jspages/index 是 tabBar 的首页,pages/user 是“我的”页,其余都是业务子页面,通过 wx.navigateTo 跳转。components 里两个自定义组件属于高频复用:movie-card 在首页和搜索结果页都会出现,seat-map 是选座页的核心,后面会单独拆开讲。
页面之间传参数,我一般建议只传 id,比如从 detail 跳到 seat 时带 sessionId,不要把整个场次对象带过去。微信小程序的页面路径长度有限,对象里夹带数组或长字符串容易出现“路径带参数丢失”的玄学问题,传 id 最稳。每个页面对应 .js、.wxml、.wxss、.json 四个文件,.json 配导航栏标题和页面背景色,.wxml 写视图结构,.js 管数据请求和事件处理。这套源码里页面文件命名规整,想改成自己的业务,直接复制一个页面文件夹再改注册信息就能跑,不用动 app.json 里其他页面配置。
2.2 后端与数据库:Express 路由、MySQL 表设计与种子数据
后端单独放在 server 目录,常见做法是用 Node.js 的 Express 框架起 HTTP 服务,默认监听 3000 端口。路由按业务模块拆分,例如:
// server/routes/index.js const router = require('express').Router(); router.use('/api/film', require('./film')); router.use('/api/session', require('./session')); router.use('/api/seat', require('./seat')); router.use('/api/order', require('./order')); router.use('/api/pay', require('./pay')); router.use('/api/user', require('./user')); module.exports = router;每个路由文件内部再细分接口,比如 /api/film/nowPlaying 和 /api/film/comingSoon、/api/session/list、/api/seat/lock 等。后端不渲染页面,只返回 JSON,小程序端通过 utils/request.js 统一请求这些地址。
数据库脚本在 sql 目录里,一般有两份:init.sql 负责建库建表,seed.sql 负责往库里灌电影、影院、场次和测试座位数据。理解这套代码的关键在表设计,核心表大概六张:film(影片)、cinema(影院)、session(场次)、seat(座位)、orders(订单)、user(用户),另外还有 banner 或 category 之类的辅助表。
orders 表是字段最集中的一张表,核心字段如下:
| 字段 | 类型 | 说明 |
|---|---|---|
| id | int unsigned | 自增主键 |
| order_no | varchar(64) | 业务订单号,对账用 |
| session_id | int | 场次id |
| user_id | int | 下单用户id |
| seat_ids | varchar(255) | 座位id列表,逗号拼接 |
| total_price | decimal(10,2) | 订单总金额,单位元 |
| status | tinyint | 0待支付 1已支付 2已退款 3已关闭 |
| created_at | datetime | 下单时间 |
seat 表里必须有 session_id 字段,因为同一个座位在不同场次下是独立存在的。一个常见错误是座位表做成“一个影厅只有一排座位”,结果不同场次共用座位状态,A 场有人买了 5 排 3 座,B 场也变成已售。正确做法是座位挂在场次下,或者挂在“场次 + 影厅”的组合下,两场之间的座位状态互不干扰。
2.3 跑通整套代码:导入、建库、改配置、启动
从零跑通这套代码,按下面四步走。
第一步:初始化数据库
mysql -u root -p < server/sql/init.sql mysql -u root -p < server/sql/seed.sqlinit.sql 会创建 movie_ticket 数据库和上面提到的核心表,seed.sql 会写入测试影片、影厅和未来几天的场次数据。如果你的 MySQL 里已有同名数据库,init.sql 通常会有 DROP DATABASE IF EXISTS 语句,执行前先看清楚,别把存量数据冲掉。
第二步:安装后端依赖并启动
cd server npm install npm run devnpm install 会装 Express、MySQL 驱动和相关中间件。npm run dev 走的是开发模式,用 nodemon 监听,改代码自动重启。启动后后端监听 3000 端口,先用浏览器访问 http://127.0.0.1:3000/api/film/nowPlaying 验证接口是否返回 JSON。
第三步:改数据库和密钥配置
// server/config/index.js module.exports = { port: 3000, mysql: { host: '127.0.0.1', port: 3306, user: 'root', password: '你的数据库密码', database: 'movie_ticket' }, appid: '你的小程序appid', secret: '你的小程序secret' };数据库名要和 init.sql 里一致,user/password 用你本机 MySQL 的真实账号。appid 和 secret 在小程序后台的「开发管理 > 开发设置」里查,申请的是测试号就先填测试号的。
第四步:在微信开发者工具里导入小程序前端
打开微信开发者工具,选择「导入项目」,目录选中 project 或 mini-program 这一层,注意不是选整个源码包的最外层。AppID 填自己注册的测试号或个人号,后端地址在 utils/request.js 里改:
// utils/request.js const BASE_URL = 'http://127.0.0.1:3000';提示:开发阶段在开发者工具「详情 > 本地设置」里勾选「不校验合法域名」,本地请求才能发出去。这个选项只对开发工具有效,真机预览和线上环境必须用正式 HTTPS 域名,否则会出现后文的真机白屏问题。
四步走完,首页能看到影片列表就算链路通了。列表空白先看后端日志有没有报错,再看 seed 是否真正写进了数据。
3. 购票主流程:影片列表、选座与订单确认是怎么串起来的
购票链路从首页点进影片详情,再从场次列表进入选座页,选完座去订单确认页下单,最后进入支付。这条链路的前后端交互集中在三个环节。
3.1 首页列表与详情页:数据请求、加载态和路由传参
首页 index.js 的 onLoad 里拉正在热映列表,常见写法是:
// pages/index/index.js const api = require('../../utils/request'); Page({ data: { filmList: [], loading: true }, async onLoad() { try { const res = await api.request('/api/film/nowPlaying', 'GET'); this.setData({ filmList: res.list, loading: false }); } catch (e) { this.setData({ loading: false }); wx.showToast({ title: '加载失败,请检查后端服务', icon: 'none' }); } }, goDetail(e) { const id = e.currentTarget.dataset.id; wx.navigateTo({ url: `/pages/detail/detail?id=${id}` }); } });utils/request 封装成 Promise 风格,接口统一返回 { code: 0, data: ... } 结构,code 非 0 时在 request.js 内部抛错。参数说明:onLoad 只在页面首次加载触发,从详情页返回首页想刷新数据,要在 onShow 里再请求一次;loading 字段配合 wxml 上的 wx:if 做加载中提示。
detail 页在 onLoad(options) 里通过 options.id 拿电影 id,然后并行请求影片详情和场次列表。场次列表不能只返回时间,还要带上影厅和剩余座位数,因为详情页的场次卡片上要展示这些信息。剩余座位数的计算不要在前端遍历所有座位再数,那是拿前端性能开玩笑,后端一条 SQL 就能算出来:
SELECT s.id, s.show_time, s.language, s.hall_name, COUNT(CASE WHEN seat.status IN (1, 2) THEN 1 END) AS sold_count FROM session s LEFT JOIN seat ON seat.session_id = s.id WHERE s.film_id = 3 GROUP BY s.id;status 为 1 是已售,2 是锁定,统计它们作为已占座位,剩余座位用总座位数减去 sold_count。这一步放后端,详情页几乎没有计算压力。
3.2 选座组件:二维数组、座位状态与锁定后的交互
选座页是这套代码里前端交互最重的一块,核心是 seat-map 组件。它拿到后端返回的座位列表后,把一维数组转成按行分组的二维结构:
// components/seat-map/index.js buildSeatMap(seatList) { const rowMap = {}; seatList.forEach(seat => { if (!rowMap[seat.rowNo]) rowMap[seat.rowNo] = []; rowMap[seat.rowNo].push(seat); }); const seatMap = Object.keys(rowMap) .sort((a, b) => a - b) .map(rowNo => ({ rowNo, seats: rowMap[rowNo].sort((a, b) => a.colNo - b.colNo) })); this.setData({ seatMap }); }座位对象通常包含 id、rowNo、colNo、status 四个字段,status 的取值建议约定为:0 空座、1 已售、2 锁定、3 当前已选。渲染时不同状态给不同 class:
<view class="seat-row" wx:for="{{seatMap}}" wx:key="rowNo"> <view class="seat-row__no">{{item.rowNo}}</view> <view wx:for="{{item.seats}}" wx:key="id" class="seat {{item.status === 1 ? 'seat--sold' : ''}} {{item.status === 2 ? 'seat--locked' : ''}} {{item.status === 3 ? 'seat--selected' : ''}}" bindtap="onSeatTap" >// pages/confirm/index.js submitOrder() { const sessionId = this.data.sessionId; const seatIds = this.data.selectedSeats.map(seat => seat.id); const totalPrice = this.data.totalPrice; if (!seatIds.length) { wx.showToast({ title: '请先选择座位', icon: 'none' }); return; } api.request('/api/order/create', 'POST', { sessionId, seatIds, totalPrice }) .then(order => { wx.navigateTo({ url: `/pages/order/order?orderNo=${order.orderNo}` }); }) .catch(err => { wx.showToast({ title: err.message || '下单失败', icon: 'none' }); }); }注意 totalPrice 不要只依赖前端算出来的值,前端只做展示。后端要根据 session 表的票价和 seatIds 数量重新计算总价,否则改一下请求体就能以任意价格下单。下单接口返回的是订单号,不是直接调起支付,真正的支付请求在进入 order 详情页后触发。后端 /api/order/create 的具体逻辑放在第 4 章,它的职责是同时保证座位不被重复卖、订单号和外部单号一致、状态从待支付开始流转。
4. 后端接口与支付回调:订单状态机是购票系统的关键
前端能跑通只是第一步,这套代码能不能真上线,要看后端订单状态机写得稳不稳。一次完整购票,状态流转是:待支付 → 已支付 → 已出票/已消费,中间还有锁座、过期释放、退款回到可售。下面三个接口是重头戏。
4.1 用户登录:wx.login 换 openid,token 怎么发
微信小程序没有传统密码登录,标准做法是小程序端 wx.login 拿临时 code,后端拿 code 找微信服务端换 openid,再发自己的 token 维持登录态。
// server/routes/user.js router.post('/api/user/login', async (req, res) => { const { code } = req.body; const { openid, session_key, errcode } = await exchangeCode(code); if (errcode) { return res.json({ code: 40001, msg: '登录失败,请重试' }); } const userId = await findOrCreateUser(openid); const token = jwt.sign({ userId }, config.secret, { expiresIn: '7d' }); res.json({ code: 0, data: { token, userInfo: { id: userId, nickname: '', avatar: '' } } }); });参数说明:exchangeCode 内部请求微信的 jscode2session 接口,入参是 appid、secret 和 code。openid 是用户在当前小程序下的唯一标识,session_key 是会话密钥,业务上一般不需要长期保存。token 用 JWT 下发,有效期 7 天,小程序端每次请求把 token 放在 header 的 Authorization 字段里。
这套源码里有几个细节值得注意:第一,没有把 openid 直接当 token 用,而是换成了业务自签 token,后续登出、续期、踢人都好处理;第二,用户头像昵称没有从微信资料里硬取,需要在小程序端引导用户用 button 的 chooseAvatar 能力自行补全;第三,JWT 密钥就是 config 里的 secret,上线前一定要换成一个足够长的随机字符串。
4.2 锁座与下单:事务、行级锁与超时释放
下单接口简化下来是三件事:校验场次有效、把座位从空座改成锁定、生成待支付订单。难点在并发,两个用户同时点同一个座位,不能都下单成功。
START TRANSACTION; SELECT id FROM seat WHERE session_id = 8 AND id IN (102, 103) AND status = 0 FOR UPDATE; UPDATE seat SET status = 2 WHERE session_id = 8 AND id IN (102, 103) AND status = 0; INSERT INTO orders (order_no, session_id, user_id, seat_ids, total_price, status, created_at) VALUES ('MT20250101001', 8, 1, '102,103', 79.80, 0, NOW()); COMMIT;SELECT ... FOR UPDATE 会给命中行加排他锁,第二个事务查到同一批座位时会阻塞,等第一个事务提交后读到最新状态,发现 status 已经变成 2,不满足 status = 0,更新影响行数为 0,事务回滚并提示“座位已被占用”。如果把这三条 SQL 拆成三次独立请求又不放在事务里,就会复现两边选同一座位都下单成功的超卖问题。
实际代码里常用 Node 的 mysql 连接池做事务,并在应用层判断 update 影响行数:
const [result] = await pool.execute( `UPDATE seat SET status = 2 WHERE session_id = ? AND id IN (?) AND status = 0`, [sessionId, seatIds] ); if (result.affectedRows !== seatIds.length) { await conn.rollback(); throw new Error('部分座位已被锁定或售出,请重新选座'); }affectedRows 等于传入座位数才继续创建订单,否则直接回滚。订单号要唯一,一般用前缀 + 日期 + 随机数生成,不要拿自增 id 当订单号,否则支付回调对账时容易被猜出订单量。
下单成功生成 status = 0 的待支付订单,座位 status 变成 2(锁定)。锁定不是永久状态,15 分钟没支付要释放。server 里需要有一个定时任务,比如每分钟扫一次:
async function releaseExpiredOrders() { const expiredOrders = await pool.query( `SELECT id, seat_ids FROM orders WHERE status = 0 AND created_at < NOW() - INTERVAL 15 MINUTE` ); for (const order of expiredOrders) { await pool.query( 'UPDATE seat SET status = 0 WHERE id IN (?) AND status = 2', [order.seat_ids.split(',')] ); await pool.query('UPDATE orders SET status = 3 WHERE id = ?', [order.id]); } }status 2 的锁定座位释放回空座,订单变成已关闭。这个定时任务用 node-cron 或 setInterval 都行,注意加事务,避免座位释放了但订单状态没更新,造成两边不一致。
4.3 支付回调:验签、幂等更新与座位最终落定
用户点支付后,小程序端发起 wx.requestPayment,微信支付扣款成功后,微信服务器会异步请求后端回调地址。回调地址在创建订单时随参数传给微信,必须是你自己服务器上可公网访问的 HTTPS 地址。
// server/routes/pay.js router.post('/api/pay/notify', async (req, res) => { const body = req.body; // 1. 验签:用微信支付商户密钥校验签名 if (!verifySign(body)) { return res.send(failXml('签名错误')); } const { out_trade_no, transaction_id, result_code } = body; if (result_code !== 'SUCCESS') { return res.send(successXml()); } // 2. 查订单,幂等处理 const order = await findOrderByNo(out_trade_no); if (!order) { return res.send(failXml('订单不存在')); } if (order.status === 1) { // 已支付,直接返回成功,避免重复处理 return res.send(successXml()); } // 3. 事务里更新订单和座位 await orderPaid(order, transaction_id); res.send(successXml()); });successXml 返回给微信的是固定 XML 格式:<xml><return_code><![CDATA[SUCCESS]]></return_code></xml>,微信收到这个才算回调成功,否则会按策略重试多次。verifySign 一定要做,否则任何人往回调地址发个伪造报文,订单就被改成已支付。orderPaid 在事务里把 orders 的 status 改成 1,写入微信侧 transaction_id,再把对应 seat 表的 status 从 2 改为 1,这一步是座位最终落定。如果回调处理中抛了异常,不要直接返回 500,正确做法是 catch 住、记日志、返回失败 XML,等微信重试时再修复处理。
5. 避坑与常见问题排查:真机、并发、回调与订单号这几个翻车点
这一章写常见问题,每条按“现象 → 原因 → 解决”展开,基本覆盖从本地跑到真机再到上线的完整过程。
5.1 开发者工具里一切正常,真机预览白屏、所有请求失败
现象:开发者工具中能正常刷出影片列表,扫码在手机上预览时白屏,Network 面板里请求全部失败。
原因:开发者工具勾选了“不校验合法域名”,绕过了域名限制;真机小程序要求所有 wx.request 的 URL 必须是 HTTPS,且域名要提前在小程序管理后台的「开发 > 开发设置 > 服务器域名」里配置。
解决:开发阶段用测试号加勾选不校验域名做联调没问题;要真机测试或发布,后端接口必须挂到配了 HTTPS 证书的服务器上,域名在小程序后台配置 request 合法域名。证书用云服务商免费的即可,小程序端 BASE_URL 改成 https 域名就行,代码逻辑不用动。
补充一点,配置合法域名后有缓存,不是立刻生效,改完等一小会儿再重新编译。如果用的是 IP 地址而不是域名,也会被拒,微信不允许直接请求 IP。
5.2 选座页面能选、提交时总提示“座位已被占用”
现象:用户明明看到座位是空的,点下一步时后端返回“部分座位已被锁定或售出”。
原因:这套源码的锁座发生在下单接口,用户停留在选座页的几分钟里后端没有预占动作。另一个用户可能已经把同一座位在确认页提交并锁定,前端看到的是旧数据。
解决:两个层面。简单方案是进入选座页时请求 /api/seat/lock,把整场座位状态拉回来,用户点选后前端先在前端标记;严格方案是下单接口的事务锁不能省,并且失败时前端要重新拉取座位状态,把冲突座位标红。锁座有代价,用户选了位置不下单,座位会被白锁 15 分钟,所以选座页最好提示用户“锁座后请尽快支付”,前端离开页面时主动调用释放接口,没有就想办法靠定时任务兜底。
5.3 支付成功但订单一直停在待支付,用户收不到票
现象:微信支付账单已经扣款,后端数据库订单 status 还是 0,小程序里订单显示未支付,票取不出来。
原因:大概率是支付回调没被正确消费。可能是回调地址不是公网 HTTPS、回调代码抛了异常、验签失败,或者 out_trade_no 与本地订单号对应不上。
解决:先看后端日志有没有收到 /api/pay/notify 请求。收到但失败,多半是验签或订单号问题;完全没收到,检查创建订单时传给微信的 notifyUrl 是不是公网 HTTPS。排查时可以先在回调接口第一行记录 body,保证先能收到请求再往下查。幂等处理必须做,否则微信重试回调时同一笔订单会被重复出票或重复处理。
5.4 场次时间显示比实际多/少 8 小时
现象:数据库存的是 20:00 的场次,小程序上显示成 12:00 或次日 04:00。
原因:MySQL 连接配置没设置时区,服务器用的 UTC 时间,小程序端 new Date 解析时按本地时区换算,于是出现 8 小时偏移。
解决:在 MySQL 连接配置里指定时区,或在连接串上设置 serverTimezone 相关参数;后端返回给前端的时间统一用时间戳或 ISO 8601 字符串,不要直接拼“YYYY-MM-DD HH:mm:ss”格式的字符串。我一般会在后端统一做一个时间格式化出口,所有时间字段经过它输出,前端不做任何时区换算。
5.5 订单号重复导致回调对账失败
现象:偶尔出现支付成功但订单状态不更新,日志显示“订单不存在”或“订单号已存在”。
原因:订单号生成太简单,直接用时间戳或自增 id,高并发下生成重复单号,下单被覆盖或回调找不到唯一记录。
解决:订单号用前缀 + 日期 + 随机串生成,随机串至少 6 位以上;数据库给 order_no 建唯一索引,下单时捕获唯一索引冲突再重新生成。这个习惯不难,但治的是最让人头疼的对账问题。
6. 进阶改造:把单影院商城改成多影院版,只动四个层次
如果这套代码要拿来做区域影院或多门店购票业务,单影院逻辑需要改成多影院。改动并不复杂,因为它已经具备 cinema 表和 session 表的关联基础,缺的主要是产品入口和订单归属。
第一个层次,数据入口加城市或影院筛选。首页接口 /api/film/nowPlaying 增加 cinema_id 参数,后端查询 film 时通过 session 关联 cinema 过滤。小程序端在首页顶部加影院切换器,点击进入影院列表页,选中后把 cinema_id 存入全局数据,后续请求都带上。
第二个层次,选座数据按场次隔离。座位表本身挂在 session_id 下,多影院共用一套表结构没问题,只要场次创建时正确关联 cinema_id。影院管理后台排片时选影院、选影厅、生成场次,前端不感知差异。建议单独建 hall 影厅表,seat 关联 hall_id,而不是在 seat 表里写死座位数。
第三个层次,订单归属和统计。orders 表增加 cinema_id 字段,创建订单时从 session 对应的 cinema 写入,后续按影院维度统计票房、结算票款、做渠道分成都靠这个字段分组。要做门店独立核算,可以在订单明细里再加一个 seller_id,方便按渠道对账。
第四个层次,分享与渠道识别。生成小程序码时在 scene 里带上 cinema_id,用户扫码进入后首页自动定位到对应影院。这样区域推广时每个影院有自己的入口和统计维度,而不是所有用户挤在统一入口里。
改造层级和对应文件可以看这张表:
| 改造点 | 主要涉及位置 | 核心内容 |
|---|---|---|
| 影院筛选入口 | pages/index、pages/cinema-list | 新增影院列表页,全局存储 cinema_id |
| 排片与场次 | server/routes/session | 创建场次时关联 cinema_id 和 hall_id |
| 订单归属 | server/routes/order、orders 表 | 订单写入 cinema_id,按影院统计 |
| 小程序码入口 | server/routes/qrcode | scene 带 cinema_id 参数 |
从那以后,我接手每一套带订单的源码,都先把订单状态流转路径画一遍,把待支付、已支付、已关闭、已退款和对应座位状态列在一张纸上,再动代码。这套购票源码里的订单状态机算得上完整,但在改成多影院、加优惠券、接第三方支付时,一定要先确认状态变更的触发点和幂等逻辑,否则上线后丢单、重复支付的坑会一个一个找上来。希望帮到你。
本文还有配套的精品资源,点击获取