Remoting Session

6.4  Remoting Session实现

由于Http协议是不保持连接、无状态的,所以 HttpInvoker、Hessian、Burlap、WebService等都是无状态的,系统无法分辨本次调用者是否是上次调用的那个客户 端;EJB、Com+等支持状态的Remoting实现本质上也是无状态的,只是它们内置了Remoting Session机制而已。

无状态的Remoting服务从严格意义上来说是回归 到了面向过程的时代,我们面对的是服务器提供的没有任何状态的“伪类”,业界专家之所以推荐使用无状态的Remoting服务,是考虑到如果服务是有状态 的,那么状态信息就会迅速地将服务器内存占满,将会降低系统的吞吐量;无状态的Remoting服务更体现了服务的概念,超市收银员只是提供收银服务,无 须在服务完成后记住每个客户买了多少东西、买了什么东西,否则收银员的脑子会爆炸掉的。

不过在某些时候调用者的状态还是有用的,超市的购物积 分就是一个例子。收银员无须记忆客户的购买历史,也无须客户出示所有的购物小票,这些购买历史全部记录在POS机中,标识这个客户唯一性的就是会员卡的卡 号。当用户付款的时候,POS机首先读取会员卡号,根据此卡号找到对应的客户记录,然后将本次的购买情况记录到此客户的名下。

在信息系统中同样需要类似的功能,比如在业务处理过程 中服务常常需要知道当前所服务的客户端的操作人员是谁、用户名是什么、密码对不对、它是否有执行此操作的权限、它要连接哪个数据库。如果这些信息全部都在 每次提供服务的时候要求客户端提供(比如每个服务端方法都增加传递这些信息的参数),那么这对于双方都会很麻烦,并且也是不安全的行为,让调用者无法理 解:“不是上次告诉你了吗?怎么还问?”。

解决这个问题就需要借助于Session技术了。Session中文翻译为“会话”,其本来的含义是指有始有终的一系列交互动作。随着Web技术的发展,Session已经变成在无状态的协议中在服务器端保存客户端状态的解决方案的代名词了。

当客户端第一次登录的时候,服务器分配给此客户端一个 标识其唯一性的Id号码,并且询问客户端“你是谁、用户名是什么、密码是什么、要连接哪个数据库”,根据这些信息服务器再到数据库中查询这个客户端有哪些 权限、密码是否正确,然后将“它是谁、用户名是什么、要连接哪个数据库、有哪些权限”等信息以唯一性的Id号码为主键保存到特定的位置。以后客户端再向服 务器请求服务的时候,只要提供此Id号码即可(类似于购物时提供会员卡),服务端就可以根据这个Id号码来取它需要的信息。

要注意此Remoting Session和Web中的Session的区别,它们的作用是类似的,不过这里的Remoting Session是不在乎调用的客户端是Swing GUI程序还是Web应用的。它是一个应用服务器端技术,在Web调用的时候常常需要把应用服务器分配的唯一的Id保存在Web Session中,这个问题在后边会有专门的论述。

6.4.1  实现思路

Session的实现方式如下:在用户第一次登录的时 候,系统为它分配一个唯一Id(被称为Session Id)作为标识,并且记录下这个用户的用户名、要登录的账套名、用户拥有的权限等,以Id为键,用户名、账套名等信息为值保存到一张Session哈希表 中。以后客户端登录的时候只要提供此Id即可,应用服务器可以通过此Id到Session哈希表中查询到所需要的一切信息。因为Session哈希表是保 存在存储器中的(通常是内存),存储过多的Session信息将会占用内存空间,所以客户端退出的时候要通知应用服务器注销此Id。

具体到细节还有一些问题需要处理:

l   如何生成唯一的Id。

l   如何保存用户名、账套名等信息,如何能在Session中放入自定义的信息。

l   如何维护管理Session。

l   如何清除Session。当系统非正常退出的时候,比如客户端机器故障,客户端是无法通知应用服务器注销Id的,这会造成应用服务器中存在垃圾Session。

l   如何防止此Id被恶意程序截获,从而冒充合法客户端登录系统。

6.4.2  Session Id的生成

