jwt衍生出的问题-登录

1.jwt衍生出的问题-登录

本文从jwt的角度考虑实现单点登录以及在登录过程中出现的一些问题

> <redis环境>

1.从头搭建

对返回结果的统一处理

/**
 * @description:统一包装返回
 * @author: hdh
 * @date: 2022/5/26 16:18
 */

@Data
@NoArgsConstructor
public class MessageResult<T> implements Serializable {

    private String code;

    private String msg;

    private T data;


    public MessageResult(final String code, final String msg) {
        this.code = code;
        this.msg = msg;
    }

    public MessageResult(final String code, final String msg, T data) {
        this.code = code;
        this.msg = msg;
        this.data = data;
    }
}

对应的统一返回状态码(基本的)

/**
 * @description: 公共常量
 * @author: hdh
 * @date: 2022/5/26 16:20
 */
public interface SysConsts {

    // MessageResule返回状态码
    public static class ResCode {
        public static final String SUCCESS_CODE = "200";
        public static final String SUCCESS_CODE_MSG = "请求成功";

        public static final String ERROR_CODE = "400";
        public static final String ERROR_CODE_MSG = "请求失败,请求不存在";

        public static final String SERVER_ERROE_CODE = "500";
        public static final String SERVER_ERROE_MSG = "请求失败,系统异常";


    }


    // 登录返回
    public static class LoginCode {

        public static final String SUCCESS_CODE = "200";
        public static final String SUCCESS_CODE_MSG = "登录成功";

        public static final String SUCCESS_ERROE_CODE = "500";
        public static final String SUCCESS_ERROE_MSG = "登录失败";

    }


    // Exception 返回状态码
    public static class ExceptionCode {
        public static final String RATE_LIMIT_ERROR = "501";
        public static final String RATE_LIMIT_ERROR_MSG = "请勿重复调用接口";

    }

    // JWT 返回状态码
    public static class JwtCode {
        public static final String BUILD_ERROR = "511";
        public static final String BUILD_ERROR_MSG = "JWT解析异常";

        public static final String JGENERATL_KEY_ERROR = "512";
        public static final String JGENERATL_KEY_ERROR_MSG = "生成JWT密匙失败";

        public static final String TOKEN_GENARATE_ERROR = "513";
        public static final String TOKEN_GENARATE_ERROR_MSG = "生成Token异常";

        public static final String TOKEN_PARSE_ERROR = "513";
        public static final String TOKEN_PARSE_ERROR_MSG = "解析Token异常";

        public static final String TOKEN_OVERDUE_ERROR = "514";
        public static final String TOKEN_OVERDUE_ERROR_MSG = "Token过期";

        public static final String TOKEN_INVALID_ERROR = "515";
        public static final String TOKEN_INVALID_ERROR_MSG = "Token无效";

    }


    // JWT 返回状态码
    public static class RedisCode {
        public static final String PUBLIC_KEY = "JWT_PUBLIC_KEY"; //公钥
        public static final String PRIVATE_KEY = "JWT_PRIVATE_KEY";//密钥 不可公开

    }


    public static class commons {
        public static final String TOKEN = "token";

    }
}

对RunTimeException的统一处理(基本的)

/**
 * @description: 系统自定义异常
 * @author: hdh
 * @date: 2022/5/26 14:35
 */

@Data
public class SysRuntimeException extends RuntimeException {

    private String code;
    private String msg;


    public SysRuntimeException(String code, String msg) {
        this.msg = msg;
        this.code = code;
    }
}

统一拦截


@RestControllerAdvice
public class SysRuntimeExceptionHandler {


    @ExceptionHandler(SysRuntimeException.class)
    public MessageResult tokenRuntimeException(SysRuntimeException e) {

        e.printStackTrace();
        final String code = e.getCode();
        final String msg = e.getMsg();
        return new MessageResult(code, msg);
    }
}
2.开始登录处理

登录逻辑:前端登录获取JWT公钥对用户名和密码进行加密传输到后端防止登录过程中被恶意劫持
集合Redis对生成的公私钥进行缓存

需要提前封装好JWTUtils

/**
 * @description: jwt工具类
 * @author: hdh
 * @date: 2022/5/27 15:55
 */
@Component
public class JwtUtils {

