MMO网络消息处理/通讯与协议

客户端如何把消息发到服务器?服务器显示客户端发送的内容

通信组件

//最大程度上把协议的封包和处理隐藏在底层,让开发者只接触到协议;

//例如:发消息只需要调用一个Send

NetworkClient&TcpSocketListener

网络客户端:发送,接收消息;把数据包发到服务器

服务端监听器:接收,响应消息;服务器对数据将进行响应

//实现对原始的数据包(字节数据)进行收发,网络协议以字节为基本单元进行数据的收发(数据会变成字节流)

封包处理器PackageHandler

封包和协议(protobuf)之间的转换;//做双向转换

封包处理负责把网络上接收的数据还原成Protobuf的协议结构;或者把Protobuf的协议结构转换成字节数据

消息分发器MessageDistributer

//是一种服务,有Start,Stop之类

主要用在服务端收到客户端发来的消息,把消息分配到服务器的各个模块,主要用来做消息的接收和分发;

这里面为了提高消息的分发速度,在服务端使用了多线程

//客户端的消息分发器不需要多线程

消息分配处理MessageDispatch

//相当于函数,维护什么消息由谁来处理

与消息分发器一起工作,分发器负责基础消息的分发,不管消息是什么;而哪个消息分发到哪由消息分配处理负责

客户端Client中
的NetClient

通过类视图观察

56987217c4f44f099fec1726c701b4d8.png

连接到服务端

Connect

若做断线重连则调用了重连机制

客户端给服务端通讯,首先连接到服务端

public void Connect(int times = DEF_TRY_CONNECT_TIMES)
{//参数调用了重拾次数
    if (this.connecting)
    {//校验是否有连接
        return;
    }

    if (this.clientSocket != null)
    {//若原来有连接,把原来的连接关闭掉
        this.clientSocket.Close();
    }
    if (this.address == default(IPEndPoint))
    {
        throw new Exception("Please Init first.");
    }
    Debug.Log("DoConnect");
    this.connecting = true;
    this.lastSendTime = 0;

    this.DoConnect();
}
DoConnect

客户端连接服务器的过程是一种阻塞;能实时等待连接的结果