客户端Session Id的生成与数据库中的主键生成面对的问题是类似的。以可移植、高效率、可靠的方式来生成主键是一个非常重要的问题。可移植指的是主键生成策略不能依赖于 服务器、操作系统、数据库等;高效率是生成主键的过程必须足够快,不能让生成主键的算法成为系统的瓶颈;可靠指的是生成的主键必须保证唯一性。主键生成方 式可以分为数据库相关方式和数据库无关方式两种。

数据库相关方式是通过数据库的帮助来生成主键。对于支 持自增字段的数据库,可以借助其序列号发生器来产生唯一的主键;对于不支持自增字段的数据库,可以在系统中放置一张表,采用此表记录本次生成的主键,这样 就保证了生成的主键与以前的不冲突。数据库相关方式的优点是实现简单,而且可以完全保证生成主键的唯一性;不过由于需要数据库来维护主键的状态和同步对主 键生成器的访问,所以对数据库有依赖性,而且由于需要访问数据库,其生成速度较慢。

数据无关方式是无须依靠数据库而生成主键的方式。最典 型的算法就是UUID算法。UUID是一个字符串,它被编码为包含了使生成的UUID在整个空间和时间上都完全唯一的所必需的系统信息集,不管这个 UUID是何时何地被生成的。原始的UUID规范是由Paul Leach和Rich Salz在网络工作组因特网草案中定义的。

UUID字符串一般由下面信息构成:

l   系统时钟的毫秒值。这是通过System.currentTimeMillis()方法得到的。这保证了在时间维度上产生主键的唯一性。

l   网络IP或者网卡标识。这保证了在集群环境中产生主键的唯一性。

l   精确到在一个JVM内部的对象的唯一。通常是System.identityHashCode(this)所调用产生的编码,这个方法调用保证对JVM中 不同对象返回不同的整数。即使在同一台机器上存在多个JVM,两个UUID生成器返回相同的UUID的情况也极不可能发生。

l   在一个对象内的毫秒级的唯一。这是由与每一个方法调用所对应的随机整数,这个随机整数是通过使用java.security.SecureRandom类生成的。这可以保证在同一毫秒内对同一个方法的多个调用都是唯一的。

上述这些部分组合在一起,就可以保证生成的UUID在所有机器中(IP不重复或者网卡地址不重复),以及在同一台机器上的JVM内部的所有UUID生成器的实例中都保持唯一,并且能精确到毫秒级甚至是一个毫秒内的单个方法调用的级别。

流行的UUID算法有很多,这些算法有的不能完全保证生成的UUID的唯一性,必须根据情况选用。下面推荐两种UUID实现算法。

【例6.5】UUID.Hex算法。

这个算法是Hibernate中主键策略为“uuid.hex”时所使用的算法,代码位于包org.hibernate.id下的UUIDHexGenerator.java文件中。

调用方法:

IdentifierGenerator gen = new UUIDHexGenerator();

for (int i = 0; i < 10; i++)

{

    String id = (String) gen.generate(null, null);

    System.out.println(id);

}

运行结果(UUID的生成是不重复的,每次的运行结果都会不同):

ff8080810ef0779f010ef0779f500000

ff8080810ef0779f010ef0779f500001

ff8080810ef0779f010ef0779f500002

ff8080810ef0779f010ef0779f500003

ff8080810ef0779f010ef0779f500004

ff8080810ef0779f010ef0779f500005

ff8080810ef0779f010ef0779f500006

ff8080810ef0779f010ef0779f500007

ff8080810ef0779f010ef0779f500008

ff8080810ef0779f010ef0779f500009

这个算法的特点是生成的UUID序列具有顺序性,因此生成的UUID具有一定的可预测性。前边的部分采用的是系统时钟、网络地址等拼凑的,而最后的有序部分采用的是内部维持一个同步了的计数器,每次生成UUID此计数器增加1,所以并发性能稍差。

【例6.6】Marc A. Mnich的算法。

代码如下:

// 随机GUID生成器

public class RandomGUID

{

    public String valueBeforeMD5 = "";

    public String valueAfterMD5 = "";

    private static Random myRand;

    private static SecureRandom mySecureRand;

    private static String s_id;

    static

