springboot整合Shiro

概述

Apache Shiro是一个强大且易用的Java安全框架,执行身份验证、授权、加密和会话管理,使用shiro可以非常方便的完成项目的权限管理模块开发

三个核心组件:

  • Subject:当前操作用户,可以是登录用户,也可以是第三方访问程序
  • SecurityManager:管理所有用户的安全操作,包括执行身份验证、授权、加密和会话管理
  • Realms:当对用户执行认证和授权验证时,Shiro会从应用配置的Realm中查找用户及其权限信息,本质上是一个实现了查询用户权限的DAO

maven依赖

<dependencies>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-web</artifactId>
        <version>1.5.5.RELEASE</version>
    </dependency>
    <dependency>
        <groupId>org.apache.shiro</groupId>
        <artifactId>shiro-spring</artifactId>
        <version>1.5.3</version>
    </dependency>
</dependencies>

Shiro配置

在springboot项目中,相关配置都是用Java代码加配置文件的方式实现,要使用shiro,必须对其进行相关配置,如下配置类

@Configuration
public class ShiroConfig {

    /**
     * 配置SecurityManager
     */
    @Bean
    @Autowired
    public DefaultWebSecurityManager securityManager(
            AuthRealm authRealm, DefaultWebSessionManager sessionManager,
            AbstractCacheManager cacheManager) {
        DefaultWebSecurityManager manager = new DefaultWebSecurityManager();
        //设置Realm
        manager.setRealm(authRealm);
        //设置 sessionManager
        manager.setSessionManager(sessionManager);
        //设置缓存管理
        manager.setCacheManager(cacheManager);
        return manager;
    }
    /**
     * 开启aop注解支持需要和advisorAutoProxyCreator方法搭配使用
     */
    @Bean
    @Autowired
    public AuthorizationAttributeSourceAdvisor authorizationAttributeSourceAdvisor(
            DefaultWebSecurityManager securityManager) {
        AuthorizationAttributeSourceAdvisor advisor = new AuthorizationAttributeSourceAdvisor();
        advisor.setSecurityManager(securityManager);
        return advisor;
    }

    /**
     * 开启Shiro的注解(如@RequiresRoles,@RequiresPermissions) 假设在controller方法上有@RequiresPermissions("123")这个注解,则它和配置文件中/xxx=c_perms[123]等效
     */
    @Bean
    public DefaultAdvisorAutoProxyCreator advisorAutoProxyCreator(){
        DefaultAdvisorAutoProxyCreator advisorAutoProxyCreator = new DefaultAdvisorAutoProxyCreator();
        advisorAutoProxyCreator.setProxyTargetClass(true);
        return advisorAutoProxyCreator;
    }
    /**
     * shiro过滤管理器 后面会详细解释
     */
    @Bean
    @Autowired
    @ConfigurationProperties(prefix = "using.shiro.filter")
    public ShiroFilterFactoryBean shiroFilterFactory(DefaultWebSecurityManager securityManager) {
        ShiroFilterFactoryBean factoryBean = new ShiroFilterFactoryBean();
        factoryBean.setSecurityManager(securityManager);
        // 此处的自定义过滤器不能交由Spring管理 重要的事情说三遍
        factoryBean.getFilters().put("c_user", new UserFilter());
        factoryBean.getFilters().put("c_perms", new PermissionsAuthorizationFilter());
        return factoryBean;
    }

    /**
     * 设置session管理器 比如超时时间等
     */
    @Bean
    @ConfigurationProperties(prefix = "using.shiro.session")
    public DefaultWebSessionManager sessionManager() {
        DefaultWebSessionManager manager = new DefaultWebSessionManager();
        // 所有的session一定要将id设置到Cookie之中,需要提供有Cookie的操作模版 -->
        manager.setSessionIdCookie(this.sessionIdCookie());
        manager.setSessionListeners(Arrays.asList(shiroSessionListener));
        manager.setSessionDAO(sessionDAO);

        return manager;
    }
    /**
     * 设置缓存管理器
     */
    @Bean
    public AbstractCacheManager cacheManager() {
        MemoryConstrainedCacheManager manager = new MemoryConstrainedCacheManager();
        return manager;
    }
    /**
     * 设置cookie
     */
    @Bean
    @ConfigurationProperties(prefix = "using.shiro.cookie.sessionId")
    public SimpleCookie sessionIdCookie() {
        SimpleCookie cookie = new SimpleCookie();
        return cookie;
    }

}

