微信小程序彩票销售系统:状态机驱动交易与支付回调实践
2026/9/16 1:21:35 网站建设 项目流程

简介:微信小程序彩票销售与管理系统项目包含一套完整的彩票销售与后端管理代码,涵盖双色球、大乐透、七星彩等彩种的选球、追号、合买、开奖信息、账户余额及中奖记录等核心功能。前端由微信小程序承载,后端基于PHP实现,适合作为毕业设计、课程项目或彩票行业原型参考。包内共1407个文件,以PHP业务逻辑文件(977个php)为主,另含JSON数据配置、JavaScript交互脚本、WXML/WXSS页面文件及HTML静态页面,整体压缩后仅2.84MB,目录结构清晰,便于按模块检索。已有534人浏览学习,适合具备微信小程序基础或PHP开发经验的读者深入研究。源码中预留了用户唯一标识openid、短信验证码、双色球与大乐透开奖数据数组等关键变量,并展示出application、system等典型PHP项目目录,读者可快速定位彩票期号管理、合买方案与开奖详情解析的逻辑,掌握选球、后端数据交互与状态管理的完整实现方式,方便二次开发。

1. 彩票销售与管理小程序,真正的难点在交易状态的对账

一家彩票店要做线上化,最容易想到的是“做一个能选号、能付款的小程序”。真做起来你会发现,最难的不是页面好看,也不是支付调通,而是“用户付了钱、门店没出票”以及“票出了、系统还显示待支付”这类状态不一致。这个标题对应的系统,核心是三块:面向顾客的选号与下单、面向门店的出票确认与额度管理、面向经营者的销售报表。整个技术方案必须围绕交易状态机展开,而不是围绕页面。适合三类人阅读:彩票门店想做小程序获客;行业软件团队在给门店做系统化改造;以及想完整跑通“登录、下单、支付、回调、报表”闭环的微信小程序开发者。

2. 技术选型决定工作量:原生小程序还是 uni-app 的微信小程序

2.1 先想清楚管理端跑在哪,再决定技术栈

彩票销售与管理系统的使用者有两类:顾客在微信里打开小程序下单,门店店员或店长需要一个操作界面来确认出票、查看当日销售。这两类操作对端的要求不一样。顾客端只需要微信小程序;店员端如果只在店里用,同样可以做成小程序里的“店员模式”入口,这样不用额外开发 App。比较现实的选型是:主体代码用 uni-app 发布到微信小程序,保留将来编译到 App 的余地;如果确定只服务微信用户,原生小程序反而更轻。

原生小程序和 uni-app 的主要区别可以从这张表里看:

对比项原生小程序uni-app(发布到微信小程序)
技术栈WXML / WXSS / JSVue 语法,编译成小程序包
多端复用只服务微信同一套代码可发 App、H5
调试方式微信开发者工具直接打开先构建,再导入微信开发者工具
组件生态官方组件 + 第三方原生组件uni-ui,跨端组件留意兼容性
包体积相对紧凑带运行时,首包略大
适用场景明确只做微信小程序后续可能做 App / H5 管理端

我一般这样判断:如果管理端打算做成“店长在手机上的一个小程序页面”,用原生就够;如果规划里还有“店员用安卓机核销”“总部看 H5 报表”这类跨端需求,从一开始就用 uni-app,避免后期重写。标题里是“微信小程序彩票销售与管理系统”,最稳妥的落地方式是 uni-app 微信小程序 + 独立管理后台接口,业务逻辑全部放服务端,小程序只做页面和交互。

2.2 搭一个能跑的微信小程序工程骨架

用 uni-app 创建工程的做法是:在 HBuilderX 里新建 uni-app 项目,选默认模板,然后通过“运行 -> 运行到小程序模拟器 -> 微信开发者工具”。也可以在命令行用 vue-cli 创建。一个可用的工程目录大概是这样:

lottery-miniapp/ ├─ src/ │ ├─ pages/ │ │ ├─ index/ # 首页:彩种列表与开奖信息 │ │ ├─ order/ # 下单页:选号、确认金额 │ │ ├─ pay/ # 支付结果页 │ │ └─ admin/ # 店长管理页:待出票、报表 │ ├─ static/ # 静态资源 │ ├─ utils/ │ │ ├─ request.js # 统一的请求封装,带 token │ │ └─ auth.js # wx.login 登录逻辑 │ ├─ App.vue │ ├─ main.js │ └─ pages.json # 页面路由与 tabBar 配置 └─ manifest.json # 微信小程序 appid 等配置