    {

        mySecureRand = new SecureRandom();

        long secureInitializer = mySecureRand.nextLong();

        myRand = new Random(secureInitializer);

        try

        {

            s_id = InetAddress.getLocalHost().toString();

        } catch (UnknownHostException e)

        {

            e.printStackTrace();

        }

    }

    public RandomGUID()

    {

        getRandomGUID(false);

    }

    public RandomGUID(boolean secure)

    {

        getRandomGUID(secure);

    }

    private void getRandomGUID(boolean secure)

    {

        MessageDigest md5 = null;

        StringBuffer sbValueBeforeMD5 = new StringBuffer();

        try

        {

            md5 = MessageDigest.getInstance("MD5");

        } catch (NoSuchAlgorithmException e)

        {

            throw ExceptionUtils.toRuntimeException(e);

        }

        try

        {

            long time = System.currentTimeMillis();

            long rand = 0;

            if (secure)

            {

                rand = mySecureRand.nextLong();

            } else

            {

                rand = myRand.nextLong();

            }

            sbValueBeforeMD5.append(s_id);

            sbValueBeforeMD5.append(":");

            sbValueBeforeMD5.append(Long.toString(time));

            sbValueBeforeMD5.append(":");

            sbValueBeforeMD5.append(Long.toString(rand));

            valueBeforeMD5 = sbValueBeforeMD5.toString();

            md5.update(valueBeforeMD5.getBytes());

            byte[] array = md5.digest();

            StringBuffer sb = new StringBuffer();

            for (int j = 0; j < array.length; ++j)

            {

                int b = array[j] & 0xFF;

                if (b < 0x10)

                    sb.append('0');

                sb.append(Integer.toHexString(b));

            }

            valueAfterMD5 = sb.toString();

        } catch (Exception e)

        {

            e.printStackTrace();

        }

    }

    public String toString()

    {

        String raw = valueAfterMD5.toUpperCase();

        StringBuffer sb = new StringBuffer();

        sb.append(raw.substring(0, 8));

        sb.append("-");

        sb.append(raw.substring(8, 12));

        sb.append("-");

        sb.append(raw.substring(12, 16));

        sb.append("-");

        sb.append(raw.substring(16, 20));

        sb.append("-");

        sb.append(raw.substring(20));

        return sb.toString().trim();

    }

}  

测试代码:

for(int i=0;i<10;i++)

{

    System.out.println(new RandomGUID().toString());

}

运行结果:

B2FAA7E0-5E46-40D5-4757-C4C91A6A8F8E

7B8E3A34-B173-AC54-8F3D-8CAF48CECD13

62380599-EFA4-0AEF-8E03-A49018308D92

F781C6B5-55ED-D553-D1F7-43C573A00AB4

19FE1D7F-41A0-EB71-E149-9FEAD2B746C7

A0C334EA-0C31-E4C8-B7B6-64F2A4A9C35A

ED9329E2-64D2-E3D7-88FC-7EC03FA0AA54

7285B963-2BBE-45FE-A074-7CA20B496D04

F0085927-12B2-BE6C-1217-281B470E1282

9AA63E7A-61C9-2CB9-46C1-3E07B60952D1

RandomGUID算法和其他算法一样采用系统时 钟、网络地址等来产生唯一编码,唯一不同的地方就是RandomGUID算法生成的UUID是随机的,由于使用SecureRandom产生随机数,所以 其安全性非常高。此算法除了能快速、可移植地生成可靠的UUID之外,其最大的优势就是无法根据以前生成的UUID来推算后边要生成什么样的UUID。这 在有的场合是非常有用的,比如在生成对安全性要求比较高的数据表的主键的时候,不希望有恶意企图的人能够猜测出后续要生成的UUID。这对这里要生成的客 户端唯一标识也是有意义的,这个客户端唯一标识是应该只有应用服务器和相应的客户端才需要知道的,如果这个唯一标识能被猜测的话,就会对系统安全造成隐患 (比如恶意清除Session等)。

基于安全和效率的考虑,我们选择RandomGUID算法作为客户唯一标识生成算法,同时案例系统中其他需要生成主键的地方也全部使用RandomGUID算法。

6.4.3  用户信息的保存

目前要保存在应用服务器端Session的信息有登录用户Id、账套名、sessionId等,并且允许把自定义的一些信息放入Session中,以实现一些特殊的功能。

