shiro集成redis实现分布式session和单点登录

shiro是一款出色的权限框架,能够实现诸如登录校验、权限校验等功能,默认情况下,shir将session保存到内存中,这在应用分布式部署的情况下会出现session不一致的问题,所以我们要将session保存到第三方,应用始终从第三方获取session,从而保证分布式部署时session始终是一致的,这里我们采用redis保存session。单点登陆的实现逻辑是在用户登陆时,生成token,然后将token以用户登陆账号为key,保存到redis中,再把token放到cookie中,用户在访问的时候,我们就能拿到cookie中的token,和redis中的做比较,如果不一致,则认为用户已经下线或者再别的地方登陆,下面看代码。

一、自定义Session

shiro默认的session是SimpleSession,这里我们自定义session,目前不做什么变化,如果有需要,我们就可以扩展自定义Session实现一些特殊功能。

public class ShiroSession extends SimpleSession implements Serializable {
}

二、自定义SessionFactory

shiro使用SessionFactory创建session,这里我们自定义SessionFactory,让它创建我们自定义的Session.

public class ShiroSessionFactory implements SessionFactory {
    @Override
    public Session createSession(SessionContext sessionContext) {
        ShiroSession session = new ShiroSession();
        HttpServletRequest request = (HttpServletRequest)sessionContext.get(DefaultWebSessionContext.class.getName() + ".SERVLET_REQUEST");
        session.setHost(getIpAddress(request));
        return session;
    }

    public static String getIpAddress(HttpServletRequest request) {
        String localIP = "127.0.0.1";
        String ip = request.getHeader("x-forwarded-for");
        if (StringUtils.isBlank(ip) || (ip.equalsIgnoreCase(localIP)) || "unknown".equalsIgnoreCase(ip)) {
            ip = request.getHeader("Proxy-Client-IP");
        }
        if (StringUtils.isBlank(ip) || (ip.equalsIgnoreCase(localIP)) || "unknown".equalsIgnoreCase(ip)) {
            ip = request.getHeader("WL-Proxy-Client-IP");
        }
        if (StringUtils.isBlank(ip) || (ip.equalsIgnoreCase(localIP)) || "unknown".equalsIgnoreCase(ip)) {
            ip = request.getRemoteAddr();
        }
        return ip;
    }

三、ShiroRedisDao

这个类就是shiro用来创建、修改、删除session的地方。在创建、修改、删除的时候,其实都是对redis做操作。

public class ShiroSessionRedisDao extends EnterpriseCacheSessionDAO {

    @Override
    protected Serializable doCreate(Session session) {
        Serializable sessionId = super.doCreate(session);
        RedisUtil.setObject(SHIROSESSION_REDIS_DB,(SHIROSESSION_REDIS_PREFIX+sessionId.toString()).getBytes(),sessionToByte(session),SHIROSESSION_REDIS_EXTIRETIME);

        return sessionId;
    }


    @Override
    public Session readSession(Serializable sessionId) throws UnknownSessionException {
        return this.doReadSession(sessionId);
    }

    @Override
    protected Session doReadSession(Serializable sessionId) {
        Session session = null;
        byte[] bytes = RedisUtil.getObject(SHIROSESSION_REDIS_DB,(SHIROSESSION_REDIS_PREFIX+sessionId.toString()).getBytes(),SHIROSESSION_REDIS_EXTIRETIME);
        if(bytes != null && bytes.length > 0){
            session = byteToSession(bytes);
        }
        return session;
    }

    @Override
    protected void doUpdate(Session session) {
        super.doUpdate(session);
        RedisUtil.updateObject(SHIROSESSION_REDIS_DB,(SHIROSESSION_REDIS_PREFIX+session.getId().toString()).getBytes(),ShiroSessionConvertUtil.sessionToByte(session),SHIROSESSION_REDIS_EXTIRETIME);
        //也要更新token
        User user = (User)session.getAttribute(Const.SESSION_USER);
        if(null != user){
            RedisUtil.updateString(SHIROSESSION_REDIS_DB,ShiroSessionRedisConstant.SSOTOKEN_REDIS_PREFIX+user.getUSERNAME(),ShiroSessionRedisConstant.SHIROSESSION_REDIS_EXTIRETIME);
        }
    }

