Java后台框架篇--Springsecurity(一)

如何使用spring security,相信百度过的都知道,总共有四种用法,从简到深为:1、不用数据库,全部数据写在配置文件,这个也是官方文档里面的demo;2、使用数据库,根据spring security默认实现代码设计数据库,也就是说数据库已经固定了,这种方法不灵活,而且那个数据库设计得很简陋,实用性差;3、spring security和Acegi不同,它不能修改默认filter了,但支持插入filter,所以根据这个,我们可以插入自己的filter来灵活使用;4、暴力手段,修改源码,前面说的修改默认filter只是修改配置文件以替换filter而已,这种是直接改了里面的源码,但是这种不符合OO设计原则,而且不实际,不可用。

本文面向读者:

因为本文准备介绍第三种方法,所以面向的读者是已经具备了spring security基础知识的。不过不要紧,读者可以先看一下这个教程,看完应该可以使用第二种方法开发了。

spring security的简单原理:

使用众多的拦截器对url拦截,以此来管理权限。但是这么多拦截器,笔者不可能对其一一来讲,主要讲里面核心流程的两个。

首先,权限管理离不开登陆验证的,所以登陆验证拦截器AuthenticationProcessingFilter要讲;还有就是对访问的资源管理吧,所以资源管理拦截器AbstractSecurityInterceptor要讲;但拦截器里面的实现需要一些组件来实现,所以就有了AuthenticationManager、accessDecisionManager等组件来支撑。

现在先大概过一遍整个流程,用户登陆,会被AuthenticationProcessingFilter拦截,调用AuthenticationManager的实现,而且AuthenticationManager会调用ProviderManager来获取用户验证信息(不同的Provider调用的服务不同,因为这些信息可以是在数据库上,可以是在LDAP服务器上,可以是xml配置文件上等),如果验证通过后会将用户的权限信息封装一个User放到spring的全局缓存SecurityContextHolder中,以备后面访问资源时使用。

访问资源(即授权管理),访问url时,会通过AbstractSecurityInterceptor拦截器拦截,其中会调用FilterInvocationSecurityMetadataSource的方法来获取被拦截url所需的全部权限,在调用授权管理器AccessDecisionManager,这个授权管理器会通过spring的全局缓存SecurityContextHolder获取用户的权限信息,还会获取被拦截的url和被拦截url所需的全部权限,然后根据所配的策略(有:一票决定,一票否定,少数服从多数等),如果权限足够,则返回,权限不够则报错并调用权限不足页面。

虽然讲得好像好复杂,读者们可能有点晕,不过不打紧,真正通过代码的讲解在后面,读者可以看完后面的代码实现,再返回看这个简单的原理,可能会有不错的收获。

spring security使用实现(基于spring security3.1.4):

