全网最全的微服务+Outh2套餐,Gateway整合Oauth2!(入门到精通,附源码)满足你的味蕾需要(三)

上篇文章主要讲解Oauth2模块、user-service模块、feign模块,那么作为重中之重的gateway,我们将其做成资源服务器来进行开发。

一、资源服务器的实现方式

资源服务器在实际开发有两种实现方式:

(1)gateway做网关转发,不做资源服务器,由各个微服务模块自己去做资源服务器;

(2)gateway做网关转发 并且 资源服务器

前者方案使得每一个微服务模块都需要导入oauth2相关依赖,并且做处理,过于繁琐且耦合高。

所以本文章在接下来介绍,也就是文章的重点,并且会介绍到如何解决通过gateway去认证授权,跳转到oauth2认证授权后,跳转不回或重定向不到gatway的bug。

二、gateway模块

 1、模块结构

2、pom

    <dependencies>
        <dependency>
            <groupId>cn.hutool</groupId>
            <artifactId>hutool-all</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.security</groupId>
            <artifactId>spring-security-oauth2-jose</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.security</groupId>
            <artifactId>spring-security-oauth2-resource-server</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.cloud</groupId>
            <artifactId>spring-cloud-starter-oauth2</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.cloud</groupId>
            <artifactId>spring-cloud-starter-gateway</artifactId>
        </dependency>
        <dependency>
            <groupId>com.alibaba.cloud</groupId>
            <artifactId>spring-cloud-starter-alibaba-nacos-discovery</artifactId>
        </dependency>

        <!--加载bootstrap 文件-->
        <dependency>
            <groupId>org.springframework.cloud</groupId>
            <artifactId>spring-cloud-starter-bootstrap</artifactId>
        </dependency>
        <!--客户端负载均衡loadbalancer-->
        <dependency>
            <groupId>org.springframework.cloud</groupId>
            <artifactId>spring-cloud-starter-loadbalancer</artifactId>
        </dependency>
        <dependency>
            <groupId>org.projectlombok</groupId>
            <artifactId>lombok</artifactId>
        </dependency>
        <dependency>
            <groupId>com.white</groupId>
            <artifactId>common</artifactId>
            <version>1.0</version>
            <scope>compile</scope>
        </dependency>

    </dependencies>

3、bootstrap

server:
  port: 10000

spring:
  application:
    name: gateway
  profiles:
    active: dev
  cloud:
    gateway:
      routes:
        - id: user
          uri: lb://user-service  # 客户端负载均衡 loadbalancer
          predicates:
            - Path=/user/**,/admin/**
        - id: order
          uri: lb://order-service
          predicates:
            - Path=/order/**
        - id: oauth
          uri: lb://oauth-service
          predicates:
            - Path=/uaa/**
    nacos:
      discovery:
        server-addr: localhost:8848
  redis:
    host: 127.0.0.1
    port: 6379
  security:
    oauth2:
      resourceserver:
        jwt:
          #配置RSA的公钥访问地址  端口对应上篇文章的oauth2模块服务的端口
          jwk-set-uri: 'http://localhost:8101/uaa/rsa/publicKey'
  main:
    web-application-type: reactive

4.GatewayApp启动类

@SpringBootApplication(exclude= {DataSourceAutoConfiguration.class})
@EnableGlobalMethodSecurity(prePostEnabled = true)
public class GatewayApp
{
    public static void main( String[] args )
    {

        SpringApplication.run(GatewayApp.class,args);
    }
}

5.IgnoreUrlsConfig

package com.white.gateway.config;

import org.springframework.stereotype.Component;

import java.util.ArrayList;
import java.util.List;

@Component
public class IgnoreUrlsConfig {
    public List<String> getUrls() {
        ArrayList<String> objects = new ArrayList<>();
        objects.add("/uaa/**");
        objects.add("/user/**");
        return objects;
    }
}

6.IgnoreUrlsRemoveJwtFilter

package com.white.gateway.filter;

import com.white.gateway.config.IgnoreUrlsConfig;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.server.reactive.ServerHttpRequest;
import org.springframework.stereotype.Component;
import org.springframework.util.AntPathMatcher;
import org.springframework.util.PathMatcher;
import org.springframework.web.server.ServerWebExchange;
import org.springframework.web.server.WebFilter;
import org.springframework.web.server.WebFilterChain;
import reactor.core.publisher.Mono;
import var.TokenVar;

import java.net.URI;
import java.util.List;

/**
 * 白名单路径访问时需要移除JWT请求头
 */
