【编程不良人】SpringSecurity实战学习笔记06---跨域、异常处理

1. 跨域

配套视频:57 CORS 跨域简介_哔哩哔哩_bilibili

  • Spring 处理方案

  • Spring Security 处理方案

1.1 简介

       跨域问题是实际应用开发中一个非常常见的需求,在Spring 框架中对于跨域问题的处理方案有好几种,在引入Spring Security之后,跨域问题的处理方案又增加了。

1.2 什么是CORS?

        CORS (Cross-Origin Resource Sharing 或称 同源策略、同源共享)是由 W3C制定的一种跨域资源共享技术标准,其目的就是为了解决前端的跨域请求。在JavaEE开发中,最常见的前端跨域请求解决方案是早期的JSONP,但是JSONP只支持GET请求,这是一个很大的缺陷;而CORS则支特多种HTTP请求方法,也是目前主流的跨域解决方案

        CORS中新增了一组HTTP请求头字段,通过这些字段,服务器告诉浏览器,哪些网站通过浏览器有权限去访问哪些资源。同时规定,对那些可能修改服务器数据的HTTP请求方法 (如GET以外的HTTP 请求等),浏览器必须首先使用 OPTIONS 方法发起一个预检请求(prenightst),预检请求的目的是查看服务端是否支持即将发起的跨域请求,如果服务端允许,才会发送实际的HTTP请求(2次请求:OPTION预检/预警请求+实际/真实请求)。在预检请求的返回中,服务器端也可以通知客户端,是否需要携带身份凭证(如Cookies、HTTP认证信息等)。

CORS : 同源/同域 = 协议 + 主机 + 端口

1.2.1 简单请求

这里以GET请求为例,如果需要发起一个跨域请求,则请求头如下:

 Host: localhost:8080
 Origin: http://localhost:8081
 Referer:http://localhost:8081/index.html

如果服务端支持该跨域请求,那么返回的响应头中将包含如下字段:

 Access-Control-Allow-Origin:http://localhost: 8081

        Access-Control-Allow-Origin字段:用来告诉浏览器可以访问该资源的域,当浏览器收到这样的响应头信息之后,提取出Access-Control-Allow-Origin字段中的值,发现该值包含当前页面所在的域,就知道这个跨域是被允许的,因此就不再对前端的跨域请求进行限制。这属于简单请求,即不需要进行预检请求的跨域(GET请求)。

1.2.2 非简单请求

