微信小程序云开发电影购票系统源码:选座锁座与支付回调幂等实践
简介:这是一份面向微信小程序初学者与进阶开发者的电影购票小程序源码包,适合用来练习页面布局、业务逻辑、微信 API 调用与第三方票务接口对接。项目围绕影院排期、影片信息、座位选择、在线支付和订单状态同步等购票主流程展开,同时包含外卖示例模块,便于理解附加服务与主业务的集成方式。源码包共 92 个文件,以 33 个 jpg、16 个 png 图片资源和 14 个 js、10 个 wxml、10 个 wxss、7 个 json 配置与逻辑文件为主,压缩包约 394KB,结构紧凑,方便按页面、组件与工具模块查阅。已有 6446 人学习,读者可从中梳理小程序目录组织、WXML/WXSS 页面实现、JavaScript 数据处理及登录支付等接口调用思路,并参考示例代码扩展外卖等周边功能,积累实际项目的问题排查与功能拆解经验。
1. 电影购票小程序能解决什么:从选片到出票的一条完整交易链路
周三下午接的需求:给本地一家影院做微信小程序,能选片、选座、下单,两周上线,影院还得自己能排片。这份电影购票小程序源码就是从这个场景长出来的——前端是小程序页面负责渲染和选座交互,后端用云函数加云数据库承接影片、场次、座位、订单四类数据,把「选片 → 选场 → 选座 → 锁座 → 支付 → 出票核销」整条链路串成了一条闭环。任何一环掉链子,用户就会在选座页直接退出,所以它的价值不在于页面多好看,而在于这条链路的每个状态都被落库、都能追溯。它适合改成本地影院票务、校园影厅、点播厅预约这类单店或小连锁体量的项目;不适合想直接对标全国出票平台的人,渠道对接和资质那一层它没有。下面按结构、主链路、运营侧、踩坑、改造的顺序拆。
2. 工程结构与技术栈:小程序端、云函数、数据库三层怎么分工
2.1 目录结构逐个文件说清楚
拿到源码先别急着编译,把目录过一遍,心里就有数了。这个项目的分层很清楚:miniprogram/是前端,cloudfunctions/是后端,两边靠wx.cloud.callFunction通信,没有中间层。
. ├── miniprogram/ # 小程序端 │ ├── app.js # 云开发初始化、全局登录态 │ ├── app.json # 页面路由、tabBar、窗口配置 │ ├── app.wxss # 全局样式变量(主色、间距、字号) │ ├── pages/ │ │ ├── index/ # 首页:正在热映 / 即将上映 │ │ ├── movie-detail/ # 影片详情 + 日期场次选择 │ │ ├── seat/ # 选座页,座位矩阵渲染 │ │ ├── order-confirm/ # 订单确认、优惠、发起支付 │ │ ├── order-list/ # 我的订单(待支付 / 已支付 / 已使用) │ │ └── ticket/ # 取票码页,展示二维码和核销状态 │ ├── components/ │ │ ├── movie-card/ # 影片卡片,纯展示 │ │ ├── seat-map/ # 座位矩阵组件,只管画不管逻辑 │ │ └── date-tabs/ # 横向日期选择条 │ ├── utils/ │ │ ├── request.js # callFunction 的统一封装 + 错误码处理 │ │ ├── format.js # 价格分转元、日期格式化 │ │ └── seat.js # 座位矩阵生成与可选状态计算 │ └── images/ # 海报占位图、图标 └── cloudfunctions/ # 云函数(后端) ├── getMovies/ # 查影片列表 ├── getSchedules/ # 按影片 + 日期查场次 ├── getSeatMap/ # 按场次查座位快照 ├── createOrder/ # 校验座位、锁座、写订单(事务) ├── payCallback/ # 支付回调,改订单状态 ├── cancelOrder/ # 取消订单,释放座位 └── verifyTicket/ # 核销取票码看这个结构能读出几个设计取向。第一,pages/seat和components/seat-map是分开的,选座的计算逻辑集中在utils/seat.js里,组件只负责接收矩阵和回调点击事件。这个拆法在选座页很好用——座位表动辄两三百个格子,setData一次全量传会很卡,拆开之后可以只更新变化的那一行。
第二,云函数是按「一个动作一个函数」切的,不是按表切的。createOrder里同时操作座位和订单,cancelOrder里同时改订单和座位,边界跟着业务动作走,而不是跟着数据表走。常见做法里也有人把所有逻辑塞进一个api云函数用event.action分发,好处是冷启动只启一次,坏处是函数体积大、权限不好收口。这个项目选了前者,我一般也更倾向这种,排查时一眼能定位是哪个函数出的问题。
第三,没有models/之类的抽象层。云函数里直接db.collection('orders').add(...)。对几百行的项目来说这不算问题,但二次开发时要注意:一旦你开始加多影院、加会员,同一张表的写入会散落在四五个云函数里,改字段容易漏,那时候该抽就抽。
启动前要改的地方只有两处:app.js里的wx.cloud.init({ env: '你的环境ID' }),以及云函数目录下每个函数config.json里绑定的环境。这两个地方不一致,最典型的后果是真机白屏,后面第 5 章会专门讲。
2.2 技术选型:原生小程序 + 云开发为什么适合这类项目
前端用的是原生小程序(WXML / WXSS / JS),没有上 uni-app 或 Taro。这不是保守,是选座页逼出来的:座位矩阵是个几十行乘几十列的网格,每次点选都要重算一次可选状态,还要做最多 4 张票的联动限制,跨端框架多一层编译和 diff,输入延迟就出来了。原生setData的路径更新(比如this.setData({ 'matrix[3][5].status': 2 }))在这种高频局部更新场景下最省事。
后端用云函数 + 云数据库,理由更实际。这个体量的项目(单影院日均几十到几百单)如果自建 Node 服务,服务器、证书、域名、日志、备份这些运维事项的价值远超代码本身,而小团队根本没有这个人。云开发把这一层抹掉了,db.collection('orders').add()就是写库,cloud.getWXContext().OPENID直接拿到用户身份,不用自己搞登录态和 token 校验。代价也要说清楚:
- 事务能力有限。云开发的事务只能对单个文档做
doc(id).get()/update()/remove(),不支持在事务里where查询。这一点直接决定了createOrder的写法,第 3 章会展开。 - 云函数有并发和超时上限,默认超时比较短,座位多的时候循环更新很容易打满。
- 聚合查询有结果条数限制,做统计页时要注意分页或者分批。
一句话总结选型:单店体量、想快上线、没有运维,走云开发;日单过万或者要做多租户 SaaS,趁早迁到自建服务。这也是我判断一份购票源码值不值得接手的第一个标准——它的数据访问是不是集中在云函数里,如果是,迁起来工作量可控;如果散在页面里到处直连数据库,那就很难受。
2.3 数据模型:影片、场次、座位、订单四张表怎么设计
云数据库没有 schema,全靠约定。这四个集合的字段约定建议先抄下来,后面改代码全靠它对照。
| 集合 | 关键字段 | 说明 |
|---|---|---|
movies | _id、title、poster、duration、rating、status、releaseDate | status取ON/OFF,下架不删数据 |
schedules | _id、movieId、hallName、showDate、startTime、price、cinemaId | showDate存YYYY-MM-DD字符串,startTime存HH:mm |
seats | _id、scheduleId、row、col、type、status、lockTime、lockBy | status:0 可售 / 1 已售 / 2 锁定 / 3 维修 |
orders | _id、openid、scheduleId、seatIds、totalPrice、status、outTradeNo、transactionId、createdAt、paidAt | status:PENDING/PAID/CANCELLED/USED |
两个设计点值得展开。
一是seats是按场次快照存的,不是按影厅模板实时算。也就是说排片的时候会往seats里插一批带scheduleId的文档,一场一百个座位就一百条记录。看着冗余,但好处很硬:某场次椅子坏了可以直接把那条改成status: 3,不影响别的场次;影厅后来改了座位布局,旧订单里的seatIds还能反查到「当时是 5 排 8 座」。如果用影厅模板实时算,这两件事都要额外写兼容逻辑。
二是价格用整数分存储。price: 4500表示 45 元,前端用utils/format.js里的fen2yuan转成45.00再渲染。浮点数在订单金额上迟早出问题,这种钱相关的字段统一用最小单位存,是我接手任何交易类项目都要先确认的一件事。
索引方面,云数据库需要手动建。按查询频率,至少给schedules建movieId + showDate的复合索引,给seats建scheduleId + status,给orders建openid + createdAt(降序)和outTradeNo的唯一索引。最后这个尤其重要,它是支付回调幂等的第一道防线,第 3 章会讲。
3. 核心购票链路:选片、选场、选座、下单、支付怎么串起来
3.1 场次渲染:一次请求把整页数据拿回来
影片详情页要做的事只有一件:用户点开某部片子,看到未来七天的场次,点某一场进选座。这里的坑在于请求次数——如果先查日期、再按日期查场次、再按场次查影厅,一个页面就是三次往返,弱网下体验很差。
源码的做法是一次性把整个影片的场次捞回来,前端做分组。云函数大致长这样:
// cloudfunctions/getSchedules/index.js const cloud = require('wx-server-sdk') cloud.init({ env: cloud.DYNAMIC_CURRENT_ENV }) const db = cloud.database() const _ = db.command exports.main = async (event) => { const { movieId, days = 7 } = event if (!movieId) return { code: 4001, msg: '缺少 movieId' } // 用字符串比较日期,避免 Date 对象在不同环境下的时区偏移 const today = new Date() const start = formatDate(today) const end = formatDate(new Date(today.getTime() + days * 86400000)) const res = await db.collection('schedules') .where({ movieId, showDate: _.gte(start).and(_.lte(end)), // 只取还没开场的场次,已过场的直接不返回 status: 'ON' }) .orderBy('showDate', 'asc') .orderBy('startTime', 'asc') .limit(200) .get() // 按日期分组,前端拿到直接渲染日期条 const grouped = {} res.data.forEach((item) => { if (!grouped[item.showDate]) grouped[item.showDate] = [] grouped[item.showDate].push(item) }) return { code: 0, data: grouped } } function formatDate(d) { const p = (n) => (n < 10 ? '0' + n : '' + n) return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}` }几个参数的含义说一下。movieId是必传的,没有就直接返回错误码,别让它进数据库查询。days默认 7 天,影院的排片一般也就排一周,多的排了也没人看。返回结构是{ 日期: [场次数组] },前端用Object.keys(grouped)拿到日期列表渲染date-tabs,选中日期再取对应数组。
formatDate这里刻意没有用toISOString().slice(0, 10)。那玩意返回的是 UTC 日期,在东八区晚上 8 点之后算出来的「今天」会比本地日期早一天,于是当晚新建的场次第二天消失。这个坑我在两个项目里都见过,属于典型的「本地测试永远正常、上线当晚就翻车」,后面第 5 章还会单独列出来。
limit(200)是个保险值。云数据库单次查询有条数上限,超过会截断但不报错。一场影院一周的场次不会超过这个数,但如果哪天有人加了多影厅批量排片,这里就要改成游标分页,否则用户会莫名其妙看不到后几天的场次。
3.2 选座页的座位矩阵与状态机
选座是整个项目最值得看的一页。后端返回的是一维座位数组,每条带row和col,前端要先还原成二维矩阵才能按行列渲染。
// utils/seat.js /** * 把一维座位数组还原成 rows × cols 的矩阵 * @param {Array} seats 后端返回的座位数组,row/col 从 1 开始 * @param {number} rows 总行数 * @param {number} cols 总列数 * @returns {Array<Array>} 二维数组,无座位的格子为 null(用于过道留白) */ function buildMatrix(seats, rows, cols) { const matrix = [] for (let r = 0; r < rows; r++) { matrix[r] = new Array(cols).fill(null) } seats.forEach((seat) => { // row/col 是 1 基,转成 0 基下标 const r = seat.row - 1 const c = seat.col - 1 if (r >= 0 && r < rows && c >= 0 && c < cols) { matrix[r][c] = seat } }) return matrix } /** * 判断某个座位当前能否被点击 * @param {Object|null} seat 座位对象,null 表示这格是空的 * @param {Array} selected 已选座位 id 列表 * @param {number} maxCount 单笔最多选几张 */ function canSelect(seat, selected, maxCount = 4) { if (!seat) return false // 空格子(过道) if (seat.status !== 0) return false // 已售 / 已锁 / 维修 if (seat.type === 'aisle') return false // 过道位不参与售卖 // 已经选中过的,永远允许反选 if (selected.indexOf(seat._id) > -1) return true return selected.length < maxCount } module.exports = { buildMatrix, canSelect }buildMatrix里那个new Array(cols).fill(null)是关键。有些写法直接用seats里的最大col当列数,遇到最后一排只有一半座位(影院常见的错位布局)就会左边整体偏移,选座图和实际影厅对不上。用固定rows和cols填null,渲染时null的格子输出一个空白占位,视觉上才对。
页面里的点击逻辑保持得很薄:
// pages/seat/seat.js 片段 const { canSelect } = require('../../utils/seat') Page({ data: { matrix: [], selected: [], maxCount: 4, totalPrice: 0 }, onSeatTap(e) { const { row, col } = e.currentTarget.dataset const seat = this.data.matrix[row][col] if (!canSelect(seat, this.data.selected, this.data.maxCount)) { if (seat && seat.status !== 0) { wx.showToast({ title: '该座位已售出', icon: 'none' }) } else if (this.data.selected.length >= this.data.maxCount) { wx.showToast({ title: `最多选${this.data.maxCount}张`, icon: 'none' }) } return } // 只更新变化的那一格,避免整个矩阵重绘 const nextStatus = this.data.selected.indexOf(seat._id) > -1 ? 0 : 4 this.setData({ [`matrix[${row}][${col}].status`]: nextStatus, selected: this.toggleId(seat._id) }) this.calcPrice() } })这里用status: 4表示「本地已选中」,和数据库里的 0/1/2/3 分开放,避免本地选中状态和真实售出状态混在一起——这个区分很重要,因为取消选中要还原成 0,如果混用,用户反选一次座位就变成「已售」,只能刷新页面。
setData用路径更新matrix[row][col].status而不是整个矩阵重传,是这一页能不能跑顺的分水岭。一个 15 行 20 列的影厅有 300 个格子,每次点击都全量传,中低端机上的点击反馈会明显拖手。
还有一处常见做法值得提:maxCount不要硬编码在页面里。改成从schedules里读一个maxPerOrder字段,运营想临时限制「每单最多 2 张」做活动,改数据就行,不用发版。
选座页的最终产出是selected数组和一个总价,点「确认选座」带着scheduleId + seatIds跳到订单确认页。注意这时候还没有锁座,座位在数据库里仍然是 0。真正的占用发生在下一步。
3.3 下单事务与支付回调的幂等处理
这是整个项目最容易出问题的地方,也是最值得逐行看的地方。
createOrder要保证三件事原子完成:校验座位可用、把座位改成锁定、写订单。任何一步失败都要整体回滚,否则会出现「座位锁了但没订单」或者「订单写了但座位没锁」这种对不上账的状态。
// cloudfunctions/createOrder/index.js const cloud = require('wx-server-sdk') cloud.init({ env: cloud.DYNAMIC_CURRENT_ENV }) const db = cloud.database() exports.main = async (event) => { const { OPENID } = cloud.getWXContext() const { scheduleId, seatIds } = event // 入口校验,脏数据不要进事务 if (!scheduleId || !Array.isArray(seatIds) || seatIds.length === 0) { return { code: 4001, msg: '参数不完整' } } const transaction = await db.startTransaction() try { // 1. 逐条校验座位状态。云开发事务不支持 where 查询, // 只能按 doc 一个个来,这是硬限制 let totalPrice = 0 for (const id of seatIds) { const seatDoc = await transaction.collection('seats').doc(id).get() const seat = seatDoc.data if (!seat || seat.scheduleId !== scheduleId) { await transaction.rollback() return { code: 4002, msg: '座位不存在' } } if (seat.status !== 0) { await transaction.rollback() return { code: 4003, msg: '座位已被占用,请重新选择' } } totalPrice += seat.price || 0 } // 2. 锁座:状态改成 2,记录锁定人和锁定时间 for (const id of seatIds) { await transaction.collection('seats').doc(id).update({ data: { status: 2, lockBy: OPENID, lockTime: Date.now() } }) } // 3. 写订单,状态 PENDING const orderRes = await transaction.collection('orders').add({ data: { openid: OPENID, scheduleId, seatIds, totalPrice, status: 'PENDING', outTradeNo: `${Date.now()}${Math.floor(Math.random() * 10000)}`, createdAt: Date.now() } }) await transaction.commit() return { code: 0, data: { orderId: orderRes._id, totalPrice } } } catch (err) { await transaction.rollback() return { code: 500, msg: err.message } } }参数说明:scheduleId用来做二次校验,防止有人拿 A 场次的座位 id 去下 B 场次的单;seatIds是数组,长度和maxCount对应;totalPrice由服务端从座位价格累加算出来,绝对不能用前端传过来的金额——这是收钱的地方,前端传什么都不能信。
outTradeNo的生成方式比较朴素,时间戳加四位随机。单店体量够用,但如果你打算做多影院,建议换成影院ID + 时间戳 + 自增序列,否则同一毫秒两个用户下单有小概率撞号,而outTradeNo一旦撞号,支付回调查到的订单就是错的。
事务里那个for循环是最慢的一段。20 个座位就是 20 次get加 20 次update,加上事务本身的开销,云函数默认超时很容易打穿。补救办法有两个:一是把config.json里的超时时间调大,二是把座位数限制在合理范围(一单最多 4 到 6 张)。常见做法是限制一单座位数,业务上也说得通——谁买电影票一次买二十张。
支付回调这一侧,核心是幂等:
// cloudfunctions/payCallback/index.js const cloud = require('wx-server-sdk') cloud.init({ env: cloud.DYNAMIC_CURRENT_ENV }) const db = cloud.database() exports.main = async (event) => { // 回调里带回来的商户订单号 const { outTradeNo, transactionId, returnCode } = event // 支付失败的通知也要正常应答,否则平台会一直重推 if (returnCode !== 'SUCCESS') return { errcode: 0, errmsg: 'ok' } try { // 幂等的关键:条件更新。只有 PENDING 的订单才允许改成 PAID const updateRes = await db.collection('orders') .where({ outTradeNo, status: 'PENDING' }) .update({ data: { status: 'PAID', transactionId, paidAt: Date.now() } }) // updated 为 0 说明这单已经被处理过了(或者根本不存在) // 不管哪种情况,都返回成功,让回调停止重推 if (updateRes.stats.updated === 0) { return { errcode: 0, errmsg: 'ok' } } // 座位状态从「锁定」转「已售」 const orderRes = await db.collection('orders').where({ outTradeNo }).get() const order = orderRes.data[0] for (const id of order.seatIds) { await db.collection('seats').doc(id).update({ data: { status: 1 } }) } return { errcode: 0, errmsg: 'ok' } } catch (err) { // 抛错让平台重推,但重推可能造成重复处理, // 所以前面的条件更新必须扛得住 return { errcode: -1, errmsg: err.message } } }逻辑上的重点在于where({ outTradeNo, status: 'PENDING' })。它把「查」和「判断状态」合并成了原子的一步:并发进来两次回调,只有一次能把updated变成 1,另一次拿到 0 直接返回成功。这比「先 get 判断 status 再 update」安全得多,后者在两次回调间隔极短时两边都能通过判断。
后面的座位转「已售」放在订单更新之后,即使这一步中途失败,订单已经是PAID了,用户能看到票,座位状态可以靠定时任务补偿。反过来先改座位再改订单,一旦订单更新失败,座位就永远卡在「已售」但没人买,只能人工修数据。钱的优先级永远高于库存,这个顺序不能反。
4. 后台与运营侧:影片上架、场次排期、订单核销怎么做
4.1 影片与场次管理:写入约定和校验
购票小程序的另一半是运营能力,影院自己得能上片、排片。源码里这块没做成独立的 Web 后台,而是塞在小程序里的一个隐藏运营页,通过openid白名单判定身份——好处是不用再搭一套登录体系,坏处是运营人员得在手机上排片,体验一般。二次开发时如果影院的排片频率高,建议单独做 H5 后台,复用同一批云函数就行。
排片写入的约定比代码更重要:
- 上片时
movies只写元信息,status默认OFF,检查海报和时长无误后再改ON。直接ON的话首页会立刻出现一张没有海报的破图。 - 排片时按
movieId + hallName + showDate + startTime做唯一性校验,同厅同时间不能排两场。这个校验必须在云函数里做,前端算不出来。 - 排片成功后同步生成座位快照。生成逻辑要幂等:如果该场次的
seats已存在,就跳过,不要重复插。重复插的后果是同一个座位出现两条记录,一条可售一条已售,用户点了没反应。
座位快照的生成代码大概是这个形状:
// cloudfunctions/createSchedule/index.js 片段 const ROWS = 10 const COLS = 16 const AISLE_COLS = [4, 13] // 第 4、13 列为过道 async function generateSeats(scheduleId, hallName, basePrice) { const tasks = [] for (let r = 1; r <= ROWS; r++) { for (let c = 1; c <= COLS; c++) { const isAisle = AISLE_COLS.indexOf(c) > -1 tasks.push({ scheduleId, hallName, row: r, col: c, type: isAisle ? 'aisle' : 'normal', // 过道位不售卖,直接标记为维修态,避免被误选 status: isAisle ? 3 : 0, price: basePrice }) } } // 云数据库单次 add 只支持一条,批量要用循环或者导入 for (const seat of tasks) { await db.collection('seats').add({ data: seat }) } }160 个座位就是 160 次add,这必然超时。实际做法一般是先把座位数据导成 JSON 文件放云存储,再用云函数批量导入,或者干脆把整个座位矩阵以数组形式存成一条文档(牺牲了单座位更新的便利,换排片速度)。这两种取舍没有绝对对错,看你的影院是排片多还是改座多。排片频率高,就选数组存储;单座维修操作多,就选一条一记录。
过道位标记成status: 3而不是留空,是因为渲染时前端只判断status !== 0就置灰,逻辑统一,不用在选座页再写一遍type === 'aisle'的分支。少一个分支就少一处可能写错的地方。
4.2 订单核销与取票码:验票端十秒能落地
用户付完钱进pages/ticket,看到的是一个取票码。这里最简单的实现是把orderId直接编码进二维码,验票端扫码后调verifyTicket云函数:
// cloudfunctions/verifyTicket/index.js const cloud = require('wx-server-sdk') cloud.init({ env: cloud.DYNAMIC_CURRENT_ENV }) const db = cloud.database() exports.main = async (event) => { const { orderId, staffOpenid } = event // 只有 PAID 才能核销,USED 是重复扫,PENDING 是没付钱 const res = await db.collection('orders') .where({ _id: orderId, status: 'PAID' }) .update({ data: { status: 'USED', usedAt: Date.now(), usedBy: staffOpenid } }) if (res.stats.updated === 0) { const orderRes = await db.collection('orders').doc(orderId).get() const status = orderRes.data && orderRes.data.status if (status === 'USED') return { code: 4001, msg: '该票已核销' } if (status === 'PENDING') return { code: 4002, msg: '订单未支付' } return { code: 4003, msg: '无效票券' } } return { code: 0, msg: '核销成功' } }和支付回调一个套路:条件更新 +stats.updated判断。验票口是最不能出并发问题的地方,两个检票员同时扫同一张票,必须只有一个放行。usedBy记下操作人,事后有争议能追溯。
取票码本身建议加个短期签名,别直接暴露orderId。源码里是明文,改造成本不高:云函数返回码值时用orderId + 时间戳算个哈希拼进去,验票时再校验一次时效,超过 24 小时自动失效。这一步不做也不算致命,但做过的人都知道,明文 id 被截图转发给朋友蹭票是迟早的事。
4.3 运营侧统计页的聚合查询
运营页最后一块是「今天卖了多少」。这块用云数据库的聚合能力做:
// cloudfunctions/stats/index.js 片段 const db = cloud.database() const $ = db.command.aggregate exports.main = async (event) => { const { date } = event // YYYY-MM-DD const start = new Date(`${date}T00:00:00+08:00`).getTime() const end = start + 86400000 const res = await db.collection('orders') .aggregate() .match({ status: _.in(['PAID', 'USED']), paidAt: _.gte(start).lt(end) }) .group({ _id: null, orderCount: $.sum(1), ticketCount: $.sum($.size('$seatIds')), revenue: $.sum('$totalPrice') }) .end() return { code: 0, data: res.list[0] || { orderCount: 0, ticketCount: 0, revenue: 0 } } }$.size('$seatIds')是统计票数的关键,比orderCount更贴近实际——一单买三张票,对影院来说卖的是三张。revenue求和出来是分,返回值别忘了在前端除 100。
聚合查询有结果集上限,日订单上千之后match完再group可能被截断。缓解办法是按小时分段查询再累加,或者单独维护一张每日汇总表,订单支付成功时顺手inc一次。我一般会选后者,统计页要求的是快,不是绝对实时。
5. 避坑与排查:座位锁不住、回调重复、真机白屏这几处最容易翻车
5.1 两个人同时选到同一个座位
现象:压测时用两个账号同时点同一场的同一个座位,两边都跳到了订单确认页,最后两边都能进支付。付款后其中一个订单显示「已支付」但没有取票码,另一个订单的座位状态是「已售」。
原因:前端选座只是本地状态,真正的占用在后端。如果createOrder里是「先where查一遍 status 为 0 的座位,再循环 update」,两个请求可能都查到了「可用」,然后都执行了 update。或者更隐蔽的一种:事务里用了where查询——云开发事务根本不支持这个,代码跑了但没生效,等于没校验。
解决:事务内改成doc(id).get()逐条校验,或者在非事务方案里用条件更新where({ _id: id, status: 0 }).update(...),然后检查stats.updated === 1,抢不到就整体回滚并返回明确的错误码,前端收到 4003 就提示「座位已被占用」并刷新座位图。压测脚本我一般会写一个 20 并发的版本,跑通才敢上线。
5.2 支付成功但订单一直挂着「待支付」
现象:用户付款成功,微信里也收到了扣款通知,但小程序订单列表还是「待支付」,点进去提示「请尽快完成支付」。客服只能手动退款。
原因:三种可能。一是回调函数抛异常了,平台重推几次后放弃;二是回调返回结构不对,平台认为没收到应答,一直重推;三是回调里「先 get 判断 PENDING 再 update」,重推时第一次已经改过状态,第二次判断不通过直接 return 了,但如果第一次改完订单、改座位那步挂了,状态就是半拉子。
解决:回调改成条件更新,updated === 0一律返回成功;返回结构严格按平台要求写,别自己加字段;再加一个每 5 分钟跑的定时任务做补偿,扫描status: 'PENDING'且createdAt超过 10 分钟、但支付侧能查到transactionId的订单,直接改成PAID。补偿任务比事后手工退款省事得多。
5.3 开发者工具正常,真机扫码白屏
现象:模拟器里点哪哪正常,真机预览进去一片白,也没报错,或者只有一行「云开发未初始化」。
原因:九成是环境 ID 对不上。app.js里wx.cloud.init({ env })写死了开发环境,而真机走的是体验版,云开发环境没绑到同一个 AppID 下;或者云函数目录里每个函数的config.json各自写了一个环境,有一个写错就整个链路断。剩下的一成是图片域名没配、或者用了只有开发者工具才有的 API。
解决:环境 ID 收到一个config.js里统一管理,用wx.getAccountInfoSync().miniProgram.envVersion区分 develop / trial / release 三个环境;真机上用小程序自带的调试面板看wx.cloud.callFunction的实际报错;上线前把云开发环境和 AppID 的绑定关系在后台确认一遍,这一步没确认,其它排查都是白费。
5.4 场次日期整体错一天
现象:晚上八点之后新排的场次,第二天在列表里找不到;或者明明排的是 15 号,用户看到的却是 14 号。
原因:new Date()存进数据库变成 UTC 时间,取出来格式化时又按本地时区算了一遍,两头不一致。八点这个时间点是因为东八区偏移 8 小时,晚上八点之后 UTC 已经是第二天了。
解决:日期统一存YYYY-MM-DD字符串,时间统一存HH:mm字符串,只在需要排序和判断是否过期时才转成时间戳,而且转换函数只写一份放在utils/format.js。这条规矩定下来之后,时区问题基本绝迹。
5.5 点「去支付」转圈十几秒然后失败
现象:选完座点确认,按钮转圈,十几秒后弹「网络异常,请重试」,重试还是失败。
原因:createOrder里循环查座位加循环更新,座位一多就超过云函数默认超时。云开发默认超时时间不长,事务本身还有额外开销,两个叠加直接打穿。
解决:一是把单笔订单座位数限制在 6 张以内,业务上完全说得通;二是在云函数的config.json里把timeout调高;三是把校验和更新合并,非关键动作(写日志、发订阅消息)拆到另一个云函数异步做。三条一起上,正常单笔下单能压到 1 秒内。
6. 二次开发:从单影院改造到多影院 + 会员体系的落地思路
接这类源码的人,十个里有八个最后都要做两件事:扩成多影院、加会员。这两件事的改造路径其实不一样,别一起动。
多影院改造是数据结构改造:所有业务表加cinemaId字段,schedules、seats、orders、movies(如果各店片源不同)全加;所有云函数入参补cinemaId;索引全部重建,把cinemaId放到复合索引第一位;前端加一个影院选择组件,选中的影院存进app.globalData,request.js里统一带上。改动点多但机械,一天能做完。唯一要想清楚的是首页逻辑:是进来先选影院,还是按定位推荐最近一家?前者实现简单,后者要接位置授权,还涉及用户拒绝授权后的降级,建议一期先做前者。
会员体系是业务逻辑改造,风险高得多。加一个users集合存openid、手机号、积分、等级;订单确认页在算价时插入一层会员折扣;支付成功后异步加积分;退票时扣回。这里最容易出错的是「折扣和积分叠加算错」,因为金额一旦算错就是真金白银。我的做法是永远在云函数里算最终价,前端只负责展示和传usePoints: true/false这种开关,绝不让前端传折扣后金额。
改造顺序和验证方法,我一般这么排:
| 阶段 | 改动内容 | 验证方式 |
|---|---|---|
| 第一步 | 加cinemaId,不动业务逻辑 | 用两家影院的测试数据跑完整购票流程,确认数据不串 |
| 第二步 | 前端加影院切换 | 切店后场次、座位、订单列表全部跟着变,购物车清空 |
| 第三步 | 加users集合和积分字段 | 老用户首次进入自动建档,不阻塞购票 |
| 第四步 | 订单算价接入会员折扣 | 用手工算的期望金额逐单比对,含 0.01 元边界值 |
| 第五步 | 压测并发下单 | 20 并发抢同一座位,只能成功一单 |
第三步有个细节值得单独说:老用户第一次进新版本时并没有users记录,如果建档逻辑放在下单流程里,一旦建档失败整个下单就挂了。我一般放在app.js的onLaunch里异步做,失败了也不影响主流程,下次进来再补。这种「非核心链路不阻塞核心链路」的判断,是改别人源码时最该先建立的习惯。
最后一个技巧,关于怎么快速确认一份购票源码靠不靠谱:别看页面,先搜三个东西——搜setData看看有没有全量更新大数组的,搜startTransaction看看事务里有没有where,搜outTradeNo看看回调是不是条件更新。这三处只要有一处写错,这个项目就一定在某个时段出过对不上账的事故。我从被一次并发锁座事故坑掉一个周末之后,每次拿到交易类源码都先跑一遍这三项检查,再决定是接着改还是重写。希望帮到你。
本文还有配套的精品资源,点击获取