Spring Cloud Zuul Oauth2 统一鉴权 最简标准实现

搭建认证中心

依赖

eureka-client,openfeign,oauth2+web为必选依赖

eureka-client提供服务注册与发现

openfeign提供远程调用

oauth2+web实现基于认证中心

    <dependencies>
        <dependency>
            <groupId>org.springframework.cloud</groupId>
            <artifactId>spring-cloud-starter-netflix-eureka-client</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-actuator</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.cloud</groupId>
            <artifactId>spring-cloud-starter-openfeign</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-data-redis</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.cloud</groupId>
            <artifactId>spring-cloud-starter-oauth2</artifactId>
        </dependency>
        <dependency>
            <groupId>org.projectlombok</groupId>
            <artifactId>lombok</artifactId>
        </dependency>
        <dependency>
            <groupId>org.apache.commons</groupId>
            <artifactId>commons-lang3</artifactId>
        </dependency>
        <dependency>
            <groupId>commons-io</groupId>
            <artifactId>commons-io</artifactId>
        </dependency>
        <dependency>
            <groupId>commons-collections</groupId>
            <artifactId>commons-collections</artifactId>
        </dependency>
        <dependency>
            <groupId>com.baomidou</groupId>
            <artifactId>mybatis-plus-boot-starter</artifactId>
        </dependency>
        <dependency>
            <groupId>mysql</groupId>
            <artifactId>mysql-connector-java</artifactId>
        </dependency>
  
    </dependencies>

实现基于rbac的认证模型(核心代码)

    /**
     * 根据用户名查询用户信息
     */
    User findByUsername(String username);


    /**
     * 根据用户id查询用户角色
     */
    List<Role> findByUserId(Long userId);

    /**
     * 根据角色id查询权限
     */
    List<Permission> findByRoleIds(List<Long> roleIds);

整合Security实现RBCA认证

UserDetails接口  认证用户信息
UserDetailsService接口  认证用户信息服务
@EqualsAndHashCode(callSuper = true)
@Data
@NoArgsConstructor
@AllArgsConstructor
public class SecurityUser extends User implements UserDetails {
    
    private Collection<? extends GrantedAuthority> authorities;

    /**
     *  security 所需用户的角色或权限
     */
    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {
        return this.authorities;
    }

    @Override
    public String getPassword() {
        return super.getPassword();
    }

    @Override
    public String getUsername() {
        return super.getUsername();
    }

    /**
     * 账号是否过期
     */
    @Override
    public boolean isAccountNonExpired() {
        return super.getIsAccountNonExpired() == 1 ;
    }

    /**
     *  账号是否锁定
     */
    @Override
    public boolean isAccountNonLocked() {
        return super.getIsAccountNonLocked() == 1;
    }

    /**
     * 密码是否过期
     */
    @Override
    public boolean isCredentialsNonExpired() {
        return super.getIsCredentialsNonExpired() == 1;
    }

    /**
     *  账号是否禁用
     */
    @Override
    public boolean isEnabled() {
        return super.getIsEnabled() == 1;
    }

}
@Slf4j
@Service
public class SecurityUserService implements UserDetailsService {
    @Resource
    private IUserService iUserService;
    @Resource
    private IRoleService iRoleService;
    @Resource
    private IPermissionService iPermissionService;
    @Override
    public UserDetails loadUserByUsername(String userName) throws UsernameNotFoundException {
        User user = iUserService.findByUsername(userName);
        if(ObjectUtils.isEmpty(user)){
            throw new UsernameNotFoundException("用户名不存在");
        }
        List<Role> roleList = iRoleService.findByUserId(user.getId());
        List<Long> roleIds = roleList.stream().map(Role::getId).collect(Collectors.toList());
        List<Permission> permissionList = iPermissionService.findByRoleIds(roleIds);

        
        List<GrantedAuthority> authorities = new ArrayList<>();
        roleList.forEach(role-> authorities.add(new SimpleGrantedAuthority("ROLE_"+role.getName())));
        permissionList.forEach(permission -> authorities.add(new SimpleGrantedAuthority(permission.getCode())));

        SecurityUser securityUser = new SecurityUser();
        BeanUtils.copyProperties(user,securityUser);
        securityUser.setAuthorities(authorities);


        log.info("当前登录的用户信息:{}",securityUser);
        return securityUser;
    }
}