对于一些非简单请求,会首先发送一个预检请求。预检请求类似下面这样:

 OPTIONS /put HTTP/1.1
 Host: localhost:8080
 Connection: keep-alive
 Accept: */*
 Access-Control-Request-Method:PUT
 Origin: http://localhost: 8081
 Referer:http://localhost:8081/index.html

        请求方法是OPTIONS,请求头Origin就告诉服务端当前页面所在域,请求头 Access-Control-Request-Methods 告诉服务器端即将发起的跨域请求所使用的万法。服务端对此进行判断,如果允许即将发起的跨域请求,则会给出如下响应:

 HTTP/1.1 200
 Access-Control-Allow-Origin:http://localhost: 8081
 Access-Control-Request-Methods: PUT
 Access-Control-Max-Age: 3600 # 周期 秒/s

        Access-Control-Allow-Metbods 字段表示允许的跨域方法;

        Access-Control-Max-Age 字段表示预检请求的有效期,单位为秒,在有效期内如果发起该跨域请求,则不用再次发起预检请求。

        预检请求结朿后,接下来就会发起一个真正的跨域请求,跨域请求和前面的简单请求跨域步骤类似。

1.3 Spring框架中跨域的3种解决方案

配套视频:58.Spring 跨域解决方案_哔哩哔哩_bilibili

1.3.1使用@CrossOrigin注解--- 解决局部跨域问题

        Spring中第一种处理跨域的方式是通过@CrossOrigin注解来标记支持跨域,该注解可以添加在方法上,也可以添加在Controller上。当添加在Controller上时,表示Controller类中的所有接口都支持跨域,具体配置如下:

 @RestController
 public Class HelloController{
     @CrossOrigin (origins ="http://localhost:8081")
     @PostMapping ("/post")
     public String post (){
         return "hello post";
     }
 }

@CrossOrigin 注解各属性含义如下:

  • alowCredentials:浏览器是否应当发送凭证信息,如 Cookie。

  • allowedHeaders: 请求被允许的请求头字段,*表示所有字段。

  • exposedHeaders:哪些响应头可以作为响应的一部分暴露出来。

    注意:这里只可以一一列举,通配符 * 在这里是无效的。

  • maxAge:预检请求的有效期,有效期内不必再次发送预检请求,默认是1800秒。

  • methods:允许的请求方法,* 表示允许所有方法。

  • origins:允许的域,*表示允许所有域。一般采用默认值。

缺点:每个Controller上都需要添加此注解,不能起到全局配置作用。

1.3.2 重写addCrosMapping方法

        @CrossOrigin注解需要添加在不同的Controller上,不能进行全局使用,为此SpringMVC提供了一种全局配置方法,就是通过重写 WebMvcConfigurerComposite#addCorsMappings方法来实现,具体配置如下:

 Configuration
 public class WebMvcConfig implements WebMvcConfigurer{
   Override
   public void addCorsMappings (CorsRegistry registry){
     registry.addMapping("/**") //处理的请求地址
     .allowedMethods ("*")
     •allowedorigins("*")
     .allowedHeaders ("*")
     .allowCredentials (false)
     •exposedHeaders ("")
     .maxAge (3600) ;
   }
 }

1.3.3 使用CrosFilter过滤器---解决全局跨域问题

        CosrFilter 是Spring Web 中提供的一个处理跨域的过滤器,开发者也可以通过该过该过滤器处理跨域,具体方案如下:

 @Configuration
 public class WebMvcConfig {
     @Bean
     FilterRegistrationBean<CorsFilter> corsFilter() {
         FilterRegistrationBean<CorsFilter> registrationBean = new FilterRegistrationBean<>();
         CorsConfiguration corsConfiguration = new CorsConfiguration();
         corsConfiguration.setAllowedHeaders(Arrays.asList("*"));
         corsConfiguration.setAllowedMethods(Arrays.asList("*"));
         corsConfiguration.setAllowedOrigins(Arrays.asList("*"));
         corsConfiguration.setMaxAge(3600L);
         UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
         source.registerCorsConfiguration("/**", corsConfiguration);
         registrationBean.setFilter(new CorsFilter(source));
         registrationBean.setOrder(-1);//filter 0 1 自然顺序,-1表示优先执行
         return registrationBean;
     }
 }

1.3.4 代码实操

(1)创建项目

        新建SpringBoot项目:spring-security-cors-test,选择JDK8、SpringBoot2.6.12版本,导入Spring Web依赖,最终建成的项目的pom.xml中包含如下2个依赖:

 <dependency>
     <groupId>org.springframework.boot</groupId>
     <artifactId>spring-boot-starter-web</artifactId>
 </dependency>
 ​
 <dependency>
     <groupId>org.springframework.boot</groupId>
     <artifactId>spring-boot-starter-test</artifactId>
     <scope>test</scope>
 </dependency>

注意:

  • jdk1.8 2.6.12 新建项目即可使用

(2)在resources路径下static文件夹下新建index.html

 <!DOCTYPE html>
 <html lang="en">
 <head>
     <meta charset="UTF-8">
     <title>跨域测试</title>
     <!--通过cdn形式引入axios异步请求库,需要网络或者本地下载才能正常使用该文件-->
     <script src="https://unpkg.com/axios/dist/axios.min.js"></script>
     <script>
         //1.发送异步请求
         axios.get("http://127.0.0.1:8080/demo");
     </script>
 </head>
 <body>
 <h1>用来测试跨域</h1>
 </body>
 </html>

(3)编写测试类DemoController

 package com.study.springsecuritycorstest.controller;
 ​
 import org.springframework.web.bind.annotation.GetMapping;
 import org.springframework.web.bind.annotation.RestController;
 ​
 /**
  * @ClassName DemoController
  * @Description TODO
  * @Author Jiangnan Cui
  * @Date 2023/2/18 10:36
  * @Version 1.0
  */
 @RestController
 public class DemoController {
     @GetMapping("/demo")
     public String demo() {
         System.out.println("Demo is Ok!");
         return "Demo is Ok!";
     }
 }

