Spring Security + JWT 完成RBAC动态授权

此篇文章为spring security系列的第一篇,着重讲解如何通过spring security完成企业级项目的权限控制,以及采用Redis的方式控制JWT的失效。

1. 什么是RBAC

RBAC(Role-Based Access Control )基于角色的权限控制,权限与角色相关联,用户通过成为适当角色的成员而得到这些角色的权限。

2. JWT 和 Spring Security

spring security 授权主要分为两种,一种是security内部负责维护登录用户的session,一种则是采用JWT的方式,不管理session。关于JWT 和 Security的详细资料请小伙伴们自行查阅(相关网址推荐:http://www.ruanyifeng.com/blog/2018/07/json_web_token-tutorial.html)
此处就不在赘述,好了下面开始正文吧。

3.1 导入依赖

<!-- spring security 和 jwt -->
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-security</artifactId>
</dependency>
<dependency>
    <groupId>io.jsonwebtoken</groupId>
    <artifactId>jjwt</artifactId>
    <version>0.9.0</version>
</dependency>

3. security 核心配置类:WebSecurityConfig

@Configuration
@EnableWebSecurity
@EnableConfigurationProperties(CustomConfig.class)
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {

    @Autowired
    private CustomConfig customConfig;

    @Autowired
    private SecurityUserService securityUserService;

    @Autowired
    @Qualifier("securityAccessDeniedHandler")
    private AccessDeniedHandler securityAccessDeniedHandler;

    @Autowired
    private JwtAuthenticationFilter jwtAuthenticationFilter;

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

    @Override
    protected void configure(HttpSecurity http) throws Exception {

        http.cors()
                // 关闭 CSRF
                .and().csrf().disable()
                // 登录行为由自己实现,参考 LoginController#login
                .formLogin().disable()
                .httpBasic().disable()

                // 认证请求
                .authorizeRequests()
                // 所有请求都需要登录访问
                .anyRequest()
                .authenticated()
                // RBAC 动态 url 认证
                .anyRequest()
                .access("@rbacAuthorityService.hasPermission(request,authentication)")

                // 登出行为由自己实现,参考 LoginController#logout
                .and().logout().disable()
                // 异常处理
                .exceptionHandling().accessDeniedHandler(securityAccessDeniedHandler);

        // Session 管理
        http.sessionManagement()
                // 因为使用了JWT,所以这里不管理Session
                .sessionCreationPolicy(SessionCreationPolicy.STATELESS);

        // 添加自定义 JWT 过滤器
        http.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);
    }

    /**
     * 放行所有不需要登录就可以访问的请求,参见 AuthController
     * 也可以在 {@link #configure(HttpSecurity)} 中配置
     * {@code http.authorizeRequests().antMatchers("/api/auth/**").permitAll()}
     */
    @Override
    public void configure(WebSecurity web) {
        WebSecurity and = web.ignoring().and();

        // 忽略 GET
        customConfig.getIgnores().getGet().forEach(url -> and.ignoring().antMatchers(HttpMethod.GET, url));

        // 忽略 POST
        customConfig.getIgnores().getPost().forEach(url -> and.ignoring().antMatchers(HttpMethod.POST, url));

        // 忽略 DELETE
        customConfig.getIgnores().getDelete().forEach(url -> and.ignoring().antMatchers(HttpMethod.DELETE, url));

        // 忽略 PUT
        customConfig.getIgnores().getPut().forEach(url -> and.ignoring().antMatchers(HttpMethod.PUT, url));

        // 忽略 HEAD
        customConfig.getIgnores().getHead().forEach(url -> and.ignoring().antMatchers(HttpMethod.HEAD, url));

        // 忽略 PATCH
        customConfig.getIgnores().getPatch().forEach(url -> and.ignoring().antMatchers(HttpMethod.PATCH, url));

        // 忽略 OPTIONS
        customConfig.getIgnores().getOptions().forEach(url -> and.ignoring().antMatchers(HttpMethod.OPTIONS, url));

        // 忽略 TRACE
        customConfig.getIgnores().getTrace().forEach(url -> and.ignoring().antMatchers(HttpMethod.TRACE, url));

        // 按照请求格式忽略
        customConfig.getIgnores().getPattern().forEach(url -> and.ignoring().antMatchers(url));
    }

    @Bean
    @Override
    public AuthenticationManager authenticationManagerBean() throws Exception {
        return super.authenticationManagerBean();
    }

    /**
     * 设置加密方式
     * @return
     */
    @Bean
    public PasswordEncoder passwordEncoder(){
        return new BCryptPasswordEncoder();
    }

}

