黑马点评学习笔记

黑马点评

导入数据

按照视频介绍导入需要的后端代码以及数据库
更改数据库和redis的地址

一、基于Session实现登录

1.1 发送短信验证码

在这里插入图片描述

在这里插入图片描述

请求为:post     ~~~    url:/user/code     ~~~    参数:phone
在usercontroller中生成方法

/**
 * 发送手机验证码
 */
@PostMapping("code")
public Result sendCode(@RequestParam("phone") String phone, HttpSession session) {
    // 发送短信验证码并保存验证码
    return userService.sendCode(phone, session);
}

业务代码

1.校验手机号
2.如果不符合,返回错误信息
3.符合,生成验证码
4.保存验证码到session
5.发送验证码
6.返回ok

@Override
public Result sendCode(String phone, HttpSession session) {
    // 1.校验手机号
    if (RegexUtils.isPhoneInvalid(phone)) {
        // 2.如果不符合,返回错误信息
        return Result.fail("手机号格式错误!");
    }
    // 3.符合,生成验证码
    String code = RandomUtil.randomNumbers(6);

    // 4.保存验证码到 session
    stringRedisTemplate.opsForValue().set(LOGIN_CODE_KEY + phone, code, LOGIN_CODE_TTL, TimeUnit.MINUTES);

    // 5.发送验证码(模拟发送验证码)
    log.debug("发送短信验证码成功,验证码:{}", code);
    // 返回ok
    return Result.ok();
}

1.2 短信验证码登录

在这里插入图片描述

在这里插入图片描述

请求为:post     ~~~     url:/user/login     ~~~     参数为json形式:code和phone

封装登陆信息

@Data
public class LoginFormDTO {
    private String phone;
    private String code;
    private String password;
}

在usercontroller中生成方法

/**
 * 登录功能
 * @param loginForm 登录参数,包含手机号、验证码;或者手机号、密码
 */
@PostMapping("/login")
public Result login(@RequestBody LoginFormDTO loginForm, HttpSession session){
    // 实现登录功能
    return userService.login(loginForm, session);
}

因为参数为json数据,所以要加 @RequstBody

业务代码

1.校验手机号
2.校验验证码
3.不一致,报错
4.一致,根据手机号查用户
5.判断用户是否存在
6.不存在,创建新用户并保存
7.保存用户信息到session中

@Override
public Result login(LoginFormDTO loginForm, HttpSession session) {
    // 1.校验手机号
    String phone = loginForm.getPhone();
    if (RegexUtils.isPhoneInvalid(phone)) {
        // 2.如果不符合,返回错误信息
        return Result.fail("手机号格式错误!");
    }
    // 3.从redis获取验证码并校验
    String cacheCode = stringRedisTemplate.opsForValue().get(LOGIN_CODE_KEY + phone);
    String code = loginForm.getCode();
    if (cacheCode == null || !cacheCode.equals(code)) {
        // 不一致,报错
        return Result.fail("验证码错误");
    }

    // 4.一致,根据手机号查询用户 select * from tb_user where phone = ?
    User user = query().eq("phone", phone).one();

    // 5.判断用户是否存在
    if (user == null) {
        // 6.不存在,创建新用户并保存
        user = createUserWithPhone(phone);
    }
// 7.保存用户信息到 session中
     session.setAttribute("user", BeanUtil.copyProperties(user,UserDTO.class));
     return Result.ok();
}
  • session.setAttribute("user", BeanUtil.copyProperties(user,UserDTO.class));
  • 将User的属性拷贝到UserDTO中,优点是不用新建对象

1.3检验登陆状态

拦截器实现
public class LoginInterceptor implements HandlerInterceptor {
//前置拦截
    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
        // 1.获取session
        HttpSession session = request.getSession();
        //2.获取session中的用户
        Object user = session.getAttribute("user");
        //3.判断用户是否存在
        if(user == null) {
            //4.不存在,拦截
            response.setStatus(401);
            return false;
        }
        //5.存在,保存用户信息在ThreadLocal
        UserHolder.saveUser((UserDTO) user);
        //6.放行
        return true;
    }