【例6.7】用户信息的保存。

写一个简单的服务器端用户上下文JavaBean即可储存这些信息。代码如下:

// 服务器端用户上下文

public class ServerUserContext implements Serializable

{

    private String sessionId;

    //账套名

    private String acName;

    //登录用户id

    private String curUserId;

    //存储用户自定义信息用的哈希表

    private Hashtable userDefAttributes = new Hashtable();

    public String getACName()

    {

        return acName;

    }

    public void setACName(String acName)

    {

        this.acName = acName;

    }

    public String getSessionId()

    {

        return sessionId;

    }

    public void setSessionId(String sessionId)

    {

        this.sessionId = sessionId;

    }

    public String getCurUserId()

    {

        return curUserId;

    }

    public void setCurUserId(String curUserId)

    {

        this.curUserId = curUserId;

    }

    //得到名称为name的用户自定义信息

    public Object getUserDefAttribute(String name)

    {

        return userDefAttributes.get(name);

    }

    //得到所有的用户自定义信息

    public Enumeration getUserDefAttributeNames()

    {

        return userDefAttributes.keys();

    }

    //移除名称为name的用户自定义信息

    public void removeUserDefAttribute(String name)

    {

        userDefAttributes.remove(name);

    }

    //在用户自定义信息中加入名称为name,值为value的用户自定义信息

    public void setUserDefAttribute(String name, Object value)

    {

        userDefAttributes.put(name, value);

    }

}

和Web中的Session一样,此处的 Session中的自定义用户信息功能只是供一些极特殊的用途使用的,不应该使用此功能使得Remoting 服务变成了有状态的。比如,在A服务中将一些逻辑信息存入Session,然后到B服务中取出使用,这违反了分布式开发的无状态原则,很容易导致数据混乱 并使系统状态控制变得复杂。即使确实有特殊需要向Session中放置自定义信息的,也要尽量避免放置大对象或者过多的对象进去,以免过多地占用宝贵的应 用服务器内存资源。当某个问题要通过向Session中放置自定义信息的时候,要首先思考能否改用其他更好的方式解决,向Session中放置自定义信息 是下下策。

6.4.4  维护管理Session

在Session的原理中曾经提到,Session通 常是应用服务器中的一张哈希表。在系统中增加一个Session哈希表,这个表以Session的Id(即前边说的客户端唯一标识)为键,以 ServerUserContext 的对象为值。其他的管理操作自然而然也就全部围绕此哈希表来完成,

【例6.8】Session的维护管理。

编写如下的Session管理器来进行Session的维护管理:

// Session管理器

public class SessionManager

{

    private static SessionManager instance = null; 

 

    //sessionId为key,ServerUserContext为value

    private Map sessionMap = Collections.synchronizedMap(new HashMap());

    private SessionManager()

    {

        super();

    }

    public static SessionManager getInstance()

    {

        if (instance == null)

        {

            instance = new SessionManager();

        }

        return instance;

    }

    /**

     * 根据会话id得到用户上下文

     * @param sessionId

     * @return

     */

    public ServerUserContext getServerUserContext(String sessionId)

    {

        return (ServerUserContext) sessionMap.get(sessionId);

    }  

    /**

     * 得到所有会话id的集合

     * @return

     */

    public Set getSessionIdSet()

    {

        return Collections.unmodifiableSet(sessionMap.entrySet());

    }

 

    /**

     * sessionId是否合法

     * @param sessionId

     * @return

     */

    public boolean isValid(String sessionId)

    {

        return sessionMap.containsKey(sessionId);

    }

    /**

     * 清除session

     * @param sessionId

     */

    public void removeSession(String sessionId)

    {

        sessionMap.remove(sessionId);

    }

    /**

     * 清除所有session

     *

     */

    public void removeAll()

    {

        sessionMap.clear();

    }

    /**

     * 根据账套名请求一个会话Id

     * @param acName

     * @return

     */

    public String requestSessionId(String acName)

    {

        String sessionId = new RandomGUID().toString();

        ServerUserContext ctx = new ServerUserContext();

        ctx.setACName(acName);

        ctx.setSessionId(sessionId);

        sessionMap.put(sessionId, ctx);

        return sessionId;

    }

}

