远程控制项目笔记

项目简介:

完成客户端(控制端)+服务端(被控端)的开发,客户端主要包括:磁盘及文件信息的获取;文件下载;监控、锁定和解锁对方屏幕等功能,服务端实现开机自动运行功能。

部分代码整理:

1,数据包的封装

包设计简图
在这里插入图片描述

CPacket(const BYTE* pData, size_t& nSize)
{
    size_t i = 0; //i:标尺
    /*解析包头*/
	for (; i < nSize; i++)
	{
		if (*(WORD*)(pData + i) == 0xFEFF)
		{
			sHead = *(WORD*)(pData + i);//设置包头的值
			i += 2;//i后移2字节,处理下一数据内容(读取数据的长度)
			break;
		}
	}
	/*nSize若小于包头(2)+包长(4)+命令(2)+和校验(2)的长度,说明解析失败,因为还没有一个基本的包长*/
	if (i + 4 + 2 + 2 > nSize)
	{
		nSize = 0;
		return;
	}
	
	/*读取包数据的长度*/
	nLength = *(DWORD*)(pData + i);
	i += 4;//i后移4字节,处理下一数据内容(解析命令)
	if (nLength + i > nSize)
	{//nSize若小于包数据的长度+(包头2字节+写有包数据长度的4字节),说明解析失败,因为还没有一个基本的包长
		nSize = 0;
		return;
	}
	
	/*解析命令*/
	sCmd = *(WORD*)(pData + i);  //这里的两个字节为命令,
	i += 2;  //i后移2字节,处理下一数据内容(解析数据)
	if (nLength > 4)
	{
		strData.resize(nLength - 2 - 2);//数据的长度=包数据长度-命令(2)-和校验(2)
		memcpy((void*)strData.c_str(), pData + i, nLength - 4); //拷贝数据内容到strData
		i += nLength - 4;//i后移nLength-4字节,处理下一数据内容(解析和校验)
	}

    /*解析和校验*/
	sSum = *(WORD*)(pData + i);
	i += 2;//i后移2字节,此时i指向包尾处
	WORD sum = 0;
	for (size_t j = 0; j < strData.size(); j++)  
		sum += BYTE(strData[j]) & 0xFF;//求和
	if (sum == sSum)//数据接收成功
	{
		nSize = i; //一个完整包的大小
		return;
	}

    /*解析失败 返回0*/
	nSize = 0;
}

2,线程同步几种方式的分析

a.互斥
在这里插入图片描述
该机制下,线程访问公共变量需先lock并在使用结束后unlock,此时如果有一个未lock就直接访问该变量的线程,则该机制无效。所以互斥依赖于开发人员的编程;其次线程1lock变量后,若此时线程2也需要访问变量,则此时的线程2会阻塞直至线程1unlock,也就是说“同时”变成了“排队”,降低了效率,且线程数越多,对效率的影响越显著。

b.消息
WM_XXX的参数WPARAM和LPARAM,在传递的过程,变量的值是被复制到了参数中,所以就不存在lock及unlock的问题,在同一对话框内SendMessage,跨线程的PostThreadMessage。但消息的缺点是参数能携载的数据量有限,而且其依赖于消息队列。

c.网络
单机程序也有网络(回环网络,127.0.0.1,本机网络)。优点:速度快;可服务多个请求;无需关注队列,消息的队列是利用软件处理的,而网络的队列则是利用硬件(网卡)来处理的,效率完全不一样,几乎无需关注后者;完成端口映射(epoll / IOCP)可以继续提升效率。

3,利用IOCP实现一个简单的线程安全的队列

大致流程为:①创建一个完成端口对象(该对象由操作系统接管)句柄,通过句柄与内核沟通;②创建一个线程,该线程负责处理队列。
在这里插入图片描述

//操作枚举值
enum
{
	IocpListEmpty=0,
	IcopListPush=1,
	IocpListPop=2,
}

struct IOCP_PARAM
{
	int Opr;//操作
	std::string strData;//数据
	_beginthread_proc_type cbFun;//回调

	IOCP_PARAM() { Opr=-1; }
	IOCP_PARAM(int inOpr,const char* inData,_beginthread_proc_type inCbFun=NULL)
	{
		Opr=inOpr;
		strData=inData;
		cbFun=inCbFun;
	}
}