上述配置类,需要一些具体的配置项,具体配置项如下yml文件所示

using:
  shiro:
    filter: #在注入过滤器时会用到
      loginUrl: /login.html #登录页面
      successUrl: /index.html #登录后的首页
      unauthorizedUrl: /index403.html #无权访问的页面
      # 必须带'|'以保留换行  
      # anon表示不需要认证可直接访问。 c_user 表示需要用c_user对应的过滤器去执行,如UserFilter
      # factoryBean.getFilters().put("c_user", new UserFilter(sessionManager, userService));
      filterChainDefinitions: |
        /static/**=anon 
        /login.html=anon
        /index403.html=anon
        /login=anon
        /logout=c_user
        /menu/queryMenu=c_user
    session:
      # 超时时间 30 分钟
      globalSessionTimeout: 1800000
      deleteInvalidSessions: true
      # 1分钟扫描一次
      sessionValidationInterval: 60000
      sessionValidationSchedulerEnabled: true
      # 定义sessionIdCookie模版可以进行操作的启用
      sessionIdCookieEnabled: true
    cookie:
      sessionId:
        # 默认为JSESSIONID
        name: token
        # 保证该系统不会受到跨域的脚本操作供给, 默认为false
        httpOnly: true
        # 单位秒,默认为-1表示浏览器关闭,则Cookie消失
        maxAge: -1
        

Shiro的认证

认证:可以理解成登录,即在应用中谁能证明他就是他本人。如提供身份证,用户名来证明。在shiro中,用户需要提供principals(身份)和credentials(证明)给shiro,从而应用能验证用户身份:

principals:身份,即主体的标识属性,如用户名、邮箱等,唯一即可。一个主体可以有多个principals,但只有一个Primary principals,一般是用户名/密码/手机号。

credentials:证明/凭证,即只有主体知道的安全值,如密码/数字证书等。

代码示例

@RestController
public class LogController{
    @PostMapping("login")
    public String login(@RequestParam("username") String userName,
                        @RequestParam("password") String password){
          //1、得到Subject及创建用户名/密码身份验证Token 
          Subject subject = SecurityUtils.getSubject();  
          //这里的token类型(UsernamePasswordToken)要和后面的AuthorizingRealm.doGetAuthenticationInfo方法参数类型保持一致
          UsernamePasswordToken token = new UsernamePasswordToken(userName, password);
          try {  
              //2、登录,即身份验证 ,并缓存session 及权限等信息 
              subject.login(token);  
          } catch (AuthenticationException e) {  
              //3、身份验证失败 
              return "登录失败"; 
          }
        return "登录成功";
    }
    
    @PostMapping("logout")
    public String logout() {
        //1. 获取用户 
     Subject subject = SecurityUtils.getSubject();
     //2. 退出,并清空session 权限等缓存信息
     subject.logout();

     return "退出成功";
    }
}

Shiro的Realm

Realm获取用户的权限信息

//自定义Realm 继承AuthorizingRealm
@Component
public class AuthRealm extends AuthorizingRealm {
    @Autowired
    private UserService userService;
    @Autowired
    private DefaultWebSessionManager sessionManager;

    /***
     * 获取认证信息,在登录的时候会自动调用,根据用户名密码去验证用户
     */
    @Override
    protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken authToken) throws AuthenticationException {
        //这里的token类型要和登录时的token类型保持一致
        if (!(authToken instanceof UsernamePasswordToken)) {
            throw new AuthenticationException("未知的验证类型, 目前仅支持用户名密码验证");
        }
        UsernamePasswordToken pwdToken = (UsernamePasswordToken) authToken;
        //根据用户名查询用户
        User user = this.userService.getUserByUsername(pwdToken.getUsername());

        if (user == null) {
            throw new UnknownAccountException();
        }
        //根据查询出来的用户名和密码存储给AuthenticationInfo类,然后在框架中去验证用户登录密码是否和查询出来的密码一致
        //如果一致则登录成功,否则登录失败
        AuthenticationInfo info = new SimpleAuthenticationInfo(user.getUserName(), user.getPassword(),
                this.getClass().getName());

        Subject subject = SecurityUtils.getSubject();
        Session session = subject.getSession();
        user.setPassword(null);
        session.setAttribute("USER_INFO", user);

        return info;
    }
    /***
     * 获取权限信息 在登录的时候回自动调用,但必须先执行上面一个方法doGetAuthenticationInfo
     * 并将权限信息缓存起来
     */
    @Override
    protected AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection principals) {
        //获取用户
        Subject subject = SecurityUtils.getSubject();
        //根据用户获取session
        Session session = subject.getSession();
        Object sysUserObj = session.getAttribute("USER_INFO");
        User userInfo = (User) sysUserObj;
        //存放角色信息
        Set<String> roleSet = new HashSet<>();
        //存放权限集合
        Set<String> permissionSet = new HashSet<>();
        // 查询用户角色 用自定义的userService查询用户角色具有的资源权限信息
        List<Resource> resources = userService.getResourcesByRole(userInfo.getRoleNo());
        for (Resource resource : resources) {
            // 这里必须和配置文件/xxx=c_perms["RES_ID_100"]或注解@RequiresPermissions("RES_ID_100")保持一致
            permissionSet.add("RES_ID_" + resource.getId());
        }
        //创建返回数据
        SimpleAuthorizationInfo authorizationInfo = new SimpleAuthorizationInfo();
        //设置角色
        authorizationInfo.setRoles(roleSet);
        //设置所有资源权限
        authorizationInfo.setStringPermissions(permissionSet);
        return authorizationInfo;
    }
}

