springmvc+mybatis+shiro MD5加密匹配&登录失败超次数锁定帐号

shiro的身份认证的流程,大致是这样的:当我们调用subject.login(token)的时候,首先这次身份认证会委托给Security Manager,而Security Manager又会委托给Authenticator,接着Authenticator会把传过来的token再交给我们自己注入的Realm进行数据匹配从而完成整个认证。如果不太了解这个流程建议再仔细读一下官方提供的Authentication说明文档:
http://shiro.apache.org/authentication.html

我们输入用户名和密码点击submit则跳到UserController执行登录的业务逻辑,接下来看看UserController的代码:
主要看逻辑即可

@RequestMapping(value = "/login", method = RequestMethod.POST)
    @ResponseBody
    public String login(@Valid TUser tuser, BindingResult result, Model model, HttpServletRequest request, HttpServletResponse response) {
        if (request.getMethod().equals(RequestMethod.GET.toString())) {
            //return "login";
            return JSON.toJSONString(ResultUtil.error("100", "请求路径错误"));
        }
        TDict dict = null;
        Map<String, Object> map = new HashedMap();
        try {
            Subject subject = SecurityUtils.getSubject();
            TUser user = userService.getUserByName(tuser.getUsername(), 0L);
            // 已登陆则 跳到首页
            if (subject.isAuthenticated()) {
                //              return "redirect:/";
                return JSON.toJSONString(ResultUtil.success("200", "已登录"));
            }
            if (result.hasErrors()) {
                model.addAttribute("error", "* 用户名或密码错误!");
                return JSON.toJSONString(ResultUtil.error("100", "用户名或密码错误"));
            }
            // 身份验证
            subject.login(new UserToken(tuser.getUsername(), tuser.getPassword(), true));
            // 验证成功在Session中保存用户信息
            final TUser authUserInfo = userService.getUserByName(tuser.getUsername(), tuser.getType());
            //最后一次登录时间
            authUserInfo.setLastlogin(new Date());
            userService.update(authUserInfo);
            request.getSession().setAttribute("userInfo" + authUserInfo.getUsername(), authUserInfo);
            request.getSession().setAttribute("user", authUserInfo);
            dict = dictService.selectDictByKey(Constant.IS_POLICY_USE);
            if (dict == null) {
                dict = new TDict();
                dict.setValue("0");
            }
            List<TRole> roleInfos = roleService.getRoleByIdList(user.getUserroles());
            List<TPermission> permissions = new ArrayList<TPermission>();
            if(roleInfos != null && roleInfos.size() > 0){
                for (TRole role : roleInfos) {
                    permissions = permissionService.getPermissionsByIdList(role.getRolepermissions());
                }   
            }
            Map<String, Object> permissionmap = new HashedMap();
            //数据处理
            dataDeal(permissions, permissionmap);
            map.put("permissions", permissionmap);
            request.getSession().setAttribute("dic", dict.getValue());
            if (roleInfos != null){
                request.getSession().setAttribute("role", roleInfos.get(0));
            }
        }catch (ExcessiveAttemptsException e) {  
            return JSON.toJSONString(ResultUtil.error("100", "登录失败超过5次,请10分钟后再次尝试"));
        }
        catch (LockedAccountException e) {  
            return JSON.toJSONString(ResultUtil.error("100", "账号被锁定"));
        }
        catch (AuthenticationException e) {
            // 身份验证失败
            model.addAttribute("error", "* 用户名或密码错误 !");
            //return "login";
            return JSON.toJSONString(ResultUtil.error("100", "用户名或密码错误"));
        }
        // redirectAttributes.addFlashAttribute("message","用户名或密码错误");
        logger.info(tuser.getUsername() + " 登录系统。");
        logService.saveAdmin(AdminOperationType.LOGIN);
        //      return "redirect:/#Dashboard";
        //      Map<String, String> dic = new HashedMap();
        map.put(dict.getKey(), dict.getValue());
        map.put("userName", tuser.getUsername());
        return JSON.toJSONString(ResultUtil.success("200", map));
    }

根据shiro的认证流程,最终Authenticator会把login传入的参数token交给Realm进行验证,Realm往往也是我们自己注入的,我们在debug模式下不难发现,在subject.login(token)打上断点,F6之后会跳到我们Realm类中doGetAuthenticationInfo(AuthenticationToken token)这个回调方法,从而也验证了认证流程确实没问题。下面贴出Realm中的代码:

package cn.easted.edm.core.security;

import java.util.List;

import javax.annotation.Resource;

import org.apache.commons.lang.StringUtils;
import org.apache.shiro.authc.AuthenticationException;
import org.apache.shiro.authc.AuthenticationInfo;
import org.apache.shiro.authc.AuthenticationToken;
import org.apache.shiro.authc.LockedAccountException;
import org.apache.shiro.authc.SimpleAuthenticationInfo;
import org.apache.shiro.authz.AuthorizationInfo;
import org.apache.shiro.authz.SimpleAuthorizationInfo;
import org.apache.shiro.realm.AuthorizingRealm;
import org.apache.shiro.subject.PrincipalCollection;
import org.apache.shiro.util.ByteSource;

import cn.easted.edm.core.generic.enums.UserSessionStatus;
import cn.easted.edm.core.generic.enums.UserType;
import cn.easted.edm.core.model.TPermission;
import cn.easted.edm.core.model.TRole;
import cn.easted.edm.core.model.TUser;
import cn.easted.edm.core.service.PermissionService;
import cn.easted.edm.core.service.RoleService;
import cn.easted.edm.core.service.UserService;
import cn.easted.edm.core.utils.DESUtil;

/**
 * 用户身份验证,授权 Realm 组件
 **/

public class SecurityRealm extends AuthorizingRealm {

    @Resource
    private UserService userService;

    @Resource
    private RoleService roleService;

    @Resource
    private PermissionService permissionService;

    /**
     * 权限检查
     */
    @Override
    protected AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection principals) {
        SimpleAuthorizationInfo authorizationInfo = new SimpleAuthorizationInfo();
        String username = String.valueOf(principals.getPrimaryPrincipal());

        final TUser user = userService.getUserByName(username, 0L);
        final List<TRole> roleInfos = roleService.getRoleByIdList(user.getUserroles());
        for (TRole role : roleInfos) {
            // 添加角色
            authorizationInfo.addRole(role.getRoleSign());

            final List<TPermission> permissions = permissionService.getPermissionsByIdList(role.getRolepermissions());
            for (TPermission permission : permissions) {
                // 添加权限
                authorizationInfo.addStringPermission(permission.getPermissionSign());
            }
        }
        return authorizationInfo;
    }

    /**
     * 登录验证
     */
    @Override
    protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken authtoken) throws AuthenticationException {
        UserToken token = (UserToken) authtoken;
        String username = String.valueOf(token.getPrincipal());
//      String password = new String((char[]) token.getCredentials());
        String key = token.getKey();
        String signature = token.getSignature();
        String domain = token.getDomain();
        TUser authentication = null;
        if (StringUtils.isEmpty(key) && StringUtils.isEmpty(signature)) {
            // 通过数据库进行验证
//          authentication = userService.userValidate(username, password, UserType.LOCAL.getValue());
            authentication = userService.getUserByName(username, UserType.LOCAL.getValue());
            if (authentication == null) {
                throw new AuthenticationException("用户名或密码错误");
            }
             // 判断帐号是否锁定  
            if (Boolean.TRUE.equals(authentication.getIslock() == 1 ? true : false)) {  
                // 抛出 帐号锁定异常  
                throw new LockedAccountException();  
            }  
        } else {
            try {
                if (signature.equals(DESUtil.encrypt(key, ""))) {
                    userService.updateSessionstatusStatus(username, domain, UserSessionStatus.ONLINE);
                } else {
                    throw new AuthenticationException();
                }
            } catch (Exception e) {
                e.printStackTrace();
                // 身份验证失败
                throw new AuthenticationException("501");
            }
            List<TUser> users = userService.getUserByNameDomain(username, domain);
            if (null == users || users.size() < 1) {
                // 用户名域验证失败
                throw new AuthenticationException("502");
            }

            // authentication=userService.userValidateForApi(username, eid,
            // userUUID, domain);
        }

        SimpleAuthenticationInfo authenticationInfo = new SimpleAuthenticationInfo(
                    username, 
                    authentication.getPassword(), 
                    ByteSource.Util.bytes(username),
                    getName());
        return authenticationInfo;
    }

    @Override  
    public void clearCachedAuthorizationInfo(PrincipalCollection principals) {  
        super.clearCachedAuthorizationInfo(principals);  
    }  

    @Override  
    public void clearCachedAuthenticationInfo(PrincipalCollection principals) {  
        super.clearCachedAuthenticationInfo(principals);  
    }  

    @Override  
    public void clearCache(PrincipalCollection principals) {  
        super.clearCache(principals);  
    }  

    public void clearAllCachedAuthorizationInfo() {  
        getAuthorizationCache().clear();  
    }  

    public void clearAllCachedAuthenticationInfo() {  
        getAuthenticationCache().clear();  
    }  

    public void clearAllCache() {  
        clearAllCachedAuthenticationInfo();  
        clearAllCachedAuthorizationInfo();  
    }  
}