@Component
public class IgnoreUrlsRemoveJwtFilter implements WebFilter {
    @Autowired
    private IgnoreUrlsConfig ignoreUrlsConfig;

    @Override
    public Mono<Void> filter(ServerWebExchange exchange, WebFilterChain chain) {
        ServerHttpRequest request = exchange.getRequest();
        URI uri = request.getURI();
        PathMatcher pathMatcher = new AntPathMatcher();

        //白名单路径移除JWT请求头
        List<String> ignoreUrls = ignoreUrlsConfig.getUrls();
        for (String ignoreUrl : ignoreUrls) {
            if (pathMatcher.match(ignoreUrl, uri.getPath())) {
                request = request.mutate().header(TokenVar.TOKEN_HEAD, "").build();
                exchange = exchange.mutate().request(request).build();

                return chain.filter(exchange);
            }
        }

        return chain.filter(exchange);
    }
}

7.AuthGlobalFilter

注意:这里拦截了路径为/oauth/authorize,在其进行跳转的时候构建响应包装类,解决通过gateway去oauth认证时,oauth成功登录后跳转不回gateway网关的bug。

package com.white.gateway.filter;

import cn.hutool.core.util.StrUtil;
import cn.hutool.json.JSONUtil;
import cn.hutool.json.JSONObject;
import com.alibaba.cloud.commons.lang.StringUtils;
import com.nimbusds.jose.JWSObject;
import org.reactivestreams.Publisher;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.cloud.gateway.filter.GatewayFilterChain;
import org.springframework.cloud.gateway.filter.GlobalFilter;
import org.springframework.cloud.gateway.filter.NettyWriteResponseFilter;
import org.springframework.core.Ordered;
import org.springframework.core.io.buffer.DataBuffer;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.http.HttpStatus;
import org.springframework.http.server.reactive.ServerHttpRequest;
import org.springframework.http.server.reactive.ServerHttpResponse;
import org.springframework.http.server.reactive.ServerHttpResponseDecorator;
import org.springframework.stereotype.Component;
import org.springframework.web.server.ServerWebExchange;
import reactor.core.publisher.Mono;
import var.TokenVar;

import java.text.ParseException;
import java.util.Objects;

/**
 * 将登录用户的JWT转化成用户信息的全局过滤器
 */
@Component
public class AuthGlobalFilter implements GlobalFilter, Ordered {
    @Autowired
    private RedisTemplate redisTemplate;

    @Override
    public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
        //TODO
        String path = exchange.getRequest().getPath().value();
        System.out.println("拦截到的路径:::" + path);
        if (path.contains("/oauth/authorize") || path.contains("/auth/authorize") || path.contains("/auth/loginBySms")) {
            //构建响应包装类
            HttpResponseDecorator responseDecorator = new HttpResponseDecorator(exchange.getRequest(), exchange.getResponse(), "http://localhost:10000");
            return chain
                    .filter(exchange.mutate().response(responseDecorator).build());
        }


        //认证信息从Header 或 请求参数 中获取
        ServerHttpRequest serverHttpRequest = exchange.getRequest();
        String token = serverHttpRequest.getHeaders().getFirst(TokenVar.TOKEN_HEAD);
        if (Objects.isNull(token)) {
            token = serverHttpRequest.getQueryParams().getFirst(TokenVar.TOKEN_HEAD);
        }

