13.live555mediaserver-describe请求与响应

这是[手把手一起学live555]的第14篇(按这个序号看,请找正确顺序看)。
live555工程在我的gitee下(doc下有思维导图、drawio图):https://gitee.com/lure_ai/live555/tree/master

章节目录链接
0.前言——章节目录链接与为何要写这个?
https://blog.csdn.net/yhb1206/article/details/127259190?spm=1001.2014.3001.5502

学习demo
live555mediaserver.cpp

学习线索和姿势
1.学习的线索和姿势

网络编程
流媒体的地基是网络编程(socket编程)。[网络编程学习]-0.学习路线

绘图规则
本文的对象图和思维导图遵守的规则详见:
2.绘图规则

非阻塞服务端网络编程流程
socket创建、bind、listen、select、accept、select、recv/send-close

rtsp协商流程
options、describe、setup、play、pause、teardown、get parameter、set parameter

本节内容和目标
(1)rtsp协议的describe请求与响应
(2)思维导图绘制
(3)wireshark抓包
(4)c++虚函数继承知识、虚函数形参初始值继承知识
(5)对象图、链表图
(6)封装、继承、多态
(7)结尾增加mkv格式的文件的抓包——包含音视频-264-AAC

正式开始
上节学习了rtsp协商的第一个节点OPTIONS,客户端知道了服务端有啥服务了,那么接下来它要调用这些服务了,但是有顺序的,接下来的节点是DESCRIBE。

1.客户端DESCRIBE请求报文

请求报文如下在这里插入图片描述
图13-1

可以参照live555mediaserver-如何解析rtsp请求报文把这请求报文解析出来。需要注意的是解析的只局限于请求方法、CSeq、Session、Content-Length等,它下发的其他字段就过滤了。

2.服务端处理DESCRIBE请求与响应

在这里插入图片描述
图13-2

如上图,根据识别出的请求方法describe,就进入到了RTSPServer::RTSPClientConnection::handleCmd_DESCRIBE方法。

拼接完整文件路径
首先是拼接完整url中的文件路径——相对于服务端程序的路径——url也可以写成rtsp://192.168.1.0/…/…/test.264等诸如此类的,因为它分两步,先识别…/…和test.264,然后在这个方法里把它们两个再用“/”拼接成…/…/test.264,你看就是相当于还原了url的路径。
当然写成rtsp://192.168.1.0/test.264这个,它在这个方法中就不会和“/”拼接了,如上图的380行是有过滤的,最后这个它会取到test.264这个文件名。

密码验证
接下来authenticationOK是密码验证,默认没有开,所以它默认返回true。

关键调用
接着它就调用了这么一句:fOurServer.lookupServerMediaSession(urlTotalSuffix, ESCRIBELookupCompletionFunction, this);

fOurServer保存的是DynamicRTSPServer对象的父类的父类GenericMediaServer的引用,而父类GenericMediaServer中有这个虚方法lookupServerMediaSession,
子类DynamicRTSPServer也有这个虚方法lookupServerMediaSession,
那么问题来了:父类子类里都有同名方法,该调用的谁的方法呢?
答案是子类的方法,即
DynamicRTSPServer::lookupServerMediaSession。

为何不是GenericMediaServer
的lookupServerMediaSession方法呢?这就涉及到c++的虚函数继承知识了。

c++虚函数继承
若父类有虚函数,派生的子类里也有同名函数,则创建子类调用其父类的虚函数则是子类的同名函数。为啥呢?这涉及到虚表和运行时虚表指针指向的问题。
先说虚表。 编译器编译阶段,如果父类有虚函数则对应一个虚表、派生的子类都会生成对应一个虚表。且虚表里各个虚函数偏移地址就固定了。如果父类有虚函数,那么派生的子类都会有虚表的,如果子类里没有同名的方法,子类虚表里的这个方法是父类的虚方法地址(也就是继承),如果子类里也有同名的方法,则子类的虚表里就存的是子类自己的同名方法(也就是派生、重写、覆盖)。
再说运行时。 创建一个对象(不管子类对象还是父类对象)对象第一个是vbptr指针就是指向哪个虚表的指针。这个指针指向的是当前创建的对象的虚表——创建父类对象就指向父类虚表,创建子类对象就指向子类对象。
如果父类子类都有相同的虚函数,那么父类子类的虚表里各有一个虚函数,但是如果创建的是子类对象,那么这个对象的虚表指针就指向子类的虚表(也就是它选择了子类的虚表)——因此,如果通过这个子类对象的父类指针或引用来调用这个同名的虚函数,那么这个子类对象就从子类虚表里找这个同名方法,调用的方法自然是子类的同名方法了——这给人的感觉就是你通过子类对象的父类指针或引用调用的虚函数竟然是子类的虚函数,这就是多态的原理。