    @Override
    protected void doDelete(Session session) {
        super.doDelete(session);
        RedisUtil.delString(SHIROSESSION_REDIS_DB,SHIROSESSION_REDIS_PREFIX+session.getId().toString());
        //也要删除token
        User user = (User)session.getAttribute(Const.SESSION_USER);
        if(null != user){
            RedisUtil.delString(SHIROSESSION_REDIS_DB,ShiroSessionRedisConstant.SSOTOKEN_REDIS_PREFIX+user.getUSERNAME());
        }
    }


四、工具类

1、Session序列化工具类,使用该类将session转化为byte[],保存到redis中

public class ShiroSessionConvertUtil {

    /**
     * 把session对象转化为byte数组
     * @param session
     * @return
     */
    public static byte[] sessionToByte(Session session){
        ByteArrayOutputStream bo = new ByteArrayOutputStream();
        byte[] bytes = null;
        try {
            ObjectOutputStream oo = new ObjectOutputStream(bo);
            oo.writeObject(session);
            bytes = bo.toByteArray();
        } catch (IOException e) {
            e.printStackTrace();
        }
        return bytes;
    }

    /**
     * 把byte数组还原为session
     * @param bytes
     * @return
     */
    public static Session byteToSession(byte[] bytes){
        ByteArrayInputStream bi = new ByteArrayInputStream(bytes);
        ObjectInputStream in;
        Session session = null;
        try {
            in = new ObjectInputStream(bi);
            session = (SimpleSession) in.readObject();
        } catch (ClassNotFoundException e) {
            e.printStackTrace();
        } catch (IOException e) {
            e.printStackTrace();
        }
        return session;
    }


}

2、SessionListener,这个监听器在发生session创建、变化、销毁等事件时,可以进行捕捉,这个类主要处理session销毁时,清楚redis中的数据

public class ShiroSessionListener implements SessionListener {
    private static final Logger LOGGER = LoggerFactory.getLogger(ShiroSessionListener.class);



    @Override
    public void onStart(Session session) {
        // 会话创建时触发
        LOGGER.info("ShiroSessionListener session {} 被创建", session.getId());
    }

    @Override
    public void onStop(Session session) {
        // 会话被停止时触发
        ShiroSessionRedisUtil.deleteSession(session);
        LOGGER.info("ShiroSessionListener session {} 被销毁", session.getId());
    }

    @Override
    public void onExpiration(Session session) {
        //会话过期时触发
        ShiroSessionRedisUtil.deleteSession(session);
        LOGGER.info("ShiroSessionListener session {} 过期", session.getId());
    }
}

3、操作redis的工具类

public class ShiroSessionRedisUtil {

    public static Session getSession(Serializable sessionId){
        Session session = null;
        byte[] bytes = RedisUtil.getObject(SHIROSESSION_REDIS_DB,(SHIROSESSION_REDIS_PREFIX+sessionId.toString()).getBytes(),SHIROSESSION_REDIS_EXTIRETIME);
        if(bytes != null && bytes.length > 0){
            session = byteToSession(bytes);
        }
        return session;
    }
    public static void updateSession(Session session){
        RedisUtil.updateObject(SHIROSESSION_REDIS_DB,(SHIROSESSION_REDIS_PREFIX+session.getId().toString()).getBytes(),ShiroSessionConvertUtil.sessionToByte(session),SHIROSESSION_REDIS_EXTIRETIME);
        //也要更新token
        User user = (User)session.getAttribute(Const.SESSION_USER);
        if(null != user){
            RedisUtil.updateString(SHIROSESSION_REDIS_DB,ShiroSessionRedisConstant.SSOTOKEN_REDIS_PREFIX+user.getUSERNAME(),ShiroSessionRedisConstant.SHIROSESSION_REDIS_EXTIRETIME);
        }
    }

    public static void deleteSession(Session session){
        RedisUtil.delString(SHIROSESSION_REDIS_DB,SHIROSESSION_REDIS_PREFIX+session.getId().toString());
        //也要删除token
        User user = (User)session.getAttribute(Const.SESSION_USER);
        if(null != user){
            RedisUtil.delString(SHIROSESSION_REDIS_DB,ShiroSessionRedisConstant.SSOTOKEN_REDIS_PREFIX+user.getUSERNAME());
        }
    }
}
public final class RedisUtil {
    