        if (StrUtil.isEmpty(token)) {
            return chain.filter(exchange);
        }
        try {
            //从token中解析用户信息并设置到Header中去
            String realToken = token.replace(TokenVar.TOKEN_PREFIX, "");
            JWSObject jwsObject = JWSObject.parse(realToken);
            String userStr = jwsObject.getPayload().toString();

            // 黑名单token(登出、修改密码)校验
            JSONObject jsonObject = JSONUtil.parseObj(userStr);
            String jti = jsonObject.getStr("jti");

            Boolean isBlack = redisTemplate.hasKey(TokenVar.TOKEN_BLACKLIST_PREFIX + jti);
            if (isBlack) {

            }

            // 存在token且不是黑名单,request写入JWT的载体信息
            ServerHttpRequest request = serverHttpRequest.mutate().header(TokenVar.USER_TOKEN_HEADER, userStr).build();
            exchange = exchange.mutate().request(request).build();
        } catch (ParseException e) {
            e.printStackTrace();
        }

        return chain.filter(exchange);
    }

    public class HttpResponseDecorator extends ServerHttpResponseDecorator {

        private String proxyUrl;

        private ServerHttpRequest request;

        public HttpResponseDecorator(ServerHttpRequest request, ServerHttpResponse delegate, String proxyUrl) {
            super(delegate);
            this.request = request;
            this.proxyUrl = proxyUrl;
        }

        @Override
        public Mono<Void> writeWith(Publisher<? extends DataBuffer> body) {
            HttpStatus status = this.getStatusCode();
            if (status.equals(HttpStatus.FOUND)) {
                String domain = "";
                if (StringUtils.isBlank(proxyUrl)) {
                    domain = request.getURI().getScheme() + "://" + request.getURI().getAuthority();
                } else {
                    domain = proxyUrl;
                }
                String location = getHeaders().getFirst("Location");
                String replaceLocation = location.replaceAll("^((ht|f)tps?):\\/\\/(\\d{1,3}.){3}\\d{1,3}(:\\d+)?", domain);
                getHeaders().set("Location", replaceLocation);
            }
            this.getStatusCode();
            return super.writeWith(body);
        }
    }

    @Override
    public int getOrder() {
        return NettyWriteResponseFilter.WRITE_RESPONSE_FILTER_ORDER - 1;
    }
}

8.ResourceServerManager

注意:这里的鉴权采用了对redis数据进行读取后,匹配当前的路径和请求方式是否与redis数据中一致,在此前提下判断当前的token是否与redis中存放的身份权限一致或包含其中,如果包含或一致才可以放行去请求资源,否则请求资源失败。

至于redis的数据从哪来,下面程序中是模拟添加了一个,实际开发中,在MySQL数据库创建一张相应的请求方式+路径,以及请求时用户必须要有的权限是什么的数据表,通过初次查询缓存到redis中,之后就可以通过redis进行数据读取了,内存速度快。

package com.white.gateway.config;

import cn.hutool.core.collection.CollectionUtil;
import cn.hutool.core.convert.Convert;
import cn.hutool.core.util.StrUtil;
import jdk.nashorn.internal.runtime.GlobalConstants;
import lombok.AllArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.http.HttpMethod;
import org.springframework.http.server.reactive.ServerHttpRequest;
import org.springframework.security.authorization.AuthorizationDecision;
import org.springframework.security.authorization.ReactiveAuthorizationManager;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.web.server.authorization.AuthorizationContext;
import org.springframework.stereotype.Component;
import org.springframework.util.AntPathMatcher;
import org.springframework.util.PathMatcher;
import reactor.core.publisher.Mono;

import java.util.ArrayList;
import java.util.List;
import java.util.Map;
/**
 * @ResourceServerManager.java的作用:鉴权管理器的相关配置
 *                                      负责被ResourceServerConfig.java文件引用
 * @author: white文
 * @time: 2023/5/30 0:57
 */
@Component
@AllArgsConstructor
@Slf4j
public class ResourceServerManager implements ReactiveAuthorizationManager<AuthorizationContext> {