DynamicRTSPServer父类的父类GenericMediaServer有一个虚函数lookupServerMediaSession,如下
在这里插入图片描述
图13-3

DynamicRTSPServer对象也有个虚函数lookupServerMediaSession
在这里插入图片描述
图13-4

因为DynamicRTSPServer是GenericMediaServer的派生,根据c++的虚表等编译器的规则和运行规则那么调用GenericMediaServer的方法lookupServerMediaSession其实就是调用DynamicRTSPServer的方法lookupServerMediaSession,因为你调用这个父类方法的前提是创建的是子类对象DynamicRTSPServer,此时这个对象的虚表指针指向(选择)的是DynamicRTSPServer的虚表。

虚函数形参初始值继承关系
另外还有一个知识点。GenericMediaServer方法lookupServerMediaSession的最后一个参数有初始值,而其派生的子类DynamicRTSPServer的方法lookupServerMediaSession最后一个参数却没有初始值,那么问题来了:派生类会继承父类的形参的初始值么?我也不知道,也很困惑,这咋办?说到这,通过交流,我受到启发,学习c++,总看书是不行的,看懂了知识点,你以为你懂了,但是让你写代码,你是写不出来的,学习设计模式也一样——看懂归看懂,写代码归写代码。看懂和会用之间是有距离的。 那怎么办?有句话叫“学以致用”,学和致用是有距离的。如何缩短这距离?写demo。
也就是说学c++、学设计模式、学算法等等,看懂是第一步,写demo是“致用”的有效手段。合上书,自己写demo,运行起来,这样才能把知识点有进一步的了解。——浅层的“致用”。
那么为了搞懂这个知识点,我就写demo验证下。如下图。
在这里插入图片描述
这是第一种情况,我把宏设为false了,这就是live555开源项目的实例demo——创建子类对象返回父类指针,再调用与子类同名的父类的虚方法,这个时候验证得出会继承父类虚函数形参的初始值的。所以
fOurServer.lookupServerMediaSession(urlTotalSuffix, ESCRIBELookupCompletionFunction, this);
只传递了3个形参,但它还是会匹配到GenericMediaServer有4个形参的方法lookupServerMediaSession,最后一个形参没有传,那就是初始值true。

这就可以结束了,但是如果好奇,可以更改下demo看下第2种情况,如下图
在这里插入图片描述
把main中的测试打开,创建子类对象返回子类对象指针,接着调用run方法,然后编译不通过,说没有匹配到2个形参的run方法,这是因为子类run方法里的第3个形参没有初始值,那它就匹配不到。

第3种情况,子类父类同名同参虚函数的同位置的形参都各自有初始值的情况,会怎么样?如图。
在这里插入图片描述
结果如下:
如果创建子类返回父类指针,再调用虚函数方法run,形参初始值是父类的。
如果创建子类返回子类指针,再调用虚函数方法run,形参初始值是子类的。

这3种情况,《c++ primer》不推荐用第3种,这太乱了,要用的话父类和子类同名虚函数形参初始值最好一样。否则也太花里胡哨了!

回来继续看下追踪到的这个方法DynamicRTSPServer::lookupServerMediaSession的实现:
在这里插入图片描述
图13-5

图13-5里的标记1-5是大致流程。它是流程简单,内容复杂。所以要一点一点地扣。也是比较惆怅怎么写。要不一个个标记的写?

图13-5的标记1
在这里插入图片描述

标记1是fopen打开url解析出的路径的文件,原来如此,所以url拼写规则就是
rtsp://ip:port/文件路径
其中,文件路径可以写…/…/test.264或直接test.264,或者./…/…/…/test.264。反正相对可执行文件来说的位置,想怎么写怎么写,只要能指向正确的文件路径就行。