(4)启动项目:浏览器访问:http://localhost:8080/demo

(5)IDEA中通过浏览器打开index.html

在浏览器F12查看查看控制台console输出错误日志,表明存在跨域问题:

(6)使用@CrossOrigin注解解决跨域问题

 @RestController
 // @CrossOrigin // 代表类中所有方法允许跨域  springmvc提供的注解解决方案
 public class DemoController {
     @GetMapping("/demo")
     // 使用@CrossOrigin注解来解决跨域问题,origins里面用来指定域,多个之间以逗号间隔
     @CrossOrigin(origins = {"http://localhost:63342"}) // 注意:localhost这里一定要与idea打开后显示网页的localhost一致,否则不能解决跨域问题
     //@CrossOrigin // 允许所有跨域访问
     public String demo() {
         System.out.println("Demo is Ok!");
         return "Demo is Ok!";
     }
 }

最终效果:

a. 不再提示跨域问题

b. 可以得到响应内容

(7)重写addCrosMapping方法解决跨域问题

 package com.study.springsecuritycorstest.config;
 ​
 import org.springframework.context.annotation.Configuration;
 import org.springframework.web.servlet.config.annotation.CorsRegistry;
 import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
 ​
 /**
  * @ClassName WebMvcConfig
  * @Description 自定义MVC的配置
  * @Author Jiangnan Cui
  * @Date 2023/2/18 11:56
  * @Version 1.0
  */
 @Configuration
 public class WebMvcConfig implements WebMvcConfigurer { // SpringMVC提供的跨域解决方案
     // 用来全局处理跨域
     @Override
     public void addCorsMappings(CorsRegistry registry) {
 //        WebMvcConfigurer.super.addCorsMappings(registry);
         registry.addMapping("/**") // 对哪些请求进行跨域处理
                 .allowCredentials(false)
                 .allowedHeaders("*")
                 .allowedMethods("*")
                 .allowedOrigins("*")
                 .maxAge(3600);
     }
 }

(8)使用CrosFilter过滤器解决跨域

 package com.study.springsecuritycorstest.filter;
 ​
 import org.springframework.boot.web.servlet.FilterRegistrationBean;
 import org.springframework.context.annotation.Bean;
 import org.springframework.context.annotation.Configuration;
 import org.springframework.web.cors.CorsConfiguration;
 import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
 import org.springframework.web.filter.CorsFilter;
 ​
 import java.util.Arrays;
 ​
 /**
  * @ClassName ConfigCorsFilter
  * @Description 进行跨域过滤器配置
  * @Author Jiangnan Cui
  * @Date 2023/2/18 12:06
  * @Version 1.0
  */
 @Configuration
 public class ConfigCorsFilter { // 原生Filter解决跨域方案
     @Bean
     FilterRegistrationBean<CorsFilter> corsFilter() {
         FilterRegistrationBean<CorsFilter> registrationBean = new FilterRegistrationBean<>();
         CorsConfiguration corsConfiguration = new CorsConfiguration();
         corsConfiguration.setAllowedHeaders(Arrays.asList("*"));
         corsConfiguration.setAllowedMethods(Arrays.asList("*"));
         corsConfiguration.setAllowedOrigins(Arrays.asList("*"));
         corsConfiguration.setMaxAge(3600L);
         UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
         source.registerCorsConfiguration("/**", corsConfiguration);
         registrationBean.setFilter(new CorsFilter(source));
         registrationBean.setOrder(-1);//filter 0 1 自然顺序,-1表示优先执行
         return registrationBean;
     }
 }

1.4 Spring Security跨域解决方案

配套视频:59.Spring Security 跨域解决方案_哔哩哔哩_bilibili