关于Realm我们一般都会继承AuthorizingRealm去实现我们自己的Realm类,虽然从名字看这个Realm是用于授权的,而我们此处需要用到的是身份认证,但实际上AuthorizingRealm也继承了AuthenticatingRealm。

UserToken.java


package cn.easted.edm.core.security;

import java.util.Arrays;

import org.apache.shiro.authc.UsernamePasswordToken;

/**
 * @Date: 2017年3月2日 下午5:08:09
 */
public class UserToken extends UsernamePasswordToken {

    /**
     * @Fields serialVersionUID : TODO
     */
    private static final long serialVersionUID = 1L;

    private String username;

    private char[] password;

    private String key;

    private String signature;

    private String domain;

    private boolean rememberMe = false;

    private String host;

    private String salt;  

    public UserToken() {
    }

    public UserToken(final String username, final String password, final String key, final String signature,
            final String domain) {
        this(username, password != null ? password.toCharArray() : null, key, signature, domain, true);
    }

    public UserToken(final String username, final String password, final boolean rememberMe) {
        this(username, password != null ? password.toCharArray() : null, null, null, null, rememberMe);
    }

    public UserToken(final String username, final char[] password, final String key, final String signature,
            final String domain, Boolean rememberMe) {
        this.username = username;
        this.password = password;
        this.key = key;
        this.signature = signature;
        this.domain = domain;
        this.rememberMe = rememberMe;
    }

    /*
     * (non-Javadoc)
     * 
     * @see org.apache.shiro.authc.AuthenticationToken#getPrincipal()
     */
    @Override
    public Object getPrincipal() {
        return getUsername();
    }

    /*
     * (non-Javadoc)
     * 
     * @see org.apache.shiro.authc.AuthenticationToken#getCredentials()
     */
    @Override
    public Object getCredentials() {
        return getPassword();
    }

    /*
     * (non-Javadoc)
     * 
     * @see org.apache.shiro.authc.RememberMeAuthenticationToken#isRememberMe()
     */
    @Override
    public boolean isRememberMe() {
        return rememberMe;
    }

    /*
     * (non-Javadoc)
     * 
     * @see org.apache.shiro.authc.HostAuthenticationToken#getHost()
     */
    @Override
    public String getHost() {
        return host;
    }

    /**
     * @return the username
     */
    public String getUsername() {
        return username;
    }

    /**
     * @param username
     *            the username to set
     */
    public void setUsername(String username) {
        this.username = username;
    }

    /**
     * @param rememberMe
     *            the rememberMe to set
     */
    public void setRememberMe(boolean rememberMe) {
        this.rememberMe = rememberMe;
    }

    /**
     * @param host
     *            the host to set
     */
    public void setHost(String host) {
        this.host = host;
    }

    /**
     * @return the password
     */
    public char[] getPassword() {
        return password;
    }

    /**
     * @param password
     *            the password to set
     */
    public void setPassword(char[] password) {
        this.password = password;
    }

    /**
     * @return the serialversionuid
     */
    public static long getSerialversionuid() {
        return serialVersionUID;
    }

    /**
     * @return the domain
     */
    public String getDomain() {
        return domain;
    }

    /**
     * @param domain
     *            the domain to set
     */
    public void setDomain(String domain) {
        this.domain = domain;
    }

    /**
     * @return the key
     */
    public String getKey() {
        return key;
    }

