Redis黑马点评项目重点

Redis点评项目重点

用户登录模块

1.设置登录拦截

设置拦截器,略

2.使用session存储验证码以及用户信息

单体应用时用户的会话信息保存在session中,session存在于服务器端的内存中,由于前前后后用户只针对一个web服务器,所以没啥问题。但是一到了web服务器集群的环境下(我们一般都是用Nginx做负载均衡,若是使用了轮询等这种请求分配策略),就会导致用户小a在A服务器登录了,session存在于A服务器中,但是第二次请求被分配到了B服务器,由于B服务器中没有用户小a的session会话,导致用户小a还要再登陆一次,以此类推。这样用户体验很不好。当然解决办法也有很多种,比如同一个用户分配到同一个服务处理、使用cookie保持用户会话信息等。
因此,要解决这样的问题必须满足以下条件:

  • 数据共享
  • 内存存储
  • key、value结构

3.使用redis替代session(hash)

  1. 生成随机token作为登陆令牌

    使用uuid生成随机字符串,可升级为JWT

  2. 用户信息使用redis的hash结构存储

    对象转hashmap,使用hutool的api

     final Map<String, Object> map = BeanUtil.beanToMap(userDTO, new HashMap<>(),
                    CopyOptions.create().setIgnoreNullValue(true).setFieldValueEditor((fieldName, fieldValue)->{
                        return fieldValue.toString();
                    })
            );
    

    存入redis并设置过期时间

    //存入redis
    redisTemplate.opsForHash().putAll(LOGIN_USER_KEY + token, map);
    //设置过期时间
    redisTemplate.expire(LOGIN_USER_KEY + token, 3000, TimeUnit.MINUTES);
    
  3. token有效期的刷新

    • 首先,对于每个请求,我们首先根据token判断用户是否已经登陆(是否已经保存到ThreadLocal中),如果没有登陆,放行交给登陆拦截器去做,如果已经登陆,刷新token的有效期,然后放行。
    • 之后来到登陆拦截器,如果ThreadLocal没有用户,说明没有登陆,拦截,否则放行。

          @Override
          public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
              //获取token
              String header = request.getHeader("authorization");
              //请求头为null,放行
              if (header == null) {
                  response.setStatus(401);
                  return false;
              }
              //header即为token,根据token从redis中查询用户信息
              final Map<Object, Object> userMap = redisTemplate.opsForHash().entries(LOGIN_USER_KEY + header);
              if (userMap.isEmpty()) {
                  response.setStatus(401);
                  return false;
              }
              UserDTO userDTO = BeanUtil.fillBeanWithMap(userMap, new UserDTO(), false);
              //通过ThreadLocale保存用户信息
              UserHolder.saveUser(userDTO);
              //刷新token有效期
              redisTemplate.expire(LOGIN_USER_KEY + header, LOGIN_USER_TTL, TimeUnit.MINUTES);
              return true;
          }
      
  4. 对于第三点中token的刷新存在问题,如果用户一直都访问不需要被拦截的网页,就不会出发拦截器,进而不会刷新token有效期,因此我们需要再加一个拦截器,并将大部分操作放进新的拦截器中

商户缓存模块

1.设置商户信息,商户分类缓存

从redis中查找->有则返回没有则从mysql获取并存入redis

2.缓存更新策略

image-20221027231037059

image-20221027231050794

选择在更新数据库的同时更新缓存。
操作缓存和数据库时有三个问题需要考虑:

  • 删除缓存还是更新缓存?

​ 更新缓存:每次更新数据库都更新缓存,无效写操作较多

​ 删除缓存:更新数据库时让缓存失效,查询时再更新缓存

  • 如何保证缓存与数据库的操作的同时成功或失败?
    单体系统,将缓存与数据库操作放在一个事务
    分布式系统,利用TCC等分布式事务方案

  • 先操作缓存还是先操作数据库?

    先操作db,再删缓存出现问题概率较小

3.更新商铺信息