认证的执行流程:

  1. 用户调用登录请求,在登录时调用SecurityUtils.getSubject().login(token)
  2. shiro调用AuthorizingRealm类的doGetAuthenticationInfo方法进行验证
  3. 验证通过后,调用AuthorizingRealm类的doGetAuthorizationInfo方法查询该用户具有的权限信息

过滤器

shiro中默认的过滤器

过滤器名称过滤器类备注
anonorg.apache.shiro.web.filter.authc.AnonymousFilter匿名拦截器,即不需要登录即可访问;一般用于静态资源过滤;示例“/static/**=anon”
authcorg.apache.shiro.web.filter.authc.FormAuthenticationFilter基于表单的拦截器;如“/**=authc”,如果没有登录会跳到相应的登录页面登录
authcBasicorg.apache.shiro.web.filter.authc.BasicHttpAuthenticationFilterBasic HTTP身份验证拦截器,如果不通过,跳转到登录页面
logoutorg.apache.shiro.web.filter.authc.LogoutFilter退出拦截器,示例“/logout=logout”
noSessionCreationorg.apache.shiro.web.filter.session.NoSessionCreationFilter不创建会话拦截器,调用 subject.getSession(false)不会有什么问题,但是如果 subject.getSession(true)将抛出 DisabledSessionException异常;
permsorg.apache.shiro.web.filter.authz.PermissionsAuthorizationFilter权限拦截器,验证用户是否拥有该URL规定的所有权限;示例“/user/**=perms”
portorg.apache.shiro.web.filter.authz.PortFilter端口拦截器,主要属性:port(80):可以通过的端口;示例“/test= port[80]”,如果用户访问该页面是非80,将自动将请求端口改为80并重定向到该80端口,其他路径/参数等都一样
restorg.apache.shiro.web.filter.authz.HttpMethodPermissionFilterrest风格拦截器,自动根据请求方法构建权限字符串(GET=read, POST=create,PUT=update,DELETE=delete,HEAD=read,TRACE=read,OPTIONS=read, MKCOL=create)构建权限字符串;示例“/users=rest[user]”,会自动拼出“user:read,user:create,user:update,user:delete”权限字符串进行权限匹配(所有都得匹配,isPermittedAll);
rolesorg.apache.shiro.web.filter.authz.RolesAuthorizationFilter角色授权拦截器,验证用户是否拥有该URL规定的所有角色;
sslorg.apache.shiro.web.filter.authz.SslFilterSSL拦截器,只有请求协议是https才能通过;否则自动跳转会https端口(443);其他和port拦截器一样;
userorg.apache.shiro.web.filter.authc.UserFilter用户拦截器,用户身份验证已经/记住我登录的都可;示例“/**=user”

自定义过滤器

自定义过滤器可继承如下类:

  1. OncePerRequestFilter:保证一次请求只调用一次doFilterInternal,即如内部的forward不会再多执行一次doFilterInternal
  2. AdviceFilter:AdviceFilter提供了AOP的功能,其实现和SpringMVC中的Interceptor思想一样
  3. PathMatchingFilter:PathMatchingFilter继承了AdviceFilter,提供了url模式过滤的功能,如果需要对指定的请求进行处理,可以扩展PathMatchingFilter
  4. AccessControlFilter(常用):继承了PathMatchingFilter,并扩展了两个方法,isAccessAllowed和onAccessDenied
  5. UserFilter:继承AccessControlFilter,表示基于用户是否登录的拦截器,可以直接使用
  6. RolesAuthorizationFilter:继承AccessControlFilter,表示基于特殊角色的拦截过滤,可以直接使用
  7. PermissionsAuthorizationFilter(常用):继承AccessControlFilter,验证某个URL请求,是否拥有访问权限,可以直接使用

要使自定义过滤器生效,必须执行如下步骤:

  1. 自定义过滤器
  2. 在拦截器工厂里注册过滤器,如factoryBean.getFilters().put(“c_once”, new MyOncePerRequestFilter());
  3. 设置URL的过滤器链,如factoryBean.getFilterChainDefinitionMap().put("/once/**", “c_once”)

一般上述2 3步都是在配置类中进行指定,即自定义过滤器不能交由spring管理,切记切记切记

OncePerRequestFilter

保证一次请求只调用一次doFilterInternal,即如内部的forward不会再多执行一次doFilterInternal

public class MyOncePerRequestFilter extends OncePerRequestFilter {  
    @Override  
    protected void doFilterInternal(ServletRequest request, ServletResponse response, FilterChain chain) throws ServletException, IOException {  
        System.out.println("=========once per request filter");  
        chain.doFilter(request, response);  
    }  
}   

AdviceFilter

AdviceFilter提供了AOP的功能,其实现和SpringMVC中的拦截器思想一样

public class MyAdviceFilter extends AdviceFilter {  
    @Override  
    protected boolean preHandle(ServletRequest request, ServletResponse response) throws Exception {  
        System.out.println("前置处理,返回false将中断后续拦截器的执行");  
        return true;  
    }  
    @Override  
    protected void postHandle(ServletRequest request, ServletResponse response) throws Exception {  
        System.out.println("执行完拦截器链之后正常返回后执行");  
    }  
    @Override  
    public void afterCompletion(ServletRequest request, ServletResponse response, Exception exception) throws Exception {  
        System.out.println("后置最终处理,不管正常结束还是异常结束都会进入本方法");  
    }  
}   

PathMatchingFilter

PathMatchingFilter继承了AdviceFilter,提供了url模式过滤的功能,如果需要对指定的请求进行处理,可以扩展PathMatchingFilter

public class MyPathMatchingFilter extends PathMatchingFilter {  
    //当调用preHandle方法时,会回调如下方法
    @Override  
    protected boolean onPreHandle(ServletRequest request, ServletResponse response, Object mappedValue) throws Exception {  
       System.out.println("url matches,config is " + Arrays.toString((String[])mappedValue));  
       return true;  
    }  
}   

AccessControlFilter

继承了PathMatchingFilter,并扩展了两个方法:

  1. isAccessAllowed:是否允许访问,返回true表示允许
  2. onAccessDenied:访问拒绝时是否自己处理,如果返回true表示自己不处理且继续拦截器链执行,返回false表示自己已经处理了
public class MyAccessControlFilter extends AccessControlFilter {  
    //是否允许访问,返回true表示允许
    protected boolean isAccessAllowed(ServletRequest request, ServletResponse response, Object mappedValue) throws Exception {  
        System.out.println("access allowed");  
        return true;  
    }  
    //访问拒绝时是否自己处理,如果返回true表示自己不处理且继续拦截器链执行,返回false表示自己已经处理了
    protected boolean onAccessDenied(ServletRequest request, ServletResponse response) throws Exception {  
        System.out.println("访问拒绝也不自己处理,继续拦截器链的执行");  
        return true;  
    }  
}  

UserFilter

继承AccessControlFilter,表示基于用户登录的拦截器,即判断用户是否登录。一般情况下这个类可以直接使用。

需求:管理员可以修改用户的状态,比如开通和停用,假设某个用户状态为开通并登录系统了,这时管理员更新状态为停用,
按照jar包提供的UserFilter类,那么只要这个用户不退出,仍然可以使用系统。现在的需求是,只要管理员更新状态为停用,
则登录用户访问系统则需要被强制退出

public class MyUserFilter extends UserFilter {
 private static final Logger LOGGER = LoggerFactory.getLogger(UserFilter.class);
 private DefaultWebSessionManager sessionManager;
 private UserService userService;

    public UserFilter(DefaultWebSessionManager sessionManager, UserService userService) {
  this.sessionManager = sessionManager;
  this.userService = userService;
 }
    /**
    * 是否允许访问,返回true表示允许 
    * 父类的方法意思是:如果是登录用户,则允许访问,否则不允许访问
    * 假设需求为:管理员可以修改用户的状态,比如开通和停用,
    *           假设原本开通的用户,管理员更新状态为停用,且状态更新后登录用户需要被退出
    * 
    */
 @Override
 protected boolean isAccessAllowed(ServletRequest servletRequest, ServletResponse servletResponse,
                                      Object mappedValue) {
        boolean allowed = super.isAccessAllowed(servletRequest, servletResponse, mappedValue);
        if (allowed && !isLoginRequest(servletRequest, servletResponse)) {
            Subject subject = getSubject(servletRequest, servletResponse);
            Session session = subject.getSession();
            String username = subject.getPrincipal().toString();
            user user = this.userService.getUserByUsername(username);
            // 检测用户状态
            if (user == null || user.getState() != SysUserConstant.STATE_NORMAL) {
                subject.logout();
                this.sessionManager.getSessionIdCookie().setValue(Cookie.DELETED_COOKIE_VALUE);
                return false;
            }
        }
        return allowed;
    }

    /**
    * 访问拒绝时是否自己处理,如果返回true表示自己不处理且继续拦截器链执行,返回false表示自己已经处理了
    * 父类的方法意思是:重定向到登录页面,没有提示信息
    * 假设需求为:所有请求都是ajax请求,并返回一个自定义的类,该类有相关提示信息
    * 
    */
 @Override
 protected boolean onAccessDenied(ServletRequest servletRequest, ServletResponse servletResponse) throws Exception {
  HttpServletRequest request = (HttpServletRequest) servletRequest;
  HttpServletResponse response = (HttpServletResponse) servletResponse;

  boolean isAjax = RequestUtils.isAjax(request);
  response.setStatus(HttpStatus.UNAUTHORIZED.value());

  if (isAjax) {
   JsonResult<String> jsonResult = new JsonResult<>();
   jsonResult.setErrorMessage(ErrorEnum.AUTH_NOT_LOGIN);
   response.getWriter().write(JSONObject.toJSONString(jsonResult));
   return false;
  } else {
   return super.onAccessDenied(servletRequest, servletResponse);
  }
 }
}

