SpringSecurity+GateWay网关+OAuth2鉴权,前后端分离模式,两种验证模式,入门级教程

说明

SpringSecurityOAuth2单点登录

昨天我发了一个单点登录版本的验证博客,到今天早上我再研究了一下,发现了一些问题:

  1. 昨天那个单点登录是在每个模块的基础上做的,也就是说如果你想让每个模块都如认证中心认证,就要在每个模块里进行相关配置,这还不是最紧要的,你要想想,因为我们是通过注解的方式在对应的方法鉴权,这样的话就会导致我们每次访问这个方法的时候就要去认证中心请求一次,也就是鉴权一次,那么整个系统模块又多,路径又多,认证中心肯定是吃不消的啊.
  2. 所以在这个基础上,就需要去将认证中心在第一次认证产生的token,交给前端,然后在GateWay里进行一个token的验证,这样子就避免我们每一次都要去认证中心里认证了,效率很高.

所以,下面就是介绍与GateWay结合的方式去认证.

基于SpringSecurity+GateWay

认证中心

这里我为什么没提OAuth,因为没有OAuth也是可以进行鉴权的,但是这种方式你必须使用一个中间件,去保存需要鉴权的路径,哪些角色可以访问哪些路径你必须保存下来,因为我们在单个微服务上是没有鉴权操作了的,而是在GateWay网关里鉴权,所以无法在每个方法上加上注解的方式去鉴权,就必须得保存每个路径是哪个角色可以访问的.

所以这里就必须由一个基本的关系,做这个之前必须得清楚:
在这里插入图片描述
一个用户可以由多个角色,一个角色又可以有多个权限,所以这个用户包含其所拥有角色的所有权限.

怎么给用户赋予角色,给角色赋予权限,是数据库的事情了,假如这里已经有一些数据,反正你就记得是这个结构就行.

那么下面的鉴权流程可以是
前端有一个请求,直接打到了认证中心,这个时候,先来到AbstractAuthenticationProcessingFilter中的doFilter方法进行请求过滤,通常可以实现自定义过滤器,过滤成功,尝试鉴权attemptAuthentication,这个方法是由其子类UsernamePasswordAuthenticationFilter实现的,通常可以实现自定义鉴权,然后来到UserDetailsService的loadUserByUsername方法,这个方法经常用来做自定义登录逻辑,授权成功后执行successfulAuthentication方法,这个方法在doFilter方法里被调用,底层会调用核心方法onAuthenticationSuccess,该方法用来实现自定义授权成功逻辑,相反,就有一个自定义授权失败逻辑AuthenticationFailureHandler接口中的onAuthenticationFailure方法

这里多提一嘴,如果是要自定义鉴权逻辑去代替系统的逻辑,一般都是通过自定义才能实现多彩的鉴权方案,至于有些人为什么自定义鉴权逻辑没有生效,那是因为你没有在配置方法configure(HttpSecurity http)中加入这些个逻辑.

下面开始上代码了,先直接上代码,继续往下我会给出相关代码的解释

  1. 我没有实现自定义过滤器和鉴权器,还是用原来的,而是直接从自定义登录逻辑开始:
@Component
public class SheepUserDetailsService implements UserDetailsService {

    @Autowired
    private PasswordEncoder passwordEncoder;