1.4.1 原理分析

        当我们为项目添加了Spring Security依赖之后,发现上面三种跨域方式有的失效了,有的则可以继续使用,这是怎么回事?通过@CrossOrigin注解或者重写addCorsMappings方法配置跨域,统统失效了(因为这两种方法都是在Interceptor这里执行,而Spring Security过滤器优先级高于Interceptor,所以这两种方法配置的跨域请求不会生效);通过CorsFilter配置的跨域,是否失效则要看过滤器的优先级,如果过滤器优先级高于Spring Security过滤器,即先于Spring Security过滤器执行,则CorsFiter所配置的跨域处理依然有效;如果过滤器优先级低于Spring Security过滤器,则CorsFilter所配置的跨域处理就会失效。

        为了理清楚这个问题,我们先简略了解一下Filter、DispatchserServlet以及Interceptor执行顺序:

        理清楚了执行顺序,我们再来看跨域请求过程:由于非简单请求都要首先发送一个预检请求(request),而预检请求并不会携带认证信息,所以预检请求就有被Spring Security拦截的可能。因此通过@CrossOrigin注解或者重写addCorsMappings方法配置跨域就会失效;如果使用CorsFilter配置的跨域,只要过滤器优先级高于Spring Security过滤器就不会有问题,反之同样会出现问题。

1.4.2 解决方案

Spring Security中也提供了更专业的方式来解决预检请求所面临的问题。如:

 @Configuration
 public class SecurityConfig extends WebSecurityConfigurerAdapter {
         @Override
     protected void configure(HttpSecurity http) throws Exception {
         http.authorizeRequests().anyRequest()
                 .authenticated()
                 .and()
                 .formLogin()
                 .and()
                 .cors() //跨域处理方案
                 .configurationSource(configurationSource()) // 开启跨域配置,使此处配置的跨域Filter优先于其它所有的Filter执行,并按照配置进行处理
                 .and()
                 .csrf().disable();
     }
 ​
     CorsConfigurationSource configurationSource() {
         CorsConfiguration corsConfiguration = new CorsConfiguration();
         corsConfiguration.setAllowedHeaders(Arrays.asList("*"));
         corsConfiguration.setAllowedMethods(Arrays.asList("*"));
         corsConfiguration.setAllowedOrigins(Arrays.asList("*"));
         corsConfiguration.setMaxAge(3600L);
         UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
         source.registerCorsConfiguration("/**", corsConfiguration);
         return source;
     }
 }

        注意:WebSecurityConfigurerAdapter在Spring Boot 2.6.-版本还可以使用,但是在Spring Boot 2.7.2之后就过时了,此时需要采用新的解决方法实现,详见:进入 SpringBoot2.7,有一个重要的类过期了_慕课手记

实际测试:

 package com.study.springsecuritycorstest.config;
 ​
 import org.springframework.context.annotation.Configuration;
 import org.springframework.security.config.annotation.web.builders.HttpSecurity;
 import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
 import org.springframework.web.cors.CorsConfiguration;
 import org.springframework.web.cors.CorsConfigurationSource;
 import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
 ​
 import java.util.Arrays;
 ​
 /**
  * @ClassName SecurityConfig
  * @Description Spring Security配置类
  * @Author Jiangnan Cui
  * @Date 2023/2/19 10:39
  * @Version 1.0
  */
 @Configuration
 public class SecurityConfig extends WebSecurityConfigurerAdapter {
     /**
      * 基本配置
      *
      * @param http
      * @throws Exception
      */
     @Override
     protected void configure(HttpSecurity http) throws Exception {
         http.authorizeRequests().anyRequest()
                 .authenticated()
                 .and()
                 .formLogin()
                 .and()
                 .cors() // Spring Security提供的跨域处理方案
                 .configurationSource(configurationSource()) // 开启跨域配置,使此处配置的跨域Filter优先于其它所有的Filter执行,并按照配置进行处理
                 .and()
                 .csrf()
                 .disable();
     }
 ​
     /**
      * 跨域配置
      *
      * @return
      */
     CorsConfigurationSource configurationSource() {
         CorsConfiguration corsConfiguration = new CorsConfiguration();
         corsConfiguration.setAllowedHeaders(Arrays.asList("*"));// 允许所有的请求头
         corsConfiguration.setAllowedMethods(Arrays.asList("*"));// 允许所有的请求方法
         corsConfiguration.setAllowedOrigins(Arrays.asList("*"));// 允许所有的请求源
         corsConfiguration.setMaxAge(3600L);// 设置响应时间
         UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
         source.registerCorsConfiguration("/**", corsConfiguration);// 允许所有路径进行跨域访问
         return source;
     }
 }

