《黑马点评》项目复盘 --功能实现(1)

一、短信登录

1. 项目架构

(1)架构介绍

      

        客户端经过nginx反向代理访问Tomcat服务器集群,Tomcat服务器与Redis集群和Mysql集群完成数据交互,Redis集群和Mysql集群存储数据。 

(2)Nginx

  •  nginx是一款轻量级的HTTP和反向代理web服务器。通过将前端发送的动态请求由nginx转发到后端服务器,启动nginx服务后,通过端口号80进行访问。
  •  nginx反向代理的好处:①提高访问速度;②进行负载均衡(本项目通过使用不同端口启动后端服务,实现服务器集群,通过nginx反向代理实现负载均衡);③保证后端服务安全。

(3)Tomcat服务器

  • Tomcat服务器是一个轻量化的应用服务器
  • Tomcat的功能组件: ①Connector:负责接收和响应请求。它是Tomcat与外界的交通枢纽,监听【Tomcat配置文件中指定的】端口(默认为8080)接收外界请求,并将请求处理后传递给容器做业务处理,最后将容器处理后的结果响应给外界。②Container:负责对内处理业务逻辑。其内部由 Engine、Host、Context和Wrapper 四个容器组成,用于管理和调用 Servlet 相关逻辑。③Service:对外提供的 Web 服务。主要包含 Connector 和 Container 两个核心组件,以及其他功能组件。Tomcat 可以管理多个 Service,且各 Service 之间相互独立。

(4)Nginx和Tomcat服务器的区别

 核心:①Tomcat服务器可以访问动态资源 (静态资源和动态资源的简单区别在于:静态资源是在不修改代码的前提下任何用户任何时间访问的内容是一样的,html,css,js等,动态资源需要做数据和逻辑处理)②Nginx可以做反向代理。

2. 基于Redis的Session登录实现

(1)相关概念

  •    Session&Cookie
  •  Token:Token是服务端生成的一串字符串,以作客户端进行请求的一个令牌,当第一次登录后,服务器生成一个Token便将此Token返回给客户端,以后客户端只需带上这个Token前来请求数据即可,无需再次带上用户名和密码。Token则需要客户端手动携带,用于识别用户。
  •  Socket:套接字通信机制,这种机制使客户/服务器之间基于网络协议的进程通信既可以在本地单机上进行,也可以跨网络进行。

(2)登录验证功能实现

      实现流程①用户在发起请求后,携带cookie,cookie中包含JSESSIONID,用于识别session,进而从Session中获取用户,判断用户是否存在判断是否登录。

②由于有一系列的Controller都需要登录验证操作,因此把该部分放到拦截器部分做。

实现步骤:

①创建拦截器类,重写preHandle和afterCompletion函数

②在preHandle验证该用户是否存在

(3)基于Redis实现共享Session登录

问题:在服务器集群下,Session无法实现共享,影响性能。
功能一、发送验证码

①验证手机号合理性②随机生成6位验证码③以String方式保存在Redis中,并设置过期时间

@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();
    }
功能二、登录功能
@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.保存用户信息到 redis中
        // 7.1.随机生成token,作为登录令牌
        String token = UUID.randomUUID().toString(true);
        // 7.2.将User对象转为HashMap存储
        UserDTO userDTO = BeanUtil.copyProperties(user, UserDTO.class);
        Map<String, Object> userMap = BeanUtil.beanToMap(userDTO, new HashMap<>(),
                CopyOptions.create()
                        .setIgnoreNullValue(true)
                        .setFieldValueEditor((fieldName, fieldValue) -> fieldValue.toString()));
        // 7.3.存储
        String tokenKey = LOGIN_USER_KEY + token;
        stringRedisTemplate.opsForHash().putAll(tokenKey, userMap);
        // 7.4.设置token有效期
        stringRedisTemplate.expire(tokenKey, LOGIN_USER_TTL, TimeUnit.MINUTES);

        // 8.返回token
        return Result.ok(token);
    }