    //Redis服务器IP
    private static String ADDR = PropertyUtils.redisUrl;
    
    //Redis的端口号
    private static int PORT = PropertyUtils.redisPort;
    
    //访问密码
    private static String AUTH = PropertyUtils.redisPasswd;
    
    //可用连接实例的最大数目,默认值为8;
    //如果赋值为-1,则表示不限制;如果pool已经分配了maxActive个jedis实例,则此时pool的状态为exhausted(耗尽)。
//    private static int MAX_ACTIVE = 50;
    
    //控制一个pool最多有多少个状态为idle(空闲的)的jedis实例,默认值也是8。
    private static int MAX_IDLE = 200;
    
    //等待可用连接的最大时间,单位毫秒,默认值为-1,表示永不超时。如果超过等待时间,则直接抛出JedisConnectionException;
    private static int MAX_WAIT = 10000;
    
    private static int TIMEOUT = 10000;
    
    //在borrow一个jedis实例时,是否提前进行validate操作;如果为true,则得到的jedis实例均是可用的;
    private static boolean TEST_ON_BORROW = true;
    
    private static JedisPool jedisPool = null;
    
    /**
     * 初始化Redis连接池
     */
    static {
        try {
            JedisPoolConfig config = new JedisPoolConfig();
            config.setMaxIdle(MAX_IDLE);
            config.setMaxWaitMillis(MAX_WAIT);
            config.setTestOnBorrow(TEST_ON_BORROW);
            if(StringUtils.isEmpty(AUTH))
                AUTH=null;
            jedisPool = new JedisPool(config, ADDR, PORT, TIMEOUT, AUTH);
        } catch (Exception e) {
            e.printStackTrace();
        }
    }
    
    /**
     * 获取Jedis实例
     * @return
     */
    public synchronized static Jedis getJedis() {
        try {
            if (jedisPool != null) {
                Jedis resource = jedisPool.getResource();
                return resource;
            } else {
                return null;
            }
        } catch (Exception e) {
            e.printStackTrace();
            return null;
        }
    }
    
    /**
     * 释放jedis资源
     * @param jedis
     */
    public static void returnResource(final Jedis jedis) {
        if (jedis != null) {
            jedisPool.returnResource(jedis);
        }
    }

	/**
     * KEY对应value加1,并且设置过期时间
     * @param db
     * @param key
     * @param ttl(s)
     * @return
     */
    public static long incrWithExpire(int db, String key, int ttl){
        Jedis resource=null;
        long res = 0;
        try {
            if (jedisPool != null) {
                resource = jedisPool.getResource();
                resource.select(db);
                res = resource.incr(key);
                if(res == 1){
                    resource.expire(key, ttl);
                }

                jedisPool.returnResource(resource);
            }
            return res;
        } catch (Exception e) {
            e.printStackTrace();
            jedisPool.returnBrokenResource(resource);
            return 0;
        }
    }

    /**
     * 删除set中多个fields
     * @param db
     * @param key
     * @return
	 */
    public static long hdel(int db, String key, String[] fields){
        Jedis resource=null;
        long res = 0;
        try {
            if (jedisPool != null) {
                resource = jedisPool.getResource();
                resource.select(db);
                res = resource.hdel(key, fields);

                jedisPool.returnResource(resource);
            }
            return res;
        } catch (Exception e) {
            e.printStackTrace();
            jedisPool.returnBrokenResource(resource);
            return 0;
        }
    }
    /**
     * 获取Redis里面的set里的值
     * @param db
     * @param key
     * @param feild
     * @return
     */
    public static String hget(int db,String key,String feild){
        Jedis jedis = null;
        String value = null;
        try {
            if (jedisPool!=null) {
                jedis = jedisPool.getResource();
                jedis.select(db);
                value=jedis.hget(key,feild);
                jedisPool.returnResource(jedis);
            }
        }catch (Exception e){
            e.printStackTrace();;
            jedisPool.returnBrokenResource(jedis);
        }
        return value;
    }
    /**
     * 写入Redis里面的set里的值
     * @param db
     * @param key
     * @param feild
     * @return
     */
    public static void hset(int db,String key,String feild,String value){
        Jedis jedis = null;
        try{
            if(jedisPool!=null){
                jedis = jedisPool.getResource();
                jedis.select(db);
                jedis.hset(key,feild,value);
                jedisPool.returnResource(jedis);
            }
        }catch (Exception e){
            e.printStackTrace();
            jedisPool.returnBrokenResource(jedis);
        }
    }