采用先更新数据库,后删除缓存,更新操作应加上spring的事务,但需要注意,spring事务可以回滚数据库操作,但无法回滚redis操作,不过redis数据在此业务中无关紧要,因此无需额外处理

//增加事务,但spring事务无法回滚redis操作
    @Override
    @Transactional
    public Result updateShopInfo(Shop shop) {
        Long id = shop.getId();
        if (id == null){
            return Result.fail("商户id不存在");
        }
        //1.更新数据库
        updateById(shop);
        //2.删除缓存
        redisTemplate.delete(CACHE_SHOP_KEY+id);
        return Result.ok("更新成功");
    }

4.缓存穿透解决

缓存穿透是指客户端请求的数据在缓存中和数据库中都不存在,这样缓存永远不会生效,这些请求都会打到数据库。
常见的解决方案有两种:

  • 缓存空对象
    优点:实现简单,维护方便
    缺点:额外的内存消耗,可能造成短期的不一致
    适合命中不高,但可能被频繁更新的数据

  • 布隆过滤
    优点:内存占用较少,没有多余key
    缺点:实现复杂,存在误判可能
    适合命中不高,但是更新不频繁的数据

image-20221027231101594

4.缓存雪崩解决

缓存雪崩是指在同一时段大量的缓存key同时失效或者Redis服务宕机,导致大量请求到达数据库,带来巨大压力。
解决方案:

  • 给不同的Key的TTL添加随机值
  • 利用Redis集群提高服务的可用性
  • 给缓存业务添加降级限流策略
  • 给业务添加多级缓存

5.缓存击穿解决

缓存击穿问题也叫热点Key问题,就是一个被高并发访问并且缓存重建业务较复杂的key突然失效了,无数的请求访问会在瞬间给数据库带来巨大的冲击。
常见的解决方案有两种:

  • 互斥锁
  • 逻辑过期

image-20221027231116009

image-20221027231126555

image-20221027231135596

优惠券秒杀模块

1.redis仿雪花算法全局id

image-20221027231146576

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

    @Autowired
    private StringRedisTemplate stringRedisTemplate;

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

    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;
    }
}

2.库存超卖

多线程同时访问库存,就会多减库存,将库存减到负数

  • 悲观锁,让程序串行执行,性能太差,不考虑
  • 乐观锁,在更新时判断是否有其他线程在修改

3.库存新需求:一人一单

解决方案:查询当前用户是否下过单

注意问题:

  • 事务和锁的关系,应在事务提交后再释放锁
  • 事务失效
  • 集群模式下,单机锁会失效

4.分布式锁

概念:满足分布式系统或者集群模式下多进程可见并互斥的锁

实现:使用redis的setnx替代单机锁

锁设计:一个用户一把锁,使用用户id作为key,uuid作为value

加锁

    @Override
    public boolean tryLock(long timeoutSec) {
        long id = Thread.currentThread().getId();
        Boolean success = redisTemplate.opsForValue().setIfAbsent(KEY_PREFIX + name, id + "", timeoutSec, TimeUnit.SECONDS);
        //避免拆箱空指针
        return BooleanUtil.isTrue(success);
    }

释放锁

 @Override
 public void unlock() {
        Boolean delete = redisTemplate.delete(KEY_PREFIX + name);
        log.info("锁删除:{}", delete);
    }

5.分布式锁误删问题

问题

由于可能发生的线程1业务阻塞,ttl到了自动释放锁,线程2再次获取相同的锁后,此时线程1执行完业务并删除锁,导致线程2的锁被删除

image-20221027231158745

解决方案:

为锁添加uuid标识,删除锁应先判断是否是自己的锁

改进代码

public class SimpleRedisLock implements ILock {
    private static final String KEY_PREFIX = "lock:";
    private static final String ID_PREFIX = UUID.randomUUID().toString(true) + "-";


    private String name;

    private StringRedisTemplate redisTemplate;

    public SimpleRedisLock(String name, StringRedisTemplate redisTemplate) {
        this.name = name;
        this.redisTemplate = redisTemplate;
    }