pages.json 里最值得先写的是 tabBar,把“首页、订单、我的”三个入口放进去,管理页不放进 tabBar,而是从“我的”页面里按角色进入。这样一个包就同时服务顾客和店长。tabBar 的配置要写图标路径,没有图标可以用纯文字,官方要求 iconPath 可选,最少配置 pagePath 和 text 即可运行。

2.3 页面拆解与顶部导航的适配

彩票销售页的信息密度比其他电商高:期号、截止时间、玩法说明、号码区、倍数、金额。设计上我一般把页面拆成三块:顶部期号倒计时、中间选号区(号码阵列 + 单式/复式切换)、底部固定金额栏。这个底部固定栏要做成普通 fixed 布局,配合safe-area-inset-bottom处理 iPhone 底部小黑条。

如果不想默认导航栏挡住倒计时,很多人会把导航栏隐藏掉,自己做一个自定义头部。这种自定义导航的做法要自己算状态栏高度。官方推荐的方式是:在App.vueonLaunch里读取uni.getSystemInfoSync().statusBarHeight,再在页面顶部的padding-top使用这个值,而不是写死 20px 或 44px。不同机型状态栏高度差异很大,写死会在刘海屏上出现内容遮挡。

// App.vue 的 onLaunch 中保存系统信息 const sysInfo = uni.getSystemInfoSync() this.globalData.statusBarHeight = sysInfo.statusBarHeight this.globalData.navBarHeight = 44 // 如果启用了自定义导航,页面里的 .custom-nav 需要动态设置高度

statusBarHeight 是状态栏高度,navBarHeight 通常取 44,Android 部分机型胶囊按钮位置不同,用uni.getMenuButtonBoundingClientRect()拿胶囊位置算居中更准确。这里的变量值只是初始值,真正渲染时要在页面的计算属性里把statusBarHeight + navBarHeight当作整个自定义导航的总高度,底部内容再用calc(100% - 该高度)避让。

3. 核心交易链路:从选号下单到支付回调,状态机不能只靠前端

3.1 登录态:用 wx.login 拿到 code,后端再换 openid

彩票销售系统必须识别用户身份,因为涉及到查订单、领奖、限购。常见的做法是:前端调 wx.login 拿临时凭证 code,把它发给自己的后端;后端调用微信接口用 code 换 openid 和 session_key,然后返回一个自定义 token 给小程序。以后每次请求都带上这个 token。

// utils/auth.js 登录封装 function login() { return new Promise((resolve, reject) => { uni.login({ provider: 'weixin', success(res) { // res.code 有效期只有 5 分钟,只能换一次 uni.request({ url: 'https://api.example.com/user/login', method: 'POST', data: { code: res.code }, success(response) { const token = response.data.data.token uni.setStorageSync('token', token) resolve(token) }, fail: reject }) }, fail: reject }) }) }

uni.login 拿到的 code 是一次性的,后端换到 openid 后,前端不要保存 openid,只保存后端签发的 token。code 不能用两次,所以登录接口要做重复 code 的兜底处理。如果你的服务端是 PHP,做法完全一样,只是把后端换成code2Session接口换取 openid,再自己生成登录态返回给小程序,前端这段代码可以保持不变。

微信开发者工具里调试登录态时,注意看 Network 面板里/user/login的返回,最常见的报错是code 无效,原因是同一份 code 在多个页面重复触发登录。处理办法是在 App.vue 的onLaunch里只调一次登录,后面所有页面从 Storage 里读 token。

3.2 下单与额度校验:先锁资源再支付

彩票下单比普通商品多一个约束:期号是有截止时间的。只要截止时间一过,再好的代码也不应该允许下单。这个判断必须放在服务端,用服务器时间,不能用用户手机时间。下单接口的流程有三步:校验期号和截止时间;校验用户购买限制;生成订单并标记“待支付”。

// order/create 下单接口核心逻辑 async function createOrder(ctx) { const { periodId, numbers, multiple } = ctx.request.body const period = await db.collection('periods').doc(periodId).get() // 服务端时间判断截止,不能用前端传的时间 if (Date.now() > period.endTime) { throw new Error('该期已截止') } const amount = numbers.length * multiple * period.price // 预扣额度:把状态从 created 推到 pending const orderNo = generateOrderNo(period.code) await db.collection('orders').add({ _id: orderNo, periodId, numbers, multiple, amount, status: 'pending', // pending -> paid -> printed -> drawn createTime: db.serverDate() }) // 返回给前端一个签名后的支付参数 return { orderNo, amount } }

periodId 指彩票期号,endTime 必须用数据库服务器时间;numbers 是选号数组;amount 由服务端计算,前端传的金额只做展示,不能参与最终计算。这里把状态流转串在单字段上,后续每一步更新都校验当前状态,能有效避免重复出票和重复兑奖。