Session管理器在一个应用服务器内只存在一个实 例,所以采用单例模式。客户端第一次登录的时候以要登录的账套名为参数调用requestSessionId方法得到唯一的Session Id;以后可以SessionId为参数调用getServerUserContext方法,这样就可以得到此Session的 ServerUserContext信息;退出的时候以SessionId为参数调用removeSession方法以清除对应的Session。

6.4.5  Session的注销

由于Session中保存着用户名、权限、账套名称等 信息,占据一定的应用服务器内存,随着登录系统的人次的增多,Session占用的存储空间也会变大。为了及时清除无用的Session,可以要求调用者 在退出时调用SessionManager的removeSession方法来清除其对应的Session。

客户端有可能意外终止或者客户端和应用服务器的连接意 外断开,这时客户端在应用服务器中的Session就会永远无法清除了。解决这个问题最直接的办法就是建立Session超时机制:某个Session对 应的客户端如果在一定时间后还没有活动的话,就将此客户端对应的Session从应用服务器清除,这也是Web Session处理这类问题的策略。

超时时长的设置最好能够配置,这样方便实施人员或者客户根据情况进行修改以便调优,为此在ServerConfig.xml中增加下面的配置项:

<SessionTimeOut>3</SessionTimeOut>

这个配置表示当客户端3分钟之后还没有活动的话就将其对应的Session清除。在ServerConfig.java中增加读取此配置项的代码,并增加getSessionTimeOut()方法以读取配置项的值。

接着在SessionManager中增加一个私有变量Map,用来记录Session的活动信息:

private Map sessionActiveMap = Collections.synchronizedMap(new HashMap());

sessionActiveMap以sessionId为键,以会话自上次活动以来的时间(分钟)为值。

然后为SessionManager增加一个公共方法用来供外界调用,表示某Session产生活动了:

public void sessionVisit(String sessionId)

{

    if (!sessionMap.containsKey(sessionId))

    {

        return;

    }

    sessionActiveMap.put(sessionId, new Integer(0));

}  

当sessionVisit被调用以后,就重置此 Session的未活动时间为0。那么谁来调用此方法呢?我们将会在讲解SessionServiceLifeListener类的时候介绍,此处可以认 为只要客户端调用应用服务器的方法的时候sessionVisit方法就会被自动调用。

剩下的问题就是实现定时清除超时Session了。要 实现这个功能,首先要设置定时任务,以使定时清除超时Session的任务每隔一段时间运行一次。设置定时任务的方式有很多种,比如Quartz就是一个 非常优秀的定时任务工具。对于这个应用,使用Quartz就有点大材小用了,最方便、高效的实现方式就是使用java.util.Timer类。

Timer类的使用是非常简单的,比如下面的代码就实现了每两秒钟打印一次的功能:

package com.cownew.Char11.Sec04;

import java.util.Timer;

public class TimerTest

{

    public static void main(String[] args)

    {

        Timer timer = new Timer(false);

        timer.schedule(new java.util.TimerTask() {

            public void run()

            {

                System.out.println("hello");

            }

        }, 0, 2 * 1000);

        System.out.println("program end!");

    }

}

使用Timer类的时候有如下几点需要特别注意:

l   Timer类有一个参数为“boolean isDaemon”的构造函数,此处的isDaemon表示执行定时器任务的线程是否是后台线程。如果是后台线程,则主程序终止的时候后台线程也就终止 了;如果不是后台线程,除非这个Timer任务停止,否则主程序无法停止。可以看到TimerTest类运行的时候“program end!”已经打印出来了,可程序仍然没有终止,这是因为timer已经被设置为后台线程,而timer是无限次循环执行的,所以程序就无法正常终止了。

l   schedule中的时间参数是以毫秒为单位的。

l   Timer中是采用Object.wait(long time)来实现定时的,由于Object.wait()不保证精确计时,所以Timer也不是一个精确的时钟。如果是实时系统,不能依赖Timer。

【例6.9】Session超时清理任务。

编写一个从TimerTask继承的SessionCleanerTimerTask类作为SessionManager的内部类,实现TimerTask的run方法,在run方法中进行Session超时的检测及处理:

// Session超时清理任务

protected class SessionCleanerTimerTask extends TimerTask

{