    @Autowired
    private RedisUtil redisUtil;

    @Value("${param.jwt.overtime:7200000}")
    private Integer OVER_TIME;

    // 签名密钥
    @Value("${param.jwt.secret}")
    private String SECRET;
    //设置过期时间

    /**
     * @description: 获取jwt密钥 公钥 保存到redis 每隔1DAY更新一次
     * @author hdh
     * @date: 2022/5/27 16:28
     * @param:
     * @return: Map<String, String>
     */
    public String getJwtKey() {

        String publicKeyStr = null;
        String privateKeyStr = null;
        try {

            Boolean isPublicKey = redisUtil.hasKey(SysConsts.RedisCode.PUBLIC_KEY);
            Boolean isPrivateKey = redisUtil.hasKey(SysConsts.RedisCode.PRIVATE_KEY);
            if (isPublicKey && isPrivateKey) {
                publicKeyStr = String.valueOf(redisUtil.get(SysConsts.RedisCode.PUBLIC_KEY));

            } else {
                // KeyPairGenerator类用于生成公钥和私钥对,基于RSA算法生成对象
                KeyPairGenerator keyPairGen = KeyPairGenerator.getInstance("RSA");
                // 初始化密钥对生成器,密钥大小为96-2048位
                keyPairGen.initialize(2048, new SecureRandom());
                // 生成一个密钥对,保存在keyPair中
                KeyPair keyPair = keyPairGen.generateKeyPair();

                RSAPrivateKey privateKey = (RSAPrivateKey) keyPair.getPrivate();
                RSAPublicKey publicKey = (RSAPublicKey) keyPair.getPublic();

                publicKeyStr = new String(Base64.encodeBase64(publicKey.getEncoded()));
                privateKeyStr = new String(Base64.encodeBase64((privateKey.getEncoded())));

                redisUtil.setEx(SysConsts.RedisCode.PUBLIC_KEY, publicKeyStr, OVER_TIME, TimeUnit.DAYS);
                redisUtil.setEx(SysConsts.RedisCode.PRIVATE_KEY, privateKeyStr, OVER_TIME, TimeUnit.DAYS);
            }

        } catch (NoSuchAlgorithmException e) {

            e.printStackTrace();
            throw new SysRuntimeException(SysConsts.JwtCode.JGENERATL_KEY_ERROR, SysConsts.JwtCode.JGENERATL_KEY_ERROR_MSG);

        }
        return publicKeyStr;

    }


    /**
     * @description:RSA公钥加密
     * @author hdh
     * @date: 2022/5/27 16:28
     * @param: str加密字符串 publicKey 公钥
     * @return: String
     */
    public String encrypt(String str) throws Exception {

        Boolean isPublicKey = redisUtil.hasKey(SysConsts.RedisCode.PUBLIC_KEY);
        Boolean isPrivateKey = redisUtil.hasKey(SysConsts.RedisCode.PRIVATE_KEY);
        if (isPublicKey == false || isPrivateKey == false) {
            getJwtKey();
        }
        //base64编码的公钥
        byte[] decoded = Base64.decodeBase64(String.valueOf(redisUtil.get(SysConsts.RedisCode.PUBLIC_KEY)));
        RSAPublicKey pubKey = (RSAPublicKey) KeyFactory.getInstance("RSA").generatePublic(new X509EncodedKeySpec(decoded));
        //RSA加密
        Cipher cipher = Cipher.getInstance("RSA");
        cipher.init(Cipher.ENCRYPT_MODE, pubKey);
        String outStr = Base64.encodeBase64String(cipher.doFinal(str.getBytes("UTF-8")));
        return outStr;
    }