//后置拦截
    @Override
    public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
       //移除用户
        UserHolder.removeUser();
    }
}
配置拦截器生效
@Configuration
public class MvcConfig implements WebMvcConfigurer {
    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        registry.addInterceptor(new LoginInterceptor())
                .excludePathPatterns(
                        "/user/code",
                        "/user/login",
                        "/voucher/**",//优惠券查询
                        "/shop/**",//商店
                        "/shop-type/**",//商店类型
                        "/upload/**",//更新
                        "/blog/hot"//博客热点
                );
    }
}

1.4集群session共享问题

Session共享问题: 多台Tomcat并不共享session存储空间,当请求切换到不同tomcat服务时导致数据丢失的问题
核心思路分析

  • 早期的方案是session拷贝,就是说虽然每个tomcat上都有不同的session,但是每当任意一台服务器的session修改时,都会同步给其他的Tomcat服务器的session,这样的话,就可以实现session的共享了

但是这种方案具有两个大问题

  • 每台服务器中都有完整的一份session数据,服务器压力过大。
  • ression拷贝数据时,可能会出现延迟

所以后来采用的方案都是基于redis来完成,把session换成redis,redis数据本身就是共享的,就可以避免session共享的问题了

session的替代方案应该满足:数据共享、内存存储、key、value结构。 即:Redis

基于Redis实现共享session登录

业务流程

在这里插入图片描述

不能使用手机号做key,容易泄露,我们用token

数据类型选择
  • 保存登录的用户信息,可以使用String结构,以JSON字符串来保存,比较直观
  • Hash结构可以将对象中的每个字段独立存储,可以针对单个字段做CRUD

这里我们选择hash

发送短信业务代码

@Override
public Result sendCode(String phone, HttpSession session) {
    // 1.校验手机号
    if (RegexUtils.isPhoneInvalid(phone)) {
        // 2.如果不符合,返回错误信息
        return Result.fail("手机号格式错误!");
    }
    // 3.符合,生成验证码
    String code = RandomUtil.randomNumbers(6);

    // 4.保存验证码到 Redis
    stringRedisTemplate.opsForValue().set(LOGIN_CODE_KEY + phone, code, LOGIN_CODE_TTL, TimeUnit.MINUTES);

    // 5.发送验证码
    log.debug("发送短信验证码成功,验证码:{}", code);
    // 返回ok
    return Result.ok();
}

登录校验

@Override
public Result sendCode(String phone, HttpSession session) {
    // 1.校验手机号
    if (RegexUtils.isPhoneInvalid(phone)) {
        // 2.如果不符合,返回错误信息
        return Result.fail("手机号格式错误!");
    }
    // 3.符合,生成验证码
    String code = RandomUtil.randomNumbers(6);

    // 4.保存验证码到 Redis
    stringRedisTemplate.opsForValue().set(LOGIN_CODE_KEY + phone, code, LOGIN_CODE_TTL, TimeUnit.MINUTES);

    // 5.发送验证码
    log.debug("发送短信验证码成功,验证码:{}", code);
    // 返回ok
    return Result.ok();
}
登录状态续期
  • 需求:用户session的过期时间不是固定的,如果期间内用户有访问系统,就应该给过期时间续期

  • 由于自定义拦截器中没有注入容器,所以无法自动注入redis操作类,只能手动注入

  • 可以通过构造方法注入,由于MvcConfig是配置类,由IoC容器管理,所以可以自动注入StringRedisTemplate对象,并将该对象通过构造器注入到自定义拦截器中

public class LoginInterceptor implements HandlerInterceptor {

    private StringRedisTemplate stringRedisTemplate;
    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
        // 1.获取请求头中的token
        String token = request.getHeader("authorization");
        //判空
        if (StrUtil.isBlank(token)) {
            //不存在,拦截,返回401
            response.setStatus(401);
            return false;
        }
        //2.基于TOKEN获取redis中的用户
        String key =RedisConstants.LOGIN_USER_KEY+token;
        Map<Object, Object> UserMap = stringRedisTemplate.opsForHash().entries(key);
        //3.判断用户是否存在
        if(UserMap.isEmpty()) {
            //4.不存在,拦截
            response.setStatus(401);
            return false;
        }
        //5.将hash转为user对象
        UserDTO userDTO = BeanUtil.fillBeanWithMap(UserMap, new UserDTO(), false);
        //6.存在,保存用户信息在ThreadLocal
        UserHolder.saveUser(userDTO);