图13-5的标记2

摘抄如下
在这里插入图片描述
从hash表里查找这个有没有这个路径,实现如下。
在这里插入图片描述
但是在说这个调用前,得知道fServerMediaSessions是个啥玩意儿?属于哪个对象的成员?它是DynamicRTSPServer对象的父类的父类GenericMediaServer的成员。它是个BasicHashTable对象的指针,对其进行揭秘,如下图:
在这里插入图片描述

图13-6 hash链表图(画对象图、链表图,我很兴奋,很幸福,爽!)

BasicHashTable对象管理了一个单向链表,这个单向链表是在头节点插入,也就是从头部插入的方式。
直接上来就讲图也是不好的,咋一看,太猛了,那么这个图咋来的呢?还得看初始化如下图。
在这里插入图片描述
怎么说呢,这个涉及到封装多态的概念。

封装多态
代码加图13-6可以看到fServerMediaSessions的诞生是调用BasicHashTable的父类HashTable的静态方法create来创建子类对象BasicHashTable再返回父类指针。这一过程,只能看到父类的方法,其实现是由子类实现的,这是封装。多态是因为看到父类的方法,子类负责实现,不同的子类实现是不一样的。——等下,这个好像不符合。算了就这样了,就当我胡说八道。

fServerMediaSessions的初始化可以解释图13-6中fStaticBuckets[0]到fStaticBuckets[3]的意思,它是个指针数组,最大是由宏SMALL_HASH_TABLE_SIZE定义的,这个宏是4,为何是4?唉,又得解释,因为后面hash算法是结果是0-3,于是就数组的各个成员都是链表头结点——各带领一个单向链表。
fBuckets是个二重指针,指向这个fStaticBuckets的第一个元素的地址。

我怎么知道是单向链表图,还是什么头结点插入?还得看下面图:
在这里插入图片描述
图13-7
164行和165行这个操作下来就是从头节点进行插入。164行是新队员指向头结点紧挨的队员(头结点保存紧挨的队员),165行头结点更改保存的地址为新队员的地址。如下图:
在这里插入图片描述

但是还有一点没有解释清楚,图13-6里的264/265等是啥玩意?其实就是说的url里最后是.264/.265等文件的所在队列。如下
在这里插入图片描述
图13-7

因为在add的时候,传入的key是url的文件路径,然后它要查看hash表里是不是已经有了,如果没有找到,那就创建新的TableEntry队员插入到单向链表里,但是要找到index,这个就在BasicHashTable::lookupkey里获取的,怎么获取的呢?如图13-7,BasicHashTable::hashIndexFromKey中搞的,怎么搞的?如图13-7,BasicHashTable::hashIndexFromKey中第270行和第272行是核心,就是左移3位加下一个字符了,最后和fMask掩码做与操作,fMask初始化时3呀,也就是最终值映射到0-3的区间。那个左移3位+下个字符的操作是啥意思呢?比如rtsp://192.168.15.22/test.264,经过前面一系列操作,它会取出key就是test.264,那么就是“test.264”这个几个字符相累加,但是每次都是累积和左移3位再加下一字符,其实等同于保留最后一个字符的低3位,在这就是“4”的字符值52,二进制是0b0110100的低3位是0b100,然后又和掩码0x3相与,结果就是0,毫无疑问,“test.264”所在队列的index就是0,那么也就是说要看最后一个字符的低2位是啥,那index就是啥,至此,图13-6的264/265等标记意思说完了。
在此总结下,各个url的文件是放在哪个链表队列里的:
264文件 对应索引: 0
265文件 对应索引: 1
aac文件 对应索引: 2
amr文件 对应索引: 2
dv文件 对应索引: 2
m4e文件 对应索引: 1
mkv文件 对应索引: 2
mp3文件 对应索引: 3
mpg文件 对应索引: 3
ogg文件 对应索引: 3
ts文件 对应索引: 3
vob文件 对应索引: 2
wav文件 对应索引: 2
webm文件 对应索引: 1

所以图13-5里我在头结点(各数组元素)里标注了264/265/aac/mp3,各头结点我都举个文件的例子,上面才是完整的各个文件的单向链表所在队列的头结点。