1. AuthenticationManagerBuilder auth主要设置了security的加密方式,(BCryptPasswordEncoder也是目前比较流行安全的一种加密方式,它比MD5效率更高),userDetailsService则负责对用户名、密码的校验和授权。

2. HttpSecurity http 主要是对security核心过滤器链的配置,可配置登录、登出及异常等处理器,因为我们采用的是JWT的方式,因此禁用了security提供的登录和登出,配置了JWT的过滤器,以及RBAC校验的方式。

3. WebSecurity web主要负责配置一些security放行的路径,文中通过customConfig读取在配置文件中设置的放行的URL。

4. 配置JWT 的认证过滤器JwtAuthenticationFilter

/**
 * @author lirong
 * @ClassName: JwtAuthenticationFilter
 * @Description: Jwt 认证过滤器
 * @date 2019-07-12 9:50
 */
@Component
@Slf4j
public class JwtAuthenticationFilter extends OncePerRequestFilter {

    @Autowired
    private SecurityUserService userDetailsService;

    @Autowired
    private JwtUtil jwtUtil;

    @Autowired
    private CustomConfig customConfig;

    @Autowired
    private IApplicationConfig applicationConfig;

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws ServletException, IOException {

        // 是否为放行的请求
        if (checkIgnores(request)) {
            chain.doFilter(request, response);
            return;
        }

        String jwt = jwtUtil.getJwtFromRequest(request);

        if (StrUtil.isNotBlank(jwt)) {
            try {
                String username = jwtUtil.getUsernameFromJWT(jwt, false);

                UserDetails userDetails = userDetailsService.loadUserByUsername(username);
                UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities());
                authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));

                SecurityContextHolder.getContext().setAuthentication(authentication);

                chain.doFilter(request, response);
            } catch (CustomException e) {
                ResponseUtils.renderJson(request, response, e, applicationConfig.getOrigins());
            }
        } else {
            ResponseUtils.renderJson(request, response, ResultCode.UNAUTHORIZED, null, applicationConfig.getOrigins());
        }
    }

    /**
     * 请求是否不需要进行权限拦截
     * @param request 当前请求
     * @return true - 忽略,false - 不忽略
     */
    private boolean checkIgnores(HttpServletRequest request) {
        String method = request.getMethod();

        HttpMethod httpMethod = HttpMethod.resolve(method);
        if (ObjectUtil.isNull(httpMethod)) {
            httpMethod = HttpMethod.GET;
        }

        Set<String> ignores = Sets.newHashSet();

        switch (httpMethod) {
            case GET:
                ignores.addAll(customConfig.getIgnores()
                        .getGet());
                break;
            case PUT:
                ignores.addAll(customConfig.getIgnores()
                        .getPut());
                break;
            case HEAD:
                ignores.addAll(customConfig.getIgnores()
                        .getHead());
                break;
            case POST:
                ignores.addAll(customConfig.getIgnores()
                        .getPost());
                break;
            case PATCH:
                ignores.addAll(customConfig.getIgnores()
                        .getPatch());
                break;
            case TRACE:
                ignores.addAll(customConfig.getIgnores()
                        .getTrace());
                break;
            case DELETE:
                ignores.addAll(customConfig.getIgnores()
                        .getDelete());
                break;
            case OPTIONS:
                ignores.addAll(customConfig.getIgnores()
                        .getOptions());
                break;
            default:
                break;
        }

        ignores.addAll(customConfig.getIgnores()
                .getPattern());

        if (CollUtil.isNotEmpty(ignores)) {
            for (String ignore : ignores) {
                AntPathRequestMatcher matcher = new AntPathRequestMatcher(ignore, method);
                if (matcher.matches(request)) {
                    return true;
                }
            }
        }
        return false;
    }
}