        //7.刷新token有效期
        stringRedisTemplate.expire(key,RedisConstants.LOGIN_USER_TTL, TimeUnit.MINUTES);
        //8.放行
        return true;
    }
登录报错数据类型转换错误
  • 原因:使用StringRedisTemplate要求存储的数据类型都为String,因此要确保hash中的每一个值都为String类型
// 7.2.将User对象转为HashMap存储
UserDTO userDTO = BeanUtil.copyProperties(user, UserDTO.class);
Map<String, Object> userMap = BeanUtil.beanToMap(userDTO,new HashMap<>(),
        CopyOptions.create()
                .setIgnoreNullValue(true)//设置忽略null值
                .setFieldValueEditor
                ((fieldName,fieldValue) //接收字段名和字段值
                        ->fieldValue.toString()));//将字段值转成string类型
登录拦截器优化

在这里插入图片描述

token刷新拦截器

public class RefreshTokenInterceptor implements HandlerInterceptor {

    private StringRedisTemplate stringRedisTemplate;

    public RefreshTokenInterceptor(StringRedisTemplate stringRedisTemplate) {
        this.stringRedisTemplate = stringRedisTemplate;
    }

    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
        // 1.获取请求头中的token
        String token = request.getHeader("authorization");
        if (StrUtil.isBlank(token)) {
            return true;
        }
        // 2.基于TOKEN获取redis中的用户
        String key  = LOGIN_USER_KEY + token;
        Map<Object, Object> userMap = stringRedisTemplate.opsForHash().entries(key);
        // 3.判断用户是否存在
        if (userMap.isEmpty()) {
            return true;
        }
        // 5.将查询到的hash数据转为UserDTO
        UserDTO userDTO = BeanUtil.fillBeanWithMap(userMap, new UserDTO(), false);
        // 6.存在,保存用户信息到 ThreadLocal
        UserHolder.saveUser(userDTO);
        // 7.刷新token有效期
        stringRedisTemplate.expire(key, LOGIN_USER_TTL, TimeUnit.MINUTES);
        // 8.放行
        return true;
    }

    @Override
    public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
        // 移除用户
        UserHolder.removeUser();
    }
}

修改登录拦截器

public class LoginInterceptor implements HandlerInterceptor {

    private StringRedisTemplate stringRedisTemplate;
    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
        // 1.判断是否需要拦截(Thread中是否有用户)
        if(UserHolder.getUser() == null)
        {
            //没有,拦截
            response.setStatus(401);
            return false;
        }
        //有,放行
        return true;
    }

    @Override
    public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
       //移除用户
        UserHolder.removeUser();
    }
}

将拦截器连接

@Configuration
public class MvcConfig implements WebMvcConfigurer {

    @Resource
    private StringRedisTemplate stringRedisTemplate;

    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        // 登录拦截器
        registry.addInterceptor(new LoginInterceptor())
                .excludePathPatterns(
                        "/shop/**",//商店
                        "/voucher/**",//优惠券
                        "/shop-type/**",//商店类型
                        "/upload/**",//更新
                        "/blog/hot",//博客
                        "/user/code",
                        "/user/login"
                ).order(1);
        //token刷新拦截器
        registry.addInterceptor(new RefreshTokenInterceptor(stringRedisTemplate)).addPathPatterns("/**").order(0);
    }
}

二、商户查询缓存

2.1什么是缓存

缓存就是数据交换的缓冲区(称作cache),是存贮数据的临时地方,一般读写性能较高。

在这里插入图片描述

在这里插入图片描述

添加Redis缓存

在这里插入图片描述

查询商户代码实现

1.从redis查询商铺缓存