    /**
     * @description:RSA私钥解密
     * @author hdh
     * @date: 2022/5/27 16:28
     * @param: str加密字符串 publicKey 私钥
     * @return: String
     */
    public String decrypt(String str) throws Exception {


        Boolean isPublicKey = redisUtil.hasKey(SysConsts.RedisCode.PUBLIC_KEY);
        Boolean isPrivateKey = redisUtil.hasKey(SysConsts.RedisCode.PRIVATE_KEY);
        if (isPublicKey == false || isPrivateKey == false) {
            getJwtKey();
        }
        //base64编码的公钥
        //64位解码加密后的字符串
        byte[] inputByte = Base64.decodeBase64(str.getBytes("UTF-8"));
        //base64编码的私钥
        byte[] decoded = Base64.decodeBase64(String.valueOf(redisUtil.get(SysConsts.RedisCode.PRIVATE_KEY)));
        RSAPrivateKey priKey = (RSAPrivateKey) KeyFactory.getInstance("RSA").generatePrivate(new PKCS8EncodedKeySpec(decoded));
        //RSA解密
        Cipher cipher = Cipher.getInstance("RSA");
        cipher.init(Cipher.DECRYPT_MODE, priKey);
        String outStr = new String(cipher.doFinal(inputByte));
        return outStr;
    }

    /**
     * @description:生成token
     * @author hdh
     * @date: 2022/5/31 9:13
     * @param:userId 用户唯一标识
     * @return:java.lang.String
     */
    public String getToken(String userId) {

        String token = "";
        try {
            //秘钥及加密算法
            Algorithm algorithm = Algorithm.HMAC256(SECRET);
            //设置头部信息
            Map<String, Object> header = new HashMap<>();
            header.put("typ", "JWT");
            header.put("alg", "HS256");

            Date date = new Date(System.currentTimeMillis() + OVER_TIME);
            System.out.println(date.toString());
            long l = System.currentTimeMillis();

            //携带username,password信息,生成签名
            token = JWT.create()
                    .withHeader(header)
                    .withClaim("userId", userId)
                    .withExpiresAt(new Date(System.currentTimeMillis() + OVER_TIME))
                    .sign(algorithm);
        } catch (Exception e) {
            e.printStackTrace();
            throw new SysRuntimeException(SysConsts.JwtCode.TOKEN_GENARATE_ERROR, SysConsts.JwtCode.TOKEN_GENARATE_ERROR_MSG);
        }
        return token;
    }

    /**
     * @description: 解密Token
     * @author hdh
     * @date: 2022/5/31 9:30
     * @param:token jwt生成的token
     * @return:Map
     */
    private Map<String, Claim> checkToken(String token) {
        DecodedJWT jwt = null;

        try {
            JWTVerifier verifier = JWT.require(Algorithm.HMAC256(SECRET)).build();
            jwt = verifier.verify(token);

        } catch (Exception e) {
            throw new SysRuntimeException(SysConsts.JwtCode.TOKEN_INVALID_ERROR, SysConsts.JwtCode.TOKEN_INVALID_ERROR_MSG);
        }
        return jwt.getClaims();
    }

    /**
     * @description: 根据Token获取user_id
     * @author hdh
     * @date: 2022/5/31 9:31
     * @param:jwt生成的token
     * @return:String
     */
    public String getUserId(String token) {
        Map<String, Claim> claims = checkToken(token);
        Claim user_id_claim = claims.get("userId");

        if (Objects.isNull(user_id_claim) || StringUtils.isEmpty(user_id_claim.asString())) {

            throw new SysRuntimeException(SysConsts.JwtCode.TOKEN_GENARATE_ERROR, SysConsts.JwtCode.TOKEN_GENARATE_ERROR_MSG);
        }
        return user_id_claim.asString();
    }

    /**
     * @description: 判断 token 是否过期
     * @author hdh
     * @date: 2022/5/31 9:31
     * @param:jwt生成的token
     * @return:String
     */
    public boolean isTokenExpired(String token) {
        DecodedJWT jwt = null;
        try {
            JWTVerifier verifier = JWT.require(Algorithm.HMAC256(SECRET)).build();
            jwt = verifier.verify(token);

        } catch (Exception e) {
            throw new SysRuntimeException(SysConsts.JwtCode.TOKEN_PARSE_ERROR, SysConsts.JwtCode.TOKEN_PARSE_ERROR_MSG);
        }
        Date expiresAt = jwt.getExpiresAt();

        //判断当前时间是否在过期时间之前  token时间小于当前时间返回true token时间大于当前时间返回false
        return expiresAt.before(new Date());
    }

}



