同一账户在不同地方登录问题

同一个账户在在不同地方登录,别的地方提示下线了。

在这里插入图片描述

概述

其实就是以用户最后一次登录的为准。其他登录的地方全部提示:你已经下线,是否重新登录。从而保护你的操作信息是安全的。

实现原理

随机产生一个uuid,然后设置一个键key,比如下面两个key中的一个,然后再设置UserBo,也要将这个uuid回传会前端
在这里插入图片描述
在这里插入图片描述
然后编写前端传给后端所要经过的拦截器
如果该用户在另一处登录会覆盖redis中的tokenuuid,如果你还是从前端向后端传原来的tokenuuid,拦截器中和redis中的tokenuuid比较,发现不相同,报出异常在异地登录。
在这里插入图片描述

在这里插入图片描述

只能一个地方登录

key = sys:login:+userid

如果同设备互斥

key = sys:login:“+pc+”:“+userid

具体实现

AdminRedisKeyManager

package com.pug.zixun.config.redis;
public interface AdminRedisKeyManager {

    // 登录token续期使用的key
    String USER_LOGIN_TOKEN_KEY = "pug:user:login:token:";
    // 下线使用的rediskey
    String USER_LOGIN_LOGOUT_KEY = "pug:user:logout:";
    // 续期返回的新的token的key
    String RESPONSE_AUTH_TOKEN = "x-auth-token";
    //指定token的claim的key的名字
    String PUG_USER_ID = "pug_user_id";

    // header中userid 接口校验和线下使用
    String TOKEN_USERID_NAME = "token_userid";
    // header中token 接口校验使用
    String TOKEN_NAME = "token";
    // header中token_uuid 下线使用
    String TOKEN_UUID_NAME = "token_uuid";
}

JwtService

package com.pug.zixun.config.jwt;

import com.auth0.jwt.JWT;
import com.auth0.jwt.JWTVerifier;
import com.auth0.jwt.algorithms.Algorithm;
import com.auth0.jwt.exceptions.TokenExpiredException;
import com.auth0.jwt.interfaces.DecodedJWT;
import com.pug.zixun.common.enums.AdminUserResultEnum;
import com.pug.zixun.common.ex.PugValidatorException;
import com.pug.zixun.common.utils.date.TmDateUtil;
import com.pug.zixun.common.utils.fn.asserts.Vsserts;
import com.pug.zixun.config.redis.AdminRedisKeyManager;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Component;

import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.util.Date;
import java.util.concurrent.TimeUnit;
// 问题1:别人工具类 static方法,方便进行操作和获取。
// 问题2:为什么要让上面容器管理,因为我要考虑把这里常量用配置文件管理,甚至用统一配置中心。所以
@Component
public class JwtService  implements AdminRedisKeyManager {

    //jwt 私钥 注意这里一定要保密,不能泄露,否则就会被别人通过程序伪造
    @Value("${pug.jwt.key}")
    private String KEY = "pugadmin123456";
    //指定作者
    @Value("${pug.jwt.author}")
    private  String AUTHOR = "xiexiangban";
    // token的私有前缀
    @Value("${pug.jwt.prefix}")
    private String PUG_TOKEN_PREFIX = "pugbear ";
    // 续期时间
    @Value("${pug.jwt.period}")
    private Long period =  30L;


    // 1 秒
    private Long ONE_SECOND = 1000L;
    // 1 分钟
    private Long ONE_MINIUTE = ONE_SECOND * 60;
    // token 30分钟过期
    private Long TOKEN_EXPIRE_TIME = ONE_MINIUTE * period;


    @Autowired
    private StringRedisTemplate stringRedisTemplate;

