基于Hashids的高效游戏礼包兑换码系统完整设计

所用生成器:Hashids - generate short unique ids from integers

系统优势:

  • 生成的兑换码不实际入库,只在数据库中记录有效个数,数据库友好
  • 兑换码使用时,无须查库校验有效性和加锁,在内存中确认有效后直接落库,由唯一索引进行数据库重复兑换校验,高效
  • 兑换码仅绑定礼包id,可随时修改礼包内容,运营掌控力度大
  • 兑换码长度和内容可随意设定(将影响可用数量),生成灵活
  • 兑换码有加密,不能被轻易破解,安全保障

运营需求

  • 运营配置游戏礼包,并为该礼包生成或指定礼包兑换码,玩家可使用兑换码兑换指定礼包
  • 每个礼包玩家仅可兑换一次
  • 礼包可设定以下属性并支持随时修改
    • 生效时段
    • 奖励内容
    • 上下架状态
    • 增加可兑换数量
    • 礼包邮件内容
    • 可兑换玩家限制(自行实现)
  • 支持通用型礼包码,即单个礼包码可由大量玩家兑换,并可设定兑换数量
  • 支持生成唯一兑换码,即单个礼包码仅可由单个玩家兑换,兑换后立刻失效
  • 随时查询某兑换码是否处于生效状态,如已被兑换需查询何人何时兑换
  • 随时查询某玩家已经兑换过的礼包

HashIds原理和使用

hashids的原理是将一个int数组转换为使用指定字符的字符串,并可混淆密码。如图所示:

 由此,我们可以将礼包码和计数值放入数组,由Hashids生成兑换码,并在兑换时解析出礼包码和计数值,用于兑换。

具体步骤如下:

  1. 新建礼包,填好礼包的各项属性,入库,得到礼包的数据库id:1
  2. for循环遍历指定次数,将礼包id和遍历的计数值放入数组,并以此通过Hashids生成兑换码
  3. 更新礼包中的可兑换数量generatedCount,表示已经为这个礼包生成了generatedCount个兑换码
  4. 将生成的兑换码返回至运营后台
  5. 运营后台可随时继续生成兑换码,实际只是将遍历的初始计数值调整为generatedCount即可。
  6. 运营后台可随时查看已生成的兑换码,实际只需重新调用一次生成逻辑但不更新可兑换数量即可。

在玩家使用某个兑换码进行兑换时:

  1. 将该礼包码进行解码,如果无法解码出含有2个元素的int数组,该兑换码即为无效兑换码
  2. 查询解码出的礼包id,查库(建议缓存在内存中)得到该礼包信息,校验上下架/可兑换时间/自定义限制等。
  3. 使用解码出的计数值,对比礼包的可兑换数量,如已超过也无效
  4. 直接使用用户id、礼包id和计数值入库,入库时数据库唯一索引校验失败则表明该玩家已兑换了该礼包或该礼包码已被他人兑换。唯一索引见数据库设计。
  5. 所有校验通过并成功入库,向玩家发送奖励邮件

数据库设计

默认字段CreatedAt、UpdatedAt和DeletedAt

礼包表
字段类型备注
idintid
namestring礼包名,仅运营后台中区分
typeint类型,通兑礼包或普通礼包
rewardstring

奖励内容

validbool兑换开关
codestring通兑码,仅通兑礼包有效
generatedCountint已生成数量(最大可兑换数量)
startTimeint开始时间
endTimeint结束时间
mailTemplateIdint邮件模板id(另行关联邮件表)
otherLimit                 string其他限制(自行实现)
兑换记录表
字段类型备注索引
idintid主键
packageIdint礼包iduk_packageId_uid,uk_packageId_num
numint计数值uk_packageId_num。触发该索引即表示该兑换码已被他人兑换
uidint

兑换用户

uk_packageId_uid。触发该索引即表示已兑换过该礼包

工具类实现代码:

package cdkey_util

import (
	"errors"
	"github.com/speps/go-hashids"
	"strings"
)

type CdKeyUtil struct {
	hashID   *hashids.HashID
	cdKeyLen int
}

func Init(secret, charSetStr string, cdKeyLen int) (CdKeyUtil, error) {

	upStr := strings.ToUpper(charSetStr)
	if strings.Compare(upStr, charSetStr) != 0 {
		return CdKeyUtil{}, errors.New("CDKEY必须使用大写字母:" + charSetStr)
	}

	mateData := &hashids.HashIDData{
		Alphabet:  charSetStr,
		MinLength: cdKeyLen,
		Salt:      secret,
	}

	HashId, err := hashids.NewWithData(mateData)
	if err != nil {
		return CdKeyUtil{}, err
	}

	return CdKeyUtil{hashID: HashId, cdKeyLen: cdKeyLen}, err
}

// Generate 生成指定数量的cdKey,同参数生成的cdKey相同
// 例如:如针对礼包1生成100个,则使用 Generate(1,1,100)。生成礼包2,之前已经生成了100个,想继续生成100个,则 Generate(2,101,100)
func (c CdKeyUtil) Generate(contentId, numFrom int64, needCount int) (cdKeyList []string, err error) {
	if numFrom < 1 || needCount < 1 {
		err = errors.New("错误的生成调用")
		return
	}
	cdKeyList = make([]string, 0, needCount)
	for i := 0; i < needCount; i++ {
		s, e := c.hashID.EncodeInt64([]int64{contentId, numFrom})
		if e != nil {
			err = e
			return
		}
		cdKeyList = append(cdKeyList, s)
		numFrom++
	}
	return
}