    @Override
    public UserDetails loadUserByUsername(String s) throws UsernameNotFoundException {
        System.out.println("函数进来了");
        if( !"admin".equals(s) )
            throw new UsernameNotFoundException("用户" + s + "不存在" );
        //TODO
        //根据用户名查询对应角色,然后根据角色查询对应所能访问路径
        //将这些路径包装成集合传过去
        //获取你直接查询到了角色,先将角色传过去,我这里是先将角色传过去
        int id=0;
        return new User( s+"-"+id, passwordEncoder.encode("123456"), AuthorityUtils.commaSeparatedStringToAuthorityList("ROLE_NORMAL,ROLE_MEDIUM"));
    }
}
  1. 这个时候就会鉴权,然后调用成功处理的方法:
    @Override
    public void onAuthenticationSuccess(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse, Authentication authentication) throws IOException, ServletException {
        User principal = (User) authentication.getPrincipal();
        String username = principal.getUsername();
        String[] users = username.split("-");
        // TODO 得到该用户全部角色
        //Collection<GrantedAuthority> authorities = principal.getAuthorities();

        //TODO 查询数据库得到该角色所能访问的全部路径
        //模拟:
        String[] s1=new String[]{"/login","/register","/serviceedu/front/listTeacher"};

        //将权限路径封装到redis中

        redisUtils.setCollectionSet(users[0],s1,24, TimeUnit.HOURS);
        String s= redisUtils.get("fromUrl");
        if (s==null)
            s="/";
        String jwtToken = JwtUtils.getJwtToken(users[1], users[0]);
        Msg msg = Msg.success().data("username", users[0]).data("fromUrl",s).data("token",jwtToken);
        redisUtils.del("fromUrl");
        httpServletResponse.setContentType("text/json;charset=utf-8");
        //塞到HttpServletResponse中返回给前台
        httpServletResponse.getWriter().write(JSON.toJSONString(msg));

    }
}
  1. 这里也给出授权失败的自定义逻辑:
@Component
public class FailHandler implements AuthenticationFailureHandler {

    @Override
    public void onAuthenticationFailure(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse, AuthenticationException e) throws IOException, ServletException {
        Msg fail = Msg.fail().data("message", "登录失败或权限不足");
        httpServletResponse.setContentType("text/json;charset=utf-8");
        //塞到HttpServletResponse中返回给前台
        //这种方式很常用,后面的代码都有很多,唯一能反馈给前端json格式的消息
        httpServletResponse.getWriter().write(JSON.toJSONString(fail));
    }
}
  1. 这里也给出自定义登录的逻辑,这个逻辑很简单,就是将cookie删除就可以了,token可以删除也可以不删除
@Component
public class CustomLogoutSuccessHandler extends AbstractAuthenticationTargetUrlRequestHandler implements LogoutSuccessHandler {
    @Override
    public void onLogoutSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException {
        // 将子系统的cookie删掉
        //建议将token也删除,直接写个controller接口就可以了,可以在前端调用/logout的同时调用删除token接口
        Cookie[] cookies = request.getCookies();
        if(cookies != null && cookies.length>0){
            for (Cookie cookie : cookies){
                cookie.setMaxAge(0);
                cookie.setPath("/");
                response.addCookie(cookie);
            }
        }
        super.handle(request, response, authentication);
    }
}
  1. 那么最关键的一步来了,记得将这些自定义登录逻辑代码加上配置中:
@Configuration
public class SpringSecurityConfig extends WebSecurityConfigurerAdapter {

    @Autowired
    private UserDetailsService userDetailsService;

    @Bean
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }

    @Autowired
    SuccessHandler successHandler;

    @Autowired
    FailHandler failHandler;

    @Autowired
    private CustomLogoutSuccessHandler customLogoutSuccessHandler;

    //@Autowired
    //CustomizeAuthenticationEntryPoint customizeAuthenticationEntryPoint;

//    @Autowired
//    MyUsernamePasswordAuthenticationFilter myUsernamePasswordAuthenticationFilter;

    @Bean
    public DaoAuthenticationProvider authenticationProvider() {
        DaoAuthenticationProvider authenticationProvider = new DaoAuthenticationProvider();
        authenticationProvider.setUserDetailsService(userDetailsService);
        authenticationProvider.setPasswordEncoder(passwordEncoder());
        authenticationProvider.setHideUserNotFoundExceptions(false);
        return authenticationProvider;
    }

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http.formLogin()
//                .usernameParameter("mobile")
//                .passwordParameter("password")
				//自定义登录页面,这个页面是一个controller路径
				//我们只需要在对应的controller中重定向到你前端的页面就可以了
                .loginPage("/unLogin")
                //登录处理逻辑路径,/login代表用系统的处理逻辑
                //但是我们重写了用户逻辑,所以会走到重写的用户逻辑里
                .loginProcessingUrl("/login")
                //自定义鉴权成功处理
                .successHandler(successHandler)
                //自定义鉴权失败处理
                .failureHandler(failHandler)
                .permitAll()
                .and()
                .logout()
                //自定义登录成功处理
                .logoutSuccessHandler(customLogoutSuccessHandler)
                // 无效会话
                .invalidateHttpSession(true)
                // 清除身份验证
                .clearAuthentication(true)
                .and().csrf().disable();
                //异常处理(权限拒绝、登录失效等)