    /**
     * 创建token
     *
     * @param userId
     * @return
     */
    public String createToken(Long userId) {
        // 1:确定token加密签名的算法和密钥
        Algorithm algorithm = Algorithm.HMAC256(KEY);
        // 2: 创建token
        String token = JWT.create()
                // 指定作者
                .withIssuer(AUTHOR)
                // 指定用户id即可,不要去放完整的用户对象信息。因为生成token太长,
                // 为什么就放一个id,因为后续开发我们会把解析的用户id,去db或redis查一遍。保证实时性。
                // 方便以后对平台的一些恶意分子直接拉黑, 就会生效。
                //PUG_USER_ID="pug_user_id"
                .withClaim(PUG_USER_ID, userId)
                // 签发时间
                .withIssuedAt(new Date())
                // 指定token的过期时间
                .withExpiresAt(new Date(System.currentTimeMillis() + TOKEN_EXPIRE_TIME))
                // 签名返回
                .sign(algorithm);
        return token;
    }
    /**
     * 校验 token
     *
     * @param token
     * @return
     */
    public boolean verify(String token) {
        try {
            // 1:确定token加密签名的算法和密钥
            Algorithm algorithm = Algorithm.HMAC256(KEY);
            // 2 : 获取token的校验对象
            JWTVerifier verifier = JWT.require(algorithm)
                    .withIssuer(AUTHOR)
                    .build();
            // 3: 开始校验,如果校验通过DecodedJWT.如果token是伪造或者失效的,就会出现异常。
            DecodedJWT jwt = verifier.verify(token);
            return true;
        } catch (Exception ex) {
            return false;
        }
    }
    /**
     * token 自动续期
     * @param token
     * @param userId
     * @return
     */
    public boolean refreshTokenRedis(String token, String userId, HttpServletResponse response){
        // Redis双倍缓存key
        String tokenKey = USER_LOGIN_TOKEN_KEY + token;
        String cacheToken = stringRedisTemplate.opsForValue().get(tokenKey);
        if(Vsserts.isEmpty(cacheToken)){
            return false;
        }
        try {
            // 把自己校验一次,如果自己能通过,说明token还没有过期
            // 1:确定token加密签名的算法和密钥
            Algorithm algorithm = Algorithm.HMAC256(KEY);
            // 2 : 获取token的校验对象
            JWTVerifier verifier = JWT.require(algorithm)
                    .withIssuer(AUTHOR)
                    .build();
            // 3: 开始校验,如果校验通过DecodedJWT.如果token是伪造或者失效的,就会出现异常。
            verifier.verify(token);
        }catch (TokenExpiredException tokenExpiredException){
            // 如果过期了。redis还能找到。说明还可以继续激活使用
            if (stringRedisTemplate.hasKey(tokenKey)) {
                // 生成新的token
                String newToken  = this.createToken(new Long(userId));
                stringRedisTemplate.opsForValue().set(tokenKey,newToken,TOKEN_EXPIRE_TIME * 2, TimeUnit.MILLISECONDS);
                return true;
            }
        }catch ( Exception ex){
            throw new PugValidatorException(AdminUserResultEnum.TOKEN_ERROR_STATUS);
        }
        return true;
    }
    /**
     * 签发时间续期
     *
     * @param token
     * @param tokenUserId
     * @param response
     */
    public void refreshToken(String token, Long tokenUserId, HttpServletResponse response) {
        // token续期
        // 获取token的签发时间 --------第一种写法
        Date signTokenTime = this.getTokenIssuedTime(token);
        int diffminutes = TmDateUtil.diffminutes(signTokenTime,new Date());
        // 开始刷新token   10的含义是:旧的token还剩下10分钟,在最后的这10分钟范围内去续期,
        // 假设你的token存活时间(TOKEN_EXPIRE_TIME=30)。那么久是久的token存活20分钟,在20分钟以后时间内都是续期时间点。
        Long period = TOKEN_EXPIRE_TIME - 10;
        if(diffminutes >= period ){
            // 续期,重新生成一个新的token
            String newToken = this.createToken(tokenUserId);
            // 通过response的头部输出token,然后前台通过reponse获取
            response.setHeader(RESPONSE_AUTH_TOKEN, newToken);
        }
    }


