Spring Security Oauth2 password登录及token续约--增加动态权限功能

项目地址百度网盘:spring-security-oauth2_免费高速下载|百度网盘-分享无限制

父工程使用的是spring-boot 2.1.3RELEASE

父工程依赖如下:

<?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>

    <groupId>com.funtl</groupId>
    <artifactId>oauth</artifactId>
    <version>1.0-SNAPSHOT</version>
    <modules>
        <module>spring-security-oauth2-server</module>
        <module>spring-security-oauth2-server-JDBC</module>
        <module>spring-security-oauth2-server-RBAC</module>
        <module>spring-security-oauth2-resource</module>
    </modules>
    <packaging>pom</packaging>

    <parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>2.1.3.RELEASE</version>
        <relativePath/>
    </parent>

    <properties>
        <spring-cloud.version>Greenwich.SR2</spring-cloud.version>
        <mysql.version>5.1.32</mysql.version>
        <mapper.starter.version>2.0.3</mapper.starter.version>
    </properties>

    <dependencyManagement>
        <dependencies>
            <!-- Spring Cloud -->
            <dependency>
                <groupId>org.springframework.cloud</groupId>
                <artifactId>spring-cloud-dependencies</artifactId>
                <version>${spring-cloud.version}</version>
                <type>pom</type>
                <scope>import</scope>
            </dependency>

            <!-- 数据库 -->

        </dependencies>
    </dependencyManagement>
    <dependencies>
        <dependency>
            <groupId>org.projectlombok</groupId>
            <artifactId>lombok</artifactId>
        </dependency>
    </dependencies>
    <build>
        <plugins>
            <plugin>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-maven-plugin</artifactId>
            </plugin>
        </plugins>
    </build>
</project>

子工程认证中心

pom依赖

<?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">
    <parent>
        <artifactId>oauth</artifactId>
        <groupId>com.funtl</groupId>
        <version>1.0-SNAPSHOT</version>
    </parent>
    <modelVersion>4.0.0</modelVersion>

    <groupId>com.funtl</groupId>
    <artifactId>spring-security-oauth2-server-RBAC</artifactId>

    <dependencies>
        <!-- Spring Boot -->
        <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>
        </dependency>

        <!-- Spring Security -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-security</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.cloud</groupId>
            <artifactId>spring-cloud-starter-oauth2</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-data-redis</artifactId>
        </dependency>

        <dependency>
            <groupId>com.zaxxer</groupId>
            <artifactId>HikariCP</artifactId>
            <version>${hikaricp.version}</version>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-jdbc</artifactId>
            <exclusions>
                <!-- 排除 tomcat-jdbc 以使用 HikariCP -->
                <exclusion>
                    <groupId>org.apache.tomcat</groupId>
                    <artifactId>tomcat-jdbc</artifactId>
                </exclusion>
            </exclusions>
        </dependency>
        <dependency>
            <groupId>mysql</groupId>
            <artifactId>mysql-connector-java</artifactId>
            <version>${mysql.version}</version>
        </dependency>
        <dependency>
            <groupId>tk.mybatis</groupId>
            <artifactId>mapper-spring-boot-starter</artifactId>
            <version>${mapper.starter.version}</version>
        </dependency>
        <dependency>
            <groupId>tk.mybatis</groupId>
            <artifactId>mapper-core</artifactId>
            <version>1.0.4</version>
        </dependency>
        <dependency>
            <groupId>tk.mybatis</groupId>
            <artifactId>mapper-extra</artifactId>
            <version>1.0.4</version>
        </dependency>
        <dependency>
            <groupId>tk.mybatis</groupId>
            <artifactId>mapper-base</artifactId>
            <version>1.0.4</version>
        </dependency>
    </dependencies>
</project>

application.yml配置文件如下:

spring:
  application:
    name: oauth2-server
  datasource:
    type: com.zaxxer.hikari.HikariDataSource
    driver-class-name: com.mysql.jdbc.Driver
    jdbc-url: jdbc:mysql://192.168.2.163:3306/oauth2?useUnicode=true&characterEncoding=utf8
    username: root
    password: 123456
    hikari:
      minimum-idle: 5
      idle-timeout: 600000
      maximum-pool-size: 10
      auto-commit: true
      pool-name: MyHikariCP
      max-lifetime: 1800000
      connection-timeout: 30000
      connection-test-query: SELECT 1
  redis:
    host: 192.168.2.163