//                .exceptionHandling()
//                .authenticationEntryPoint(customizeAuthenticationEntryPoint);
        http.authorizeRequests()
                .antMatchers(
                        "/oauth/**",
                        "/login/**",
                        "/unLogin",
                        "/logout/**",
                        "/uac/oauth/token",
                        "http://localhost:3000/login",
                        "http://localhost:8085/uac/login"

                ).permitAll().anyRequest().authenticated();

//将@bean注入的鉴权器加入到配置当中
        //http.addFilterAt(myAuthenticationFilter(), UsernamePasswordAuthenticationFilter.class);

    }
//如果自定义鉴权器,也要做相关配置,不然不走你的鉴权器
//    @Bean
//    MyUsernamePasswordAuthenticationFilter myAuthenticationFilter() throws Exception {
//        MyUsernamePasswordAuthenticationFilter filter = new MyUsernamePasswordAuthenticationFilter();
//        filter.setAuthenticationManager(authenticationManagerBean());
//        filter.setAuthenticationSuccessHandler(successHandler);
//        filter.setAuthenticationFailureHandler(failHandler);
//        return filter;
//    }


    @Override
    protected void configure(AuthenticationManagerBuilder auth) throws Exception {
        auth.authenticationProvider(authenticationProvider());
    }

}

认证中心这样的就完成了,有人会问,怎么没有OAtuh的配置?注意我这里是用SpringSecurity+GateWay方式去认证,用不到OAuth.

这也是为什么你会看到我用了一个redis去保存用户跟权限这两者的关系,我这里为什么不保存角色,因为没那个必要,虽然一个用户有多个角色,但是这些角色下的权限都属于用户的,而前端传过来的是路径,所以这里我们只是存储用户跟路径权限之间的关系,到时候就可以直接判断该用户是否可以访问该路径了.

那么还有一个小问题就是,你会发现我并没有利用认证中心底层给我生成的token,这个token很难利用上,要利用的话估计要实现自定义过滤器,反正我拿不到.
但是我想到了一个绝妙且笨的方法,就是在鉴权成功的自定义处理方法中,可以直接用RestTemplate发送一个/oauth/token的请求,这样子不就直接拿到token值了吗?然后将其反馈给前端,也不用我们自己去生成这个token

GateWay网关层面

  1. GateWay网关这个层面,因为我们用redis保存了权限和用户的映射表,所以只要在全局过滤器里判断就可以了.
  2. 第一次我们token为空,说明没有登录,这个时候就需要提醒用户去登录页面进行登录了,登录成功后,认证中心反馈一个url给前端,让前端去跳转对应的路径,这个路径一般是我们在进入登录页面前的页面.注意,如果你使用了前端某个页面作为登录页面,一定要在认证中心里的loginPage方法指定前端登录页面,不然你前端的登录是无效的,这个时候你前端的登录表单提交按钮,可以直接请求到认证中心的/login路径,然后认证中心才会承认你前端登录页面提交的数据有效
    这下子前面就达到了前后端分离的效果.
  3. 前面是token为空的情况,如果token不为空,我们为了提升效率,就不能放行让每个微服务单独的鉴权,而是在网关层面鉴权,这里的鉴权很简单,直接用前端传来的token去验证其合法性,合法了就解析其用户名,然后将用户名去redis去查询对应的权限.
@Component("a")
@Order(2)
 //设置执行优先级,在 全局权限认证过滤器 之前执行
public class AuthenticationFilter implements GlobalFilter, InitializingBean {

    @Autowired
    private RestTemplate restTemplate;

    @Autowired
    RedisUtils redisUtils;

    private static Set<String> shouldSkipUrl = new LinkedHashSet<>();