    @Autowired
    private RedisTemplate redisTemplate;
    @Override
    public Mono<AuthorizationDecision> check(Mono<Authentication> mono, AuthorizationContext authorizationContext) {
        ServerHttpRequest request = authorizationContext.getExchange().getRequest();
        if (request.getMethod() == HttpMethod.OPTIONS) { // 预检请求放行
            return Mono.just(new AuthorizationDecision(true));
        }
        PathMatcher pathMatcher = new AntPathMatcher();
        String method = request.getMethodValue();
        String path = request.getURI().getPath();
        String restfulPath = method + ":" + path; // RESTFul接口权限设计: https://www.cnblogs.com/haoxianrui/p/14961707.html
        String token = request.getHeaders().getFirst("Authorization");


        // 如果token为空 或 不是以"bearer "为前缀 则无效并且需要鉴权
        if (!StrUtil.isNotBlank(token) || !StrUtil.startWithIgnoreCase(token, "Bearer ") ) {
            log.info("token为空 或 不是以 bearer 为前缀 则无效并且需要鉴权");
            return Mono.just(new AuthorizationDecision(false));
        }
        log.info("鉴权开始");

        /**
         * 鉴权开始
         *
         * 缓存取 [URL权限-角色集合] 规则数据
         * urlPermRolesRules = [{'key':'GET:/admin/*','value':['ADMIN','TEST']},...]
         */
        Map<String, Object> urlPermRolesRules = redisTemplate.opsForHash().entries("auth:resourceRolesMap");
        if (urlPermRolesRules.isEmpty()) {
            log.info("空的,我手动加一些上去");
            ArrayList<String> objects = new ArrayList<>();
            objects.add("TEST");
            objects.add("USER");
            redisTemplate.opsForHash().put("auth:resourceRolesMap","GET:/admin/*",objects);
            urlPermRolesRules = redisTemplate.opsForHash().entries("auth:resourceRolesMap");
        }

        // 根据请求路径获取有访问权限的角色列表
        List<String> authorizedRoles = new ArrayList<>(); // 拥有访问权限的角色
        boolean requireCheck = false; // 是否需要鉴权,默认未设置拦截规则不需鉴权

        for (Map.Entry<String, Object> permRoles : urlPermRolesRules.entrySet()) {
            String perm = permRoles.getKey();
            System.out.println("路径:"+perm+"  值:"+permRoles.getValue().toString());
            // 判断传过来的 方法:路径 是否在redis缓存中
            if (pathMatcher.match(perm, restfulPath)) {
                List<String> roles = Convert.toList(String.class, permRoles.getValue());
                // 加入授权数组中
                authorizedRoles.addAll(roles);
                if (requireCheck == false) {
                    requireCheck = true;
                }
            }
        }
        // 没有设置拦截规则放行
        if (requireCheck == false) {
            return Mono.just(new AuthorizationDecision(true));
        }

        // 判断JWT中携带的用户角色是否有权限访问
        Mono<AuthorizationDecision> authorizationDecisionMono = mono
                .filter(Authentication::isAuthenticated)
                .flatMapIterable(Authentication::getAuthorities)
                .map(GrantedAuthority::getAuthority)
                .any(authority -> {
                    String roleCode = StrUtil.removePrefix(authority,"ROLE_");// ROLE_ADMIN移除前缀ROLE_得到用户的角色编码ADMIN
                    if (String.valueOf("ADMIN").equals(roleCode)) {
                        return true; // 如果是超级管理员则放行
                    }
                    boolean hasAuthorized = CollectionUtil.isNotEmpty(authorizedRoles) && authorizedRoles.contains(roleCode);
                    return hasAuthorized;
                })
                .map(AuthorizationDecision::new)
                .defaultIfEmpty(new AuthorizationDecision(false));
        return authorizationDecisionMono;
    }
}


9.ResourceServerConfig