server:
  port: 8080
mybatis:
  type-aliases-package: oauth2.server.domain
  mapper-locations: classpath:mapper/*.xml

启动类:

package oauth2;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import tk.mybatis.spring.annotation.MapperScan;

@SpringBootApplication
@MapperScan(basePackages = "oauth2.server.mapper")
public class RBACServerApplication {
    public static void main(String[] args) {
        SpringApplication.run(RBACServerApplication.class,args);
    }
}

权限服务控制类:AuthorizationServerConfiguration

endpoints.tokenStore(new MyRedisTokenStoreService(redisConnectionFactory,null))设置的MyRedisTokenStoreService用刷新redis时限来实现用户不掉线;endpoints.tokenStore(new RedisTokenStore(redisConnectionFactory))需要前端请求RefreshToken来获取新的token实现不掉线
package oauth2.server.config;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.boot.jdbc.DataSourceBuilder;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Primary;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.oauth2.config.annotation.configurers.ClientDetailsServiceConfigurer;
import org.springframework.security.oauth2.config.annotation.web.configuration.AuthorizationServerConfigurerAdapter;
import org.springframework.security.oauth2.config.annotation.web.configuration.EnableAuthorizationServer;
import org.springframework.security.oauth2.config.annotation.web.configurers.AuthorizationServerEndpointsConfigurer;
import org.springframework.security.oauth2.config.annotation.web.configurers.AuthorizationServerSecurityConfigurer;
import org.springframework.security.oauth2.provider.ClientDetailsService;
import org.springframework.security.oauth2.provider.client.JdbcClientDetailsService;
import org.springframework.security.oauth2.provider.token.DefaultTokenServices;
import org.springframework.security.oauth2.provider.token.TokenStore;
import org.springframework.security.oauth2.provider.token.store.JdbcTokenStore;
import org.springframework.security.oauth2.provider.token.store.redis.RedisTokenStore;

import javax.sql.DataSource;
import java.util.concurrent.TimeUnit;

@Configuration
@EnableAuthorizationServer
public class AuthorizationServerConfiguration extends AuthorizationServerConfigurerAdapter {

    @Autowired
    private BCryptPasswordEncoder passwordEncoder;

    @Autowired
    private RedisConnectionFactory redisConnectionFactory;

    /**
     * 注入 AuthenticationManager 支持password grant_type
     */
    @Autowired
    private AuthenticationManager authenticationManagerBean;

    /**
     *Primary注解,去除系统自动配置的数据源
     * @return
     */
    @Primary
    @Bean
    @ConfigurationProperties(prefix = "spring.datasource")
    public DataSource dataSource(){
        // 配置数据源(注意,我使用的是 HikariCP 连接池),以上注解是指定数据源,否则会有冲突
       return DataSourceBuilder.create().build();
    }

    @Bean
    public TokenStore tokenStore(){
        // 基于 JDBC 实现,令牌保存到数据
        return new JdbcTokenStore(dataSource());
    }

    @Bean
    public ClientDetailsService jdbcDetailsService(){
        // 基于 JDBC 实现,需要事先在数据库配置客户端信息
        return new JdbcClientDetailsService(dataSource());
    }

    @Override
    public void configure(AuthorizationServerSecurityConfigurer security) throws Exception {
        security.allowFormAuthenticationForClients()
                .passwordEncoder(passwordEncoder)
                .tokenKeyAccess("permitAll()") //允许/oauth/token调用
                .checkTokenAccess("isAuthenticated()");  //允许/oauth/check_token被调用
    }

    /**
     * 认证授权
     * @param clients
     * @throws Exception
     */
    @Override
    public void configure(ClientDetailsServiceConfigurer clients) throws Exception {
        // 读取客户端配置
        //告诉springoauth2使用上面定义的jdbcDetailsService存储
       clients.withClientDetails(jdbcDetailsService())
                ;
    }

    @Override
    public void configure(AuthorizationServerEndpointsConfigurer endpoints) throws Exception {

        // 设置令牌
        //告诉springoauth2使用上面定义的tokenStore存储到数据库
//        endpoints.tokenStore(tokenStore())
        //把token相关东西存入redis中,需要前端RefreshTokens来实现用户不掉线
//        endpoints.tokenStore(new RedisTokenStore(redisConnectionFactory))
        //自定义RedisTokenStore,可以根据用户浏览刷新token时限
          endpoints.tokenStore(new MyRedisTokenStoreService(redisConnectionFactory,null))
                 .authenticationManager(authenticationManagerBean);
                //该字段设置设置refresh token是否重复使用,true:reuse;false:no reuse.
//                .reuseRefreshTokens(false);

        //配置TokenService参数
        DefaultTokenServices tokenService = new DefaultTokenServices();
        tokenService.setTokenStore(endpoints.getTokenStore());
        tokenService.setSupportRefreshToken(true);
        tokenService.setClientDetailsService(endpoints.getClientDetailsService());
        tokenService.setTokenEnhancer(endpoints.getTokenEnhancer());
        tokenService.setAccessTokenValiditySeconds((int) TimeUnit.MINUTES.toSeconds(2)); //3分钟
        //refresh token时长大于2被的access_token时长,当access_token过期,
        // 前端发送refresh_token重新获取access_token和refresh_token,如此实现登录一直操作登录不过期
        //https://www.code996.cn/post/2018/token-front/
        tokenService.setRefreshTokenValiditySeconds((int)TimeUnit.MINUTES.toSeconds(6)); //6分钟
        //该字段设置设置refresh token是否重复使用,true:reuse;false:no reuse.
        tokenService.setReuseRefreshToken(false);

        endpoints.tokenServices(tokenService);
    }
}
WebSecurityConfiguration类:

设置加密方式及制定自己实现的UserDetailsServiceImpl类来拓展权限

package oauth2.server.config;

import lombok.SneakyThrows;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder;
import org.springframework.security.config.annotation.method.configuration.EnableGlobalMethodSecurity;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.builders.WebSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;

@Configuration
@EnableWebSecurity
@EnableGlobalMethodSecurity(prePostEnabled = true, securedEnabled = true, jsr250Enabled = true)
public class WebSecurityConfiguration extends WebSecurityConfigurerAdapter {

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http
                .authorizeRequests()
                .and()
                .formLogin()
                .loginPage("/login").failureForwardUrl("/login-error")
//                .successForwardUrl("/index")
                .permitAll();
    }

    @Bean
    @Override
    public UserDetailsService userDetailsService() {
        return new UserDetailsServiceImpl();
    }

    @Bean
    public BCryptPasswordEncoder passwordEncoder(){
        // 设置默认的加密方式
        return new BCryptPasswordEncoder();
    }

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

    /**
     * 管理器
     * 加载这个管理器,让其支持password模式授权
     * @return
     * @throws Exception
     */
    @Bean
    @Override
    @SneakyThrows
    public AuthenticationManager authenticationManagerBean() throws Exception {
        return super.authenticationManagerBean();
    }

    @Override
    public void configure(WebSecurity web) throws Exception {
        web.ignoring().antMatchers("/oauth/check_token");
    }
}

redis存储token实现类MyRedisTokenStoreService

package oauth2.server.config;

import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.security.oauth2.common.DefaultOAuth2AccessToken;
import org.springframework.security.oauth2.common.OAuth2AccessToken;
import org.springframework.security.oauth2.provider.ClientDetails;
import org.springframework.security.oauth2.provider.ClientDetailsService;
import org.springframework.security.oauth2.provider.OAuth2Authentication;
import org.springframework.security.oauth2.provider.OAuth2Request;
import org.springframework.security.oauth2.provider.token.store.redis.RedisTokenStore;
import java.util.Date;

/**
 * 2种方式续期token,1:前端接收到后台返回401然后请求refresh_token重新获取token
 * 2:刷新redis里的token时限
 * access_token续期
 * 自定义TokenStoreService
 */
public class MyRedisTokenStoreService extends RedisTokenStore {
    private ClientDetailsService clientDetailsService;

    public MyRedisTokenStoreService(RedisConnectionFactory connectionFactory, ClientDetailsService clientDetailsService) {
        super(connectionFactory);
        this.clientDetailsService = clientDetailsService;
    }