    @Override
    public void afterPropertiesSet() throws Exception {
        // 在类被初始化完成时,把不拦截认证的请求放入集合
        shouldSkipUrl.add("/uac");
        shouldSkipUrl.add("/serviceedu/front/listTeacher");

    }

    @SneakyThrows
    @Override
    public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {

        //获取request请求

        String requestPath = exchange.getRequest().getURI().getPath();

        //如果请求url不需要认证,直接跳过
        if(shouldSkip(requestPath)) {
            return chain.filter(exchange);
        }

        //获取Authorization请求头
        String token = exchange.getRequest().getHeaders().getFirst("token");
        //Authorization请求头为空,抛异常
        if(StringUtils.isEmpty(token)) {
            return out(exchange.getResponse());
        }

        redisUtils.set("fromUrl",requestPath,10, TimeUnit.MINUTES);

        if (!JwtUtils.checkToken(token)){
            return out(exchange.getResponse());
        }

        Claims memberClaims = JwtUtils.getMemberClaims(token);
        if (memberClaims==null)
            return out(exchange.getResponse());
        String nickname = (String) memberClaims.get("nickname");

        Set<String> set = redisUtils.getSet(nickname);

        if (!hasPermisson(set,requestPath)){
            exchange.getResponse().setStatusCode(HttpStatus.NON_AUTHORITATIVE_INFORMATION);
            return exchange.getResponse().setComplete();
        }
        return chain.filter(exchange);
    }

    private Mono<Void> out(ServerHttpResponse response) {
        JsonObject message = new JsonObject();
        message.addProperty("success", false);
        message.addProperty("code", 20001);
        message.addProperty("data", "鉴权失败");
        byte[] bits = message.toString().getBytes(StandardCharsets.UTF_8);
        DataBuffer buffer = response.bufferFactory().wrap(bits);
        //response.setStatusCode(HttpStatus.UNAUTHORIZED);
        //指定编码,否则在浏览器中会中文乱码
        response.getHeaders().add("Content-Type", "application/json;charset=UTF-8");
        return response.writeWith(Mono.just(buffer));
    }


    private boolean hasPermisson(Set<String> set,String path){
        for (String s :
                set) {
            if (path.contains(s))
                return true;
        }
        return false;
    }


    private boolean shouldSkip(String reqPath) {

        for(String skipPath:shouldSkipUrl) {
            if(reqPath.contains(skipPath)) {
                return true;
            }
        }
        return false;
    }
}

这样子,验证失败了,我们也能传给前端一个json格式的数据,让前端做出反应,验证成功后,就可以放心大胆的去让用户浏览该路径了,效率很高,因为只有第一次需要请求认证中心鉴权,往后直接拿token解析取出redis中的映射路径去鉴权.

其实这里我有个问题的: 就是如果在GateWay网关鉴权,那有用户绕过了网关去访问某个微服务呢?这又该怎么办,那岂不是所有的权限都暴露出来,但是我又想到了其实绕过去极为困难.

  1. 先不说有专门的安全人员去维护
  2. 其次是只有在网关做了跨越处理,而其他微服务是没有跨域的,所以理论上是访问不了了,请求必须经过网关.

但我们不得不承认的事情是,凡事都有两面性,你在网关鉴权了,效率上是高,但是加重了网关的负担,提高了单点故障的风险,如果网关挂了怎么办?通常只能搞成网关"集群"了.

SpringSecurity+GateWay+OAtuh2

这个其实可以省去redis,就是利用RestTemplate来进行鉴权,也是在网关处,加快性能:

@Component("a")
@Order(2)
 //设置执行优先级,在 全局权限认证过滤器 之前执行
public class AuthenticationFilter implements GlobalFilter, InitializingBean {

    @Autowired
    private RestTemplate restTemplate;

    @Autowired
    RedisUtils redisUtils;

    private static Set<String> shouldSkipUrl = new LinkedHashSet<>();

    @Override
    public void afterPropertiesSet() throws Exception {
        // 在类被初始化完成时,把不拦截认证的请求放入集合
        shouldSkipUrl.add("/uac");
        shouldSkipUrl.add("/serviceedu/front/listTeacher");

    }