购买限制这一步也放在这里:按用户维度查当日已购金额,超过阈值直接返回错误。很多门店会设置“单期限额”或者“每人每日限额”,这个配置放数据库,不要写死在代码里。金额单位统一用分,避免浮点误差,前端展示时再除以 100。

3.3 微信支付回调:验签、幂等、和状态机的流转

小程序的微信支付流程是:后端调用微信支付统一下单接口,拿到预支付参数,前端用uni.requestPayment拉起收银台。这一步和普通电商一样。容易出问题的是支付回调。微信支付成功后,微信服务器会以 POST 方式回调你填写的 notify_url,这个回调里才有最终的支付结果。

设计状态机时至少要覆盖这些状态:

状态含义流转触发
pending订单已创建,等待支付用户发起支付
paid支付成功,等待门店出票支付回调验签通过
printed门店已出票店长端确认出票
drawn已开奖可兑奖开奖后批处理更新
refunded已退款管理员操作或系统对账

回调处理要写两个关键点:验签和幂等。验签保证请求确实来自微信;幂等保证同一次支付成功通知重复到达时,不会给用户加两次余额。

// 支付回调处理,Express 风格的简化实现 router.post('/notify/wechat-pay', async (ctx) => { const raw = ctx.request.rawBody const valid = verifyWechatSign(raw, ctx.request.headers) // 基于商户密钥验签 if (!valid) return ctx.body = 'fail' const { out_trade_no, result_code, total_fee } = parseData(raw) if (result_code === 'SUCCESS') { // 幂等:先查订单当前状态,只有 pending 才更新 const order = await db.collection('orders').doc(out_trade_no).get() if (order.status === 'pending') { await db.collection('orders').doc(out_trade_no).update({ status: 'paid', payTime: db.serverDate(), paidFee: total_fee }) // 推送订阅消息提醒门店有新订单 } } return ctx.body = 'success' // 告诉微信不要再重复回调 })

total_fee单位是分,自己生成订单时的金额也是分,避免浮点数误差;回调里必须拿out_trade_no去查订单,比较实际支付金额和订单金额是否一致,不一致要标记异常订单。验签失败必须返回非 success 字符串,微信侧会继续重试。out_trade_no必须用系统的订单号而不是自增 id。订阅消息模板 ID 需要在微信公众平台申请,门店侧可以订阅“新订单”模板,用户侧可以订阅“出票成功”模板,两套通知分开申请。

4. 管理端:订单同步、出票确认与报表导出

4.1 管理端承载方式:小程序内嵌管理页还是独立 Web

销售和管理是一套系统,但使用者不一样。彩票店规模不大时,店长在店里用手机操作最方便,所以管理功能优先做进小程序,在“我的”页面里根据角色显示“待出票”“当日报表”入口。角色判断放在后端,前端只根据接口返回控制入口显示。这只算“半套管理端”,优点是开发量小,缺点是不适合大量打票操作。

如果店里是电脑连热敏打印机,管理端就得有 Web 页面。常见做法是服务端同一套 API,Web 端用 Vue 做单页应用,在小程序里只保留“待出票数”的角标提醒。两种方案的取舍如下:

方案优点缺点适用场景
小程序管理页免安装、手机随时处理打票依赖蓝牙或手工录入单店、票量小
独立 Web 管理端键盘 + 大屏效率高多一套前端工程票量大、需要接打印机
小程序管理页 + 共用 API一套接口两端用权限控制要细大多数门店的起步方案

我一般建议先用“小程序管理页 + 共用 API”起步,出票确认做成“点一下标记已出票”,机器打票留到 Web 端再加。权限控制要细,接口层用中间件判断角色:

// 服务端中间件:只有店长角色能调用出票接口 async function requireAdmin(ctx, next) { const user = ctx.state.user if (!user || user.role !== 'admin') { ctx.status = 403 ctx.body = { code: 403, message: '无权限' } return } await next() } // 路由示例 router.post('/admin/order/print', requireAdmin, adminController.markPrinted)

token 里不要存角色,角色应每次从数据库读取,因为店员可能被提升为店长,也可能被撤销权限。前端隐藏按钮只是体验优化,真正的控制必须在后端中间件里完成。

4.2 出票回写与开奖通知:状态同步的两个关键点

门店出票后,订单状态要从 paid 变成 printed。这一步操作方是门店,不是用户,所以接口权限上必须是店长。用户端要能看到“已出票”而不是一直转圈。前端拉订单列表的时机:onShow时拉一次即可,不需要 WebSocket。如果店面大、票量快,可以用定时轮询,间隔 5 秒已经足够,不要小于 3 秒,避免接口压力大。