这个时候终于可以看下图13-5标记2的调用fServerMediaSessions->Lookup了,如下图:
在这里插入图片描述
图13-8

可以看到fServerMediaSessions->Lookup也是调用了BasicHashTable
::lookupKey,这个在图13-7里已经贴了,需要对图13-7
补充说明的是先找到index这个前面已经说过,然后在这个对应的数组成员带领的单向链表了遍历查找这个key,找到了就返回对应的TableEntry对象的指针,如果没找到就返回NULL。
当然这个时候这是第一次,里面还没有链表成员呢,肯定是NULL。
咦!你说我说了半天,这个图13-5的标记2最后返回的是NULL?我说,是呀,我没办法呀!

图13-5的标记3
如果文件不存在或者路径写错了,那么fopen就打不开文件,返回NULL了,则走图13-5的标记3,但是如果此时hash表里有这个文件那么就从hash表的单向链表队列里移除。
第一次是不会进这个的,但是后面如果不是第一次的话,从单向链表队列如何移除的呢?如下图:
在这里插入图片描述
图13-9 单向链表队员移除操作

上图最右边是单向链表队员移除的核心操作,比较绕,你细细品品,如下图,fNext是TableEntry * 类型的,这个对象开辟了块内存来存放它,自然fNext这个成员也是在内存中的,图13-9的第202行,ep = &((ep)->fNext)中ep就拿到了TableEntry对象的内存地址,然后取出fNext成员的内存地址,如下图TableEntry对象的fNext成员在内存中,所以也是有地址的,这句话就是定位到这个fNext成员的内存地址。接着图13-9的第199行,就是匹配到了这个要删除的链表队员,那么把当前这个链表队员的fNext的值要改成删除队员的下一个链表队员。
在这里插入图片描述
怎么说呢,举个例子,单向链表A->B->C->D,我要删除C,那么搞个指针变量指向B的时候,判断下B的成员fNext保存的地址是不是C的地址,如果是就把B的fNext成员变量更新为D的地址,这就自然从单向链表中删除了队员C。上面的操作就是这个思路。

图13-5的标记4
如果文件存在,则走的标记4,如下。
在这里插入图片描述

第1个if是文件在hash表存在就移除,就是个容错判断,刚开始hash表就有这个是不对的。这个移除操作和图13-5标记3一样,前面已分析过,不再赘述。

第2个if可是DynamicRTSPServer::lookupServerMediaSession的核心操作哦。
主要是干2件事:
(1)调用createNewSMS创建ServerMediaSession对象。
在这里插入图片描述
strrchr是取指定字符在字符串最后一个匹配的位置,比如test.264取“.”,那么它会找到最后个.的位置,也就是“.264”。
接着创建对象ServerMediaSession对象,将其指针返回出去。
然后创建264/265等等的具体对象,都是类FileServerMediaSubsession派生出的子类,而类FileServerMediaSubsession都是继承于类ServerMediaSubsession,类ServerMediaSubsession是继承于类Medium。
以H264VideoFileServerMediaSubsession为例如下继承关系。

在这里插入图片描述

sms->addSubsession(ServerMediaSubsession* subsession)形参是这样类型的,这就涉及多态了,不同文件都会创建子类对象,返回其父类对象ServerMediaSubsession的指针,然后addSubsession加入到ServerMediaSession对象中。

注意,这一步是创建2个对象——ServerMediaSession和264VideoFileServerMediaSubsession,但是呢,ServerMediaSession这个对象通过addSubsession把264VideoFileServerMediaSubsession给装到自己的成员变量里了。
如下图
在这里插入图片描述
ServerMediaSession::addSubsession这个操作也是加入链表,如上图ServerMediaSession的fSubsessionsHead和fSubsessionsTail成员指向了H264VideoFileServerMediaSubsession对象的父类ServerMediaSubsession地址,我先这么简单画,它这个也是单向链表,只是有头尾指针。

(2)RTSPServer::addServerMediaSession加入到hash单向链表中。
上面2个对象创建完,通过RTSPServer::addServerMediaSession又把ServerMediaSession对象给装走了,装到哪里去了呢?就是13-6的单向链表了。如下代码
在这里插入图片描述