Security认证配置

@EnableWebSecurity
public class SpringSecurityConfig extends WebSecurityConfigurerAdapter {

    /**
     *  security5.x 必须指定加密方式
     */

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

    @Resource
    private UserDetailsService userDetailsService;

    /**
     * 基于数据库的认证和授权
     */
    @Override
    protected void configure(AuthenticationManagerBuilder auth) throws Exception {
        auth.userDetailsService(userDetailsService);
    }

    /**+
     * 密码模式支持
     */
    @Bean
    @Override
    public AuthenticationManager authenticationManagerBean() throws Exception {
        return super.authenticationManagerBean();
    }
}

Token服务配置

@Configuration
public class TokenConfig {
    /**
     * 基于rsa的非对此加密 加密的密钥
     */
    @Value("${token.signing_key}")
    public static  String SIGNING_KEY;

    @Bean
    public JwtAccessTokenConverter jwtAccessTokenConverter() {
        JwtAccessTokenConverter converter = new JwtAccessTokenConverter();
        KeyStoreKeyFactory factory = new KeyStoreKeyFactory(
                new ClassPathResource("oauth2.jks"), "oauth2".toCharArray());
        converter.setKeyPair(factory.getKeyPair("oauth2"));
        return converter;
    }

    @Bean
    public TokenStore tokenStore() {
        return new JwtTokenStore(jwtAccessTokenConverter());
    }

}

认证服务器配置

/**
 * 认证服务器配置类
 */
@Configuration
@EnableAuthorizationServer // 开启了认证服务器
public class AuthorizationServerConfig extends AuthorizationServerConfigurerAdapter {

    @Resource
    private DataSource dataSource;
    @Resource
    private AuthenticationManager authenticationManager;
    @Resource
    private UserDetailsService userDetailsService;
    @Resource
    private TokenStore tokenStore;
    @Resource
    private JwtAccessTokenConverter jwtAccessTokenConverter;

    @Bean
    public ClientDetailsService jdbcClientDetailsService() {
        return new JdbcClientDetailsService(dataSource);
    }

    @Override
    public void configure(ClientDetailsServiceConfigurer clients) throws Exception {
        clients.withClientDetails(jdbcClientDetailsService());
    }

    @Bean
    public AuthorizationCodeServices jdbcAuthorizationCodeServices() {
        return new JdbcAuthorizationCodeServices(dataSource);
    }

    @Override
    public void configure(AuthorizationServerEndpointsConfigurer endpoints) throws Exception {
        // password
        endpoints.authenticationManager(authenticationManager);
        // 刷新令牌
        endpoints.userDetailsService(userDetailsService);
        // 令牌的管理方式
        endpoints.tokenStore(tokenStore).accessTokenConverter(jwtAccessTokenConverter);
        // 授权码管理策略
        endpoints.authorizationCodeServices(jdbcAuthorizationCodeServices());
    }

    @Override
    public void configure(AuthorizationServerSecurityConfigurer security) throws Exception {
        // 认证后可访问 /oauth/token_key , 默认拒绝访问
        security.tokenKeyAccess("permitAll()");
        // 认证后可访问 /oauth/check_token , 默认拒绝访问
        security.checkTokenAccess("isAuthenticated()");
    }
}

配置文件

eureka:
  client:
    service-url:
      defaultZone: http://127.0.0.1:8761/eureka
  instance:
    instance-id: ${spring.application.name}${server.port}
    prefer-ip-address: true
server:
  port: 9000
