兑换券的实现(UUID,雪花算法,自增id)

提出需求:

 算法分析

UUID

优点:唯一,高随机,以32位16进制,可读性不差

缺点: 占用过多内存(32*16=128位),不可控

雪花算法:

优点:全局唯一,适用于分布式系统,生成的效率较高,id是数字容易理解

缺点:不适合要求可读性较高的情况,不容易防止刷暴(这里可以去了解雪花算法的组成,他由一位符号位,为0的符号位,然后41位的时间戳,10位工作机器id,12位序列号),数据较大的时候,可能存在性能问题。

自增id

 优点:效率高,通常是数字,易于理解

缺点:不适合全局唯一性,难防爆刷,有明显规律,无法满足特殊字符要求。


这里我们就要引入Base32

角标

0

1

2

3

4

5

6

7

8

9

10

11

12

13

14

15

字符

A

B

C

D

E

F

G

H

J

K

L

M

N

P

Q

R

角标

16

17

18

19

20

21

22

23

24

25

26

27

28

29

30

31

字符

S

T

U

V

W

X

Y

Z

2

3

4

5

6

7

8

9

 举例子:

01001 00010 01100 10010 01101 11000 01101 00010 11110 11010

  • 01001转10进制是9,查数组得字符为:K

  • 00010转10进制是2,查数组得字符为:C

  • 01100转10进制是12,查数组得字符为:N

  • 10010转10进制是18,查数组得字符为:B

  • 01101转10进制是13,查数组得字符为:P

  • 11000转10进制是24,查数组得字符为:2

但是大家思考一下,我们最终要求字符不能超过10位,而每个字符对应5个bit位,因此二进制数不能超过50个bit位。

UUID和Snowflake算法得到的结果,一个是128位,一个是64位,都远远超出了我们的要求。

那自增id算法符合我们的需求呢?

自增id从1增加到Integer的最大值,可以达到40亿以上个数字,而占用的字节仅仅4个字节,也就是32个bit位,距离50个bit位的限制还有很大的剩余,符合要求!

接着我们就要想重兑

那重兑问题该如何判断呢?此处有两种方案:

  • 基于数据库:我们在设计数据库时有一个字段就是标示兑换码状态,每次兑换时可以到数据库查询状态,避免重兑。

    • 优点:简单

    • 缺点:对数据库压力大

  • 基于BitMap:兑换或没兑换就是两个状态,对应0和1,而兑换码使用的是自增id.我们如果每一个自增id对应一个bit位,用每一个bit位的状态表示兑换状态,是不是完美解决问题。而这种算法恰好就是BitMap的底层实现,而且Redis中的BitMap刚好能支持2^32个bit位。

    • 优点:简答、高效、性能好

    • 缺点:依赖于Redis

 防暴

考虑下JWT的思路

  • Header:记录算法

  • Payload:记录用户信息

  • Verify Signature:验签,用于验证整个token

因此,我们也可以模拟这种思路:

  • 首先准备一个秘钥

  • 然后利用秘钥对自增id做加密,生成签名

  • 将签名、自增id利用Base32转码后生成兑换码

只要秘钥不泄露,就没有人能伪造兑换码。只要兑换码被篡改,就会导致验签不通过。

当然,这里我们不能采用MD5和RSA算法来生成签名,因为这些算法得到的签名都太长了,一般都是128位以上,超出了长度限制。

因此,这里我们必须采用一种特殊的签名算法。由于我们的兑换码核心是自增id,也就是数字,因此这里我们打算采用按位加权的签名算法:

  • 将自增id(32位)每4位分为一组,共8组,都转为10进制

  • 每一组给不同权重

  • 把每一组数加权求和,得到的结果就是签名

  • 比如

    最终的加权和就是:4*2 + 2*5 + 9*1 + 10*3 + 8*4 + 2*7 + 1*8 + 6*9 = 165

    这里的权重数组就可以理解为加密的秘钥

  • 当然,为了避免秘钥被人猜测出规律,我们可以准备16组秘钥。在兑换码自增id前拼接一个4位的新鲜值,可以是随机的。这个值是多少,就取第几组秘钥。

 CodeUtil

package com.tianji.promotion.utils;

import com.tianji.common.constants.RegexConstants;
import com.tianji.common.exceptions.BadRequestException;