代码的图形化如下:
在这里插入图片描述
fServerMediaSessions->Add前面已经讲过,可以看到插入单向链表时,它把ServerMediaSession对象指针放到链表成员对象TableEntry的成员变量value里了,前面我们知道key是文件路径。后面就可以通过这个链表成员对象TableEntry来找到上面创建的2个对象了。
真是一层一层又一层。——如果代码不能图形化,真是好难理解。

图13-5的标记5
图13-5的标记5如下
在这里插入图片描述
又掉用了个回调函数。
其中回调函数completionFunc是RTSPServer::RTSPClientConnection
::DESCRIBELookupCompletionFunction。
形参completionClientData是RTSPServer::RTSPClientConnection对象的this指针,
形参sms是上面说的创建的ServerMediaSession对象指针。

也就是说图13-2的调用fOurServer.lookupServerMediaSession最后的调用就是调用传进去的回调,看其跳的轨迹:

可以看到,这就像一个跳板一样,又回到了RTSPServer::RTSPClientConnection
::DESCRIBELookupCompletionFunction——它也是个静态方法,所以调用它必须得传给它对象的this指针才行呀。
最终呀,是调用的RTSPServer::RTSPClientConnection::handleCmd_DESCRIBE_afterLookup——这个也是describe响应报文最终组织的地方。

describe响应报文最终组织的地方
来看下这个describe响应报文最终组织的地方:RTSPServer::RTSPClientConnection::handleCmd_DESCRIBE_afterLookup,如下图:

在这里插入图片描述

图13-18

图13-18是最终处理describe的地方——组sdp、组响应报文。

组sdp信息
在这里插入图片描述
至于SDP的介绍可以参见很多博客,比如会话描述协议-SDP

至于具体的组sdp的,可以参见网上文章,先流程,再说。
如下
live555学习阶段二之二SDP流程
https://blog.csdn.net/gp410863881/article/details/78300747

live555 源码分析:子会话 SDP 行生成

组url
fOurRTSPServer.rtspURL这个调用其实就是调用RTSPServer::rtspURL,这个它组url,形式是rtsp://ip:port/文件路径,其中ip:port是直接找本地的ip和监听端口,文件路径是用的RTSPServer::RTSPClientConnection对象客户端带的。
我以为它会直接使用RTSPServer::RTSPClientConnection对象解析的url呢,没想到它是这样的。

组响应报文
在这里插入图片描述
把sdp信息拿到、url拿到,就开始用snprintf组了,这个没啥好说的。关键是sdp如何拿到的。

最终响应抓包
在这里插入图片描述

流程梳理
在这里插入图片描述

整个流程梳理下,可以知道RTSPServer::RTSPClientConnection::handleCmd_DESCRIBE中调用fOurServer.lookupServerMediaSession,即DynamicRTSPServer::lookupServerMediaSession,它最终调用了RTSPServer::RTSPClientConnection::DESCRIBELookupCompletionFunction。
这发现一个特点:RTSPServer::RTSPClientConnection::handleCmd_DESCRIBE和RTSPServer::RTSPClientConnection::DESCRIBELookupCompletionFunction都是RTSPServer::RTSPClientConnection对象的方法,中间的fOurServer.lookupServerMediaSession只是一个“转发”——相当于一个跳板——又跳回到原来对象的另一个方法了。
那RTSPServer::RTSPClientConnection::handleCmd_DESCRIBE中为何不直接调用RTSPServer::RTSPClientConnection::DESCRIBELookupCompletionFunction,而非得经过DynamicRTSPServer对象的lookupServerMediaSession方法呢?从DynamicRTSPServer::lookupServerMediaSession这个方法中我发现原来跳回来的时候RTSPServer::RTSPClientConnection::DESCRIBELookupCompletionFunction的形参需要调用createNewSMS来获取,而且要用DynamicRTSPServer对象来记录它,所以要这样中间跳一层。
这就比如有A和B两个对象,B对象的方法1要调用B的另一个方法2,但是方法2的某个形参需要用到A对象的方法来获取,那这怎么办呢?live555是这么设计的:A为B开了个专门的转换接口。在这里A对象就是DynamicRTSPServer,B对象就是RTSPServer::RTSPClientConnection,A对象里的转换接口就是DynamicRTSPServer::lookupServerMediaSession——也是跳板。其中跳回来的B对象的方法2是静态方法——RTSPServer::RTSPClientConnection::DESCRIBELookupCompletionFunction——为何是静态方法?因为A对象实现的转换接口是直接调用方法指针的而不是对象指针->方法的形式。