    @Override
    public boolean tryLock(long timeoutSec) {
        long id = Thread.currentThread().getId();

        Boolean success = redisTemplate.opsForValue().setIfAbsent(KEY_PREFIX + name, ID_PREFIX + id, timeoutSec, TimeUnit.SECONDS);
        //避免拆箱空指针
        return BooleanUtil.isTrue(success);
    }

    @Override
    public void unlock() {
        //当前线程锁
        String threadId = ID_PREFIX + Thread.currentThread().getId();
        //redis中的锁
        String s = redisTemplate.opsForValue().get(KEY_PREFIX + name);
        //锁一致才能删除锁
        if (threadId.equals(s)){
            Boolean delete = redisTemplate.delete(KEY_PREFIX + name);
            log.info("锁删除:{}", delete); 
        }
    }
}

6.分布式锁的原子性问题

image-20221027231208382

即解锁过程无法保证原子性,使用lua脚本执行解锁,即可保证原子性

使用lua脚本解决
--获取锁
local id = redis.call('get',KEYS[1])
--对比
if( id == ARGV[1]) then
return redis.call('del',KEYS[1])
end
return 0
java调用lua脚本

使用静态变量初始化lua脚本,避免多次io

 private static final DefaultRedisScript<Long> UNLOCK_SCRIPT;
    static {
        UNLOCK_SCRIPT = new DefaultRedisScript<>();
        UNLOCK_SCRIPT.setLocation(new ClassPathResource("lua/redis-unlock.lua"));
        UNLOCK_SCRIPT.setResultType(Long.class);
    }

调用

//当前线程锁
        String threadId = ID_PREFIX + Thread.currentThread().getId();
        redisTemplate.execute(UNLOCK_SCRIPT, Collections.singletonList(KEY_PREFIX + name), threadId);

7.Redission

基于stenx的分布式锁存在的问题

image-20221027231215132

配置

引入依赖

        <dependency>
            <groupId>org.redisson</groupId>
            <artifactId>redisson</artifactId>
            <version>3.17.7</version>
        </dependency>

配置redisson客户端

@Configuration
public class RedisConfig {
    @Bean
    public RedissonClient redissonClient(){
        Config config = new Config();
        config.useSingleServer().setAddress("39.99.141.194:6379").setPassword("mqq05*09*");
        return Redisson.create(config);
    }
}
使用

替换掉自定义的锁即可,使用方式大致一样,但他的trylock有三个参数,如tryLock(5,10, TimeUnit.SECONDS);其含义是10秒钟后释放锁,如果获取锁失败,在5s内会不断重试

  @Autowired
    private RedissonClient redissonClient; 

RLock lock = redissonClient.getLock("lock:order:" + id);
        boolean b = lock.tryLock(1,10, TimeUnit.SECONDS);
        if (!b) {
            return Result.fail("禁止重复下单");
        }
        try {
            return createOrder(voucherId);
        } finally {
            lock.unlock();
        }
可重入锁原理

image-20221027231219687

8.Redis优化秒杀

秒杀逻辑优化
  1. 使用redis判断库存以及库存预减
  2. 使用异步队列进行库存扣减

image-20221027231223354

需求

image-20221027231226506

lua脚本实现秒杀资格判断
-- 1.参数列表
-- 1.1.优惠券id
local voucherId = ARGV[1]
-- 1.2.用户id
local userId = ARGV[2]
-- 1.3.订单id
local orderId = ARGV[3]

-- 2.数据key
-- 2.1.库存key
local stockKey = 'seckill:stock:' .. voucherId
-- 2.2.订单key
local orderKey = 'seckill:order:' .. voucherId

-- 3.脚本业务
-- 3.1.判断库存是否充足 get stockKey,tonumber()强转数字
if(tonumber(redis.call('get', stockKey)) <= 0) then
    -- 3.2.库存不足,返回1
    return 1
end
-- 3.2.判断用户是否下单 SISMEMBER orderKey userId
if(redis.call('sismember', orderKey, userId) == 1) then
    -- 3.3.存在,说明是重复下单,返回2
    return 2