spring:
  application:
    name: auth-center
  datasource:
    driver-class-name: com.mysql.cj.jdbc.Driver
    username: root
    password: 123456
    url: jdbc:mysql://192.168.70.136:3306/oauth2?useUnicode=true&characterEncoding=utf-8&serverTimezone=Asia/Shanghai
mybatis-plus:
  global-config:
    db-config:
      id-type: auto
      logic-delete-value: 1
      logic-not-delete-value: 0
  mapper-locations: classpath*:/mapper/**/*.xml
  configuration:
    map-underscore-to-camel-case: true
    log-impl: org.apache.ibatis.logging.stdout.StdOutImpl


token:
  signing_key: xxxxxxxxxx

启动类

@EnableEurekaClient
@SpringBootApplication(exclude = {RedisAutoConfiguration.class})
public class AuthCenterApp {
    public static void main(String[] args) {
        SpringApplication.run(AuthCenterApp.class,args);
    }
}

搭建网关统一鉴权

依赖

zull 网关,eureka,oauth2必选依赖

    <dependencies>
        <dependency>
            <groupId>org.springframework.cloud</groupId>
            <artifactId>spring-cloud-starter-oauth2</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.cloud</groupId>
            <artifactId>spring-cloud-starter-netflix-eureka-client</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.cloud</groupId>
            <artifactId>spring-cloud-starter-netflix-zuul</artifactId>
        </dependency>
        <dependency>
            <groupId>commons-io</groupId>
            <artifactId>commons-io</artifactId>
        </dependency>
        <dependency>
            <groupId>org.projectlombok</groupId>
            <artifactId>lombok</artifactId>
        </dependency>
    </dependencies>

跨域访问配置

/**
 * 跨域
 */
@Configuration
public class GatewayConfig {

    @Bean
    public CorsFilter corsFilter() {
        final UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
        final CorsConfiguration corsConfiguration = new CorsConfiguration();
        corsConfiguration.addAllowedHeader("*");
        corsConfiguration.addAllowedOrigin("*");
        corsConfiguration.addAllowedMethod("*");
        //↓核心代码
        corsConfiguration.addExposedHeader("Authorization");
        source.registerCorsConfiguration("/**", corsConfiguration);
        return new CorsFilter(source);
    }

}

Security安全配置

@EnableWebSecurity
public class SpringSecurityConfig extends WebSecurityConfigurerAdapter {
    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http.authorizeRequests().anyRequest().permitAll();
    }
}

Token解析服务

@Configuration
public class TokenConfig {

    @Bean
    public JwtAccessTokenConverter jwtAccessTokenConverter() {
        JwtAccessTokenConverter converter = new JwtAccessTokenConverter();
        // 非对称加密,资源服务器使用公钥解密 public.txt
        ClassPathResource resource = new ClassPathResource("public.txt");
        String publicKey = null;
        try {
            publicKey = IOUtils.toString(resource.getInputStream(), StandardCharsets.UTF_8);
        } catch (IOException e) {
            e.printStackTrace();
        }
        converter.setVerifierKey(publicKey);
        return converter;
    }

    @Bean
    public TokenStore tokenStore() {
        return new JwtTokenStore(jwtAccessTokenConverter());
    }

}

资源服务管理配置

@Configuration
public class ResourceServerConfig {
    public static final String RESOURCE_ID = "product-server";
    @Resource
    private TokenStore tokenStore;

    @Configuration
    @EnableResourceServer
    public class AuthResourceServerConfig extends ResourceServerConfigurerAdapter {
        @Override
        public void configure(ResourceServerSecurityConfigurer resources) throws Exception {
            resources.resourceId(RESOURCE_ID)
                    .tokenStore(tokenStore);
        }
        @Override
        public void configure(HttpSecurity http) throws Exception {
            http.authorizeRequests().anyRequest().permitAll();
        }
    }
    @Configuration
    @EnableResourceServer
    public class Product1ResourceServerConfig extends ResourceServerConfigurerAdapter {
        @Override
        public void configure(ResourceServerSecurityConfigurer resources) throws Exception {
            resources.resourceId(RESOURCE_ID)
                    .tokenStore(tokenStore);
        }