void threadMain(HANDLE hIOCP)
{
	std::list<std::string> lstString;
	DWORD dwTransferred=0;
	ULONG_PTR CompletionKey=0;
	OVERLAPPED* pOverlapped=NULL;
	//获取完成端口的状态
	while(GetQueuedComletionStatus(hIOCP,&dwTransferred,
	     &CompletionKey,&pOverlapped,INFINITE))
	{
		if(dwTransferred==0 || CompletionKey==NULL)
		{
			printf("Thread is prepare to exit!\r\n");
			break;
		}
		IOCP_PARAM* parm=(IOCP_PARAM*)CompletionKey;
		//push操作
		if(parm->Opr==IocpListPush)
			lstString.push_back(parm->strData);
		//pop操作	
		if(parm->Opr==IocpListPop)
		{
			std::string* pStr=NULL;
			if(lstString.size()>0)
			{
				pStr=new std::string(lstString.front());
				lstString.pop_front();
			}
			if(parm->cbFun)
				parm->cbFun(pStr);
		}
		//empty操作
		if(parm->Opr==IocpListEmpty)
			lstString.clear();	
		delete parm;
	}
}

void threadQueueEntry(HANDLE hIOCP)
{
	threadMain(hIOCP);

	//代码到此为止,导致本地对象无法调用析构,进而内存泄漏
	_endthread();
}

void Fun(void* arg)
{
	std::string* pStr=(std::string*)arg;
	if(pStr!=NULL)
	{
		printf("Pop from list,value is : %s",pStr->c_str());
		delete pStr;
	}
	else
		printf("List is empty!");
}

int main()
{
	HANDLE hIOCP=INVALID_HANDLE_VALUE;
	//创建一个完成端口对象
	hIOCP=CreateIoCompletionPort(INVALID_HANDLE_VALUE,NULL,NULL,1);//因为是队列,所以线程数传1
	//将完成端口对象传给线程
	HANDLE hThread=(HANDLE)_beginthread(threadQueueEntry,0,hIOCP);

	ULONGLONG tick=GetTickCount64();
	while(_kbhit()==0)//完成端口 把请求和实现分离了
	{
		//投递状态
		if(GetTickCount64()-tick>1300)
		{
		    PostQueuedComletionStatus(hIOCP,sizeof(IOCP_PARAM),
		        (ULONG_PTR)new IOCP_PARAM(IocpListPop,"Hello World",Fun),NULL);
		}
		if(GetTickCount64()-tick>2000)
		{
		    PostQueuedComletionStatus(hIOCP,sizeof(IOCP_PARAM),
		        (ULONG_PTR)new IOCP_PARAM(IocpListPush,"Hello World"),NULL);
		    tick=GetTickCount64();
		}
		Sleep(1);
	}
	
	if(hIOCP!=NULL)
	{
		PostQueuedComletionStatus(hIOCP,0,NULL,NULL);
		WaitForSingleObject(hThread,INFINITE);
	}

	CloseHandle(hIOCP);
}

该实现主要是方便刚接触IOCP的理解。当前写法还有一个逻辑漏洞,当在WaitForSingleObject执行时,hIOCP还有效,此时若有线程投递数据,则会导致内存泄漏。

在threadQueueEntry中又单独调用threadMain的原因:当调用_endthread时,代码的执行就到这里了,后面的内容不会继续被执行(或调用),所以当代码都写在threadQueueEntry中时,在退出的那一下就会导致内存泄漏,因为本地对象无法去调用析构。而将该部分代码单独拿出去做一个函数,当函数执行结束的时候会,本地对象会去调用析构函数,继而解决了内存泄漏的问题。

4,重叠结构(OVERLAPPED)

大致流程:
在这里插入图片描述

使用重叠结构要将初始化稍稍修改一下,

bool Init()
{
	//normal
	WSADATA data;
	if( WSAStartup(MAKEWORD(1,1),&data)!=0 )
		return false;
	return true;

	//unnormal,use overlapped
	WSADATA data;
	if( WSAStartup(MAKEWORD(2,0),&data)!=0 )
		return false;
	return true;
}
class COverLapped
{
public:
	//必须把它放在最前面
	OVERLAPPED m_overlapped;
	DWORD      m_opr;//操作值 或者命令
	char       m_buffer[4096];
	COverLapped()
	{
		m_opr=0;
		memset(&m_overlapped,0,sizeof(m_overlapped));
		memset(&m_buffer,0,sizeof(m_buffer));
	}
}