    private int timeOut = ServerConfig.getInstance().getSessionTimeOut();

    public void run()

    {

        Set idSet = sessionActiveMap.keySet();

        Iterator idIt = idSet.iterator();

        // 已经失效的Session的Id列表

        List invalidIdList = new ArrayList();

        while (idIt.hasNext())

        {

            String id = (String) idIt.next();

            // 自上次访问以来的时长,即未活动时间

            Integer lastSpan = (Integer) sessionActiveMap.get(id);

            if (lastSpan.intValue() > timeOut)

            {

                invalidIdList.add(id);

            }

            //Session的未活动增加一分钟

            sessionActiveMap.put(id, new Integer(lastSpan.intValue() + 1));

        }

 

        //清除超时的Session

        for (int i = 0, n = invalidIdList.size(); i < n; i++)

        {

            String id = (String) invalidIdList.get(i);

            removeSession(id);

            sessionActiveMap.remove(id);

        }

    }

}

SessionCleanerTimerTask 是SessionManager的内部类,能够访问sessionActiveMap并调用removeSession等方法。此任务每隔一段时间对所有 的Session进行扫描,拣出超时的Session,然后把所有Session的未活动时间增加一分钟,处理完毕后统一清除超时的Session。

为了使此任务与SessionManager一起启动,需要把任务的部署(schedule)工作放到SessionManager的构造函数中:

private SessionManager()

{

    super();

    //要设置成后台线程,否则会造成服务器无法正常关闭

    Timer sessionClearTimer = new Timer(true);

    //ONE_MINUTE是CTK的DateUtils中定义的常量,表示一分钟

    //ONE_MINUTE = 60000;

    int oneMin = DateUtils.ONE_MINUTE;

    //1分钟以后开始,每隔一分钟探测一次

    sessionClearTimer.schedule(new
                        SessionCleanerTimerTask(),oneMin,oneMin);

}

6.4.6  安全问题

Session机制可以防止恶意攻击者跳过登录模块直接调用服务端方法,因为对系统安全有影响的操作(查询数据、修改删除数据)都必须通过SessionId才能得到数据库连接,由于恶意攻击者得不到一个正确的SessionId,所以就无法正确调用这些方法。

Session采用了RandomGUID来防止恶意 攻击者通过猜测SessionId的方式来冒充合法的用户进入系统,但这不足以防范恶意攻击者。恶意攻击者可以截获客户端发往应用服务器的数据包并从数据 包中分析出SessionId,这样恶意攻击者就可以采用此SessionId来冒充合法的用户进行系统的操作了。对此进行防范的比较好的方法就是采用 SSL连接,SSL连接会对客户端和应用服务器之间的数据交换过程进行加密及数字签名,恶意攻击者根本无法正确地截获数据,商业系统目前大都采用此种方式 保证数据的安全,比如网上银行、银企平台等。

为了在不安全的网络上安全保密地传输关键信 息,Netscape公司开发了SSL协议,后来IETF(Internet Engineering Task Force)把它标准化了,并且取名为TLS,目前TLS的版本为1.0,TLS 1.0的完整版本请参考rfc2246(www.ietf.org)。

基于TLS协议的通信双方的应用数据是经过加密后传输 的,应用数据的加密采用了对称密钥加密方式,通信双方通过TLS握手协议来获得对称密钥。为了不让攻击者偷听、篡改或者伪造消息,通信的双方需要互相认 证,来确认对方确实是其所声称的主体。TLS握手协议通过互相发送证书来认证对方,一般来说只需要单向认证,即客户端能确认服务器便可。但是对于对安全性 要求很高的应用往往需要双向认证,以获得更高的安全性。

可以向可信的第三方认证机构(CA)申请证书,也可以 自己做CA,由自己来颁发证书。如果自己做证书颁发机构,可以使用Openssl,Openssl是能用来产生CA证书、证书签名的软件,可以在其官方网 站http://www.openssl.org下载最新版本。使用的时候要同时生成服务器端证书和颁发并发布个人证书。服务器端证书用来向客户端证明服 务器的身份,也就是说在SSL协议握手的时候,服务器发给客户端的证书。个人证书用来向服务器证明个人的身份,即在SSL协议握手的时候,客户端发给服务 器端的证书。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值