此过滤器会拦截访问系统的所有的请求,因此需要放行所有被忽略的URL,包括登录和登出,并将校验通过的JWT的用户信息封装为authentication。

5. RBAC权限匹配器

/**
 * @author lirong
 * @ClassName: JwtAuthenticationFilter
 * @Description: Jwt 认证过滤器
 * @date 2019-07-12 9:50
 */
@Slf4j
@Component
public class RbacAuthorityService {
    @Autowired
    private SecurityUserService userDetails;

    @Autowired
    private RequestMappingHandlerMapping mapping;

    public boolean hasPermission(HttpServletRequest request, Authentication authentication) {
        checkRequest(request);

        Object userInfo = authentication.getPrincipal();
        boolean hasPermission = false;

        if (userInfo instanceof UserDetails) {
            SecurityUser principal = (SecurityUser) userInfo;
            SecurityUser userDTO = (SecurityUser) this.userDetails.loadUserByUsername(principal.getUsername());

            //获取资源,前后端分离,所以过滤页面权限,只保留按钮权限
            List<MenuRight> btnPerms = userDTO.getMenus().stream()
                    // 过滤页面权限
                    .filter(menuRight -> menuRight.getGrades() >= 3)
                    // 过滤 URL 为空
                    .filter(menuRight -> StrUtil.isNotBlank(menuRight.getUrl()))
                    // 过滤 METHOD 为空
                    .collect(Collectors.toList());
            for (MenuRight btnPerm : btnPerms) {
                AntPathRequestMatcher antPathMatcher = new AntPathRequestMatcher(btnPerm.getUrl(), btnPerm.getMethod());
                if (antPathMatcher.matches(request)) {
                    hasPermission = true;
                    break;
                }
            }

            return hasPermission;
        } else {
            return false;
        }
    }

    /**
     * 校验请求是否存在
     *
     * @param request 请求
     */
    private void checkRequest(HttpServletRequest request) {
        // 获取当前 request 的方法
        String currentMethod = request.getMethod();
        Multimap<String, String> urlMapping = allUrlMapping();

        for (String uri : urlMapping.keySet()) {
            // 通过 AntPathRequestMatcher 匹配 url
            // 可以通过 2 种方式创建 AntPathRequestMatcher
            // 1:new AntPathRequestMatcher(uri,method) 这种方式可以直接判断方法是否匹配,因为这里我们把 方法不匹配 自定义抛出,所以,我们使用第2种方式创建
            // 2:new AntPathRequestMatcher(uri) 这种方式不校验请求方法,只校验请求路径
            AntPathRequestMatcher antPathMatcher = new AntPathRequestMatcher(uri);
            if (antPathMatcher.matches(request)) {
                if (!urlMapping.get(uri)
                        .contains(currentMethod)) {
                    throw new CustomException(ResultCode.HTTP_BAD_METHOD);
                } else {
                    return;
                }
            }
        }

        throw new CustomException(ResultCode.REQUEST_NOT_FOUND);
    }

    /**
     * 获取 所有URL Mapping,返回格式为{"/test":["GET","POST"],"/sys":["GET","DELETE"]}
     *
     * @return {@link ArrayListMultimap} 格式的 URL Mapping
     */
    private Multimap<String, String> allUrlMapping() {
        Multimap<String, String> urlMapping = ArrayListMultimap.create();

        // 获取url与类和方法的对应信息
        Map<RequestMappingInfo, HandlerMethod> handlerMethods = mapping.getHandlerMethods();

        handlerMethods.forEach((k, v) -> {
            // 获取当前 key 下的获取所有URL
            Set<String> url = k.getPatternsCondition()
                    .getPatterns();
            RequestMethodsRequestCondition method = k.getMethodsCondition();

            // 为每个URL添加所有的请求方法
            url.forEach(s -> urlMapping.putAll(s, method.getMethods()
                    .stream()
                    .map(Enum::toString)
                    .collect(Collectors.toList())));
        });

        return urlMapping;
    }
}

此方法看似很多,实则只做了一件事,就是把页面请求的URL和用户拥有的所有权限资源(URL)进行匹配。