过滤器链

如果一个URL只有一个过滤器,那么这一个过滤器就是一个链,如果为一个URL配置多个过滤器时,那么这多个过滤器组成一个过滤器链,链中的过滤器的处理顺序则和配置中的先后顺序一致。

需求:假设需要先验证用户是否登录,再验证用户是否有这个权限,则有如下2种处理方式:

  1. 使用一个过滤器,先验证是否登录再验证是否有权限,配置为/api/**=c_login_and_perms
  2. 使用两个过滤器,一个验证是否登录,一个验证是否有权限,配置为/api/**=c_login,c_perms,则先执行c_login对应的过滤器,再执行c_perms对应的过滤器

重新生成过滤器链

为什么需要我们重新生成过滤器链?

我们以shiro框架提供的UserFilter和PermissionsAuthorizationFilter为例进行说明。

一般情况下我们的配置文件如下:

shiro:
  filter: #在注入过滤器时会用到
    loginUrl: /login.html #登录页面
    successUrl: /index.html #登录后的首页
    unauthorizedUrl: /index403.html #无权访问的403页面
    # 必须带'|'以保留换行  
    # anon表示不需要认证可直接访问。 
    # c_user 表示需要用c_user对应的过滤器去执行,如框架自带的UserFilter,需要在配置类的factorybean中进行配置
    # factoryBean.getFilters().put("c_user", new UserFilter());
    # c_perms 表示需要用c_perms对应的过滤器去执行,如框架自带的PermissionsAuthorizationFilter,也需要在配置类中配置
    # factoryBean.getFilters().put("c_perms", new 如框架自带的PermissionsAuthorizationFilter());
    # /**=c_perms 配置中千万不要出现这个,重要的事说三遍,
    # 原因是所有请求在isAccessAllowed方法中,因为mappedValue参数为null,会直接返回true,达不到权限校验的效果,详细原因见后面的说明
    filterChainDefinitions: |
      /static/**=anon 
      /login.html=anon
      /index403.html=anon
      /login=anon
      /logout=c_user
      /menu/queryMenu=c_user 
      /**=c_user,c_perms

先说结论:上述配置文件中,/**=c_user,c_perms,如果想这样配置会出问题。为什么?

在方法public boolean isAccessAllowed(ServletRequest request, ServletResponse response, Object mappedValue)中,mappedValue参数是什么,有什么用?

如果配置为/hello=c_perms[“helloworld”],那么在初始化类PermissionsAuthorizationFilter(UserFilter以及所有继承于PathMatchingFilter的子类都一样)时,
在父类PathMatchingFilter中有一个Map属性appliedPaths,这个map的key为配置中的/hello,value为helloworld。
isAccessAllowed方法的mappedValue参数就是appliedPaths中匹配请求URL对应的value,如果请求匹配到了/hello,那么mappedValue的值就为helloworld。
如果配置为/hello=c_perms,请求匹配到了/hello时,isAccessAllowed方法的mappedValue的值null。

在框架中提供的UserFilter类的isAccessAllowed方法,本身不关注mappedValue参数,因此可以直接配置为/hello=c_user,
但在框架提供的PermissionsAuthorizationFilter类的isAccessAllowed方法中,使用到了mappedValue参数,并将其作为权限进行权限校验,
如果mappedValue参数为空,则直接返回true表示验证通过,如果mappedValue参数不为空,则获取到值去shiro框架进行权限校验,
因此不能简单的配置为/hello=c_perms,而需要配置为/hello=c_perms[xxx],但一个系统中的请求URL很多,我们不能都配置到配置文件中吧,
怎么办呢?

这就是为什么要重新生成过滤器链的原因。

在shiro框架获取到shiro配置后,springboot系统启动完成前,我们需要从数据库把所有URL读取出来,并组装成/url=c_perms[xxx]的形式,最后重新写入过滤器链
具体代码如下:

@Component
public class ShiroFilterManger {

    @Autowired
    private ShiroFilterFactoryBean shiroFilterFactoryBean;
    @Autowired
    private UserService userService;

    @PostConstruct
    public synchronized void reloadFilterChainDefinition() throws Exception {
        // 获取预置的过滤器链
        Map<String, String> filterChainDefinitionMap = shiroFilterFactoryBean.getFilterChainDefinitionMap();
        Map<String, String> newFilterChainDefinitionMap = new LinkedHashMap<>();
        newFilterChainDefinitionMap.putAll(filterChainDefinitionMap);
        // 查询数据库所有资源
        List<Resource> resources = userService.getAllResources();
        for (Resource resource : resources) {
            newFilterChainDefinitionMap.put(resource.getResourceUrl(),"c_user,c_perms[\"" + "RES_ID_"+resource.getId() + "\"]");
        }

        // 重新生成过滤器链
        AbstractShiroFilter shiroFilter = (AbstractShiroFilter) shiroFilterFactoryBean.getObject();
        PathMatchingFilterChainResolver chainResolver = (PathMatchingFilterChainResolver) shiroFilter
                .getFilterChainResolver();
        DefaultFilterChainManager chainManager = (DefaultFilterChainManager) chainResolver.getFilterChainManager();
        chainManager.getFilterChains().clear();
        newFilterChainDefinitionMap.forEach((linkUrl, permission) -> {
            chainManager.createChain(linkUrl, permission);
        });
    }
}
  • 1
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值