获取公钥

    /**
     * @description:用户登录之前获取公钥后对用户名密码进行加密传输到后台
     * @author hdh
     * @date: 2022/5/26 17:33
     * @param: []
     * @return: com.example.demo.jwtLogin.MessageResult
     */
    @GetMapping("/getJwtKey")
    @RateLimit(number = 2, cycle = 10)
    public MessageResult getJwtKey() {
        String publicKey = null;

        try {
            jwtUtils.getJwtKey();
        } catch (Exception e) {
            throw new SysRuntimeException(SysConsts.JwtCode.JGENERATL_KEY_ERROR, SysConsts.JwtCode.JGENERATL_KEY_ERROR_MSG);
        }
        return new MessageResult(SysConsts.ResCode.SUCCESS_CODE, SysConsts.ResCode.SUCCESS_CODE_MSG, publicKey);
    }

用户登录

逻辑:
1.用户登录成功后以token+userId为key,用户信息为 value存入redis中实现单点登录,和随时获取到用户信息。在注销或者token过期时销毁key
2.用户登录后返回携带userId的token并且在访问所有接口时需要验签

3.登录token拦截器
/**
 * @description:登录token拦截器
 * @author: hdh
 * @date: 2022/6/1 9:58
 */
@Component
public class LoginInterceptor implements HandlerInterceptor {

    @Autowired
    private JwtUtils jwtUtils;


    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {

        String token = request.getHeader(SysConsts.commons.TOKEN);
        if (StringUtils.isNotEmpty(token)) {

            //校验token是否有效
            jwtUtils.isTokenExpired(token);
            //校验token是否过期
            boolean tokenExpired = jwtUtils.isTokenExpired(token);
            String userId = jwtUtils.getUserId(token);

            request.getSession().setAttribute("userId",userId);

            if (tokenExpired) {
                throw new SysRuntimeException(SysConsts.JwtCode.TOKEN_OVERDUE_ERROR, SysConsts.JwtCode.TOKEN_OVERDUE_ERROR_MSG);
            }
            request.setAttribute(SysConsts.commons.TOKEN, jwtUtils.getUserId(token));
        }
        return true;
    }

    @Override
    public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {

        request.getSession().removeAttribute("userId");

    }
}

加入WebMvcConfigurationSupport

/**
 * @description:拦截器配置类
 * @author: hdh
 * @date: 2022/6/1 10:04
 */
@Configuration
public class WebMvcConfig extends WebMvcConfigurationSupport {

    @Autowired
    private RateLimitInterceptor rateLimitInterceptor;

    @Autowired
    private LoginInterceptor loginInterceptor;

    @Override
    protected void addInterceptors(InterceptorRegistry registry) {
        registry.addInterceptor(rateLimitInterceptor).order(2);
        registry.addInterceptor(loginInterceptor).addPathPatterns("/sys/login").addPathPatterns("sys/getJwtKey").order(1);


    }
}

redis对于登录的公共方法

/**
 * @description:redis公共方法
 * @author: hdh
 * @date: 2022/6/1 10:08
 */
@Component
public class CommonsRedisUtil {

    @Autowired
    private RedisUtil redisUtil;

    /**
     * @description: 用户登录用户信息存储redis
     * @author hdh
     * @date: 2022/6/1 10:22
     * @param:user 用户信息
     * @return:void
     */
    public void loginInToRedis(User user) {
        if (Objects.nonNull(user) && StringUtils.isNotEmpty(user.getUserId())) {
            try {
                String userInfoJson = JSONObject.toJSONString(user);
                redisUtil.set(SysConsts.RedisKey.TOKEN + user.getUserId(), userInfoJson);
            } catch (Exception e) {
                throw new SysRuntimeException(SysConsts.LoginCode.USERINFO_ERROE_CODE, SysConsts.LoginCode.USERINFO_ERROE_CODE_MSG);
            }
            return;
        }
        throw new SysRuntimeException(SysConsts.LoginCode.USERINFO_ERROE_CODE, SysConsts.LoginCode.USERINFO_ERROE_CODE_MSG);
    }

    /**
     * @description: 用户登出注销用户信息
     * @author hdh
     * @date: 2022/6/1 10:22
     * @param:userId 用户id
     * @return:void
     */
    public void loginOutToRedis(String userId) {
        redisUtil.delete(SysConsts.RedisKey.TOKEN + userId);
    }