6. UserDetailsService查询数据库用户信息

/**
 * @author lirong
 * @Date 2019-7-14 22:46:54
 * @Desc 从数据库查询用户数据
 */
@Component("securityUserService")
public class SecurityUserService implements UserDetailsService {
    @Resource
    private UserMapper userMapper;
    @Resource
    private RoleMapper roleMapper;
    @Resource
    private MenuRightMapper menuRightMapper;
    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {

        UserDTO userDTO = userMapper.getRolesByUsername(username);
        // 默认用户ID为1的为管理员
        if (null != userDTO){
            if(1L == userDTO.getId()) {
                this.getAdminPermission(userDTO);
            }
            SecurityUser securityUser = new SecurityUser(LoginUserDTO.user2LoginUserDTO(userDTO));
            return securityUser;
        } else {
            throw new UsernameNotFoundException(username + " 用户不存在!");
        }
    }

    /**
     * 为管理员赋所有权限
     * @param userDTO
     * @return
     */
    private UserDTO getAdminPermission(UserDTO userDTO) {
        List<Role> roles = roleMapper.selectAll();
        List<MenuRight> menuRights = menuRightMapper.selectAll();
        userDTO.setRoles(roles);
        userDTO.setMenus(menuRights);
        return userDTO;
    }
}

7. JWT的刷新和登录用户的注销

众所周知,JWT是无状态的,服务端无法通过解析JWT知道用户是否提前注销,因此借助了Redis的过期机制,来达到通知用户退出的目的。创建JWT时,会将生成的JWT以用户名为前缀存入Redis,退出时,清除Redis中此用户名的JWT,每次访问时解析JWT并判断Redis中是否还存在此用户名的JWT,若不存在,则表示此用户已注销。
JWT的续签,此处采用的是refresh_token的形式,及登录的时候创建两个JWT,一个token,一个refresh_token,refresh_token的过期时间设置比较长,token失效后,前端可调用refresh_token的接口来刷新来达到续签的目的。

/**
 * jwt工具类
 * @author  lirong
 * @date 2018-9-26
 */
@EnableConfigurationProperties(JwtConfig.class)
@Configuration
@Slf4j
public class JwtUtil {

    @Autowired
    private RedisTemplate redisTemplate;

    @Autowired
    private JwtConfig jwtConfig;

    /**
     * 创建JWT
     *
     * @param authentication 用户认证信息
     * @param rememberMe     记住我
     * @return JWT
     */
    public String createJWT(Authentication authentication, Boolean rememberMe, Boolean isRefresh) {
        SecurityUser user = (SecurityUser) authentication.getPrincipal();
        return createJWT(isRefresh, rememberMe, user.getId(), user.getUsername(), user.getRoles(), user.getMenus(), user.getAuthorities());
    }

    /**
     * 创建JWT
     *
     * @param id          用户id
     * @param subject     用户名
     * @param roles       用户角色
     * @param authorities 用户权限
     * @return JWT
     */
    public String createJWT(Boolean isRefresh,
                            Boolean rememberMe,
                            Long id,
                            String subject,
                            List<Role> roles,
                            List<MenuRight> menus,
                            Collection<? extends GrantedAuthority> authorities) {
        Date now = new Date();
        JwtBuilder builder = Jwts.builder()
                .setId(id.toString())
                .setSubject(subject)
                .setIssuedAt(now)
                .signWith(SignatureAlgorithm.HS256, jwtConfig.getSecret())
                .claim("roles", roles)
                // .claim("perms", menus)
                .claim("authorities", authorities);

        // 设置过期时间
        Long ttl = rememberMe ? jwtConfig.getRemember() : jwtConfig.getTtl();
        String redisKey;
        if (isRefresh){
            ttl *= 3;
            redisKey = Constant.REDIS_JWT_REFRESH_TOKEN_KEY_PREFIX + subject;
        }else{
            redisKey = Constant.REDIS_JWT_TOKEN_KEY_PREFIX + subject;
        }
        if (ttl > 0) {
            builder.setExpiration(DateUtil.offsetMillisecond(now, ttl.intValue()));
        }

        String jwt = builder.compact();
        // 将生成的JWT保存至Redis
        redisTemplate.opsForValue().set(redisKey, jwt, ttl, TimeUnit.MILLISECONDS);
        return jwt;
    }

