
简介这份资源是一篇基于微信小程序的马拉松运动会报名系统毕业论文面向计算机相关专业毕业生或需要完成SSM框架课程设计的开发者。文档围绕赛事报名场景从系统背景、需求分析到技术选型与功能设计展开完整覆盖个人中心、用户管理、赛事信息与报名管理、活动商场、留言板、订单管理等核心模块并详细说明Spring、SpringMVC、MyBatis整合的SSM后台架构及MySQL数据库设计思路可直接作为毕业设计参考或答辩素材。压缩包内共1个doc文件大小1.83MB内容包含中英文摘要、目录、系统概述、技术介绍及后续设计章节便于按章节查阅。目前已有315人学习下载适合需要快速搭建同类报名系统框架或撰写相关论文的读者能帮助理解微信小程序与SSM前后端分离的开发流程及数据库表结构规划具有较高的实用参考价值。1. 马拉松报名系统不是普通表单它要解决的是“资格 名额 支付”三件事先泼一盆冷水马拉松运动会报名系统如果只做成“填表 存库”撑不过报名第一天。真正的复杂度不在表单字段而在三件事——报名资格怎么核完赛证明、体检报告、名额怎么锁先到先得还是抽签、支付状态怎么对微信支付回调、退款、释放名额。这个题目把“微信小程序”和“SSM”绑在一起本身就是在问前端用小程序承载报名交互后端用 Spring SpringMVC MyBatis 处理业务和事务能不能用最小成本搭出一套可上线、可答辩、可扩展的闭环。答案是能而且不需要引入微服务或消息队列。我下面按自己做这类毕业设计或小规模商用系统的习惯把登录、数据模型、支付状态机、并发守门四条线串起来讲。新手可以照着把代码敲出来熟手可以重点看状态流转和防重复提交那几处那是这类系统最容易翻车的地方。2. 从 wx.login 到 SSM 会话code 换 token 的最小闭环2.1 登录链路里谁保管 openid谁保管 token微信小程序的登录和传统网页登录有个本质区别没有密码。小程序端调用wx.login()拿到的是一个一次性凭证code这个 code 只能交给后端由后端拿着 appid、secret 去微信接口换成 openid 和 session_key。很多初学者把code直接当身份标识用或者把session_key下发到前端都是错的。正确的职责划分是openid 是用户在小程序生态里的身份证由后端保管并绑定到 user 表session_key 是微信会话密钥只在后端使用用来解密手机号等敏感数据绝不能进数据库长期保存小程序端每次请求携带的应该是由后端签发的业务 token而不是 openid 本身。流程上就是常说的“用 code 换 token”前端把 code 交给后端后端换回 openid 后生成自己的 token后续请求只认这个 token。这里要补一句微信授权的新变化现在wx.getUserProfile拿到的头像昵称已经是脱敏的匿名数据不能作为真实用户信息存储。报名系统里要收集姓名、身份证号、紧急联系人最稳妥的做法是让用户在小程序里手动填写而不是依赖微信授权否则用户体验和合规都会出问题。数据生成方存储位置有效期用途code小程序 wx.login()不落库5 分钟换 openidopenid微信服务端用户表永久用户唯一标识session_key微信服务端后端内存或缓存随会话解密手机号等token后端生成Redis2 小时可续期后续请求鉴权2.2 后端 Controller拿 code 换微信会话再换自己的 token后端用 SSM 写这个接口核心就是用 RestTemplate 调微信的jscode2session接口。参数固定是四个appid、secret、js_code、grant_type其中 grant_type 固定填authorization_code。微信返回的 JSON 里有 openid 和 session_key如果小程序关联了开放平台还会有 unionid。RestController RequestMapping(/api/auth) public class WxAuthController { Autowired private UserMapper userMapper; Autowired private StringRedisTemplate redisTemplate; Value(${wx.appid}) private String appid; Value(${wx.secret}) private String secret; PostMapping(/login) public Result login(RequestBody LoginRequest request) { // 1. 用 code 向微信服务端换 openid String url https://api.weixin.qq.com/sns/jscode2session ?appid appid secret secret js_code request.getCode() grant_typeauthorization_code; RestTemplate restTemplate new RestTemplate(); String response restTemplate.getForObject(url, String.class); JSONObject json JSON.parseObject(response); if (json.get(errcode) ! null) { return Result.error(code 无效或已过期); } String openid json.getString(openid); // 2. 查不到用户则自动注册 User user userMapper.findByOpenid(openid); if (user null) { user new User(); user.setOpenid(openid); user.setNickname(跑者 openid.substring(openid.length() - 6)); user.setCreateTime(new Date()); userMapper.insert(user); } // 3. 生成业务 token 存入 Rediskey 为 tokenvalue 为 userId String token UUID.randomUUID().toString().replace(-, ); redisTemplate.opsForValue() .set(marathon:token: token, String.valueOf(user.getId()), 2, TimeUnit.HOURS); // 4. 只返回 token不返回 openid 和 session_key return Result.success(token); } }这段代码里有几个参数值得说明。LoginRequest里只有一个code字段前端调wx.login成功后在 success 回调里拿到的就是它。Redis 的 key 加了marathon:token:前缀避免和其他业务数据混淆ttl 设 2 小时跑者只要在 2 小时内没操作就得重新登录这个时间可以根据实际场景调整。openid.substring截取后六位做默认昵称是为了避免直接暴露完整 openid 给前端。2.3 拦截器里校验 token用户态从 Redis 拿不问数据库上面的登录接口是白名单其他接口都要做 token 校验。在 SSM 项目里这一步用 SpringMVC 的HandlerInterceptor比 Servlet 的 Filter 更顺手因为可以直接拿到 handler 对象也能用注解做放行控制。自定义注解NoAuth打在登录接口上拦截器里检查到就放行。public class TokenInterceptor implements HandlerInterceptor { Autowired private StringRedisTemplate redisTemplate; Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { // 放行登录接口 if (handler instanceof HandlerMethod) { HandlerMethod method (HandlerMethod) handler; if (method.hasMethodAnnotation(NoAuth.class)) { return true; } } // 从请求头取 token String token request.getHeader(token); if (token null || token.isEmpty()) { response.setStatus(401); return false; } // 校验 token 并写入 userId String userId redisTemplate.opsForValue() .get(marathon:token: token); if (userId null) { response.setStatus(401); return false; } request.setAttribute(userId, Integer.parseInt(userId)); return true; } }这个拦截器的妙处在于校验过程完全不查数据库Redis 命中即通过报名高峰期能省掉大量无效的 SQL 查询。request.setAttribute(userId, ...)这一行很关键后面的 Controller 方法里直接用RequestAttribute(userId) Integer userId就能拿到当前登录用户不用再去解析 token。注册拦截器时要注意排除路径比如小程序端访问静态资源或者支付回调地址都不该走这个拦截器。3. 赛事、项目、报名、订单马拉松报名数据模型的四张核心表3.1 为什么报名记录要绑定“项目”而不是“赛事”马拉松运动会和普通活动报名的最大区别在于一场赛事下会有多个项目全程马拉松、半程马拉松、欢乐跑每个项目有独立的报名费、名额上限、起跑时间。如果把报名记录直接挂在赛事表上那“半马满了但全马还有名额”这类需求就没办法用数据库约束表达。所以四张核心表的关系是赛事表在最上层项目表挂赛事报名表挂项目和用户订单表管支付。项目表里的quota是总名额registered是已报人数报名时要保证registered quota才能插入报名记录。订单表不直接关联报名表而是通过project_id和user_id关联这样同一个用户可以给家里人报不同项目每人一条订单互不干扰。CREATE TABLE event ( id BIGINT PRIMARY KEY AUTO_INCREMENT, name VARCHAR(128) NOT NULL COMMENT 赛事名称, start_date DATE NOT NULL COMMENT 比赛日期, reg_start_time DATETIME NOT NULL COMMENT 报名开始时间, reg_end_time DATETIME NOT NULL COMMENT 报名截止时间, status TINYINT DEFAULT 0 COMMENT 0未开始 1报名中 2已截止, create_time DATETIME DEFAULT CURRENT_TIMESTAMP ); CREATE TABLE event_item ( id BIGINT PRIMARY KEY AUTO_INCREMENT, event_id BIGINT NOT NULL COMMENT 所属赛事, name VARCHAR(64) NOT NULL COMMENT 项目名称, quota INT NOT NULL COMMENT 总名额, registered INT DEFAULT 0 COMMENT 已报名人数, fee DECIMAL(10,2) NOT NULL COMMENT 报名费, start_time DATETIME COMMENT 发枪时间 ); CREATE TABLE registration ( id BIGINT PRIMARY KEY AUTO_INCREMENT, user_id BIGINT NOT NULL, event_item_id BIGINT NOT NULL, real_name VARCHAR(64) NOT NULL, id_card VARCHAR(32) NOT NULL, phone VARCHAR(20) NOT NULL, emergency_contact VARCHAR(64), emergency_phone VARCHAR(20), blood_type VARCHAR(8), status TINYINT DEFAULT 0 COMMENT 0待支付 1已支付 2已取消, create_time DATETIME DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_user_item (user_id, event_item_id) ); CREATE TABLE payment_order ( id BIGINT PRIMARY KEY AUTO_INCREMENT, order_no VARCHAR(64) NOT NULL UNIQUE COMMENT 业务订单号, user_id BIGINT NOT NULL, event_item_id BIGINT NOT NULL, registration_id BIGINT NOT NULL, amount DECIMAL(10,2) NOT NULL, status TINYINT DEFAULT 0 COMMENT 0待支付 1已支付 2已退款, notify_time DATETIME, create_time DATETIME DEFAULT CURRENT_TIMESTAMP );registration表里的uk_user_item唯一索引是本设计的核心它从数据库层面保证了一个用户对一个项目只能有一条报名记录后面并发防重全靠它兜底。payment_order表里order_no也建了唯一索引支付回调处理时可以靠它做幂等。注意registration和payment_order是一对一关系通过registration_id关联没必要在报名表里冗余订单状态以外的信息。3.2 用唯一索引把“重复报名”挡在数据库层很多人在应用层判断“用户是否已报名”先 SELECT 再 INSERT这在低并发下没问题但报名开启的前十秒两个请求同时查询都可能返回“未报名”然后同时插入成功。唯一索引的介入让这个判断变得刚性数据库本身就不允许同一个人对同一个项目有第二条记录重复插入直接抛DuplicateKeyException。实际开发里我不建议把报名的业务校验全堆在 Service 层。正确的姿势是Service 层做名额判断和状态检查DAO 层负责插入捕获唯一索引冲突后给用户提示“您已报名该项目请勿重复提交”。这里有个技巧异常捕获要精准不要 catch 所有Exception否则会把真正的系统错误也吞掉。try { registrationMapper.insert(registration); } catch (DuplicateKeyException e) { return Result.error(您已报名该项目请勿重复提交); }3.3 MyBatis 事务与“先到先得”的库存扣减写法报名动作本身包含两步写操作插入报名记录 更新项目的已报人数。这两步必须在一个事务里要么都成功要么都失败。SSM 里用Transactional注解就能搞定但要注意事务的粒度把校验放在事务方法内部而不是让 Controller 先查了再调 Service。名额扣减的写法是这个模块最容易踩坑的地方。最直观但错误的方式是先SELECT registered FROM event_item WHERE id ?判断小于 quota 后再UPDATE event_item SET registered registered 1 WHERE id ?。这个写法在并发下一定会超卖两个线程都读到 2999都执行加一。正确的做法是用一条条件更新语句让数据库在更新时判断名额余量。update idincreaseRegistered UPDATE event_item SET registered registered 1 WHERE id #{itemId} AND registered lt; quota /updateTransactional(rollbackFor Exception.class) public Result register(RegisterRequest request, Integer userId) { // 1. 扣减名额影响行数为 0 说明名额已满 int updated eventItemMapper.increaseRegistered(request.getItemId()); if (updated 0) { throw new BusinessException(该项目名额已满); } // 2. 插入报名记录 Registration registration buildRegistration(request, userId); try { registrationMapper.insert(registration); } catch (DuplicateKeyException e) { throw new BusinessException(您已报名该项目); } return Result.success(); }increaseRegistered里的registered quota条件就是传说中的乐观锁思路不加数据库锁用条件更新保证原子性。注意 XML 里小于号要转义成lt;初学者常在这里收到 MyBatis 的解析报错。事务方法内先扣名额再插报名如果插入失败抛出异常事务回滚名额自动加回去不会出现“名额扣了但报名记录没有”的中间状态。4. 支付回调与报名状态机从待支付到已中签4.1 先到先得和抽签两种模式的状态流转差异马拉松运动会报名和普通电商下单有一个明显不同存在“先到先得”和“抽签”两种模式。先到先得是用户报名后直接生成待支付订单支付成功即锁定名额抽签是先报名进入待抽签池抽中后才能支付没抽中名额释放。两种模式对应不同的状态机设计但它们可以共用同一套报名表和订单表只是多一个字段区分审核状态。状态触发动作下一个状态说明待支付用户提交报名待支付生成订单锁定名额已支付支付回调成功已支付名额正式锁定生成参赛号待抽签抽签制下提交报名待抽签不生成支付订单已中签抽签结果公布待支付生成支付订单限时支付未中签抽签结果公布已取消释放占位已取消用户超时未支付已取消释放名额状态只进不退先到先得模式下没有“待抽签”这一环实际做的时候我建议把抽签模式的状态字段单独出来不要在原有状态字段上叠加否则后面统计报表会非常痛苦。我在registration表里一般会加一个ticket_status字段专门管“未抽签/已中签/未中签”跟支付状态status分开维护。4.2 微信支付统一下单与回调验签支付环节必须后端统一处理前端不应该直接拿金额调用微信支付。正确流程是报名提交成功后后端调微信支付的统一下单接口JSAPI 下单拿到 prepay_id再封装成小程序端需要的支付参数返回给前端。前端拿到参数后调wx.requestPayment拉起收银台。后端生成支付参数时的签名算法必须和微信官方保持一致否则会报sign error。小程序端拉起支付需要五个参数timeStamp、nonceStr、package注意这个字段名就叫 package值为prepay_idxxx、signType、paySign。paySign 的计算方式是把这几个参数按固定格式拼接再用商户密钥做 MD5 或 HMAC-SHA256。Override public PayParam createPayment(Integer registrationId, Integer userId) { Registration reg registrationMapper.selectById(registrationId); EventItem item eventItemMapper.selectById(reg.getEventItemId()); // 金额必须从数据库读不能信任前端传值 String orderNo generateOrderNo(); paymentOrderMapper.insert(new PaymentOrder(orderNo, userId, item.getId(), reg.getId(), item.getFee())); // 统一下单参数 MapString, String params new HashMap(); params.put(appid, appid); params.put(mch_id, mchId); params.put(out_trade_no, orderNo); params.put(total_fee, String.valueOf( item.getFee().multiply(new BigDecimal(100)).intValue())); params.put(body, 马拉松报名费- item.getName()); params.put(notify_url, notifyUrl); params.put(trade_type, JSAPI); params.put(openid, userMapper.selectById(userId).getOpenid()); // 按微信规则排序拼接后 mD5 签名 String sign wxSignUtil.sign(params); params.put(sign, sign); String prepayId wxPayClient.unifiedOrder(params); // 后续封装 paySign 返回给小程序 return buildPayParam(prepayId); }这段逻辑里有三个点需要特别说明。第一个是total_fee的单位是分不是元金额从EventItem表读取而不是从请求参数读这是防篡改的基本要求。第二个是notify_url必须是公网 HTTPS 地址微信服务器会主动回调这个地址通知支付结果本地调试可以用内网穿透工具但不能上线用。第三个是订单号需要保证唯一常见做法是日期时间加随机数我用的是时间戳加用户ID加四位随机数。4.3 回调用唯一约束保证幂等用乐观锁防超扣支付回调是系统里最容易出问题的环节。微信支付成功后服务器会以 POST 方式回调notify_url携带完整的报文和签名。如果回调处理失败返回非成功响应微信会间隔重试多次所以回调处理逻辑必须幂等——同一个订单被回调十次结果与回调一次完全一样。处理回调第一步是验签把回调报文里的签名和商家自己按规则算出来的签名做比对防止伪造回调。第二步是按out_trade_no查订单如果订单已经是“已支付”状态直接返回成功不再重复处理。第三步是更新订单状态和报名状态这一步要用条件更新确保并发下不会重复退款或重复释放名额。PostMapping(/pay/notify) public String payNotify(RequestBody String xmlData) { // 1. 解析微信回调报文并验签 MapString, String data WxPayUtil.xmlToMap(xmlData); if (!WxPayUtil.verifyNotifySign(data)) { return xmlreturn_codeFAIL/return_code/xml; } if (SUCCESS.equals(data.get(result_code))) { String orderNo data.get(out_trade_no); // 2. 幂等更新只处理待支付状态的订单 int updated paymentOrderMapper.updatePaySuccess(orderNo); if (updated 0) { // 3. 更新报名表状态为已支付 registrationMapper.updateStatusByOrderNo(orderNo, 1); } } // 4. 必须返回成功否则微信会重试 return xmlreturn_codeSUCCESS/return_code/xml; }updatePaySuccess的 SQL 是关键UPDATE payment_order SET status 1, pay_time NOW() WHERE order_no ? AND status 0。这里的status 0条件就是乐观锁只有待支付订单才会被更新成功其他更新返回 0天然避开了重复处理。回调接口要放在拦截器的白名单里因为微信服务器请求时不会带业务 token如果被拦截器拦下来支付永远无法回调成功。5. 报名高峰的并发守门与赛后验证5.1 三种库存扣减写法的对与错很多教程喜欢在上述条件更新的基础上再套一层 Redis 分布式锁锁上加锁。实际上对于一场马拉松运动会报名峰值 QPS 能到 200 就算很夸张了。条件更新 唯一索引这套组合在这个量级下完全够用查一次数据库加一次更新耗时在 10 毫秒以内。真正影响体验的不是数据库写冲突而是前端重复点击和用户支付超时后的名额残留。方案并发安全实现成本适用场景先查后改不安全一定超卖最低仅演示用条件更新 registered quota安全低报名峰值 QPS 低于 1000Redis 预扣 异步落库安全高抢购、秒杀等 QPS 上万场景我见过最冤枉的“系统崩溃”不是数据库被打挂而是前端报名按钮没有做防重复提交用户连点五次每次请求都到了后端虽然唯一索引挡住了多余的报名记录但会抛出大量异常。解法是在前端button上做提交锁定同时在小程序端用wx.showLoading遮住页面两个措施搭配才有效。5.2 用并发脚本证明唯一索引真的挡得住系统上线前建议先在本机跑一轮并发脚本。不需要引入 JMeter 这种重工具一段简单的 bash 循环配合 curl 就能模拟 20 个并发请求。脚本里的TOKEN是真实登录获取的 token执行完后再统计数据库里的报名记录数验证是否只有一条记录存在。for i in $(seq 1 20); do curl -s -X POST http://localhost:8080/api/registration \ -H Content-Type: application/json \ -H token: $TOKEN \ -d {\eventItemId\: 1} \ -o /dev/null done wait mysql -u root -p -e SELECT COUNT(*) FROM registration WHERE event_item_id 1; SELECT registered, quota FROM event_item WHERE id 1; 这段脚本用把 curl 丢到后台并行执行wait保证所有请求完成后再查结果。如果第一句 SELECT 返回 1第二句的 registered 也恰好是 1说明唯一索引和条件更新都正常工作不会出现“报名表里只有一条记录但名额被扣了 20 个”的故障。5.3 赛后成绩与证书不依赖第三方服务的加载方案马拉松系统还有个绕不开的功能参赛证书。证书不能提前生成必须等比赛结束后导入成绩数据才能出图。不引入专业报告引擎用 Java 自带的Graphics2D就足够画一张像样的完赛证书。思路是加载一张设计好的背景图在指定坐标处写入姓名、项目、成绩、枪声成绩最后ImageIO.write输出 PNG 文件把文件名回填到数据库里的certificate_url字段。BufferedImage image ImageIO.read(new File(bgPath)); Graphics2D g image.createGraphics(); g.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON); g.setFont(new Font(微软雅黑, Font.BOLD, 36)); g.setColor(new Color(139, 69, 19)); g.drawString(runner.getRealName(), 500, 390); g.drawString(完赛成绩 runner.getFinishTime(), 500, 450); ImageIO.write(image, png, new File(outputPath));证书生成的性能关键在于字体渲染createGraphics每调一次就创建一幅内存画像建议后端在项目启动时预加载字体对象复用而不是每次都new Font。证书 URL 存数据库后小程序端用image组件就能直接展示这条路线的成本接近零也完全可控。最后提醒一句无论做的是毕业设计还是商用项目报名数据至少要每天导出一次备份马拉松系统挂了可以修跑者数据丢了没法补。本文还有配套的精品资源点击获取