enum
{
	opr_Accept=1,
	opr_Send=2,
	opr_Recv=3,
	//other opr value...
}

void testOverLapped()
{
    //正常用法
	SOCKET sock_normal=socket(AF_INET,SOCK_STREAM,0);
	//使用重叠结构的用法
	SOCKET sock_unnoemal=WSASocket(AF_INET,SOCK_STREAM,0,NULL,0,WSA_FLAG_OVERLAPPED);
	if(sock_unnormal==INVALID_SOCKET)
		return;
	HANDLE hIOCP=CreateIoCompletionPort(INVALID_HANDLE_VALUE,NULL,sock_unnormal,4);
	//绑定IOCP与套接字
	CreateIoCompletionPort((HANDLE)sock_unnormal,hIOCP,0,0);
	SOCKET client=WSASocket(AF_INET,SOCK_STREAM,0,NULL,0,WSA_FLAG_OVERLAPPED);
	
	sockaddr_in addr;
	addr.sin_family=PF_INET;
	addr.sin_addr.s_addr=inet_addr("0.0.0.0");
	addr.sin_port=htons(9527);
	
	bind(sock_unnormal,(sockaddr*)addr,sizeof(addr));
	listen(sock_unnormal,5);
	
	COverLapped overlapped;
	overlapped.m_opr=opr_Accept;
	memset(&overlapped,0,sizeof(OVERLAPPED));
	DWORD received=0;
	if(!AcceptEx(sock_unnormal,client,overlapped.m_buffer,0,sizeof(sockaddr_in)+16,sizeof(sockaddr_in)+16,&received,&overlapped.m_overlapped))
		return;
	
	//todo:开启一个线程
	
	//代表一个线程
	while(true)
	{
		DWORD dwTransferred=0;
	    DWORD CompletionKey=0;
	    LPOVERLAPPED pOverlapped=NULL;
	    if(GetQueuedCompletionStatus(hIOCP,&dwTransferred,&CompletionKey,&pOverlapped,INFINITY))
	    {
			COverLapped* ptrOL = CONTAINING_RECORD(pOverlapped,COverLapped,m_overlapped);
			switch(ptrOL->m_opr)
			{
			case opr_Accept:
				//todo:处理accept
			case opr_Send:
				//todo:处理send
			case opr_Recv:
				//todo:处理recv
			}
	    }
	}
}

5,线程类和线程池类的设计与实现

为什么要设计线程池?代码中所提到的线程,其主要任务就是不断的去GetQueuedCompletionStatus,而IOCP是可以达到上万的并发量的,如果每一个请求都在该线程里处理,就已经失去了使用IOCP的意义。所以正确的应该是,当线程Get到一个状态之后,就立刻将工作分给一个新的线程,从而继续执行下一次的Get,至于被分配到工作的线程,如何处理业务,需要花多久处理业务,这并不是当前这个主线程需要去关注的。
在这里插入图片描述

class ThreadFuncBase{};
typedef int (ThreadFuncBase::* FuncType)();
class ThreadWorker
{
public:
	ThreadWorker():thiz(NULL),func(NULL) {}
	ThreadWorker(ThreadFuncBase* obj,FuncType func);
	ThreadWorker(const ThreadWorker& worker);
	ThreadWorker& operator=(const ThreadWorker& worker);

	int operator()()
	{
		return (isValid()?(thiz->*func)():-1);
	}
	bool isValid()
	{
		return (thiz!=NULL)&&(func!=NULL);
	}
private:
	ThreadFuncBase* thiz;//ThreadFuncBase成员对象的指针
	FuncType func;//ThreadFuncBase成员函数的指针
}

