从零开始搭建微服务:认证服务器

通常的,单体架构,我们会采用Shiro对系统做防护以及权限控制。在搭建微服务系统时,同样也要对资源做保护,只有通过认证的资源才能被访问。下面,我们将借助Spring Cloud OAuth和Spring Cloud Security搭建一个统一给微服务发放访问令牌的认证服务器elsa-auth。

Oauth2协议简介

在微服务架构下,我们通常根据不同的业务来构建不同的微服务子系统,各个子系统对外提供相应的服务。客户端除了浏览器外,还可能是手机App,小程序等。在微服务架构出现之前,我们的系统一般为单体模式,客户端只是单一的浏览器,所以通常情况下都是通过Session进行客户端,服务端通信,Session模式有个弊端,就是在一般存在于应用内,而随着客户端种类越来越多,这种交互方式变得越来越困难(当然可以通过Session缓存化的方式来解决),于是OAuth协议应运而生。

OAuth是一种用来规范令牌(Token)发放的授权机制,目前最新版本为2.0,其主要包含了四种授权模式:授权码模式、简化模式、密码模式和客户端模式。Spring Cloud OAuth对这四种授权模式进行了实现。如有不理解的,可以访问如下阮一峰介绍的Oauth2。

  1. OAuth 2.0 的一个简单解释
  2. OAuth 2.0 的四种方式

由于我们的前端系统是通过用户名和密码来登录系统的,所以我们选用密码模式。

认证服务器搭建

创建认证服务器子项目

File==>新建==>Other==>搜索Maven,选择Maven Module,然后Next
在这里插入图片描述
填写Module Name:elsa-auth,点击Next
在这里插入图片描述
一直Next至FInish为止,创建完成,项目结构如下
在这里插入图片描述
右键点击Elsa-Auth项目:点击Java Build Path,在Resouce资源下创建资源目录resources。
在这里插入图片描述
Elsa-Auth完整目录结构
在这里插入图片描述
认证服务器项目已经创建完成,下面我们做相关依赖和配置。

认证服务器引入依赖
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>
    <parent>
        <groupId>com.elsa</groupId>
        <artifactId>elsa-cloud</artifactId>
        <version>1.0-SNAPSHOT</version>
    </parent>

    <artifactId>elas-auth</artifactId>
    <name>Elsa-Auth</name>
    <description>Elsa-Cloud认证服务器</description>

    <dependencies>
        <dependency>
            <groupId>com.elsa</groupId>
            <artifactId>elsa-common</artifactId>
            <version>1.0-SNAPSHOT</version>
        </dependency>
    </dependencies>

    <build>
        <plugins>
            <plugin>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-maven-plugin</artifactId>
            </plugin>
        </plugins>
    </build>
</project>
通用模块引入依赖

在elsa-common模块引入相关依赖

        <!-- redis -->
		<dependency>
		    <groupId>org.springframework.boot</groupId>
		    <artifactId>spring-boot-starter-data-redis</artifactId>
		</dependency>  
		<!-- eureka client -->
		<dependency>
		    <groupId>org.springframework.cloud</groupId>
		    <artifactId>spring-cloud-starter-netflix-eureka-client</artifactId>
		</dependency>
  • spring-boot-starter-data-redis
    -因为后续我们需要将认证服务器生成的Token存储到Redis中,并且Redis依赖可能会被多个微服务使用到

  • spring-cloud-starter-netflix-eureka-client
    -因为每个微服务都可能需要通过Eureka客户端将服务注册到注册中心,所以将依赖添加到通用模块,以方便其他微服务依赖。

认证服务器人口类
@EnableDiscoveryClient
@SpringBootApplication
public class ElsaAuthApp 
{
    public static void main(String[] args) {
        SpringApplication.run(ElsaAuthApp.class, args);
    }
}

@EnableDiscoveryClient注解,用于开启服务注册与发现功能

基本配置文件配置

编写配置文件application.yml,Eureka相关配置的含义已通过注解体现,可自行查看。在application.yml如果没有配置Redis相关配置,则采用的是Redis默认配置,但是为了更为直观,建议还是在application.yml中添加Resis配置。

server:
  port: 8101

