SpringSecurity授权流程分析+动态授权实现

1. SpringSecurity的授权流程分析

回顾之前看过的一张SpringSecurity基本原理的图:

authentication_1.jpeg

之前说过,SpringSecurity过滤器链,图中绿色的是认证相关的,蓝色部分是异常相关的,而橙色部分是授权相关,今天我们就是要理清橙色部分授权相关的流程,以及实现动态授权。

  1. 首先来看看授权逻辑的入口过滤器FilterSecurityInterceptor源码

    public class FilterSecurityInterceptor extends AbstractSecurityInterceptor implements Filter {
        @Override
        public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
            invoke(new FilterInvocation(request, response, chain));
        }
    

    FilterSecurityInterceptor的主要方法是doFilter方法,过滤器在请求进来后会执行doFilter方法,在这个方法里,是调用本类中的invoke方法,所以invoke方法才是主要逻辑的地方

    public void invoke(FilterInvocation filterInvocation) throws IOException, ServletException {
        if (isApplied(filterInvocation) && this.observeOncePerRequest) {
            // filter already applied to this request and user wants us to observe
            // once-per-request handling, so don't re-do security checking
            filterInvocation.getChain().doFilter(filterInvocation.getRequest(), filterInvocation.getResponse());
            return;
        }
        // first time this request being called, so perform security checking
        if (filterInvocation.getRequest() != null && this.observeOncePerRequest) {
            filterInvocation.getRequest().setAttribute(FILTER_APPLIED, Boolean.TRUE);
        }
        InterceptorStatusToken token = super.beforeInvocation(filterInvocation);
        try {
            filterInvocation.getChain().doFilter(filterInvocation.getRequest(), filterInvocation.getResponse());
        }
        finally {
            super.finallyInvocation(token);
        }
        super.afterInvocation(token, null);
    }
    
    1. 这里最核心的就是最后这几句了:

      InterceptorStatusToken token = super.beforeInvocation(filterInvocation);
      try {
          filterInvocation.getChain().doFilter(filterInvocation.getRequest(), filterInvocation.getResponse());
      }
      finally {
          super.finallyInvocation(token);
      }
      super.afterInvocation(token, null);
      

      分三步:

      1. 调用了父类的方法super.beforeInvocation(filterInvocation),这个是最核心的代码,授权核心步骤就是在这一步了。
      2. 这一步是每个过滤器都有的一步,授权通过执行真正的业务
      3. 后续的一些处理
  2. 接下来看看核心的授权逻辑: beforeInvocation方法,在类AbstractSecurityInterceptor中实现

    protected InterceptorStatusToken beforeInvocation(Object object) {
        
        Collection<ConfigAttribute> attributes = this.obtainSecurityMetadataSource().getAttributes(object);
        
        Authentication authenticated = authenticateIfRequired();
    
        // Attempt authorization
        attemptAuthorization(object, attributes, authenticated);
    
        if (this.publishAuthorizationSuccess) {
            publishEvent(new AuthorizedEvent(object, attributes, authenticated));
        }
    
        // no further work post-invocation
        return new InterceptorStatusToken(SecurityContextHolder.getContext(), false, attributes, object);
    
    }
    

    这里的源码删减了一些关系不大的部分,这段代码大体可以分为三步:

    1. Collection<ConfigAttribute> attributes = this.obtainSecurityMetadataSource().getAttributes(object);拿到系统配置的URL权限,并封装为ConfigAttribute对象集合,其实这里面就是我们在配置文件中配置的权限

    2. 通过authenticateIfRequired方法拿到已认证过的Authentication对象,其实里面还是通过SecurityContextHolder通过上下文拿到的。

    3. 调用attemptAuthorization方法去授权

      代码运行到这里后,我们拿到了系统配置的URL权限attributes,认证用户对象Authentication也拿到了,还有当前请求相关的信息FilterInvocation ,也就是这个方法的参数object,接下来授权肯定是拿着三部分的信息去实现的。

      我们再看看这个方法具体实现:

      private void attemptAuthorization(Object object, Collection<ConfigAttribute> attributes,Authentication authenticated) {
          try {
              this.accessDecisionManager.decide(authenticated, object, attributes);
          }
          catch (AccessDeniedException ex) {
              if (this.logger.isTraceEnabled()) {
                  this.logger.trace(LogMessage.format("Failed to authorize %s with attributes %s using %s", object,attributes, this.accessDecisionManager));
              }
              else if (this.logger.isDebugEnabled()) {
                  this.logger.debug(LogMessage.format("Failed to authorize %s with attributes %s", object, attributes));
              }
              publishEvent(new AuthorizationFailureEvent(object, attributes, authenticated, ex));
              throw ex;
          }
      }
      

      这段代码最核心就是调用了this.accessDecisionManager.decide(authenticated, object, attributes);,通过accessDecisionManager进行授权,并且将前面获取到的三部分信息传参进去。

  3. 紧接着来了解下这个决策管理器AccessDecisionManager

    前面我们一步步走到了授权处理方法attemptAuthorization,发现它又是调用了accessDecisionManagerdecide方法去真正处理授权的,我们来看看这个决策管理器的源码:

    public interface AccessDecisionManager {
    	void decide(Authentication authentication, Object object, Collection<ConfigAttribute> configAttributes)
    			throws AccessDeniedException, InsufficientAuthenticationException;
    
    	boolean supports(ConfigAttribute attribute);
    
    	boolean supports(Class<?> clazz);
    
    }
    

    从源码可知这个AccessDecisionManager是一个接口,声明了三个方法,核心方法是decide用以授权,另外两个supports方法主要起辅助作用,大都执行检查操作的。

    既然是一个接口,那调用的肯定是实现类了,我们可以接着看看他有哪些实现类:

    AccessDecisionManager.png

    从图中可以看到它有一个抽象实现类,然后抽象实现类下又有三个实现类,我们可以通过Debug看看默认实现的是哪个

    attemptAuthorization.png

    可以看到SpringSecurity默认的实现类是AffirmativeBased

    再来看看这三种不同的授权逻辑,分别为:

    • AffirmativeBased:默认的实现类,一票通过制,只要有一票同意则通过
    • ConsensusBased:一票反对制,只要有一票反对都不能通过
    • UnanimousBased:少数服从多数制,以多数票为结果

    这里之所以用投票来形容,是因为这个决策管理器采用了委托的形式,将请求委托给了投票器,由每个投票器去决策,这么一来,说明真正决策的并不是这三种实现类,而是投票器。

    那就接着跟着默认的实现类AffirmativeBased源码看看具体的实现。

  4. AffirmativeBased源码

    public class AffirmativeBased extends AbstractAccessDecisionManager {
    
        public AffirmativeBased(List<AccessDecisionVoter<?>> decisionVoters) {
            super(decisionVoters);
        }
    
        @Override
        @SuppressWarnings({ "rawtypes", "unchecked" })
        public void decide(Authentication authentication, Object object, Collection<ConfigAttribute> configAttributes)
            throws AccessDeniedException {
            int deny = 0;
            for (AccessDecisionVoter voter : getDecisionVoters()) {
                int result = voter.vote(authentication, object, configAttributes);
                switch (result) {
                    case AccessDecisionVoter.ACCESS_GRANTED:
                        return;
                    case AccessDecisionVoter.ACCESS_DENIED:
                        deny++;
                        break;
                    default:
                        break;
                }
            }
            if (deny > 0) {
                throw new AccessDeniedException(
                    this.messages.getMessage("AbstractAccessDecisionManager.accessDenied", "Access is denied"));
            }
            // To get this far, every AccessDecisionVoter abstained
            checkAllowIfAllAbstainDecisions();
        }
    }
    

    前面说了实现类是委托给了投票器进行决策的,从源码中也可以看到,这里是通过轮询所有配置的AccessDecisionVoter,根据投票器的结果进行权限授予。

    这里的getDecisionVoters方法是在父类AbstractAccessDecisionManager中实现的,源码中就是在构造器AbstractAccessDecisionManager中传入Voter的列表,而在类AffirmativeBased的构造器中调用了父类的构造器super(decisionVoters);,也就是说最终由多少个AccessDecisionVoterAffirmativeBased的构造器中注入的,是一个List。

    我们再debug下看看,这个List有多少个投票器

    getDecisionVoters.png

    可以看到,默认只有一个投票器WebExpressionVoter,这个投票器会根据我们在配置文件中的配置进行逻辑处理得出投票结果。

    public class WebExpressionVoter implements AccessDecisionVoter<FilterInvocation> {
        @Override
        public int vote(Authentication authentication, FilterInvocation filterInvocation,
                        Collection<ConfigAttribute> attributes) {
            Assert.notNull(authentication, "authentication must not be null");
            Assert.notNull(filterInvocation, "filterInvocation must not be null");
            Assert.notNull(attributes, "attributes must not be null");
            WebExpressionConfigAttribute webExpressionConfigAttribute = findConfigAttribute(attributes);
            if (webExpressionConfigAttribute == null) {
                this.logger
                    .trace("Abstained since did not find a config attribute of instance WebExpressionConfigAttribute");
                return ACCESS_ABSTAIN;
            }
            EvaluationContext ctx = webExpressionConfigAttribute.postProcess(
                this.expressionHandler.createEvaluationContext(authentication, filterInvocation), filterInvocation);
            boolean granted = ExpressionUtils.evaluateAsBoolean(webExpressionConfigAttribute.getAuthorizeExpression(), ctx);
            if (granted) {
                return ACCESS_GRANTED;
            }
            this.logger.trace("Voted to deny authorization");
            return ACCESS_DENIED;
        }
    	// 循环判断,只要有一个权限符合就返回
        private WebExpressionConfigAttribute findConfigAttribute(Collection<ConfigAttribute> attributes) {
            for (ConfigAttribute attribute : attributes) {
                if (attribute instanceof WebExpressionConfigAttribute) {
                    return (WebExpressionConfigAttribute) attribute;
                }
            }
            return null;
        }
    }
    
  5. 最后来看看返回的过程

    从投票器WebExpressionVoter返回到AffirmativeBaseddecide方法

    @Override
    @SuppressWarnings({ "rawtypes", "unchecked" })
    public void decide(Authentication authentication, Object object, Collection<ConfigAttribute> configAttributes)
        throws AccessDeniedException {
        int deny = 0;
        for (AccessDecisionVoter voter : getDecisionVoters()) {
            int result = voter.vote(authentication, object, configAttributes);
            switch (result) {
                case AccessDecisionVoter.ACCESS_GRANTED:
                    return;
                case AccessDecisionVoter.ACCESS_DENIED:
                    deny++;
                    break;
                default:
                    break;
            }
        }
        if (deny > 0) {
            throw new AccessDeniedException(
                this.messages.getMessage("AbstractAccessDecisionManager.accessDenied", "Access is denied"));
        }
        // To get this far, every AccessDecisionVoter abstained
        checkAllowIfAllAbstainDecisions();
    }
    

    如果投票通过,直接return,没有返回值,回到了AbstractSecurityInterceptorattemptAuthorization方法

    private void attemptAuthorization(Object object, Collection<ConfigAttribute> attributes,Authentication authenticated) {
        try {
            this.accessDecisionManager.decide(authenticated, object, attributes);
        }
        catch (AccessDeniedException ex) {
            if (this.logger.isTraceEnabled()) {
                this.logger.trace(LogMessage.format("Failed to authorize %s with attributes %s using %s", object,attributes, this.accessDecisionManager));
            }
            else if (this.logger.isDebugEnabled()) {
                this.logger.debug(LogMessage.format("Failed to authorize %s with attributes %s", object, attributes));
            }
            publishEvent(new AuthorizationFailureEvent(object, attributes, authenticated, ex));
            throw ex;
        }
    }
    

    再回到了beforeInvocation方法,最后回到了最开始的过滤器FilterSecurityInterceptorinvoke方法

  6. 总结流程

    通过上面的分析,我们大体能了解到整个授权的流程是这样的:(网上找的图)

    accessDecision-flow.png

