Spring Security 实现 antMatchers 配置路径的动态获取

原文:Spring Security 实现 antMatchers 配置路径的动态获取 - 黑帽子技术的个人空间 - OSCHINA - 中文开源技术交流社区​​​​​​

1,引入pom

<dependency>
    <groupId>org.hepeng</groupId>
    <artifactId>hp-java-commons</artifactId>
    <version>1.1.3</version>
</dependency>

2,实现 SecurityConfigAttributeLoader (这里也可以从数据库获取)

@Configuration
@RefreshScope
public class MemorySecurityConfigAttributeLoader implements SecurityConfigAttributeLoader {

    @Value("${s-supply-chain-crm.white-list}")
    private String whiteList;

    @Value("${s-supply-chain-crm.black-list}")
    private String blackList;

    @Override
    public LinkedHashMap<RequestMatcher, Collection<ConfigAttribute>> loadConfigAttribute(HttpServletRequest request) {
        LinkedHashMap<RequestMatcher, Collection<ConfigAttribute>> requestMatcherCollectionLinkedHashMap = new LinkedHashMap<>();
        if (StringUtils.isNotBlank(whiteList)) {
            Arrays.asList(whiteList.split(",")).forEach(url -> {
                AntPathRequestMatcher antPathRequestMatcher = new AntPathRequestMatcher(url);
                SecurityAccessConfigHelper securityAccessConfigHelper = new SecurityAccessConfigHelper();
                List<ConfigAttribute> configAttributes = SecurityConfig.createList(securityAccessConfigHelper.permitAll().access());
                requestMatcherCollectionLinkedHashMap.put(antPathRequestMatcher, configAttributes);
            });
        }
        if (StringUtils.isNotBlank(blackList)) {
            Arrays.asList(blackList.split(",")).forEach(url -> {
                AntPathRequestMatcher antPathRequestMatcher = new AntPathRequestMatcher(url);
                SecurityAccessConfigHelper securityAccessConfigHelper = new SecurityAccessConfigHelper();
                List<ConfigAttribute> configAttributes = SecurityConfig.createList(securityAccessConfigHelper.denyAll().access());
                requestMatcherCollectionLinkedHashMap.put(antPathRequestMatcher, configAttributes);
            });
        }
        return requestMatcherCollectionLinkedHashMap;
    }
}

3,配置

  @Override
    public void configure(HttpSecurity http) throws Exception {     
               http.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
                .and().authorizeRequests()
                .anyRequest().authenticated()
                .withObjectPostProcessor(new CustomizeSecurityMetadataSourceObjectPostProcessor(new MemorySecurityConfigAttributeLoader()))
                .withObjectPostProcessor(new GlobalSecurityExpressionHandlerCacheObjectPostProcessor());
    }

4,扩展spring oauth2 资源服务器

package com.lemontree.crm.utils;

import java.lang.reflect.Method;
import java.util.Arrays;

