微信小程序+Java马拉松报名系统:高并发名额扣减与微信支付实战

发布时间:2026/10/8 17:07:05

微信小程序+Java马拉松报名系统:高并发名额扣减与微信支付实战 简介这是一套面向高校计算机相关专业毕业设计与课程设计场景的马拉松报名系统完整项目包采用微信小程序前端搭配Java后端与MySQL数据库实现适合正在准备毕设或需要小程序全栈练手项目的学生参考。压缩包共1220个文件约41.42MB涵盖vue页面组件、js逻辑脚本、java后端源码、wxml与wxss小程序页面样式、json配置、sql建表脚本以及png、svg等界面素材前后端与数据库结构一应俱全。功能上管理员可管理用户、赛事信息、赛事报名、活动商场、留言板、订单与系统设置用户端支持注册登录、浏览赛事、在线报名及购买商城商品业务闭环较为完整。资源另附演示视频、项目说明与数据库文件便于快速理解运行流程与模块划分。目前已有300人学习下载可作为毕设选题落地的参考方案也能帮助读者梳理小程序与Java后端联调的思路。1. 马拉松报名系统从「跑者抢名额」到「后端扛并发」的真实拆解每年赛事季跑圈里最玄学的一件事不是配速而是报名。几千个名额放出来几十秒内被抢空页面转圈、提交失败、重复扣款、名额超卖——这些不是跑者手速问题是报名系统本身没扛住。这套「微信小程序 Java 后端」的马拉松报名系统解决的就是这条链路上的核心矛盾前端要让跑者在微信里三分钟填完信息后端要在瞬时高并发下保证名额不超卖、订单不重复、支付状态可追溯。它适合正在做计算机毕业设计的学生也适合想练手前后端分离项目实战的 Java 工程师。整套东西包含源码、演示视频、说明文档和数据库脚本拿到手能跑、能改、能讲清楚每一层在干什么。下面我按实际落地的顺序把选型理由、建表逻辑、接口实现和踩坑记录一层层拆开。2. 技术选型与工程骨架为什么是微信小程序加 Spring Boot2.1 前端为什么锁定微信小程序而不是 uni-app 或 H5马拉松报名这个场景有个硬约束用户几乎全在微信里传播。赛事方发一条公众号推文跑者点开就报名中间不能跳出微信去下载 App也不能让用户在手机浏览器里输一堆信息。微信小程序原生开发在这个链路上最短——登录直接拿code换openid支付直接调wx.requestPayment分享直接转发卡片。常见做法是用原生小程序框架而不是 uni-app 多端打包因为报名系统不需要上支付宝或 App多端反而增加调试成本。原生小程序的页面结构是wxml wxss js json四件套和 Vue 的写法有相似处但不一样。顶部导航栏高度这个热搜词背后其实是个真问题小程序默认导航栏高度在不同机型上不一致报名表单页如果自己画头部必须用wx.getSystemInfoSync()拿状态栏高度再算。我一般会在app.js里全局存一份// app.js 全局获取系统信息供各页面计算导航栏高度 App({ onLaunch() { const sys wx.getSystemInfoSync(); // statusBarHeight 是状态栏高度单位 px // 自定义导航栏总高度 状态栏 44px胶囊按钮区域标准高度 this.globalData.navBarHeight sys.statusBarHeight 44; this.globalData.statusBarHeight sys.statusBarHeight; }, globalData: { navBarHeight: 0, statusBarHeight: 0, userInfo: null } });这段逻辑说明statusBarHeight是刘海屏/挖孔屏顶部那条状态栏的高度iOS 和 Android 不同机型差异明显44px 是微信胶囊按钮所在区域的经验高度。参数上如果你不做自定义导航栏直接用小程序默认导航栏这段可以省掉。但报名页往往要放赛事 Logo 和倒计时默认导航栏不够用所以自定义是常态。登录获取手机号是另一个高频需求。马拉松报名要实名手机号是必填项。小程序里获取手机号不是直接读而是用button open-typegetPhoneNumber触发拿到加密的code后传给后端后端再调微信接口解密。前端代码// 报名页获取手机号按钮回调 onGetPhoneNumber(e) { if (e.detail.code) { // e.detail.code 是动态令牌不是手机号本身 wx.request({ url: https://your-domain.com/api/user/phone, method: POST, data: { code: e.detail.code }, success: (res) { // 后端解密后返回真实手机号 this.setData({ phone: res.data.phone }); } }); } else { wx.showToast({ title: 需要授权手机号才能报名, icon: none }); } }逻辑说明e.detail.code是一次性凭证五分钟内有效后端拿它加上access_token去微信服务端换手机号。参数上要注意这个接口需要小程序有「获取手机号」权限且用户必须主动点击按钮不能静默调用。很多新手在这里翻车以为能像openid一样自动拿结果线上发现拿不到。2.2 后端为什么用 Spring Boot MyBatis-Plus 而不是别的组合Java 后端在这个项目里的职责很清晰管用户、管赛事、管名额、管订单、管支付回调。Spring Boot 的优势是起步快一个main方法就能跑配合 MyBatis-Plus 省掉大量单表 CRUD 的 XML。热搜里有人问「mybatisplus 根据 java 实体类生成创建表的 sql 语句」这其实是个反向操作——MyBatis-Plus 本身不生成建表 SQL但它的代码生成器能根据数据库表生成实体类。实际项目里我一般先手写建表 SQL再用生成器出实体和 Mapper顺序不能反。工程骨架按前后端分离来搭后端只返回 JSON小程序端负责渲染。目录结构大致是marathon-backend/ ├── src/main/java/com/marathon/ │ ├── controller/ // 接口层 │ ├── service/ // 业务逻辑 │ ├── mapper/ // 数据访问 │ ├── entity/ // 数据库实体 │ ├── dto/ // 传输对象 │ └── config/ // 跨域、拦截器、微信配置 ├── src/main/resources/ │ ├── application.yml // 数据源、微信 appid/secret │ └── mapper/ // 复杂查询的 XML └── pom.xmlapplication.yml里几个关键配置server: port: 8080 spring: datasource: url: jdbc:mysql://localhost:3306/marathon?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/Shanghai username: root password: your_password driver-class-name: com.mysql.cj.jdbc.Driver wechat: appid: wx_your_appid secret: your_secret参数说明serverTimezoneAsia/Shanghai必须加否则报名时间存进去会差 8 小时这个坑我见过不止一次。wechat.appid和secret是小程序后台拿的不要硬编码在代码里放配置文件方便换环境。后端跨域是前后端分离的必答题。小程序请求不受浏览器同源策略限制但如果你用浏览器调试接口或者以后加管理后台网页就需要配 CORS。常见做法是加一个配置类Configuration public class CorsConfig implements WebMvcConfigurer { Override public void addCorsMappings(CorsRegistry registry) { registry.addMapping(/**) .allowedOriginPatterns(*) // 生产环境换成具体域名 .allowedMethods(GET, POST, PUT, DELETE) .allowCredentials(true) .maxAge(3600); } }逻辑说明allowedOriginPatterns比allowedOrigins更宽松支持通配。allowCredentials(true)允许带 cookie但此时 origin 不能是*所以用 pattern。生产环境一定要把*换成赛事方域名否则等于开门。3. 数据库设计与报名核心表名额不超卖的底层逻辑3.1 五张核心表怎么划用户、赛事、名额、订单、支付流水报名系统的数据模型不复杂但每张表的字段设计直接决定后面会不会出并发问题。我一般拆成五张表user用户、race赛事、race_quota名额库存、order报名订单、payment支付流水。为什么把名额单独拆一张表而不是放在race里因为名额是高频扣减字段单独拆出来可以加行锁减少对赛事主表的锁竞争。race_quota表结构CREATE TABLE race_quota ( id BIGINT NOT NULL AUTO_INCREMENT, race_id BIGINT NOT NULL COMMENT 赛事ID, category VARCHAR(32) NOT NULL COMMENT 项目组别全马/半马/迷你, total INT NOT NULL DEFAULT 0 COMMENT 总名额, sold INT NOT NULL DEFAULT 0 COMMENT 已售名额, version INT NOT NULL DEFAULT 0 COMMENT 乐观锁版本号, create_time DATETIME DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY uk_race_category (race_id, category) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;参数说明sold是已占用名额判断是否还有余量用sold total。version是乐观锁字段更新时带上版本号防止并发覆盖。uk_race_category唯一索引保证一个赛事一个组别只有一条库存记录避免重复插入。order表关键字段CREATE TABLE order ( id BIGINT NOT NULL AUTO_INCREMENT, order_no VARCHAR(32) NOT NULL COMMENT 业务订单号, user_id BIGINT NOT NULL, race_id BIGINT NOT NULL, category VARCHAR(32) NOT NULL, status TINYINT NOT NULL DEFAULT 0 COMMENT 0待支付 1已支付 2已取消 3已退款, amount DECIMAL(10,2) NOT NULL, create_time DATETIME DEFAULT CURRENT_TIMESTAMP, pay_time DATETIME DEFAULT NULL, PRIMARY KEY (id), UNIQUE KEY uk_order_no (order_no), KEY idx_user_race (user_id, race_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;order_no唯一索引是防重复下单的第一道闸。idx_user_race用于查「这个用户有没有报过这个赛事」报名系统通常限制一人一赛一项目。3.2 扣减名额的两种写法乐观锁和 Redis 预扣名额扣减是整套系统最容易翻车的地方。最朴素的写法是「先查再改」// 错误示范先查后改并发下必然超卖 RaceQuota quota quotaMapper.selectById(raceId); if (quota.getSold() quota.getTotal()) { quota.setSold(quota.getSold() 1); quotaMapper.updateById(quota); }这段代码在单线程下没问题但两个请求同时查到sold99, total100都判断通过都加 1最后sold101超卖一个。血泪经验任何「先查再改」的库存逻辑只要没加锁线上一定出问题。正确写法用乐观锁在 SQL 里带条件更新// RaceQuotaMapper.java Update(UPDATE race_quota SET sold sold 1, version version 1 WHERE race_id #{raceId} AND category #{category} AND sold total AND version #{version}) int deductQuota(Param(raceId) Long raceId, Param(category) String category, Param(version) Integer version);逻辑说明sold total放在WHERE里数据库层面保证不会扣成负数version #{version}保证只有拿到当前版本号的请求能更新成功。返回值为 1 表示扣减成功为 0 表示被别人抢先业务层重试或直接返回「名额已满」。参数上version要从查询结果里带过来不能写死。如果并发量再高比如几千人同时抢乐观锁重试次数会变多。这时候常见做法是上 Redis 预扣把名额放到 Redis 里用DECR原子操作扣减扣成功再异步落库。但毕业设计场景下乐观锁加数据库唯一索引已经够用上 Redis 反而增加部署复杂度。我一般建议先把乐观锁写对压测到瓶颈再考虑 Redis。3.3 报名接口的完整链路从提交到落库一个报名请求进来后端要做的事按顺序是校验登录态、校验赛事是否在报名期、校验用户是否重复报名、扣减名额、创建订单、返回订单号。任何一步失败都要回滚前面的操作所以整个方法要加Transactional。Transactional(rollbackFor Exception.class) public OrderVO createOrder(Long userId, Long raceId, String category) { // 1. 查赛事校验报名时间窗口 Race race raceMapper.selectById(raceId); if (race null || !race.isInSignWindow()) { throw new BizException(不在报名时间内); } // 2. 查是否重复报名靠唯一索引兜底 Long count orderMapper.selectCount( new QueryWrapperOrder().eq(user_id, userId).eq(race_id, raceId)); if (count 0) { throw new BizException(您已报名该赛事); } // 3. 乐观锁扣减名额 RaceQuota quota quotaMapper.selectOne( new QueryWrapperRaceQuota().eq(race_id, raceId).eq(category, category)); int affected quotaMapper.deductQuota(raceId, category, quota.getVersion()); if (affected 0) { throw new BizException(名额已满请选择其他组别); } // 4. 创建订单 Order order new Order(); order.setOrderNo(generateOrderNo()); order.setUserId(userId); order.setRaceId(raceId); order.setCategory(category); order.setStatus(0); order.setAmount(race.getFee()); orderMapper.insert(order); return convertToVO(order); }逻辑说明Transactional保证扣名额和建订单要么都成功要么都回滚。generateOrderNo()一般用时间戳加用户 ID 后几位加随机数保证唯一且可读。参数上race.isInSignWindow()是业务方法判断当前时间是否在sign_start和sign_end之间。注意重复报名校验用selectCount只是提前拦截真正防重靠order表的唯一索引因为并发下两个请求可能同时查到 count 为 0。4. 微信支付与回调订单状态怎么才能不丢4.1 小程序下单接口的参数怎么传支付这块是毕业设计里最容易讲不清的部分。小程序调起支付需要后端先调微信统一下单接口拿到prepay_id再签名返回给前端。后端代码public MapString, String wxPay(Long orderId, String openid) { Order order orderMapper.selectById(orderId); // 构建统一下单参数 MapString, String params new HashMap(); params.put(appid, wxConfig.getAppid()); params.put(mch_id, wxConfig.getMchId()); params.put(out_trade_no, order.getOrderNo()); params.put(total_fee, order.getAmount().multiply(new BigDecimal(100)).intValue() ); params.put(openid, openid); params.put(notify_url, wxConfig.getNotifyUrl()); params.put(trade_type, JSAPI); // 签名后请求微信统一下单拿 prepay_id String prepayId wxPayClient.unifiedOrder(params); // 二次签名返回给小程序 return wxPayClient.buildPayParams(prepayId); }参数说明total_fee单位是分所以金额要乘 100 再转 int这里用BigDecimal避免浮点误差。notify_url是支付回调地址必须是公网可访问的 HTTPS 地址本地开发要用内网穿透工具映射出去。trade_type固定JSAPI因为是小程序内支付。4.2 支付回调的幂等处理同一笔通知可能来多次微信支付回调有个特点如果你没及时返回成功它会重复通知。所以回调接口必须幂等。常见做法是拿out_trade_no查订单如果已经是已支付状态直接返回成功不再处理。PostMapping(/pay/notify) public String payNotify(RequestBody String xmlData) { MapString, String result wxPayClient.parseNotify(xmlData); String orderNo result.get(out_trade_no); // 查订单判断状态 Order order orderMapper.selectOne( new QueryWrapperOrder().eq(order_no, orderNo)); if (order null) { return wxPayClient.failResponse(订单不存在); } if (order.getStatus() 1) { // 已处理过直接返回成功保证幂等 return wxPayClient.successResponse(); } // 更新订单状态和支付时间 order.setStatus(1); order.setPayTime(new Date()); orderMapper.updateById(order); // 记录支付流水 paymentService.savePayment(order, result.get(transaction_id)); return wxPayClient.successResponse(); }逻辑说明先查订单状态为 1 说明之前已经处理过直接返回成功。transaction_id是微信侧流水号存到payment表方便对账。参数上回调返回的 XML 要解析成 Map注意微信回调的签名验证不能省否则有人伪造通知就能白嫖报名。4.3 超时未支付订单怎么自动取消报名场景下用户下单后不付款会占着名额必须设超时取消。常见做法是用定时任务扫order表把创建超过 15 分钟且状态为 0 的订单改成已取消同时把名额加回去。Scheduled(cron 0 */1 * * * ?) // 每分钟执行一次 public void cancelTimeoutOrders() { Date deadline new Date(System.currentTimeMillis() - 15 * 60 * 1000); ListOrder timeoutOrders orderMapper.selectList( new QueryWrapperOrder().eq(status, 0).lt(create_time, deadline)); for (Order order : timeoutOrders) { order.setStatus(2); orderMapper.updateById(order); // 名额回滚同样用乐观锁 quotaMapper.rollbackQuota(order.getRaceId(), order.getCategory()); } }参数说明cron表达式0 */1 * * * ?表示每分钟第 0 秒执行。15 * 60 * 1000是 15 分钟超时阈值可以按赛事调整。名额回滚的 SQL 是UPDATE race_quota SET sold sold - 1 WHERE race_id ? AND category ? AND sold 0加sold 0防止扣成负数。5. 避坑与排查报名系统上线前必须过的五道坎5.1 现象压测时名额扣成负数原因扣减 SQL 没加边界条件解决WHERE 里带 sold total这是最典型的翻车。有人写UPDATE race_quota SET sold sold 1 WHERE race_id ?忘了sold total并发一上来直接超卖。解决就是把边界条件写进 SQL让数据库来保证。改完用 JMeter 开 200 个线程压观察sold是否超过total。5.2 现象用户重复报名原因只靠代码查重没加唯一索引解决order 表加 user_id race_id 唯一索引代码里selectCount查重在并发下不可靠两个请求可能同时查到 0。解决是在order表加UNIQUE KEY uk_user_race (user_id, race_id)插入时如果重复会抛异常业务层捕获后返回友好提示。注意加了唯一索引后已取消的订单也会占用这个唯一键如果允许取消后重新报名索引要改成user_id race_id status或者用逻辑删除。5.3 现象支付回调收不到原因notify_url 是 localhost 或没配 HTTPS解决用公网域名并确保 443 可达本地开发时notify_url写http://localhost:8080/pay/notify微信服务器根本访问不到。解决是用内网穿透工具把本地端口映射到一个公网 HTTPS 地址填到配置里。另外回调接口不能有登录拦截要在拦截器里放行/pay/notify。5.4 现象报名时间差 8 小时原因数据库时区没设解决JDBC URL 加 serverTimezoneAsia/Shanghai前面提过serverTimezone不配的话new Date()存进去和读出来会差 8 小时导致报名窗口判断错误。解决是在 JDBC URL 里显式指定时区同时 MySQL 服务端时区也建议设成08:00。5.5 现象小程序请求后端报跨域原因CORS 配置漏了 OPTIONS 预检解决allowedMethods 包含 OPTIONS 并放行拦截器浏览器发 POST 带 JSON 时会先发 OPTIONS 预检请求如果后端没处理 OPTIONS预检失败正式请求就不会发。解决是 CORS 配置里allowedMethods加上 OPTIONS拦截器里也放行 OPTIONS 请求。小程序真机不走浏览器同源策略但开发者工具和网页调试会走所以这个坑在调试阶段就会遇到。6. 把系统跑起来之后压测、对账和二次开发的切入点系统能跑通只是第一步真正让它像回事的是压测和对账。压测我一般用 JMeter 对报名接口开 500 线程、循环 10 次观察三个指标名额是否超卖、订单号是否重复、平均响应时间是否超过 500ms。如果响应时间飙高先看数据库连接池Spring Boot 默认 HikariCP 最大 10 个连接压测时明显不够改成 50 再试。对账是另一个容易被忽略的环节。每天凌晨跑一个定时任务把payment表和微信账单比对找出「本地已支付但微信无记录」或「微信有记录但本地未更新」的订单。这个逻辑不复杂但能体现你对支付链路的理解// 对账任务比对本地支付流水和微信账单 Scheduled(cron 0 0 3 * * ?) // 每天凌晨3点 public void reconcile() { ListPayment localList paymentMapper.selectList( new QueryWrapperPayment().ge(create_time, yesterday())); MapString, String wxBills wxPayClient.downloadBill(yesterday()); for (Payment p : localList) { if (!wxBills.containsKey(p.getOrderNo())) { // 本地有、微信无标记异常待人工核查 log.warn(对账异常本地流水 {} 微信无记录, p.getOrderNo()); } } }二次开发的切入点我建议从三个方向选一是加「团队报名」把order表扩展出team_id支持多人一起报二是加「成绩查询」对接计时芯片数据跑者赛后在小程序里查净成绩三是加「消息通知」用小程序订阅消息在报名成功、支付成功、赛前提醒三个节点推送。这三个方向都不需要推翻现有架构改起来有抓手。我自己做这类系统最大的习惯是任何涉及钱和名额的操作先写 SQL 再写 Java因为数据库层面的约束比代码里的 if-else 可靠得多。代码可以重构数据错了就是事故。希望帮到你。本文还有配套的精品资源点击获取
延伸阅读