出票后给用户发订阅消息的代码:

// 出票确认后,服务端调微信订阅消息接口 const messageData = { touser: order.openid, templateId: '模板ID-出票通知', page: 'pages/order/index?orderNo=' + orderNo, data: { thing1: { value: '双色球 第 2024123 期' }, amount2: { value: order.amount + '元' }, thing3: { value: '已出票,等待开奖' } } } await callWechatMessageApi(messageData)

订阅消息一次性订阅只能发一次,用户下单时就要先用wx.requestSubscribeMessage发起订阅授权,否则后面没有资格推送。授权时机放在支付成功后弹窗提示最自然,放在下单前容易因为打断选号操作被用户拒绝。

开奖后的兑奖逻辑写进定时任务:每期开奖后,比对订单号码和开奖号码,把中奖订单标记为 drawn,再把奖金退回用户余额。这一步必须在服务端完成,不能依赖用户手动点击。定时任务跑完后要生成一份对账单,金额不平的单独标记出来人工核对。

4.3 报表导出 Excel:后端生成比前端拼接更省心

店主看报表的频率很高,但小程序里直接生成 Excel 体验一般,更稳的是让服务端生成文件,小程序端用wx.downloadFile下载后用wx.openDocument打开。前端不需要引入 xlsx 库,代码量少,维护也简单。报表页里放一个“导出本月销售”的按钮,点击后请求后端接口拿到下载地址。

服务端生成 Excel 可以用 Node.js 的 exceljs 库,数据流直接响应给下载接口:

// 服务端生成 Excel 并返回临时下载地址 const ExcelJS = require('exceljs') async function exportReport(ctx) { const workbook = new ExcelJS.Workbook() const sheet = workbook.addWorksheet('日销售明细') sheet.columns = [ { header: '日期', key: 'date', width: 12 }, { header: '销售额', key: 'amount', width: 10 }, { header: '订单数', key: 'count', width: 8 }, { header: '退款额', key: 'refund', width: 10 } ] // 从订单表聚合数据后逐行添加 sheet.addRow({ date: '2024-07-01', amount: 12000, count: 86, refund: 240 }) // 生成文件流,通过 Content-Disposition 触发下载 }

sheet.columns定义表头,addRow按列 key 填值。生成文件后如果不方便存本地,可以上传到云存储拿到一个带有效期的临时链接。小程序端wx.downloadFile下载后拿到tempFilePath,再传给wx.openDocument预览,文件类型传fileType: 'xlsx'。报表里除了销售额,还要统计各彩种占比,建议在订单表里冗余一个lotteryType字段,按它分组聚合最方便。

5. 发布前调对的几个参数,和真机上容易翻车的细节

5.1 request 合法域名、支付回调地址与基础库版本

小程序发到体验版之前,先到微信公众平台把接口域名加入 request 合法域名。开发时可以在开发者工具里打开“不校验合法域名”,但体验版和正式版不会放过,这个开关只属于开发环境。域名要求 HTTPS,证书需要完整链,缺中间证书会导致真机上偶发请求失败。支付回调地址的域名也要加入支付回调域名配置,否则微信服务器找不到回调入口。

开发者工具调试时,用自带的 Network 面板看请求最直接:确认每个 wx.request 都带了 token,确认支付参数里的timeStamp是字符串。支付接口最容易错的是把timeStamp传成 number,微信要求是字符串;再有就是package参数要以prepay_id=开头,少一个字符都调不起收银台。基础库版本方面,用了wx.requestSubscribeMessage就要在详情里设置最低基础库版本,避免用户微信版本过旧导致接口未定义。

5.2 一个容易忽略的坑:期号切换不能用本地时间

彩票销售系统最常见的线上事故是“开奖前 10 分钟还能下单”。原因多半是前端用手机时间判断截止,而手机时间可以被用户修改。正确做法是:所有截止校验都在服务端,前端倒计时只做展示,接口返回截止时间,前端用它做倒计时,服务器在收到下单请求时再校验一次。另一个相关的坑是:开奖后店主在后台切换期号,用户端还停留在旧期号页面,小程序进入页面时要重新拉一次“当前可售期号”接口,不能把期号缓存写死在本地。

最后一个实用技巧:把彩种、每期销售时间、末班截止时间放到数据库的 settings 表里,小程序启动时拉一次并缓存,而不是每次改配置都发版。启动加载页的逻辑可以先渲染本地缓存,再异步拉设置更新,用户感知不到等待。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询