    /**
     * @param key
     *            the key to set
     */
    public void setKey(String key) {
        this.key = key;
    }

    /**
     * @return the signature
     */
    public String getSignature() {
        return signature;
    }

    /**
     * @param signature
     *            the signature to set
     */
    public void setSignature(String signature) {
        this.signature = signature;
    }

    public String getSalt() {  
        return salt;  
    }  

    public void setSalt(String salt) {  
        this.salt = salt;  
    }

    /* (non-Javadoc)
     * @see java.lang.Object#toString()
     */
    @Override
    public String toString() {
        return "UserToken [username=" + username + ", password=" + Arrays.toString(password) + ", key=" + key
                + ", signature=" + signature + ", domain=" + domain + ", rememberMe=" + rememberMe + ", host=" + host
                + ", salt=" + salt + "]";
    }  



}

TUser.java

package cn.easted.edm.core.model;

import java.util.Date;

public class TUser {
    private Long id;

    private String username;

    private String nickname;

    private String password;

    private Long status;

    private Date createtime;

    private String usergroups;

    private String email;

    private String phone;

    private Long type;

    private String userroles;

    private String uuid;

    private String domainentryid;

    private String domain;

    private Date lastlogin;

    private String sessionstatus;

    private Long version;

    private String reserve;

    private String ecenterid;

    private Long islock;

    public Long getId() {
        return id;
    }

    public void setId(Long id) {
        this.id = id;
    }

    public String getUsername() {
        return username;
    }

    public void setUsername(String username) {
        this.username = username == null ? null : username.trim();
    }

    public String getNickname() {
        return nickname;
    }

    public void setNickname(String nickname) {
        this.nickname = nickname == null ? null : nickname.trim();
    }

    public String getPassword() {
        return password;
    }

    public void setPassword(String password) {
        this.password = password == null ? null : password.trim();
    }

    public Long getStatus() {
        return status;
    }

    public void setStatus(Long status) {
        this.status = status;
    }

    public Date getCreatetime() {
        return createtime;
    }

    public void setCreatetime(Date createtime) {
        this.createtime = createtime;
    }

    public String getUsergroups() {
        return usergroups;
    }

    public void setUsergroups(String usergroups) {
        this.usergroups = usergroups == null ? null : usergroups.trim();
    }

    public String getEmail() {
        return email;
    }

    public void setEmail(String email) {
        this.email = email == null ? null : email.trim();
    }

    public String getPhone() {
        return phone;
    }

    public void setPhone(String phone) {
        this.phone = phone == null ? null : phone.trim();
    }

    public Long getType() {
        return type;
    }

    public void setType(Long type) {
        this.type = type;
    }

    public String getUserroles() {
        return userroles;
    }

    public void setUserroles(String userroles) {
        this.userroles = userroles == null ? null : userroles.trim();
    }

    public String getUuid() {
        return uuid;
    }

    public void setUuid(String uuid) {
        this.uuid = uuid == null ? null : uuid.trim();
    }

    public String getDomainentryid() {
        return domainentryid;
    }

    public void setDomainentryid(String domainentryid) {
        this.domainentryid = domainentryid == null ? null : domainentryid.trim();
    }

    public String getDomain() {
        return domain;
    }

    public void setDomain(String domain) {
        this.domain = domain == null ? null : domain.trim();
    }

    public Date getLastlogin() {
        return lastlogin;
    }

    public void setLastlogin(Date lastlogin) {
        this.lastlogin = lastlogin;
    }

    public String getSessionstatus() {
        return sessionstatus;
    }

    public void setSessionstatus(String sessionstatus) {
        this.sessionstatus = sessionstatus == null ? null : sessionstatus.trim();
    }

    public Long getVersion() {
        return version;
    }

    public void setVersion(Long version) {
        this.version = version;
    }

    public String getReserve() {
        return reserve;
    }

    public void setReserve(String reserve) {
        this.reserve = reserve == null ? null : reserve.trim();
    }

    public String getEcenterid() {
        return ecenterid;
    }

    public void setEcenterid(String ecenterid) {
        this.ecenterid = ecenterid == null ? null : ecenterid.trim();
    }

    public Long getIslock() {
        return islock;
    }

    public void setIslock(Long islock) {
        this.islock = islock;
    }
}

HashedCredentialsMatcher.java