核心代码分析:①通过UUID随机生成令牌。②将UserDTO转换为Map结构,属性名称作为key,具体的值作为value进行(并将他们转换位string形式)③token作为key,userMap作为Hash结构的值保存在Redis中。即通过Redis的HashMap保存UserDTO对象。④设置token有效期⑤返回token值,用于用户识别。

功能三、校验登录状态--拦截器实现

①创建拦截器1——检验登录并更新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();
    }
}

核心代码分析:①在Requset请求头中提取token②根据token,查询Redis中是否存在该用户③查询后将结果转换位UserDTO④将UsesDTO存入ThreadLocal(线程存储空间)⑤更新token有效期。

②创建拦截器2——实际检验用户是否登录,是否拦截

public class LoginInterceptor implements HandlerInterceptor {

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

核心代码分析:如果拦截器1一切顺利,会存入用户到ThreadLocal,如果未保存,表明用户未登录,拦截该请求。

③拦截器配置(WebMvcConfigurer)

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

核心代码分析:登录拦截器,配置所需拦截路径,配置拦截器执行顺序。

异常记录:创建拦截器类实现HandleInterceptor接口,重写方法时必须写@Override注解,否则拦截器不执行。

二、商户查询缓存

1. 缓存

2. 基于Redis缓存的商铺信息查询

(1)实现流程

(2)代码实现

@override
public Result queryById (Long id) {
    string key = "cache : shop:" + 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.不存在,根据id查询数据库
    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);
}

核心代码分析:①以id为key,查询Redis是否有该商铺信息②有:通过JSONUtil.toBean转换为Shop对象返回直接返回 ③没有查询数据库,获得Shop对象。④以shopId作为key,Shop对象转换成Json的字符串形式作为value存入Redis。⑤返回

3. 缓存更新策略——数据一致性问题

(1)缓存更新策略

①内存淘汰:Redis本身的内存淘汰机制②超时剔除:ttl ③主动更新:自己手写的数据库更新策略

(2)主动更新的问题

①读数据时,缓存未命中,查询数据库写入缓存,通过超时剔除作为缓存更新策略的兜底方案

②写数据时的问题:

  •    删除缓存中的数据还是更新数据?删除数据。删除数据可以在更新数据库时让缓存失效,等查询时再进行缓存更新,避免一些无效的读写操作。
  •   先操作数据库,再删除缓存还是先删除缓存,再操作数据库?先操作数据库,再删除缓存,因为对于后者:操作数据库的时间较长,在删除缓存,更新数据库未完成时,另一个线程来查询缓存发现数据未命中并查询数据库写入旧数据的可能性更高。前者:若某个数据恰好失效,删除缓存,这时候查询数据库写入的是旧数据,此时再更新数据库,再删除缓存,出现数据一致性问题。可能性小。
  • 怎么保证操作原子性:事务或者分布式中一致性的解决方案。

(3)店铺信息更新的代码实现(确保数据一致性)

 @Override
    @Transactional
    public Result update(Shop shop) {
        Long id = shop.getId();
        if (id == null) {
            return Result.fail("店铺id不能为空");
        }
        // 1.更新数据库
        updateById(shop);
        // 2.删除缓存
        stringRedisTemplate.delete(CACHE_SHOP_KEY + id);
        return Result.ok();
    }

4.缓存穿透、缓存雪崩、缓存击穿

(1)问题描述

       缓存穿透:当访问缓存和数据库中都不存在的数据时,Redis缓存未命中后会访问数据库进行查找,但当大量这样的请求同时访问时,会导致数据库崩溃,存在数据库安全的问题。(缓存空对象、布隆过滤器)

       缓存雪崩:同一时间段大量的缓存key同时失效或者Redis服务宕机,导致大量的请求到达数据库,给数据库带来很大的压力。(有效期+随机值)

       缓存击穿:对于热点key,被高并发访问并且重建业务叫复杂的key突然失效,无数的请求访问会在瞬间到达数据库,给数据库带来巨大的压力。(互斥锁、逻辑有效期)

(2)解决方案

        缓存穿透
  •  布隆过滤器:在Redis缓存之前添加布隆过滤器(一种通过Hash计算、比特位存储来判断数据是否存在的存储工具)数据预热时,预热布隆过滤器。当数据来时,首先在布隆过滤器中查找该数据是否存在,不存在,直接返回。内存占用少,存在误判误差。代码实现具体见博客:java实现布隆过滤器(手写和Guava库提供的)_布隆过滤器 java-CSDN博客