end
-- 3.4.扣库存 incrby stockKey -1
redis.call('incrby', stockKey, -1)
-- 3.5.下单(保存用户)sadd orderKey userId
redis.call('sadd', orderKey, userId)
-- 3.6.发送消息到队列中, XADD stream.orders * k1 v1 k2 v2 ...
redis.call('xadd', 'stream.orders', '*', 'userId', userId, 'voucherId', voucherId, 'id', orderId)
return 0

基于阻塞队列实现异步秒杀
	//创建阻塞队列
    private BlockingQueue<VoucherOrder> orders = new ArrayBlockingQueue<>(1024 * 1024);
    //创建线程池
	private ExecutorService executorService = Executors.newCachedThreadPool();

    //将订单放入阻塞队列
    orders.add(voucherOrder);

    //当前类初始化完毕后执行
    @PostConstruct
    private void init() {
        executorService.submit(new VoucherOrderHandler());
    }

    private class VoucherOrderHandler implements Runnable {

        @Override
        public void run() {
            while (true) {
                //从阻塞队列获取订单信息
                try {
                    //取出订单信息
                    VoucherOrder voucherOrder = orders.take();
                    //创建订单
                    createOrder(voucherOrder);
                } catch (InterruptedException e) {
                    throw new RuntimeException(e);
                }
            }
        }
    }
	//数据库减库存
    @Transactional
    public void createOrder(VoucherOrder voucherOrder) {
        boolean result = seckillVoucherService.update().setSql("stock=stock-1").eq("voucher_id", 					voucherOrder.getVoucherId()).gt("stock", 0).update();
        save(voucherOrder);
    }

问题:

  • 内存问题,阻塞队列运行在jvm中,有内存溢出的风险
  • 安全问题,如果服务意外宕机,阻塞队列的数据将丢失
基于redis消息队列实现异步秒杀(Stream)

image-20221027231232138

创建队列
xgroup create stream.orders g1 0 mkstream

达人探店

完善点赞功能

需求:
  • 同一个用户只能点赞一次,再次点击则取消点赞
  • 如果当前用户已经点赞,则点赞按钮高亮显示(前端已实现,判断字段Blog类的isLike属性)
实现步骤:
  1. 给Blog类中添加一个isLike字段,标示是否被当前用户点赞
  2. 修改点赞功能,利用Redis的set集合判断是否点赞过,未点赞过则点赞数+1,已点赞过则点赞数-1
  3. 修改根据id查询Blog的业务,判断当前登录用户是否点赞过,赋值给isLike字段
  4. 修改分页查询Blog业务,判断当前登录用户是否点赞过,赋值给isLike字段
  public Result likeBlog(Long id) {
        // 1.获取登录用户
        Long userId = UserHolder.getUser().getId();
        // 2.判断当前登录用户是否已经点赞
        String key = "blog:liked:" + id;
        Double score = redisTemplate.opsForZSet().score(key, userId.toString());
        if (score == null) {
            // 3.如果未点赞,可以点赞
            // 3.1.数据库点赞数 + 1
            boolean isSuccess = update().setSql("liked = liked + 1").eq("id", id).update();
            // 3.2.保存用户到Redis的set集合  zadd key value score
            if (isSuccess) {
                redisTemplate.opsForZSet().add(key, userId.toString(), System.currentTimeMillis());
            }
        } else {
            // 4.如果已点赞,取消点赞
            // 4.1.数据库点赞数 -1
            boolean isSuccess = update().setSql("liked = liked - 1").eq("id", id).update();
            // 4.2.把用户从Redis的set集合移除
            if (isSuccess) {
                redisTemplate.opsForZSet().remove(key, userId.toString());
            }
        }
        return Result.ok();
    }

点赞排行榜(SortedSet)

需求:

按照点赞时间先后排序,返回Top5的用户

set和SortedSet优劣

image-20221027231236896