    @SneakyThrows
    @Override
    public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {

        //获取request请求

        String requestPath = exchange.getRequest().getURI().getPath();

        //如果请求url不需要认证,直接跳过
        if(shouldSkip(requestPath)) {
            return chain.filter(exchange);
        }

        //获取Authorization请求头
        String token = exchange.getRequest().getHeaders().getFirst("token");
        //Authorization请求头为空,抛异常
        if(StringUtils.isEmpty(token)) {
            return out(exchange.getResponse());
        }

       TokenInfo tokenInfo=null;
        try {
            //往授权服务发http请求 /oauth/check_token 并封装返回结果!
            tokenInfo = getTokenInfo(authHeader);
            
        }catch (Exception e) {
            throw new RuntimeException("校验令牌异常");
        }
        if(!hasPremisson(tokenInfor,requestPath){
			return out(exchange.getResponse());
		}
        return chain.filter(exchange);
    }

    private Mono<Void> out(ServerHttpResponse response) {
        JsonObject message = new JsonObject();
        message.addProperty("success", false);
        message.addProperty("code", 20001);
        message.addProperty("data", "鉴权失败");
        byte[] bits = message.toString().getBytes(StandardCharsets.UTF_8);
        DataBuffer buffer = response.bufferFactory().wrap(bits);
        //response.setStatusCode(HttpStatus.UNAUTHORIZED);
        //指定编码,否则在浏览器中会中文乱码
        response.getHeaders().add("Content-Type", "application/json;charset=UTF-8");
        return response.writeWith(Mono.just(buffer));
    }
    private boolean hasPremisson(TokenInfo tokenInfo,String currentUrl) {
        boolean hasPremisson = false;
        //登录用户所拥有的请求url权限集合
        List<String> premessionList = Arrays.asList(tokenInfo.getAuthorities());
        //与当前请求url,看是否有对应的访问权限
        for (String url: premessionList) {
            if(currentUrl.contains(url)) {
                hasPremisson = true;
                break;
            }
        }
        //如果没有,抛异常
        if(!hasPremisson){
            throw new RuntimeException("没有权限");
        }
        return hasPremisson;
    }


    private boolean shouldSkip(String reqPath) {

        for(String skipPath:shouldSkipUrl) {
            if(reqPath.contains(skipPath)) {
                return true;
            }
        }
        return false;
    }

    private TokenInfo getTokenInfo(String authHeader) {
        // 往授权服务发请求 /oauth/check_token
        // 获取token的值
        String token = StringUtils.substringAfter(authHeader, "bearer ");

        //组装请求头
        HttpHeaders headers = new HttpHeaders();
        headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED);
        //必须设置 basicAuth为对应的 clienId、 clientSecret
        headers.setBasicAuth("admin", "123456");

        MultiValueMap<String, String> params = new LinkedMultiValueMap<>();
        params.add("token", token);

        HttpEntity<MultiValueMap<String, String>> entity = new HttpEntity<>(params, headers);

        //往授权服务发http请求 /oauth/check_token
        ResponseEntity<TokenInfo> response = restTemplate.exchange("http://localhost:8085/uac/oauth/check_token", HttpMethod.POST, entity, TokenInfo.class);
        //获取响应结果 TokenInfo
        return response.getBody();
    }
}

网上的鉴权方式差不多都是这个方式,直接发送RestTemplate风格的请求去验证token是否正确,然后返回一个权限列表,但在这里需要注意的一点是:

你这个token必须从前端过来是认证中心生成的那个,因为你要发送check_token请求是去认证中心底层/oauth/check_token路径验证的,那它肯定是拿自己生成那个token验证,而且你必须将这个token反馈给前端,就需要知道如何在认证中心的认证成功方法里将底层的token反馈给前端,这样这边网关层才能发送过来,如果你需要验证自己生成的token,可以在认证中心自定义/oauth/token,所以这个方法很麻烦.

其实这里我提出一个疑惑,我在网上看的时候,有人用这种方法重写了两个全局过滤器,有些蒙蔽,为啥要这样做,不是多余了吗?他是将两个一样的过滤器分不同前后加载到ioc容器中,然后在前一个过滤器采用setAttribute的方式放在里面,之后在后面的过滤器里取出来,???各位网友可以说说,是不是多余了,用了两个一模一样的过滤器,直接在第一个过滤器里查出来后验证不就行了吗?