    /**
     * 过期时间续期
     *
     * @param token
     * @param tokenUserId
     * @param response
     */
    public void refreshToken2(String token, Long tokenUserId, HttpServletResponse response) {
        // token续期
        // 获取token的签发时间 --------第一种写法
        Date expireTime = this.getTokenExpireTime(token);
        // 假设过期时间是 30分钟,用过期时间减去当前时间:30 29 28 27 20...10
        int diffminutes = TmDateUtil.diffminutes(new Date(),expireTime);
        // 如果时间以及过去了20分钟,到最后十分钟的时候就开始续期 10 9 8 7
        if(diffminutes <= 10 ){
            // 续期,重新生成一个新的token
            String newToken = this.createToken(tokenUserId);
            // 通过response的头部输出token,然后前台通过reponse获取
            response.setHeader(RESPONSE_AUTH_TOKEN, newToken);
        }
    }


    /**
     * @param token
     * @return
     */
    public Long getTokenUserId(String token) {
        try {
            // 1:确定token加密签名的算法和密钥
            Algorithm algorithm = Algorithm.HMAC256(KEY);
            // 2 : 获取token的校验对象
            JWTVerifier verifier = JWT.require(algorithm)
                    .withIssuer(AUTHOR)
                    .build(); //Reusable verifier instance
            // 3: 开始校验,如果校验通过DecodedJWT.如果token是伪造或者失效的,就会出现异常。
            DecodedJWT jwt = verifier.verify(token);
            return jwt.getClaim(PUG_USER_ID).asLong();
        } catch (Exception ex) {
            throw new PugValidatorException(AdminUserResultEnum.TOKEN_ERROR);
        }
    }


    /**
     * 根据token 获取签发时间
     * @param token
     * @return
     */
    public Date getTokenIssuedTime(String token){
        try {
            // 1:确定token加密签名的算法和密钥
            Algorithm algorithm = Algorithm.HMAC256(KEY);
            // 2 : 获取token的校验对象
            JWTVerifier verifier = JWT.require(algorithm)
                    .withIssuer(AUTHOR)
                    .build(); //Reusable verifier instance
            // 3: 开始校验,如果校验通过DecodedJWT.如果token是伪造或者失效的,就会出现异常。
            DecodedJWT jwt = verifier.verify(token);
            return jwt.getIssuedAt();
        } catch (Exception ex) {
            throw new PugValidatorException(AdminUserResultEnum.TOKEN_ERROR);
        }
    }

    /**
     * 获取过期时间
     * @param token
     * @return
     */
    public Date getTokenExpireTime(String token){
        try {
            // 1:确定token加密签名的算法和密钥
            Algorithm algorithm = Algorithm.HMAC256(KEY);
            // 2 : 获取token的校验对象
            JWTVerifier verifier = JWT.require(algorithm)
                    .withIssuer(AUTHOR)
                    .build(); //Reusable verifier instance
            // 3: 开始校验,如果校验通过DecodedJWT.如果token是伪造或者失效的,就会出现异常。
            DecodedJWT jwt = verifier.verify(token);
            return jwt.getExpiresAt();
        } catch (Exception ex) {
            throw new PugValidatorException(AdminUserResultEnum.TOKEN_ERROR);
        }
    }

    /**
     * 获取请求头的token
     *
     * @param request
     * @return
     */
    public String getToken(HttpServletRequest request) {
        String token = request.getHeader(TOKEN_NAME);
        if (Vsserts.isEmpty(token)) {
            return null;
        }
        if (!token.startsWith(PUG_TOKEN_PREFIX)) {
            throw new PugValidatorException(AdminUserResultEnum.TOKEN_ERROR_STATUS);
        }
        // 截取前缀
        token = token.substring(PUG_TOKEN_PREFIX.length());
        // 返回
        return token;
    }


    /**
     * 获取请求头的token的用户ID
     *
     * @param request
     * @return
     */
    public String getTokenUserId(HttpServletRequest request) {
        String tokenUserId = request.getHeader(TOKEN_USERID_NAME);
        if (Vsserts.isEmpty(tokenUserId)) {
            return null;
        }
        // 返回
        return tokenUserId;
    }