    /**
     * 迭代set里的元素
     * @param db
     * @param key
     * @return
     */
    public static ScanResult<Map.Entry<String,String>> hscan(int db, String key, String cursor, ScanParams scanParams){
        Jedis resource=null;
        ScanResult<Map.Entry<String,String>> scanResult = null;
        try {
            if (jedisPool != null) {
                resource = jedisPool.getResource();
                resource.select(db);
                scanResult = resource.hscan(key, cursor, scanParams);

                jedisPool.returnResource(resource);
            }
            return scanResult;
        } catch (Exception e) {
            e.printStackTrace();
            jedisPool.returnBrokenResource(resource);
            return scanResult;
        }
    }

    public static void main(String[] args) {
        System.out.println(incrWithExpire(0, "test", 10));
        ScanParams scanParams = new ScanParams();
        scanParams.count(10);
        Map<String, String> map = new HashMap<String, String>();
        System.out.println(JSON.toJSONString(hscan(2, "cuserMobileCabSet", "0", scanParams)));
    }


    /**
     * 获取byte类型数据
     * @param key
     * @return
     */
    public static byte[] getObject(int db,byte[] key,int expireTime){
        Jedis jedis = getJedis();
        byte[] bytes = null;
        if(jedis != null){
            jedis.select(db);
            try{
                bytes = jedis.get(key);
                if(null != bytes){
                    jedis.expire(key,expireTime);
                }
            }catch(Exception e){
                e.printStackTrace();
            } finally{
                returnResource(jedis);
            }
        }
        return bytes;
    }

    /**
     * 保存byte类型数据
     * @param key
     * @param value
     */
    public static void setObject(int db,byte[] key, byte[] value,int expireTime){
        Jedis jedis = getJedis();
        if(jedis != null){
            jedis.select(db);
            try{
                jedis.set(key, value);
                // redis中session过期时间
                jedis.expire(key, expireTime);
            } catch(Exception e){
                e.printStackTrace();
            } finally{
                returnResource(jedis);
            }
        }
    }

    /**
     * 更新byte类型的数据,主要更新过期时间
     * @param key
     */
    public static void updateObject(int db,byte[] key,byte[] value,int expireTime){
        Jedis jedis = getJedis();
        if(jedis != null){
            try{
                // redis中session过期时间
                jedis.select(db);
                jedis.set(key, value);
                jedis.expire(key, expireTime);
            }catch(Exception e){
                e.printStackTrace();
            } finally{
                returnResource(jedis);
            }
        }
    }

    /**
     * 删除字符串数据
     * @param key
     */
    public static void delString(int db ,String key){
        Jedis jedis = getJedis();
        if(jedis != null){
            try{
                jedis.select(db);
                jedis.del(key);
            }catch(Exception e){
                e.printStackTrace();
            } finally{
                returnResource(jedis);
            }
        }
    }

    /**
     * 存放字符串
     * @param db
     * @param key
     * @param value
     * @param expireTime
     */
    public static void setString(int db,String key,String value,int expireTime){
        Jedis jedis = getJedis();
        if(jedis != null){
            jedis.select(db);
            try{
                jedis.set(key, value);
                // redis中session过期时间
                jedis.expire(key, expireTime);
            } catch(Exception e){
                e.printStackTrace();
            } finally{
                returnResource(jedis);
            }
        }
    }

    /**
     * 获取字符串
     * @param db
     * @param key
     * @param expireTime
     * @return
     */
    public static String getString(int db,String key,int expireTime){
        Jedis jedis = getJedis();
        String result = null;
        if(jedis != null){
            jedis.select(db);
            try{
                result = jedis.get(key);
                if(org.apache.commons.lang.StringUtils.isNotBlank(result)){
                    jedis.expire(key,expireTime);
                }
            }catch(Exception e){
                e.printStackTrace();
            } finally{
                returnResource(jedis);
            }
        }
        return result;
    }