class MyThread
{
public:
	MyThread() { m_hThread=NULL; }
	~MyThread() { Stop(); }
	//启动线程 true:启动成功 false:启动失败
	bool Start();
	//是否有效 true:有效 false:线程异常或已终止
	bool isValid()
	{
		//如果句柄为空会返回错误;如果句柄已结束会返回WAIT_ABANDONED
		return WaitForSingleObject(m_hThread,0)==WAIT_TIMEOUT;
	}
	//关闭线程
	bool Stop();
	//更新工作
	void updateWorker(const ::ThreadWorker& worker=::ThreadWorker())
	{
		m_worker.store(worker);
	}
	//是否空闲 true:空闲 可以分配工作 false:非空闲 已经被分配了工作
	bool isIdle() 
	{ 
		return !m_worker.load().isValid(); 
	}
private:
	void threadWorker()
	{
		while(m_bStatus)
		{
			::ThreadWorker worker=m_worker.load();
			if(worker.isValid())
			{
				int iRet=worker();
				if(iRet!=0)
					//打印警告日志
				if(iRet<0)//出现问题时 就设置一个无效的
					m_worker.store(::ThreadWorker());
			}
			else
				Sleep(1);
		}
	}
	static void threadEntry(void* arg)
	{
		MyThread* thiz=(MyThread*)arg;
		if(thiz)
			thiz->threadWorker();
		_endthread();
	}
private:
	HANDLE m_hThread;
	bool   m_bStatus;//false:线程将要关闭,true:线程正在运行
	std::atomic<::ThreadWorker> m_worker;
}

void MyThreadPool
{
public:
	MyThreadPool();
	MyThreadPool(size_t size) { m_threads.resize(size); }
	~MyThreadPool();

	bool Invoke();
	void Stop();
	//分配工作 返回值为将工作分给了第几个线程,若所有线程都忙则返回-1
	int dispatchWorker(const ThreadWorker& worker)
	{
		m_lock.lock();
		int index=-1;
		for(size_t i=0;i<m_threads.size(),++i)
		{
			if(m_threads[i].isIdle())
			{
				m_threads[i].updateWorker(worker);
				index=i;
				break;
			}
		}
		m_lock.unlock();
		return index;
	}
private:
	std::vector<MyThread> m_threads;
	std::mutex m_lock;
}

单独封装一个ThreadWoker类的意义是,在MyThread中threadWoreker这个线程函数与要执行的内容分离,由用户继承ThreadFuncBase类,然后在创建一个ThreadWoker对象,通过MyThread类的updateWorker拿到要执行的内容就可以开始工作。这样做的好处是,当某线程执行完任务后,无需再去关闭该线程,因为当再次分配工作时还需要再创建线程,而创建线程是要耗费时间的,其执行结束后将其置空,并依然保留它在while中,当updateWorker拿到工作后,可以快速的去执行。

6,UDP穿透

a.原理
原理其实并没有很复杂,UDP的穿透利用了公网在公共服务器的IP固定的特性,以及UDP协议的可主动发送请求且无需应答的特性。
在这样的条件下,就可以利用UDP建立与公共服务器的连接,在通过公共服务器返回IP和端口来建立与另一客户端的连接。
在此过程中,公共服务器是必须存在的,并且与公共服务器的连接不可中断。
b.网络环境
普通PC机器连接到互联网时,都需要先经过运营商(拨号)连接到互联网的域名服务器,由域名服务器转发请求致实机服务器,进而实现内外网连接。
在这里插入图片描述
在内网(局域网)中,同一时间段内,无论局域网有多少联网设备,对于外网而言都是同一个临时IP;同一局域网内的设备连接外网时,IP是相同的,端口是不同的;同一局域网内的联网设备都是共用局域网内同一个临时的IP。
c.问题
基于网络环境情况,会面临以下问题:
①因为局域网IP是临时的,即会发生变化,因此其他机器想要通过IP主动连接到PC端就变得很困难;
②猫和路由器默认是不允许外网IP主动连接内网的;
③即使猫或路由器可以将IP发到公网上,使得目标机器可以连接到你,那么同样其他机器也可以连接到你,由此会引发数据泄露等安全问题。
d.协议的分析
在这里插入图片描述
基于网络环境引出的问题,在对通信协议分析后可以确定,需要使用UDP协议,因为它近乎完美的解决了上述问题。
e.解决方案
①临时IP的问题:需要在公网中有一个公共服务器,通过PC向服务器发起UDP请求,并从服务器获取到应答包后建立连接。在连接之后到结束前,PC端的IP和端口不会改变。
②猫和路由器的防火墙:在PC主动发起连接请求时,防火墙其实是会留一个通道来接收服务器的应答包,以建立连接的。但要注意,必须是由PC主动发起请求且收到应答包后才可以确认通道已打开。
③安全性问题:因为防火墙的机制,外网是无法主动向PC发起请求的,防火墙会将这些请求拦截在外。
④主端与从端的连接:由于主端与从端都以与公共服务器建立连接,所以公共服务器就同时拥有了主端与从端的IP和端口。只要服务器将IP和端口分别发送给对方,这样双方就可以直接建立连接。但即使是在建立连接后,双方也不可与服务器中断连接,因为①中已说明,一旦与服务器的连接断开,局域网内的IP和端口就会发生变化。
在这里插入图片描述