    /**
     * 登录使用,双倍时间
     * @param token
     */
    public void redisToken(String token){
        // Jwt和redis的续期双倍时间
        String tokenKey = USER_LOGIN_TOKEN_KEY + token;
        // 记住,在redis的单位默认是 秒,也就是说这个tokenkey是双倍,时间是30分钟,双倍也就是60分钟  转换成秒 3600秒
        stringRedisTemplate.opsForValue().set(tokenKey, token, TOKEN_EXPIRE_TIME * 2, TimeUnit.MILLISECONDS);
    }
}

IJwtBlackService

package com.pug.zixun.config.redis;
public interface IJwtBlackService {

    String BLACK_STRING_KEY = "blacklist:string";
    Long BLACK_EXPIRE_TIME = 30L;
    String BLACK_LIST_KEY = "blacklist:set";

    //添加黑白名单
    void addBlackList(String token);

    // 2: 判断当前用户是否在黑名单中
    boolean isBlackList(String token);

    // 4: 删除黑名单
    boolean removeBlackList(String token);
}

AdminUserResultEnum

采用枚举

package com.pug.zixun.common.enums;

public interface AdminResultInterface {

    Integer getCode();
    String getMsg();

}
package com.pug.zixun.common.enums;
public enum AdminUserResultEnum implements AdminResultInterface{

    USER_NULL_ERROR(100601, "用户不存在"),
    USER_SERVER_ERROR(100602, "服务出现故障"),
    USER_INPUT_USERNAME_ERROR(100603, "用户名或密码输入有误"),
    TOKEN_ERROR(100604, "token expired"),
    TOKEN_NOT_FOUND(100605, "token not found"),
    USER_FORBIDDEN_ERROR(100606, "用户异常,请联系管理员"),
    USER_NAME_NOT_EMPTY(100607, "账号不能是空"),
    USER_PWD_NOT_EMPTY(100607, "密码不能为空"),
    USER_LOGIN_UUID_EMPTY(100608, "会话过期了..."),
    TOKEN_ERROR_STATUS(100609, "token无效"),
    USER_LOGIN_SAME(100610, "你已经在别地方登录了");


    private Integer code;
    private String msg;

    AdminUserResultEnum(Integer code, String msg) {
        this.code = code;
        this.msg = msg;
    }

    @Override
    public Integer getCode() {
        return code;
    }

    @Override
    public String getMsg() {
        return msg;
    }
}

UserVo

package com.pug.zixun.vo;

import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@AllArgsConstructor
@NoArgsConstructor
public class UserVo extends ParentVo implements java.io.Serializable {
    // id
    private Long id;
    // 用户姓名
    private String username;
    // 密码
    private String password;
    // 验证码
    private String code;

    // 验证码的UUID
    private String uuid;
    // token
    private String token;
}

UserServiceImpl,IUserService

package com.pug.zixun.service.user;

import com.baomidou.mybatisplus.extension.service.IService;
import com.pug.zixun.domain.User;
import com.pug.zixun.vo.UserVo;
public interface IUserService extends IService<User> {


    /**
     * 登录
     * @param userVo
     * @return
     */
    User login(UserVo userVo);


}

package com.pug.zixun.service.user;

import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.pug.zixun.domain.User;
import com.pug.zixun.mapper.UserMapper;
import com.pug.zixun.vo.UserVo;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
@Service
@Slf4j
public class UserServiceImpl extends ServiceImpl<UserMapper, User> implements IUserService {

    /**
     * 登录
     *
     * @param userVo
     * @return
     */
    @Override
    public User login(UserVo userVo) {
        LambdaQueryWrapper<User> lambdaQueryWrapper = new LambdaQueryWrapper<>();
        lambdaQueryWrapper.eq(User::getUsername, userVo.getUsername());
        User user = this.getOne(lambdaQueryWrapper);
        return user;
    }


}