上面的GateWay网关中,还可以这样优化:
因为你从验证中心发送请求获取数据的,所以你必须得每次都请求一次,这样子效率不太好,所以你可以将第一次请求的数据缓存下来,网上的方式都是放在请求头里,一般不要这样做,因为请求头我完全可以在前端制造,这样子就危险了,而是放在redis里,在请求之前先查缓存,如果没有缓存再请求,有缓存就直接拿出缓存鉴权.这样子是不是发现又回到了第一个方式的鉴权?所以我没有使用第二种方式.

注意点

  1. 使用第一种方式的时候,可以不加上OAtuh2的配置,加上了也不起效果,但是第二种就必须得加上了OAuth2这个配置了,因为你要请求/oauth/checkt_token,想要配置的可以直接去我昨天的博文里看看,链接放在开头了.
  2. 有些简单的代码我是没有加上去的,你要认真看完,不然复制代码是肯定不行的.
  3. 我的认证中心代码地址: https://gitee.com/hyb182/OAuth2
  • 7
    点赞
  • 65
    收藏
    觉得还不错? 一键收藏
  • 4
    评论
### 回答1: 前后端分离是一种将前端界面与后端逻辑进行分离开发的架构方式,使得前端与后端可以并行开发。OAuth 2.0是一种授权框架,用于授权和认证流程的规范化,而Spring Security是一个在Java中实现安全控制的框架,提供了大量的安全特性。Spring Authorization Server是Spring Security中用于实现授权服务器的模块,它支持OAuth 2.0的各种授权模式。 密模式OAuth 2.0中的一种授权模式,它允许用户通过提交用户名和密来获取访问令牌,然后使用该令牌来访问受保护的资源。在前后端分离的架构中,可以使用Spring Security配合Spring Authorization Server来实现密模式的认证和授权。 在密模式下,前端首先需要收集用户的用户名和密,并将其发送给后端。后端使用Spring Security提供的密器对密进行加密,并验证用户名和密的正确性。如果验证通过,则后端向客户端颁发一个访问令牌,通常是一个JWT(JSON Web Token)。前端使用获得的访问令牌来访问需要受保护的资源,每次请求将该令牌作为Authorization头的Bearer字段发送给后端进行验证。后端可以使用Spring Security的资源服务器来验证该令牌的有效性,并根据用户的权限控制对资源的访问。 使用Spring SecuritySpring Authorization Server的密模式可以实现安全的前后端分离架构。通过合理配置和使用安全特性,可以保障用户的身份认证和资源的授权,确保系统的安全性。 ### 回答2: 前后端分离是一种软件架构模式,前端和后端通过使用API进行通信,分别负责处理用户界面和数据逻辑。OAuth 2.0是一种用于授权的开放标准协议,它允许用户在第三方应用程序中授权访问其受保护的资源。Spring SecuritySpring框架中的一个模块,提供了身份验证和授权功能。 在前后端分离的架构中,前端应用程序通常需要使用OAuth 2.0协议进行用户授权,以访问后端应用程序的受保护资源。为了实现密模式,我们可以使用Spring Security的模块之一,即spring-authorization-server。 spring-authorization-server是Spring Security的一个子模块,用于实现OAuth 2.0协议中的授权服务器。密模式OAuth 2.0协议中的一种授权模式,允许前端应用程序通过用户的用户名和密进行授权。密模式在安全性上有一定的风险,因此在实际应用中需要谨慎使用。 使用spring-authorization-server的密模式,我们可以在前端应用程序中收集用户的用户名和密,并将其提交给后端应用程序进行验证。后端应用程序将使用Spring Security进行身份验证,并向前端应用程序颁发一个访问令牌,该令牌可以用于后续的API请求。 通过使用前后端分离OAuth 2.0和spring-authorization-server的密模式,我们可以实现安全的用户授权和身份验证机制,确保只有经过授权的用户才能访问受保护的资源。这种架构模式能够提高系统的安全性和可扩展性,适用于各种类型的应用程序。

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值