/**
 * <h1 style='font-weight:500'>1.兑换码算法说明:</h1>
 * <p>兑换码分为明文和密文,明文是50位二进制数,密文是长度为10的Base32编码的字符串 </p>
 * <h1 style='font-weight:500'>2.兑换码的明文结构:</h1>
 * <p style='padding: 0 15px'>14(校验码) + 4 (新鲜值) + 32(序列号) </p>
 *   <ul style='padding: 0 15px'>
 *       <li>序列号:一个单调递增的数字,可以通过Redis来生成</li>
 *       <li>新鲜值:可以是优惠券id的最后4位,同一张优惠券的兑换码就会有一个相同标记</li>
 *       <li>载荷:将新鲜值(4位)拼接序列号(32位)得到载荷</li>
 *       <li>校验码:将载荷4位一组,每组乘以加权数,最后累加求和,然后对2^14求余得到</li>
 *   </ul>
 *  <h1 style='font-weight:500'>3.兑换码的加密过程:</h1>
 *     <ol type='a' style='padding: 0 15px'>
 *         <li>首先利用优惠券id计算新鲜值 f</li>
 *         <li>将f和序列号s拼接,得到载荷payload</li>
 *         <li>然后以f为角标,从提前准备好的16组加权码表中选一组</li>
 *         <li>对payload做加权计算,得到校验码 c  </li>
 *         <li>利用c的后4位做角标,从提前准备好的异或密钥表中选择一个密钥:key</li>
 *         <li>将payload与key做异或,作为新payload2</li>
 *         <li>然后拼接兑换码明文:f (4位) + payload2(36位)</li>
 *         <li>利用Base32对密文转码,生成兑换码</li>
 *     </ol>
 * <h1 style='font-weight:500'>4.兑换码的解密过程:</h1>
 * <ol type='a' style='padding: 0 15px'>
 *      <li>首先利用Base32解码兑换码,得到明文数值num</li>
 *      <li>取num的高14位得到c1,取num低36位得payload </li>
 *      <li>利用c1的后4位做角标,从提前准备好的异或密钥表中选择一个密钥:key</li>
 *      <li>将payload与key做异或,作为新payload2</li>
 *      <li>利用加密时的算法,用payload2和s1计算出新校验码c2,把c1和c2比较,一致则通过 </li>
 * </ol>
 */
public class CodeUtil {
    /**
     * 异或密钥表,用于最后的数据混淆
     */
    private final static long[] XOR_TABLE = {
            61261925471L, 61261925523L, 58169127203L, 64169927267L,
            64169927199L, 61261925629L, 58169127227L, 64169927363L,
            59169127063L, 64169927359L, 58169127291L, 61261925739L,
            59169127133L, 55139281911L, 56169127077L, 59169127167L
    };
    /**
     * fresh值的偏移位数
     */
    private final static int FRESH_BIT_OFFSET = 32;
    /**
     * 校验码的偏移位数
     */
    private final static int CHECK_CODE_BIT_OFFSET = 36;
    /**
     * fresh值的掩码,4位
     */
    private final static int FRESH_MASK = 0xF;
    /**
     * 验证码的掩码,14位
     */
    private final static int CHECK_CODE_MASK = 0b11111111111111;
    /**
     * 载荷的掩码,36位
     */
    private final static long PAYLOAD_MASK = 0xFFFFFFFFFL;
    /**
     * 序列号掩码,32位
     */
    private final static long SERIAL_NUM_MASK = 0xFFFFFFFFL;
    /**
     * 序列号加权运算的秘钥表
     */
    private final static int[][] PRIME_TABLE = {
            {23, 59, 241, 61, 607, 67, 977, 1217, 1289, 1601},
            {79, 83, 107, 439, 313, 619, 911, 1049, 1237},
            {173, 211, 499, 673, 823, 941, 1039, 1213, 1429, 1259},
            {31, 293, 311, 349, 431, 577, 757, 883, 1009, 1657},
            {353, 23, 367, 499, 599, 661, 719, 929, 1301, 1511},
            {103, 179, 353, 467, 577, 691, 811, 947, 1153, 1453},
            {213, 439, 257, 313, 571, 619, 743, 829, 983, 1103},
            {31, 151, 241, 349, 607, 677, 769, 823, 967, 1049},
            {61, 83, 109, 137, 151, 521, 701, 827, 1123},
            {23, 61, 199, 223, 479, 647, 739, 811, 947, 1019},
            {31, 109, 311, 467, 613, 743, 821, 881, 1031, 1171},
            {41, 173, 367, 401, 569, 683, 761, 883, 1009, 1181},
            {127, 283, 467, 577, 661, 773, 881, 967, 1097, 1289},
            {59, 137, 257, 347, 439, 547, 641, 839, 977, 1009},
            {61, 199, 313, 421, 613, 739, 827, 941, 1087, 1307},
            {19, 127, 241, 353, 499, 607, 811, 919, 1031, 1301}
    };