    /**
     * 更新string类型的数据,主要更新过期时间
     * @param key
     */
    public static void updateString(int db,String key,String value,int expireTime){
        Jedis jedis = getJedis();
        if(jedis != null){
            try{
                // redis中session过期时间
                jedis.select(db);
                jedis.set(key, value);
                jedis.expire(key, expireTime);
            }catch(Exception e){
                e.printStackTrace();
            } finally{
                returnResource(jedis);
            }
        }
    }
    /**
     * 更新string类型的数据,主要更新过期时间
     * @param key
     */
    public static void updateString(int db,String key,int expireTime){
        Jedis jedis = getJedis();
        if(jedis != null){
            try{
                // redis中token过期时间
                jedis.select(db);
                jedis.expire(key, expireTime);
            }catch(Exception e){
                e.printStackTrace();
            } finally{
                returnResource(jedis);
            }
        }
    }

}

4、一些常量设置

public class ShiroSessionRedisConstant {

    /**
     * shirosession存储到redis中key的前缀
     */
    public static final String SHIROSESSION_REDIS_PREFIX = "SHIROSESSION_";
    /**
     * shirosession存储到redis哪个库中
     */
    public static final int SHIROSESSION_REDIS_DB = 0;
    /**
     * shirosession存储到redis中的过期时间
     */
    public static final int SHIROSESSION_REDIS_EXTIRETIME = 30*60;

    /**
     * token存到cookie中的key
     */
    public static final String SSOTOKEN_COOKIE_KEY = "SSOTOKENID";
    /**
     * token存到redis中的key前缀
     */
    public static final String SSOTOKEN_REDIS_PREFIX = "SSOTOKEN_";

}

五、用户登陆时,将session保存到redis中

//shiro管理的session
Subject currentUser = SecurityUtils.getSubject();
Serializable sessionId = currentUser.getSession().getId();
Session session = ShiroSessionRedisUtil.getSession(sessionId);
///一些用户查找逻辑,将用户、权限等信息放到session中,再更新redis
session.setAttribute(Const.SESSION_USER, user);
session.removeAttribute(Const.SESSION_SECURITY_CODE);
ShiroSessionRedisUtil.updateSession(session);
//其他校验
if("success".equals(errInfo)){
//校验成功,生成一条token存到redis中,key为SSOTOKEN_userId,并以SSOTOKENID为key,放到cookie中
String token = UUID.randomUUID().toString().trim().replaceAll("-", "");;
RedisUtil.setString(ShiroSessionRedisConstant.SHIROSESSION_REDIS_DB,ShiroSessionRedisConstant.SSOTOKEN_REDIS_PREFIX+KEYDATA[0],token,ShiroSessionRedisConstant.SHIROSESSION_REDIS_EXTIRETIME);
Cookie tokenCookie = new Cookie(ShiroSessionRedisConstant.SSOTOKEN_COOKIE_KEY,token);
tokenCookie.setMaxAge(30*60);
tokenCookie.setPath("/");
response.addCookie(tokenCookie);
}

六、拦截器校验

Subject currentUser = SecurityUtils.getSubject();
Serializable sessionId = currentUser.getSession().getId();
Session session = ShiroSessionRedisUtil.getSession(sessionId);
if (null == session) {
response.sendRedirect(request.getContextPath() + Const.LOGIN);
}
User user = (User) session.getAttribute(Const.SESSION_USER);
if (user != null) {


/*校验token,单点登录*/
Cookie[] cookies = request.getCookies();
boolean hasTokenCookie = false;
for (Cookie cookie : cookies) {
    if (ShiroSessionRedisConstant.SSOTOKEN_COOKIE_KEY.equals(cookie.getName())) {
	hasTokenCookie = true;
	String tokenRedis = RedisUtil.getString(ShiroSessionRedisConstant.SHIROSESSION_REDIS_DB, ShiroSessionRedisConstant.SSOTOKEN_REDIS_PREFIX + user.getUSERNAME(), ShiroSessionRedisConstant.SHIROSESSION_REDIS_EXTIRETIME);
	if (StringUtils.isBlank(tokenRedis) || !tokenRedis.equalsIgnoreCase(cookie.getValue())) {
	    response.sendRedirect(request.getContextPath() + Const.LOGIN);
	}
    }
}
if (!hasTokenCookie) {
    response.sendRedirect(request.getContextPath() + Const.LOGIN);
}


path = path.substring(1, path.length());
boolean b = Jurisdiction.hasJurisdiction(path);
if (!b) {
    response.sendRedirect(request.getContextPath() + Const.LOGIN);
}
return b;
} else {
//登陆过滤
response.sendRedirect(request.getContextPath() + Const.LOGIN);
return false;
//return true;
}

七、xml配置

<!-- ================ Shiro start ================ -->
<bean id="securityManager" class="org.apache.shiro.web.mgt.DefaultWebSecurityManager">
	<property name="realm" ref="ShiroRealm" />
	<property name="sessionManager" ref="sessionManager"/>
</bean>

<!-- 項目自定义的Realm -->
<bean id="ShiroRealm" class="com.rrs.rrsck.interceptor.shiro.ShiroRealm" ></bean>

<bean id="tokenFilter" class="com.rrs.rrsck.filter.AccessTokenShiroFilter"/>

<!-- Shiro Filter -->
<bean id="shiroFilter" class="org.apache.shiro.spring.web.ShiroFilterFactoryBean">
	<property name="securityManager" ref="securityManager" />
	