重新启动服务进行请求:

(1)http://localhost:8080/demo需要输入正确的用户名(user)、密码(IDEA控制台打印的密码)才可正常访问页面

(2)IDEA打开的网页:

        返回代码为302,表示网页已重定向,需要在前后端分离的场景下进行解决。但此时仍能表明跨域问题已经解决,若存在跨域问题时,还会进行提示跨域失败。

1.4 项目整体结构

 

2. 异常处理

配套视频:60.Spring Security中异常处理_哔哩哔哩_bilibili

  • Spring Security 异常体系

  • 自定义异常配置

2.1 异常体系

Spring Security 中异常主要分为两大类:

  • AuthenticationException: 认证异常

  • AccessDeniedException: 授权异常

其中认证所涉及异常类型比较多,默认提供的异常类型如下:

相比于认证异常,权限异常类就要少了很多,默认提供的权限异常如下:

在实际项目开发中,如果默认提供异常无法满足需求时,就需要根据实际需要来自定义异常类。

2.2 自定义异常处理配置

 @Configuration
 public class SecurityConfig extends WebSecurityConfigurerAdapter {
     @Override
     protected void configure(HttpSecurity http) throws Exception {
         http.authorizeRequests().anyRequest()
                 .authenticated()
                     //.....
                 .and()
                 .exceptionHandling()//异常处理
                 .authenticationEntryPoint((request, response, e) -> { // 处理认证异常
                   response.setContentType("application/json;charset=UTF-8");
                   response.setStatus(HttpStatus.UNAUTHORIZED.value());
                   response.getWriter().write("尚未认证,请进行认证操作!");
                 })
                 .accessDeniedHandler((request, response, e) -> { // 处理授权异常
                   response.setContentType("application/json;charset=UTF-8");
                   response.setStatus(HttpStatus.FORBIDDEN.value());
                   response.getWriter().write("无权访问!");
                 });
     }
 }

2.3 实测

2.3.1 创建项目

  • 新建SpringBoot项目:spring-security-exception,选择JDK8、Spring Boot 2.6.12版本,导入Spring Web、Spring Security依赖。

:阿里云创建Spring Boot项目初始化工具地址:https://start.aliyun.com/

  • 新建HelloConoller

 package com.study.controller;
 ​
 import org.springframework.web.bind.annotation.GetMapping;
 import org.springframework.web.bind.annotation.RestController;
 ​
 /**
  * @ClassName HelloController
  * @Description TODO
  * @Author Jiangnan Cui
  * @Date 2023/2/19 11:24
  * @Version 1.0
  */
 @RestController
 public class HelloController {
     @GetMapping("/hello")
     public String hello() {
         System.out.println("Hello, Mr.Cui!");
         return "Hi, Mr.Cui!";
     }
 }

2.3.2 不配置异常处理

 package com.study.config;
 ​
 import org.springframework.context.annotation.Configuration;
 import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder;
 import org.springframework.security.config.annotation.web.builders.HttpSecurity;
 import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
 import org.springframework.security.core.userdetails.User;
 import org.springframework.security.core.userdetails.UserDetailsService;
 import org.springframework.security.provisioning.InMemoryUserDetailsManager;
 ​
 /**
  * @ClassName SecurityConfig
  * @Description Spring Security配置类
  * @Author Jiangnan Cui
  * @Date 2023/2/19 11:24
  * @Version 1.0
  */
 @Configuration
 public class SecurityConfig extends WebSecurityConfigurerAdapter {
     /**
      * 使用内存中的数据库
      *
      * @return
      */
     public UserDetailsService userDetailsService() {
         InMemoryUserDetailsManager inMemoryUserDetailsManager = new InMemoryUserDetailsManager();
         inMemoryUserDetailsManager.createUser(User.withUsername("root").password("{noop}123456").roles("ADMIN").build());
         inMemoryUserDetailsManager.createUser(User.withUsername("cjn").password("{noop}123456").roles("USER").build());
         return inMemoryUserDetailsManager;
     }
 ​
     @Override
     protected void configure(AuthenticationManagerBuilder auth) throws Exception {
         auth.userDetailsService(userDetailsService());
     }
 ​
     /**
      * 基本配置
      *
      * @param http
      * @throws Exception
      */
     @Override
     protected void configure(HttpSecurity http) throws Exception {
         http.authorizeRequests()
                 .mvcMatchers("/hello").hasRole("ADMIN") // 匹配路径:访问"/hello"时必须具有"admin"才能进行访问
                 .anyRequest().authenticated()// 所有请求必须认证
                 .and().formLogin() // 表单登录
                 .and().csrf().disable();
     }
 }