    /**
     * 解析JWT
     *
     * @param jwt JWT
     * @return {@link Claims}
     */
    public Claims parseJWT(String jwt, Boolean isRefresh) {
        try {
            Claims claims = Jwts.parser()
                    .setSigningKey(jwtConfig.getSecret())
                    .parseClaimsJws(jwt)
                    .getBody();

            String username = claims.getSubject();
            String redisKey = (isRefresh ? Constant.REDIS_JWT_REFRESH_TOKEN_KEY_PREFIX : Constant.REDIS_JWT_TOKEN_KEY_PREFIX)
                    + username;

            // 校验redis中的JWT是否存在
            Long expire = redisTemplate.getExpire(redisKey, TimeUnit.MILLISECONDS);
            if (Objects.isNull(expire) || expire <= 0) {
                throw new CustomException(ResultCode.TOKEN_EXPIRED);
            }

            // 校验redis中的JWT是否与当前的一致,不一致则代表用户已注销/用户在不同设备登录,均代表JWT已过期
            String redisToken = (String) redisTemplate.opsForValue().get(redisKey);
            if (!StrUtil.equals(jwt, redisToken)) {
                throw new CustomException(ResultCode.TOKEN_OUT_OF_CTRL);
            }
            return claims;
        } catch (ExpiredJwtException e) {
            log.error("Token 已过期");
            throw new CustomException(ResultCode.TOKEN_EXPIRED);
        } catch (UnsupportedJwtException e) {
            log.error("不支持的 Token");
            throw new CustomException(ResultCode.TOKEN_PARSE_ERROR);
        } catch (MalformedJwtException e) {
            log.error("Token 无效");
            throw new CustomException(ResultCode.TOKEN_PARSE_ERROR);
        } catch (IllegalArgumentException e) {
            log.error("Token 参数不存在");
            throw new CustomException(ResultCode.TOKEN_PARSE_ERROR);
        }
    }

    /**
     * 设置JWT过期
     *
     * @param request 请求
     */
    public void invalidateJWT(HttpServletRequest request) {
        String jwt = getJwtFromRequest(request);
        String username = getUsernameFromJWT(jwt, false);
        // 从redis中清除JWT
        redisTemplate.delete(Constant.REDIS_JWT_REFRESH_TOKEN_KEY_PREFIX + username);
        redisTemplate.delete(Constant.REDIS_JWT_TOKEN_KEY_PREFIX + username);
    }

    /**
     * 从 request 的 header 中获取 JWT
     *
     * @param request 请求
     * @return JWT
     */
    public String getJwtFromRequest(HttpServletRequest request) {
        String bearerToken = request.getHeader("Authorization");
        if (StrUtil.isNotBlank(bearerToken) && bearerToken.startsWith("Bearer ")) {
            return bearerToken.substring(7);
        }
        return null;
    }

    /**
     * 根据 jwt 获取用户名
     *
     * @param jwt JWT
     * @return 用户名
     */
    public String getUsernameFromJWT(String jwt, Boolean isRefresh) {
        Claims claims = parseJWT(jwt, isRefresh);
        return claims.getSubject();
    }

    public Map<String, String> refreshJWT(String token) {
        Claims claims = parseJWT(token, true);
        // 获取签发时间
        Date lastTime = claims.getExpiration();
        // 1. 判断refreshToken是否过期
        if (!new Date().before(lastTime)){
            throw new CustomException(ResultCode.TOKEN_EXPIRED);
        }
        // 2. 在redis中删除之前的token和refreshToken
        String username = claims.getSubject();
        // redisTemplate.delete(Constant.REDIS_JWT_REFRESH_TOKEN_KEY_PREFIX + username);
        // redisTemplate.delete(Constant.REDIS_JWT_TOKEN_KEY_PREFIX + username);
        // 3. 创建新的token和refreshToken并存入redis
        String jwtToken = createJWT(false, false, Long.parseLong(claims.getId()), username,
                (List<Role>) claims.get("roles"), null, (Collection<? extends GrantedAuthority>) claims.get("authorities"));
        String refreshJwtToken = createJWT(true, false, Long.parseLong(claims.getId()), username,
                (List<Role>) claims.get("roles"), null, (Collection<? extends GrantedAuthority>) claims.get("authorities"));
        Map<String, String> map = new HashMap<>();
        map.put("token", jwtToken);
        map.put("refreshToken", refreshJwtToken);
        return map;
    }