更多相关文章

2026/10/8 17:07:04

HyperFrames:多维时序数据高性能容器设计与实战指南

如果你最近在折腾多传感器时序数据、量化特征工程或者边缘端上的流式处理,八成会撞到同一个尴尬:Pandas 处理海量高维数据时有点力不从心,NumPy 又太底层,换个专用时序库又没有统一的数据结构。我这几周把一个叫 hyperframes 的想…

2026/10/8 17:07:04

Agent-Reach CLI Agent 实战:架构、token 优化与自动化集成

1. 从命令行出发:Agent-Reach 到底在解决什么问题 第一次看到 Agent-Reach 这个名字,我下意识把它和市面上那些“AI Agent 框架”归到了一类。但翻了一圈热词和社区讨论之后,我发现它真正有意思的地方不在“又一个 Agent 框架”,而…

2026/10/8 17:07:04

Agent-Reach:基于Python的轻量级AI Agent命令行工具实战

1. 项目缘起:为什么我要折腾一个叫 Agent-Reach 的东西先坦白讲,我不是那种看到新概念就冲上去的人。AI Agent 这个词火了一年多,各种框架、平台、白皮书满天飞,我一开始的态度是观望。真正让我动手的契机,是手上几个重…

2026/10/8 17:42:15

OpenClaw 本地安装部署讲解:TaoToken 统一 Key 接入与配置验证

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