    @Override
    public OAuth2Authentication readAuthentication(OAuth2AccessToken token) {
        OAuth2Authentication result = readAuthentication(token.getValue());
        if (result != null) {
            // 如果token没有失效  更新AccessToken过期时间
            DefaultOAuth2AccessToken oAuth2AccessToken = (DefaultOAuth2AccessToken) token;

            //重新设置过期时间
            int validitySeconds = getAccessTokenValiditySeconds(result.getOAuth2Request());
            if (validitySeconds > 0) {
                oAuth2AccessToken.setExpiration(new Date(System.currentTimeMillis() + (validitySeconds * 1000L)));
            }

            //将重新设置过的过期时间重新存入redis, 此时会覆盖redis中原本的过期时间
            storeAccessToken(token, result);
        }
        return result;
    }

    protected int getAccessTokenValiditySeconds(OAuth2Request clientAuth) {
        if (clientDetailsService != null) {
            ClientDetails client = clientDetailsService.loadClientByClientId(clientAuth.getClientId());
            Integer validity = client.getAccessTokenValiditySeconds();
            if (validity != null) {
                return validity;
            }
        }
        // 2 分钟.
        int accessTokenValiditySeconds = 60 * 2;
        return accessTokenValiditySeconds;
    }
}
UserDetailsServiceImpls实现类:通过查询用户权限并在ResourceServerConfiguration设置需要访问的权限
package oauth2.server.config;

import oauth2.server.domain.TbPermission;
import oauth2.server.domain.TbUser;
import oauth2.server.service.TbPermissionService;
import oauth2.server.service.TbUserService;
import org.assertj.core.util.Lists;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.stereotype.Service;

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

@Service
public class UserDetailsServiceImpl implements UserDetailsService {

    @Autowired
    private TbUserService tbUserService;

    @Autowired
    private TbPermissionService tbPermissionService;


    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        TbUser tbUser = tbUserService.getByUsername(username);
        List<GrantedAuthority> grantedAuthorities = Lists.newArrayList();
        if (tbUser != null) {
            // 获取用户授权
            List<TbPermission> tbPermissions = tbPermissionService.selectByUserId(tbUser.getId());
            // 声明用户授权

            for (TbPermission tbPermission : tbPermissions) {
                if (tbPermission != null && tbPermission.getEnname() != null) {
                    GrantedAuthority grantedAuthority = new SimpleGrantedAuthority(tbPermission.getEnname());
                    grantedAuthorities.add(grantedAuthority);
                }
            }
        }
        return new User(tbUser.getUsername(),tbUser.getPassword(),grantedAuthorities);
    }
}

资源模块自定义权限MyAuthorityPermitImpl

接口:

import org.springframework.security.core.Authentication;
import javax.servlet.http.HttpServletRequest;

/**
 * 自定义权限类
 */
public interface MyAuthorityPermit {

    /**
     * 权限校验逻辑方法 ,登录逻辑本质上处理不了
     * 当用户有权限返回true,没权限返回false
     * 当用户登录成功后,通过用户携带的权限集合,验证用户本次访问的路径是否有访问权限
     * @param request 获取请求路径,请求参数,请求作用域
     * @param authentication 用户登录后security维护的用户登录标记
     * @return true/false。 access方法,参数如果是字符串"true"有权限,"false"无权限
     */
    public boolean hasAuthority(HttpServletRequest request, Authentication authentication);
}

实现类:


import com.aadata.CAManage.authority.MyAuthorityPermit;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.stereotype.Component;

import javax.servlet.http.HttpServletRequest;


@Component("myAuthorityPermitImpl")
public class MyAuthorityPermitImpl implements MyAuthorityPermit {