public class ReflectUtils {
    public static Class<?>[] types(Object... values) {
        if (values == null) {
            return new Class[0];
        } else {
            Class<?>[] result = new Class[values.length];

            for(int i = 0; i < values.length; ++i) {
                Object value = values[i];
                result[i] = value == null ? NULL.class : value.getClass();
            }

            return result;
        }
    }
    private static class NULL {
        private NULL() {
        }
    }
    public static Method similarMethod(Class t, String name, Class<?>[] types) throws NoSuchMethodException {
        Method[] var4 = t.getMethods();
        int var5 = var4.length;

        int var6;
        Method method;
        for(var6 = 0; var6 < var5; ++var6) {
            method = var4[var6];
            if (ReflectUtils.isSimilarSignature(method, name, types)) {
                return method;
            }
        }

        do {
            var4 = t.getDeclaredMethods();
            var5 = var4.length;

            for(var6 = 0; var6 < var5; ++var6) {
                method = var4[var6];
                if (isSimilarSignature(method, name, types)) {
                    return method;
                }
            }

            t = t.getSuperclass();
        } while(t != null);

        throw new NoSuchMethodException("No similar method " + name + " with params " + Arrays.toString(types) + " could be found on type .");
//        throw new NoSuchMethodException("No similar method " + name + " with params " + Arrays.toString(types) + " could be found on type " + this.type() + ".");
    }
    private static boolean isSimilarSignature(Method possiblyMatchingMethod, String desiredMethodName, Class<?>[] desiredParamTypes) {
        return possiblyMatchingMethod.getName().equals(desiredMethodName) && match(possiblyMatchingMethod.getParameterTypes(), desiredParamTypes);
    }
    private static boolean match(Class<?>[] declaredTypes, Class<?>[] actualTypes) {
        if (declaredTypes.length == actualTypes.length) {
            for(int i = 0; i < actualTypes.length; ++i) {
                if (actualTypes[i] != NULL.class && !wrapper(declaredTypes[i]).isAssignableFrom(wrapper(actualTypes[i]))) {
                    return false;
                }
            }

            return true;
        } else {
            return false;
        }
    }
    public static <T> Class<T> wrapper(Class<T> type) {
        if (type == null) {
            return null;
        } else {
            if (type.isPrimitive()) {
                if (Boolean.TYPE == type) {
                    return (Class<T>) Boolean.class;
                }

                if (Integer.TYPE == type) {
                    return (Class<T>) Integer.class;
                }

                if (Long.TYPE == type) {
                    return (Class<T>) Long.class;
                }

                if (Short.TYPE == type) {
                    return (Class<T>) Short.class;
                }

                if (Byte.TYPE == type) {
                    return (Class<T>) Byte.class;
                }

                if (Double.TYPE == type) {
                    return (Class<T>) Double.class;
                }

                if (Float.TYPE == type) {
                    return (Class<T>) Float.class;
                }

                if (Character.TYPE == type) {
                    return (Class<T>) Character.class;
                }

                if (Void.TYPE == type) {
                    return (Class<T>) Void.class;
                }
            }

            return type;
        }
    }
    public static Method exactMethod(Class t,String name, Class<?>[] types) throws NoSuchMethodException {
        try {
            return t.getMethod(name, types);
        } catch (NoSuchMethodException var7) {
            while(true) {
                try {
                    return t.getDeclaredMethod(name, types);
                } catch (NoSuchMethodException var6) {
                    t = t.getSuperclass();
                    if (t == null) {
                        throw new NoSuchMethodException();
                    }
                }
            }
        }
    }
}
package com.lemontree.crm.config.refresh;

import cn.hutool.core.util.ReflectUtil;
import com.lemontree.crm.utils.ReflectUtils;
import org.joor.Reflect;
import org.springframework.expression.ExpressionParser;
import org.springframework.security.access.ConfigAttribute;
import org.springframework.security.access.SecurityMetadataSource;
import org.springframework.security.access.expression.SecurityExpressionHandler;
import org.springframework.security.web.FilterInvocation;
import org.springframework.security.web.access.expression.ExpressionBasedFilterInvocationSecurityMetadataSource;
import org.springframework.security.web.access.intercept.DefaultFilterInvocationSecurityMetadataSource;
import org.springframework.security.web.util.matcher.RequestMatcher;

import javax.servlet.http.HttpServletRequest;
import java.lang.reflect.Method;
import java.util.*;

/**
 * @author lwc
 */
public class CustomizeConfigSourceFilterInvocationSecurityMetadataSource extends DefaultFilterInvocationSecurityMetadataSource {


    private SecurityMetadataSource delegate;
    private SecurityConfigAttributeLoader metadataSourceLoader;
    private ExpressionParser expressionParser;
    private SecurityExpressionHandler<FilterInvocation> expressionHandler;

    public CustomizeConfigSourceFilterInvocationSecurityMetadataSource(
            SecurityMetadataSource delegate,
            SecurityConfigAttributeLoader metadataSourceLoader, SecurityExpressionHandler<FilterInvocation> expressionHandler) {
        super(new LinkedHashMap<>());
        this.delegate = delegate;
        this.metadataSourceLoader = metadataSourceLoader;
        this.expressionHandler = expressionHandler;

        copyDelegateRequestMap();
    }

