简介:这是一套面向高校计算机相关专业学生的毕业设计/课程设计完整项目,采用微信小程序前端搭配Java后端与MySQL数据库,实现马拉松赛事报名与活动商城一体化业务。系统区分管理员与普通用户两类角色:管理员可管理个人中心、用户、赛事信息、赛事报名、活动商场、留言板、系统配置及订单;用户可注册登录、浏览赛事、在线报名并购买商城商品,适合作为小程序开发与Java Web综合实践的参考案例。资源包共1220个文件,约41.42MB,包含127个java后端源码、135个vue管理端页面、172个js脚本、86个wxss与84个wxml小程序页面,以及png、svg、jpg等图片素材和sql数据库脚本、mp4演示视频,前后端与数据库结构完整。目前已有299人学习下载,配套演示视频与说明文档可帮助读者快速理解项目架构、梳理报名与订单业务流程,并在此基础上完成二次开发或答辩准备。
1. 马拉松报名系统:从高并发抢名额到毕业设计落地的完整拆解
每年三四月份,国内各大城市马拉松赛事密集开闸,报名通道一开,几万个名额在几十秒内被抢空。这个场景对后端工程师来说并不陌生——它本质上是一个典型的“限时高并发写”问题:大量用户在同一时刻争抢有限资源,数据库连接池瞬间打满,超卖和重复报名接踵而至。而“基于微信小程序+Java后端的马拉松报名系统”这个毕业设计选题,恰好把微信小程序开发、Java后端、前后端分离项目实战、数据库设计这几个热搜词串成了一条完整的工程链路。它适合正在找计算机毕业设计选题的学生,也适合想通过一个完整项目补齐前后端分离经验的新手开发者。这篇文章不讲空泛的架构图,而是从环境搭建、数据库设计、核心接口实现到部署排错,把一套可复现的方案讲透。
2. 技术选型与环境搭建:为什么是 Spring Boot + 微信小程序原生
2.1 后端框架选型:Spring Boot 而非 RuoYi 的取舍
毕业设计的时间窗口通常只有两到三个月,选型的第一原则是“能跑通、能讲清、能扩展”。Spring Boot 在这个场景下比 RuoYi 框架更合适,原因有三:第一,RuoYi 自带权限管理和代码生成器,功能全但层次多,答辩时被问到“你的代码在哪”容易说不清;第二,Spring Boot 的自动配置让项目结构扁平,Controller、Service、Mapper 三层一目了然,方便在论文里画架构图;第三,热搜词里“前后端分离项目实战”是高频需求,Spring Boot 天然适合做纯 JSON 接口,不掺和页面渲染。
我一般会用的依赖组合是:spring-boot-starter-web提供 REST 能力,mybatis-plus-boot-starter简化单表 CRUD,mysql-connector-java做数据库驱动,hutool-all处理日期和加密工具类。版本上,Spring Boot 2.7.x 是稳妥选择,JDK 用 1.8 或 11 都行,别追最新版,毕业设计求稳不求新。
<!-- pom.xml 核心依赖片段 --> <parent> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-parent</artifactId> <version>2.7.18</version> </parent> <dependencies> <!-- Web 层:提供 REST 接口能力 --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <!-- ORM 层:MyBatis-Plus 简化单表操作 --> <dependency> <groupId>com.baomidou</groupId> <artifactId>mybatis-plus-boot-starter</artifactId> <version>3.5.3.1</version> </dependency> <!-- 数据库驱动 --> <dependency> <groupId>mysql</groupId> <artifactId>mysql-connector-java</artifactId> <version>8.0.33</version> </dependency> <!-- 工具类:日期处理、加密、随机数 --> <dependency> <groupId>cn.hutool</groupId> <artifactId>hutool-all</artifactId> <version>5.8.22</version> </dependency> </dependencies>这段配置的逻辑很直接:Web 层负责接收小程序发来的 HTTP 请求,MyBatis-Plus 把数据库操作简化到几乎不用写 XML,Hutool 处理报名时间戳和订单号生成。参数上唯一需要注意的是 MySQL 驱动版本要和本地数据库对齐,8.0 的驱动连 5.7 的库会报时区错误,在 JDBC URL 里加serverTimezone=Asia/Shanghai就能解决。
2.2 微信小程序端:原生开发还是 uni-app
热搜词里“uniapp 开发 微信小程序 vs android/ios/鸿蒙”出现频率很高,说明很多人在纠结跨端方案。我的建议是:毕业设计阶段用微信小程序原生开发。理由很实际——原生小程序的 API 文档最全,遇到问题搜索“微信小程序 + 具体 API 名”几乎都能找到答案;uni-app 虽然能一套代码多端发布,但编译层的黑匣子问题在调试时非常折磨人,一个样式错位可能要查半天。而且毕业设计的演示环境就是微信开发者工具,原生开发省去编译环节,热重载更快。
小程序端的目录结构按功能划分:pages/放页面,utils/放请求封装和工具函数,config/放后端接口地址。请求封装是第一个要写的模块,因为后面所有接口调用都依赖它。
// utils/request.js —— 统一请求封装 const BASE_URL = 'http://localhost:8080/api'; const request = (url, method = 'GET', data = {}) => { return new Promise((resolve, reject) => { wx.request({ url: BASE_URL + url, method: method, data: data, header: { 'content-type': 'application/json', 'token': wx.getStorageSync('token') || '' // 从缓存取登录令牌 }, success: (res) => { if (res.statusCode === 200 && res.data.code === 200) { resolve(res.data.data); // 只返回业务数据 } else if (res.data.code === 401) { wx.showToast({ title: '请先登录', icon: 'none' }); wx.navigateTo({ url: '/pages/login/login' }); reject(res.data); } else { wx.showToast({ title: res.data.msg || '请求失败', icon: 'none' }); reject(res.data); } }, fail: (err) => { wx.showToast({ title: '网络异常', icon: 'none' }); reject(err); } }); }); }; module.exports = { request };这个封装做了三件事:拼接基础 URL、自动携带 token、统一处理 401 未登录跳转。参数说明:BASE_URL在开发阶段指向本地localhost:8080,上线前要改成服务器地址;token存在小程序缓存里,登录成功后写入,退出时清除。注意微信开发者工具需要勾选“不校验合法域名”才能请求 localhost,真机调试则必须用 HTTPS 域名。
2.3 数据库设计:三张核心表撑起报名逻辑
马拉松报名系统的数据库不需要几十张表,核心就三张:赛事表race、报名记录表registration、用户表user。赛事表存比赛名称、报名开始时间、报名截止时间、总名额、已报名人数;报名记录表存用户 ID、赛事 ID、报名时间、参赛号码、支付状态;用户表存微信 openid、昵称、手机号。
-- 赛事表:控制报名开关和名额 CREATE TABLE `race` ( `id` BIGINT NOT NULL AUTO_INCREMENT, `race_name` VARCHAR(100) NOT NULL COMMENT '赛事名称', `sign_start_time` DATETIME NOT NULL COMMENT '报名开始时间', `sign_end_time` DATETIME NOT NULL COMMENT '报名截止时间', `total_quota` INT NOT NULL DEFAULT 0 COMMENT '总名额', `signed_count` INT NOT NULL DEFAULT 0 COMMENT '已报名人数', `race_date` DATE NOT NULL COMMENT '比赛日期', `status` TINYINT DEFAULT 1 COMMENT '1=启用 0=禁用', PRIMARY KEY (`id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- 报名记录表:唯一索引防止重复报名 CREATE TABLE `registration` ( `id` BIGINT NOT NULL AUTO_INCREMENT, `user_id` BIGINT NOT NULL, `race_id` BIGINT NOT NULL, `sign_time` DATETIME DEFAULT CURRENT_TIMESTAMP, `bib_number` VARCHAR(20) DEFAULT NULL COMMENT '参赛号码', `pay_status` TINYINT DEFAULT 0 COMMENT '0=未支付 1=已支付', PRIMARY KEY (`id`), UNIQUE KEY `uk_user_race` (`user_id`, `race_id`) -- 关键:同一用户同一赛事只能报一次 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;这里最关键的是registration表上的唯一索引uk_user_race。很多同学在 Service 层用“先查再插”来防重复,但在并发场景下两个请求可能同时查到“未报名”,然后都执行插入。唯一索引是数据库层面的最后一道防线,配合 Service 层的捕获异常处理,才能彻底杜绝重复报名。signed_count字段是冗余设计,用空间换查询性能,每次报名成功后原子递增,避免实时COUNT(*)拖慢列表接口。
3. 核心接口实现:报名、支付回调与名额扣减
3.1 报名接口:乐观锁 + 唯一索引双保险
报名接口是整个系统最核心也最容易翻车的地方。热搜词里“java怎么保证数据一致性”在这个场景下就是:如何保证名额不超卖、用户不重复报名。我的方案是三层防护:第一层,Redis 预扣减名额(可选,毕业设计可简化);第二层,数据库乐观锁更新signed_count;第三层,唯一索引兜底。
// RegistrationService.java —— 报名核心逻辑 @Service public class RegistrationService { @Autowired private RaceMapper raceMapper; @Autowired private RegistrationMapper registrationMapper; @Transactional(rollbackFor = Exception.class) public void signUp(Long userId, Long raceId) { // 1. 查询赛事信息,校验报名时间窗口 Race race = raceMapper.selectById(raceId); if (race == null || race.getStatus() == 0) { throw new BizException("赛事不存在或已下架"); } LocalDateTime now = LocalDateTime.now(); if (now.isBefore(race.getSignStartTime()) || now.isAfter(race.getSignEndTime())) { throw new BizException("不在报名时间段内"); } // 2. 乐观锁扣减名额:WHERE signed_count < total_quota int updated = raceMapper.decreaseQuota(raceId); if (updated == 0) { throw new BizException("名额已满"); } // 3. 插入报名记录,唯一索引兜底防重复 try { Registration reg = new Registration(); reg.setUserId(userId); reg.setRaceId(raceId); reg.setSignTime(new Date()); registrationMapper.insert(reg); } catch (DuplicateKeyException e) { throw new BizException("您已报名该赛事"); } } }对应的 Mapper XML 里,decreaseQuota的 SQL 是:
<update id="decreaseQuota"> UPDATE race SET signed_count = signed_count + 1 WHERE id = #{raceId} AND signed_count < total_quota </update>逻辑说明:WHERE signed_count < total_quota这个条件就是乐观锁的核心——只有名额未满时才更新,更新影响行数为 0 说明名额已满。参数上,@Transactional保证扣减和插入在同一个事务里,任何一步失败都回滚。注意DuplicateKeyException要单独捕获,不能和业务异常混在一起,否则用户会看到“系统错误”而不是“您已报名”。
3.2 微信登录与用户信息解密
微信小程序的登录流程是:小程序端调用wx.login()拿到临时 code,传给后端;后端用 code + appid + secret 调用微信接口换 openid 和 session_key;后端生成自定义 token 返回给小程序。这个过程涉及一次外部 HTTP 请求,用 Hutool 的HttpUtil就能搞定。
// WxLoginService.java —— 微信登录换取 openid @Service public class WxLoginService { @Value("${wx.appid}") private String appid; @Value("${wx.secret}") private String secret; public String login(String code) { // 调用微信接口,用 code 换 openid String url = String.format( "https://api.weixin.qq.com/sns/jscode2session?appid=%s&secret=%s&js_code=%s&grant_type=authorization_code", appid, secret, code ); String response = HttpUtil.get(url); JSONObject json = JSONUtil.parseObj(response); String openid = json.getStr("openid"); if (StrUtil.isBlank(openid)) { throw new BizException("微信登录失败:" + json.getStr("errmsg")); } // 查库:openid 已存在则直接登录,不存在则注册 User user = userMapper.selectByOpenid(openid); if (user == null) { user = new User(); user.setOpenid(openid); user.setCreateTime(new Date()); userMapper.insert(user); } // 生成 JWT token 返回 return JwtUtil.createToken(user.getId()); } }参数说明:appid和secret写在application.yml里,不要硬编码在 Java 文件中。jscode2session接口的 code 只能用一次,有效期五分钟,所以小程序端每次登录都要重新wx.login()。注意这个接口的调用频率有限制,开发阶段频繁重启服务可能导致临时被封,建议把 openid 缓存到 Redis 或本地缓存里。
3.3 支付回调与报名状态流转
毕业设计里的支付通常是模拟支付,但流程要完整:用户点击报名 → 生成待支付订单 → 调用支付接口(模拟)→ 支付回调更新报名状态。真实场景下微信支付回调需要验签,毕业设计可以简化为一个内部接口。
// PayCallbackController.java —— 模拟支付回调 @RestController @RequestMapping("/api/pay") public class PayCallbackController { @Autowired private RegistrationMapper registrationMapper; @PostMapping("/callback") public Result callback(@RequestBody PayCallbackDTO dto) { // 实际场景需验签,毕业设计简化为校验订单号存在 Registration reg = registrationMapper.selectById(dto.getRegId()); if (reg == null) { return Result.fail("报名记录不存在"); } if (reg.getPayStatus() == 1) { return Result.success("已处理"); // 幂等:重复回调直接返回成功 } // 更新支付状态,生成参赛号码 reg.setPayStatus(1); reg.setBibNumber(generateBibNumber(reg.getRaceId())); registrationMapper.updateById(reg); return Result.success("支付成功"); } private String generateBibNumber(Long raceId) { // 参赛号码规则:赛事ID后两位 + 6位随机数 return String.format("%02d%06d", raceId % 100, ThreadLocalRandom.current().nextInt(1000000)); } }这里有两个关键点:第一,回调接口必须幂等,微信支付会重复推送回调,用payStatus == 1判断直接返回成功;第二,参赛号码生成要避免重复,毕业设计用随机数够用,生产环境需要用序列号或数据库自增。参数上,PayCallbackDTO至少包含regId和transactionId,后者用于对账。
4. 避坑与排查:那些让答辩翻车的细节
4.1 小程序请求本地后端报“不在以下 request 合法域名列表中”
现象:开发者工具里请求http://localhost:8080直接失败,控制台提示域名不合法。原因:微信小程序默认只允许 HTTPS 域名,localhost 不在白名单。解决:开发者工具右上角“详情”→“本地设置”→勾选“不校验合法域名、web-view(业务域名)、TLS 版本以及 HTTPS 证书”。真机调试则必须把后端部署到有 HTTPS 的服务器上,或者用内网穿透工具临时映射一个 HTTPS 地址。
4.2 报名接口并发测试时出现超卖
现象:用 JMeter 模拟 100 个并发请求抢 10 个名额,结果signed_count变成 13 甚至更多。原因:Service 层用了“先查再更新”的写法,两个线程同时查到signed_count=9,都认为还有名额,然后各自加 1。解决:把更新逻辑改成UPDATE race SET signed_count = signed_count + 1 WHERE id = ? AND signed_count < total_quota,用数据库行锁保证原子性。如果用了 Redis 预扣减,注意 Redis 和数据库的一致性,毕业设计建议直接用数据库乐观锁,简单可靠。
4.3 微信登录 code 失效导致“invalid code”
现象:用户第一次登录成功,退出后再登录报错“invalid code”。原因:wx.login()返回的 code 只能用一次,且有效期五分钟。如果小程序端把 code 缓存起来重复使用,第二次请求必然失败。解决:每次调用登录接口前都重新执行wx.login()获取新 code,不要缓存 code。后端如果收到errcode: 40163,直接提示用户重新登录即可。
4.4 MySQL 8.0 驱动连接 5.7 数据库报时区错误
现象:启动 Spring Boot 时报The server time zone value '?D1ú±ê×?ê±??' is unrecognized。原因:MySQL 8.0 驱动默认使用 UTC 时区,而 5.7 数据库的时区配置是系统默认。解决:JDBC URL 里加serverTimezone=Asia/Shanghai,完整写法jdbc:mysql://localhost:3306/marathon?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&useSSL=false。
4.5 报名记录表唯一索引导致事务回滚后名额未恢复
现象:用户重复报名触发DuplicateKeyException,事务回滚,但signed_count没有减回去。原因:decreaseQuota和insert在同一个事务里,插入失败导致整个事务回滚,signed_count的更新也被撤销了。这其实是正确行为——事务保证了原子性。但要注意:如果用了 Redis 预扣减,Redis 的扣减不在数据库事务里,需要在捕获异常后手动把 Redis 的名额加回去。毕业设计如果只用数据库方案,这个问题不存在。
5. 从能跑到能答辩:三个让项目加分的进阶技巧
5.1 用定时任务自动关闭过期报名通道
报名截止时间到了之后,接口应该自动拒绝新报名,而不是靠人工改数据库。用 Spring 的@Scheduled注解写一个每分钟执行一次的定时任务,扫描race表里sign_end_time < now()且status = 1的记录,批量更新为禁用状态。
@Component public class RaceStatusTask { @Autowired private RaceMapper raceMapper; // 每分钟执行一次,关闭已过报名截止时间的赛事 @Scheduled(cron = "0 * * * * ?") public void closeExpiredRaces() { LocalDateTime now = LocalDateTime.now(); raceMapper.closeExpired(now); } }对应的 SQL 是UPDATE race SET status = 0 WHERE sign_end_time < #{now} AND status = 1。这个技巧在答辩时很加分,因为它体现了“系统能自动运转”的工程思维,而不是所有操作都靠手动。
5.2 报名列表接口的分页与缓存
赛事列表页是访问频率最高的接口,每次打开小程序都会请求。如果每次都查数据库,并发上来后数据库压力很大。我的做法是用 MyBatis-Plus 的分页插件做分页,然后在 Service 层加一层本地缓存,缓存时间 30 秒。
// RaceService.java —— 带缓存的分页查询 @Cacheable(value = "raceList", key = "#page + '-' + #size") public PageResult<RaceVO> listRaces(int page, int size) { Page<Race> pageParam = new Page<>(page, size); Page<Race> result = raceMapper.selectPage(pageParam, new LambdaQueryWrapper<Race>() .eq(Race::getStatus, 1) .orderByDesc(Race::getSignStartTime)); return PageResult.of(result); }注意@Cacheable需要在启动类上加@EnableCaching注解。缓存 key 用页码和每页条数拼接,避免不同分页互相覆盖。参数上,size建议限制在 20 以内,防止一次拉取过多数据。这个技巧在答辩时可以说“通过缓存降低数据库压力”,比单纯说“用了 Redis”更实在。
5.3 用 Postman 做接口自测的检查清单
答辩前一定要把接口全部自测一遍,我一般会按这个顺序检查:登录接口能否正常换 openid、报名接口在名额满时是否返回正确提示、重复报名是否被拦截、支付回调是否幂等、分页接口在 page 超出范围时是否返回空列表而不是报错。每个接口至少测三种情况:正常参数、边界参数(如 page=0)、异常参数(如 raceId 不存在)。把测试结果截图放进论文的“系统测试”章节,比空口说“系统运行稳定”有说服力得多。
5.4 数据库备份与演示环境准备
答辩现场最怕演示时数据库连不上。我的习惯是:答辩前一天用mysqldump把数据库导出成 SQL 文件,同时把后端项目打成 jar 包,在本地用java -jar启动,不依赖 IDE。小程序端提前在开发者工具里登录好,缓存 token,避免现场扫码登录时网络卡顿。
# 导出数据库结构和数据 mysqldump -u root -p marathon > marathon_backup.sql # 启动后端服务(后台运行) nohup java -jar marathon-backend.jar --spring.profiles.active=prod > app.log 2>&1 &这两条命令花不了五分钟,但能在答辩现场救你一命。我见过太多同学因为现场连不上数据库或者 IDE 启动报错而手忙脚乱,提前准备好 jar 包和 SQL 备份,心里踏实很多。
希望帮到你。
本文还有配套的精品资源,点击获取