2.判断是否存在

3.存在,直接返回

4.不存在,查询数据库

5.不存在,返回错误
6.存在,写入redis
7.返回

@Override
public Result queryById(Long id) {
    String key = CACHE_SHOP_KEY + id;
    //1.从redis查询商铺缓存
    String shopJson = stringRedisTemplate.opsForValue().get(key);
    //2.判断是否存在
    if (StrUtil.isNotBlank(shopJson)) {
        //3.存在,直接返回
        Shop shop = JSONUtil.toBean(shopJson, Shop.class);
        return Result.ok(shop);
    }
    //4.不存在,查询数据库
    Shop shop = getById(id);
    //5.不存在,返回错误
    if (shop == null) {
        return Result.fail("店铺不存在");
    }
    //6.存在,写入redis
        stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(shop));
    // 7.返回
    return Result.ok(shop);
}

2.2店铺类型查询业务添加缓存

ShopTypeController中

@RestController
@RequestMapping("/shop-type")
public class ShopTypeController {
    @Resource
    private IShopTypeService typeService;

    /**
     * 查询商户类型
     * @return
     */
    @GetMapping("list")
    public Result queryTypeList() {
        //List<ShopType> typeList = typeService
        //      .query().orderByAsc("sort").list();
        return typeService.queryShopType();
    }
}

业务代码如上

1.从redis查询商铺缓存

2.判断是否存在

3.存在,直接返回

4.不存在,查询数据库

5.不存在,返回错误
6.存在,写入redis
7.返回

@Service
public class ShopTypeServiceImpl extends ServiceImpl<ShopTypeMapper, ShopType> implements IShopTypeService {

    @Resource
    private StringRedisTemplate stringRedisTemplate;
    /**
     * 查询商户类型
     * @return
     */

    @Override
    public Result queryShopType() {
        String key = RedisConstants.CACHE_SHOPTYPE_KEY;
        //1.从redis中查询商户缓存
        String shopTypeJson = stringRedisTemplate.opsForValue().get(key);
        //2.判断存在
        if(StrUtil.isNotBlank(shopTypeJson)){
            //3.存在,返回
            List<ShopType> shopTypes = JSONUtil.toList(shopTypeJson, ShopType.class);
            return Result.ok(shopTypes);
        }
        //4.不存在查数据库
        List<ShopType> shopTypes = query().orderByAsc("sort").list();
        //5.不存在,返回错误
        if(shopTypes==null){
            return Result.fail("类型为空");
        }
        //6.存在,写入redis
        stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(shopTypes));
        // 7.返回
        return Result.ok(shopTypes);
    }
}

2.3缓存更新策略

在这里插入图片描述

2.4主动更新策略

在这里插入图片描述

第一种最常用

在这里插入图片描述

在这里插入图片描述

2.5商家缓存

在这里插入图片描述

缓存穿透

缓存穿透是指客户端请求的数据在缓存中和数据库中都不存在,这样缓存永远不会生效,这些请求都会打到数据库。

常见的解决方法有两种:

  • 缓存空对象

    • 优点:实现简单,维护方便
    • 缺点:额外的内存消耗
                ~~~~~~~~~           可能造成短期的不一致
  • 布隆过滤

    • 优点:内存占用少,没有多余的key
    • 缺点: 实现复杂
                  ~~~~~~~~~~~            存在误判可能

在这里插入图片描述

业务流程

在这里插入图片描述

修改代码

//判断是否是空值
if(shopJson != null){
    //返回错误
    return Result.fail("店铺信息不存在");
}
//4.不存在,查询数据库
Shop shop = getById(id);
//5.不存在,返回错误
if (shop == null) {
    //将空值写入redis
    stringRedisTemplate.opsForValue().set(key,"", CACHE_NULL_TTL,TimeUnit.MINUTES);
    //返回错误信息
    return Result.fail("店铺不存在");
}
缓存雪崩

在这里插入图片描述

缓存击穿

在这里插入图片描述

在这里插入图片描述