spring:
  application:
    name: Elsa-Auth
  # redis相关配置
  redis:
    database: 0
    host: 127.0.0.1
    port: 6379
    jedis:
      pool:
        min-idle: 8
        max-idle: 500
        max-active: 2000
        max-wait: 10000
    timeout: 5000    

eureka:
  instance:
    # 向Eureka 服务端发送心跳的间隔时间,单位为秒,用于服务续约。这里配置为20秒,即每隔20秒向febs-register发送心跳,表明当前服务没有宕机
    lease-renewal-interval-in-seconds: 20
  client:
    # 为true时表示将当前服务注册到Eureak服务端
    register-with-eureka: true
    # 为true时表示从Eureka 服务端获取注册的服务信息
    fetch-registry: true
    # 新实例信息的变化到Eureka服务端的间隔时间,单位为秒
    instance-info-replication-interval-seconds: 30
    # 默认值为30秒,即每30秒去Eureka服务端上获取服务并缓存,这里指定为3秒的原因是方便开发时测试,实际可以指定为默认值即可;
    registry-fetch-interval-seconds: 3
    serviceUrl:
      # 指定Eureka服务端地址
      defaultZone: http://elsa:123456@localhost:8001/register/eureka/
安全配置类
认证安全配置类

首先我们需要定义一个WebSecurity类型的认证安全配置类ElsaSecurityConfigure,在com.elsa.auth路径下新增configure包,然后在configure包下新增ElsaSecurityConfigure类,代码如下所示:

@Order(2)	// 增加过滤链的优先级,因为ElsaResourceServerConfigure的优先级为3
@EnableWebSecurity	// 开启和Web相关的安全配置
public class ElsaSecurityConfigure extends WebSecurityConfigurerAdapter {

    @Autowired
    private ElsaUserDetailService userDetailService;

    @Bean
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();//一个相同的密码,每次加密出来的加密串都不同
    }

    
    // 一个相同的密码,每次加密出来的加密串都不同
    public static void main(String[] args) {
        String password = "123456";
        PasswordEncoder encoder = new BCryptPasswordEncoder();
        System.out.println(encoder.encode(password));
        System.out.println(encoder.encode(password));
    }
    
    //密码模式需要使用到这个Bean:AuthenticationManager
    @Bean
    @Override
    public AuthenticationManager authenticationManagerBean() throws Exception {
        return super.authenticationManagerBean();
    }

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http.requestMatchers()
                .antMatchers("/oauth/**")	//安全配置类只对/oauth/开头的请求有效
            .and()
                .authorizeRequests()
                .antMatchers("/oauth/**").authenticated()
            .and()
                .csrf().disable();
    }

    @Override
    protected void configure(AuthenticationManagerBuilder auth) throws Exception {
        auth.userDetailsService(userDetailService).passwordEncoder(passwordEncoder());
    }
}

  • @EnableWebSecurity
    注解标注,开启了和Web相关的安全配置
  • PasswordEncoder
    该类是一个接口,定义了几个和密码加密校验相关的方法,这里我们使用的是Spring Security内部实现好的BCryptPasswordEncoder(也可以自己实现PasswordEncoder接口)。BCryptPasswordEncoder的特点就是,对于一个相同的密码,每次加密出来的加密串都不同:
    // 一个相同的密码,每次加密出来的加密串都不同
    public static void main(String[] args) {
        String password = "123456";
        PasswordEncoder encoder = new BCryptPasswordEncoder();
        System.out.println(encoder.encode(password));
        System.out.println(encoder.encode(password));
    }

运行该main方法,可以看到两次输出的结果并不一样:

$2a$10$CztjcNZW8xMlol4EAN/L8eroQly7NZfZe5lNcih.arCEd9MDwkHAi
$2a$10$Jstxp5K0rsp6xocA70M.aOfCYkrdZFV/6mIacOKb6ZtnpBN.r1waK
  • authenticationManagerBean
    注册了一个authenticationManagerBean,因为密码模式需要使用到这个Bean
  • configure(HttpSecurity http)方法
    requestMatchers().antMatchers("/oauth/**")的含义是:ElsaSecurityConfigure安全配置类只对/oauth/开头的请求有效
  • configure(AuthenticationManagerBuilder auth)方法
    重写了configure(AuthenticationManagerBuilder auth)方法,指定了userDetailsService和passwordEncoder
  • ElsaUserDetailService