    /**
     * @description: 通过userId获取用户信息
     * @author hdh
     * @date: 2022/6/1 10:22
     * @param:userId 用户id
     * @return:User
     */
    public User getUserInfo(String userId) {
        User user = null;
        try {
            String userJson = String.valueOf(redisUtil.get(SysConsts.RedisKey.TOKEN + userId));
            user = JSONObject.parseObject(userJson, User.class);
            if (Objects.isNull(user)) {
                throw new SysRuntimeException(SysConsts.LoginCode.OBTAIN_USERINFO_ERROE_CODE, SysConsts.LoginCode.OBTAIN_USERINFO_ERROE_CODE_MSG);
            }
        } catch (Exception e) {
            e.printStackTrace();
            throw new SysRuntimeException(SysConsts.LoginCode.OBTAIN_USERINFO_ERROE_CODE, SysConsts.LoginCode.OBTAIN_USERINFO_ERROE_CODE_MSG);
        }
        return user;
    }
}

用户登录基本逻辑

/**
     * @description:用户登录返回TOKEN
     * @author hdh
     * @date: 2022/5/26 17:33
     * @param: [userInfo] 加密后的登录信息
     * @return: com.example.demo.jwtLogin.MessageResult
     */
    @PostMapping("/login")
    public MessageResult login(@RequestBody JSONObject userInfoJwt) {
        String token = null;
        try {
            String decrypt = jwtUtils.decrypt(userInfoJwt.getString("userInfoJwt"));
            User userInfo = JSONObject.parseObject(decrypt, User.class);
            //登录校验
            //校验用户名密码
            //数据库获取用户加密过的登陆密码
            // String pwd = "$2a$10$Jd4J8ZlCCa2VBmLpbY89ReBk3gSKDylJ3Ah35uvdrPVmYwvanpPPu";
            String userId = "123456789";
            User user = new User();
            // boolean matches = new BCryptPasswordEncoder().matches(userInfo.getPassword(), pwd);
            //true 返回token
            token = jwtUtils.getToken(userId);

            redisCommonsUtil.loginInToRedis(user);
        } catch (Exception e) {
            e.printStackTrace();
            throw new SysRuntimeException(SysConsts.LoginCode.ERROE_CODE, SysConsts.LoginCode.ERROE_CODE_MSG);

        }
        return new MessageResult(SysConsts.LoginCode.SUCCESS_CODE, SysConsts.LoginCode.SUCCESS_CODE_MSG, token);

    }

带有Token的访问接口并获取用户信息

/**
  * @description:访问接口
  * @author hdh
  * @date: 2022/6/1 11:17
  * @param: []
  * @return: com.example.demo.jwtLogin.MessageResult
  */
 @GetMapping("/getList")
 public MessageResult getList() {
     
     //可以封装一下
     Object userId = request.getSession().getAttribute("userId");
     return new MessageResult(SysConsts.ResCode.SUCCESS_CODE, SysConsts.ResCode.SUCCESS_CODE_MSG, userId);
 }
4.接口防刷
1.Java自定义注解

@interface:

@interface接口自动继承了所有注释类型的公共扩展接口java.lang.annotation.Annotation
interface是面向对象编程语言中接口操作的关键字,功能是把所需成员组合起来,用来封装一定功能的集合

声明了RateLimit 注解cycle=5,number=2被该注解修饰的方法5秒类只能被调用2次


@Retention(RetentionPolicy.RUNTIME)
@Target({ElementType.METHOD})
@Documented
public @interface RateLimit {

    /**
     * 周期,单位是秒
     */
    int cycle() default 5;

    /**
     * 请求次数
     */
    int number() default 2;

}

``
@Retention 注解保留的环境

public enum RetentionPolicy {
    SOURCE,            /* Annotation信息仅存在于编译器处理期间,编译器处理完之后就没有该Annotation信息了  */

    CLASS,             /* 编译器将Annotation存储于类对应的.class文件中。默认行为  */

    RUNTIME            /* 编译器将Annotation存储于class文件中,并且可由JVM读入 */
}

@Target注解可使用的位置

public enum ElementType {
    TYPE,               /* 类、接口(包括注释类型)或枚举声明  */

    FIELD,              /* 字段声明(包括枚举常量)  */

    METHOD,             /* 方法声明  */

    PARAMETER,          /* 参数声明  */

