1. 马拉松报名系统的业务本质:它为什么不是一张普通表单加支付?
先讲一个我实际遇到的场景。某地一场半程马拉松开放报名,通道刚打开五分钟,小程序端就有用户反馈:"我明明提交成功了,为什么两小时后收到短信说没有名额?""支付成功了,但订单列表里查不到记录。"客服后台被刷屏,运营团队一脸懵地来找我排查。
问题出在哪?出在很多人把马拉松报名系统当成"一张报名表单 + 一个微信支付"来做。但实际上,马拉松报名这个业务场景,它对系统的要求非常特殊,至少有三个点是普通报名表单不会遇到的:
第一,同一时刻的并发量极高。一场热门城市马拉松,开放报名后第一分钟可能涌进几万人同时点击提交。普通表单系统每秒处理几百个请求就够了,报名系统面对的是每秒几千甚至上万的写请求。此时数据库连接池、事务锁、接口响应速度都可能成为瓶颈。
第二,资格判断不是一个简单的是非题。马拉松报名不是"填了就能报名成功",它涉及赛事规则:是否重复报名、是否已报名其他项目、年龄是否符合组别要求、是否需要上传健康证明或完赛证书、报名人数是否已满。这些规则的组合判断,必须在提交的瞬间完成,而且结果要绝对一致——不能出现两个人同时提交,都通过了校验,最后只有一个能拿到名额。
第三,"报名成功"这件事的定义是分阶段的。用户提交报名信息后,先进入"待支付"状态;支付成功后才真正锁定名额;如果名额已经占满,已提交未支付的用户会被淘汰,甚至需要退款。这个状态流转,比普通商品订单要复杂得多,因为它是一场"先到先得 + 支付确认"的竞争机制。
所以,你要做的不是一个"能报名"的小程序,而是一个能在高并发下保证数据一致性的赛事名额分配系统。这个认知决定了后面所有的设计决策。很多项目做到一半发现各种奇怪问题,根源都在于一开始把系统想简单了。
这篇文章我就按我实际落地的一套方案来讲:如何设计数据模型、如何实现完整的报名主流程、如何解决并发抢名额、如何做后台赛事管理和消息触达。技术栈用微信小程序原生或 uni-app 都可以,后端我用 Spring Boot + MySQL + Redis,这套组合在报名类场景里非常成熟。文章会给出关键接口设计和核心逻辑,有需要可以直接照着改。
2. 先建模:赛事、期次、报名记录,三层结构缺一不可
2.1 为什么必须拆成"赛事—期次—报名记录"三层
很多初版设计会把赛事信息直接做成一张大表:赛事名称、比赛时间、报名开始时间、报名结束时间、人数上限、报名费用、年龄要求……全塞在一起。如果只办一场比赛,这么干勉强能跑。但真实运营中,你会遇到这些情况:
- 同一场赛事分多个项目:全马、半马、迷你跑、亲子跑,每个项目的报名费用、人数上限、年龄要求都不同。
- 同一场赛事分多期报名:第一轮开放给早鸟,第二轮普通报名,第三轮补录。每一期的名额数量、价格、开放时间都不一样。
- 已经关闭报名的赛事,下一届要开启时,不能直接把上一届的数据覆盖掉。
所以正确的做法是拆成三层:
- 赛事表(marathon_event):存一场比赛的固定属性,比如赛事名称、比赛日期、起终点位置、主办方信息、赛事介绍。这些信息一旦发布基本不变。
- 期次表(registration_period):存这次赛事下的某个报名期次,比如"2025年春季全马项目-早鸟期"。它的核心字段是:关联的赛事ID、关联的项目ID(或者直接存项目名称和组别)、报名开始时间、报名结束时间、名额总数、报名费、是否启用。为什么要单独建期次表?因为"名额""价格""开放时间"这些是随报名进度动态变化的,它们不属于赛事静态信息。
- 报名记录表(registration_order):存用户每一次提交报名生成的一条记录。它关联期次ID,记录用户在小程序端的身份信息、参赛资料、报名状态、支付状态、支付单号等。
这三层结构里,最关键的是期次表。所有并发控制、名额扣减、资格校验,都挂在期次粒度的字段上。赛事表反而是最"静态"的。
2.2 报名记录表的关键字段设计
报名记录表是整个系统的数据核心,我建议至少包含以下字段:
| 字段 | 类型 | 说明 |
|---|---|---|
| id | bigint | 主键 |
| order_no | varchar(64) | 业务订单号,展示给用户和客服看 |
| event_id | bigint | 赛事ID |
| period_id | bigint | 期次ID |
| openid | varchar(64) | 用户在小程序端的身份标识 |
| user_name | varchar(32) | 真实姓名 |
| id_card | varchar(18) | 身份证号 |
| phone | varchar(20) | 手机号 |
| gender | tinyint | 性别 |
| age_group | varchar(16) | 年龄组别 |
| bib_no | varchar(16) | 参赛号码,报名成功后分配 |
| registration_status | tinyint | 报名状态:1待支付 2已支付 3已取消 4已退款 5名额作废 |
| pay_status | tinyint | 支付状态:0未支付 1已支付 2已退款 |
| pay_amount | decimal(10,2) | 支付金额 |
| transaction_id | varchar(64) | 微信支付单号 |
| pay_time | datetime | 支付时间 |
| create_time | datetime | 提交时间 |
| update_time | datetime | 更新时间 |
这里有个非常容易被忽略的点:身份证号这类敏感信息,在小程序前端提交时就要加密传输,后端存储建议进行加密或掩码处理。我见过有项目把用户身份证明文存在数据库里,这不仅是合规风险,一旦数据库泄露就是重大事故。至少要做到:后端接口用 HTTPS,身份证号入库前做加密,查询时只展示掩码后的内容。
另外,我用registration_status和pay_status两个字段分开存,不要合并成一个状态字段。原因很简单:报名记录有"待支付但已提交资料"的中间态,如果只用一个状态,你无法区分"用户还没支付"和"用户支付失败"这两种情况,也无法做超时自动取消。分开存,语义清晰,后续做统计报表也方便。
2.3 说一个关于主键的坑
有人喜欢用用户ID做报名记录表的唯一索引,认为一个用户只能报名一次。但实际业务里,一个用户可能报名同一场赛事的全马,又报名迷你跑,甚至在一期内有多条历史记录(比如上一届报过、这届又报)。所以报名记录表一定不要对openid或user_id建唯一索引,唯一键应该是(period_id, openid, id_card)这样的组合维度,或者干脆就只用业务订单号order_no做唯一。
我之前看到一个项目用用户ID做主键,结果同一期次内用户重复报名时直接主键冲突,整个提交接口报错。排查了半天才发现是建表时拍脑袋加的唯一约束。建模阶段多想一步,后面省一周的排查时间。
3. 用户端报名主流程:从赛事发现到支付成功的完整链路
3.1 赛事列表与期次状态的前后端联动
小程序首页通常是一个赛事列表,展示可报名的赛事和期次。这里要注意一个点:期次能否报名,不能只靠前端判断时间,后端接口必须每次都校验当前时间是否在报名窗口内。
前端展示逻辑通常是:
- 期次状态为"未开始",显示倒计时,报名按钮置灰。
- "进行中",报名按钮可点。
- "已结束"或"名额已满",按钮变成"已满"或"查看成绩"。
后端接口则要在返回赛事数据时就带上期次状态字段,不要只给开始时间和结束时间让前端自己算。原因有两个:一是前端时钟可能与服务器时间不一致,二是状态计算逻辑如果散落在前端多个页面,后期调整规则要改好几处。我把状态计算收敛在后端一个枚举里:
- 1 未开始(当前时间小于报名开始时间)
- 2 报名中(当前时间在报名时间窗口内且还有名额)
- 3 已满(名额已耗尽,不管时间是否到结束)
- 4 已结束(当前时间超过报名结束时间)
列表接口返回赛事简要信息 + 当前期次状态 + 剩余名额。重点说下剩余名额:不要在列表页直接返回精确的剩余数量,返回一个模糊的梯度(充足/紧张/已满)就够了。精确数量会引发用户反复刷新套数据,而且并发场景下返回的剩余数量和用户提交时的实际数量一定存在误差,反而引发投诉。我用的是:剩余名额 > 50% 显示"充足",10%~50% 显示"紧张",小于 10% 显示"少量",0 显示"已满"。
3.2 报名表单的关键校验项
用户点击报名后,进入报名信息填写页。这个页面除了常见的姓名、手机、身份证、性别、紧急联系人,还必须做以下几个关键校验:
年龄组别校验。全马和半马通常有年龄下限,比如全马要求年满20周岁。后端要根据身份证号解析出生日期,计算出实际年龄,再判断题目的年龄要求是否满足。这部分逻辑一定要放后端,前端只是做体验性提示。后端校验的公式很简单:
年龄 = 报名年份 - 出生年份,再判断是否已过生日更严谨的写法是:
LocalDate birthDate = parseIdCard(idCard); LocalDate regDate = LocalDate.now(); int age = regDate.getYear() - birthDate.getYear(); if (regDate.getMonthValue() < birthDate.getMonthValue() || (regDate.getMonthValue() == birthDate.getMonthValue() && regDate.getDayOfMonth() < birthDate.getDayOfMonth())) { age--; }重复报名校验。规则通常是:同一用户、同一赛事,只能报名一个项目。这个校验要在后端用event_id + openid + registration_status in (1,2)查一下是否存在已提交或已支付的记录。注意要把"已取消"和"已退款"的记录排除掉,否则用户退赛后想重新报名会被挡在门外,这种问题运营人员能把你电话打爆。
跨期次冲突校验。如果一个用户在早鸟期提交了全马报名但未支付,之后普通期开放时又想报半马,系统应该拦截还是在普通期放行?不同赛事有不同的规则。我的处理方式是:把拦截规则做成配置项,在后端设置一个开关——"是否允许同一赛事多期次同时存在未支付记录"。默认关闭,也就是同一赛事下,只要存在待支付或已支付的记录,就不允许提交新的报名。这个开关放在管理员后台,运营人员可以根据实际赛制调整。
3.3 提交报名的核心接口设计
报名提交接口是我重点设计的,因为它是并发压力最大、最容易出数据问题的环节。接口设计如下:
POST /api/registration/submit请求参数:
{ "periodId": 1001, "userName": "张三", "idCard": "110101199003077777", "phone": "13800138000", "gender": 1, "emergencyContact": "李四", "emergencyPhone": "13900139000" }后端逻辑分五步:
- 校验期次是否存在、是否启用、当前时间是否在报名窗口内。
- 校验用户提交的身份证号、手机号格式,解析身份证得到年龄,做组别判断。
- 检查重复报名和跨期次冲突。
- 尝试扣减名额。
- 扣减成功则生成报名记录,状态置为"待支付",返回订单号;扣减失败返回"名额已满"。
这里的重点是第四步和第五步之间的关联,下面专门用一节讲并发扣减的实现。我先说一个很多人会犯的错误:先插入报名记录,再判断名额够不够。这种顺序在并发量小的时候没问题,一旦并发上来,就会出现多个人插入成功但名额超卖的情况。正确的顺序必须是"先扣名额,后写记录",而且扣名额和写记录要保证要么都成功、要么都失败。
还有一个细节:扣减名额的对象是期次的剩余名额字段,不是赛事的总名额。因为每一期独立开放、独立限额,不同期次之间互不影响。剩余名额存在期次表还是单独一张表?我建议单独建一张名额表,关联期次ID,字段就两个:total_quota 和 remaining_quota。为什么要单独拆出来?因为并发扣减时这张表会被频繁更新,如果和其他字段挤在同一张表里,行锁竞争会拖慢整体性能,而且后续如果要支持名额池动态调配(比如从 A 期挪一部分名额给 B 期),单独一张表更好操作。
3.4 支付流程与支付回调的幂等处理
用户提交报名后会拿到 order_no,然后在小程序前端调用微信支付。这一步的流程是标准的小程序支付:
- 后端根据订单号生成微信支付预支付单,拿到 prepay_id。
- 返回给前端,前端用
wx.requestPayment拉起收银台。 - 用户完成支付,微信服务器向你的后端回调地址发送支付结果通知。
- 后端处理回调,更新订单状态,给用户发送报名成功通知。
这里我踩过一个比较大的坑:支付回调必须做幂等处理。微信支付文档明确说明,同样的支付结果通知可能会多次发送,且通知顺序不保证。如果后端不做幂等,可能出现的问题包括:
- 用户支付成功,但回调先到,处理了一次,随后又收到同样的回调,再次更新订单状态,把支付时间覆盖了。
- 回调与用户主动查询并发,导致状态错乱。
我的做法是:在回调处理逻辑里,先根据 transaction_id 或 order_no 查询订单当前状态,只有在"待支付"或"支付处理中"的状态下才执行状态变更;如果已经是"已支付",直接返回成功应答,不再做任何更新。同时,回调处理要加分布式锁或数据库行锁,防止同一订单的多个回调线程并发执行。
还有一个容易被忽视的问题:支付金额必须校验。回调通知里带的total_fee要和订单表里的应付金额比对,不一致直接返回失败,防止中间环节被篡改。不要觉得这是多余,我见过有项目因为没校验金额,被刷单薅了羊毛——用户改了支付金额参数,用一分钱支付了几百块的报名费,等发现时已经退不了款了。
3.5 超时未支付:名额释放的定时策略
报名系统必然会有"提交了不付钱"的用户。这些用户占着名额,导致真正想报名的人进不来。所以必须设计超时取消机制:用户提交报名后,如果在规定时间内(比如 15 分钟)未完成支付,系统自动取消订单,释放名额。
实现方式有两种:
一种是定时任务扫表。每 30 秒扫一次报名记录表,找出registration_status=1且create_time超过 15 分钟的记录,将其置为"已取消",同时将名额回补到期次的 remaining_quota。这种方式简单直接,但要注意:名额回补必须做原子操作,不能先更新订单状态再回补名额,否则一旦中途失败,名额就丢了。我建议把回补名额放在同一个数据库事务里,或者用 Redis 的原子自增。
另一种是延迟队列。用 Redis 的 ZSET 实现,订单创建时以create_time + timeout作为 score 存入,后台定时拉取到期订单处理。这种方式比定时扫表更精准,但实现复杂度略高。对于报名系统这种量级,定时扫表就够用了,没必要为了"优雅"增加维护成本。
我再提醒一个点:当超时取消和支付回调同时发生时,有可能出现支付已经成功、但超时任务已经把订单取消的情况。处理方式是:支付回调处理时,如果发现订单已是已取消状态,不要直接改回已支付,而是标记为"已支付待人工确认",同时自动退款。这个逻辑写清楚,能避免很多客诉。我当时的实现是,在超时取消前先检查订单是否已处于支付中状态(比如前端已拉起收银台但用户还在操作),如果支付中则跳过取消。
4. 并发抢名额:Redis 预扣减 + 数据库兜底
4.1 为什么单纯靠数据库扣减会出事
假设期次表里有字段remaining_quota = 5000,用户提交报名时后端执行:
UPDATE registration_quota SET remaining_quota = remaining_quota - 1 WHERE period_id = ? AND remaining_quota > 0这个 SQL 在数据库层面是原子操作,本身不会超卖。问题出在哪里?出在事务隔离级别和锁竞争上。当大量请求同时执行这条更新语句时,数据库会对该行加行锁,其他请求必须等待前一个事务提交才能继续。在 5000 人同时抢 5000 个名额的场景里,这个行锁竞争会导致接口响应时间飙升,甚至出现大量超时。
更严重的是,如果你的代码把"扣名额"和"插订单"放在两个事务里,中间出现异常而事务回滚不一致,就会产生已扣名额但无订单记录的情况,或者有订单但名额没扣的情况。这种脏数据排查起来非常痛苦。
所以我的思路是:把名额扣减从数据库前移到 Redis,用 Redis 的原子操作快速完成预扣减,数据库只负责最终的数据持久化。Redis 单线程模型天然保证原子性,处理每秒几千次自减毫无压力。
4.2 Redis 预扣减的具体实现
整个并发控制流程我拆成两段:
第一段:接口入口处做快速预检。用户提交报名时,后端先从 Redis 读取该期次的可用名额 key,比如:
key: reg:quota:1001 value: 5000如果 value <= 0,直接返回"名额已满"。这一步是快速失败,避免大量无效请求打到数据库。
第二段:扣减名额。用 Redis 的原子自减命令:
DECR reg:quota:1001返回结果如果小于 0,说明名额已经扣超了,需要立即执行INCR回补,然后返回"名额已满"。如果返回值大于等于 0,说明名额扣减成功,这时候才允许继续插入报名记录。
这里有一个很关键的细节:DECR 返回的是自减后的新值,不是旧值。比如剩余名额是 1,第一个请求 DECR 后返回 0,说明扣减成功;第二个请求 DECR 后返回 -1,说明超卖,必须把最后一个名额通过 INCR 回补。这个逻辑写错的话,名额会越扣越少甚至越回补越多。
用代码表示:
Long remaining = redisTemplate.opsForValue().decrement("reg:quota:" + periodId); if (remaining < 0) { redisTemplate.opsForValue().increment("reg:quota:" + periodId); throw new BusinessException("名额已满"); }4.3 数据库兜底:防止 Redis 与数据库数据不一致
Redis 扣减只是"预占",真正要落到数据库的还是那条 UPDATE 语句。为什么不直接用 Redis 扣减结果作为最终名额依据?因为 Redis 是内存态,一旦宕机数据会丢失,必须用数据库做最终对账。
所以我在扣减订单插入成功后,还会执行数据库的兜底更新:
UPDATE registration_quota SET remaining_quota = remaining_quota - 1 WHERE period_id = ? AND remaining_quota > 0这条语句如果在数据库层面影响行数为 0,说明数据库里名额已经耗尽,但 Redis 可能还没同步到位(或者 Redis 数据被重置过),此时需要人工或自动对账:以数据库的实际剩余名额为准,重新回填 Redis。
对账方案我会在后台管理系统里实现一个定时任务:每 5 分钟用数据库的名额数据覆盖 Redis 中的值。但要注意,覆盖操作不能简单地直接 SET,否则会出现"用户已经扣减了 Redis 名额,尚未写入订单,但数据库对账把 Redis 重置了"的竞态问题。所以对账时要做"取更大值"的合并逻辑,或者把对账放在业务低峰期执行,最好配合分布式锁。
4.4 防重提交:一个请求只允许扣一次名额
并发场景下,用户可能因为网络超时,连续点了两次提交按钮。如果不做防重,同一个用户可能扣两个名额、生成两条订单。
我的处理方式是:在提交接口入口处,根据(openid, periodId)生成一个唯一键,用 Redis 的 SETNX 命令实现请求锁:
Boolean locked = redisTemplate.opsForValue().setIfAbsent("reg:lock:" + openid + ":" + periodId, "1", 10, TimeUnit.SECONDS); if (!locked) { throw new BusinessException("请勿重复提交"); }锁过期时间设 10 秒,足够一次提交接口完成全部逻辑。如果第一次请求还没处理完,第二次请求就会被拦下。同时,在数据库层面,我给报名记录表加了(openid, period_id)的唯一索引兜底,即使 Redis 锁失效,数据库唯一约束也能拦住重复插入。
这里要说明一下:为什么不用数据库唯一索引做唯一的防重手段?因为唯一索引冲突时会抛出异常,你需要在代码里捕获异常并转换成业务提示,但并发高时异常量太大会拖慢数据库。Redis 锁拦在门口,数据库唯一索引只是最后一道保险,两者配合,既高效又安全。
4.5 容量评估:Redis 够不够快
有人会担心:Redis 做预扣减,扛得住几万并发吗?我可以给个参考数据。我用 Redis 单实例做了个压测,纯 DECR 操作(无网络延迟理想状态),每秒可以做到 10 万次以上;加上网络请求、JSON 解析、业务逻辑,单机扛每秒 5000 次扣减毫无压力。如果赛事规模巨大,比如同时开放多个期次,还可以做 Redis 集群分片,每个期次一个 key,天然分散压力。
真正可能成为瓶颈的是数据库的订单插入。所以我建议:订单插入采用批量写入或者异步落库的优化方案。用户提交后,先返回"报名受理中",后台把订单数据写入消息队列,消费者批量插入数据库。但要注意:异步落库意味着用户可能查不到即时订单,需要配合"报名状态查询"接口提供凭据查询。如果你的项目体量不大,完全没必要上异步,直接同步插入加索引优化就够了。报名系统一年也就几场大赛,单场峰值再高也是短暂的,没必要为了极限性能把系统搞复杂。
这里有个经验之谈:技术选型要匹配业务量级。做个县城的马拉松报名,用 Redis 预扣减 + 数据库兜底这套方案已经是杀鸡用牛刀了,但好在实现成本低、稳定性高,后续体量涨了也不用重构。真到了北上广深万人级赛事,再考虑分区分流和消息队列也不迟。
5. 管理后台:赛事配置、名额监控与数据统计
5.1 管理员能配什么:把业务规则做成可配置项
马拉松报名系统真正的运营压力在后端管理。运营人员可不是程序员,他们需要一套能自己改规则的后台。我的后台管理页面最少包含以下几个模块:
赛事管理:创建赛事、编辑赛事信息、上下架赛事。赛事下架后,用户端列表不再展示,但已产生的订单和成绩查询功能不受影响。
期次管理:在赛事下创建期次,填写期次名称、报名时间窗口、名额总数、报名费用、项目组别、年龄要求等。这里我强烈建议把"允许重复报名""是否需要健康证明""是否显示剩余名额"这些开关都做成下拉选项或勾选框,不要写死在代码里。
报名订单管理:以列表形式展示所有报名记录,支持按赛事、期次、姓名、身份证、手机号、状态筛选。运营人员可以进行手动取消、人工退款、修改参赛组别、录入参赛号码。这些操作全部记录操作日志,方便追溯。
5.2 实时监控:剩余名额的看板
管理后台首页我放了一个实时监控看板,展示每个期次的:
- 总名额 / 已报名人数 / 剩余名额
- 当前报名状态(未开始 / 报名中 / 已满 / 已结束)
- 待支付订单数量、超时即将释放的名额数量
- 最近 5 分钟的报名趋势曲线
这个看板的数据从哪里来?实时性要求高的字段从 Redis 读(比如剩余名额),历史汇总数据从数据库聚合。这里有个小技巧:不要在每次看板请求时实时 count 订单表,数据量大了会很慢。我的做法是维护一张期次统计表,每次报名成功、取消、退款时同步更新这张表的已报名人数字段。后台看板只查这张统计表,性能非常稳。
5.3 数据导出与报表:运营人员最常用的功能
报名结束后,运营人员需要导出参赛名单、生成参赛号码、统计各组别人数。我的后台提供了两个导出功能:
报名名单导出:按期次导出 Excel,包含姓名、性别、身份证(掩码)、手机号、组别、报名状态、支付金额、参赛号码。导出操作放到异步任务里,生成文件后存到对象存储,前端提供下载链接。为什么不能同步导出?我曾经在一个两万人报名的赛事上同步导出,后端卡了 30 秒,网关直接超时,文件还没生成完。后来改成异步生成,用户体验反而更好。
号码布批量分配:报名结束后,运营人员需要给每个参赛者分配参赛号码。号码规则通常有固定格式,比如"A1001",按组别前缀 + 序号。我实现了后台自动分配功能:先按组别排序,再按报名时间顺序分配号码。分配后可以人工调整个别号码(比如赞助商名额),调整时校验号码唯一性。
5.4 定时对账与数据修复
整个系统运行过程中,Redis 和数据库的数据一致性不能完全依赖"代码不出错",必须有一套自动对账机制。我在后台做了一个定时任务模块,包含两类任务:
名额对账任务:每 10 分钟执行一次,遍历所有启用中的期次,统计数据库里registration_status in (1,2)的记录数,计算"实际剩余名额 = 总名额 - 已报名数",与 Redis 中的值比对。不一致时以数据库为准回写 Redis。回写时加上分布式锁,避免与正在进行的报名操作冲突。
订单超时取消任务:每 30 秒执行一次,找出待支付超过 15 分钟的订单,执行取消和名额回补。上面已经讲过了,这里不重复。
另外,我还加了一个重复订单检测任务:每天凌晨扫描一次,找出同一用户在同一赛事下的多条已支付订单,标记出来供运营人员人工确认。真出过这种情况——用户先报了全马,支付成功后,因为网络延迟又提交了一次半马报名,居然也通过了(因为重复校验按"待支付或已支付"只查了当时时间点的状态,没挡住他第二次并发提交)。这种极端场景靠业务逻辑很难完全防住,事后检测 + 人工处理是最稳妥的方案。
6. 消息触达与用户体验:报名成功通知、赛事提醒、结果查询
6.1 报名状态的主动通知:微信订阅消息的合理用法
马拉松报名系统里,用户最关心几个时间点:报名成功、报名失败(名额已满)、支付成功、退款成功、赛前提醒、成绩公布。这些节点都应该给用户发消息。微信小程序里就是订阅消息。
订阅消息有个限制:每次调用要求用户主动同意,且一次性订阅只能推送一次。设计上要注意:
- 报名提交成功后,弹出订阅请求,订阅"报名结果通知"。
- 支付成功后,再次请求订阅,订阅"赛事提醒"(可以设置多个提前量,比如提前 7 天、提前 1 天)。
- 赛事结束后,请求订阅"成绩通知"。
不要试图在用户刚进入小程序时就请求订阅一堆消息类型,转化的成功率很低,而且会干扰核心操作。我的经验是:每一次订阅请求都跟着一个用户当前正在完成的动作走,自然且干扰小。
6.2 报名状态查询与主动刷新
用户在小程序里进入"我的报名"页面,能看到自己所有报名记录及其状态。这里要支持下拉刷新,并在状态变更时给出明确的引导文案:
- 待支付:显示"请在 15 分钟内完成支付,超时名额将自动释放",按钮为"去支付"。
- 已支付:显示参赛号码、赛事时间、地点,按钮为"查看详情"。
- 已取消:显示"订单已超时取消,如需报名请重新提交"。
- 已退款:显示退款金额和退款时间。
"我的报名"页面的数据来源,我建议直接查后端接口,不要在小程序本地缓存。因为报名状态是强一致的,用户刷新页面就应该看到最新状态。这个接口做了分页,一次加载 20 条,下拉加载更多。数据量不大,不用做太多缓存优化。
6.3 报名成功的电子凭证:多一道身份核验
报名成功后,除了发订阅消息,我还给用户生成了一张电子报名凭证。凭证上包含:赛事名称、项目组别、参赛号码、用户姓名、身份证掩码、报名状态、赛事二维码。现场领物时,工作人员可以通过小程序扫描二维码核验参赛者身份。
不要小看这个"电子凭证",它能显著减少赛前领物的排队时间,也避免用户忘带纸质确认函导致无法领物。实现方式不复杂:二维码内容用一个带签名的 URL 或者加密字符串,后端接口校验签名后返回参赛信息。注意二维码要有有效期,防止截图被冒用。
6.4 关于 iOS 静音播放、视频下载这些"附属功能"
做小程序的过程中,有些用户反馈的怪问题你迟早会遇到。比如 iOS 设备上视频没声音、长按图片出菜单、web-view 高度不合适等。我看过很多讨论帖子,这里挑两个和报名系统相关的说一说。
iOS 静音状态下播放视频无声:赛事宣传视频如果在小程序里播放,iOS 的静音开关会全局静音,用户以为出了 bug。解决方式是使用微信小程序同层渲染的 video 组件,并引导用户关闭静音模式,或者在视频播放按钮旁加提示"请在非静音模式下观看"。
web-view 高度问题:如果赛事详情页嵌入了 HTML 网页,web-view 的高度在小程序里经常出现内容被截断的情况。我的处理方式是,在 HTML 页面里保证外层容器有足够高度,同时在 web-view 加载完成后通过 postMessage 告知小程序调整高度。这个方案不完美,但能用。当然,如果赛事详情是纯文本+图片,用小程序原生页面展示更省心,没必要为了省开发时间引入 web-view。
这些"小问题"虽然和报名系统核心业务无关,但用户体验的细节往往决定了用户对这个小程序的整体评价。我在项目里专门有一类 bug 清单叫"平台兼容性",每遇到一个新问题就记录解决方案,下一个赛事直接复用,效率会提高很多。
7. 反作弊与风控边界:黄牛、刷单和异常行为的处理
7.1 报名场景会遇到哪些"作弊行为"
很多人写报名系统只关注正常流程,忽略了恶意行为。马拉松报名场景里,最常见的风险是:
- 黄牛占坑:大批量注册微信账号,提交报名但不支付,占用名额后加价转让参赛资格。
- 刷单攻击:用脚本批量调用提交接口,造成虚假报名记录,干扰系统正常运行。
- 信息篡改:修改身份证号、手机号等关键字段,绕过年龄或身份校验。
这些行为的危害不同,应对方式也不同。我的原则是:核心目标是保证真实用户能正常报名,不追求绝对杜绝作弊——绝对杜绝的成本太高,而且会影响正常用户体验。
7.2 低成本的风控手段:设备指纹 + 频控 + 人工审核
我先说三个实现成本低、效果明显的风控手段:
接口频控:对同一 openid 限制报名接口调用频率,比如 1 分钟内最多调用 5 次。超出直接返回"操作过于频繁"。这个用 Redis 计数就能实现,成本极低。
设备指纹:通过前端收集设备的型号、操作系统版本、屏幕分辨率等信息生成一个哈希值,作为设备标识传给后端。同一设备在短时间内关联多个 openid 提交报名,触发告警。设备指纹不需要做到绝对准确,够识别脚本批量操作就行。
敏感操作人工审核:同一个身份证号出现在多个订单中、年龄临界值、性别与身份证信息不符这类记录,后台标记为"异常待核验",由运营人员人工判断。不要指望全自动风控能把所有问题都挡掉,报名系统的人工审核机制比复杂的算法更可靠。
7.3 关于"骗审"和"虚拟支付"的边界提醒
搜索记录里有人问"微信小程序如果开发骗审""虚拟支付苹果 IAP 退款"这类问题,我这里明确说一句:小程序审核是平台规则,不是用来钻的空子,更不要尝试用违规手段通过审核。报名类小程序属于服务类目,核心是线下赛事服务,不是虚拟支付,不涉及苹果 IAP 的虚拟商品规则,只要业务流程合规,审核不会有问题。
从技术上,确保小程序审核通过你有几件必须做对的事:
- 小程序类目选择和赛事主办方资质材料匹配。
- 用户隐私保护指引中明确说明收集身份证号、手机号的用途。
- 不能有未开放的功能入口(比如未完成的按钮放在页面上)。
- 支付流程必须走微信官方组件,不能自己构造支付页面。
这几点做到,审核一般很顺利。反过来,动"歪心思"的结果通常是永久封禁开发者账号,得不偿失。
8. 腾讯云与部署注意点:从开发到上线的完整链路
8.1 小程序前后端的云环境选择
微信小程序的后端部署方案,我推荐两种:
方案一:微信云开发。适合小规模赛事,不需要自建服务器。云开发提供云函数、云数据库、云存储,天然和微信生态打通。报名人数在几千人的赛事,用云开发完全够用。我一开始做第一届赛事时用的就是云开发,两周就上线了,成本几乎为零。
方案二:自建后端 + 小程序。适合预计规模较大、后续要扩展的赛事。自建后端可以用任何语言,我熟悉的是 Java + Spring Boot,部署在腾讯云服务器或容器服务上。这个方案的好处是数据完全自主可控,坏处是要自己处理微信登录、支付回调签名验证、HTTPS 证书这些基础设施问题。
如果按方案二,前端请求的域名必须在小程序后台配置为合法域名,并且必须 HTTPS。我见过有人把后端部署在 IP 上,小程序请求直接报 "url not in domain list" 错误。这个配置在 mp.weixin.qq.com 的后台「开发管理 → 开发设置 → 服务器域名」里,request 合法域名和 uploadFile 合法域名要分别配置。配完不是立刻生效,有缓存,开发时可以用"不校验合法域名"选项临时调试,但上线前一定要改回来。
8.2 微信登录与 UnionID 的坑
小程序的登录流程是:前端调用wx.login获取 code,后端拿 code 调用微信接口换取 openid 和 session_key。openid 是用户在某个小程序内的唯一 ID,同一个用户在不同小程序下 openid 不同。如果你后续要做一个公众号关联的管理端,让运营人员在小程序和管理端之间打通账号,就要用 UnionID。
UnionID 的获取前提是:小程序和公众号绑定在同一个开放平台账号下。这里有个常见的坑:很多开发者只取了 openid,没处理 UnionID,导致后面做多端数据打通时发现匹配不上。我的建议是:从第一个版本开始,用户表设计时就加上 union_id 字段,登录时把 openid 和 union_id 都存下来。即使现在没用到,以后也省得做数据迁移。
8.3 上线前的安全自查清单
上线报名系统之前,我建议按这个清单逐项检查:
- HTTPS 强制:后端只支持 HTTPS,HTTP 请求重定向到 HTTPS。
- 接口鉴权:除支付回调外,所有业务接口都必须校验用户登录态,不能靠 openid 明文传输来识别用户。
- 敏感字段加密:身份证号存储加密,日志中不打印完整身份证号。
- 参数校验:手机号格式、身份证校验位、金额范围都要做。
- 数据库备份:每日自动备份,备份文件异地存储,至少保留 7 天。
- 监控告警:接口 5 分钟错误率超阈值、Redis 连接异常、数据库慢查询超 10 条,都触发告警通知到运维群。
这些不是"加分项",是"必须项"。马拉松报名涉及用户真实身份信息和资金,一旦出问题就是大事。
9. 实测中容易踩的五个隐形坑
最后单独列一节,专门讲我在开发这类报名系统时反复踩过、也帮别人排查过的坑。这些坑在文档里很难找到,但实际运行中百分之百会遇到。
9.1 小程序冷启动时wx.login与后端 session 异步问题
小程序每次冷启动,前端会调用wx.login获取新的 code,后端用 code 换取 openid 后会得到一个新的 session_key。如果你把用户登录态存到后端 session,要特别注意:同一个用户短时间内多次调用 wx.login,会生成多个不同的 session_key,但你只能用一个。处理不当会出现"用户已登录,但部分页面提示未登录"的诡异现象。
我的做法是:后端拿到 openid 后,自己生成一个自定义的 token(UUID 或 JWT),返回给前端存储在小程序本地。后续所有接口都带这个 token,后端根据 token 查询用户身份,不依赖微信 session_key 的时效性。这种方式实现简单,且完全可控。
9.2 支付回调与用户主动查询的并发状态错乱
这个前面提过,我再展开说一次。用户支付成功后,小程序前端可能同时做三件事:微信支付回调通知后端、前端通过接口查询订单状态、用户点了"刷新"再次查询。这三者之间如果没有幂等控制,订单状态可能被反复修改。
我的处理方式是,在订单状态变更的入口加一个状态机校验:只允许从"待支付"迁移到"已支付";如果当前状态已经是"已支付",任何新的回调或查询都不做状态变更,只返回当前状态。这个状态机写在一个服务方法里,所有入口都走这个方法,避免逻辑散落。
9.3 名额回补时 Redis 与数据库的先后顺序
超时取消订单时,需要回补名额。这里的顺序是:先更新数据库订单状态为"已取消",再回补数据库名额,最后回补 Redis 名额。我强调一下 Redis 要放最后,为什么?如果先回补 Redis 再更新数据库,一旦更新数据库失败,Redis 已经多了名额,用户实际还能报名,但数据库里名额没增加,最终对账时数据库会强制改回 Redis,造成"用户提交成功但后来被对账取消"的严重客诉。
反过来,先更新数据库成功、Redis 回补失败,这个不一致可以通过定时对账自动修复,最多造成短时间的名额偏差。所以优先保证数据库一致,Redis 允许短暂滞后,这是全局设计原则。
9.4 上传身份证照片的隐私合规
有些报名流程需要用户上传身份证照片或扫描身份证。微信小程序里可以用wx.scanCode或第三方 OCR 插件实现身份证识别。但要注意:识别结果不能直接在页面里明文展示完整身份证号,要打码显示。OCR 识别服务建议在调用前先获得用户明确的授权同意,隐私协议里写明"用于实名认证及赛事保险购买"。
我碰到过一次用户投诉,说小程序上传身份证后,照片在后台管理列表里能被运营人员查看。后来我把身份证照片做了权限隔离:只有超级管理员和指定的报名审核员有权限查看,且查看动作留下日志。这个改动成本不高,但对合规很有意义。
9.5 期次名额释放"最后一秒"的边界判断
报名截止时间精确到秒,比如 23:59:59 结束。在这个时间点前一刻提交的用户,算不算有效?我的规则是:只要当前时间 <= 报名结束时间,就允许提交;名额是否充足,以扣减时的 Redis 值为准,与提交时间先后无关。这个规则要写清楚,否则容易出现边界争执——用户 23:59:59.8 提交成功了,另一个用户 23:59:59.9 提交却提示"报名已结束",客服解释不清楚。
实际开发中,我把这些边界规则统一写在配置类里,作为业务规则常量,运营人员可以在后台修改一部分。规则一旦确定,所有代码片段都引用同一个常量,避免各写各的判断逻辑导致前后不一致。
以上就是我做马拉松报名系统微信小程序的核心经验。整套方案从数据模型、报名主流程、并发控制、管理后台到风控和部署,都是在一次次赛事报名中打磨出来的。如果你正准备做一个类似的报名小程序,建议先从第 2 节的数据模型和第 4 节的并发控制入手,把这两块啃透了,整个系统的大框架就稳了。至于表格里那几个字段怎么定、后台按钮怎么排,后续按实际需求调就行,不影响大局。最后分享一点个人心得:报名系统的核心不是功能多炫酷,而是"该有的坑一个都不少地被提前堵住"。多从运营人员和参赛者的角度去推演流程,比多写一百行代码更有价值。