启动服务后访问:http://localhost:8080/hello

输入用户名(cjn)、密码(123456)进行登录:

403表示被禁止了,此处应该是没有访问权限。

再次输入用户名(root)、密码(123456)进行登录:

此时表示登录成功,说明只有角色为”ADMIN“的用户才能访问“/hello"。

2.3.3 配置异常处理

 package com.study.config;
 ​
 import org.springframework.context.annotation.Configuration;
 import org.springframework.http.HttpStatus;
 import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder;
 import org.springframework.security.config.annotation.web.builders.HttpSecurity;
 import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
 import org.springframework.security.core.userdetails.User;
 import org.springframework.security.core.userdetails.UserDetailsService;
 import org.springframework.security.provisioning.InMemoryUserDetailsManager;
 ​
 /**
  * @ClassName SecurityConfig
  * @Description Spring Security配置类
  * @Author Jiangnan Cui
  * @Date 2023/2/19 11:24
  * @Version 1.0
  */
 @Configuration
 public class SecurityConfig extends WebSecurityConfigurerAdapter {
     /**
      * 使用内存中的数据库
      *
      * @return
      */
     public UserDetailsService userDetailsService() {
         InMemoryUserDetailsManager inMemoryUserDetailsManager = new InMemoryUserDetailsManager();
         inMemoryUserDetailsManager.createUser(User.withUsername("root").password("{noop}123456").roles("ADMIN").build());
         inMemoryUserDetailsManager.createUser(User.withUsername("cjn").password("{noop}123456").roles("USER").build());
         return inMemoryUserDetailsManager;
     }
 ​
     @Override
     protected void configure(AuthenticationManagerBuilder auth) throws Exception {
         auth.userDetailsService(userDetailsService());
     }
 ​
     /**
      * 基本配置
      *
      * @param http
      * @throws Exception
      */
     @Override
     protected void configure(HttpSecurity http) throws Exception {
         http.authorizeRequests()
                 .mvcMatchers("/hello").hasRole("ADMIN") // 匹配路径:访问"/hello"时必须具有"admin"才能进行访问
                 .anyRequest().authenticated()// 所有请求必须认证
                 .and().formLogin() // 表单登录
                 .and().exceptionHandling()// 异常处理
                 .authenticationEntryPoint((request, response, e) -> { // 处理认证异常
                     /*if(e instanceof LockedException){
 ​
                     }*/
                     response.setContentType("application/json;charset=UTF-8");
                     response.setStatus(HttpStatus.UNAUTHORIZED.value());
                     response.getWriter().write("尚未认证,请进行认证操作!");
                 })
                 .accessDeniedHandler((request, response, e) -> { // 处理授权异常
                     response.setContentType("application/json;charset=UTF-8");
                     response.setStatus(HttpStatus.FORBIDDEN.value());
                     response.getWriter().write("无权访问!");
                 })
                 .and().csrf().disable();
     }
 }

启动服务后访问:http://localhost:8080/hello

提示”尚未认证,请进行认证操作“,具体认证操作要通过认证页面实现,详见:【编程不良人】SpringSecurity实战学习笔记02---自定义认证_Coder_Cui的博客-CSDN博客_编程不良人

2.4 项目整体结构

 

文中如有问题,欢迎批评指正!

评论 4
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值