逻辑过期设置的一般不是TTL,设置缓存基本上是一直有效到活动结束后,才移除缓存中数据
之所以会逻辑过期,不是因为有效时间,而是因为数据更新了,缓存也需要更新数据,这时逻辑过期。

在这里插入图片描述

基于互斥锁方式解决缓存击穿问题

在这里插入图片描述

定义锁

//创建锁
 private boolean tryLock(String key){
    Boolean flag = stringRedisTemplate.opsForValue().setIfAbsent(key,"1", LOCK_SHOP_TTL,TimeUnit.SECONDS);
    return BooleanUtil.isTrue(flag);
 }
 //释放锁
 private void unLock(String key) {
stringRedisTemplate.delete(key);
}

缓存穿透

public Shop queryWithPassThrough(Long id) {
    String key = CACHE_SHOP_KEY + id;
    //1.从redis查询商铺缓存
    String shopJson = stringRedisTemplate.opsForValue().get(key);
    //2.判断是否存在
    if (StrUtil.isNotBlank(shopJson)) {
        //3.存在,直接返回
        return JSONUtil.toBean(shopJson, Shop.class);
    }

    //判断是否是空值
    if(shopJson != null){
        //返回错误
        return null;
    }
    //4.不存在,查询数据库
    Shop shop = getById(id);
    //5.不存在,返回错误
    if (shop == null) {
        //将空值写入redis
        stringRedisTemplate.opsForValue().set(key,"", CACHE_NULL_TTL,TimeUnit.MINUTES);
        //返回错误信息
        return null;
    }
    //6.存在,写入redis
    stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(shop), CACHE_SHOP_TTL, TimeUnit.MINUTES);
    // 7.返回
    return shop;
}

基于互斥锁方式解决缓存击穿问题

public Shop queryWithMutex(Long id) {
    String key = CACHE_SHOP_KEY + id;
    //1.从redis查询商铺缓存
    String shopJson = stringRedisTemplate.opsForValue().get(key);
    //2.判断是否存在
    if (StrUtil.isNotBlank(shopJson)) {
        //3.存在,直接返回
        return JSONUtil.toBean(shopJson, Shop.class);
    }

    //判断是否是空值
    if(shopJson != null){
        //返回错误
        return null;
    }
    //4.实现缓存重建
    //4.1获取互斥锁
    String lockKey = LOCK_SHOP_KEY+id;
    Shop shop = null;
    try {
        boolean isLock = tryLock(lockKey);
        //4.2判断是否成功
        if(!isLock){
            //4.3失败,休眠并重试
            Thread.sleep(50);
            return queryWithMutex(id);
        }

        //4.4成功,根据id查询数据库
        shop = getById(id);
        //模拟延时
        Thread.sleep(200);
        //5.不存在,返回错误
        if (shop == null) {
            //将空值写入redis
            stringRedisTemplate.opsForValue().set(key,"", CACHE_NULL_TTL,TimeUnit.MINUTES);
            //返回错误信息
            return null;
        }
        //6.存在,写入redis
        stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(shop), CACHE_SHOP_TTL, TimeUnit.MINUTES);
    } catch (InterruptedException e) {
        throw new RuntimeException(e);
    } finally {
        // 7.释放互斥锁
        unLock(lockKey);
    }
    //8.返回
    return shop;
}
public Result queryById(Long id) {
    //缓存穿透
    // Shop shop = queryWithPassThrough(id);
    //互斥锁解决缓存击穿
    Shop shop = queryWithMutex(id);
    if (shop == null) {
        return Result.fail("店铺不存在");
    }
    // 7.返回
    return Result.ok(shop);
}

基于逻辑过期方式解决缓存击穿问题

在这里插入图片描述

代码忽略

2.6缓存工具封装

在这里插入图片描述

  • 方法一和方法三应对普通缓存
  • 方法二和方法四应对热点key解决击穿问题

三、优惠券秒杀

3.1全局ID生成器

全局ID生成器,是一种在分布式系统下用来生成全局唯一ID的工具,一般要满足下列特性:

在这里插入图片描述

为了增加ID的安全性,我们可以不直接使用Redis自增的数值,而是拼接一些其它信息:

在这里插入图片描述

@Component
public class RedisIdWorker {
    /**
     * 开始时间戳
     */
    private static final long BEGIN_TIMESTAMP = 1640995200L;
    /**
     * 序列号的位数
     */
    private static final int COUNT_BITS = 32;

    private StringRedisTemplate stringRedisTemplate;

    public RedisIdWorker(StringRedisTemplate stringRedisTemplate) {
        this.stringRedisTemplate = stringRedisTemplate;
    }

    /**
     * 生成ID
     * @param keyPrefix
     * @return
     */
    public long nextId(String keyPrefix) {
        // 1.生成时间戳
        LocalDateTime now = LocalDateTime.now();
        long nowSecond = now.toEpochSecond(ZoneOffset.UTC);
        long timestamp = nowSecond - BEGIN_TIMESTAMP;

        // 2.生成序列号
        // 2.1.获取当前日期,精确到天
        String date = now.format(DateTimeFormatter.ofPattern("yyyy:MM:dd"));
        // 2.2.自增长
        long count = stringRedisTemplate.opsForValue().increment("icr:" + keyPrefix + ":" + date);

        // 3.拼接并返回
        return timestamp << COUNT_BITS | count;//时间戳左移32位,然后或运算拼接count
    }
}

生成全局唯一ID策略:

  • UUID
  • Redis自增
  • snowflake算法
  • 数据库自增

其中Redis自增策略:

  • 每天一个key,方便统计订单量
  • ID构成时间戳+计数器

3.2实现优惠券秒杀下单

在这里插入图片描述

下单时需要判断两点:

  • 秒杀是否开始或结束,如果尚未开始或已经结束则无法下单
  • 库存是否充足,不足则无法下单

在这里插入图片描述

业务实现

/**
 * 优惠券抢购
 * @param voucherId
 * @return
 */
@Override
@Transactional
public Result seckillVoucher(Long voucherId) {
   //1.查询优惠券
    SeckillVoucher voucher = seckillVoucherService.getById(voucherId);
    //2.判断秒杀是否开始
    if(voucher.getBeginTime().isAfter(LocalDateTime.now()))
    {
        //尚未开始
        return Result.fail("秒杀尚未开始");
    }
    //3.判断秒杀是否结束
    if(voucher.getEndTime().isBefore(LocalDateTime.now()))
    {
        //结束
        return Result.fail("秒杀已经结束");
    }
    //4.判断库存是否充足
    if (voucher.getStock()<1) {
        //库存不足
        return Result.fail("库存不足");
    }
    //5.扣减库存
    boolean success = seckillVoucherService.update()
            .setSql("stock=stock-1")
            .eq("voucher_id", voucherId).update();
    if(!success)
    {
        return Result.fail("库存不足");
    }
    //6.创建订单
    VoucherOrder voucherOrder = new VoucherOrder();
    //6.1订单id
    long orderId = redisIdWorker.nextId("order");
    voucherOrder.setId(orderId);
    //6.2用户id
    Long userId = UserHolder.getUser().getId();
    voucherOrder.setUserId(userId);
    //6.3代金券id
    voucherOrder.setVoucherId(voucherId);
    save(voucherOrder);
    //7.返回订单id
    return Result.ok(orderId);
}

3.3超卖问题

即多线程并发安全问题,常见解决方案就是加锁:

在这里插入图片描述

乐观锁

乐观锁的关键是判断之前查询得到的数据是否有被修改过,常见的方式有两种:

  • 版本号法
  • CAS法

更新数据用乐观锁,我们采用CAS法来:

//5.扣减库存
boolean success = seckillVoucherService.update()
        .setSql("stock=stock-1")
        .eq("voucher_id", voucherId).gt("stock",0) //stock>0
        .update();
if(!success)
{
    return Result.fail("库存不足");
}

3.4一人一单

在这里插入图片描述

插入数据要用悲观锁
反复观看视频,知识点很多
通过加锁可以解决在单机情况下的一人一单安全问题,但是在集群模式下就不行了。

  • 22
    点赞
  • 6
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值