项目背景: 一款支持实物奖品(手表、酒水、数码等)的盲盒翻赏小程序,包含一番赏、赛博赏、大转盘、房间系统等多种玩法,Uniapp开发,小程序/APP/H5三端同步上线。本文从技术实现角度解析核心功能的开发要点。
一、系统功能结构
基于实际项目整理的核心功能模块:
| 模块 | 包含功能 |
|---|---|
| 首页 | 模式切换(ALL/一番赏/赛博赏/天选赏)、分类标签、公告栏、新人入口...等 |
| 抽奖详情 | 库存进度条、奖品列表(A/B/C/D/E赏)、中奖记录、换箱、一发/三发/五发/十发...等 |
| 赛博赏 | 9宫格奖品墙、颜色区分稀有度、光标下落动画、跳过按钮...等 |
| 大转盘 | 九宫格转盘、折扣券/免费开盒、提现规则...等 |
| 房间系统 | 房间列表、魂力机制、创建房间、加入房间...等 |
| 用户端 | 赏袋管理、订单记录、收货地址...等 |
| 管理后台 | 奖品配置、库存管理、订单管理、概率设置...等 |
二、首页模式切换的技术实现
首页支持四种模式(ALL/一番赏/赛博赏/天选赏)并行切换,用户点击Tab切换当前显示的奖池数据。
前端实现:
javascript复制
// 模式切换的核心逻辑
data() {
return {
currentMode: 'ALL', // 默认显示全部
prizeList: [] // 当前模式下的奖品列表
}
},
methods: {
switchMode(mode) {
this.currentMode = mode
this.loadPrizeList(mode)
},
async loadPrizeList(mode) {
// 调用后端接口,根据模式筛选数据
const res = await this.$api.getPrizeList({ mode })
this.prizeList = res.data
}
}
模式切换时重新请求后端接口,按mode参数筛选对应奖池。分类标签(高达、初音未来、卡牌等)的实现方式相同,只是筛选维度从mode换成category。
数据表设计:
sql复制
-- 奖池表,mode字段区分玩法
CREATE TABLE prize_pool (
id BIGINT PRIMARY KEY,
name VARCHAR(100),
mode ENUM('ALL', 'YIFAN', 'CYBER', 'TIANXUAN'),
category VARCHAR(50),
total_stock INT,
remain_stock INT,
price DECIMAL(10,2),
rare_level ENUM('A','B','C','D','E'),
probability DECIMAL(5,4), -- 抽取概率
create_time DATETIME
);
三、一番赏模式开发要点
一番赏的核心逻辑是固定总数、递减库存、概率按位置分布。详情页包含几个关键组件:
3.1 库存进度条
html复制
<!-- 进度条显示 -->
<view class="progress-bar">
<view class="progress-fill" :style="{width: remainPercent + '%'}"></view>
<text class="progress-text">剩余{{remain}}/{{total}}发</text>
</view>
进度条低于20%时加红色闪烁效果提醒用户"快没了",通过CSS动画实现:
css复制
@keyframes blink {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
}
.low-stock .progress-fill {
background: #ff4d4f;
animation: blink 1s infinite;
}
3.2 换箱功能
当一个番赏的库存抽完后,后端自动开启下一箱。用户可手动切换"上一箱/下一箱":
javascript复制
// 换箱逻辑
async changeBox(direction) {
const currentPool = this.currentPool
const targetIndex = direction === 'next'
? currentPool.boxIndex + 1
: currentPool.boxIndex - 1
// 检查目标箱是否存在
const res = await this.$api.checkBox({
poolId: currentPool.id,
boxIndex: targetIndex
})
if (res.data.available) {
this.currentPool = res.data.pool
}
}
3.3 高并发防超卖
一番赏在高并发下最大的风险是超卖------库存显示还有但实际已经被别人抽走了。解决方案:
java复制
// Redis Lua脚本,原子性扣减库存
String luaScript =
"if redis.call('exists', KEYS[1]) == 1 then " +
" local stock = tonumber(redis.call('get', KEYS[1])) " +
" if stock > 0 then " +
" redis.call('decr', KEYS[1]) " +
" return 1 " +
" else " +
" return 0 " +
" end " +
"else return -1 end";
RedisScript<Long> script = RedisScript.of(luaScript, Long.class);
Long result = redisTemplate.execute(script, Collections.singletonList(stockKey));
先扣Redis库存,扣成功后再写MySQL订单记录。如果MySQL写入失败,Redis库存加回去并返回"库存不足"。这个流程保证了Redis和MySQL的一致性。
四、赛博赏模式实现
赛博赏的交互是"光标在9宫格奖品墙上逐行下落,停在哪格就中哪个奖品"。
4.1 奖品墙布局
html复制
<view class="prize-grid">
<view
v-for="(prize, index) in prizeList"
:key="index"
:class="['prize-cell', 'level-' + prize.rareLevel]">
<image :src="prize.image" mode="aspectFit" />
<!-- 稀有度用边框颜色区分 -->
</view>
</view>
稀有度颜色对应关系:A赏红色、B赏粉色、C赏青色、D/E赏橙色,用CSS边框实现,不需要额外的图标资源。
4.2 光标动画
javascript复制
// 光标下落动画核心逻辑
async startDrop() {
// 1. 预加载所有奖品图片
await this.preloadImages()
// 2. 服务端计算中奖结果
const result = await this.$api.drawLottery({ poolId: this.poolId })
const targetIndex = result.data.prizeIndex
// 3. 计算光标路径(先快后慢停在目标位置)
const totalRows = 3
const targetRow = Math.floor(targetIndex / 3)
// 动画分三阶段:快速下落 -> 减速 -> 停在目标
await this.animateDrop(targetRow, 800, 400) // 快速阶段
await this.animateSlow(targetRow, 600) // 减速阶段
}
动画的减速曲线调了很久------太快显得突兀,太慢拖沓。最后用的是二次贝塞尔曲线cubic-bezier(0.2, 0.8, 0.2, 1),实测视觉效果最自然。
4.3 服务端校验
前端动画只是视觉效果,真实中奖结果必须由服务端决定:
java复制
@PostMapping("/draw")
public AjaxResult drawLottery(@RequestBody DrawDTO dto) {
// 1. 验证用户状态
// 2. 检查库存
// 3. 按概率算法计算中奖结果(与前端动画无关)
// 4. 扣减库存
// 5. 返回中奖信息给前端
PrizeDTO result = prizeService.draw(dto.getPoolId());
return AjaxResult.success(result);
}
五、大转盘与房间系统
5.1 大转盘九宫格
大转盘不是圆形转盘而是九宫格布局,中心位是"免费开盒",周围8格是折扣券。抽奖结果同样由服务端生成:
java复制
// 转盘概率配置(后台可调)
Map<Integer, BigDecimal> probability = new LinkedHashMap<>();
probability.put(0, new BigDecimal("0.01")); // 免费开盒 1%
probability.put(1, new BigDecimal("0.05")); // 9.9折 5%
// ...
// 根据概率随机选中
int result = weightedRandom(probability);
转盘每天约40%活跃用户会参与一次,是个有效的日活钩子。用户来转一次顺手可能就进赏袋看有没有新的可提现奖品,复购动机就产生了。
5.2 房间系统设计
房间系统的核心是魂力机制------用户投入件数/金额累积魂力,魂力越高在房间内中奖概率越大。
sql复制
-- 房间表
CREATE TABLE room (
id BIGINT PRIMARY KEY,
name VARCHAR(100),
creator_id BIGINT,
total_soul INT DEFAULT 0,
status TINYINT, -- 0正常 1已满 2已结束
create_time DATETIME
);
-- 用户魂力表(用Redis zset实时排序)
ZINCRBY room:{roomId}:souls {increment} {userId}
ZREVRANGE room:{roomId}:souls 0 -1 WITHSCORES
魂力用Redis zset存储,按分数倒序排列,实时显示用户在房间内的排名。排名越高越有竞争感,驱动用户持续投入。
踩过的坑: 上线第一个月大量空房间没人加入白白占用资源。后来加了"创建房间消耗房卡"机制,空房间48小时无新用户自动解散。
六、实物奖品的库存管理
实物奖品和虚拟奖品不同,抽中后需要发货。库存管理多了几步:
sql复制
-- 实物库存表
CREATE TABLE physical_stock (
id BIGINT PRIMARY KEY,
prize_id BIGINT,
warehouse VARCHAR(50), -- 仓库
stock INT, -- 库存数
lock_stock INT DEFAULT 0, -- 已锁库存(下单未发货)
update_time DATETIME
);
-- 退款时回滚库存
@Transactional
public void refund(Long orderId) {
Order order = orderMapper.selectById(orderId);
if (order.getStatus() == 0) { // 未发货才能退款
physicalStockMapper.incrStock(order.getPrizeId());
orderMapper.updateStatus(orderId, -1); // -1表示已退款
}
}
实物库存和虚拟库存逻辑分开写,混在一起会导致代码耦合严重,出问题排查困难。
七、总结
这个项目用Uniapp做三端开发,Java做后端,MySQL+Redis做数据层。几个技术要点:
- 库存超卖用Redis Lua脚本解决,原子性扣减是核心
- 赛博赏动画要做好图片预加载和减速曲线调参
- 房间系统用Redis zset做魂力排行,性能好且支持实时排序
- 实物奖品的库存和虚拟奖品逻辑要分开设计
- Uniapp多端要注意支付回调、推送等API的兼容性
开发周期约三个月,前后端加联调测试一共12周。需求冻结、并发测试、兼容测试这几件事做在前面,上线后基本稳定。
有技术方案选型或开发需求可以来聊。