    /**
     * 生成兑换码
     *
     * @param serialNum 递增序列号
     * @return 兑换码
     */
    public static String generateCode(long serialNum, long fresh) {
        // 1.计算新鲜值
        fresh = fresh & FRESH_MASK;
        // 2.拼接payload,fresh(4位) + serialNum(32位)
        long payload = fresh << FRESH_BIT_OFFSET | serialNum;
        // 3.计算验证码
        long checkCode = calcCheckCode(payload, (int) fresh);
        System.out.println("checkCode = " + checkCode);
        // 4.payload做大质数异或运算,混淆数据
        payload ^= XOR_TABLE[(int) (checkCode & FRESH_MASK)];
        // 5.拼接兑换码明文: 校验码(14位) + payload(36位)
        long code = checkCode << CHECK_CODE_BIT_OFFSET | payload;
        // 6.转码
        return Base32.encode(code);
    }

    private static long calcCheckCode(long payload, int fresh) {
        // 1.获取码表
        int[] table = PRIME_TABLE[fresh];
        // 2.生成校验码,payload每4位乘加权数,求和,取最后13位结果
        long sum = 0;
        int index = 0;
        while (payload > 0) {
            sum += (payload & 0xf) * table[index++];
            payload >>>= 4;
        }
        return sum & CHECK_CODE_MASK;
    }

    public static long parseCode(String code) {
        if (code == null || !code.matches(RegexConstants.COUPON_CODE_PATTERN)) {
            // 兑换码格式错误
            throw new BadRequestException("无效兑换码");
        }
        // 1.Base32解码
        long num = Base32.decode(code);
        // 2.获取低36位,payload
        long payload = num & PAYLOAD_MASK;
        // 3.获取高14位,校验码
        int checkCode = (int) (num >>> CHECK_CODE_BIT_OFFSET);
        // 4.载荷异或大质数,解析出原来的payload
        payload ^= XOR_TABLE[(checkCode & FRESH_MASK)];
        // 5.获取高4位,fresh
        int fresh = (int) (payload >>> FRESH_BIT_OFFSET & FRESH_MASK);
        // 6.验证格式:
        if (calcCheckCode(payload, fresh) != checkCode) {
            throw new BadRequestException("无效兑换码");
        }
        return payload & SERIAL_NUM_MASK;
    }

    public static void main(String[] args) {
        // 定义你的参数
        long serialNum = 123456; // 替换为实际的serialNum值
        long fresh = 789;        // 替换为实际的fresh值

        // 调用生成兑换码的函数
        String exchangeCode = generateCode(serialNum, fresh);

        // 打印生成的兑换码
        System.out.println("生成的兑换码: " + exchangeCode);
        System.out.println(parseCode("HG9SLK6WW4"));
    }

}
RegexConstants
package com.tianji.common.constants;

import cn.hutool.core.lang.RegexPool;

public interface RegexConstants extends RegexPool {
    /**
     * 手机号正则
     */
    String PHONE_PATTERN = "^1([38][0-9]|4[579]|5[0-3,5-9]|6[6]|7[0135678]|9[89])\\d{8}$";
    /**
     * 邮箱正则
     */
    String EMAIL_PATTERN = "^[a-zA-Z0-9_-]+@[a-zA-Z0-9_-]+(\\.[a-zA-Z0-9_-]+)+$";
    /**
     * 密码正则。6~32位的字母、数字、下划线
     */
    String PASSWORD_PATTERN = "^\\w{4,24}$";
    /**
     * 用户名正则。6~32位的字母、数字、下划线
     */
    String USERNAME_PATTERN = "^\\w{4,32}$";
    /**
     * 验证码正则, 6位数字或字母
     */
    String VERIFY_CODE_PATTERN = "^[a-zA-Z\\d]{6}$";

    /**
     * 优惠券兑换码模板
     */
    String COUPON_CODE_PATTERN = "^[23456789ABCDEFGHJKLMNPQRSTUVWXYZ]{8,10}$";
}
BadRequestException
package com.tianji.common.exceptions;

import lombok.Getter;

@Getter
public class BadRequestException extends CommonException{
    private final int status = 400;

    public BadRequestException(String message) {
        super(400, message);
    }

    public BadRequestException(int code, String message) {
        super(code, message);
    }

    public BadRequestException(int code, String message, Throwable cause) {
        super(code, message, cause);
    }
}

评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值