同时用了一种异步的方式(IAsyncResult;异步的方法(BeginConnect)调用完成后不会再等待

需要连接到服务器,所以需要等待(能保证连接成功或是失败能及时得到通知

捕获到足够多的异常

连接成功通讯转为非阻塞

//这里网络上用了阻塞,不用多线程会采用同样的效果:发了消息,网络差会阻塞应用

阻塞:当线程发起一个I/O请求后,如果数据尚未就绪,当前线程会被挂起,知道数据准备好并返回结果位置

//线程会暂停,直到I/O操作完成;发信息,若服务器不返回会一直等待

非阻塞:如果I/O操作不能立即完成(数据未就绪)调用会立即返回一个错误或指示操作未完成的值;这允许程序继续执行其他任务,直到再次需要检查I/O状态是否改变

//线程不会被挂起,可以继续执行其他任务,直到I/O操作完成或出现错误

void DoConnect()
{
    Debug.Log("NetClient.DoConnect on " + this.address.ToString());
    try
    {//防止连接过程中出错,捕获信息
        if (this.clientSocket != null)
        {
            this.clientSocket.Close();
        }

        //使用TCP协议则需要这种方式New
        this.clientSocket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
        this.clientSocket.Blocking = true;//使用Blocking阻塞状态

        Debug.Log(string.Format("Connect[{0}] to server {1}", this.retryTimes, this.address) + "\n");
        //异步的方法基本通过Begin..或End..实现
        IAsyncResult result = this.clientSocket.BeginConnect(this.address, null, null);
        //返回了一个接口reslut
        //根据接口判断是否需要等待;这里用Wait等待了一个超时时间
        bool success = result.AsyncWaitHandle.WaitOne(NetConnectTimeout);
        //Wait会等待异步事件完成之后再执行

        //若不适用Wait;则逻辑完全不阻塞往下执行
        if (success)
        {//若连接成功则调用End把异步请求结束
            this.clientSocket.EndConnect(result);
        }
    }
    catch(SocketException ex)
    {
        //catch到连接被服务器拒绝(网络专有的异常
        if(ex.SocketErrorCode == SocketError.ConnectionRefused)
        {
            this.CloseConnection(NET_ERROR_FAIL_TO_CONNECT);
        }
        Debug.LogErrorFormat("DoConnect SocketException:[{0},{1},{2}]{3} ", ex.ErrorCode,ex.SocketErrorCode,ex.NativeErrorCode, ex.ToString()); 
    }
    //常规的异常
    catch (Exception e)
    {
        Debug.Log("DoConnect Exception:" + e.ToString() + "\n");
    }
    //前面的连接执行完了,再判断连接状态是否成功
    if (this.clientSocket.Connected)
    {//连接成功,把阻塞的模式设置成False
        this.clientSocket.Blocking = false;//即游戏运行时服务端客户端的通讯采用非阻塞
        this.RaiseConnected(0, "Success");
    }
    else
    {
        this.retryTimes++;
        if (this.retryTimes >= this.retryTimesTotal)
        {
            this.RaiseConnected(1, "Cannot connect to server");
        }
    }
    this.connecting = false;//标记连接过程完成
}

发消息到服务端

SendMessage

判断连接

发送消息

//send a Protobuf message
public void SendMessage(NetMessage message)
{//NetMessage是ProtoBuf生成的//写协议文件,执行代码生成,到这里直接Send
    if (!running)
    {
        return;
    }

    if (!this.Connected)
    {
        this.receiveBuffer.Position = 0;
        this.sendBuffer.Position = sendOffset = 0;
        //判断没连接再连接一次
        this.Connect();
        Debug.Log("Connect Server before Send Message!");
        return;
    }
    //发送队列;执行发送时不影响玩家体验;要发送的东西放在队列中
    //发的时候一个个发;真正发出去是在Update
    sendQueue.Enqueue(message);

    if (this.lastSendTime == 0)
    {
        this.lastSendTime = Time.time;
    }
}

Update

 public void Update()
 {
     if (!running)
     {
         return;
     }

     if (this.KeepConnect())
     { //保证断线重连;若断开则重连
         if (this.ProcessRecv())
         {//每帧判断是否有收到的数据,有则先处理收到的数据
             //只接收,从服务器发到网卡,从网卡提到内存中
             if (this.Connected)
             {//接受完可能会断线,需要在判断一次
                 this.ProcessSend();//发送消息
                 this.ProceeMessage();//处理消息;在这里真正执行
             }
         }
     }
 }
ProceeMessage
void ProceeMessage()
{//分发器分发
    MessageDistributer.Instance.Distribute();
}
在接收时,ProcessRecv

使用了Poll

bool ProcessRecv()
{
    bool ret = false;
    try
    {
        if (this.clientSocket.Blocking)
        {
            Debug.Log("this.clientSocket.Blocking = true\n");
        }
        bool error = this.clientSocket.Poll(0, SelectMode.SelectError);
        //类似于Linux的服务器开发
        if (error)
        {
            Debug.Log("ProcessRecv Poll SelectError\n");
            this.CloseConnection(NET_ERROR_SEND_EXCEPTION);
            return false;
        }
        //非阻塞形式高效查询数据
        ret = this.clientSocket.Poll(0, SelectMode.SelectRead);
        //Poll查询了底层事件,数据是否可读;如果没有数据则返回速度块
        if (ret)
        {
            //接收
            int n = this.clientSocket.Receive(this.receiveBuffer.GetBuffer(), 0, this.receiveBuffer.Capacity, SocketFlags.None);
            if (n <= 0)
            {
                this.CloseConnection(NET_ERROR_ZERO_BYTE);
                return false;
            }
            //接收完成后把数据丢到接收缓冲区(packageHandler
            this.packageHandler.ReceiveData(this.receiveBuffer.GetBuffer(), 0, n);
            //给packageHandler后调用一次分发
        }
    }
    catch (Exception e)
    {
        Debug.Log("ProcessReceive exception:" + e.ToString() + "\n");
        this.CloseConnection(NET_ERROR_ILLEGAL_PACKAGE);
        return false;
    }
    return true;
}

790748d65c234bb6bf71ecdc40f41e11.png

//小图的这些脚本都在服务端中

PackageHandler(处理了粘包问题

ReceiveData

public void ReceiveData(byte[] data,int offset,int count)
{//用流来接收
    if(stream.Position + count > stream.Capacity)
    {//接收后判断是否超容量;本地缓冲是否够
        throw new Exception("PackageHandler write buffer overflow");
    }
    //够则写进流中
    stream.Write(data, offset, count);
    //在做数据包的解析
    ParsePackage();
}
ParsePackage

把网络收下的所有数据包调用Protobuf的协议

做封包的处理;若包没收完或不完整或收的多连在一起//粘包

bool ParsePackage()
{
    if (readOffset + 4 < stream.Position)
    {
        int packageSize = BitConverter.ToInt32(stream.GetBuffer(), readOffset);
        if (packageSize + readOffset + 4 <= stream.Position)
        {//包有效
            //解包UnpackMessage//Protobuf的方法
            SkillBridge.Message.NetMessage message = UnpackMessage(stream.GetBuffer(), this.readOffset + 4, packageSize);
            if (message == null)
            {
                throw new Exception("PackageHandler ParsePackage faild,invalid package");
            }
            MessageDistributer<T>.Instance.ReceiveMessage(this.sender, message);
            this.readOffset += (packageSize + 4);
            return ParsePackage();
        }
    }

    //未接收完/要结束了
    if (this.readOffset > 0)
    {
        long size = stream.Position - this.readOffset;
        if (this.readOffset < stream.Position)
        {
            Array.Copy(stream.GetBuffer(), this.readOffset, stream.GetBuffer(), 0, stream.Position - this.readOffset);
        }
        //Reset Stream
        this.readOffset = 0;
        stream.Position = size;
        stream.SetLength(size);
    }
    return true;
}
public static SkillBridge.Message.NetMessage UnpackMessage(byte[] packet,int offset,int length)
{
    SkillBridge.Message.NetMessage message = null;
    using (MemoryStream ms = new MemoryStream(packet, offset, length))
    {//解包
        message = ProtoBuf.Serializer.Deserialize<SkillBridge.Message.NetMessage>(ms);
    }
    return message;
}
//关于消息分配(common中

两个接口:

分发响应;给客户端用

分发请求;给服务器用

MessageDispatch<T>接口调用分发

            if (message.userRegister != null) { MessageDistributer<T>.Instance.RaiseEvent(sender, message.userRegister); }

消息分配知道消息该分发给谁;具体的分发工作让分发器做

//关于消息分发(服务器通讯

有订阅Subscribe:要想知道消息需要订阅,有消息则会发送,不订阅则不会收到

取消订阅Unsubscribe

//服务器通讯使用了多线程;客户端用单线程

在Start方法中有线程数

public void Start(int ThreadNum)
{
    this.ThreadCount = ThreadNum;
    if (this.ThreadCount < 1) this.ThreadCount = 1;
    if (this.ThreadCount > 1000) this.ThreadCount = 1000;
    Running = true;
    for (int i = 0; i < this.ThreadCount; i++)
    {//使用了线程池
        ThreadPool.QueueUserWorkItem(new WaitCallback(MessageDistribute));
    }
    while (ActiveThreadCount < this.ThreadCount)
    {
        Thread.Sleep(100);
    }
}
 服务端GameServer

//涉及多线程模型处理网络收发;基本的接收有基本的接收和收发

TcpSocketListener
        private Socket listenerSocket;

Socket跟客户端一样声明一个变量;

Start中New完绑定在一个端口上并且监听

public void Start()
{
    lock (this)
    {
        if (!IsRunning)
        {
            listenerSocket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
            listenerSocket.Bind(endPoint);
            listenerSocket.Listen(connectionBacklog);
            BeginAccept(args);
        }
        else
            throw new InvalidOperationException("The Server is already running.");
    }

}

服务端有一个监听,接收,再是数据的收发

//服务端监听时确认是否有客户端连接过来;连过来一个客户端会执行接受,接收后客户端才能开始通讯

区别

客户端创建网络对象直接发送连接,连接到服务器;

服务器先让自己绑定在一个端口上(从某个端口监听;客户端连接过来之后服务器就接受;连接正常建立;双方可以正常通讯;

//若服务器不接受客户端无法发信息给服务器

//端口:不同程序在同一个IP地址和同一个网卡下面进行数据传输

 private void OnSocketAccepted(object sender, SocketAsyncEventArgs e)
 {//这里使用了异步编程
     SocketError error = e.SocketError;
     if (e.SocketError == SocketError.OperationAborted)
         return; //Server was stopped

     if (e.SocketError == SocketError.Success)
     {//若服务器被接受,这里会传进来是哪个客户端
         Socket handler = e.AcceptSocket;//这里是客户端对应的Socket
         OnSocketConnected(handler);
     }

     lock (this)
     {
         BeginAccept(e);
     }
 }

//关于扩展方法类ExtensionMethods

对某个类做扩展可以产生一种额外的方法//这里没有具体讲

//关于网络连接NetConnection

定义了一个连接,有效管理客户端和服务端的通讯(客户端连入服务端则会创建一个连接;若网络中断,Connection也会中断;

Connection中会有SendData,BeginReceive等

在监听器中

 private void BeginAccept(SocketAsyncEventArgs args)
 {
     args.AcceptSocket = null;
     listenerSocket.AcceptAsync(args);
     /*listenerSocket.InvokeAsyncMethod(new SocketAsyncMethod(listenerSocket.AcceptAsync)
         , OnSocketAccepted, args);*/
 }

//接上面的若服务器被接受,进入OnSocketConnected

private void OnSocketConnected(Socket client)
{
    if (SocketConnected != null)
        SocketConnected(this, client);
}

SocketConnected注册给了NetService

//对用户提供服务:Service

NetService是从这里开始启用的

3d06d73ddde046c3a03318d2b03f9e61.png

GameServer是从Program来的
//前边是配置文件,然后是New GameServer->New NetService->定8000端口->Init(int port)中创建监听器(在NetService中

406d4b2f80ff46cbb7c4b2ef3f65ebc1.png

NetService中的Init:
static TcpSocketListener ServerListener;
public bool Init(int port)
{
//创建一个监听器
    ServerListener = new TcpSocketListener("127.0.0.1", GameServer.Properties.Settings.Default.ServerPort, 10);
//事件和回调;
    ServerListener.SocketConnected += OnSocketConnected;
    return true;
}
private void OnSocketConnected(object sender, Socket e)
{
    //在 OnSocketConnected中,如果一个服务器连接上了一个客户端,则查看客的IP
    IPEndPoint clientIP = (IPEndPoint)e.RemoteEndPoint;
    //可以在这里对IP做一级验证,比如黑名单

    SocketAsyncEventArgs args = new SocketAsyncEventArgs();
    NetSession session = new NetSession();

    //若验证通过,创建一个网络连接;网络连接中也有一个回调,断开时发生DisconnectedCallback,接受数据时发生DataReceivedCallback
    NetConnection<NetSession> connection = new NetConnection<NetSession>(e, args,
        new NetConnection<NetSession>.DataReceivedCallback(DataReceived),
        new NetConnection<NetSession>.DisconnectedCallback(Disconnected), session);


    Log.WarningFormat("Client[{0}]] Connected", clientIP);
}
static void DataReceived(NetConnection<NetSession> sender, DataEventArgs e)
{
    Log.WarningFormat("Client[{0}] DataReceived Len:{1}", e.RemoteEndPoint, e.Length);
    //由包处理器处理封包
    lock (sender.packageHandler)
    {
        sender.packageHandler.ReceiveData(e.Data, 0, e.Data.Length);
    }
    //PacketsPerSec = Interlocked.Increment(ref PacketsPerSec);
    //RecvBytesPerSec = Interlocked.Add(ref RecvBytesPerSec, e.Data.Length);
}
NetService的使用:

创建一个监听器,监听器提供一个事件,

当客户端连上来了,新建一个连接,给连接两个回调,

收到数据封包处理器

//Start时把刚才创建的监听器启动了

 public void Start()
 {
     //启动监听
     Log.Warning("Starting Listener...");
     ServerListener.Start();

     MessageDistributer<NetConnection<NetSession>>.Instance.Start(8);
     Log.Warning("NetService Started");
 }

//因为消息分发器在服务端,客户端不需要如此调用;

//服务端使用了多线程因此可以Start,把线程数传入(以上代码启用了8个线程数来做消息分发

//因为大部分的CPU都是8核的;每个核心分在一个线程上是最有效率的(因此一般按照CPU核心的整数倍设置线程数

关于服务端的分发器

启动和停止

public void Start()
{
    //启动监听
    Log.Warning("Starting Listener...");
    ServerListener.Start();

    MessageDistributer<NetConnection<NetSession>>.Instance.Start(8);
    Log.Warning("NetService Started");
}


public void Stop()
{
    Log.Warning("Stop NetService...");

    ServerListener.Stop();

    Log.Warning("Stoping Message Handler...");
    MessageDistributer<NetConnection<NetSession>>.Instance.Stop();
}
在UserService中
public UserService()
{
    //创建一个分发器的单例然后执行订阅;这里订阅登录消息,这是登陆消息的处理器:UserLoginRequest
    MessageDistributer<NetConnection<NetSession>>.Instance.Subscribe<UserLoginRequest>(this.OnLogin);
    MessageDistributer<NetConnection<NetSession>>.Instance.Subscribe<UserRegisterRequest>(this.OnRegister);
}

用this.OnLogin方法执行收到的UserLoginRequest消息;

//OnLogin方法+有了以上的一行订阅->只要有消息来服务器,OnLogin中的

NetMessage message = new NetMessage();

就会被调用

//收到消息后给客户端回发;即New一个NetMessage;填充信息;把信息打包封装成字节流;发给客户端

OnLogin.cs

void OnLogin(NetConnection<NetSession> sender,UserLoginRequest request)
{
    Log.InfoFormat("UserLoginRequest: User:{0}  Pass:{1}", request.User, request.Passward);

    NetMessage message = new NetMessage();
    message.Response = new NetMessageResponse();
    message.Response.userLogin = new UserLoginResponse();

   

    TUser user = DBService.Instance.Entities.Users.Where(u => u.Username == request.User).FirstOrDefault();
    if(user==null)
    {
        message.Response.userLogin.Result = Result.Failed;
        message.Response.userLogin.Errormsg = "用户不存在";
    }
    else if(user.Password != request.Passward)
    {
        message.Response.userLogin.Result = Result.Failed;
        message.Response.userLogin.Errormsg = "密码错误";
    }
    else
    {
        sender.Session.User = user;//将当前会话的用户设置为user

        message.Response.userLogin.Result = Result.Success;//设置响应结果;表示操作成功
        message.Response.userLogin.Errormsg = "None"; 
        //初始化获取了用户ID,玩家信息和角色信息
        message.Response.userLogin.Userinfo = new NUserInfo();
        message.Response.userLogin.Userinfo.Id = 1;
        message.Response.userLogin.Userinfo.Player = new NPlayerInfo();
        message.Response.userLogin.Userinfo.Player.Id = user.Player.ID;

        foreach(var c in user.Player.Characters)
        {//从已经创建的角色中找
            NCharacterInfo info = new NCharacterInfo();
            info.Id = c.ID;
            info.Name = c.Name;
            info.Class = (CharacterClass)c.Class;
            //把数据库当前已经有的填充到协议中发给客户端
            message.Response.userLogin.Userinfo.Player.Characters.Add(info);//添加角色到响应
        }
        
    }
    byte[]  data = PackageHandler.PackMessage(message);
    sender.SendData(data, 0, data.Length);
}
总结!在服务器中信息的收发

1.UserService中的订阅一行:保证能收到来自客户端的信息

2.订阅方法中的ae165ed204d54dcba86b61b9eb132c63.png

保证把消息发给客户端

//这两处代码中间//对应例如登录消息的逻辑:查连接数据库,有无用户名和密码之类 ;把结果告诉客户端

//这些都在UserService中

手动演示

启动服务器

GameService中添加

aae6d6aaf4544841ab98332a53ce999a.png

fc3ebe422f7f489ca896f3fcf92198a0.png

添加服务器

4370b8db579e41fa90b0810b2c9fbde5.png

点击启动~~

6af668c2398e4a79800dda56962de27f.png

b73d72ad7a86498f8bc70c1921d5c2a2.png

发现没有启动数据库

启动数据库SSMS

91b931c4156f452db158f37aa9719ae9.png

把DBService注释掉就不会连DB了

再启动服务端

8c72b7121f2c424a9907a50874c6de24.png

//可以看到8个分发线程已经启动;NetService网络服务也已经启动

//输入Exit退出

启动客户端

新建一个场景,新建一个脚本挂在摄像机上

在脚本里面使用net Client连接

Connect

b136ad0b345544abb72e573f904a33c6.png

把网络客户端绑上来

f0d90ecf811e4e249917fbb6c9af4709.pngec3af020baec4296a0cff1989259e757.png

Unity启动~~

25c8fe1f59ff49059fbfb8b014b76386.png

发现没有连网络地址address

初始化IP,端口做连接

7f809497c9324a899bd017d6994bca66.png

//关于IP地址:

若用多台电脑(服务端和客户端不在同电脑上

Network.NetClient.Instance.Init("****", 8000);

****位置表示的是连接到的服务端的IP地址

再启动~客户端和服务器显示

18a218702d11480caadcc09bf888b101.png

连接上IP和端口;连接到服务器

f4602ba0243c49718429a8b355cc9658.png

这里客户端端口34062已经连接到服务器

关掉Unity服务端

44aa0d10a3b84436b40a120f75b1a120.png

后面这条显示服务端断开

总结!
客户端连接到服务器需要的代码:
Network.NetClient.Instance.Init("127.0.0.1", 8000);
Network.NetClient.Instance.Connect();
服务器完成一个服务的监听需要:

声明一个NetService;

New;

指定监听的端口号;

执行一次Start;

(结束时需要执行Stop;

0854849971a548b8813ab218bcdf6ba8.png

用特定协议发消息
打开proto

f6220a6542714babb9a85351b1a8c955.png

在里面添加一个测试消息

9a4ed5dfbf434e87882282f9b3ea3484.png

添加一条完整的新协议

504a26af859a48abb0ca72adac857ccf.png

保存

点击.cmd文件生成协议

1abeff201bac4e69abbc301b36ad0b23.png

在服务器选中解决方案生成

a56b9e844c7b4faf9e3e0f67bebee5ac.png

f08908dda1e4497e89cdfeff4ae1f6ac.png

把服务端生成的协议放在客户端下

864af991e7d443c594669c9f6b3525f0.png

401d5df397884930b71600433e5c01ab.png

打开客户端的代码(exLogin.cs)

//所有协议的前缀:SkillBridge.Message

创建主消息

创建自定义消息

自定义消息填充数据

调用Send发送

//完整的客户端:

3c3135b18ea14ca4aae2d580fe2b9860.png

服务端添加一个HelloWorld

b3e7b42720d547a4b27ce1d618d6f669.png

里面有init,Start,Stop

服务端接收:OnFirstTestRequest

把Service写成单例

2a9bbc0505d745fe96058d2190ca1988.png

//这里的Sender可以知道连进来的客户端,发送了什么数据...

GameServer添加这两

24d95f27a8f54687bc0afde27e02d33d.png

启动~~

31eb1660b25f4c86a0b1166b819bd7f6.png

服务端收到了DataReceived

但是消息“Helloworld!!!!"没有执行

消息分发

在消息分配器里面没有分发器负责把消息分发到服务器

在Request中添加一行

852dfdb20b2a4ebfa39d5beaca0e7f9c.png

生成解决方案再启动服务端

f10b6c562a8d42859ac0b9f91011aa37.png

//于是我们的helloWorld就水灵灵的出现了~

总结!
服务端:

的总结在上面

客户端:

1.遵循所有的网络请求都有Service负责,构建一个HelloWorldService,构建一个消息处理函数用来处理网络消息

2.处理的消息在Start里订阅;告诉网络底层,要用这个方法OnFirstTestRequest处理协议FirstTestRequest;OnFirstTestRequest是协议的处理器

3.在Game Server中要启动Init,Start

加协议:

1.打开proto生成新协议

2.生成解决方案

3.复制到服务端

处理消息:

在分发器中Add一行

解答:

客户端连接成功服务器;服务器完成一个服务的监听

在客户端的代码中选定消息填充数据,发送

//若是添加了特定消息要在Gameserver中启用;以及添加消息分发语句

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值