// Decode 解析cdKey,返回生成时所用的数据
func (c CdKeyUtil) Decode(cdKey string) (contentId, number int64) {
	if len(cdKey) < c.cdKeyLen {
		return
	}
	cdKey = strings.ToUpper(cdKey)
	d, err := c.hashID.DecodeInt64WithError(cdKey)
	if err != nil {
		return
	}
	if len(d) != 2 {
		return
	}
	return d[0], d[1]
}

生成代码:

func (s *CdKeyService) Generate(packageId int64, from int64, count int) (res []string, err error) {
	if packageId == 0 || from == 0 || count == 0 {
		err = errors.New("无效参数")
		return
	}
	// 数据库中检查该礼包是否存在
	p, err := s.DB.CdKeyPackageGet(packageId)
	if err != nil {
		return
	}
	if p.ID != packageId {
		return nil, errors.New("记录未找到")
	}

	// 调用生成器生成指定数量兑换码
	res, err = s.CdKeyUtil.Generate(packageId, from, count)
	if err != nil {
		return
	}

	// 更新数据库中可兑换礼包的数量
	nowMaxCount := from + int64(count) - 1
	if nowMaxCount > p.GenerateCount {
		err = s.RdsGame.CdKeyPackageUpdateCount(packageId, nowMaxCount)
		if err != nil {
			return
		}
	}

	return
}

兑换代码:

func (s *CdKeyService) Redeem(uid int64, code string) (err error) {
	// 校验阶段
	var cdkeyPackage dao.CdKeyPackage
	packageId, number := s.CdKeyUtil.Decode(code)
	if packageId == 0 || number == 0 {
		// 解析失败
		return errors.New("兑换码无效")
	}
	
	// 查询礼包信息
	cdkeyPackage, err = s.DB.CdKeyPackageGet(packageId)
	if err != nil {
		return err
	}
	if cdkeyPackage.ID == 0 {
		return errors.New("兑换码无效")
	}
	if !cdkeyPackage.Valid {
		return errors.New("兑换尚未开启")
	}
	if number > cdkeyPackage.GenerateCount {
		return errors.New("兑换码无效")
	}
	now := time.Now().Unix()
	if cdkeyPackage.StartTime > now {
		return errors.New("兑换尚未开启")
	}
	if cdkeyPackage.EndTime < now {
		return errors.New("兑换已经结束")
	}
	
	user, err := s.DB.GetUser(uid)
	if err != nil {
		return errors.New("用户不存在")
	}

	// 实际兑换
	
	// 直接插入数据库
	repeatedCode, err := s.DB.CdKeyRecordCreate(uid, packageId, number)
	if err != nil {
		return err
	}
	if repeatedCode == 1 {
		return errors.New("礼包码无效:您已兑换过该礼包")
	}else if repeatedCode == 2{
        return errors.New("礼包码无效:该礼包码已被他人使用")
    }
	
	// 发送奖励
	err = s.sendReward(user, cdkeyPackage.MailTemplateId,cdkeyPackage.Reward)
	
	return err
}

其他闲话:

  • 通过索引uk_packageId_uid可以快速查询玩家兑换过的所有礼包记录
  • 查询某兑换码是否生效,可走一遍解析流程,解析出礼包码和计数值后查库即可
  • 因为兑换码和礼包的关联仅为礼包id,所以礼包的各项属性可以随意修改,无须担心影响已生成的兑换码
  • 建议使用邮件模板来发送奖励邮件,灵活性较高,具体设计后续会更新。
  • 推荐使用 ABCDEFGHJKLMNPQRSTUVWXYZ23456789 作为可用字符,防止玩家看错,无此需求也可以加入小写字母和特殊字符等,可用字符越多,指定位数下可生成的兑换码数量就越多,安全性也越高。
  • 通兑码的兑换流程是在上面的兑换代码中加入packageType的判断,num值使用redis计数自增即可
  • 使用该方案需要注意兑换码上限问题,以使用2个元素的数组,使用上面的可用字符,生成8位兑换码,则当第一个元素<22时,第二个元素最大可达到5153631,超出后将溢出为9位兑换码。当第一个元素<22*22=484时,第二个元素可达5153631/22=234255,以此类推。建议使用10位兑换码,可生成数量更多。
  • 如果有更多需求,可以使用三个元素的int数组,表达更多信息,但在限定了兑换码字符数量的情况下,可用的兑换码数量将会大幅减少。
  • 在连续生成兑换码时,因底层数组相似,生成的兑换码也比较相似,如需将一批兑换码交给合作商等不安全人员,建议进行人为乱序,防止根据规律破解密码。
  • 初始化Util时的密码非常重要,必须妥善保存,且终身不能修改(如必须修改需则可以在礼包中增加所用密码字段,同时使用老密码和新密码解析,如使用老密码解析出礼包码后与该字段对比即可)
  • 3
    点赞
  • 15
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值