package cn.easted.edm.core.security;

import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.atomic.AtomicInteger;

import org.apache.log4j.Logger;
import org.apache.shiro.authc.AuthenticationInfo;
import org.apache.shiro.authc.AuthenticationToken;
import org.apache.shiro.authc.ExcessiveAttemptsException;
import org.apache.shiro.authc.credential.HashedCredentialsMatcher;
import org.apache.shiro.cache.Cache;
import org.apache.shiro.cache.CacheManager;
import org.springframework.beans.factory.annotation.Autowired;

import cn.easted.edm.core.generic.enums.UserType;
import cn.easted.edm.core.model.TUser;
import cn.easted.edm.core.service.UserService;

/**
 * 登陆次数验证
 * @ClassName:RetryLimitHashedCredentialsMatcher
 * @author:Wanghao
 * @date: 2017年10月11日 上午11:29:58
 */
public class RetryLimitHashedCredentialsMatcher extends HashedCredentialsMatcher{

    private static final Logger logger = Logger.getLogger(RetryLimitHashedCredentialsMatcher.class);
    @Autowired
    private UserService userservice;
    private Cache<String, AtomicInteger> passwordRetryCache;  
    private Map<Long, String> map = new HashMap<Long, String>();

    public RetryLimitHashedCredentialsMatcher(CacheManager cacheManager) {  
        passwordRetryCache = cacheManager.getCache("passwordRetryCache"); 
    }  

    @Override  
    public boolean doCredentialsMatch(AuthenticationToken token, AuthenticationInfo info) {  
        String username = (String) token.getPrincipal();  
        // retry count + 1  
        AtomicInteger retryCount = passwordRetryCache.get(username);  
        if (retryCount == null) {  
            retryCount = new AtomicInteger(0);  
            passwordRetryCache.put(username, retryCount);  
        }  
        if (retryCount.incrementAndGet() > 5) { 
            TUser user = userservice.getUserByName(username,  UserType.LOCAL.getValue());
            if (user != null){
                user.setIslock(1L);
                userservice.update(user);
                if (! map.containsValue(user.getUsername())){
                    map.put(System.currentTimeMillis(), user.getUsername());
                }
            }
            logger.info("锁定用户" + user.getUsername());
            throw new ExcessiveAttemptsException();  
        }  
        boolean matches = super.doCredentialsMatch(token, info); 
        if (matches) {  
            passwordRetryCache.remove(username);  
        }  
        return matches;  
    }

    /**
     * @Title: getMap <BR>
     * @return:Map<Long,TUser> <BR>
     */
    public Map<Long, String> getMap() {
        return map;
    }

}

在回调方法doCredentialsMatch(AuthenticationToken token,AuthenticationInfo info)中进行身份认证的密码匹配,这里我们引入了Ehcahe用于保存用户登录次数,如果登录失败retryCount变量则会一直累加,如果登录成功,那么这个count就会从缓存中移除,从而实现了如果登录次数超出指定的值就锁定。我们看一下spring的缓存配置和ehcache的配置:

 <!-- 缓存管理器 使用Ehcache实现 -->
    <bean id="shiroEhcacheManager" class="org.apache.shiro.cache.ehcache.EhCacheManager">
        <property name="cacheManagerConfigFile" value="classpath:ehcache-shiro.xml"/>
    </bean>

ehcache-shiro.xml

