老板:我需要个抽奖的系统,你来搞一下
前文
因为临近年底了,项目大部分都快结束了,难得的开启了快乐的摸鱼时光,可是🐟还没开始摸几天,老板就来了个任务:我们要搞活动,现在需要一个抽奖系统,你来搞一下。
好的,🐟还没摸两条,又要开始干了,说干就干。
准备工作
这次的系统我用的是我最爱的nodeJs来完成,用到的框架也就是Koa2,其余的数据库的话,我用的是mySql,还有Redis,先来一份思维导图,捋清楚整个业务流程
大概奖品分为四种,一种是大奖(iphone12 pro max),还有小奖(苹果耳机),以及虚拟卷,还有一些支付代币,然后在抽奖之前,会对用户ID和IP地址进行今天的抽奖次数的相关验证,以及用户黑名单的验证,如果这个已经中过大奖的情况下,这个用户ID和IP地址则会被拉黑,接下来一段时间内的抽奖都将不会在中奖或者说只能中虚拟币这样的奖,最后,如果中奖的话,会有一个对于这个用户的中奖记录。
好了,貌似业务也不是特别复杂,so easy,项目初始化,model建起来,数据表创起来,搞起。
小试牛刀第一步
通过把基础的奖品什么的增删改查创建好,我大概说明一下我的奖品列表在刚开始创建的时候的样子,这里我就通过prize的model来展示它最初的样子。
title: { type: DataTypes.STRING, comment: '奖品名称' },
prize_num: { type: DataTypes.INTEGER, comment: '奖品数量' }, //0 无奖品,>0限量,<0无限量
left_num: { type: DataTypes.INTEGER, comment: '剩余奖品数量' },
prize_code: { type: DataTypes.STRING, comment: '中奖的概率' }, //0~9999
prize_time: { type: DataTypes.INTEGER, comment: '发奖周期' }, //抽奖活动持续多少天
img: { type: DataTypes.STRING, comment: '奖品图片' },
displayOrder: { type: DataTypes.INTEGER, comment: '位置序号' }, //小的排在前面
gType: {
type: DataTypes.INTEGER,
comment: '奖品类型',
}, //3 虚拟币,2 虚拟卷,1 实物 小,0 实物 大
sys_status: {
type: DataTypes.INTEGER,
comment: '状态',
}, // 0 正常 1 删除
time_begin: { type: DataTypes.DATE, comment: '开始时间' },
time_end: { type: DataTypes.DATE, comment: '结束时间' },
复制代码
然后在抽奖的时候,会先对有各种乱七八糟的验证,这里可以通过我的路由来说明整体的情况
router.use(Token.checkToken)
//用户参与次数验证
router.use(usersignService.checkUser)
//ip参与次数验证
router.use(ipService.checkIp)
//ip黑名单
router.use(ipBlacklistService.checkIp)
//用户黑名单
router.use(blacklistService.checkUser)
//抽奖接口
router.get('/getPrize', luckyConstructor.prizeGet)
//礼品的颁发
router.use(couponService.setCoupon)
//中奖纪录
router.use(recodingsService.createData)
复制代码
这个大概就是业务的整体的一个流程走向,那么下面就是一个抽奖的环节了。
因为本菜鸡算法水平有限,网上那些抽奖算法实在是看不懂,就用了最简单粗暴的方式,通过生成随机数的方式来进行匹配。
首先我是通过随机数生成一个四位数的随机数,用来匹配奖品的中奖概率,如果生成的随机数小于所设置的奖品中奖概率则会中奖,否则就不中奖,如果在抽奖过程中触发了上面的某一个拦截器,那么就也是谢谢参与的了。
当然,如果你真的一个不小心抽中了我们的超级大奖,你就会被拉入黑名单,之后的抽奖里面不论你怎么抽,都会是一个 谢谢参与 送给您。
正式开始
抽奖奖品的设计
首先因为这个抽奖是在特定的时间点限时开始的,所以为了可以让我们的会员大老爷们可以有个更好的体验,这里我是在创建活动的抽奖商品的时候,同时把抽奖商品的信息缓存到redis里面,因为抽奖商品总的就只有四个,所以可以直接把它们打包成一个数组,然后将这个数组转换成字符串的形式存储起来,之后需要的时候,再把他重新转换回来就可以了。
if (!data || data.length <= 0) {
return
}
const list = JSON.stringify(data) //序列化
await ctx.redis.set('allList', list)
复制代码
同时这样设计的一个好处就是,当奖品的一些数据发生改变的时候,直接删除这个key就完事了,因为对于redis来说,去更改数据相当于删除在重新创建数据,开销上会大很多。
接下来,在抽奖的过程中,用户会先去读取缓存上面的奖品,如果缓存上面没有的话,就会去数据库里面找,然后把找到的数据再给它存到redis里面去。
注意一个小细节
因为抽奖的概率都是随机的,虽然说大奖的中奖概率非常的低,但也会有可能说在某一个时间节点上,突然很多人中了大奖,然后把大奖一次性给抽空了,所以为了避免这种情况的出现,我们需要有一个在抽奖时间内的一个发奖计划。
例如,在一天可以分成24小时,奖品分成很多个等分,在热点时间,像是晚上八点这样的时间就发出去多点,具体实现我是这样的
先设置一份记录
const houseData = [
// 中奖概率分成一百份,24小时每个时间段的机会
//100 / (3*24) = 28
//剩下28份分给不同的时间段
0,0,0,
1,1,1,
2,2,2,
3,3,3,
4,4,4,
5,5,5,
6,6,6,
7,7,7,
8,8,8,
9,9,9,
10,10,10,10,10,10,
11,11,11,11,11,
12,12,12,
13,13,13,
14,14,14,14,14,
15,15,15,15,15,15,
16,16,16,16,16,16,
17,17,17,
18,18,18,
19,19,19,19,19,19,
20,20,20,20,20,20,20,
21,21,21,21,21,21,21,
22,22,22,22,22,22,22,
23,23,23,
]
复制代码
然后就开始进行发奖计划的重置
// 重置发奖计划
async resetPrizeData(data) {
const { prize_num, left_num, prize_time, time_begin } = data.dataValues
data.dataValues.prize_data = JSON.stringify(this.newHash)
if (prize_num < 0 || left_num < 0) {
return
}
if (prize_time <= 0) {
return
}
const num = Math.floor(prize_num / prize_time)
//每一天的发奖计划重制
let time = dayjs(time_begin)
if (num >= 1) {
for (let index = 1; index <= prize_time; index++) {
this.hash[time.format('YYYY-MM-DD')] = num
time = time.add(1, 'day')
}
const remainder = prize_num % prize_time
if (remainder) {
for (let index = 0; index < remainder; index++) {
const ran = Math.floor(Math.random() * (1, prize_time)) + 1
let t = dayjs(time_begin)
t = t.add(ran, 'day')
this.hash[t.format('YYYY-MM-DD')] += 1
}
}
for (const it in this.hash) {
this.setTime(it)
}
}
data.dataValues.prize_data = JSON.stringify(this.newHash)
}
//一天24小时的发奖计划
setTime(it) {
while (this.hash[it]) {
const day = dayjs(it)
this.hash[it]--
const ran = Math.floor(Math.random() * (0, 99))
const d = day.hour(houseData[ran])
const item = d.format('YYYY-MM-DD HH:mm:ss')
if (!this.newHash[item]) {
this.newHash[item] = 0
}
this.newHash[item] += 1
}
}
复制代码
这样就可以避免出现突然在某一个时段把奖品给抽空了,造成后续的参与者没办法获得奖品的情况出现了。
用户抽奖次数,ip抽奖次数的限制,用户黑名单和ip黑名单
这里是为了防止某些人恶意刷抽奖的情况出现,如果一个用户已经达到了一定的抽奖次数,那么也就不允许这个用户再抽奖了,那么ip黑名单则是为了避免某一个ip地址下,通过申请不同的用户来参与抽奖。
同时ip黑名单和用户黑名单的作用上面也就提到了,如果是如果这个用户已经中奖了,则同时把这个用户和这个用户下的ip都一起拉黑,之后的抽奖过程中,如果他还继续抽奖,则给个虚拟币或者是谢谢参与给他。
开始抽奖
这里我的逻辑非常的简单,就是生成一个随机数,然后去匹配中奖区间,中了哪个就是哪个。
const num = Math.floor(Math.random() * 10000) //生成抽奖编码
let prizeList = await prizeService.getData(ctx)
if (!prizeList) {
prizeList = await prizeService.setRData(ctx)
}
let it
for (let index = 0; index < prizeList.length; index++) {
if (
prizeList &&
prizeList[index] &&
prizeList[index].prize_code > num &&
!ctx.bool
) {
it = prizeList[index]
break
}
}
if (!it) {
throw new successExpection('没有中奖,谢谢参与')
}
ctx.it = it
await next()
复制代码
这里所有的数据,我都是通过ctx的上下文,然后根据koa2的洋葱模型的方式,传递给下一个中间件。
发奖
中了奖,那么就需要有一个发奖的过程,这里我是设置了一个奖品池,用户从奖品池里面去抽取奖品,也就是把所有的奖品的个数,放到奖品池里面去,然后把奖品数通过redis缓存起来,这样在发奖的时候,就不用直接的去操作数据库,而是可以通过使用redis中的Incrby命令来完成相关的操作,当完成了相关的数据扣款之后,在通过异步任务的方式,来对数据库进行数据的同步,从而保持数据的一致性。
当然如果抽中了奖品,但是该奖品已经没有库存了,那么也是属于没有中奖。
大概的实现思路是这样的
const item = await ctx.redis.hget('Pool', ctx.it.id)
if (item <= 0) {
throw new successExpection('没有中奖,谢谢参与')
}
const data = await ctx.redis.hincrby('Pool', ctx.it.id, -1)
if (data < 0) {
throw new successExpection('没有中奖,谢谢参与')
}
const prize = await Prize.findOne({
where: {
gType: num,
},
})
await prize.decrement(['left_num'])
复制代码
还有一点需要注意的,就是在往奖品池里面放入奖品的时候,最好是加一个分布式锁,从而来控制只有一个服务去初始化我们的库存数据。
最后
实际开发工作中,类似的业务,肯定还会有更多的考量,本菜🐔只是写了一个有点类似于demo级别的东西,提供了一个思路,如果有哪里写的不好的地方,欢迎各位老哥执正👏。
作者:切图老司机
链接:https://juejin.cn/post/6939717568928153613
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。