2. 动态权限的实现

通过上面的授权流程分析,咱们大致清楚了SpringSecurity是怎么授权的,那么我们要实现动态授权应该怎么做?其实就是实现自定义上图中的两个类:一个是SecurityMetadataSource类用来获取当前请求所需要的权限;另一个是AccessDecisionManager类来实现授权决策

宗旨就是需要三个数据:请求所需的权限,能获取到该请求的Object,以及已认证对象所拥有的权限。(其实就是投票器执行方法decide的三个参数)

下面就以实现SecurityMetadataSource类和AccessDecisionManager类的方式来实现动态授权

  1. 数据库结构

    建立user用户表,role角色表,resource资源表以及user_role表,resource_role表

    预先插入一些数据,如下图

    用户表:(密码都是经过加密的,分别是123,admin,user)

    table_user.png

    角色表:

    table_role.png

    资源表:

    table_resource.png

    用户-角色关系表:

    table_user_role.png

    资源角色关系表:

    table_resource_role.png

  2. 创建实体类:User,Role,Resource

    @Data
    public class User implements UserDetails {
        private static final long serialVersionUID = -3185138705702678193L;
    
        private Integer id;
        private String username;
        private String password;
        private boolean enabled;
        private boolean locked;
    
        private List<Role> roleList;
    
        /**
         * 获取用户的权限信息,封装为GrantedAuthority
         * @return
         */
        @Override
        public Collection<? extends GrantedAuthority> getAuthorities() {
            List<SimpleGrantedAuthority> authorities = new ArrayList<>();
            for (Role role : roleList) {
                authorities.add(new SimpleGrantedAuthority(role.getName()));
            }
    
            return authorities;
        }
    
        @Override
        public String getPassword() {
            return this.password;
        }
    
        @Override
        public String getUsername() {
            return this.username;
        }
    
        @Override
        public boolean isAccountNonExpired() {
            return true;
        }
    
        @Override
        public boolean isAccountNonLocked() {
            return !locked;
        }
    
        @Override
        public boolean isCredentialsNonExpired() {
            return true;
        }
    
        @Override
        public boolean isEnabled() {
            return this.enabled;
        }
    }
    
    @Data
    public class Role {
        // 角色ID
        private Integer id;
        // 角色英文名
        private String name;
        // 角色中文名
        private String nameZh;
    }
    
    @Data
    public class Resource {
        // 资源ID
        private Integer id;
        // 资源路径
        private String url;
        // 角色列表
        private List<Role> roleList;
    
    }
    
  3. 创建Mapper类和xml

    @Mapper
    public interface UserMapper {
        /**
         * 根据用户名获取用户信息
         * @param username
         * @return
         */
        User getUserByUsername(@Param("username") String username);
    
        /**
         * 获取指定ID的用户的所有角色信息
         * @param id
         * @return
         */
        List<Role> getRolesByUserId(@Param("userId") Integer id);
    
        /**
         * 插入一个用户
         * @param user
         * @return
         */
        int insertOneUser(@Param("user") User user);
    }
    
    <?xml version="1.0" encoding="UTF-8" ?>
        <!DOCTYPE mapper
        PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
        "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
    <mapper namespace="com.zzy.accessdecision.mapper.UserMapper">
        <select id="getUserByUsername" resultType="user">
        	select * from user where username = #{username};
    	</select>
    
        <select id="getRolesByUserId" resultType="role">
        	select * from role where id in (select rid from user_role where uid = #{userId});
    	</select>
    
        <insert id="insertOneUser">
        	insert into user  values(#{user.id}, #{user.username}, #{user.password}, #{user.enabled}, #{user.locked});
    	</insert>
    </mapper>
    
    @Mapper
    public interface ResourceMapper {
        List<Resource> getAllResourceWithRole();
    }
    
    <?xml version="1.0" encoding="UTF-8" ?>
        <!DOCTYPE mapper
        PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
        "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
    
    <mapper namespace="com.zzy.accessdecision.mapper.ResourceMapper">
        <resultMap id="resources_map" type="resource">
        	<id property="id" column="id"/>
        	<result property="url" column="url"/>
        	<collection property="roleList" ofType="role">
        		<id property="id" column="rid" />
        		<result property="name" column="name"/>
        		<result property="nameZh" column="nameZh"/>
        	</collection>
        </resultMap>
    
        <select id="getAllResourceWithRole" resultMap="resources_map">
        	select resource.*, role.id as rid, role.name, role.nameZh  from resource left join resource_role on resource.id = resource_role.resource_id left join role on resource_role.role_id = role.id
        </select>
    </mapper>
    
  4. 创建UserService

    @Service
    public class UserService implements UserDetailsService {
    
        @Autowired
        private UserMapper userMapper;
    
        /**
         * 通过用户名获取用户信息
         * @param username
         * @return
         * @throws UsernameNotFoundException
         */
        @Override
        public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
            User user = userMapper.getUserByUsername(username);
            if (user == null) {
                throw new UsernameNotFoundException("用户不存在!");
            }
            // 将用户权限填充进去
            user.setRoleList(userMapper.getRolesByUserId(user.getId()));
            return user;
        }
    }
    
  5. 自定义一个FilterInvocationSecurityMetadataSource实现类,实现getAttributes方法

    public class CustomFilterInvocationSecurityMetadataSource implements FilterInvocationSecurityMetadataSource {
        @Autowired
        private ResourceMapper resourceMapper;
    
        // Ant风格匹配器
        private final AntPathMatcher antPathMatcher = new AntPathMatcher();
        /**
         * 获取当前请求所需要的权限
         * @param object
         * @return
         * @throws IllegalArgumentException
         */
        @Override
        public Collection<ConfigAttribute> getAttributes(Object object) throws IllegalArgumentException {
            FilterInvocation request = (FilterInvocation) object;
            String url = request.getRequestUrl();
    
            // 获取所有的资源与对应角色信息
            List<Resource> resources = resourceMapper.getAllResourceWithRole();
    
            // 遍历所有的资源,查找与当前请求匹配的url
            for (Resource resource : resources) {
                // 匹配上
                if (antPathMatcher.match(resource.getUrl(), url)) {
                    // 获取url对应的角色信息
                    List<Role> roles = resource.getRoleList();
                    String[] roleStr = new String[roles.size()];
                    // 遍历roles集合,将每一个角色信息转为字符串形式
                    for (int i = 0; i < roleStr.length; i++) {
                        roleStr[i] = roles.get(i).getName();
                    }
    
                    // 返回所有的角色信息
                    return SecurityConfig.createList(roleStr);
                }
            }
            // 如果没有匹配上的,就返回一个自定义的作为个标记,只要是ROLE_null则说明不匹配
            return SecurityConfig.createList("ROLE_null");
        }
    
        @Override
        public Collection<ConfigAttribute> getAllConfigAttributes() {
            return null;
        }
    
        /**
         * 校验类是否支持
         * @param clazz
         * @return
         */
        @Override
        public boolean supports(Class<?> clazz) {
            return FilterInvocation.class.isAssignableFrom(clazz);
        }
    }
    
  6. 自定义AccessDecisionManager实现类,重写decide方法

    public class CustomAccessDecisionManager implements AccessDecisionManager {
        /**
         * 权限决策
         * @param authentication 已认证用户对象
         * @param object    包含请求相关信息的FilterInvocation
         * @param configAttributes 当前请求所需要的角色信息
         * @throws AccessDeniedException
         * @throws InsufficientAuthenticationException
         */
        @Override
        public void decide(Authentication authentication, Object object, Collection<ConfigAttribute> configAttributes) throws AccessDeniedException, InsufficientAuthenticationException {
            // 获取认证的用户所具有的角色权限
            Collection<? extends GrantedAuthority> authorities = authentication.getAuthorities();
    
            // 循环遍历当前请求所需的角色信息,只要有一个满足就可以
            for (ConfigAttribute configAttribute : configAttributes) {
                // 该请求在数据库中不具备角色
                if ("ROLE_null".equals(configAttribute.getAttribute()) && authentication instanceof UsernamePasswordAuthenticationToken) {
                    return;
                }
                // 轮询判断用户的角色权限是否符合当前资源请求的所需要的权限
                for (GrantedAuthority authority : authorities) {
                    System.out.println("authority = " + authority.getAuthority());
                    if (authority.getAuthority().equals(configAttribute.getAttribute())){
                        return;
                    }
                }
            }
            throw new AccessDeniedException("权限不足,无法访问!");
        }
    
        @Override
        public boolean supports(ConfigAttribute attribute) {
            return true;
        }
    
        @Override
        public boolean supports(Class<?> clazz) {
            return true;
        }
    }
    
  7. 创建配置类

    @Configuration
    public class MyWebSecurityConfig extends WebSecurityConfigurerAdapter {
    
        @Autowired
        private UserService userService;
    
        @Bean
        public PasswordEncoder passwordEncoder() {
            return new BCryptPasswordEncoder();
        }
    
        @Bean
        public CustomFilterInvocationSecurityMetadataSource customFilterInvocationSecurityMetadataSource() {
            return  new CustomFilterInvocationSecurityMetadataSource();
        }
    
        @Bean
        public CustomAccessDecisionManager customAccessDecisionManager() {
            return new CustomAccessDecisionManager();
        }
    
        @Override
        public void configure(AuthenticationManagerBuilder builder) throws Exception {
            builder.userDetailsService(userService).passwordEncoder(passwordEncoder());
        }
    
        @Override
        public void configure(WebSecurity web) {
            web.ignoring().antMatchers("/register", "/index");
        }
    
        @Override
        public void configure(HttpSecurity http) throws Exception {
            http.authorizeRequests()
                .withObjectPostProcessor(new ObjectPostProcessor<FilterSecurityInterceptor>() {
    
                    @Override
                    public <O extends FilterSecurityInterceptor> O postProcess(O object) {
                        object.setSecurityMetadataSource(customFilterInvocationSecurityMetadataSource());
                        object.setAccessDecisionManager(customAccessDecisionManager());
                        return object;
                    }
                })
                .and()
                .formLogin()
                .loginProcessingUrl("/login").successForwardUrl("/index")
                .permitAll()
                .and()
                .csrf()
                .disable();
    
            http.exceptionHandling().accessDeniedHandler(new SimpleAccessDeniedHandler());
        }
    }
    
  8. 自定义异常处理

    public class SimpleAccessDeniedHandler implements AccessDeniedHandler {
        @Override
        public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException) throws IOException, ServletException {
            Map<String, Object> map = new HashMap<>();
            map.put("status", HttpServletResponse.SC_FORBIDDEN);
            map.put("msg", "没有权限!");
    
            response.setStatus(HttpServletResponse.SC_FORBIDDEN);
            response.setCharacterEncoding("utf-8");
            response.setContentType(MediaType.APPLICATION_JSON_VALUE);
    
            response.getWriter().write(new ObjectMapper().writeValueAsString(map));
        }
    }
    
  9. 创建测试Controller

    @RestController
    public class TestController {
    
        @GetMapping("/hello")
        public String hello() {
            return "hello!";
        }
    
        @RequestMapping("/index")
        public String index() {
            return "index!";
        }
    
        @GetMapping("/root/hello")
        public String root() {
            return "hello root!";
        }
    
        @GetMapping("/admin/hello")
        public String admin() {
            return "hello admin!";
        }
    
        @GetMapping("/user/hello")
        public String user() {
            return "hello user!";
        }
    }
    
  10. 开始测试!

    以root用户来测试,在设计的数据库表中,root用户只有访问/root/**的权限,其他的没有权限

    • 首先先访问http://localhost:8080/login,输入账号密码登录

    • 首先访问/root/hello

      -root-hello.png

    • 然后再访问下/admin/hello,因为/admin/hello这个资源路径所需的角色信息是ROLE_admin,所以root用户是没有权限访问的

      -admin-hello.png

    • 接着咱们来试试动态权限,在数据库resource_role中插入一行,赋予root用户访问/admin/**的权限

      INSERT INTO resource_role VALUES(NULL, 2, 1)

      资源/admin/**的id是2,ROLE_root角色的id是1。

      现在root用户就拥有了访问/admin/**的权限了,我们可以再次访问验证:

      -root-admin-hello.png

      至此,我们就可以动态的对权限做出控制,赋予资源路径的访问角色,从而决定用户的访问权限

  11. 源码地址

    源码我已经放到了gitee上了,地址是: Lucas-张 / SpringSecurity

  • 5
    点赞
  • 14
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值