void udp_server()
{
    SOCKET sock=socket(PF_INET,SOCK_DGRAM,0);
    if(sock==INVALID_SOCKET)
    	return;
    sockaddr_in server,client;
    memset(server,0,sizeof(server));
    memset(client,0,sizeof(client));
    server.sin_family=AF_INET;
    server.sin_port=htons(20000);
    server.sin_addr.s_addr=inet_addr("127.0.0.1");
}

void udp_client(bool ishost)
{
    Sleep(2000);
    sockaddr_in server, client;
    int len = sizeof(client);
    server.sin_family = AF_INET;
    server.sin_port = htons(20000);  //udp的端口最好取在20000-40000之间
    server.sin_addr.s_addr = inet_addr("127.0.0.1");
    SOCKET sock = socket(PF_INET, SOCK_DGRAM, 0);
    if (sock == INVALID_SOCKET)
    	return;
    if(ishost)  //主客户端代码
    {
        EBuffer msg = "Hello World!\n";
        int ret = sendto(sock, msg.c_str(), msg.size(), 0, (sockaddr*)&server, sizeof(server));
        if (ret > 0)
        {
            msg.resize(1024);
            memset((char*)msg.c_str(), 0, msg.size());
            //收服务器发来的数据
            ret = recvfrom(sock, (char*)msg.c_str(), msg.size(), 0, (sockaddr*)&client, &len);
            if (ret > 0)
            	//打印获取到的数据
            //收另一个客户端发来的数据
            ret = recvfrom(sock, (char*)msg.c_str(), msg.size(), 0, (sockaddr*)&client, &len);
            if (ret > 0)
            	//打印获取到的数据
        }
    }
    else  //从客户端代码
    {
        std::string msg = "Hello World!\n";
        int ret = sendto(sock, msg.c_str(), msg.size(), 0, (sockaddr*)&server, sizeof(server));
        if (ret > 0)
        {
            msg.resize(1024);
            memset((char*)msg.c_str(), 0, msg.size());
            ret = recvfrom(sock, (char*)msg.c_str(), msg.size(), 0, (sockaddr*)&client, &len);
            if (ret > 0)
            {
                sockaddr_in addr;
                memcpy(&addr, msg.c_str(), sizeof(addr));
                sockaddr_in* paddr = (sockaddr_in*)&addr;
                msg = "Hello,I am client!\n";
                ret = sendto(sock, (char*)msg.c_str(), msg.size(), 0, (sockaddr*)paddr, sizeof(sockaddr_in));
            }
        }
    }
    closesocket(sock);
}

int main(int argc,char* argv[])
{   
	InitSock();//socket初始化
    
    if (argc == 1)
    {
        char wstrDir[MAX_PATH];
        GetCurrentDirectoryA(MAX_PATH, wstrDir); //取得当前进程的路径
        STARTUPINFOA si;   
        PROCESS_INFORMATION pi;
        memset(&si, 0, sizeof(si));
        memset(&pi, 0, sizeof(pi));
        std::string strCmd = argv[0];//strCmd为该程序的路径
        strCmd += " 1";   //路径后面加个数字1做区分,用于开启一个新进程
        BOOL bRet = CreateProcessA(NULL, (LPSTR)strCmd.c_str(), NULL, NULL, FALSE, 0, NULL, wstrDir, &si, &pi);//创建一个新进程
        if (bRet)
        {
            CloseHandle(pi.hThread);
            CloseHandle(pi.hProcess);
            TRACE("进程ID : %d\n", pi.dwProcessId); 
            TRACE("线程ID : %d\n", pi.dwThreadId);
            strCmd += " 2";  //然后在刚才的基础上 在路径名字后面再加个数字2,用于再次开启一个新进程
            bRet = CreateProcessA(NULL, (LPSTR)strCmd.c_str(), NULL, NULL, FALSE, 0, NULL, wstrDir, &si, &pi);//创建一个新进程
            if (bRet)   //若创建成功
            {
                CloseHandle(pi.hThread);
                CloseHandle(pi.hProcess);
                TRACE("进程ID : %d\n", pi.dwProcessId);
                TRACE("线程ID : %d\n", pi.dwThreadId);
                udp_server();// 服务器代码,开始启动服务器服务端
            }
        }
    }
    else if (argc == 2)//主客户端代码
    	udp_client();
    else 
    	udp_client(false);//从客户端代码
    
    ClearSock();//清理socket
}

