简介一款基于微信小程序的电影购票完整源码包面向有小程序基础、想深入掌握电商与生活服务类项目开发的读者。源码覆盖影院列表、电影排期、在线选座、订单支付等真实购票链路并额外附带外卖模块示例便于扩展食品饮料售卖场景。压缩包共92个文件包括33张jpg和16张png图片资源、14个js逻辑脚本、10个wxml页面结构、10个wxss样式表、7个json配置及2个gitignore文件包体仅394KB目录组织规范适合对照学习WXML布局、WXSS样式、JavaScript业务逻辑和微信API交互。目前已有6438人学习下载可作为课程设计或毕业设计的参考。通过阅读源码既能理解页面生命周期、数据绑定、本地存储等基础机制也能掌握与第三方票务API的对接思路还能从外卖模块中学习多服务集成的扩展方式。对希望快速上手实际小程序项目、提升工程化能力的开发者来说是一份可直接运行和二次改写的实用素材。1. 微信小程序源码电影购票小程序别急着改页面先把链路想清楚很多同学下载了微信小程序源码电影购票小程序导入开发者工具后第一感觉是“这就是个普通 CRUD”首页有个电影列表详情页有一堆场次。结果一到选座就开始翻车要么座位锁不住要么支付成功后订单还是待支付。这个标题真正在讲的东西不是页面而是“锁座—下单—支付—出票—退票”这条链路。它能解决的问题很具体让一个微信小程序在数据端支撑电影场次展示、座位状态变更、订单生命周期和支付回调适合做毕设、个人练手、中小商户快速搭建售票入口。我按微信云开发加原生小程序这条最省事的路来讲从数据模型、锁座事务、支付联调一路到并发压测和补单照着复现能撑住一个真实场景。2. 跑通最小闭环技术选型、源码结构与数据模型设计2.1 为什么先选微信云开发而不是自建后端一套电影购票小程序如果要自建后端最小也得准备一台云服务器配置 HTTPS 域名在小程序后台把 request 合法域名加白然后还要自己实现微信登录换取 openid、维护 token 过期。这些事和票务本身没有关系却会消耗两三天时间。微信云开发直接省掉这一步小程序端调用wx.cloud.init之后页面和云函数都能直接读写云数据库登录上下文在云函数里通过cloud.getWXContext()拿 openid不用自己设计会话体系。我的判断是个人开发者、课程设计、中小商户演示用云开发先跑通比自建后端合适得多。电影购票的核心数据是电影、影院、场次、订单特征是“文档为主、关系简单”云数据库的文档模型刚好接得住这类业务。如果你们公司已经有 Java/Node 后端也可以把云函数换成普通 HTTP 接口后面讲的数据模型、座位状态、事务思路完全能平移不用推翻重来。这里给出一张选型对比方便你对着自己的场景做决定维度自建后端微信云开发登录鉴权自己接 jscode2session维护 session云函数上下文直接拿 OPENID服务器成本按云服务器规格付费按调用次数和数据库容量计费免费额度够开发用域名备案需要 HTTPS 域名并配置白名单不需要自己配域名数据库操作关系型或非关系型自选文档型适合电影票这类数据上线门槛有后端就能上线依然是微信小程序审核支付需企业主体云开发也不是银弹比如它没有关系型数据库的 join 能力报表统计要自己拼数据。但电影购票的数据访问模式非常适合文档模型一场电影的场次、一个影厅的座位天然就是一份文档。先看初始化代码这是所有云函数和页面数据请求的地基// app.js App({ onLaunch() { if (!wx.cloud) { console.error(当前基础库不支持云开发) return } wx.cloud.init({ env: your-env-id, // 替换成云开发控制台里的环境ID traceUser: true // 记录用户访问日志方便排查问题 }) } })这里的env参数是云开发环境 ID在微信开发者工具左上角“云开发”控制台里能查到一个项目可能有两个环境开发环境和生产环境如果这一行不传 env默认使用当前项目的默认环境。traceUser建议开着支付回调排错时能从小程序侧追踪到用户行为。基础库版本选择支持云开发的版本即可详情面板里可以切换。接下来是登录云函数它负责返回 openid并把第一次进入的用户写入users集合// cloudfunctions/login/index.js const cloud require(wx-server-sdk) cloud.init({ env: cloud.DYNAMIC_CURRENT_ENV }) exports.main async () { const { OPENID } cloud.getWXContext() const db cloud.database() const found await db.collection(users).where({ _openid: OPENID }).get() if (found.data.length 0) { await db.collection(users).add({ data: { _openid: OPENID, createdAt: Date.now() } }) } return { openid: OPENID } }注意一点在云函数里往users集合写数据系统不会自动帮你存_openid必须手动把 OPENID 写入字段后续订单、票夹关联用户都靠这个字段。DYNAMIC_CURRENT_ENV表示云函数运行在哪个环境就操作哪个环境的数据库避免把测试数据写进生产库。提示users集合的权限建议设为“所有人不可读写”只允许云函数操作。电影票涉及的座位和订单数据权限配置错误会直接暴露给普通用户。2.2 四张表和它们之间的关系电影、影院、场次、订单电影购票小程序源码里最容易被忽略的是集合设计很多项目把票价、座位、场次全塞进一个表里最后统计和并发都难受。我一般按四张核心集合拆集合作用关键字段movies影片信息title、poster、duration、releaseDate、statuscinemas影院信息name、address、hallsscreenings场次信息movieId、cinemaId、hallName、startTime、endTime、price、seatsorders订单信息openid、screeningId、seatPositions、amount、status、expireAt关系很简单一部电影对应多个场次一个影院对应多个场次一个场次产生多个订单。screenings.seats是整个系统的核心它存一个二维数组比如 8 行 10 列{ _id: screening_1001, movieId: movie_001, cinemaId: cinema_001, hallName: 3号厅, startTime: 1739604600000, endTime: 1739612400000, price: 45, seats: [ [0, 0, 1, 1, 0, 0, 0], [0, 1, 0, 2, 0, 0, 1], [1, 2, 2, 0, 0, 0, 0] ] }seats里的每个格子有三种状态0 表示可售1 表示已锁定2 表示已售。这里不要用字符串或布尔值前端渲染时直接比较数字避免类型不一致的隐式 bug。为什么把座位放进场次文档而不是单独建一张座位表遇到过不少人把每个座位建成一条文档一场 100 个座位就是 100 条记录选座时要锁的文档太多事务复杂并发一上来就出问题。二维数组放进场次文档读一次拿全量座位锁座时只需要处理一个文档的并发冲突思路清晰很多。代价是你是不能直接按“某个座位是否售出”做全局查询但电影院的核心诉求是“一场电影的座位状态”不是跨影厅的座位统计所以这个取舍是值的。订单集合里seatPositions建议存[{row:0,col:0},{row:0,col:1}]这种结构而不是只存[A1,A2]因为代码后续要按行列号改seats数组存格式化座位号还需要反向解析多一层麻烦。2.3 一个最小可跑的代码骨架目录划分与初始化配置拿到源码后先别急着看页面把工程结构搞清楚后面定位问题会快很多。我常用的最小目录如下movie-ticket-miniprogram/ ├── project.config.json ├── miniprogram/ │ ├── app.js │ ├── app.json │ ├── pages/ │ │ ├── index/ # 电影列表 │ │ ├── movie-detail/ # 影片详情与场次列表 │ │ ├── seat-select/ # 选座页 │ │ ├── order-confirm/ # 确认订单页 │ │ ├── order-list/ # 我的订单 │ │ └── ticket/ # 票夹核销 │ └── utils/ │ └── format.js # 时间格式化工具 └── cloudfunctions/ ├── login/ # 登录 ├── lockSeats/ # 锁座 ├── payOrder/ # 支付下单 ├── payCallback/ # 支付回调 └── timerRelease/ # 超时释放座位定时器project.config.json里最关键的是两个 root 配置{ miniprogramRoot: miniprogram/, cloudfunctionRoot: cloudfunctions/, compileType: miniprogram, setting: { es6: true, minified: true, urlCheck: false } }miniprogramRoot告诉开发者工具前端代码在哪cloudfunctionRoot告诉它云函数在哪两个路径配错云函数会传不上去。urlCheck建议在开发阶段设成 false否则调试时请求本地云函数会提示域名不合法虽然云开发本身不需要白名单但这个设置偶尔会干扰本地联调。app.json只保留页面注册信息和窗口样式{ pages: [ pages/index/index, pages/movie-detail/movie-detail, pages/seat-select/seat-select, pages/order-confirm/order-confirm, pages/order-list/order-list, pages/ticket/ticket ], window: { navigationBarTitleText: 电影购票小程序源码演示, navigationBarBackgroundColor: #1f1f1f, navigationBarTextStyle: white } }这个骨架没有用 tabBar因为核心链路是首页进详情、详情进场次、场次进选座属于纵向流程底部导航反而碍事。订单入口放在首页导航栏右上角一个小按钮即可页面路径在App里用onShow做一次登录把 openid 存在全局后续页面请求直接携带订单参数。3. 座位是电影购票的命门场次数据设计与锁座接口3.1 座位到底怎么存二维数组为什么比独立座位集合更合适电影院小程序真正的技术难点集中在座位状态上。网上不少源码把座位设计成独立集合比如seats表每个座位一行字段有 row、col、status、orderId。这样设计在数据查询时很直观但一场电影 100 个座就意味着 100 条记录用户选座时要一次性更新多条文档事务没法做。更麻烦的是前端渲染需要把 100 条记录拼成二维数组每次页面切换都多一次聚合请求。二维数组方案则把一场电影的座位全部放进screenings文档的seats字段里生成函数也很简单// 生成一个 rows 行 cols 列的座位图全为可售状态 function createSeatMap(rows 8, cols 10) { return Array.from( { length: rows }, () Array.from({ length: cols }, () 0) ) }这个函数在创建场次时调用一次结果直接写入screenings集合。参数rows和cols对应影厅的实际座椅布局普通影厅 8 行 10 列足够IMAX 厅可以调整为 12 行 20 列。注意座位数组不要无限加大单文档大小和更新性能都有上限一般控制在 200 个座位以内比较稳。座位状态建议固定使用以下数字约定状态值含义前端表现0可售浅色空心1已锁定灰色不可点击2已售深色不可点击为什么要区分锁定和已售因为用户选中座位后要经过支付支付成功后状态才变成 2。如果直接把选座改成已售用户未支付期间座位就被占了体验和库存都不对。锁定状态给支付留出时间窗口超时后由定时任务把座位恢复成 0。3.2 “先锁座再下单”的接口实现用事务防止超卖锁座是电影购票小程序里最需要较真的接口。两个用户同时点同一个座位如果代码是“先查再改”两个请求都可能查到状态是 0然后各自写入订单造成超卖。云开发数据库提供了事务能力关键在于把一个场次文档的读取和更新放进同一个事务里一旦发现文档在事务期间被改过提交时就会失败重试即可。下面是我常用的一段锁座云函数核心逻辑// cloudfunctions/lockSeats/index.js const cloud require(wx-server-sdk) cloud.init({ env: cloud.DYNAMIC_CURRENT_ENV }) const db cloud.database() const MAX_RETRY 3 // 事务冲突最多重试次数 const LOCK_MINUTES 5 // 座位锁定时间单位分钟 async function lockSeatWithTx(screeningId, seatPositions) { const transaction await db.startTransaction() try { // 1. 事务内读取场次拿到当前座位数组 const doc await transaction .collection(screenings) .doc(screeningId) .get() const seats doc.data.seats // 2. 校验所有目标座位的状态 for (const pos of seatPositions) { if (seats[pos.row][pos.col] ! 0) { const err new Error(seat taken) err.code SEAT_TAKEN throw err } } // 3. 将目标座位改为锁定状态 for (const pos of seatPositions) { seats[pos.row][pos.col] 1 } // 4. 写回并提交事务 await transaction .collection(screenings) .doc(screeningId) .update({ data: { seats } }) await transaction.commit() return true } catch (e) { await transaction.rollback() throw e } } exports.main async (event) { const { OPENID } cloud.getWXContext() const { screeningId, seatPositions } event for (let attempt 0; attempt MAX_RETRY; attempt) { try { await lockSeatWithTx(screeningId, seatPositions) // 锁座成功后再创建订单初始状态为 PENDING const addRes await db.collection(orders).add({ data: { openid: OPENID, screeningId: screeningId, seatPositions: seatPositions, status: PENDING, amount: 0, // 支付云函数会根据场次价格重新计算防前端改价 orderNo: T Date.now() Math.random().toString(36).slice(-6).toUpperCase(), createdAt: Date.now(), expireAt: Date.now() LOCK_MINUTES * 60 * 1000, updatedAt: Date.now() } }) return { success: true, orderId: addRes._id } } catch (e) { if (e.code SEAT_TAKEN) { return { success: false, message: 座位已被选中请重新选择 } } // 其他错误多半是事务冲突继续重试 } } return { success: false, message: 系统繁忙请重试 } }这段代码有三个参数是你拿到源码后必调的MAX_RETRY控制冲突重试次数建议 3 次太多会让接口耗时变长LOCK_MINUTES是座位锁定时间影院业务常见设置是 5 分钟太短用户来不及支付太长会有人恶意锁座orderNo是业务订单号支付回调时用来反查订单不要用数据库自动生成的_id去对账可读性太差。还要特别注意订单创建在事务之外。这意味着极端情况下会出现“座位锁定成功但订单创建失败”所以必须有兜底定时释放任务会把超过expireAt的 PENDING 订单座位释放等于自动补偿。这种做法能接受因为锁座时间乘以每个用户的支付延迟不会造成大面积死锁。3.3 超时释放与无主订单定时触发器怎么写用户选好座位但中途退出、微信支付放弃付款这种订单靠支付回调永远等不到。需要定时任务清理过期 PENDING 订单。云开发定时器在云函数同级目录下的config.json里配置{ triggers: [ { name: releaseTimer, type: timer, config: 0 */1 * * * * * } ] }这个 cron 表达式的意思是每分钟触发一次云函数代码要做的事有两件找出所有 PENDING 且已过期的订单释放它们占用的座位并关闭订单// cloudfunctions/timerRelease/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 () { const now Date.now() const expiredOrders await db.collection(orders) .where({ status: PENDING, expireAt: _.lt(now) }) .get() for (const order of expiredOrders.data) { // 释放座位 const tx await db.startTransaction() const doc await tx.collection(screenings).doc(order.screeningId).get() const seats doc.data.seats for (const pos of order.seatPositions) { if (seats[pos.row][pos.col] 1) { seats[pos.row][pos.col] 0 } } await tx.collection(screenings).doc(order.screeningId).update({ data: { seats } }) await tx.commit() // 更新订单状态 await db.collection(orders).doc(order._id).update({ data: { status: CLOSED, updatedAt: Date.now() } }) } }这里判断条件是expireAt小于当前时间而不是去比对每笔订单创建时间加五分钟效率更高也为将来调整锁定时间留了余地。订单状态机只需记住一个链条PENDING可以变成PAID也可以变成CLOSEDPAID之后才允许退款变成REFUNDED。如果代码里看到从PENDING直接跳到REFUNDED状态流转肯定有问题。4. 从选片到支付页面流程与微信支付联调的必改点4.1 首页和场次列表的数据流下拉刷新、分页与时间戳格式化页面层的第一件事是解决“数据从哪来”。小程序端直接db.collection(movies).get()受权限控制而且每次都要处理权限报错我习惯通过云函数取数这样权限只依赖云函数身份不受集合权限配置影响。电影列表云函数需要支持分页和状态过滤// cloudfunctions/movieList/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 { page 1, pageSize 10, status now } event const where status now ? { releaseDate: _.lte(Date.now()) } : { releaseDate: _.gt(Date.now()) } const res await db.collection(movies) .where(where) .orderBy(releaseDate, desc) .skip((page - 1) * pageSize) .limit(pageSize) .get() return { list: res.data, total: res.total } }这里有个很多人踩过的坑releaseDate不要存成2025-02-14这种字符串以后做“正在热映/即将上映”比较会非常难受。直接存时间戳云函数里用Date.now()比较前端展示时再格式化成用户能看懂的日期。page和pageSize是分页参数首页首次加载传page: 1触底时把当前页加一再请求避免一次性拉全量电影数据。场次列表的云函数比电影列表多一点逻辑因为一个场次要带上影院名称和影片名称。云数据库没有 join常见做法是查出screenings后再用field批量查出对应的影院和影片前端组装。这一步在源码里如果只有简单查询大概率是后续体验卡顿的隐患建议尽早按这个方向改。4.2 选座页怎么把座位状态渲染成色块选座页拿到的seatMap就是screenings.seats二维数组。渲染时最忌讳的是用wx:if把已锁定和已售座位过滤掉那样用户看不到座位上有人视觉反馈完全缺失。正确做法是循环全部座位通过 class 区分状态view wx:for{{seatMap}} wx:for-itemrow wx:for-indexrowIndex classseat-row view wx:for{{row}} wx:for-itemseatItem wx:for-indexcolIndex classseat {{seatItem 0 ? available : }} {{seatItem 1 ? locked : }} {{seatItem 2 ? sold : }} {{selectedMap[rowIndex - colIndex] ? selected : }} bindtaponSeatTap >const MAX_SELECT 5 Page({ data: { seatMap: [], selectedMap: {} }, onSeatTap(e) { const { row, col } e.currentTarget.dataset const seat this.data.seatMap[row][col] if (seat ! 0) return const key row - col const selectedMap { ...this.data.selectedMap } if (selectedMap[key]) { delete selectedMap[key] } else if (Object.keys(selectedMap).length MAX_SELECT) { wx.showToast({ title: 单笔最多购买 MAX_SELECT 张, icon: none }) return } else { selectedMap[key] true } this.setData({ selectedMap }) } })MAX_SELECT是硬性业务参数电影院单笔订单限购 5 张算比较合理的值防止一个人一次锁几十个座位影响其他人。注意前端这个判断只是体验层面云函数锁座时必须再做一次同样的校验不然恶意用户绕过前端直接调用锁座接口限购就是摆设。4.3 支付下单与回调金额单位、签名与订单状态支付是电影购票小程序源码里最容易“感觉能用、实际不能用”的部分。先明确一个硬限制个人主体小程序不能开通微信支付要真实支付必须有企业主体和微信支付商户号。个人开发者跑通流程时一般用模拟支付开关源码里我习惯留一个USE_MOCK_PAY常量// cloudfunctions/payOrder/index.js const cloud require(wx-server-sdk) cloud.init({ env: cloud.DYNAMIC_CURRENT_ENV }) const db cloud.database() const USE_MOCK_PAY true // 演示模式直接置为已支付 exports.main async (event) { const { orderId } event const orderRes await db.collection(orders).doc(orderId).get() const order orderRes.data if (!order || order.status ! PENDING) { return { success: false, message: 订单状态异常 } } if (Date.now() order.expireAt) { return { success: false, message: 订单已过期请重新选座 } } // 金额从场次价格计算永远不信任前端传来的金额 const screeningRes await db.collection(screenings).doc(order.screeningId).get() const amountInFen screeningRes.data.price * order.seatPositions.length * 100 if (USE_MOCK_PAY) { await db.collection(orders).doc(orderId).update({ data: { status: PAID, amount: amountInFen, paidAt: Date.now() } }) await markSeatsSold(order) return { success: true, paid: true } } // 真实微信支付这里替换成统一下单接口参数需要 appid、mchid、证书 const payParams await unifiedOrder({ openid: order.openid, orderNo: order.orderNo, amount: amountInFen }) return { success: true, payParams } }两处必须强调。第一金额单位是分不是元接口入参和回调验签都要用分差 100 倍的 bug 在支付审核阶段才会暴露。第二amount必须由云函数根据场次价格计算前端传进来的金额只能做展示不能作为下单金额来源否则用户改一下请求参数就能一折买票。markSeatsSold负责把座位的锁定状态改成已售这里也建议用事务async function markSeatsSold(order) { const tx await db.startTransaction() const doc await tx.collection(screenings).doc(order.screeningId).get() const seats doc.data.seats for (const pos of order.seatPositions) { seats[pos.row][pos.col] 2 } await tx.collection(screenings).doc(order.screeningId).update({ data: { seats } }) await tx.commit() }真实支付模式下不要直接在payOrder里改订单为 PAID要等微信支付异步回调到了再改。云函数payCallback里做验签后按orderNo找到订单先查状态如果已经是 PAID 就直接返回成功避免重复回调造成重复出票。回调处理完统一调用markSeatsSold确保座位落为已售。5. 避坑支付回调解耦、并发锁座与时间格式踩过的坑5.1 支付成功但订单还是“待支付”回调幂等怎么补现象用户支付弹窗显示扣款成功订单列表里却一直是“待支付”手动刷新也没有变化。原因支付回调是异步的在云函数处理回调时可能出现两种情况一是回调触发了两次第一次更新失败第二次拿着已经 PENDING 的状态继续处理结果被状态判断拦下二是回调不是我们主动发起处理服务重启或网络抖动时容易丢。解决支付回调必须幂等而且要把“标记订单状态”和“出票”做成可重入的流程。我常用的方式是先按orderNo查订单// cloudfunctions/payCallback/index.js const orderRes await db.collection(orders).where({ orderNo }).get() const order orderRes.data[0] if (!order) return { success: false, message: order not found } if (order.status PAID) { return { success: true } // 重复回调直接返回成功 } await db.collection(orders).doc(order._id).update({ data: { status: PAID, paidAt: Date.now() } }) await markSeatsSold(order)这段代码的核心思路是“先查再改已支付就短路”。如果担心极端情况下两个回调同时进来还可以用条件更新where({ orderNo, status: PENDING }).update(...)只会有一个请求真正把 PENDING 改成 PAID另一个更新到的记录数为 0。补单机制也不能省定时任务里扫描时间较长但仍是 PENDING 的订单主动向微信支付查询真实支付状态。5.2 两人同时选同一个座位谁拿到票现象压测或真实抢票场景下两个用户同时提交同一个座位两个订单都创建成功出票时座位出现重复。原因锁座接口没有使用事务或者事务只包了“查看”没包“更新”。常见写法是先查座位是否为空再 update这两个步骤之间会有时间窗口并发请求都能通过检查。更隐蔽的是订单创建放到了事务外面锁座环节虽然稳了但订单创建失败时没有补偿也会出现座位状态错乱。解决按第 3 章的方案把“读取座位状态、校验、更新座位、提交事务”放在同一个事务里。事务提交失败后重试重试次数用尽再给用户提示“座位已被选请重新选择”。markSeatsSold同样必须用事务两个已支付订单同时回写同一个场次文档不带事务会把对方的已售状态覆盖成可售。云开发文档型数据库的事务会把整个场次文档当作冲突检测对象两个人抢不同座位只要在同一场次同样会产生冲突这是设计上必须接受的。解决方案是减少单场次座位文档的写入频率这也是把座位放进二维数组而不是独立集合的一个原因。5.3 iOS 上new Date(2025-02-14 19:30)变成 NaN现象开发工具里一切正常安卓机也正常iPhone 上电影列表的时间和场次全部显示Invalid Date或NaN。原因iOS 的 JavaScriptCore 引擎对-分隔的日期字符串解析支持不好new Date(2025-02-14 19:30)在 iOS 上解析失败而开发者工具用的是电脑浏览器内核不会报错。解决统一用时间戳传递和存储前端只对时间戳做格式化// utils/format.js function formatTime(ts) { const d new Date(ts) const pad n String(n).padStart(2, 0) return ${d.getFullYear()}-${pad(d.getMonth() 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())} } module.exports { formatTime }如果后端接口已经有字符串日期可以在解析前做一次替换ts.replace(/-/g, /)。所以数据模型里的startTime、endTime、releaseDate全部存时间戳展示层统一走formatTime。这个习惯能帮你绕开 iOS 一大半日期相关的问题。5.4 取消订单和退票后座位还留在“锁定”状态现象用户主动取消了一个 PENDING 订单或者退了一张已支付订单座位图上对应座位依然是灰色或已售别人没法买。原因订单状态改成 CLOSED 或 REFUNDED 之后代码没有同步释放座位。释放座位看起来只是把一个数字改回 0但因为要改的是同一个场次文档不处理并发就会产生“幽灵座位”。解决释放座位必须和关闭订单做成同一套动作我习惯抽一个releaseSeats函数定时器、取消订单、退票都调用它async function releaseSeats(order) { const tx await db.startTransaction() const doc await tx.collection(screenings).doc(order.screeningId).get() const seats doc.data.seats for (const pos of order.seatPositions) { if (seats[pos.row][pos.col] 2 || seats[pos.row][pos.col] 1) { seats[pos.row][pos.col] 0 } } await tx.collection(screenings).doc(order.screeningId).update({ data: { seats } }) await tx.commit() }退票时把 2 改回 0取消订单时把 1 改回 0这个函数不用关心订单当前是什么状态只负责“回到可售”。但上层调用方必须保证不会把已售座位误释放比如用户取消了别人已经出票的座位这种逻辑错误不该由释放函数来判断而是在调用前校验订单状态和当前持有者。5.5 个人开发者调起支付时报“当前商户号没有该权限”现象代码完全按文档写了支付统一下单接口也返回了参数但小程序调起支付时弹窗报错或者审核提示“缺少票务类目”。原因个人主体小程序没有微信支付能力而且电影票业务需要对应的服务类目个人主体基本无法申请到该类目。这一点在源码演示阶段容易误导人有些人改了半天商户配置才发现是主体问题。解决个人开发者把USE_MOCK_PAY打开先把选座、订单、出票、退票全流程跑通真实上线需要公司主体小程序并在微信支付商户平台完成商户号申请和绑定。这不是代码能绕过的限制不要在源码里试图绕过支付资质审核环节会直接拦截。6. 上线前最值得做的一次验证并发锁座压测与订单补单6.1 用云函数模拟并发抢座验证事务是否真的生效电影购票小程序上线前最值得做的验证不是反复点页面而是同一时间抢同一个座位。没有真实用户并发时用一个压测云函数把锁座逻辑并发跑几遍就能看出问题。// cloudfunctions/lockStressTest/index.js const cloud require(wx-server-sdk) cloud.init({ env: cloud.DYNAMIC_CURRENT_ENV }) const { lockSeatWithTx } require(./lockSeat) exports.main async (event) { const { screeningId, seatPositions } event const results await Promise.allSettled([ lockSeatWithTx(screeningId, seatPositions), lockSeatWithTx(screeningId, seatPositions) ]) return { first: results[0].status, second: results[1].status, conflictHandled: results[0].status ! results[1].status } }注意这里的lockSeatWithTx必须和第 3 章锁座接口抽成同一个模块压测的才是线上真正跑的代码。如果压测结果两个都成功了说明你的事务校验没生效如果一个成功一个失败事务逻辑才算正常。压测后要手动清理测试场次的座位状态避免脏数据留到正式环境。真实线上案例里这种压测最早能抓到的往往是“订单创建在事务外导致座位锁了但订单没产生”的补偿缺失问题。6.2 补单与对账脚本给历史订单补写座位状态再稳的系统也会遇到手动改库、支付回调丢失、服务重启脏数据。我的习惯是留一个orderRepair云函数专门扫描已 PAID 订单和场次座位状态是否一致// cloudfunctions/orderRepair/index.js const db cloud.database() const { markSeatsSold } require(./seatFlush) exports.main async () { const paidOrders await db.collection(orders).where({ status: PAID }).get() for (const order of paidOrders.data) { const doc await db.collection(screenings).doc(order.screeningId).get() const seats doc.data.seats const mismatch order.seatPositions.some( pos seats[pos.row][pos.col] ! 2 ) if (mismatch) { await markSeatsSold(order) } } }这个脚本不会动正常订单扫描出来的不一致记录自动修复用完就把定时触发关了平时手动跑。补单的逻辑不能只修订单状态必须同时修座位否则用户拿到的订单号在票夹里能看到但入场时会和现场座位状态对不上。我自己的习惯是把锁座、释放座位、标记已售这几个座位操作全部收进同一个公共模块定时器、支付回调、取消订单、压测脚本都用这一份。座位状态是电影票系统的命门分散写早晚会有一处逻辑不一致。线上出过的几回问题最后都指向同一类原因某处状态改了另一处没跟上。希望这个环节能帮到你。本文还有配套的精品资源点击获取