MD5Utils

package com.pug.zixun.common.utils.pwd;

import java.math.BigInteger;
import java.security.MessageDigest;

public class MD5Util {

    public MD5Util() {
    }

    public static String md5(String str) {
        try {
            MessageDigest md5 = MessageDigest.getInstance("MD5");
            md5.update(str.getBytes("UTF-8"));
            return bytesToHex(md5.digest());
        } catch (Exception var2) {
            throw new RuntimeException(var2);
        }
    }

    public static String md5slat(String str) {
        //MD5加盐
        return MD5Util.md5(MD5Util.md5("kuangstudy" + str + "202102170318!!!"));
    }

    public static String bytesToHex(byte[] bytes) {
        BigInteger bigInt = new BigInteger(1, bytes);
        String hashtext;
        for (hashtext = bigInt.toString(16); hashtext.length() < 32; hashtext = "0" + hashtext) {
        }
        return hashtext;
    }

    public static void main(String[] args) {
        System.out.println(md5slat("123456"));
    }
}

UserBo

返回给前端的格式

package com.pug.zixun.bo;
import com.pug.zixun.domain.User;
import lombok.Data;
@Data
public class UserBo implements java.io.Serializable {
    // 接口校验使用
    private String token;
    // 下线使用
    private String tokenUuid;
    //  登录的用户信息
    private User user;
}

* PassportLoginController

1 : 在登录产生一个随机的uuid放入缓存中

package com.pug.zixun.controller.login;

import com.pug.zixun.bo.UserBo;
import com.pug.zixun.common.enums.AdminUserResultEnum;
import com.pug.zixun.common.ex.PugValidatorException;
import com.pug.zixun.common.utils.pwd.MD5Util;
import com.pug.zixun.config.BaseController;
import com.pug.zixun.config.jwt.JwtService;
import com.pug.zixun.config.validator.PugAssert;
import com.pug.zixun.domain.User;
import com.pug.zixun.service.user.IUserService;
import com.pug.zixun.vo.UserVo;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RestController;

import java.util.UUID;
@RestController
@Slf4j
public class PassportLoginController extends BaseController {


    @Autowired
    private IUserService userService;
    @Autowired
    private JwtService jwtService;
    @Autowired
    private RedisTemplate redisTemplate;

    /**
     * 登录
     *
     * @param userVo
     * @return
     */
    @PostMapping("/login/toLogin")
    public UserBo logined(@RequestBody UserVo userVo) {
        // 这里有校验,spring-validator框架来完成 或者用断言 或者用自己封装的
        PugAssert.isEmptyEx(userVo.getUsername(), AdminUserResultEnum.USER_NAME_NOT_EMPTY);
        PugAssert.isEmptyEx(userVo.getPassword(), AdminUserResultEnum.USER_PWD_NOT_EMPTY);

        // 根据用户名称查询用户信息
        User dbLoginUser = userService.login(userVo);
        PugAssert.isNullEx(dbLoginUser, AdminUserResultEnum.USER_NULL_ERROR);

        // 用户输入的密码,加盐处理不容易被破解
        String inputPwd = MD5Util.md5slat(userVo.getPassword());
        // 如果输入密码和数据库密码不一致
        boolean isLogin = dbLoginUser.getPassword().equalsIgnoreCase(inputPwd);
        // 如果输入的账号和有误,isLogin=false.注意isFalseEx在里面取反的,所以会抛出异常
        PugAssert.isFalseEx(isLogin,AdminUserResultEnum.USER_INPUT_USERNAME_ERROR);

        UserBo userBo = new UserBo();
        // 根据用户生成token
        String token = jwtService.createToken(dbLoginUser.getId());
        userBo.setToken(token);
        // 注意把一些敏感信息全部清空返回
        dbLoginUser.setPassword(null);
        userBo.setUser(dbLoginUser);

        // 登录挤下线
        String tokenUuid = UUID.randomUUID().toString();
        //获取用户id设置key
        String tokenUuidKey = "pug:user:login:"+dbLoginUser.getId();
        redisTemplate.opsForValue().set(tokenUuidKey,tokenUuid);
        userBo.setTokenUuid(tokenUuid);


        return userBo;
    }

}