在该文件下,如果想实现 token无效或者已过期自定义响应(ServerAuthenticationEntryPoint 和 自定义未授权响应(ServerAccessDeniedHandler) 的话,可以自定义配置返回前端的相关配置,以下代码没做实现,采用程序默认返回给前端的401,如下效果:

package com.white.gateway.config;

import cn.hutool.core.convert.Convert;
import cn.hutool.core.util.ArrayUtil;
import com.white.gateway.filter.IgnoreUrlsRemoveJwtFilter;
import lombok.Setter;
import lombok.SneakyThrows;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.convert.converter.Converter;
import org.springframework.security.authentication.AbstractAuthenticationToken;
import org.springframework.security.config.annotation.web.reactive.EnableWebFluxSecurity;
import org.springframework.security.config.web.server.SecurityWebFiltersOrder;
import org.springframework.security.config.web.server.ServerHttpSecurity;
import org.springframework.security.oauth2.jwt.Jwt;
import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationConverter;
import org.springframework.security.oauth2.server.resource.authentication.JwtGrantedAuthoritiesConverter;
import org.springframework.security.oauth2.server.resource.authentication.ReactiveJwtAuthenticationConverterAdapter;
import org.springframework.security.web.server.SecurityWebFilterChain;
import reactor.core.publisher.Mono;

import java.security.interfaces.RSAPublicKey;
import java.util.List;

/**
 * 资源服务器配置
 */

@Configuration
@EnableWebFluxSecurity
@Slf4j
public class ResourceServerConfig {

    @Autowired
    private ResourceServerManager resourceServerManager;

    @Autowired
    private IgnoreUrlsConfig ignoreUrlsConfig;

    @Autowired
    private IgnoreUrlsRemoveJwtFilter ignoreUrlsRemoveJwtFilter;

    @Bean
    public SecurityWebFilterChain securityWebFilterChain(ServerHttpSecurity http) {
        http
                .oauth2ResourceServer()
                .jwt()
                .jwtAuthenticationConverter(jwtAuthenticationConverter());

        //TODO 对白名单路径,直接移除JWT请求头
        http.addFilterBefore(ignoreUrlsRemoveJwtFilter, SecurityWebFiltersOrder.AUTHENTICATION);

        http.authorizeExchange()
                //白名单配置
                .pathMatchers(Convert.toStrArray(ignoreUrlsConfig.getUrls())).permitAll()
                //鉴权管理器配置
                .anyExchange().access(resourceServerManager)
                .and().csrf().disable();

        return http.build();
    }

    /**
     * @link https://blog.csdn.net/qq_24230139/article/details/105091273
     * ServerHttpSecurity没有将jwt中authorities的负载部分当做Authentication
     * 需要把jwt的Claim中的authorities加入
     * 方案:重新定义权限管理器,默认转换器JwtGrantedAuthoritiesConverter
     */
    @Bean
    public Converter<Jwt, ? extends Mono<? extends AbstractAuthenticationToken>> jwtAuthenticationConverter() {
        JwtGrantedAuthoritiesConverter jwtGrantedAuthoritiesConverter = new JwtGrantedAuthoritiesConverter();
        jwtGrantedAuthoritiesConverter.setAuthorityPrefix("ROLE_");
        jwtGrantedAuthoritiesConverter.setAuthoritiesClaimName("authorities");

        JwtAuthenticationConverter jwtAuthenticationConverter = new JwtAuthenticationConverter();
        jwtAuthenticationConverter.setJwtGrantedAuthoritiesConverter(jwtGrantedAuthoritiesConverter);
        return new ReactiveJwtAuthenticationConverterAdapter(jwtAuthenticationConverter);
    }

}

10.RedisConfig

package com.white.gateway.config;

import com.fasterxml.jackson.annotation.JsonAutoDetect;
import com.fasterxml.jackson.annotation.PropertyAccessor;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.cache.annotation.CachingConfigurerSupport;
import org.springframework.cache.annotation.EnableCaching;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.serializer.Jackson2JsonRedisSerializer;
import org.springframework.data.redis.serializer.StringRedisSerializer;

import javax.annotation.Resource;

/*
 * Redis配置
 * 解决redis在业务逻辑处理层RedisCon上不出错,缓存序列化问题
 * @author: white
 * */
@Configuration
@EnableCaching
public class RedisConfig extends CachingConfigurerSupport {
    @Resource
    RedisConnectionFactory redisConnectionFactory;
    @Bean
    public RedisTemplate<String,Object> redisTemplate(){
        System.out.println("gateway 读取redis配置");
        RedisTemplate<String,Object> redisTemplate=new RedisTemplate<>();
        redisTemplate.setConnectionFactory(redisConnectionFactory);
//Json序列化配置
        //1、String的序列化
        StringRedisSerializer stringRedisSerializer=new StringRedisSerializer();
        // key采用String的序列化方式
        redisTemplate.setKeySerializer(stringRedisSerializer);
        // hash的key也采用String的序列化方式
        redisTemplate.setHashKeySerializer(stringRedisSerializer);

        //2、json解析任意的对象(Object),变成json序列化
        Jackson2JsonRedisSerializer<Object> serializer=new Jackson2JsonRedisSerializer<Object>(Object.class);
        ObjectMapper mapper=new ObjectMapper(); //用ObjectMapper进行转义
        mapper.setVisibility(PropertyAccessor.ALL, JsonAutoDetect.Visibility.ANY);
        //该方法是指定序列化输入的类型,就是将数据库里的数据按照一定类型存储到redis缓存中。
        mapper.enableDefaultTyping(ObjectMapper.DefaultTyping.NON_FINAL);
        serializer.setObjectMapper(mapper);

        // value序列化方式采用jackson
        redisTemplate.setValueSerializer(serializer);
        // hash的value序列化方式采用jackson
        redisTemplate.setHashValueSerializer(serializer);

        return redisTemplate;
    }
}

11、TokenVar

package var;

public interface TokenVar {
    public static final String APP_SECRET ="white";
    public static final String TOKEN_HEAD="Authorization"; // 认证信息Http请求头

    public static final String TOKEN_PREFIX = "Bearer "; //  JWT令牌前缀
    /**
     * JWT存储权限前缀
     */
    String AUTHORITY_PREFIX = "ROLE_";

    /**
     * JWT存储权限属性
     */
    String AUTHORITY_CLAIM_NAME = "authorities";

    /**
     * 后台client_id
     */
    String ADMIN_CLIENT_ID = "api-admin";

    /**
     * 前端client_id
     */
    String PORTAL_CLIENT_ID = "api-portal";

    /**
     * 后台接口路径匹配
     */
    String ADMIN_URL_PATTERN = "/admin/**";

    /**
     * Redis缓存权限规则key
     */
    String RESOURCE_ROLES_MAP_KEY = "auth:resourceRolesMap";

    /**
     * 用户信息Http请求头
     */
    String USER_TOKEN_HEADER = "user";

    /**
     * 黑名单
     */
    String TOKEN_BLACKLIST_PREFIX = "blacklist";
}

 三、测试

与第二篇文章的测试相同,只不过我们将地址的端口改成10000,意思是通过网关去请求oauth认证授权。

1、请求授权码

http://localhost:10000/uaa/oauth/authorize?client_id=123&response_type=code&scop=all&redirect_uri=http://localhost:10000

自动跳转到:http://localhost:10000/uaa/login

2、请求令牌

3、验证令牌

4、刷新令牌

 5.请求资源,不带token

在前面的过滤中,我们仅仅对/user/**,/uaa/**两个路径进行白名单路径设置,而在user-service模块中,小编还设置一个/admin/**的一个路径,该路径没有被设置进白名单,并且在redis的设置中,该路径需要权限为:admin

现在不带token来请求/admin/1,如下,结果为401:

 6.请求资源,带有token

现在我们带着token,并且token中的用户信息权限为admin

四、进一步调优

到此,gateway+oauth整合完成了,接下来会在评论区出下一章,下一章会针对oauth的推出登录如何解决以及如何整合第三方应用进行登录(如:gitee平台)。

  • 1
    点赞
  • 5
    收藏
    觉得还不错? 一键收藏
  • 1
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值