微信小程序在线活动报名系统开发:从微信登录、手机号授权到原子化库存扣减

发布时间:2026/10/29 7:32:43
微信小程序在线活动报名系统开发:从微信登录、手机号授权到原子化库存扣减

简介:这是一份基于微信小程序开发的在线活动报名系统完整源码,面向小程序初学者、前端开发者及需要快速搭建活动报名场景的运营人员。项目实现活动展示与分类、报名表单填写、微信支付集成、报名状态追踪、消息推送等核心模块,并配有后台管理功能,可覆盖活动发布、用户报名、数据查看与处理的全流程。压缩包共74个文件,以png图标资源、js逻辑代码、wxss样式、wxml页面结构和json配置文件为主,整体仅81KB,目录层次清晰,便于直接导入微信开发者工具运行或按需二次定制。已有898人学习下载,适合通过阅读pages、utils、services等模块理解小程序生命周期、WXML/WXSS语法及微信开放接口的典型用法。开发者可从中获得一套可运行的前后端交互示例,同时参考支付、消息推送及个人中心等模块设计,快速迁移到自己的实际项目中。

1. 微信小程序在线活动报名系统:为什么表单提交不等于报名成功

做微信小程序-在线活动报名系统,本质是把「发布活动 → 用户浏览 → 填写报名 → 名额管理 → 名单导出」这条链路完整搬进小程序里。我见过不少开发者和活动运营把这件事想简单了:一个活动列表、一个表单页、一个提交按钮,看上去跑通了,结果活动真正开放报名那天,重复提交、库存超卖、手机号拿不到、用户收不到确认通知,随便一个都能让活动方在开场前夜手忙脚乱。下面会把报名系统的核心链路拆开讲清楚:页面骨架怎么做、微信登录态怎么接、提交接口怎么设计、有哪些坑必须提前避开。适合正准备自己开发报名小程序,或者接手这类项目的开发者阅读。

2. 报名页面的最小闭环:从活动列表到提交按钮的骨架

2.1 先定技术选型:原生框架是报名系统的最短路径

做这个项目的第一步不是写页面,而是定框架。常见做法有两种:微信原生小程序框架,或者 Uniapp 这类跨端方案。就报名系统而言,我更推荐原生框架,尤其是只做小程序端、不带 App 的场景。

理由有三点。第一,微信登录、手机号授权、订阅消息这些报名系统的关键能力,原生框架的封装链路最短,官方文档对应最直接,出问题时能少绕一层。第二,原生框架在微信开发者工具里的调试体验最稳定,报错信息能直接定位到代码文件。第三,报名系统本身的业务逻辑不算复杂,原生的开发效率完全够用。Uniapp 的优势在多端复用,但打包到微信小程序时经常遇到 source size 超过 2MB 上限的问题,还要处理自定义组件和原生 API 的差异,对「只做小程序」的报名业务来说属于额外成本。

页面结构上,一个最小可用的报名小程序只需要三个页面:

  • pages/activities/list:活动列表,展示可报名的活动
  • pages/activities/detail:活动详情,展示时间、地点、剩余名额
  • pages/signup/form:报名表单,收集姓名、手机号和备注

在app.json里注册页面:

{ "pages": [ "pages/activities/list", "pages/activities/detail", "pages/signup/form" ], "window": { "navigationBarTitleText": "活动报名", "navigationBarBackgroundColor": "#ffffff" } }

pages数组里的第一项是启动页,这里把活动列表作为首页。navigationBarTitleText决定顶部导航的标题,会直接影响用户第一眼看到的品牌信息,建议改成活动方的名称,不要用默认的「微信小程序」。

2.2 活动列表与 wx.request 渲染:合法域名是第一个拦路虎

活动列表页的数据来源是后端接口。小程序端用wx.request发起请求,这跟浏览器里的fetch类似,但有一个硬性约束:请求的域名必须在小程序后台配置到 request 合法域名列表里,否则真机上直接请求失败。开发者工具里可以临时勾选「不校验合法域名」来调试,但这只是本地开关,上线前必须把域名配好,而且要求 HTTPS。

列表页的 JS 逻辑如下:

Page({ data: { activities: [], loading: true }, onLoad() { this.fetchActivities() }, fetchActivities() { wx.request({ url: 'https://api.example.com/api/activities', method: 'GET', success: (res) => { if (res.statusCode === 200 && res.data.code === 0) { this.setData({ activities: res.data.data.list, loading: false }) } }, fail: () => { wx.showToast({ title: '活动列表加载失败', icon: 'none' }) this.setData({ loading: false }) } }) } })