* PassportLoginInterceptor

2:编写下线拦截器

package com.pug.zixun.config.interceptor;

import com.pug.zixun.common.anno.IgnoreToken;
import com.pug.zixun.common.enums.AdminUserResultEnum;
import com.pug.zixun.common.ex.PugValidatorException;
import com.pug.zixun.common.utils.fn.asserts.Vsserts;
import com.pug.zixun.config.jwt.JwtService;
import com.pug.zixun.config.validator.PugAssert;
import com.pug.zixun.domain.User;
import com.pug.zixun.local.UserThreadLocal;
import com.pug.zixun.service.user.IUserService;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Component;
import org.springframework.web.method.HandlerMethod;
import org.springframework.web.servlet.HandlerInterceptor;
import org.springframework.web.servlet.ModelAndView;

import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.lang.reflect.Method;
import java.util.UUID;

/**
 * 挤下线使用
 */
@Component
@Slf4j
public class PassportLogoutInterceptor implements HandlerInterceptor {


    @Autowired
    private StringRedisTemplate  stringRedisTemplate;

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

        /*********************这里是用户输入的信息********************/
        // 获取用户传递过来的tokenuuid
        String tokenUuid = request.getHeader("token_uuid");
        // 如果没有获取到,说明没有登录
        PugAssert.isEmptyEx(tokenUuid,AdminUserResultEnum.USER_LOGIN_UUID_EMPTY);

        // *******************从redis获取uuid********************/
        String tokenUserId = request.getHeader("token_userid");
        String tokenUuidKey = "pug:user:login:"+tokenUserId;
        String cacheUuid =  stringRedisTemplate.opsForValue().get(tokenUuidKey);
        // 如果没有获取到,说明没有登录
        PugAssert.isEmptyEx(tokenUuid,AdminUserResultEnum.USER_LOGIN_UUID_EMPTY);

        // *******************比较********************/
        // 如果你当前访问的uuid和缓存的uuid不同,就说明你在别的地方登录了。
        if(!tokenUuid.equalsIgnoreCase(cacheUuid)){
            throw new PugValidatorException(AdminUserResultEnum.USER_LOGIN_SAME);
        }

        return true;
    }


    @Override
    public void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler, ModelAndView modelAndView) throws Exception {
        UserThreadLocal.remove();
    }

    @Override
    public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
        UserThreadLocal.remove();
    }
}

WebMvcConfiguration

3.注册和设置规则

package com.pug.zixun.config.mvc;

import com.pug.zixun.config.interceptor.PassportLoginInterceptor;
import com.pug.zixun.config.interceptor.PassportLogoutInterceptor;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
@Configuration
public class WebMvcConfiguration implements WebMvcConfigurer {

    @Autowired
    private PassportLogoutInterceptor passportLogoutInterceptor;
    @Autowired
    private PassportLoginInterceptor passportLoginInterceptor;



    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        // 下线拦截器
        registry.addInterceptor(passportLogoutInterceptor).addPathPatterns("/admin/**")
                .excludePathPatterns("/admin/login/**");
        // 设置passportlogin的规则。以/admin开头的所有请求都要进行token校验
        registry.addInterceptor(passportLoginInterceptor).addPathPatterns("/admin/**")
                .excludePathPatterns("/admin/login/**");
    }
}

测试

先登录,返回前端userbo。获得token,tokenUuid,user属性
在这里插入图片描述
然后通过得到的属性开始测试
在这里插入图片描述
debug发现cacheUuid和tokenUuid相等
在这里插入图片描述
返回成功
在这里插入图片描述
重新登录后,更新了redis的uuid,所以抛出异常
在这里插入图片描述
在这里插入图片描述

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

龙崎流河

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值