    /**
     * 权限校验逻辑方法 ,登录逻辑本质上处理不了
     * 当用户有权限返回true,没权限返回false
     * 当用户登录成功后,通过用户携带的权限集合,验证用户本次访问的路径是否有访问权限
     * @param request 获取请求路径,请求参数,请求作用域
     * @param authentication 用户登录后security维护的用户登录标记
     * @return true/false。 access方法,参数如果是字符串"true"有权限,"false"无权限
     */
    @Override
    public boolean hasAuthority(HttpServletRequest request, Authentication authentication) {
//        // 请求中携带的角色和资源(权限),一般不用这种,一般使用url
//        String[] roles = request.getParameterValues("roles");
//        String[] authorities = request.getParameterValues("authorities");
        // 请求url判断
        String servletPath = request.getServletPath();

        System.out.println("请求路径:"+servletPath);
        System.out.println("权限集合:"+authentication.getAuthorities());
        System.out.println("权限集合:"+authentication.getAuthorities());
        System.out.println("身份信息:"+authentication.getPrincipal());
        System.out.println("凭证信息:"+authentication.getCredentials());  // 一直为空
        System.out.println("明细:"+authentication.getDetails());
        System.out.println("是否登录:"+authentication.isAuthenticated());

//        // 角色判断
//        for(String role:roles){
//            String tem = "ROLE_"+role; // 前缀拼接
//            // 判断用户权限集合中,是否包含当前请求路径需要的权限
//            if(authentication.getAuthorities().contains(new SimpleGrantedAuthority(tem))){
//                return true;
//            }
//        }
//        // 权限(资源)判断
//        for(String authority:authorities){
//            // 判断用户权限集合中,是否包含当前请求路径需要的权限
//            if(authentication.getAuthorities().contains(new SimpleGrantedAuthority(authority))){
//                return true;
//            }
//        }

        // 请求url判断,自己组装最终的servletPath,只要和UserDetailsServiceImpl中设置的grantedAuthorities一样即可
        if(authentication.getAuthorities().contains(new SimpleGrantedAuthority(servletPath))){
            // 本测试例子不满足
            return true;
        }


        // 无权限,返回false
        return false;
    }
}

资源模块ResourceServerConfiguration(在spring-security-oauth2-resource子模块里)类:

在使用SpringEL表达式(@myAuthorityPermitImpl.hasAuthority(request,authentication))时,请求会报错,无法找到Bean:myAuthorityPermitImpl,所以引OAuth2WebSecurityExpressionHandler;具体原因请参考:

https://github.com/spring-projects/spring-security-oauth/issues/730#issuecomment-219480394
package com.oauth2.resource.config;

import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.method.configuration.EnableGlobalMethodSecurity;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.oauth2.config.annotation.web.configuration.EnableResourceServer;
import org.springframework.security.oauth2.config.annotation.web.configuration.ResourceServerConfigurerAdapter;

@Configuration
@EnableResourceServer
@EnableGlobalMethodSecurity(prePostEnabled = true, securedEnabled = true, jsr250Enabled = true)
public class ResourceServerConfiguration extends ResourceServerConfigurerAdapter {

    @Override
    public void configure(HttpSecurity http) throws Exception {
        http
                .exceptionHandling()
                .and()
                .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
                .and()
                .authorizeRequests()
//                .antMatchers("/").hasAuthority("SystemContent")
//                .antMatchers("/view/**").hasAuthority("SystemContentView")
//                .antMatchers("/insert/**").hasAuthority("SystemContentInsert")
//                .antMatchers("/update/**").hasAuthority("SystemContentUpdate")
 //               .antMatchers("/delete/**").hasAuthority("SystemContentDelete")
.antMatchers("/**").access("@myAuthorityPermitImpl.hasAuthority(request,authentication)")

;
    }


    @Autowired
    private OAuth2WebSecurityExpressionHandler expressionHandler;
    @Bean
    public OAuth2WebSecurityExpressionHandler oAuth2WebSecurityExpressionHandler(ApplicationContext applicationContext) {
        OAuth2WebSecurityExpressionHandler expressionHandler = new OAuth2WebSecurityExpressionHandler();
        expressionHandler.setApplicationContext(applicationContext);
        return expressionHandler;
    }


    @Override
    public void configure(ResourceServerSecurityConfigurer resources)  {
//        super.configure(resources);
//        resources.authenticationEntryPoint(new LLGAuthenticationEntryPoint());
//        resources.accessDeniedHandler(customAccessDeniedHandler);
        resources.expressionHandler(expressionHandler);
    }

}

postman测试:

Authorization设置

如果不设置上面一步Authorization设置,在这边设置

请求参数设置及获取的token

使用access_token获取资源

如果配置refresh_token,也可以用refresh_token进行token的续约,postman请求如下

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值