    private void copyDelegateRequestMap() {
        ReflectUtil.setFieldValue(this,"requestMap",getDelegateRequestMap());
    }

    private LinkedHashMap<RequestMatcher, Collection<ConfigAttribute>> getDelegateRequestMap() {
        return (LinkedHashMap<RequestMatcher, Collection<ConfigAttribute>>) ReflectUtil.getFieldValue(this.delegate,"requestMap");
    }


    @Override
    public Collection<ConfigAttribute> getAttributes(Object object) {
        final HttpServletRequest request = ((FilterInvocation) object).getRequest();
        Collection<ConfigAttribute> configAttributes = new ArrayList<>();
        LinkedHashMap<RequestMatcher, Collection<ConfigAttribute>> requestMap =
                this.metadataSourceLoader.loadConfigAttribute(request);

        if (requestMap == null || requestMap.size() == 0) {
            configAttributes.addAll(this.delegate.getAttributes(object));
            return configAttributes;
        }

        if (Objects.isNull(this.expressionParser)) {
            this.expressionParser = expressionHandler.getExpressionParser();
        }


        Class<ExpressionBasedFilterInvocationSecurityMetadataSource> expressionBasedFilterInvocationSecurityMetadataSourceClass = ExpressionBasedFilterInvocationSecurityMetadataSource.class;//ExpressionBasedFilterInvocationSecurityMetadataSource
        LinkedHashMap<RequestMatcher, Collection<ConfigAttribute>> webExpressionRequestMap = null;
        Class<?>[] types = ReflectUtils.types(requestMap, this.expressionParser);
        try {
            Method processMap = ReflectUtils.exactMethod(expressionBasedFilterInvocationSecurityMetadataSourceClass, "processMap", types);
            processMap.setAccessible(true);
            webExpressionRequestMap = (LinkedHashMap<RequestMatcher, Collection<ConfigAttribute>>) processMap.invoke(expressionBasedFilterInvocationSecurityMetadataSourceClass, requestMap, this.expressionParser);

        } catch (Exception e) {
            try {
                Method processMap = ReflectUtils.similarMethod(expressionBasedFilterInvocationSecurityMetadataSourceClass, "processMap", types);
                processMap.setAccessible(true);
                webExpressionRequestMap = (LinkedHashMap<RequestMatcher, Collection<ConfigAttribute>>) processMap.invoke(expressionBasedFilterInvocationSecurityMetadataSourceClass, requestMap, this.expressionParser);
            } catch (Exception noSuchMethodException) {
                noSuchMethodException.printStackTrace();
            }
        }

        for (Map.Entry<RequestMatcher, Collection<ConfigAttribute>> entry : webExpressionRequestMap.entrySet()) {
            if (entry.getKey().matches(request)) {
                configAttributes.addAll(entry.getValue());
                break;
            }
        }

        configAttributes.addAll(this.delegate.getAttributes(object));
        return configAttributes;
    }
}
package com.lemontree.crm.config.refresh;

import org.springframework.security.access.expression.SecurityExpressionHandler;
import org.springframework.security.config.annotation.ObjectPostProcessor;
import org.springframework.security.web.FilterInvocation;
import org.springframework.security.web.access.intercept.FilterSecurityInterceptor;

/**
 * @author lwc
 */
public class CustomizeSecurityMetadataSourceObjectPostProcessor implements ObjectPostProcessor<FilterSecurityInterceptor> {

    private SecurityConfigAttributeLoader securityConfigAttributeLoader;

    private SecurityExpressionHandler<FilterInvocation> expressionHandler;

    public CustomizeSecurityMetadataSourceObjectPostProcessor(SecurityConfigAttributeLoader securityConfigAttributeLoader, SecurityExpressionHandler<FilterInvocation> expressionHandler) {
        this.securityConfigAttributeLoader = securityConfigAttributeLoader;
        this.expressionHandler = expressionHandler;
    }