        @Override
        public void configure(HttpSecurity http) throws Exception {
            http.authorizeRequests().antMatchers("/product1/**").access("#oauth2.hasScope('PRODUCT_API')");
        }
    }
    @Configuration
    @EnableResourceServer
    public class Product2ResourceServerConfig extends ResourceServerConfigurerAdapter {
        @Override
        public void configure(ResourceServerSecurityConfigurer resources) throws Exception {
            resources.resourceId(RESOURCE_ID).tokenStore(tokenStore);
        }
        @Override
        public void configure(HttpSecurity http) throws Exception {
            http.authorizeRequests().antMatchers("/product2/**");
//                    .access("#oauth2.hasScope('PRODUCT_API')");
        }
    }



}

JwtToken令牌黑名单

可采用redis使用比较优雅的处理,这里不做处理选择简单的单机抛异常拉黑阻止路由转发

@RestController("/jwt")
public class JwtController {
    public final static Set<Authentication> jwtSet = new HashSet<>();
    @GetMapping("/jwtBlacklist")
    public String jwtBlacklist(@AuthenticationPrincipal Authentication authentication){
        jwtSet.add(authentication);
        for (Authentication authentication1: jwtSet) {
            System.out.println("黑名单"+jwtSet);
        }
        return "添加成功";
    }
}

Token令牌传递过滤器

/**
 * 请求资源前,先通过此 过滤器进行用户信息解析和校验 转发
 */
@Slf4j
@Component 
public class AuthenticationFilter extends ZuulFilter {
    Logger logger = LoggerFactory.getLogger(getClass());
    @Override
    public String filterType() {
        return "pre";  //前置处理
    }

    @Override
    public int filterOrder() {
        return 0; //最高优先级
    }

    @Override
    public boolean shouldFilter() {
        return true; //是否启用
    }

    @SneakyThrows
    @Override
    public Object run() {
        Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
        if( !(authentication instanceof OAuth2Authentication)) {
            return null;
        }else if(JwtController.jwtSet.contains(authentication)){
            System.out.println("拉入黑名单");
            throw new RuntimeException("禁止访问");
        }else {
            RequestContext context = RequestContext.getCurrentContext();
            Map<String, Object> stringObjectMap = authenticationToMap(authentication);
            System.out.println("\n\n"+stringObjectMap+"\n\n");
            // 将用户信息和权限信息转成json,再通过base64进行编码
            byte[] header = new ObjectMapper().writeValueAsBytes(stringObjectMap);
            String base64 = Base64Utils.encodeToString(header);
            context.addZuulRequestHeader("auth-token", base64);
        }
        return null;
    }



    private Map<String,Object> authenticationToMap(Authentication authentication){
        Map<String, Object> result =  new HashMap<>();
        result.put("principal", authentication.getPrincipal()); //用户名
        result.put("authorities", AuthorityUtils.authorityListToSet(authentication.getAuthorities())); //角色和权限信息
        result.put("details", authentication.getDetails());//ip地址等其他信息
        return  result;
    }

}

配置文件

eureka:
  client:
    service-url:
      defaultZone: http://127.0.0.1:8761/eureka
  instance:
    instance-id: ${spring.application.name}${server.port}
    prefer-ip-address: true
server:
  port: 80
spring:
  application:
    name: zuul-gateway