这里有几个参数值得说明。url必须是线上可访问的 HTTPS 接口,method默认是GET,列表查询用 GET 没问题。success回调里我习惯先判断statusCode,再做业务码code的判断——HTTP 200 只代表请求通了,不代表业务成功。fail回调处理的是网络层异常,比如断网、域名未配置、证书过期,这些情况要单独给用户提示,不能静默失败。

对应的 WXML 用wx:for循环渲染活动卡片,每一张卡片展示标题、时间和剩余名额,点击后跳转详情页:

<view class="activity-list"> <view wx:for="{{activities}}" wx:key="id" class="activity-card"> <view class="activity-title">{{item.title}}</view> <view class="activity-time">{{item.start_time}} - {{item.end_time}}</view> <view class="activity-remain">剩余名额:{{item.remaining}}</view> <button bindtap="goDetail">const PHONE_RE = /^1[3-9]\d{9}$/ Page({ data: { form: { name: '', phone: '', remark: '' } }, onSubmit() { const { form } = this.data if (!form.name || form.name.trim().length < 2) { wx.showToast({ title: '请填写真实姓名', icon: 'none' }) return } if (!PHONE_RE.test(form.phone)) { wx.showToast({ title: '手机号格式不正确', icon: 'none' }) return } // 校验通过,进入提交流程 this.submitSignup(form) } })

/^1[3-9]\d{9}$/这个正则覆盖了目前国内绝大部分手机号段。需要说明的是,正则只能校验格式,不能校验号码是否真的在用,所以服务端提交后还要做一次同样的校验,并在必要时接入短信验证码。trim()处理的是用户输入前后空格的情况,这类细节在表单场景里很常见——用户从通讯录复制号码时经常会带一个空格。

前端校验只是体验优化,真正的安全边界在后端。这一点在第四章的接口设计里会重点展开。

3. 微信登录与手机号授权:让报名数据具备实名可用性

3.1 wx.login 换 openid:先搞清楚谁在报名

报名系统最容易被忽略的是「身份」问题。活动方要的是名单,但如果名单里只有姓名和手机号,没有任何微信身份标识,一旦用户说「我没报过名」或者「我报错了想取消」,你很难定位到具体记录。所以第一步要先接微信登录。

wx.login是微信小程序的身份入口,它拿到的code是一个临时凭证,需要发给后端,由后端去微信接口换openid:

wx.login({ success: async (res) => { if (!res.code) { wx.showToast({ title: '登录失败,请重试', icon: 'none' }) return } const loginRes = await new Promise((resolve) => { wx.request({ url: 'https://api.example.com/api/auth/login', method: 'POST', data: { code: res.code }, success: resolve }) }) if (loginRes.data.code === 0) { wx.setStorageSync('token', loginRes.data.data.token) } } })

后端拿到code后,用jscode2session接口换取openid:

const axios = require('axios') const APPID = '你的小程序AppID' const APPSECRET = '你的小程序AppSecret' const { code } = req.body const url = 'https://api.weixin.qq.com/sns/jscode2session' const { data } = await axios.get(url, { params: { appid: APPID, secret: APPSECRET, js_code: code, grant_type: 'authorization_code' } }) const openid = data.openid

这段代码有两个关键点。第一,APPID和APPSECRET在小程序后台的「开发管理 - 开发设置」里获取,APPSECRET只能保存在服务端,绝不能写进小程序代码里。第二,openid是用户在当前小程序下的唯一标识,换到另一个小程序会变,所以它只能用来识别「谁在这个小程序里操作」,不能跨小程序使用。

后端拿到openid后,查用户表,存在则复用记录并签发自定义token,不存在则创建新用户。token的有效期我一般设 2 小时,过期后前端再调wx.login走一次静默登录,用户无感知。

3.2 手机号快速验证组件:报名数据能不能用的关键

姓名可以随便填,手机号却必须是真实的,否则活动方联系不上用户。微信提供了手机号快速验证组件,用户点击一次授权,微信直接返回手机号,不需要手动输入,也省掉了短信验证码的成本。

组件的用法是一个带特殊open-type的按钮:

<button open-type="getPhoneNumber" bindgetphonenumber="onGetPhoneNumber"> 使用微信手机号快速验证 </button>

对应的 JS 处理:

onGetPhoneNumber(e) { if (e.detail.errMsg !== 'getPhoneNumber:ok') { wx.showToast({ title: '需要授权手机号才能报名', icon: 'none' }) return } // e.detail.code 是动态令牌,传给后端换取真实手机号 wx.request({ url: 'https://api.example.com/api/signup/phone', method: 'POST', data: { code: e.detail.code }, success: (res) => { const phone = res.data.data.phoneNumber this.setData({ 'form.phone': phone }) } }) }

这里要特别注意:新版手机号验证组件返回的不是加密数据,而是一个code动态令牌。后端需要用这个code调用微信的getuserphonenumber接口,换取真实的手机号,接口地址是:

POST https://api.weixin.qq.com/wxa/business/getuserphonenumber?access_token=ACCESS_TOKEN

请求体只需要一个字段:{ "code": "用户端传过来的动态令牌" }。响应里的phone_info.phoneNumber就是完整手机号。

这个方案省掉短信成本,但对真机测试要求很高。开发者工具里点击手机号授权按钮返回的是模拟数据,不能代表真实链路。所以涉及手机号授权的功能,必须在真机上完整测一遍。测试时还要覆盖「用户拒绝授权」的场景——拒绝后要能给出友好提示,最好保留手动输入手机号的入口做兜底。

3.3 报名记录落库:openid、手机号与唯一索引怎么配合

登录态和手机号都接好后,要设计数据表。报名系统的核心是两张表,一张存活动,一张存报名记录:

CREATE TABLE activities ( id INT PRIMARY KEY AUTO_INCREMENT, title VARCHAR(128) NOT NULL, start_time DATETIME NOT NULL, end_time DATETIME NOT NULL, total_quota INT DEFAULT 0, remaining INT DEFAULT 0, status TINYINT DEFAULT 1, created_at DATETIME DEFAULT CURRENT_TIMESTAMP ); CREATE TABLE signups ( id INT PRIMARY KEY AUTO_INCREMENT, user_id INT NOT NULL, activity_id INT NOT NULL, name VARCHAR(64) NOT NULL, phone VARCHAR(20) NOT NULL, status TINYINT DEFAULT 0, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_user_activity (user_id, activity_id), UNIQUE KEY uk_activity_phone (activity_id, phone) );

signups表里存了user_id和activity_id两个外键,但没有用外键约束,只建了普通索引。这样做的原因是:外键约束在高并发写入时开销大,而报名系统的并发峰值往往集中在活动开放报名的那几秒,去掉外键约束能减少锁竞争。

uk_user_activity唯一索引保证同一个用户对同一个活动只能报一次名,这是常规做法。uk_activity_phone是额外加的一道锁,用来防「一个人换个微信号再报一次」——同一个活动下,相同手机号只允许出现一条报名记录。这两个唯一索引是数据库层面的兜底,比应用层if判断可靠得多。status字段默认 0,后面在第四章的状态机里解释。

4. 报名提交与库存扣减:接口设计、原子操作与状态机

4.1 报名提交接口:字段、返回与异常约定

报名接口是整个系统的核心接口,字段设计要一次到位,避免后期返工。常见的报名提交接口是POST /api/signup,请求体如下:

{ "activityId": 10, "name": "张三", "phone": "13800001111", "remark": "带一个朋友" }

activityId对应活动表主键,name是报名人姓名,phone是手机号,remark选填。注意:这三个字段必须全部由服务端重新校验,不能信任前端传过来的任何值。

返回体统一用一个结构:

{ "code": 0, "message": "ok", "data": { "signupId": 123 } }

code是业务状态码。我习惯把业务码和 HTTP 状态码分开:HTTP 200 不代表报名成功,一切以code为准。报名接口可能出现的业务码如下:

code含义前端提示
0报名成功成功,跳转结果页
4001参数错误请补充必填项
4002活动不存在活动已下线
4003活动未开始或已结束当前不在报名时间
4004名额已满手慢了,下次早点
4005重复报名你已报过该活动

接口层把业务码定清楚,前端处理逻辑就简单了:showToast根据code映射提示语即可。如果后端还返回了data.signupId,前端可以把它带上,作为报名记录的凭证。

4.2 库存扣减用原子操作:先查后写一定会超卖

报名系统最经典的坑是库存超卖。活动只有 50 个名额,结果 52 个人报名成功。原因几乎都是同一个:先查剩余名额,再判断是否能报名,最后更新库存。

错误写法是这样的:

SELECT remaining FROM activities WHERE id = 10; -- 假设查出来 remaining = 1

此时判断remaining > 0,然后执行更新。问题在于,两个请求可能同时查出remaining = 1,都判定可以报名,然后都执行扣减,最终名额变成 -1。

正确的做法是单条 SQL 完成「判断 + 扣减」:

UPDATE activities SET remaining = remaining - 1 WHERE id = ? AND remaining > 0;

这行 SQL 的巧妙之处在于WHERE remaining > 0:如果剩余名额是 0,更新不生效,affectedRows为 0。应用层只需要判断affectedRows:

  • affectedRows = 1:扣减成功,继续插入报名记录
  • affectedRows = 0:名额已满,返回4004

扣减和插入报名记录必须放在同一个事务里:

START TRANSACTION; UPDATE activities SET remaining = remaining - 1 WHERE id = ? AND remaining > 0; -- 如果 affectedRows = 0,这里直接 ROLLBACK INSERT INTO signups (user_id, activity_id, name, phone, status) VALUES (?, ?, ?, ?, 0); COMMIT;

为什么必须用事务?因为扣减库存和插入报名记录是两步操作,如果先扣库存、插入失败,名额就凭空消失了;反过来先插入、扣减失败,用户会看到报名成功但实际没占上名额。事务保证这两步要么都成功,要么都失败。如果插入时报唯一索引冲突(重复报名),事务回滚后已经扣掉的名额会自动恢复,不需要额外补偿逻辑。

4.3 报名状态机:取消报名必须归还名额

signups表里的status字段需要一个明确的状态机。常见的报名状态:

状态值含义说明
0待确认刚提交,等待活动方审核
1已确认审核通过,正式占一个名额
2已拒绝活动方驳回
3已取消用户或活动方取消
4已签到活动当天完成签到

状态流转有几个约束:待确认可以流转到已确认或已拒绝;已确认可以流转到已取消;已取消和已拒绝是终态,不能回退;已签到只有在活动结束后由活动方在后台操作。

取消报名时要记得归还名额:

UPDATE signups SET status = 3 WHERE id = ? AND status IN (0, 1); UPDATE activities SET remaining = remaining + 1 WHERE id = ?;

这两条语句同样要放在事务里,顺序上先更新报名状态、再归还名额。归还名额时要保证只有status IN (0, 1)的记录才能取消,已签到的记录不能取消,否则会出现活动都结束了名额还在变的情况。

5. 报名系统的 5 个高频避坑点:真机、并发与时间校验

5.1 用户收不到报名确认通知:订阅消息的「一次性」授权陷阱

现象:用户在报名页填完表单点了提交,报名记录也插进去了,但用户一直收不到确认通知,活动方后台确认了也没用。

原因:微信小程序的订阅消息是「一次性订阅」——用户每次同意授权,只能收到一条消息。如果代码里把wx.requestSubscribeMessage放在onLoad里调用,或者在用户还没有交互时触发,微信会直接拒绝授权。而且模板 ID 配错、模板内容审核未通过,也会静默失败,前端不报错。

解决:把订阅消息的请求放到报名按钮的bindtap回调里,用户在点击按钮这一瞬间发起订阅请求,这是微信允许的交互时机:

onSubmit() { wx.requestSubscribeMessage({ tmplIds: ['模板ID'], success: (res) => { // 无论用户是否同意,都继续提交报名 this.submitSignup() }, fail: () => { this.submitSignup() } }) }

要点是:订阅消息的成功与否不能阻断报名流程,用户拒收通知也要允许报名,否则活动方会流失大量潜在报名者。服务端在报名状态变更时(从待确认到已确认)调用发送接口,用openid定位接收人。

5.2 开发者工具能拿手机号,真机上拿不到

现象:手机号快速验证组件在微信开发者工具里点一下就能返回手机号,但真机预览时点了授权要么没反应,要么弹了授权框却拿不到数据。

原因:开发者工具里手机号组件返回的是模拟数据,不经过微信真实链路。真机上受微信版本、基础库版本、用户是否已授权等条件影响,任何一个环节不满足都会失败。

解决:涉及getPhoneNumber的功能必须用真机预览测试,不要依赖开发者工具模拟器。onGetPhoneNumber回调里要同时判断errMsg和errCode:

onGetPhoneNumber(e) { if (e.detail.errMsg !== 'getPhoneNumber:ok') { // 给用户一个可回退的入口 this.setData({ showManualPhoneInput: true }) return } // 正常处理动态令牌 }

活动报名是低频场景,用户授权失败后最友好的做法是显示一个手动填写手机号的输入框,配合短信验证码兜底,既保证号码真实,又不让用户卡死在授权页。

5.3 抢报名瞬间库存变负数

现象:活动 10:00 开放报名,10:00:02 后台就显示剩余名额变成 -2,报名人数超过了活动名额。

原因:库存扣减用了「先查后写」的非原子操作,或者把判断逻辑写在了应用层,两个请求同时读到相同的剩余名额,都通过了if判断。

解决:用第四章的原子扣减 SQL,UPDATE activities SET remaining = remaining - 1 WHERE id = ? AND remaining > 0。数据库行锁会挡住并发更新,同一时刻只有一个请求能扣减成功。如果活动报名人数达到几千人、并发极高,可以在 Redis 里提前加载名额,用DECR原子操作扣减,再异步同步到数据库。但大多数场地类活动(几十到几百个名额),数据库原子扣减已经足够,不需要引入 Redis 增加运维复杂度。

5.4 同一手机号被不同微信号反复报名占名额

现象:用户先报上名,又换一个微信号提交了一次报名,两个名额都被同一个真人占用。

原因:微信的openid是按微信号维度唯一的,防不住同一个人换号。如果报名表只对user_id + activity_id建唯一索引,数据库层面拦不住这种重复。

解决:在signups表上增加(activity_id, phone)唯一索引。这是我在 3.3 节建表时就强调的。有了这道约束,数据库会在插入时直接拒绝同一手机号在同一活动的第二条记录,应用层捕获唯一索引冲突,返回4005重复报名。要注意这个约束的前提是活动规则为「一人一票」,如果活动允许代报名(一个人给三个人报名),这个方案就不适用了,需要改成业务层判断、增加代办人与被代办人手机号维度。

5.5 活动结束后还能提交报名

现象:前端报名按钮在结束时间后已经置灰,但有用户反馈仍然报名成功。

原因:前端只是把按钮置灰、加了倒计时,这是「体验层」控制;用户完全可以通过其他手段绕过前端,直接构造请求调用后端接口。后端没有校验活动时间窗口,只校验了参数和名额。

解决:服务端在报名接口里强制校验当前时间和活动时间的关系:

if (now < activity.start_time || now > activity.end_time) { return { code: 4003, message: '当前不在报名时间' } } if (activity.status !== 1) { return { code: 4002, message: '活动已下线' } }

这里的时间要以服务端为准,不能信任客户端传的时间。用户改自己手机时间没有任何意义,因为判断发生的服务端。这是一个典型的「前端控制体验、后端控制安全」的边界问题,报名系统里凡是涉及状态判断的逻辑,服务端都要做一遍完整校验。

6. 让报名系统能收尾:验证清单、日志回溯与核销习惯

6.1 上线前按这张清单跑一遍

报名系统的功能不复杂,但边界条件多。我整理的验证清单,每次上线前会完整跑一遍:

验证项操作预期结果
页面跳转列表页 → 详情页 → 报名页活动 ID 正确透传
手机号授权真机首次点击授权 / 拒绝授权成功返回手机号,拒绝出现手动输入兜底
重复报名同一微信号提交第二次返回 4005,名额不减少
同号换号同一手机号配不同微信号报名返回 4005,只允许一条记录
并发抢报两台设备同时提交最后一个名额只有一人成功,库存不为负
时间校验手动把活动改为已结束,再调接口返回 4003,拒绝报名

这张清单覆盖了第五章提到的全部坑点。每次改完代码,我都会至少跑一遍「重复报名」和「并发抢报」两项,因为这两项一旦出错,就是活动事故级别的故障。

6.2 用 requestId 串联一次报名的完整链路

活动方反馈「用户报不上名」时,最怕的是什么都查不到。我习惯在服务端入口中间件给每个请求生成一个requestId:

app.use((req, res, next) => { req.requestId = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}` res.setHeader('X-Request-Id', req.requestId) next() })

报名提交时,把requestId和user_id、activity_id一起存入signups表,后续排查时输入手机号或openid,就能反查requestId,再到日志系统里定位请求从入口到数据库每一步的执行情况。这个习惯帮我省掉了无数次「用户报不上名,但是不知道在哪一步失败」的排查时间。

我自己经手的报名类小程序,每次上线前都会把清单完整过一遍,尤其是库存扣减和重复提交这两个坑,一旦踩了就是现场事故。后来我养成了一个固定习惯:任何一次改动,哪怕只是改了一个提示文案,也要先跑「提交一次、再提交一次」这个最基本的回归用例。报名系统代码量不大,但边界条件比想象中多,服务端多做一层校验,前端多留一个兜底入口,胜过上线后熬夜补锅。希望帮到你。

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