十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

SpringBoot+Uniapp:前后端分离抽奖平台核心设计与并发防超发实战

SpringBoot+Uniapp:前后端分离抽奖平台核心设计与并发防超发实战 简介这是一份面向Java毕业设计学生的完整前后端分离抽奖平台源码包。项目基于SpringBootUniappMySQLMyBatisRedis等技术栈实现用户注册登录、购买会员、购买抽奖券参与抽奖、兑换奖品以及管理员对用户信息、抽奖池和转盘的全功能管理。技术亮点在于抽奖模块采用Redis消息队列、分布式锁、冒泡与快速排序完成中奖逻辑计算可应对多并发场景适合学习高并发处理与后端设计。资源共1334个文件包含90余个Java类文件与约130个XML配置、75个Vue前端页面、100个class编译文件及SQL数据库脚本同时提供大量jpg/png界面图片和Markdown说明文档压缩包约110.58MB目录结构清晰。整体模块区分用户端与管理端角色权限明确源码附带数据库脚本与部署说明可直接运行并便于二次开发目前已有123人学习适合作为Java毕业设计、课程设计或前后端分离项目的实战蓝本。1. JavaUniapp毕业设计zip的交付边界这个抽奖平台包到底该有什么拿到一个命名为“Java毕业设计基于SpringBootUniapp的前后端分离java抽奖平台项目源码数据库文档说明.zip”的压缩包第一反应不是解压而是先想清楚一件事这种标题里藏了多少层交付物。它至少包含四个可独立验收的部分——SpringBoot写的REST后端、Uniapp编译的移动端前端、一份能直接导入的数据库脚本、以及答辩和查重时真正能救命的文档说明。前后端分离意味着两套工程分开启动、分开部署中间靠HTTP接口对接Token做身份标识。这类项目在Java面试里也常被追问Token放哪里、抽奖扣库存怎么防超发、页面在微信里打开时定位怎么拿。本文就按这条线把抽奖平台的用户表、奖品表、抽奖记录表、接口鉴权、Uniapp请求封装和并发下的库存扣减一次说透最后落到文档该怎么写才能过审。2. SpringBoot抽奖后端表结构、概率计算与库存扣减实现2.1 抽奖业务的状态建模用户、奖品、配置、记录四张核心表抽奖平台看起来功能不多但数据库设计比普通CRUD复杂因为抽奖涉及“资格校验、概率匹配、库存扣减、记录落库”四个动作任何一个环节缺约束都会出脏数据。常见做法是拆四张表用户表、奖品表、抽奖配置表、抽奖记录表。奖品表存奖品名称、类型、库存总量、剩余库存、所属奖池配置表存每个奖品的概率百分比、每日限抽次数、每人限中次数记录表存每次抽奖的userId、奖品Id、中奖结果、抽奖时间。CREATE TABLE t_user ( id bigint(20) NOT NULL AUTO_INCREMENT, username varchar(64) NOT NULL COMMENT 登录名, password varchar(128) NOT NULL COMMENT BCrypt密文, openid varchar(128) DEFAULT NULL COMMENT 微信小程序openid, PRIMARY KEY (id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; CREATE TABLE t_prize ( id bigint(20) NOT NULL AUTO_INCREMENT, pool_id bigint(20) NOT NULL COMMENT 奖池id, name varchar(255) NOT NULL, total_stock int(11) NOT NULL DEFAULT 0, remain_stock int(11) NOT NULL DEFAULT 0, probability decimal(8,6) NOT NULL COMMENT 中奖概率如0.000100表示万分之一的整数倍, enabled tinyint(1) DEFAULT 1, PRIMARY KEY (id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; CREATE TABLE t_lottery_config ( id bigint(20) NOT NULL AUTO_INCREMENT, pool_id bigint(20) NOT NULL, daily_limit int(11) DEFAULT 3 COMMENT 每人每日抽奖次数, created_at datetime DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; CREATE TABLE t_lottery_record ( id bigint(20) NOT NULL AUTO_INCREMENT, user_id bigint(20) NOT NULL, prize_id bigint(20) DEFAULT NULL COMMENT 未中奖时为空, win tinyint(1) NOT NULL DEFAULT 0, create_time datetime DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;这段建表脚本里有三个必须在文档里写明白的关键点。第一probability字段选用 decimal 而非 float避免浮点比较时出现丧失精度导致概率总和偏差。第二中奖记录中的win字段不参与概率计算但参与统计答辩时把“中奖率”视图直接基于t_lottery_record汇总即可。第三openid字段为Uniapp微信小程序登录预留H5端走username/password小程序端走code换openid两套登录链路在同一个user表上共鸣。2.2 抽奖核心算法按权重抽奖与库存前置校验概率抽奖的落地实现有很多种写法常见做法是先把所有奖品按概率归一化成区间再生成一个0到1之间的随机数落在哪个区间就中哪个奖。这里有一个容易被忽视的坑库存不足的奖品必须先从抽奖列表里剔除否则用户表面上“中了”一个卖完的奖品实际却拿不到东西投诉和毕业设计的演示效果都会翻车。Service public class LotteryService { private ListPrize loadAvailablePrizes(Long poolId) { return prizeMapper.selectAvailable(poolId); } public LotteryResult draw(Long userId, Long poolId) { ListPrize prizes loadAvailablePrizes(poolId); if (prizes.isEmpty()) { return LotteryResult.noStock(); } double total prizes.stream().mapToDouble(Prize::getProbability).sum(); double hit ThreadLocalRandom.current().nextDouble() * total; double cursor 0d; Prize target null; for (Prize p : prizes) { cursor p.getProbability(); if (hit cursor) { target p; break; } } if (target null) { return LotteryResult.miss(); } return doDeductAndRecord(userId, target); } }逻辑说明loadAvailablePrizes只查库存大于0且启用的奖品没有库存的奖品直接进不来兜底在数据层而不是业务层。nextDouble() * total把概率总和映射到[0, total)区间curosr 累加比较概率大的奖品占的区间长被命中的机会自然大。最后doDeductAndRecord承担原子扣减和落库这一小段是并发安全的重头戏单独拆开说。参数说明如果奖品概率总和不到1剩余的部分代表“谢谢参与”的空区间这也是抽奖平台和强行归一化方案的差别——空区间要有但别留太大不然用户连续几次全是“未中奖”会立刻流失。daily_limit的校验可以放这里也可以放到AOP拦截器放Service里最简单明了。2.3 扣库存与落库的事务边界更新条件代替先查后扣抽奖的并发问题集中在“库存只有1个同时进来10个人”这一场景。先查remain_stock再在代码里if判断再update在高并发下一定超发因为查询和更新之间有空窗。正确写法是把库存判断塞进update语句的where条件里让数据库在更新时做原子校验更新影响行数为0说明库存已被抢走直接返回未中奖。Transactional(rollbackFor Exception.class) public LotteryResult doDeductAndRecord(Long userId, Prize prize) { int rows prizeMapper.deductStock(prize.getId()); if (rows 0) { return LotteryResult.noStock(); } int win 1; lotteryRecordMapper.insert(userId, prize.getId(), win); return LotteryResult.win(prize); }update iddeductStock UPDATE t_prize SET remain_stock remain_stock - 1 WHERE id #{id} AND remain_stock 0 /update这里的事务边界要克制deductStock和insert必须在同一个事务里要么都成功要么都失败。但事务只保护这两个数据库操作抽奖到目前为止做过的所有查询都不在事务内因为概率计算和库存扣减之间本来就有时间差这份“中间状态”放出去就是超卖的温床。另外一个关键点是Transactional的失效场景同类调用不经过Spring代理、方法被final修饰、异常被吞掉这三个坑在毕业设计答辩时被老师拿代码挑出来会很被动。最好在Service的一个公开方法上打注解不要出现class内部自调用。3. Uniapp端登录与token处理H5和小程序共用一套请求层3.1 基于uni.request的请求封装与token注入Uniapp最大的优势是一套代码同时出H5、微信小程序、App但它们对Header的处理有细微差异。H5端存在跨域小程序端不需要跨域但要配合法域名App端只要关闭安全校验就能连本地接口。为了不让这三个端的差异散落到每个页面里所有请求都封装到一个模块中页面只负责调用业务方法。// utils/request.js const BASE_URL http://192.168.1.100:8080/api const request (options) { return new Promise((resolve, reject) { uni.request({ url: BASE_URL options.url, method: options.method || GET, data: options.data || {}, header: { Content-Type: application/json, Authorization: uni.getStorageSync(token) }, success: (res) { if (res.statusCode 401) { uni.removeStorageSync(token) uni.navigateTo({ url: /pages/login/login }) return } resolve(res.data) }, fail: (err) reject(err) }) }) } export const login (username, password) request({ url: /auth/login, method: POST, data: { username, password } }) export const lottery (poolId) request({ url: /lottery/draw, method: POST, data: { poolId } })这段封装里最值得讲的是Authorization的取值逻辑它统一从uni.getStorageSync(token)拿不区分端因为后端人处理同一套请求头。401的处理不是弹窗而是静默跳转用户在抽奖中途掉线时直接回登录页比弹一个“登录过期”再让用户自己点确定要顺滑得多。BASE_URL写死是本地的拿来做演示够用但文档里必须标注清楚上线时要换成域名并把协议写全。如果是微信小程序还要在manifest.json的 h5 节点下配置devServer.proxy或确保请求的域名在小程序后台的 request 合法域名列表里这一项经常被漏掉导致真机预览时全部请求失败。3.2 抽奖页面的按钮防重与结果态切换抽奖和大转盘还不一样大转盘的动画决定请求时机而简单抽奖动作是点一下按钮发一次请求。问题在于用户手速快时连点两次后端并发时可能一次是“中奖”一次是“未中奖”用户会认为平台乱来。前端需要用锁变量在按钮层面拦截重复请求。methods: { async onDraw() { if (this.drawing) return this.drawing true try { const res await lottery(this.poolId) if (res.code 0) { this.resultVisible true this.resultText res.data.win ? 恭喜中奖 res.data.prizeName : 很遗憾未中奖 } else { this.resultText res.message } } finally { this.drawing false } } }逻辑说明drawing标志位在请求发送前置位在finally里复位除非用户强制杀进程否则这个请求期间无论怎么点按钮都不会发出第二个请求。这里隐藏着前后端分离项目的一个设计原则——前端防重复是为了体验后端防重复要靠token或幂等键两者缺一不可。毕业设计答辩时老师常问“如果用户绕过前端直接调接口怎么办”答不上来就露怯了。后端的做法是给抽奖接口加自定义注解用Redis存一个“该用户本秒已抽”的标记或者在数据库层面用 userId create_time 做唯一索引实操推荐前者因为不用加表。4. 前后端分离跑通数据库导入、跨域配置与三端联调4.1 导入数据库与SpringBoot启动的前置检查数据库脚本标明“数据库”在标题里说明这个交付项是被验收人单独检查过的。脚本不是建完表就完事至少要包含三部分内容建库语句、建表语句、初始化数据。初始化数据里建议自带一个H2风格的演示账号、若干个奖品、一个奖池配置让评审老师双击启动就能看到抽奖页面有数据入口。项目值说明数据库版本MySQL 5.7脚本不兼容MySQL 8.0的caching_sha2_password时要在URL加allowPublicKeyRetrievaltrue后端端口8080application.yml中server.portContext Path/api前后端分离时建议统一API前缀Uniapp和Postman都从/api开始Java版本1.8SpringBoot 2.x对应的稳定基线不要用SpringBoot 3.0配JDK8编译直接挂Redis可选只做验证码/限流时用没装Redis也能用本地Map顶一阵导入命令虽然只有一行但要注意指定编码Windows下MySQL默认读的是gbk切换编码脚本前中文注释会乱。执行顺序是先创建数据库再use否则在未选中库的状态下执行建表语句会报No database selected。mysql -uroot -p123456 --default-character-setutf8mb4 lottery.sql参数说明--default-character-setutf8mb4指定客户端字符集 lottery.sql不是MySQL命令而是shell重定向把本地文件内容喂给mysql程序。如果脚本里已经有CREATE DATABASE IF NOT EXISTS lottery和USE lottery这行命令就不用担心哪个库的问题。4.2 SpringBoot的CORS跨域配置与接口路径统一前后端分离项目在本地联调时的第一道坎是CORS。Uniapp跑在浏览器里地址是http://localhost:8081后端在http://localhost:8080端口不同即跨域。后端要在配置类里放开跨域否则前端控制台报blocked by CORS policy这个报错信息在H5端几乎必现。Configuration public class CorsConfig implements WebMvcConfigurer { Override public void addCorsMappings(CorsRegistry registry) { registry.addMapping(/api/**) .allowedOriginPatterns(*) .allowedMethods(GET, POST, PUT, DELETE, OPTIONS) .allowedHeaders(*) .allowCredentials(true) .maxAge(3600); } }这里的allowedOriginPatterns(*)和allowedOrigins(*)的差别值得在文档里注明前者兼容携带凭证Cookie/Authorization头的跨域请求后者在SpringBoot新版本里会拒绝带凭证的跨域。maxAge设定为3600秒让浏览器在一小时内不用对每个请求都先发OPTIONS预检省掉大量无效网络往返。路径设计上后端接口全挂在/api之下Controller的RequestMapping统一为/api/lottery、/api/auth、/api/user这样一个前缀可以下列到拦截器里做登录校验不用每加一个Controller就写一遍过滤器路径。拦截器注册时要让/api/auth/login免校验其他全进拦截器顺序是先跨域后拦截否则预检请求直接被拦掉。5. 抽奖平台的并发安全进阶限量奖品与超发拦截5.1 每人限中次数与每日限抽的联合校验到了并发这一层光有库存扣减还不够。限量奖品还要面对一个场景同一个用户用两个设备同时抽奖把仅剩的3个奖品全抽走。单纯在业务代码里先查再插来校验每人限中次数并发下依然会漏。做法是把“限次”也变成数据库层面的条件判断或者引入唯一索引去重。ALTER TABLE t_lottery_record ADD UNIQUE KEY uk_user_day (user_id, DATE(create_time));逻辑说明UNIT唯一索引把“同一用户同一天内多次抽奖”变成数据库约束抽奖成功后insert时如果命中重复就抛异常业务层catch后转换为“今日次数已用完”。这种方式比先select计数再判断要稳健得多因为它把并发时最容易被击穿的“检查与执行分离”合并成了“执行即检查”。代价是抽奖记录表不同日期的数据不再能通过唯一索引直接去重但实际统计中奖率时本来就要靠win1过滤所以这个索引设计是可接受的。5.2 乐观锁或悲观锁怎么选毕业设计级抽奖的推荐方案抽奖库存扣减有两种锁实现悲观锁用SELECT ... FOR UPDATE行记录被锁直到事务提交写并发环境下排队效果明显但会造成锁等待乐观锁用version字段update时校验version冲突则重试。抽奖场景的特点是写多读多、每次写操作极小乐观锁重试到第二次时大概率已经没库存用户体验和代码复杂度的平衡很微妙。维度悲观锁 FOR UPDATE乐观锁 version适用场景奖品数量极少、并发极高普通抽奖、点击频率中等代码复杂度低靠SQL实现中需要重试机制库存为1时的表现强一致重试大概率失败用户体验差死锁风险有须控制事务时长无我的建议是核心扣减仍用UPDATE ... WHERE remain_stock 0这个条件更新方案它在数据库层面把“判断剩余库存”和“扣减库存”合并成一个原子操作不需要version字段不会产生死锁效果也远超先查后扣。乐观锁可以留作面试回答时的进阶表达——面试官问“如果不用条件更新怎么防超发”再讲version重试才显出深度。5.3 Redis限流与最终一致性对账当抽奖活动面向多人开放时单纯的库存条件更新已经防住超发但防不住刷接口。攻击者用脚本每秒请求100次每次到remain_stock 0的判断都可能击中把真实用户的奖品抢光。推荐加一个极简的Redis限流用计数器和过期时间实现每分钟最多抽6次。public boolean isAllowed(Long userId, int maxPerMinute) { String key lottery:minute: userId; Long count redisTemplate.opsForValue().increment(key); if (count ! null count 1) { redisTemplate.expire(key, 60, TimeUnit.SECONDS); } return count ! null count maxPerMinute; }逻辑说明increment在key不存在时返回1并新建命中返回1时顺手设置60秒过期。超过maxPerMinute返回false抽奖请求直接返回“您的操作太频繁”。这个方案有一个小缺口Redis挂了抽奖功能直接不可用所以生产方案要配降级开关但毕业设计里把Redis当作可选组件在文档里说明白即可评审不会揪着高可用不放。对账逻辑也要补一层t_lottery_record中win1的记录数量和t_prize的remain_stock的消耗总量必须对得上。写一个定时任务每天零点扫一次发现不符就告警。这不是毕业设计的必选项但对“追责”和“系统可信”两个维度都是加分项同时能在文档的“系统展望”里顺理成章地写成已完成的功能点。6. 文档说明的工程化写法ER图、接口清单与首次运行验证6.1 文档该写什么从数据库设计说明书到接口文档的固定框架这个zip里“文档说明”四个字决定了答辩分数的下限。一份合格的文档至少包括六块项目概述、技术选型背景、数据库设计说明、核心接口文档、部署运行步骤、测试用例与结果。技术选型背景这一块最容易写空建议使用比较的方式例如“为什么用SpringBoot而不选SSM”“为什么用Uniapp而不做两个独立App”只要在两三句话内说清楚取舍理由就是一个能拿得出手的独立分析。数据库设计说明不能贴建表语句就完事要配上ER图或表关系文字描述强调t_prize与t_lottery_record通过prize_id关联、t_lottery_config与奖池的映射关系。6.2 首次运行验证的最小路径从复现到自查的一句话清单拿到整个projet的人最关心的其实是“按什么顺序跑起来”。文档里把这个过程压缩成一个验证清单按从上到下的顺序执行任何一步卡住都能从对应的输出定位到前置条件是否满足。以下是我在交付包里推荐使用的验证路径导入lottery.sql执行后检查SHOW TABLES能否看到四张核心表。启动SpringBoot查看控制台日志中是否出现Tomcat started on port(s): 8080。用Postman调POST /api/auth/login传入用户名admin和密码123456应返回token。在Uniapp中运行到H5浏览器打开首页后能正常加载奖品列表。点击抽奖按钮数据库t_lottery_record表新增一条记录且win值合法。最后一行的win值校验是个被大部分人忽略的自查点中奖记录必须能把收益和奖品对应上未中奖记录的prize_id必须为空。很多实现为了图省事把未中奖也写入一个“谢谢参与”的奖品对象这样统计总奖品数时会多算一条文档里要把这个差异写清楚——win0和prize_idnull才是未中奖的规范状态。把这个口径统一好无论是在最终的项目报告里还是在面试自我介绍里讲抽奖平台都会少掉一个“数据对不上”的隐患。本文还有配套的精品资源点击获取
返回列表