zuul:
  sensitive-headers: null
  add-host-header: true
  routes:
    authentication:
      path: /auth/**
      serviceId: auth-center
      stripPrefix: true  //去除前缀
    product1:
      path: /product1/**
      serviceId: product1-server
      stripPrefix: true
    product2:
      path: /product2/**
      serviceId: product2-server
      stripPrefix: true

启动类

@EnableZuulProxy
@EnableEurekaClient
@SpringBootApplication
public class ZuulGatewayApp {
    public static void main(String[] args) {
        SpringApplication.run(ZuulGatewayApp.class,args);
    }
}

资源服务器搭建

依赖

    <dependencies>
        <dependency>
            <groupId>org.springframework.cloud</groupId>
            <artifactId>spring-cloud-starter-netflix-eureka-client</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-actuator</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.cloud</groupId>
            <artifactId>spring-cloud-starter-openfeign</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-data-redis</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.cloud</groupId>
            <artifactId>spring-cloud-starter-oauth2</artifactId>
        </dependency>
        <dependency>
            <groupId>org.projectlombok</groupId>
            <artifactId>lombok</artifactId>
        </dependency>
        <dependency>
            <groupId>org.apache.commons</groupId>
            <artifactId>commons-lang3</artifactId>
        </dependency>
        <dependency>
            <groupId>commons-io</groupId>
            <artifactId>commons-io</artifactId>
        </dependency>
        <dependency>
            <groupId>commons-collections</groupId>
            <artifactId>commons-collections</artifactId>
        </dependency>
        <dependency>
            <groupId>com.baomidou</groupId>
            <artifactId>mybatis-plus-boot-starter</artifactId>
        </dependency>
        <dependency>
            <groupId>mysql</groupId>
            <artifactId>mysql-connector-java</artifactId>
        </dependency>

    </dependencies>

资源服务器配置

TokenConfig  令牌解析 
ResourceServerConfig  资源服务器配置 
TokenAuthenticationFilter  token获取 手动认证
@Configuration
public class TokenConfig {


    public static final String SIGNING_KEY = "xxxxxxx";

    @Bean
    public JwtAccessTokenConverter jwtAccessTokenConverter() {
        JwtAccessTokenConverter converter = new JwtAccessTokenConverter();
        ClassPathResource resource = new ClassPathResource("public.txt");
        String publicKey = null;
        try {
            publicKey = IOUtils.toString(resource.getInputStream(), StandardCharsets.UTF_8);
        } catch (IOException e) {
            e.printStackTrace();
        }
        converter.setVerifierKey(publicKey);
        return converter;
    }

    @Bean
    public TokenStore tokenStore() {
        return new JwtTokenStore(jwtAccessTokenConverter());
    }

}
@Configuration
@EnableResourceServer
@EnableGlobalMethodSecurity(prePostEnabled = true)
public class ResourceServerConfig extends ResourceServerConfigurerAdapter {
    @Resource
    private TokenStore tokenStore;
    @Override
    public void configure(ResourceServerSecurityConfigurer resources){
        resources.resourceId(TokenConstant.RESOURE_ID).tokenStore(tokenStore).stateless(true);
    }
    @Override
    public void configure(HttpSecurity http) throws Exception {
        http
                .addFilterBefore(new TokenAuthenticationFilter(),UsernamePasswordAuthenticationFilter.class)
                .sessionManagement()
                .sessionCreationPolicy(SessionCreationPolicy.STATELESS)
                .and().authorizeRequests().anyRequest().authenticated();
    }
    
}
/**
 * 获取网关转发过来的请求头中保存的明文token值,用户信息
 */
public class TokenAuthenticationFilter extends OncePerRequestFilter {

    @Override
    protected void doFilterInternal(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse, FilterChain filterChain) throws ServletException, IOException {
        String authToken = httpServletRequest.getHeader(TokenConstant.TOKEN_NAME);
        if(StringUtils.isNotEmpty(authToken)) {
            String authTokenJson = new String(Base64Utils.decodeFromString(authToken));
            ObjectMapper objectMapper = new ObjectMapper();
            Map hashMap = objectMapper.readValue(authTokenJson, Map.class);
            Object principal = hashMap.get(TokenConstant.PRINCIPAL);
            Object details = hashMap.get(TokenConstant.DETAILS);
            ArrayList authorities = (ArrayList) hashMap.get(TokenConstant.AUTHORITIES);
            List<GrantedAuthority> grantedAuthorities = new ArrayList<> ();
            authorities.forEach(authorite->grantedAuthorities.add(new SimpleGrantedAuthority(authorite.toString())));
            UsernamePasswordAuthenticationToken usernamePasswordAuthenticationToken = new UsernamePasswordAuthenticationToken(principal,null,grantedAuthorities);
            usernamePasswordAuthenticationToken.setDetails(details);
            SecurityContextHolder.getContext().setAuthentication(usernamePasswordAuthenticationToken);
        }
        filterChain.doFilter(httpServletRequest, httpServletResponse);
    }


}