    CONSTRUCTOR,        /* 构造方法声明  */

    LOCAL_VARIABLE,     /* 局部变量声明  */

    ANNOTATION_TYPE,    /* 注释类型声明  */

    PACKAGE             /* 包声明  */
}

2.实现类实现调用次数限制
@Service
public class RateLimitServiceImpl implements RateLimitService {

    @Autowired
    @Qualifier("redisTemplateOverried")
    private RedisTemplate<String, Object> redisTemplate;

    private static final String RATE_LIMIT_LOCK_LUA_SCRIPT = "local limit = tonumber(ARGV[1])"// 限制次数
            + "local expire_time = ARGV[2]"// 过期时间
            + "local result = redis.call('SETNX',KEYS[1],1);"// key不存在时设置value为1,返回1、否则返回0
            + "if result == 1 then"// 返回值为1,key不存在此时需要设置过期时间
            + "		redis.call('expire',KEYS[1],expire_time)"// 设置过期时间
            + "		return 1 "// 返回1
            + "else"// key存在
            + "		if tonumber(redis.call('GET', KEYS[1])) >= limit then"// 判断数目比对
            + "			return 0"// 如果超出限制返回0
            + "		else" //
            + "			redis.call('incr', KEYS[1])"// key自增
            + "			return 1 " // 返回1
            + "		end "// 结束
            + "end";// 结束

    @Override
    public Boolean limit(String ip, String uri, RateLimit rateLimit) {
        String key = "custom:rate:" + ip + ":" + uri;
        // 指定 lua 脚本,并且指定返回值类型
        DefaultRedisScript<Long> redisScript = new DefaultRedisScript<>(RATE_LIMIT_LOCK_LUA_SCRIPT, Long.class);
        // 参数一:redisScript,参数二:key列表,参数三:arg(可多个)
        Long result = redisTemplate.execute(redisScript, Collections.singletonList(key), rateLimit.number(), rateLimit.cycle());
        log.info("lua脚本返回值为:[{}]", result);
        if (result == 0) {
            throw new SysRuntimeException(SysConsts.ExceptionCode.RATE_LIMIT_ERROR, SysConsts.ExceptionCode.RATE_LIMIT_ERROR_MSG);
        }
        return true;
    }

LUA脚本入门编写
大致意思就是利用setnx 去设置Key的过期时间,判断该ip或者用户ID访问次数

3.拦截器配置
@Component
public class RateLimitInterceptor implements HandlerInterceptor {

    @Autowired
    private RateLimitService rateLimitService;


    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) {
        // 判断请求是否属于方法的请求
        if (handler instanceof HandlerMethod) {
            HandlerMethod handlerMethod = (HandlerMethod) handler;
            // 获取方法中的注解,看是否有该注解
            RateLimit rateLimit = handlerMethod.getMethodAnnotation(RateLimit.class);
            if (rateLimit == null) {
                return true;
            }
            // 请求IP地址
            String ip = request.getRemoteAddr();
            // 请求url路径
            String uri = request.getRequestURI();
            //url 可以改为用户的userId
            return rateLimitService.limit(ip, uri, rateLimit);
        }
        return true;
    }
}

判断访问的方法是否被@RateLimit修饰 是否需要添加访问次数限制

RateLimit rateLimit = handlerMethod.getMethodAnnotation(RateLimit.class);

5.加入WebMvcConfiguration配置类
/**
 * @description:拦截器配置类
 * @author: hdh
 * @date: 2022/6/1 10:04
 */
@Configuration
public class WebMvcConfig extends WebMvcConfigurationSupport {

    @Autowired
    private RateLimitInterceptor rateLimitInterceptor;

    @Autowired
    private LoginInterceptor loginInterceptor;

    @Override
    protected void addInterceptors(InterceptorRegistry registry) {
        registry.addInterceptor(rateLimitInterceptor).order(2);

        registry.addInterceptor(loginInterceptor).excludePathPatterns("/sys/login").excludePathPatterns("/sys/getJwtKey").excludePathPatterns("/sys/aa").order(1);

    }
}
6.注解使用
    @RateLimit(number = 2, cycle = 10)
    @PostMapping("/rate")
    public void rate() {
    }
  • 0
    点赞
  • 4
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值