<dependency>
    <groupId>com.google.guava</groupId>
    <artifactId>guava</artifactId>
    <version>30.1-jre</version>
</dependency>
public static void main(String[] args) {
        Config config = new Config();
        config.useSingleServer().setAddress("redis://120.48.17.2");
        config.useSingleServer().setPassword("123456");
        //构造Redisson
        RedissonClient redisson = Redisson.create(config);
 
        RBloomFilter<String> bloomFilter = redisson.getBloomFilter("随便起个名");
        //初始化布隆过滤器:预计元素为100000000L,误差率为3%
        bloomFilter.tryInit(100000000L,0.03);
        //将号码10086插入到布隆过滤器中
        bloomFilter.add("10086");
 
        //判断下面号码是否在布隆过滤器中
        //输出false
        System.out.println(bloomFilter.contains("123456"));
        //输出true
        System.out.println(bloomFilter.contains("10086"));
    }
  • 缓存空对象:在Redis缓存未命中查询数据库时,若返回空值,则缓存空对象“”。在下次访问Redis缓存请求时,可以在缓存中查询到当前空值,并返回。操作简单,需要额外的key,占用内存。
public <R,ID> R queryWithPassThrough(
            String keyPrefix, ID id, Class<R> type, Function<ID, R> dbFallback, Long time, TimeUnit unit){
        String key = keyPrefix + id;
        // 1.从redis查询商铺缓存
        String json = stringRedisTemplate.opsForValue().get(key);
        // 2.判断是否存在
        if (StrUtil.isNotBlank(json)) {
            // 3.存在,直接返回
            return JSONUtil.toBean(json, type);
        }
        // 判断命中的是否是空值
        if (json != null) {
            // 返回一个错误信息
            return null;
        }

        // 4.不存在,根据id查询数据库
        R r = dbFallback.apply(id);
        // 5.不存在,返回错误
        if (r == null) {
            // 将空值写入redis
            stringRedisTemplate.opsForValue().set(key, "", CACHE_NULL_TTL, TimeUnit.MINUTES);
            // 返回错误信息
            return null;
        }
        // 6.存在,写入redis
        this.set(key, r, time, unit);
        return r;
    }
  • 其它需要注意的点:①增强id的复杂性,避免被猜测id规律(时间戳)②做好数据的基础格式校验③加强用户权限校验④做好热点参数的限流。
缓存雪崩
  • 给不同的Key的TTL添加随机值(Random),解决大量key同时过期的情况
  • 利用Redis集群提高服务的可用性(解决Redis宕机)
  • 给缓存业务添加降级限流策略
  • 给业务添加多级缓存
缓存击穿
  • 互斥锁:添加互斥锁,在查询缓存未命中时,获取互斥锁查询数据库重建缓存数据;其它线程阻塞(自旋),避免多个线程同时访问数据库出现的问题。
public <R, ID> R queryWithMutex(
            String keyPrefix, ID id, Class<R> type, Function<ID, R> dbFallback, Long time, TimeUnit unit) {
        String key = keyPrefix + id;
        // 1.从redis查询商铺缓存
        String shopJson = stringRedisTemplate.opsForValue().get(key);
        // 2.判断是否存在
        if (StrUtil.isNotBlank(shopJson)) {
            // 3.存在,直接返回
            return JSONUtil.toBean(shopJson, type);
        }
        // 判断命中的是否是空值
        if (shopJson != null) {
            // 返回一个错误信息
            return null;
        }

        // 4.实现缓存重建
        // 4.1.获取互斥锁
        String lockKey = LOCK_SHOP_KEY + id;
        R r = null;
        try {
            boolean isLock = tryLock(lockKey);
            // 4.2.判断是否获取成功
            if (!isLock) {
                // 4.3.获取锁失败,休眠并重试
                Thread.sleep(50);
                return queryWithMutex(keyPrefix, id, type, dbFallback, time, unit);
            }
            // 4.4.获取锁成功,根据id查询数据库
            r = dbFallback.apply(id);
            // 5.不存在,返回错误
            if (r == null) {
                // 将空值写入redis
                stringRedisTemplate.opsForValue().set(key, "", CACHE_NULL_TTL, TimeUnit.MINUTES);
                // 返回错误信息
                return null;
            }
            // 6.存在,写入redis
            this.set(key, r, time, unit);
        } catch (InterruptedException e) {
            throw new RuntimeException(e);
        }finally {
            // 7.释放锁
            unlock(lockKey);
        }
        // 8.返回
        return r;
    }