Fegin配置

请求携带请求头信息

支持https

public class FeignBasicAuthRequestInterceptor implements RequestInterceptor {
    @Override
    public void apply(RequestTemplate requestTemplate) {
        ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder
                .getRequestAttributes();
        HttpServletRequest request = attributes.getRequest();
        Enumeration<String> headerNames = request.getHeaderNames();
        if (headerNames != null) {
            while (headerNames.hasMoreElements()) {
                String name = headerNames.nextElement();
                String values = request.getHeader(name);
                requestTemplate.header(name, values);
            }
        }
    }
}

@Configuration
public class FeignHeaderSupportConfig {
    /**
     * feign请求拦截器
     *
     * @return
     */
    @Bean
    public RequestInterceptor requestInterceptor(){
        return new FeignBasicAuthRequestInterceptor();
    }



}
public class FeignHttpSupportConfig{
    @Bean
    Logger.Level feignLoggerLevel() {
        return Logger.Level.FULL;
    }

    @Bean
    public Client getClient() throws  KeyManagementException, NoSuchAlgorithmException {
        SSLContext sslContext = SSLContext.getInstance("tls");
        final TrustManager[] trustAllCerts = new TrustManager[]{
                new X509TrustManager() {
                    @Override
                    public void checkClientTrusted(java.security.cert.X509Certificate[] chain, String authType) throws CertificateException {
                    }

                    @Override
                    public void checkServerTrusted(java.security.cert.X509Certificate[] chain, String authType) throws CertificateException {
                    }

                    @Override
                    public java.security.cert.X509Certificate[] getAcceptedIssuers() {
                        return new java.security.cert.X509Certificate[]{};
                    }
                }
        };
        sslContext.init(null, trustAllCerts, new SecureRandom());

        return new Client.Default(sslContext.getSocketFactory(), null);
    }
}

配置文件

eureka:
  client:
    service-url:
      defaultZone: http://127.0.0.1:8761/eureka
  instance:
    instance-id: ${spring.application.name}${server.port}
    prefer-ip-address: true
server:
  port: 9002
spring:
  application:
    name: product2-server

启动类和测试代码


@FeignClient(value = "product1-server",configuration = {FeignHeaderSupportConfig.class, FeignHttpSupportConfig.class})
public interface Product1Client {
    @GetMapping
    String get();
}
@RestController
@EnableFeignClients
@EnableEurekaClient
@SpringBootApplication(exclude = {RedisAutoConfiguration.class, DataSourceAutoConfiguration.class})
public class Product2ServerApp {

    public static void main(String[] args) {
        SpringApplication.run(Product2ServerApp.class, args);
    }

    @GetMapping
    public String test(@AuthenticationPrincipal Authentication authentication) {
        System.out.println("Product2ServerApp访问成功");
        System.out.println(authentication);
        return "Product2ServerApp";
    }

    @PreAuthorize("hasAuthority('sys:user:list')")
    @GetMapping("/userlist")
    public String string() {
        return "恭喜你 拥有sys:user:list权限";
    }

    @PreAuthorize("hasAuthority('xxxx')")
    @GetMapping("/testAA")
    public String test() {
        return "这个权限不开放";
    }

    @Resource
    Product1Client product1Client;
    @GetMapping("/fegin")
    public String  aa(){
        String s = product1Client.get();
        return "远程调用成功";
    }
}

测试

推荐使用密码模式获取token进行测试

通过网关获取token

http://localhost/auth/oauth/token

通过网关校验token

http://localhost:9000/oauth/check_token

 

 

 

  • 0
    点赞
  • 10
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值