使用SortedSet:
  • 通过 ZSCORE 命令获取 SortedSet 中存储的元素的相关的 SCORE 值。
  • 通过 ZRANGE 命令获取指定范围内的元素。
 public Result queryBlogLikes(Long id) {
        String key = "blog:liked:" + id;
        // 1.查询top5的点赞用户 zrange key 0 4
        Set<String> top5 = redisTemplate.opsForZSet().range(key, 0, 4);
        if (top5 == null || top5.isEmpty()) {
            return Result.ok(Collections.emptyList());
        }
        // 2.解析出其中的用户id
        List<Long> ids = top5.stream().map(Long::valueOf).collect(Collectors.toList());
        String idStr = StrUtil.join(",", ids);
        // 3.根据用户id查询用户 WHERE id IN ( 5 , 1 ) ORDER BY FIELD(id, 5, 1)
        List<UserDTO> userDTOS = userService.query()
                .in("id", ids).last("ORDER BY FIELD(id," + idStr + ")").list()
                .stream()
                .map(user -> BeanUtil.copyProperties(user, UserDTO.class))
                .collect(Collectors.toList());
        // 4.返回
        return Result.ok(userDTOS);
    }

好友关注

共同关注(set)

主要使用了redis的set集合的求交集功能

Set<String> intersect = stringRedisTemplate.opsForSet().intersect(key, key2);
  @Override
    public Result followCommons(Long id) {
        // 1.获取当前用户
        Long userId = UserHolder.getUser().getId();
        String key = "follows:" + userId;
        // 2.求交集
        String key2 = "follows:" + id;
        Set<String> intersect = stringRedisTemplate.opsForSet().intersect(key, key2);
        if (intersect == null || intersect.isEmpty()) {
            // 无交集
            return Result.ok(Collections.emptyList());
        }
        // 3.解析id集合
        List<Long> ids = intersect.stream().map(Long::valueOf).collect(Collectors.toList());
        // 4.查询用户
        List<UserDTO> users = userService.listByIds(ids)
                .stream()
                .map(user -> BeanUtil.copyProperties(user, UserDTO.class))
                .collect(Collectors.toList());
        return Result.ok(users);
    }

关注推送(feed流)

image-20221027231243083

feed流分类

image-20221027231251606

实现关注推送(SortedSet)

    @Override
    public Result saveBlog(Blog blog) {
        // 1.获取登录用户
        UserDTO user = UserHolder.getUser();
        blog.setUserId(user.getId());
        // 2.保存探店笔记
        boolean isSuccess = save(blog);
        if (!isSuccess) {
            return Result.fail("新增笔记失败!");
        }
        // 3.查询笔记作者的所有粉丝 select * from tb_follow where follow_user_id = ?
        List<Follow> follows = followService.query().eq("follow_user_id", user.getId()).list();
        // 4.推送笔记id给所有粉丝
        for (Follow follow : follows) {
            // 4.1.获取粉丝id
            Long userId = follow.getUserId();
            // 4.2.推送
            String key = FEED_KEY + userId;
            redisTemplate.opsForZSet().add(key, blog.getId().toString(), System.currentTimeMillis());
        }
        // 5.返回id
        return Result.ok(blog.getId());
    }

附近商铺(geo)

附近商铺搜索

需求:
  1. 前端传入商铺id,经纬度,后端计算范围内的商铺
  2. 采用分页返回
  3. 距离从近到远