<ehcache name="shirocache" updateCheck="false">
    <defaultCache
            maxElementsInMemory="10000"
            eternal="false"
            timeToIdleSeconds="0"
            timeToLiveSeconds="600"
            overflowToDisk="false"
            diskPersistent="false"
            diskExpiryThreadIntervalSeconds="600"
            />
     <!-- 登录记录缓存 锁定10分钟 -->  
    <cache name="passwordRetryCache" eternal="false"  
        timeToIdleSeconds="0" timeToLiveSeconds="600" overflowToDisk="false"  
        statistics="true">  
    </cache>  

    <cache name="authorizationCache" eternal="false"  
        timeToIdleSeconds="0" timeToLiveSeconds="600" overflowToDisk="false"  
        statistics="true">  
    </cache>  

    <cache name="authenticationCache" eternal="false"  
        timeToIdleSeconds="0" timeToLiveSeconds="600" overflowToDisk="false"  
        statistics="true">  
    </cache>  

    <cache name="shiro-activeSessionCache" eternal="false"  
        timeToIdleSeconds="0" timeToLiveSeconds="600" overflowToDisk="false"  maxBytesLocalHeap="2000"
        statistics="true">  
    </cache> 
     <!-- timeToIdleSeconds 此属性设置后,限制时间以上次访问开始   
        eg:设置时间为10分钟的话,我们在1分的时候账号被锁定,预定为10分时解锁.而我们在5分时再次输入密码(此时密码已被锁定,无论怎样都不会通过认证),那么解锁时间变为15分.  
         timeToLiveSeconds 此属性设置后,限制时间以缓存创建开始  
        eg:设置时间为10分钟的话,我们在1分的时候账号被锁定,预定为10分时解锁.而我们在5分时再次输入密码(此时密码已被锁定,无论怎样都不会通过认证),那么解锁时间还是10分.  
        maxBytesLocalHeap用来限制缓存所能使用的堆内存的最大字节数的,如果不设置则需设置另外一个属性,否则项目会编译出错,无法允许,此处不再详写。  
         -->   
</ehcache>

我这里用的是2.4.8版本的ehcache:

    <dependency>  
        <groupId>net.sf.ehcache</groupId>  
        <artifactId>ehcache-core</artifactId>  
        <version>2.4.8</version>  
    </dependency>  

下面再回到重点,密码是如何匹配的?我们在我们自定义的HashedCredentialsMatcher应该可以看到这样一个方法:

    boolean matches = super.doCredentialsMatch(token, info);  

显而易见,是通过这个方法进行密码验证的,如果成功,则清除ehcache中存储的记录登录失败次数的count。我们可以看到这个方法的两个参数,token和info,它们是回调方法:

boolean doCredentialsMatch(AuthenticationToken token,AuthenticationInfo info) 由UserRealm传过来的参数,所以至于如何验证密码,其实还是由UserRealm返回的SimpleAuthenticationInfo决定的。HashedCredentialsMatcher允许我们指定自己的算法和盐,比如:我们采取加密的方法是(1024次md5迭代,用户名当作盐),通过shiro提供的通用散列来实现:

package cn.easted.edm.core.security;

import org.apache.shiro.crypto.hash.SimpleHash;

/**
 * 生成密码
 * @ClassName:GenerateMD5pwd
 * @author:Wanghao
 * @date: 2017年10月11日 上午11:45:36
 */
public class GenerateMD5pwd{
    public static void main(String[] args) {  

        String hashAlgorithmName = "md5";
        String username = "safer";
        String password = "easted2013";
        int hashIterations = 1024;
        String salt = username; 
        SimpleHash hash = new SimpleHash(hashAlgorithmName, password,  
                salt, hashIterations); 
        String encodedPassword = hash.toHex();  
        System.out.println(encodedPassword);
    }  
}

我们输出密码,保存到数据库中模拟已经注册好的用户数据.

这样我们在Realm中调用UserService的时候就可以查询出密码和盐,最后通过SimpleAuthenticationInfo将它们组装起来即可,上面也提到了HashedCredentialsMatcher会自动识别这个盐。还有不要忘记算法要一致,即加密和匹配时的算法,如果我们采取上述main方法中的加密方式,那么我们需要给自定义的HashedCredentialsMatcher注入如下属性

 <!-- 凭证匹配器 -->  
    <bean id="credentialsMatcher"  
        class="cn.easted.edm.core.security.RetryLimitHashedCredentialsMatcher">  
        <constructor-arg ref="shiroEhcacheManager"/>  
        <property name="hashAlgorithmName" value="md5"/>  
        <property name="hashIterations" value="1024"/>  
        <property name="storedCredentialsHexEncoded" value="true"/>  
    </bean>  

     <!-- Realm实现 -->  
    <bean id="securityRealm" class="cn.easted.edm.core.security.SecurityRealm">  
        <!-- 将凭证匹配器设置到realm中,realm按照凭证匹配器的要求进行散列 -->  
        <property name="credentialsMatcher" ref="credentialsMatcher"/>  
        <property name="cachingEnabled" value="true"/>
        <property name="cacheManager" ref="shiroEhcacheManager"/>
    </bean>  
  • 2
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值