	<property name="loginUrl" value="/" />
	
	<property name="successUrl" value="/main/index" />
	
	<property name="unauthorizedUrl" value="/login_toLogin" />

<property name="filters">
<map>
    <entry key="tokenFilter" value-ref="tokenFilter"/>
</map>
</property>
	
	<property name="filterChainDefinitions">
		<value>
			/static/**			= anon
		/static/login/** 			= anon
		/static/js/myjs/** 			= authc
		/static/js/** 				= anon
		/uploadFiles/uploadImgs/** 	= anon
	/code.do 					= anon
	/login_login	 			= anon
			/XWZTMBTX/**	 			= anon
			/guiziSunYi/**	 			= anon
	/app**/** 					= anon
	/weixin/** 					= anon
		/druid/**					= anon
/guiziFlow/showGuiziFlow*              = tokenFilter,authc
		/contactPoint/showContactPoint*        = tokenFilter,authc
/contactPointL2/showContactPointL2*    = tokenFilter,authc
	/**							= authc
		</value>
	</property>
</bean>

<!--shiro redis start-->
<bean id="shiroSessionDao" class="com.rrs.rrsck.shiroredis.ShiroSessionRedisDao"></bean>
<bean id="shiroSessionFactory" class="com.rrs.rrsck.shiroredis.ShiroSessionFactory"></bean>
<bean id="sessionManager" class="org.apache.shiro.web.session.mgt.DefaultWebSessionManager">
		<!-- 设置全局会话超时时间,默认30分钟(1800000) -->
		<property name="globalSessionTimeout" value="1800000"/>
		<!-- 是否在会话过期后会调用SessionDAO的delete方法删除会话 默认true-->
		<property name="deleteInvalidSessions" value="false"/>
		<!-- 是否开启会话验证器任务 默认true -->
		<property name="sessionValidationSchedulerEnabled" value="false"/>
		<!-- 会话验证器调度时间 -->
		<property name="sessionValidationInterval" value="1800000"/>
		<property name="sessionFactory" ref="shiroSessionFactory"/>
		<property name="sessionDAO" ref="shiroSessionDao"/>
		<!-- 默认JSESSIONID,同tomcat/jetty在cookie中缓存标识相同,修改用于防止访问404页面时,容器生成的标识把shiro的覆盖掉 -->
		<property name="sessionIdCookie">
			<bean class="org.apache.shiro.web.servlet.SimpleCookie">
				<constructor-arg name="name" value="SHRIOSESSIONID"/>
			</bean>
		</property>
		<property name="sessionListeners">
			<list>
				<bean class="com.rrs.rrsck.shiroredis.ShiroSessionListener"/>
			</list>
		</property>
	</bean>
<!--shiro redis end-->

<!-- ================ Shiro end ================ -->

注意点:

只要session发生了改变,如session.setAttribute(),就要更新redis中的session.

更新redis中session的时间时,也要同步更新redis中的token的时间.

删除redis中的session时,也要删除redis中的token.


  • 8
    点赞
  • 74
    收藏
    觉得还不错? 一键收藏
  • 8
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值