7,管理员权限和开机启动

a.管理员权限:
vs在编译时可以进行选择:右键项目→属性→链接器→清单文件→UAC执行级别→选择Administrator,选择好之后应用并确定,再重新生成一下该项目,编译出的exe文件便会在右下角多出一个盾牌的图标,即管理员权限。
在这里插入图片描述

b.开机启动
b-1:注册表修改
HKEY_LOCAL_MACHINE→SOFTWARE→Microsoft→Windows→CurrentVersion→Run,这个路径下记录的都是一些开机启动的程序。
在这里插入图片描述
注意程序的路径必须要指向系统目录,一般为system32,即要把欲开机启动的程序复制到system32下面去。但在Windows下使用mklink创建一个软链接会更好。
在这里插入图片描述
软链接与快捷方式的区别:
首先,文件格式不一样,快捷方式后缀为.lnk;
在这里插入图片描述
其次,使用type出的值也不一样,快捷方式是L开头的,软链接的文件则是MZ开头的,其实软链接的文件就是原始的exe文件,是等效的;
在这里插入图片描述
在这里插入图片描述
第三,文件大小不一样;
在这里插入图片描述

bool WriteRegisterTable(const CString& strPath)  //注册表方式完成开机启动需要的函数
{
    CString strSubKey = _T("SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Run");
    TCHAR sPath[MAX_PATH] = _T("");
    GetModuleFileName(NULL, sPath, MAX_PATH);
    BOOL ret = CopyFile(sPath, strPath, FALSE);
    if (ret == FALSE)
    {
        MessageBox(NULL, _T("文件复制失败,是否权限不足?\n"), _T("错误"), MB_ICONERROR | MB_TOPMOST);
        return false;
    }
    HKEY hKey = NULL;
    ret = RegOpenKeyEx(HKEY_LOCAL_MACHINE, strSubKey, 0, KEY_ALL_ACCESS | KEY_WOW64_64KEY, &hKey);
    if (ret != ERROR_SUCCESS)
    {
        RegCloseKey(hKey);
        MessageBox(NULL, _T("设置开机自动启动失败,是否权限不足?\n程序启动失败!"), _T("错误"), MB_ICONERROR | MB_TOPMOST);
        return false;
    }
    ret = RegSetValueEx(hKey, _T("RemoteCtrl"), 0, REG_SZ, (BYTE*)(LPCTSTR)strPath, strPath.GetLength() * sizeof(TCHAR));
    if (ret != ERROR_SUCCESS)
    {
        RegCloseKey(hKey);
        MessageBox(NULL, _T("设置开机自动启动失败,是否权限不足?\n程序启动失败!"), _T("错误"), MB_ICONERROR | MB_TOPMOST);
        return false;
    }
    RegCloseKey(hKey);
    return true;
}

b-2:开机启动的第二种方法
将程序复制到启动的文件夹中,相对第一种修改注册表的方法要简单一些,但这种方法需要等到机器完全启动以后才去启动程序,所以时间上要慢于修改注册表的方式。
在这里插入图片描述

BOOL WriteStartupDir(const CString& strPath)    //直接复制文件到启动文件夹下完成开机启动所需要的函数
{
    TCHAR sPath[MAX_PATH] = _T("");
    GetModuleFileName(NULL, sPath, MAX_PATH);
    return CopyFile(sPath, strPath, FALSE);
}
  • 0
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值