资源安全配置类

虽然我们现在正在搭建的是一个认证服务器,但是认证服务器本身也可以对外提供REST服务,比如通过Token获取当前登录用户信息,注销当前Token等,所以它也是一台资源服务器。于是我们需要定义一个资源服务器的配置类,在com.elsa.auth.configure包下新建ElsaResourceServerConfigure类:

@Configuration
@EnableResourceServer	//开启资源服务器相关配置
public class ElsaResourceServerConfigure extends ResourceServerConfigurerAdapter {

    @Override
    public void configure(HttpSecurity http) throws Exception {
        http.csrf().disable()
                .requestMatchers().antMatchers("/**")	//表明该安全配置对所有请求都生效
                .and()
                .authorizeRequests()
                .antMatchers("/**").authenticated();
    }
}

  • @EnableResourceServer
    用于开启资源服务器相关配置
  • ResourceServerConfigurerAdapter
  • configure(HttpSecurity http)方法
    通过requestMatchers().antMatchers("/**")的配置表明该安全配置对所有请求都生效

相信看到这里的人会发现,ElsaSecurityConfigure和ElsaResourceServerConfigure两个配置的功能似乎是一样的,都是对请求过滤的。
ElsaSecurityConfigure对/oauth/开头的请求生效,而ElsaResourceServerConfigure对所有请求都生效,那么当一个请求进来时,到底哪个安全配置先生效?其实并没有哪个配置先生效这么一说,当在Spring Security中定义了多个过滤器链的时候,根据其优先级,只有优先级较高的过滤器链会先进行匹配。
那么ElsaSecurityConfigure和ElsaResourceServerConfigure的优先级是多少?首先我们查看ElsaSecurityConfigure继承的类WebSecurityConfigurerAdapter的源码:

@Order(100)
public abstract class WebSecurityConfigurerAdapter implements WebSecurityConfigurer<WebSecurity> {
   ......
}

可以看到类上使用了@Order(100)标注,说明其顺序是100。
再来看看ElsaResourceServerConfigure类上@EnableResourceServer注解源码:

@Target({ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Import({ResourceServerConfiguration.class})
public @interface EnableResourceServer {
}

该注解引入了ResourceServerConfiguration配置类,查看ResourceServerConfiguration源码:

@Configuration
public class ResourceServerConfiguration extends WebSecurityConfigurerAdapter implements Ordered {
    private int order = 3;
    ......
}

所以ElsaResourceServerConfigure的顺序是3。在Spring中,数字越小,优先级越高,也就是说ElsaResourceServerConfigure的优先级要高于ElsaSecurityConfigure,这也就意味着所有请求都会被ElsaResourceServerConfigure过滤器链处理,包括/oauth/开头的请求。这显然不是我们要的效果,我们原本是希望以/oauth/开头的请求由ElsaSecurityConfigure过滤器链处理,剩下的其他请求由ElsaResourceServerConfigure过滤器链处理。

为了解决上面的问题,我们可以手动指定这两个类的优先级,让ElsaSecurityConfigure的优先级高于ElsaResourceServerConfigure。在ElsaSecurityConfigure类上使用Order(2)注解标注即可:

@Order(2)
@EnableWebSecurity
public class ElsaSecurityConfigure extends WebSecurityConfigurerAdapter {
    ......
}

ElsaSecurityConfigure和ElsaResourceServerConfigure的区别:

ElsaSecurityConfigure用于处理/oauth开头的请求,Spring Cloud OAuth内部定义的获取令牌,刷新令牌的请求地址都是以/oauth/开头的,也就是说FebsSecurityConfigure用于处理和令牌相关的请求;
ElsaResourceServerConfigure用于处理非/oauth/开头的请求,其主要用于资源的保护,客户端只能通过OAuth2协议发放的令牌来从资源服务器中获取受保护的资源。

授权配置类

接着我们定义一个和认证服务器相关的授权配置类。在configure包下新建ElsaAuthorizationServerConfigure,配置的解释在代码中体现,代码如下所示:

@Configuration
@EnableAuthorizationServer	//开启认证服务器相关配置
public class ElsaAuthorizationServerConfigure extends AuthorizationServerConfigurerAdapter {

    @Autowired
    private AuthenticationManager authenticationManager;
    @Autowired
    private RedisConnectionFactory redisConnectionFactory;
    @Autowired
    private ElsaUserDetailService userDetailService;
    @Autowired
    private PasswordEncoder passwordEncoder;

    /**
     * 1.客户端从认证服务器获取令牌的时候,必须使用client_id为elsa,client_secret为123456的标识来获取;
	 * 2. 该client_id支持password模式获取令牌,并且可以通过refresh_token来获取新的令牌;
 	 * 3. 在获取client_id为elsa的令牌的时候,scope只能指定为all,否则将获取失败
     */
    @Override
    public void configure(ClientDetailsServiceConfigurer clients) throws Exception {
        clients.inMemory()
        		// 需要指定多个client,可以继续使用withClient配置
                .withClient("elsa")
                .secret(passwordEncoder.encode("123456"))
                .authorizedGrantTypes("password", "refresh_token")
                .scopes("all");
    }
	// tokenStore使用的是RedisTokenStore,认证服务器生成的令牌将被存储到Redis中
    @Override
    public void configure(AuthorizationServerEndpointsConfigurer endpoints) {
        endpoints.tokenStore(tokenStore())
                .userDetailsService(userDetailService)
                .authenticationManager(authenticationManager)
                .tokenServices(defaultTokenServices());
    }
    
    // 认证服务器生成的令牌将被存储到Redis中
    @Bean
    public TokenStore tokenStore() {
        return new RedisTokenStore(redisConnectionFactory);
    }

    @Primary
    @Bean
    public DefaultTokenServices defaultTokenServices() {
        DefaultTokenServices tokenServices = new DefaultTokenServices();
        tokenServices.setTokenStore(tokenStore());
        // 设置为true表示开启刷新令牌的支持
        tokenServices.setSupportRefreshToken(true);
        // 指定了令牌的基本配置,比如令牌有效时间为60 * 60 * 24秒,刷新令牌有效时间为60 * 60 * 24 * 7秒
        tokenServices.setAccessTokenValiditySeconds(60 * 60 * 24);
        tokenServices.setRefreshTokenValiditySeconds(60 * 60 * 24 * 7);
        return tokenServices;
    }
}

其他相关类

ElsaUserDetailService

ElsaSecurityConfigure及ElsaAuthorizationServerConfigure用到的ElsaUserDetailService。在com.elsa.auth路径下新增service包,然后在service包下新增ElsaUserDetailService类,代码如下所示:

@Service
public class ElsaUserDetailService implements UserDetailsService {

    @Autowired
    private PasswordEncoder passwordEncoder;

    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        ElsaAuthUser user = new ElsaAuthUser();
        user.setUsername(username);
        user.setPassword(this.passwordEncoder.encode("123456"));

        return new User(username, user.getPassword(), user.isEnabled(),
                user.isAccountNonExpired(), user.isCredentialsNonExpired(),
                user.isAccountNonLocked(), AuthorityUtils.commaSeparatedStringToAuthorityList("user:add"));
    }
}

ElsaUserDetailService实现了UserDetailsService接口的loadUserByUsername方法,主要用于校验用户账号和密码,以及授权等,我们模拟了一个用户,用户名为用户输入的用户名,密码为123456(后期再改造为从数据库中获取用户),然后返回org.springframework.security.core.userdetails.User。这里使用的是User类包含7个参数的构造器,其还包含一个三个参数的构造器User(String username, String password,Collection<? extends GrantedAuthority> authorities),由于权限参数不能为空,所以这里先使用AuthorityUtils.commaSeparatedStringToAuthorityList方法模拟一个user:add权限。

ElsaAuthUser

loadUserByUsername方法返回一个UserDetails对象,该对象也是一个接口,包含一些用于描述用户信息的方法,源码如下:

public interface UserDetails extends Serializable {
	//获取用户包含的权限,返回权限集合,权限是一个继承了GrantedAuthority的对象;
    Collection<? extends GrantedAuthority> getAuthorities();
	//用于获取密码和用户名;
    String getPassword();
	//方法返回boolean类型,用于判断账户是否未过期,未过期返回true反之返回false;
    String getUsername();
	//方法用于判断账户是否未锁定;
    boolean isAccountNonExpired();
	//用于判断用户凭证是否没过期,即密码是否未过期;
    boolean isAccountNonLocked();

    boolean isCredentialsNonExpired();
	//方法用于判断用户是否可用。
    boolean isEnabled();
}

实际中我们可以自定义UserDetails接口的实现类,也可以直接使用Spring Security提供的UserDetails接口实现类org.springframework.security.core.userdetails.User。

ElsaUserDetailService中ElsaAuthUser为我们自定义的用户实体类,代表我们从数据库中查询出来的用户。我们在febs-common中定义该实体类,在elsa-cmmon模块下新增com.elsa.common.entity包,然后在entity包下新增ElsaAuthUser:

public class ElsaAuthUser implements Serializable {
    private static final long serialVersionUID = -1748289340320186418L;

    private String username;

    private String password;

    private boolean accountNonExpired = true;

    private boolean accountNonLocked= true;

    private boolean credentialsNonExpired= true;

    private boolean enabled= true;

	public String getUsername() {
		return username;
	}

	public void setUsername(String username) {
		this.username = username;
	}

	public String getPassword() {
		return password;
	}

	public void setPassword(String password) {
		this.password = password;
	}

	public boolean isAccountNonExpired() {
		return accountNonExpired;
	}

	public void setAccountNonExpired(boolean accountNonExpired) {
		this.accountNonExpired = accountNonExpired;
	}

	public boolean isAccountNonLocked() {
		return accountNonLocked;
	}

	public void setAccountNonLocked(boolean accountNonLocked) {
		this.accountNonLocked = accountNonLocked;
	}

	public boolean isCredentialsNonExpired() {
		return credentialsNonExpired;
	}

	public void setCredentialsNonExpired(boolean credentialsNonExpired) {
		this.credentialsNonExpired = credentialsNonExpired;
	}

	public boolean isEnabled() {
		return enabled;
	}

	public void setEnabled(boolean enabled) {
		this.enabled = enabled;
	}
    
    
}
SecurityController

最后定义一个Controller,对外提供一些REST服务。在com.elsa.auth路径下新增controller包,在controller包下新增SecurityController:

@RestController
public class SecurityController {
	@Autowired
    private ConsumerTokenServices consumerTokenServices;

    @GetMapping("oauth/test")
    public String testOauth() {
        return "oauth";
    }
	//currentUser用户获取当前登录用户
    @GetMapping("user")
    public Principal currentUser(Principal principal) {
        return principal;
    }
	//signout方法通过ConsumerTokenServices来注销当前Token
    @DeleteMapping("signout")
    public ElsaResponse signout(HttpServletRequest request) throws ElsaAuthException {
        String authorization = request.getHeader("Authorization");
        String token = StringUtils.replace(authorization, "bearer ", "");
        ElsaResponse elsaResponse = new ElsaResponse();
        if (!consumerTokenServices.revokeToken(token)) {
            throw new ElsaAuthException("退出登录失败");
        }
        return elsaResponse.message("退出登录成功");
    }
}
ElsaResponse

ElsaResponse为系统的统一相应格式,我们在elsa-common模块中定义它,在elsa-common模块的com.elsa.common.entity路径下新增ElsaResponse类:

public class ElsaResponse extends HashMap<String, Object> {

    private static final long serialVersionUID = -8713837118340960775L;

    public ElsaResponse message(String message) {
        this.put("message", message);
        return this;
    }

    public ElsaResponse data(Object data) {
        this.put("data", data);
        return this;
    }

    @Override
    public ElsaResponse put(String key, Object value) {
        super.put(key, value);
        return this;
    }

    public String getMessage() {
        return String.valueOf(get("message"));
    }

    public Object getData() {
        return get("data");
    }
}

ElsaAuthException

ElsaAuthException为自定义异常,在elsa-common模块com.elsa.common路径下新增exception包,然后在该包下新增ElsaAuthException:

public class ElsaAuthException extends Exception{

    private static final long serialVersionUID = -6916154462432027437L;

    public ElsaAuthException(String message){
        super(message);
    }
}

PostMan测试

分别启动如下应用
1.redis
2.ElsaRegesterApp
3.ElsaAuthApp

测试令牌获取

在这里插入图片描述
grant_type填password,表示密码模式,然后填写用户名和密码,根据我们定义的ElsaUserDetailService逻辑,这里用户名随便填,密码必须为123456。
除了这几个参数外,我们需要在请求头中配置Authorization信息,否则请求将返回401:
值为Basic加空格加client_id:client_secret(就是在ElsaAuthorizationServerConfigure类configure(ClientDetailsServiceConfigurer clients)方法中定义的client和secret)经过base64加密后的值(可以使用http://tool.oschina.net/encrypt?type=3):
在这里插入图片描述
点击Send结果如下
在这里插入图片描述
查看Redis
在这里插入图片描述

获取受保护资源

我们已经成功获取了访问令牌access_token,接下来使用这个令牌去获取/user资源。

使用PostMan发送 localhost:8101/user GET请求,带上令牌,可以看到已经成功返回了数据。
在这里插入图片描述

/oauth/test测试

接着我们使用PostMan发送 localhost:8101/oauth/test GET请求:
在这里插入图片描述
可以看到,虽然我们在请求头中已经带上了正确的令牌,但是并没有成功获取到资源,正如前面所说的那样,/oauth/开头的请求由ElsaSecurityConfigure定义的过滤器链处理,它不受资源服务器配置管理,所以使用令牌并不能成功获取到资源。

注销测试

在这里插入图片描述
注销成功后Redis中数据已清空
在这里插入图片描述

刷新令牌

然后使用refresh_token去换取新的令牌,使用PostMan发送 localhost:8101/oauth/token POST请求,请求参数如下:
刷新令牌在Headers添加参数:
Authorization=Basic ZWxzYToxMjM0NTY=
Params中添加两个参数:
grant_type=refresh_token
refresh_token=登录时得到的refresh_token
在这里插入图片描述
在这里插入图片描述
可以看到,成功获取到了新的令牌。

源码下载

源码地址:认证服务器

  • 1
    点赞
  • 4
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
很高兴回答你关于使用Spring Cloud Alibaba搭建微服务项目的问题!下面是从零开始搭建的步骤: 1. 创建父项目:首先,在你的IDE中创建一个空的Maven父项目作为整个微服务项目的容器。 2. 添加依赖:在父项目的pom.xml文件中添加Spring Cloud Alibaba的依赖,包括spring-cloud-starter-alibaba-dependencies和spring-cloud-starter-alibaba-nacos-discovery等。 3. 创建子模块:在父项目下创建子模块,每个子模块代表一个微服务。可以使用Maven的模块化管理。 4. 配置子模块:在每个子模块的pom.xml文件中添加Spring Boot的依赖,并配置相应的插件和属性。 5. 编写业务代码:在每个子模块中编写业务逻辑代码,包括控制器、服务、数据访问等。 6. 配置文件:在每个子模块中添加相应的配置文件,包括数据库配置、Nacos注册中心配置、Feign客户端配置等。 7. 注册中心:在Nacos注册中心中注册微服务,确保微服务能够被其他微服务或客户端发现和调用。 8. 服务调用:使用Spring Cloud Alibaba中的Feign或RestTemplate等方式进行微服务之间的调用,通过Nacos注册中心进行服务发现。 9. 启动微服务:分别启动各个子模块,可以使用IDE的Run或Debug功能,或者使用Maven命令进行启动。 10. 测试和部署:通过Postman或其他方式进行接口测试,确保微服务的正常运行。最后,根据实际需求选择合适的部署方式,如Docker、Kubernetes等。 以上是使用Spring Cloud Alibaba从零开始搭建微服务项目的基本步骤。当然,具体的实施细节会根据项目需求和实际情况有所差异,希望对你有所帮助!如果有更多问题,请随时提问。

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值