    @Override
    public <O extends FilterSecurityInterceptor> O postProcess(O object) {
        FilterSecurityInterceptor interceptor = object;

        CustomizeConfigSourceFilterInvocationSecurityMetadataSource metadataSource =
                new CustomizeConfigSourceFilterInvocationSecurityMetadataSource(
                        interceptor.obtainSecurityMetadataSource(), securityConfigAttributeLoader, expressionHandler);
        interceptor.setSecurityMetadataSource(metadataSource);
        return (O) interceptor;
    }
}
package com.lemontree.crm.config.refresh;

import org.springframework.util.Assert;
import org.springframework.util.StringUtils;

/**
 * @author lwc
 */
public class SecurityAccessConfigHelper {

    public static final String PERMIT_ALL = "permitAll";
    public static final String DENY_ALL = "denyAll";
    public static final String ANONYMOUS = "anonymous";
    public static final String AUTHENTICATED = "authenticated";
    public static final String FULLY_AUTHENTICATED = "fullyAuthenticated";
    public static final String REMEMBER_ME = "rememberMe";

    private StringBuilder access = new StringBuilder();

    public SecurityAccessConfigHelper permitAll() {
        and();
        this.access.append(PERMIT_ALL);
        return this;
    }

    public SecurityAccessConfigHelper denyAll() {
        and();
        this.access.append(DENY_ALL);
        return this;
    }

    public SecurityAccessConfigHelper anonymous() {
        and();
        this.access.append(ANONYMOUS);
        return this;
    }

    public SecurityAccessConfigHelper authenticated() {
        and();
        this.access.append(AUTHENTICATED);
        return this;
    }

    public SecurityAccessConfigHelper fullyAuthenticated() {
        and();
        this.access.append(FULLY_AUTHENTICATED);
        return this;
    }

    public SecurityAccessConfigHelper rememberMe() {
        and();
        this.access.append(REMEMBER_ME);
        return this;
    }

    public SecurityAccessConfigHelper hasAnyRole(String... authorities) {
        String anyAuthorities = StringUtils.arrayToDelimitedString(authorities,
                "','ROLE_");
        and();
        this.access.append("hasAnyRole('ROLE_" + anyAuthorities + "')");
        return this;
    }

    public SecurityAccessConfigHelper hasRole(String role) {
        Assert.notNull(role, "role cannot be null");
        if (role.startsWith("ROLE_")) {
            throw new IllegalArgumentException(
                    "role should not start with 'ROLE_' since it is automatically inserted. Got '"
                            + role + "'");
        }

        and();
        this.access.append("hasRole('ROLE_" + role + "')");
        return this;
    }

    public SecurityAccessConfigHelper hasAnyAuthority(String... authorities) {
        String anyAuthorities = StringUtils.arrayToDelimitedString(authorities, "','");
        and();
        this.access.append("hasAnyAuthority('" + anyAuthorities + "')");
        return this;
    }

    public SecurityAccessConfigHelper hasAuthority(String authority) {
        and();
        this.access.append("hasAuthority('" + authority + "')");
        return this;
    }

    public SecurityAccessConfigHelper hasIpAddress(String ipAddressExpression) {
        and();
        this.access.append("hasIpAddress('" + ipAddressExpression + "')");
        return this;
    }

    public String access() {
        return this.access.toString();
    }


    private SecurityAccessConfigHelper and() {
        if (this.access.length() != 0) {
            this.access.append(" and ");
        }
        return this;
    }
}
package com.lemontree.crm.config.refresh;

import org.springframework.security.access.ConfigAttribute;
import org.springframework.security.web.util.matcher.RequestMatcher;

import javax.servlet.http.HttpServletRequest;
import java.util.Collection;
import java.util.LinkedHashMap;

/**
 * @author lwc
 */
public interface SecurityConfigAttributeLoader {

    LinkedHashMap<RequestMatcher, Collection<ConfigAttribute>> loadConfigAttribute(HttpServletRequest request);
}
package com.lemontree.crm.config.oauth;