private boolean tryLock(String key) {
        Boolean flag = stringRedisTemplate.opsForValue().setIfAbsent(key, "1", 10,         TimeUnit.SECONDS);
        return BooleanUtil.isTrue(flag);
    }

private void unlock(String key) {
        stringRedisTemplate.delete(key);
    }

核心代码分析:①Redis查询数据是否命中,命中返回。②不存在,获取锁。③.1未成功,休眠后继续尝试获取锁。③.2 成功,进行数据库和缓存重建操作(顺便缓存空值解决缓存穿透),写入Redis缓存,并返回数据。④释放锁

  • 逻辑过期时间:①添加逻辑过期时间,实质上就是保存了一个数值,用RedisData类封装,转换为str后使用Redis的String保存②添加互斥锁,在发现逻辑过期时,获取互斥锁。③获取锁失败,先返回旧的数据④成功后开启一个独立的线程实现查询数据库和缓存重建的过程,需要添加逻辑过期时间。
private static final ExecutorService CACHE_REBUILD_EXECUTOR = Executors.newFixedThreadPool(10);

public void setWithLogicalExpire(String key, Object value, Long time, TimeUnit unit) {
        // 设置逻辑过期
        RedisData redisData = new RedisData();
        redisData.setData(value);
        redisData.setExpireTime(LocalDateTime.now().plusSeconds(unit.toSeconds(time)));
        // 写入Redis
        stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(redisData));
}
public <R, ID> R queryWithLogicalExpire(
            String keyPrefix, ID id, Class<R> type, Function<ID, R> dbFallback, Long time, TimeUnit unit) {
        String key = keyPrefix + id;
        // 1.从redis查询商铺缓存
        String json = stringRedisTemplate.opsForValue().get(key);
        // 2.判断是否存在
        if (StrUtil.isBlank(json)) {
            // 3.存在,直接返回
            return null;
        }
        // 4.命中,需要先把json反序列化为对象
        RedisData redisData = JSONUtil.toBean(json, RedisData.class);
        R r = JSONUtil.toBean((JSONObject) redisData.getData(), type);
        LocalDateTime expireTime = redisData.getExpireTime();
        // 5.判断是否过期
        if(expireTime.isAfter(LocalDateTime.now())) {
            // 5.1.未过期,直接返回店铺信息
            return r;
        }
        // 5.2.已过期,需要缓存重建
        // 6.缓存重建
        // 6.1.获取互斥锁
        String lockKey = LOCK_SHOP_KEY + id;
        boolean isLock = tryLock(lockKey);
        // 6.2.判断是否获取锁成功
        if (isLock){
            // 6.3.成功,开启独立线程,实现缓存重建
            CACHE_REBUILD_EXECUTOR.submit(() -> {
                try {
                    // 查询数据库
                    R newR = dbFallback.apply(id);
                    // 重建缓存
                    this.setWithLogicalExpire(key, newR, time, unit);
                } catch (Exception e) {
                    throw new RuntimeException(e);
                }finally {
                    // 释放锁
                    unlock(lockKey);
                }
            });
        }
        // 6.4.返回过期的商铺信息
        return r;
}

  • 24
    点赞
  • 22
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
### 回答1: 2019年黑马项目-畅购商城springcloud微服务实战是一门以实战为主的课程,旨在通过项目实践的方式,帮助学员深入理解和掌握SpringCloud微服务架构以及相关技术的应用。 课程的主要内容包括搭建基础的微服务架构、使用SpringCloud构建服务注册与发现、实现服务间的负载均衡、实现分布式配置中心、服务间的调用与容错处理、使用网关统一接入服务等。通过这些实战练习,学员不仅能够熟悉SpringCloud架构与组件,还能够了解微服务架构下的常见问题与解决方案。 畅购商城项目是一个典型的电商应用,通过实现项目,学员可以接触到真实的业务场景与需求,并能够将所学知识应用到实际项目中。课程中通过模块化的方式逐步完善商城的功能,包括用户注册登录、商品浏览、购物车管理、订单生成与支付等。通过这些实践,学员除了掌握SpringCloud微服务的开发技术,还能够了解和掌握电商项目的开发流程和注意事项。 该课程的目标是让学员通过实战项目,全面了解和掌握SpringCloud微服务架构的设计与开发,在此基础上能够独立完成具有较高要求的微服务项目。通过参与实战项目的过程,学员还能够提升团队协作能力、解决问题的能力以及项目管理能力。 通过这门课程的学习,学员将会对SpringCloud微服务架构有更深入的理解,并能够将这些知识应用到实际项目中,提高自己在微服务开发领域的竞争力。 ### 回答2: 2019年黑马项目-畅购商城springcloud微服务实战是一个基于springcloud微服务架构的商城项目。该项目的目标是通过运用微服务的理念和技术,构建一个高可用、可扩展的商城系统。 在该项目中,使用了springcloud的多个组件,如Eureka注册中心、Feign负载均衡、Ribbon客户端负载均衡、Hystrix服务降级和容错、Zuul网关等。这些组件共同协作,实现了系统的弹性伸缩和高可用性。 畅购商城的功能包括商品展示、购物车、订单管理、支付、用户管理等。通过将这些功能拆分成独立的微服务,使得系统更加灵活和可维护。同时,使用分布式事务和消息队列来保障数据的一致性和可靠性。 在项目的开发过程中,采用了敏捷开发的方法,以迭代的方式进行开发和测试。通过使用Jenkins进行持续集成和部署,保证了代码的质量和系统的稳定性。 在项目的实战过程中,面临了许多挑战和困难,如微服务之间的通信、服务的负载均衡、服务的容错等。但通过团队的共同努力和不断的学习,最终成功地完成了该项目的开发和部署。 在该项目的实施过程中,不仅学到了springcloud微服务架构的相关知识和技术,还体会到了团队合作和解决问题的能力。该项目的成功实施,不仅为公司带来了商业价值,也提升了团队的技术水平和项目管理能力。 ### 回答3: 2019年黑马项目-畅购商城springcloud微服务实战是一个以Spring Cloud为基础的微服务项目。微服务架构是一种将应用拆分成多个小型服务的架构模式,这些服务可以独立开发、部署、扩展和管理。 畅购商城项目使用了Spring Cloud的一系列子项目,如Eureka、Ribbon、Feign、Hystrix、Zuul等,来实现各个微服务之间的通信、负载均衡、服务降级与熔断等功能。 在项目中,我们会通过Eureka来实现服务的注册与发现,每个微服务都会向Eureka注册自己的地址,其他微服务可以通过Eureka来发现并调用这些服务。而Ribbon则负责实现客户端的负载均衡,可以轮询、随机、加权等方式分发请求。 Feign是一种声明式的HTTP客户端,它简化了服务间的调用方式。我们只需编写接口,并通过注解来描述需要调用的服务和方法,Feign会自动实现远程调用。 Hystrix是一个容错机制的实现,可以通过断路器来实现服务的降级与熔断,当某个服务出现故障或超时时,Hystrix会快速响应并返回一个可控制的结果,从而保证系统的稳定性。 另外,Zuul作为微服务网关,可以实现请求的统一入口和路由转发,提高系统的安全性和性能。 通过这些Spring Cloud的组件,畅购商城项目可以实现高可用、容错、自动扩展等优质的微服务架构。 总之,2019年黑马项目-畅购商城springcloud微服务实战是一个基于Spring Cloud的微服务项目,通过使用Spring Cloud的各个子项目,可以实现微服务之间的通信、负载均衡、服务降级与熔断等功能,为项目的开发、部署和管理提供了便利。

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值