    /**
     *
     * 功能:生成 jwt token<br/>
     * @param name	实例名
     * @param param	需要保存的参数
     * @param secret	秘钥
     * @param expirationtime	过期时间(5分钟 5*60*1000)
     * @return
     *
     */
    public static String sign(String name, Map<String,Object> param, String secret, Long expirationtime){
        String JWT = Jwts.builder()
                .setClaims(param)
                .setSubject(name)
                .setExpiration(new Date(System.currentTimeMillis() + expirationtime))
                .signWith(SignatureAlgorithm.HS256,secret)
                .compact();
        return JWT;
    }
    /**
     *
     * 功能:解密 jwt<br/>
     * @param JWT	token字符串
     * @param secret	秘钥
     * @return
     * @exception
     *
     */
    public static Claims verify(String JWT, String secret){
        Claims claims = Jwts.parser()
                .setSigningKey(secret)
                .parseClaimsJws(JWT)
                .getBody();
        return claims;
    }

    public static Object getValueFromToken(String jwt,String key, String secret){
        return verify(jwt, secret).get(key);
    }
}

登录和登出的方法

/**
 * @author lirong
 * @ClassName: LoginController
 * @Description: 登录Controller
 * @date 2019-07-12 9:31
 */
@Slf4j
@RestController
@RequestMapping("/")
public class LoginController {

    @Autowired
    private AuthenticationManager authenticationManager;

    @Autowired
    private JwtUtil jwtUtil;

    /**
     * 登录
     */
    @PostMapping("/login")
    public RestResult login(@RequestParam String username,
                            @RequestParam String password,
                            @RequestParam(required = false, defaultValue = "false") Boolean rememberMe,
                            HttpServletRequest request,
                            HttpServletResponse response) {
        Authentication authentication = authenticationManager.authenticate(new UsernamePasswordAuthenticationToken(username, password));

        SecurityContextHolder.getContext()
                .setAuthentication(authentication);

        String jwt = jwtUtil.createJWT(authentication, rememberMe, false);
        String jwt_refresh = jwtUtil.createJWT(authentication, rememberMe, true);
        Map<String, String> map = new HashMap<>();
        map.put("token", jwt);
        map.put("refreshToken", jwt_refresh);

        CookieUtils.setCookie(response, "localhost", jwt);
        return ResultGenerator.genSuccessResult().setMessage("登录成功").setData(map);
    }

    /**
     * 退出
     * @param request
     * @return
     */
    @PostMapping("/logout")
    public RestResult logout(HttpServletRequest request) {
        try {
            // 设置JWT过期
            jwtUtil.invalidateJWT(request);
        } catch (CustomException e) {
            throw new CustomException(ResultCode.UNAUTHORIZED);
        }
        return ResultGenerator.genSuccessResult().setMessage("退出成功");
    }

    /**
     * 刷新过期的token
     * @param refreshToken
     * @return
     */
    @PostMapping("/refresh/token")
    public RestResult refreshToken(String refreshToken) {
        Map<String, String> map;
        try {
            // 刷新
            map = jwtUtil.refreshJWT(refreshToken);
        } catch (CustomException e) {
            throw new CustomException(ResultCode.TOKEN_EXPIRED);
        }
        return ResultGenerator.genSuccessResult().setMessage("token刷新成功").setData(map);
    }
}

7. 效果测试

8. 数据库和源码

上面只是项目的部分核心代码,完整代码和数据库已托管到Github上,请访问:https://github.com/Janche/spring-security-rbac-jwt.git自行下载,觉得有用的话,记得star哦,有什么问题欢迎大家通过issues或者邮件进行交流。

评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值