javaEE的入口:web.xml:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
<? xml version = "1.0" encoding = "UTF-8" ?> 
< web-app version = "2.5" xmlns = "http://java.sun.com/xml/ns/javaee" 
     xmlns:xsi = "http://www.w3.org/2001/XMLSchema-instance" 
     xsi:schemaLocation = "http://java.sun.com/xml/ns/javaee http://java.sun.com/xml/ns/javaee/web-app_2_5.xsd" >  
      <!--加载Spring XML配置文件 --> 
     < context-param
         < param-name >contextConfigLocation</ param-name
         < param-value > classpath:securityConfig.xml           </ param-value
     </ context-param >  
       <!-- Spring Secutiry3.1的过滤器链配置 --> 
     < filter
         < filter-name >springSecurityFilterChain</ filter-name
         < filter-class >org.springframework.web.filter.DelegatingFilterProxy</ filter-class
     </ filter
     < filter-mapping
         < filter-name >springSecurityFilterChain</ filter-name
         < url-pattern >/*</ url-pattern
     </ filter-mapping >  
        <!-- Spring 容器启动监听器 --> 
     < listener
         < listener-class >org.springframework.web.context.ContextLoaderListener</ listener-class
     </ listener >    
       <!--系统欢迎页面 --> 
     < welcome-file-list
         < welcome-file >index.jsp</ welcome-file
     </ welcome-file-list
</ web-app >

上面那个配置不用多说了吧

直接上spring security的配置文件securityConfig.xml:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
<?xml version= "1.0" encoding= "UTF-8" ?> 
<b:beans xmlns= "http://www.springframework.org/schema/security" 
     xmlns:b= "http://www.springframework.org/schema/beans" 
     xmlns:xsi= "http://www.w3.org/2001/XMLSchema-instance" 
     xsi:schemaLocation="http: //www.springframework.org/schema/beans http://www.springframework.org/schema/beans/spring-beans-3.0.xsd 
                         http: //www.springframework.org/schema/security http://www.springframework.org/schema/security/spring-security-3.1.xsd"> 
 
   <!--登录页面不过滤 --> 
     <http pattern= "/login.jsp" security= "none" /> 
     <http access-denied-page= "/accessDenied.jsp"
         <form-login login-page= "/login.jsp" /> 
         <!--访问/admin.jsp资源的用户必须具有ROLE_ADMIN的权限 --> 
         <!-- <intercept-url pattern= "/admin.jsp" access= "ROLE_ADMIN" /> --> 
         <!--访问/**资源的用户必须具有ROLE_USER的权限 --> 
         <!-- <intercept-url pattern= "/**" access= "ROLE_USER" /> --> 
         <session-management> 
             <concurrency-control max-sessions= "1" 
                 error- if -maximum-exceeded= "false" /> 
         </session-management> 
         <!--增加一个filter,这点与 Acegi是不一样的,不能修改默认的filter了, 这个filter位于FILTER_SECURITY_INTERCEPTOR之前 --> 
         <custom-filter ref= "myFilter" before= "FILTER_SECURITY_INTERCEPTOR" /> 
     </http> 
     <!--一个自定义的filter,必须包含 authenticationManager,accessDecisionManager,securityMetadataSource三个属性,  
         我们的所有控制将在这三个类中实现,解释详见具体配置 --> 
     <b:bean id= "myFilter" 
         class = "com.erdangjiade.spring.security.MyFilterSecurityInterceptor"
         <b:property name= "authenticationManager" ref= "authenticationManager" /> 
         <b:property name= "accessDecisionManager" ref= "myAccessDecisionManagerBean" /> 
         <b:property name= "securityMetadataSource" ref= "securityMetadataSource" /> 
     </b:bean> 
     <!--验证配置,认证管理器,实现用户认证的入口,主要实现UserDetailsService接口即可 --> 
     <authentication-manager alias= "authenticationManager"
         <authentication-provider user-service-ref= "myUserDetailService"
             <!--如果用户的密码采用加密的话 <password-encoder hash= "md5" /> --> 
         </authentication-provider> 
     </authentication-manager> 
     <!--在这个类中,你就可以从数据库中读入用户的密码,角色信息,是否锁定,账号是否过期等 --> 
     <b:bean id= "myUserDetailService" class = "com.erdangjiade.spring.security.MyUserDetailService" /> 
     <!--访问决策器,决定某个用户具有的角色,是否有足够的权限去访问某个资源 --> 
     <b:bean id= "myAccessDecisionManagerBean" 
         class = "com.erdangjiade.spring.security.MyAccessDecisionManager"
     </b:bean> 
     <!--资源源数据定义,将所有的资源和权限对应关系建立起来,即定义某一资源可以被哪些角色访问 --> 
     <b:bean id= "securityMetadataSource" 
         class = "com.erdangjiade.spring.security.MyInvocationSecurityMetadataSource" />  
 
  </b:beans>

其实所有配置都在<http></http>里面,首先这个版本的spring security不支持了filter=none的配置了,改成了独立的<http pattern=”/login.jsp” security=”none”/>,里面你可以配登陆页面、权限不足的返回页面、注销页面等,上面那些配置,我注销了一些资源和权限的对应关系,笔者这里不需要在这配死它,可以自己写拦截器来获得资源与权限的对应关系。

session-management是用来防止多个用户同时登陆一个账号的。

最重要的是笔者自己写的拦截器myFilter(终于讲到重点了),首先这个拦截器会加载在FILTER_SECURITY_INTERCEPTOR之前(配置文件上有说),最主要的是这个拦截器里面配了三个处理类,第一个是authenticationManager,这个是处理验证的,这里需要特别说明的是:这个类不单只这个拦截器用到,还有验证拦截器AuthenticationProcessingFilter也用到 了,而且实际上的登陆验证也是AuthenticationProcessingFilter拦截器调用authenticationManager来处理的,我们这个拦截器只是为了拿到验证用户信息而已(这里不太清楚,因为authenticationManager笔者设了断点,用户登陆后再也没调用这个类了,而且调用这个类时不是笔者自己写的那个拦截器调用的,看了spring技术内幕这本书才知道是AuthenticationProcessingFilter拦截器调用的)。

securityMetadataSource这个用来加载资源与权限的全部对应关系的,并提供一个通过资源获取所有权限的方法。

accessDecisionManager这个也称为授权器,通过登录用户的权限信息、资源、获取资源所需的权限来根据不同的授权策略来判断用户是否有权限访问资源。

authenticationManager类可以有许多provider(提供者)提供用户验证信息,这里笔者自己写了一个类myUserDetailService来获取用户信息。

MyUserDetailService:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
package com.erdangjiade.spring.security; 
 
import java.util.ArrayList; 
import java.util.Collection; 
 
import org.springframework.dao.DataAccessException; 
import org.springframework.security.core.GrantedAuthority; 
import org.springframework.security.core.authority.GrantedAuthorityImpl; 
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; 
 
public class MyUserDetailService implements UserDetailsService {  
 
     //登陆验证时,通过username获取用户的所有权限信息, 
     //并返回User放到spring的全局缓存SecurityContextHolder中,以供授权器使用 
     public UserDetails loadUserByUsername(String username)  
             throws UsernameNotFoundException, DataAccessException {    
         Collection<GrantedAuthority> auths= new ArrayList<GrantedAuthority>();  
 
         GrantedAuthorityImpl auth2= new GrantedAuthorityImpl( "ROLE_ADMIN" );  
         GrantedAuthorityImpl auth1= new GrantedAuthorityImpl( "ROLE_USER" );  
 
         if (username.equals( "lcy" )){  
             auths= new ArrayList<GrantedAuthority>();  
             auths.add(auth1); 
             auths.add(auth2);       
         }      
 
         User user = new User(username, "lcy" , true , true , true , true , auths);  
         return user;   
         }  
     }

其中UserDetailsService接口是spring提供的,必须实现的。别看这个类只有一个方法,而且这么简单,其中内涵玄机。

读者看到这里可能就大感疑惑了,不是说好的用数据库吗?对,但别急,等笔者慢慢给你们解析。

首先,笔者为什么不用数据库,还不是为了读者们测试方便,并简化spring security的流程,让读者抓住主线,而不是还要烦其他事(导入数据库,配置数据库,写dao等)。

这里笔者只是用几个数据模拟了从数据库中拿到的数据,也就是说ROLE_ADMIN、ROLE_USER、lcy(第一个是登陆账号)、lcy(第二个是密码)是从数据库拿出来的,这个不难实现吧,如果需要数据库时,读者可以用自己写的dao通过参数username来查询出这个用户的权限信息(或是角色信息,就是那个ROLE_*,对必须是ROLE_开头的,不然spring security不认账的,其实是spring security里面做了一个判断,必须要ROLE_开头,读者可以百度改一下),再返回spring自带的数据模型User即可。

这个写应该比较清晰、灵活吧,总之数据读者们通过什么方法获取都行,只要返回一个User对象就行了。(这也是笔者为什么要重写这个类的原因)

    通过MyUserDetailService拿到用户信息后,authenticationManager对比用户的密码(即验证用户),然后这个AuthenticationProcessingFilter拦截器就过咯。

下面要说的是另外一个拦截器,就是笔者自己写的拦截器MyFilterSecurityInterceptor:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
package com.erdangjiade.spring.security; 
 
import java.io.IOException; 
 
import javax.servlet.Filter; 
import javax.servlet.FilterChain; 
import javax.servlet.FilterConfig; 
import javax.servlet.ServletException; 
import javax.servlet.ServletRequest; 
import javax.servlet.ServletResponse; 
 
import org.springframework.security.access.SecurityMetadataSource; 
import org.springframework.security.access.intercept.AbstractSecurityInterceptor; 
import org.springframework.security.access.intercept.InterceptorStatusToken; 
import org.springframework.security.web.FilterInvocation; 
import org.springframework.security.web.access.intercept.FilterInvocationSecurityMetadataSource; 
 
public class MyFilterSecurityInterceptor extends AbstractSecurityInterceptor  implements Filter {   
 
     //配置文件注入 
     private FilterInvocationSecurityMetadataSource securityMetadataSource; 
 
     //登陆后,每次访问资源都通过这个拦截器拦截 
     public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {  
         FilterInvocation fi = new FilterInvocation(request, response, chain);  
         invoke(fi);   
        
 
     public FilterInvocationSecurityMetadataSource getSecurityMetadataSource() {   
         return this .securityMetadataSource;   
         }    
 
     public Class<? extends Object> getSecureObjectClass() {  
         return FilterInvocation. class ;     
         }   
 
     public void invoke(FilterInvocation fi) throws IOException, ServletException { 
         //fi里面有一个被拦截的url 
         //里面调用MyInvocationSecurityMetadataSource的getAttributes(Object object)这个方法获取fi对应的所有权限 
         //再调用MyAccessDecisionManager的decide方法来校验用户的权限是否足够 
         InterceptorStatusToken token = super .beforeInvocation(fi); 
         try
             //执行下一个拦截器 
             fi.getChain().doFilter(fi.getRequest(), fi.getResponse());    
             } finally {  
                 super .afterInvocation(token, null );   
             }    
         }   
     public SecurityMetadataSource obtainSecurityMetadataSource() {  
         return this .securityMetadataSource;    
         }  
     public void setSecurityMetadataSource( 
             FilterInvocationSecurityMetadataSource newSource) 
     {  
         this .securityMetadataSource = newSource;  
     }  
     public void destroy() {   
 
     }    
     public void init(FilterConfig arg0) throws ServletException {   
 
     }   
}

继承AbstractSecurityInterceptor、实现Filter是必须的。

首先,登陆后,每次访问资源都会被这个拦截器拦截,会执行doFilter这个方法,这个方法调用了invoke方法,其中fi断点显示是一个url(可能重写了toString方法吧,但是里面还有一些方法的),最重要的是beforeInvocation这个方法,它首先会调用MyInvocationSecurityMetadataSource类的getAttributes方法获取被拦截url所需的权限,在调用MyAccessDecisionManager类decide方法判断用户是否够权限。弄完这一切就会执行下一个拦截器。

再看一下这个MyInvocationSecurityMetadataSource的实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
package com.erdangjiade.spring.security; 
 
import java.util.ArrayList; 
import java.util.Collection; 
import java.util.HashMap; 
import java.util.Iterator; 
import java.util.Map; 
 
import org.springframework.security.access.ConfigAttribute; 
import org.springframework.security.access.SecurityConfig; 
import org.springframework.security.web.FilterInvocation; 
import org.springframework.security.web.access.intercept.FilterInvocationSecurityMetadataSource; 
 
import com.erdangjiade.spring.security.tool.AntUrlPathMatcher; 
import com.erdangjiade.spring.security.tool.UrlMatcher; 
 
public class MyInvocationSecurityMetadataSource implements FilterInvocationSecurityMetadataSource {  
     private UrlMatcher urlMatcher = new AntUrlPathMatcher();  
     private static Map<String, Collection<ConfigAttribute>> resourceMap = null
 
     //tomcat启动时实例化一次 
     public MyInvocationSecurityMetadataSource() { 
         loadResourceDefine();   
         }    
     //tomcat开启时加载一次,加载所有url和权限(或角色)的对应关系 
     private void loadResourceDefine() { 
         resourceMap = new HashMap<String, Collection<ConfigAttribute>>();  
         Collection<ConfigAttribute> atts = new ArrayList<ConfigAttribute>();  
         ConfigAttribute ca = new SecurityConfig( "ROLE_USER" ); 
         atts.add(ca);  
         resourceMap.put( "/index.jsp" , atts);   
         Collection<ConfigAttribute> attsno = new ArrayList<ConfigAttribute>(); 
         ConfigAttribute cano = new SecurityConfig( "ROLE_NO" ); 
         attsno.add(cano); 
         resourceMap.put( "/other.jsp" , attsno);    
         }   
 
     //参数是要访问的url,返回这个url对于的所有权限(或角色) 
     public Collection<ConfigAttribute> getAttributes(Object object) throws IllegalArgumentException {  
         // 将参数转为url     
         String url = ((FilterInvocation)object).getRequestUrl();    
         Iterator<String>ite = resourceMap.keySet().iterator();  
         while (ite.hasNext()) {          
             String resURL = ite.next();   
             if (urlMatcher.pathMatchesUrl(resURL, url)) {  
                 return resourceMap.get(resURL);          
                 }        
             }  
         return null ;     
         }   
     public boolean supports(Class<?>clazz) {  
             return true ;   
             }  
     public Collection<ConfigAttribute> getAllConfigAttributes() {  
         return null ;   
        
     }

实现FilterInvocationSecurityMetadataSource接口也是必须的。

首先,这里也是模拟了从数据库中获取信息。

其中loadResourceDefine方法不是必须的,这个只是加载所有的资源与权限的对应关系并缓存起来,避免每次获取权限都访问数据库(提高性能),然后getAttributes根据参数(被拦截url)返回权限集合。

这种缓存的实现其实有一个缺点,因为loadResourceDefine方法是放在构造器上调用的,而这个类的实例化只在web服务器启动时调用一次,那就是说loadResourceDefine方法只会调用一次,如果资源和权限的对应关系在启动后发生了改变,那么缓存起来的就是脏数据,而笔者这里使用的就是缓存数据,那就会授权错误了。但如果资源和权限对应关系是不会改变的,这种方法性能会好很多。

现在说回有数据库的灵活实现,读者看到这,可能会说,这还不简单,和上面MyUserDetailService类一样使用dao灵活获取数据就行啦。

如果读者这样想,那只想到了一半,想一下spring的机制(依赖注入),dao需要依赖注入吧,但这是在启动时候,那个dao可能都还没加载,所以这里需要读者自己写sessionFactory,自己写hql或sql,对,就在loadResourceDefine方法里面写(这个应该会写吧,基础来的)。那如果说想用第二种方法呢(就是允许资源和权限的对应关系改变的那个),那更加简单,根本不需要loadResourceDefine方法了,直接在getAttributes方法里面调用dao(这个是加载完,后来才会调用的,所以可以使用dao),通过被拦截url获取数据库中的所有权限,封装成Collection<ConfigAttribute>返回就行了。(灵活、简单)

注意:接口UrlMatcher和实现类AntUrlPathMatcher是笔者自己写的,这本来是spring以前版本有的,现在没有了,但是觉得好用就用会来了,直接上代码(读者也可以自己写正则表达式验证被拦截url和缓存或数据库的url是否匹配):

1
2
3
4
5
6
7
8
package com.erdangjiade.spring.security.tool; 
 
public interface UrlMatcher{ 
     Object compile(String paramString); 
     boolean pathMatchesUrl(Object paramObject, String paramString); 
     String getUniversalMatchPattern();  
     boolean requiresLowerCaseUrl(); 
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
package com.erdangjiade.spring.security.tool;  
import org.springframework.util.AntPathMatcher; 
import org.springframework.util.PathMatcher;  
 
   public class AntUrlPathMatcher implements UrlMatcher {   
       private boolean requiresLowerCaseUrl; 
       private PathMatcher pathMatcher;  
       public AntUrlPathMatcher()   {  
           this ( true );  
 
   }   
       public AntUrlPathMatcher( boolean requiresLowerCaseUrl)  
       {   
           this .requiresLowerCaseUrl = true
       this .pathMatcher = new AntPathMatcher();  
       this .requiresLowerCaseUrl = requiresLowerCaseUrl; 
       }  
 
       public Object compile(String path) {  
           if ( this .requiresLowerCaseUrl) {  
               return path.toLowerCase();   
               }    
           return path;   
       }   
 
       public void setRequiresLowerCaseUrl( boolean requiresLowerCaseUrl){ 
 
           this .requiresLowerCaseUrl = requiresLowerCaseUrl;  
       }  
 
       public boolean pathMatchesUrl(Object path, String url) {  
           if (( "/**" .equals(path)) || ( "**" .equals(path))) { 
               return true ;      
               }   
 
           return this .pathMatcher.match((String)path, url);  
       }  
 
       public String getUniversalMatchPattern() { 
           return "/**" ;   
      
 
       public boolean requiresLowerCaseUrl() {  
           return this .requiresLowerCaseUrl;   
       }   
 
       public String toString() {   
           return super .getClass().getName() + "[requiresLowerCase='"  
       + this .requiresLowerCaseUrl + "']" ;   
      
   }

然后MyAccessDecisionManager类的实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
package com.erdangjiade.spring.security; 
 
import java.util.Collection; 
import java.util.Iterator; 
 
import org.springframework.security.access.AccessDecisionManager; 
import org.springframework.security.access.AccessDeniedException; 
import org.springframework.security.access.ConfigAttribute; 
import org.springframework.security.access.SecurityConfig; 
import org.springframework.security.authentication.InsufficientAuthenticationException; 
import org.springframework.security.core.Authentication; 
import org.springframework.security.core.GrantedAuthority; 
 
public class MyAccessDecisionManager implements AccessDecisionManager { 
 
     //检查用户是否够权限访问资源 
     //参数authentication是从spring的全局缓存SecurityContextHolder中拿到的,里面是用户的权限信息 
     //参数object是url 
     //参数configAttributes所需的权限 
     public void decide(Authentication authentication, Object object,     
             Collection<ConfigAttribute> configAttributes)  
                     throws AccessDeniedException, InsufficientAuthenticationException { 
         if (configAttributes == null ){  
             return ;        
         }   
 
         Iterator<ConfigAttribute> ite=configAttributes.iterator(); 
         while (ite.hasNext()){ 
             ConfigAttribute ca=ite.next();   
             String needRole=((SecurityConfig)ca).getAttribute(); 
             for (GrantedAuthority ga : authentication.getAuthorities()){  
                 if (needRole.equals(ga.getAuthority())){   
 
                     return ;               
         }             
     }       
}  
         //注意:执行这里,后台是会抛异常的,但是界面会跳转到所配的access-denied-page页面 
         throw new AccessDeniedException( "no right" );    
}    
     public boolean supports(ConfigAttribute attribute) {  
         return true
     }   
     public boolean supports(Class<?>clazz) { 
         return true ;  
         }  
     }

接口AccessDecisionManager也是必须实现的。

decide方法里面写的就是授权策略了,笔者的实现是,没有明说需要权限的(即没有对应的权限的资源),可以访问,用户具有其中一个或多个以上的权限的可以访问。这个就看需求了,需要什么策略,读者可以自己写其中的策略逻辑。通过就返回,不通过抛异常就行了,spring security会自动跳到权限不足页面(配置文件上配的)。

就这样,整个流程过了一遍。

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值