2026/10/8 10:03:18

Jev+Agent接管浏览器:browser-use实战与jev-ultrafast性能优化

1. 从“Jev”说起:为什么我要把Agent接进浏览器“Jev”这个词最近在圈子里出现的频率越来越高,很多人第一次听到会以为是某个新模型的名字,其实它更像是一种思路——把Jev模型的能力当作底座,通过Agent的方式去接管浏览器&#xf…

2026/10/8 10:03:20

多智能体集群实战:DeepAgents编排、MCP与A2A协议及Skills体系

1. 从"单兵作战"到"集群协同":多智能体编排到底在解决什么问题如果你最近在折腾 Agent 相关的东西,大概率会有一种感觉:单个 Agent 能做的事情,其实很快就摸到天花板了。你给它一个提示词,挂几个工…

2026/10/8 6:05:44

无源低通滤波器设计实战:从RC到LC,手把手教你避开那些坑

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

2026/10/8 0:02:17

自然数立方等于连续奇数之和:从证明到编程验证

十几年来我一直游走在数学科普和编程教学这两块内容之间,对“看起来像魔法、拆开全是数学”的结论总是格外敏感。最近翻资料时又撞见一句话:任何一个自然数 m 的立方,都可以写成 m 个连续奇数之和。2 的立方等于 3 加 5,3 的立方等…

2026/10/8 0:02:17

C#上位机SSH连接实战:用SSH.NET补齐超时、批量与密钥认证

简介:这是一份基于 C# 开发的 SSH 连接功能半成品工程,原本作为另一个主项目的子功能模块,现独立打包分享。工程采用 WinForms 界面,包含源码、解决方案、安装部署工程、NuGet 依赖包及说明文档,适合正在做远程连接、网…

2026/10/8 0:02:17

Java SpringBoot一体化智能售后系统设计与实现全解析

毕业设计年年做,Java Web 方向的题目翻来覆去就那么几个,但“一体化智能售后系统”这个题,每次看到我都觉得值得认真聊一聊。它不是一个简单 curd 堆出来的管理系统,而是把客户、工单、派单、处理、回访、统计整条链路串起来的一套…

还想了解更多?直接咨询顾问

免费诊断 + 免费方案 + 透明报价。

全国咨询热线400-8866-253
免费获取方案
☎咨询二维码 ☎ ↑