import cn.hutool.extra.spring.SpringUtil;
import com.alibaba.fastjson.JSONObject;
import com.lemontree.crm.config.refresh.SecurityAccessConfigHelper;
import com.lemontree.crm.config.refresh.SecurityConfigAttributeLoader;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.cloud.context.config.annotation.RefreshScope;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.access.ConfigAttribute;
import org.springframework.security.access.SecurityConfig;
import org.springframework.security.web.util.matcher.AntPathRequestMatcher;
import org.springframework.security.web.util.matcher.RequestMatcher;

import javax.servlet.http.HttpServletRequest;
import java.util.Arrays;
import java.util.Collection;
import java.util.LinkedHashMap;
import java.util.List;

/**
 * @author liwenchao
 */
@Configuration
@RefreshScope
@Slf4j
public class MemorySecurityConfigAttributeLoader implements SecurityConfigAttributeLoader {

    @Override
    public LinkedHashMap<RequestMatcher, Collection<ConfigAttribute>> loadConfigAttribute(HttpServletRequest request) {
        LinkedHashMap<RequestMatcher, Collection<ConfigAttribute>> requestMatcherCollectionLinkedHashMap = new LinkedHashMap<>();
        //获取当前环境
        String activeProfile = SpringUtil.getActiveProfile();
        String application = request.getHeader("application");
        log.info("crm当前运行环境:{}",activeProfile);
        log.info("crm请求头application:{}",application);
        if (!"app".equalsIgnoreCase(application) || "local".equalsIgnoreCase(activeProfile)) {
            AntPathRequestMatcher antPathRequestMatcher = new AntPathRequestMatcher("/**");
            SecurityAccessConfigHelper securityAccessConfigHelper = new SecurityAccessConfigHelper();
            List<ConfigAttribute> configAttributes = SecurityConfig.createList(securityAccessConfigHelper.permitAll().access());
            requestMatcherCollectionLinkedHashMap.put(antPathRequestMatcher,configAttributes);
            return requestMatcherCollectionLinkedHashMap;
        }
        JSONObject whiteAndBlackList = UtilsController.getWhiteAndBlackList();
        String white = whiteAndBlackList.getString("white");
        String black = whiteAndBlackList.getString("black");
        log.info("crm黑白名单列表:{}",whiteAndBlackList);
        if (StringUtils.isNotBlank(white)) {
            Arrays.asList(white.split(",")).forEach(url -> {
                AntPathRequestMatcher antPathRequestMatcher = new AntPathRequestMatcher(url);
                SecurityAccessConfigHelper securityAccessConfigHelper = new SecurityAccessConfigHelper();
                List<ConfigAttribute> configAttributes = SecurityConfig.createList(securityAccessConfigHelper.permitAll().access());
                requestMatcherCollectionLinkedHashMap.put(antPathRequestMatcher, configAttributes);
            });
        }
        if (StringUtils.isNotBlank(black)) {
            Arrays.asList(black.split(",")).forEach(url -> {
                AntPathRequestMatcher antPathRequestMatcher = new AntPathRequestMatcher(url);
                SecurityAccessConfigHelper securityAccessConfigHelper = new SecurityAccessConfigHelper();
                List<ConfigAttribute> configAttributes = SecurityConfig.createList(securityAccessConfigHelper.denyAll().access());
                requestMatcherCollectionLinkedHashMap.put(antPathRequestMatcher, configAttributes);
            });
        }
        return requestMatcherCollectionLinkedHashMap;
    }
}
package com.lemontree.crm.config.oauth;

import com.lemontree.crm.config.refresh.CustomizeSecurityMetadataSourceObjectPostProcessor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.cloud.context.config.annotation.RefreshScope;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.access.expression.SecurityExpressionHandler;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.jwt.crypto.sign.MacSigner;
import org.springframework.security.oauth2.config.annotation.web.configuration.EnableResourceServer;
import org.springframework.security.oauth2.config.annotation.web.configuration.ResourceServerConfigurerAdapter;
import org.springframework.security.oauth2.config.annotation.web.configurers.ResourceServerSecurityConfigurer;
import org.springframework.security.oauth2.provider.authentication.OAuth2AuthenticationManager;
import org.springframework.security.oauth2.provider.expression.OAuth2WebSecurityExpressionHandler;
import org.springframework.security.oauth2.provider.token.DefaultTokenServices;
import org.springframework.security.oauth2.provider.token.TokenStore;
import org.springframework.security.oauth2.provider.token.store.JwtAccessTokenConverter;
import org.springframework.security.oauth2.provider.token.store.JwtTokenStore;
import org.springframework.security.web.FilterInvocation;
import org.springframework.security.web.authentication.preauth.AbstractPreAuthenticatedProcessingFilter;