可以看到A对象转换接口返回值——ServerMediaSession对象指针——在这里大有用处:获取rtspURL,来放到响应buffer里。
待续…

增加mkv的抓包分析

前面只学单一的视频264的rtsp协议报文,但是实际中不只是有视频还有音频,所以为了完整分析,再加上live555支持的音视频mkv格式的文件播放的rtsp报文分析。
其流程和前面不变。
只是sdp信息不一样而已。

vlc输入 rtsp://192.168.145.1/…/…/…/test/backkom.mkv 如下,
在这里插入图片描述

抓到的rtsp请求报文:
在这里插入图片描述

只是url变了而已,其他没啥不同。

live555服务端回的响应rtsp报文:
在这里插入图片描述

sdp是增加了一个音频的媒体描述信息——有2个m字段了——这决定了vlc在setup阶段要下发2次setup了,那么setup的url怎么拼?——可以注意下,control:tracks1和control:tracks2——前者是视频的标志,后者是音频的标志。这决定了2个setup的url的拼写:

视频的setup url
rtsp://192.168.145.1/…/…/…/test/backkom.mkv/tracks1

音频的setup url
rtsp://192.168.145.1/…/…/…/test/backkom.mkv/tracks2

另外那个fServerMediaSessions管理的链表,插入的索引是2了。
然后创建的ServerMediaSession对象管理的链表对象代码图如下、
在这里插入图片描述
这些代码一执行,我去,创建的对象图如下——还是颇为复杂,
在这里插入图片描述
可以看到,代码图265行里创建了对象MatroskaFileServerDemux,这个对象创建了如对象图里上面部分一系列的对象:MatroskaFile、MatroskaFileParser、ByteStreamFileSource等主要是这3个。
然后接着就env.taskScheduler().doEventLoop(&creationState.watchVariable);这句了,这句是啥意思?而describe等rtsp协议和rtp数据都是在这里循环执行的,在这里再调它就是为了解析这个mkv文件,解析出它的音视频属性,解析成功就会把creationState.watchVariable = 1;这样就退出了。
它一定会执行的,为啥?因为创建对象MatroskaFileServerDemux会执行MatroskaFileParser::parse(),第一次执行会走到哪里呢?
MatroskaFileParser::parseStartOfFile
->
MatroskaFileParser::parseEBMLIdAndSize
->
MatroskaFileParser::parseEBMLNumber
->
StreamParser::get1Byte
->
StreamParser::ensureValidBytes
->
StreamParser::ensureValidBytes1
->
FramedSource::getNextFrame
->
ByteStreamFileSource::doReadFromFile
->
ByteStreamFileSource::doReadFromFile里将FramedSource::afterGetting加入到定时任务,延时为0s。
->
StreamParser::ensureValidBytes1,在这里throw出异常,第一次是正常的,为了退出构造函数。
->
MatroskaFileParser::parse()捕捉到异常,然后退出构造函数。

接着执行env.taskScheduler().doEventLoop(&creationState.watchVariable)循环,必然立马执行定时任务,然后解析mkv文件,成功则creationState.watchVariable = 1,然后退出循环。

继续执行代码图的268行等,继续完成describe。

可以结合[live555] 谈一谈 (*.mkv) track1 和 track2 的信息获取述来看一看,相得益彰。
https://blog.csdn.net/engineer_james/article/details/81810127
待续…

小结

describe作为rtsp协商的必经节点还是比较复杂的。它这个主要是查找url中解析出的本地文件是否存在,如果存在就解析,组sdp,最后发给客户端。是什么文件类型。为接下来的节点做铺垫。
另外hash单向链表这个是老早初始化完的,但是前面很多节都没有说,因为和我的业务关注点无关,只有用到才去说,我不是胡子眉毛一把抓,不然很难学的,学到哪再去看。

另外为了实现describe新增了几个类和链表:
在这里插入图片描述
虚线右边这就是describe业务实现新增的类。

最后,增加mkv文件,不同的地方在于对象增加了:

在这里插入图片描述

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值