实现:
  1. 使用redis的geo数据类型,存储商铺的经纬度,key设计,商铺类型作为key,商铺id作为value,商铺经纬度作为score存入

  2. 查询并返回,实现代码如下:

     @Override
        public Result queryShopByType(Integer typeId, Integer current, Double x, Double y) {
            // 1.判断是否需要根据坐标查询
            if (x == null || y == null) {
                // 不需要坐标查询,按数据库查询
                Page<Shop> page = query()
                        .eq("type_id", typeId)
                        .page(new Page<>(current, SystemConstants.DEFAULT_PAGE_SIZE));
                // 返回数据
                return Result.ok(page.getRecords());
            }
    
            // 2.计算分页参数
            int from = (current - 1) * SystemConstants.DEFAULT_PAGE_SIZE;
            int end = current * SystemConstants.DEFAULT_PAGE_SIZE;
    
            // 3.查询redis、按照距离排序、分页。结果:shopId、distance
            String key = SHOP_GEO_KEY + typeId;
            GeoResults<RedisGeoCommands.GeoLocation<String>> results = redisTemplate.opsForGeo() // GEOSEARCH key BYLONLAT x y BYRADIUS 10 WITHDISTANCE
                    .search(
                            key,
                            GeoReference.fromCoordinate(x, y),
                            new Distance(5000),
                            RedisGeoCommands.GeoSearchCommandArgs.newGeoSearchArgs().includeDistance().limit(end)
                    );
            // 4.解析出id
            if (results == null) {
                return Result.ok(Collections.emptyList());
            }
            List<GeoResult<RedisGeoCommands.GeoLocation<String>>> list = results.getContent();
            if (list.size() <= from) {
                // 没有下一页了,结束
                return Result.ok(Collections.emptyList());
            }
            // 4.1.截取 from ~ end的部分
            List<Long> ids = new ArrayList<>(list.size());
            Map<String, Distance> distanceMap = new HashMap<>(list.size());
            list.stream().skip(from).forEach(result -> {
                // 4.2.获取店铺id
                String shopIdStr = result.getContent().getName();
                ids.add(Long.valueOf(shopIdStr));
                // 4.3.获取距离
                Distance distance = result.getDistance();
                distanceMap.put(shopIdStr, distance);
            });
            // 5.根据id查询Shop
            String idStr = StrUtil.join(",", ids);
            List<Shop> shops = query().in("id", ids).last("ORDER BY FIELD(id," + idStr + ")").list();
            for (Shop shop : shops) {
                shop.setDistance(distanceMap.get(shop.getId().toString()).getValue());
            }
            // 6.返回
            return Result.ok(shops);
        }
    

    关键查询代码:

    GeoResults<RedisGeoCommands.GeoLocation<String>> results = redisTemplate.opsForGeo() // GEOSEARCH key BYLONLAT x y BYRADIUS 10 WITHDISTANCE
                    .search(
                            key,
                            GeoReference.fromCoordinate(x, y),
                            new Distance(5000),
                            RedisGeoCommands.GeoSearchCommandArgs.newGeoSearchArgs()
        //由近到远排序
        .sortAscending()
        //结果包含距离
        .includeDistance()
        //查询end条
        .limit(end)
                    );
    

    难点:逻辑分页

​ 由于redis返回的是0-end条的数据,因此需要手动处理,截取数据

 // 4.1.截取 from ~ end的部分
        List<Long> ids = new ArrayList<>(list.size());
        Map<String, Distance> distanceMap = new HashMap<>(list.size());
        list.stream().skip(from).forEach(result -> {
            // 4.2.获取店铺id
            String shopIdStr = result.getContent().getName();
            ids.add(Long.valueOf(shopIdStr));
            // 4.3.获取距离
            Distance distance = result.getDistance();
            distanceMap.put(shopIdStr, distance);
        });

用户签到(bitmap)

签到

分析

使用mysql,硬盘占用过大

image-20221027231308844

采用redis的bitmap结构

image-20221027231315220

image-20221027231320897

实现
   @Override
    public Result sign() {
        // 1.获取当前登录用户
        Long userId = UserHolder.getUser().getId();
        // 2.获取日期
        LocalDateTime now = LocalDateTime.now();
        // 3.拼接key
        String keySuffix = now.format(DateTimeFormatter.ofPattern(":yyyyMM"));
        String key = USER_SIGN_KEY + userId + keySuffix;
        // 4.获取今天是本月的第几天
        int dayOfMonth = now.getDayOfMonth();
        // 5.写入Redis SETBIT key offset 1
        redisTemplate.opsForValue().setBit(key, dayOfMonth - 1, true);
        return Result.ok();
    }

UV统计(hyperloglog)

image-20221027231327154

image-20221027231332484

  • 2
    点赞
  • 18
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

打赏作者

Acerola-

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

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

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

打赏作者

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

抵扣说明:

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

余额充值