/**
 * @Description
 * @Author lwc
 * @Data 2022/8/29 22:26
 */
@Configuration
@EnableResourceServer //开启资源服务器功能
@EnableWebSecurity(debug = true)  //开启web访问安全
@RefreshScope
@Slf4j
public class ResourceServerConfiger extends ResourceServerConfigurerAdapter {

    @Value("${oauth2-resource.sign-key}")
    private String SIGN_KEY; //jwt签名密钥
    //    private String sign_key = "imugua20220829"; //jwt签名密钥 ee7dcc6cad12f7d7ef9642e680fdbc4d
    @Value("${oauth2-resource.release}")
    private String release; //资源服务器标识

    private SecurityExpressionHandler<FilterInvocation> expressionHandler = new OAuth2WebSecurityExpressionHandler();

    /**
     * @Description 该⽅法⽤于定义资源服务器向远程认证服务器发起请求,进⾏token校验
     * 等事宜
     */
    @Override
    public void configure(ResourceServerSecurityConfigurer resources) throws Exception {
        /*// 设置当前资源服务的资源id
        resources.resourceId("release");
        // 定义token服务对象(token校验就应该靠token服务对象)
        RemoteTokenServices remoteTokenServices = new RemoteTokenServices();
        // 校验端点/接⼝设置
        remoteTokenServices.setCheckTokenEndpointUrl("http://localhost:9999/oauth/check_token");
        // 携带客户端id和客户端安全码
        remoteTokenServices.setClientId("clientmugua");
        remoteTokenServices.setClientSecret("zbcxyz");

        resources.tokenServices(remoteTokenServices);*/

        //使用jwt令牌
        resources.resourceId(release).tokenStore(tokenStore()).stateless(true);//无状态设置
    }

    /**
     * @Description 场景:⼀个服务中可能有很多资源(API接⼝)
     * * 某⼀些API接⼝,需要先认证,才能访问
     * * 某⼀些API接⼝,压根就不需要认证,本来就是对外开放的接⼝
     * * 我们就需要对不同特点的接⼝区分对待(在当前configure⽅法中
     * 完成),设置是否需要经过认证
     */
    @Override
    public void configure(HttpSecurity http) throws Exception {


        http.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
                .and().authorizeRequests()
                .expressionHandler(expressionHandler)
                .anyRequest().authenticated()
                .withObjectPostProcessor(new CustomizeSecurityMetadataSourceObjectPostProcessor(new MemorySecurityConfigAttributeLoader(), expressionHandler));
    }

    /**
     * @Description 该⽅法⽤于创建tokenStore对象(令牌存储对象)
     * token以什么形式存储
     */
    public TokenStore tokenStore() {
        //return new InMemoryTokenStore();
        // 使⽤jwt令牌
        return new JwtTokenStore(jwtAccessTokenConverter());
    }


    /**
     * @Description * 返回jwt令牌转换器(帮助我们⽣成jwt令牌的)
     * 在这⾥,我们可以把签名密钥传递进去给转换器对象
     */
    public JwtAccessTokenConverter jwtAccessTokenConverter() {
        JwtAccessTokenConverter jwtAccessTokenConverter = new JwtAccessTokenConverter();
        jwtAccessTokenConverter.setSigningKey(SIGN_KEY); // 签名密钥
        jwtAccessTokenConverter.setVerifier(new MacSigner(SIGN_KEY)); // 验证时使⽤的密钥,
        // 和签名密钥保持⼀致3.3.5 从数据库加载Oauth2客户端信息
        // 创建数据表并初始化数据(表名及字段保持固定)
        return jwtAccessTokenConverter;
    }
}

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值