
简介一份用VC设计简单聊天室程序的课程设计文档面向计算机专业学生尤其适合正在做网络编程、MFC应用或Socket通信相关课题的读者。资料围绕TCP/IP协议与网络编程基础、MFC/Visual C开发环境、CSocket类实现客户端/服务器架构展开完整覆盖了需求分析、系统设计、数据库MS SQL 2000设计、程序测试与文档编写等环节。压缩包内仅含1个doc文件大小约287KB轻量易读。目前已有207人学习。文档不仅给出服务器端与客户端的整体设计思路还具体展示了消息转发、私聊功能、在线成员管理等关键模块的代码实现逻辑并附带课程设计任务书和使用说明能帮助读者快速理清聊天室项目的开发流程也可作为类似课程设计的参考模板。1. 用VC做聊天室程序课程设计这题到底在考什么每年都有大量计算机专业的学生拿到“用VC设计简单的聊天室程序课程设计”这个题目看起来是个老掉牙的项目实际上一头扎进去才发现它同时踩了Socket编程、多线程、MFC控件刷新、字符集转换四个雷区。很多人以为聊天室聊天窗口发送按钮结果做到一半卡在“服务端收不到消息”或者“一关窗口程序就崩溃”上。这篇笔记直接把一套能跑通的方案拆开讲先选型再给代码最后把高发问题列清楚。适合正在做课程设计、或者想用VC把网络编程基础补扎实的人照着改就能交差改完还能说清楚每一行在干什么。2. 选型决定成败Winsock、MFC、TCP与UDP怎么搭配2.1 用TCP还是UDP课程设计最常见的二选一聊天室程序的核心是网络通信第一步不是写代码而是选协议。TCP面向连接、可靠、有序适合“一条消息都不能丢”的场景UDP无连接、不可靠、但延迟低、实现简单。课程设计里绝大多数人会选TCP因为聊天室业务天然需要可靠传输而且TCP的服务端模型listen、accept、recv、send在教材里讲得最多答辩时也容易解释。但TCP有一个麻烦每个客户端都要占一个socket服务端需要维护连接列表还得处理断开重连。而UDP广播则简单得多——一个socket绑定端口往广播地址一发同网段所有机器都能收到不用维护连接。如果你的课程设计只要求两个窗口互发消息“能跑通”是底线我一般建议直接看题目要求要求“多客户端同时在线聊天”选TCP要求“局域网内多台机器互相看到消息”选UDP广播。两个方案后面都会给你按需选。2.2 MFC对话框程序为主界面与网络线程的配合方式VC做界面有两条路MFC和Win32 API。课程设计基本都用MFC对话框程序因为它拖控件快编辑框、按钮、列表控件都是现成的。但MFC有个致命特性控件必须在主线程操作。网络收发如果放在主线程一个recv阻塞住整个窗口就卡死表现为“拖不动、点不了、像死机”。所以标准做法是开一个工作线程做网络收发收到数据后用PostMessage把消息投递到主线程再由主线程更新控件。这条设计决定了整个程序的结构主线程管界面工作线程管网络两者之间通过自定义消息传递数据。后面所有代码都围绕这个分工展开。不少同学图省事在按钮点击事件里直接写recv结果窗口假死这就是没理解MFC的线程边界。2.3 最小开发环境VS2017/2019的VC项目设置用Visual Studio 2017或2019都能做安装时勾选“使用C的桌面开发”即可。新建项目时选“MFC应用程序”在向导里选“基于对话框”这样自动生成一个带确定/取消按钮的对话框骨架省去手写WinMain的麻烦。创建完项目后有两处设置必须检查一是项目属性里“字符集”选“使用Unicode字符集”不要选多字节否则后面CString转char*会多一层转换二是“链接器→输入→附加依赖项”里确认有ws2_32.lib这是Winsock库少了它编译会报一堆LNK2019 unresolved external symbol错误。另外如果目标机器没装VC运行库生成的exe需要带上对应的运行库安装包打包时把这几个文件一起拷过去就能在别的电脑上跑。3. 写一个能同时当客服端的TCP聊天室监听、收发与界面联动3.1 一个程序两个角色服务端与客户端的操作流程TCP聊天室有两种组织方式一种是单独写一个服务端程序和一个客户端程序另一种是同一个程序里既包含服务端代码又包含客户端代码。课程设计为了演示方便通常用后者——程序启动后你先点“启动服务”监听端口再点“连接服务器”连到本机或别人的机器上两个按钮都点了消息就能互发。这种方式的好处是只用一份代码、一个工程演示时开两个实例就能模拟两个用户对话答辩现场不用准备两台电脑。角色切换的关键是启动顺序服务端先listen客户端后connect。如果客户端先发起连接而服务端还没启动connect会返回WSAECONNREFUSED错误码10061。我在代码里会把服务端线程也独立出来主线程只负责把界面操作转成命令发给线程避免网络初始化阻塞界面。下面这个函数是服务端初始化的核心片段。3.2 服务端初始化与接收线程绑定、监听、accept循环服务端要干三件事初始化Winsock、绑定并监听端口、循环accept新连接。每accept到一个客户端就开一个线程单独处理它的收包。代码片段如下// 服务端初始化绑定端口并开始监听 bool StartServer(CString strPort) { WSADATA wsaData; // 启动Winsock第二个参数是请求的版本号2.2表示最高支持2.2版 if (WSAStartup(MAKEWORD(2, 2), wsaData) ! 0) { AfxMessageBox(_T(WSAStartup失败)); return false; } // AF_INET是IPv4SOCK_STREAM是TCP流式套接字 // 第三个参数传0让系统根据前两个参数自动选择协议 SOCKET serverSocket socket(AF_INET, SOCK_STREAM, 0); if (serverSocket INVALID_SOCKET) return false; sockaddr_in servAddr; servAddr.sin_family AF_INET; // htons把主机字节序转成网络字节序端口号在这里被转换 servAddr.sin_port htons((u_short)_ttoi(strPort)); // INADDR_ANY表示绑定到本机所有网卡这样局域网其他机器也能连进来 servAddr.sin_addr.S_un.S_addr htonl(INADDR_ANY); // bind失败最常见的原因是端口被占用换一个端口即可 if (bind(serverSocket, (sockaddr*)servAddr, sizeof(servAddr)) SOCKET_ERROR) { AfxMessageBox(_T(bind失败端口可能被占用)); return false; } // listen的第二个参数是等待队列长度课程设计给5就够 if (listen(serverSocket, 5) SOCKET_ERROR) return false; // 开一个线程循环accept不阻塞主线程 AfxBeginThread(AcceptThreadProc, (LPVOID)serverSocket); return true; }这里的关键点是WSAStartup每个进程只调用一次别放在按钮点击里反复调用。AF_INET和SOCK_STREAM的搭配是TCP的标准写法一旦改成SOCK_DGRAM就是UDP。htons和htonl容易混端口用htons16位IP地址用htonl32位。listen的第二个参数设5就够了因为这是并发连接数上限不是客户端总数上限。接收线程的写法是另一个高频出错点。accept会阻塞在等待连接上所以必须用独立线程。每接收一个客户端连接就把新socket传给一个新的工作线程主线程继续回到accept等待下一个连接。如果不这样分线程第二个客户端永远连不上。UINT AcceptThreadProc(LPVOID pParam) { SOCKET listenSocket (SOCKET)pParam; while (TRUE) { sockaddr_in clientAddr; int addrLen sizeof(clientAddr); // 这里阻塞等到有客户端连接时返回一个新的socket // 这个新socket专用于和该客户端通信listenSocket继续监听 SOCKET clientSocket accept(listenSocket, (sockaddr*)clientAddr, addrLen); if (clientSocket INVALID_SOCKET) break; // 用inet_ntoa把网络字节序的IP转成字符串用于显示客户端地址 CString strIP inet_ntoa(clientAddr.sin_addr); AfxBeginThread(RecvThreadProc, (LPVOID)clientSocket); } return 0; }注意inet_ntoa返回的静态缓冲区在多线程下会被覆盖多个客户端同时接入时可能出现IP显示错乱。课程设计里如果只演示两个客户端问题不明显如果想做得严谨改用inet_ntop并传入自己的缓冲区。3.3 客户端连接与消息收发CString与char*的转换客户端部分逻辑简单得多创建socket、connect到服务端IP和端口然后同样开一个接收线程。connect调用会主动触发三次握手失败时返回SOCKET_ERROR这时用WSAGetLastError查错误码10061就是“目标没有在监听”10060是“超时”常见原因是IP填错或防火墙拦截。消息发送时你从编辑框拿到的数据是CString但send函数的第二个参数要求char*。在Unicode字符集下直接把CString强转成LPCTSTR再交给send会得到一串零宽字符因为每个英文字符占两字节。安全的转换写法是使用CW2A宏CString Wide to ANSI它把宽字符转成多字节转换后的指针只能用于本次调用不能保存后用void SendMessageToServer(CString strMsg) { if (clientSocket INVALID_SOCKET) return; // CW2A将Unicode字符串转为多字节编码避免乱码 // 注意这条语句结束后缓冲区就失效了不能存指针必须马上发送 CW2A szAnsiMsg(strMsg); int iLen (int)strlen(szAnsiMsg); // send的第三个参数必须是实际字节数不能用sizeof(CString) // 返回值小于0表示发送失败要检查socket状态 int iRet send(clientSocket, szAnsiMsg, iLen, 0); if (iRet SOCKET_ERROR) { AfxMessageBox(_T(发送失败)); } }CW2A是ATL提供的转换宏它的原理是动态分配一块临时内存存放转换结果所以用起来方便但别保存指针。send的返回值需要检查它可能小于你传入的长度表示只发送了一部分数据TCP不会一次保证发完这就是后面要讲的“粘包/半包”问题的起点。课程设计里短消息一次发完的概率很高但严谨的做法是循环发送直到全部字节发送完毕。接收线程里同样要处理编码转换反方向用CA2Wchar* to wideUINT RecvThreadProc(LPVOID pParam) { SOCKET hSocket (SOCKET)pParam; char buf[4096] {0}; while (TRUE) { // 阻塞等待服务端数据收到0表示对端已关闭连接 // 收到SOCKET_ERROR表示连接异常常见原因是对方直接关了程序 int iRet recv(hSocket, buf, sizeof(buf) - 1, 0); if (iRet 0) break; buf[iRet] 0; // 手动加字符串结束符防止越界拼接脏数据 // CA2W将多字节的缓冲区内容转成Unicode的CString交给界面层 CString strMsg(CA2W(buf)); // PostMessage发一条自定义消息到主线程WM_USER101是自定义消息编号 // 不能直接在这里更新控件工作线程操作控件会导致句柄访问冲突 ::PostMessage(g_hMainWnd, WM_USER_RECV_MSG, (WPARAM)new CString(strMsg), 0); } // 线程退出前关闭socket防止句柄泄漏 closesocket(hSocket); return 0; }注意PostMessage发的是指针接收方处理完消息后要手动delete否则每收一条消息就泄漏一次内存。这里能顺带解释一个经典崩溃如果直接在工作线程里调用SetWindowText更新编辑框看起来有时行、有时崩本质是控件句柄可能已被销毁或者主线程正在处理别的消息导致竞争。3.4 界面联动把收到的消息显示到列表控件主线程收到自定义消息后在消息处理函数里把内容追加到ListBox或RichEdit控件。ListBox用AddString最省事RichEdit用SetSel和ReplaceSel实现追加效果。完整的消息分发函数类似这样LRESULT OnRecvMsg(WPARAM wParam, LPARAM lParam) { // wParam传递的是new出来的CString指针处理完必须delete CString* pMsg (CString*)wParam; m_listChat.AddString(*pMsg); // 追加后滚动到最后一行否则消息多时看不到最新内容 m_listChat.SetTopIndex(m_listChat.GetCount() - 1); delete pMsg; return 0; }这里容易忽略的是m_listChat的成员变量声明必须和控件绑定。在DoDataExchange里写上DDX_Control(pDX, IDC_LIST_CHAT, m_listChat)控件ID对应对话框资源里的ListBox控件ID。这一步不写m_listChat就是无效句柄AddString直接崩溃。4. 用UDP广播写简化版聊天室不维护连接也能互相喊话4.1 UDP广播的核心机制与适用边界如果你的课程设计要求偏向“通信原理演示”而不是“完整聊天系统”UDP广播是更省事的选择。UDP广播的本质是设置socket的SO_BROADCAST选项然后向子网广播地址发送数据报同网段内所有机器上监听该端口的程序都能收到。它不需要连接不需要accept不需要维护客户端列表代码量比TCP少一半。代价是消息不保证送达——丢包、乱序都是UDP的常态。课程设计的局域网环境里丢包率极低演示效果不受影响。但如果你要跨网段通信广播就到不了需要改用组播IP_MULTICAST。这个差别在答辩时解释清楚反而是加分项。4.2 一个封装好的UDP收发类完整代码与应用UDP版聊天室的核心就是一个类封装初始化、发送、接收三个方法。这里给出一个可以直接贴进项目的实现注释里标注了每行参数的含义class CUdpChat { public: SOCKET m_sock; sockaddr_in m_localAddr; sockaddr_in m_broadAddr; // 初始化创建socket并绑定端口 // nPort是本地监听端口strBroadcastIP是广播地址一般填255.255.255.255 BOOL Init(UINT nPort, CString strBroadcastIP) { WSADATA wsaData; WSAStartup(MAKEWORD(2, 2), wsaData); // SOCK_DGRAM表示UDP第三个参数仍传0让系统自动选协议 m_sock socket(AF_INET, SOCK_DGRAM, 0); if (m_sock INVALID_SOCKET) return FALSE; // 允许广播是UDP广播的关键开关不设置这行发送广播会被拒绝 BOOL bOpt TRUE; setsockopt(m_sock, SOL_SOCKET, SO_BROADCAST, (char*)bOpt, sizeof(BOOL)); m_localAddr.sin_family AF_INET; m_localAddr.sin_port htons((u_short)nPort); m_localAddr.sin_addr.S_un.S_addr htonl(INADDR_ANY); // 绑定后本机收到的UDP包会被投递到recvfrom bind(m_sock, (sockaddr*)m_localAddr, sizeof(m_localAddr)); m_broadAddr.sin_family AF_INET; m_broadAddr.sin_port htons((u_short)nPort); // 广播地址255.255.255.255等价于INADDR_BROADCAST也可用子网定向广播地址 m_broadAddr.sin_addr.S_un.S_addr htonl(INADDR_BROADCAST); return TRUE; } // 发送向广播地址发送消息所有同网段监听同一端口的程序都能收到 int SendMsg(CString strMsg) { CW2A szMsg(strMsg); // sendto要指定目标地址这里用的是广播地址 // 返回值是发送的字节数失败返回SOCKET_ERROR return sendto(m_sock, szMsg, (int)strlen(szMsg), 0, (sockaddr*)m_broadAddr, sizeof(m_broadAddr)); } // 接收阻塞等待数据收到后转成Unicode返回 int RecvMsg(CString strMsg) { char buf[4096] {0}; sockaddr_in fromAddr; int nLen sizeof(fromAddr); // recvfrom的fromAddr参数能拿到来数据的源地址可以显示是谁发的 // 注意最后一个参数是in/out传入缓冲区大小传出实际地址长度 int iRet recvfrom(m_sock, buf, sizeof(buf) - 1, 0, (sockaddr*)fromAddr, nLen); if (iRet 0) return -1; buf[iRet] 0; CString strFrom inet_ntoa(fromAddr.sin_addr); strMsg CString(CA2W(buf)); return iRet; } };这个类用起来很直白Init绑端口SendMsg发广播RecvMsg收消息。RecvMsg的阻塞同样会卡线程所以接收仍然要放在AfxBeginThread里。发送端不用开线程因为sendto速度很快不会阻塞。如果你在自测时发现自己的消息发出去自己能收到这是正常的UDP广播默认把数据同时发给本机回环接口如果不希望收到自己发的内容可以在接收时判断来源IP本机IP就跳过。4.3 参数设置广播地址、端口与TTL的取舍UDP聊天室的成功与否大半取决于参数。端口号建议取1024以上的高位端口避开系统保留端口多台电脑演示时所有人的端口必须一致否则收不到。广播地址推荐填255.255.255.255它是全局广播地址路由器默认不转发所以只能在局域网内用——这恰好符合聊天室课程设计的场景。一个值得注意的边界是虚拟机如果你的程序跑在VMware或VirtualBox虚拟机里NAT网络模式下广播是发不出去的因为虚拟网卡默认隔离广播域。解决办法是把虚拟机网络模式改成“桥接模式”让虚拟网卡和物理网卡处在同一个广播域里。很多人在虚拟机里收不到消息检查代码半天发现是网络模式的问题这种“翻车”消息在答辩现场特别常见。5. 聊天室程序的五个高发坑粘包、乱码、线程退出与防火墙5.1 粘包与半包UDP不发愁TCP必须处理现象TCP聊天室里客户端连续发送“你好”、“在吗”、“收到请回复”三条消息服务端却一次性收到了一长串字符串“你好在吗收到请回复”。原因TCP是字节流协议它只保证字节顺序不保证消息边界。发送端的两次send之间没有间隔接收端的recv缓冲区可能一次就把多条小消息合并取走这就是粘包。反过来一条长消息被拆成多次到达第一次recv只取走前半段后半段下次才到这叫半包。解决项目里最简单的做法是给每条消息前加固定长度的包头写入消息长度。接收端先读4字节得到长度再按长度读取消息体。具体代码是在发送前用sprintf在缓冲区开头写入字符长度接收时先取前4个字符解析长度再取对应字节。这个方案可以应付课程设计里的所有消息类型。如果怕麻烦在每条消息末尾加\n换行符接收后按换行拆包也能勉强用但消息内容里一旦本身包含换行符就会出错。5.2 中文乱码Unicode字符集下的编码转换错位现象发送“你好”对方收到“浣犲ソ”一类乱码或者收字母正常、收中文全是问号。原因典型的字符集不匹配。发送端用GBK编码发出中文接收端却按UTF-8去解析字节序列对不上。MFC的CString在Unicode模式下内部是UTF-16如果你在send时不转换直接用(LPCTSTR)强转发送的是16位宽字符接收端按8位字节流存下来自然全是乱码。解决统一用CW2A/CA2W做转换发送端发送ANSIGBK编码接收端用CA2W转回Unicode。如果接收端不是MFC程序而是网页端那要改用UTF-8这时用CW2A的第二个参数指定代码页比如CW2A(szMsg, CP_UTF8)。课程设计里两端都是MFC统一走CW2A就够。还有一个小坑发送端的编辑框如果输入了中文CString内部长度按字符数计算但send需要的是字节数必须用strlen(CW2A转出的char*)来取实际字节长度用GetLength()算出的是宽字符数会少发一半数据。5.3 关窗口崩溃线程的退出时机和closesocket的顺序现象聊天窗口正常聊天没问题但点右上角“X”关闭窗口时程序直接弹“Debug Assertion Failed”或者触发0xC0000005访问违规。原因窗口关闭时主线程销毁对话框和控件而工作线程还阻塞在recv或accept里。这时候系统回收了socket相关的资源线程在无效的socket上继续等待一旦返回错误代码线程还要去访问已经被销毁的控件句柄自然崩溃。本质是主线程没有通知工作线程退出就先把socket和控件销毁了。解决在窗口的OnClose或OnDestroy里先做三件事把自定义的退出标志位设为TRUE、用shutdown或select唤醒阻塞中的recv、等待线程结束再销毁控件。最省事且可靠的做法是WorkThread先检查退出标志然后调用closesocket来解除阻塞// 在主窗口OnDestroy里调用关闭socket后recv会立即返回SOCKET_ERROR // 工作线程收到错误后就会正常退出不会访问已销毁的控件 void OnDestroy() { // 通知线程退出标志位置TRUE后续recv一旦返回就退出循环 g_bThreadExit TRUE; // 关闭socket让recv立即返回阻塞解除 closesocket(m_hSocket); // 等待线程真正结束再继续销毁窗口避免线程还在运行但控件已销毁 WaitForSingleObject(m_hThreadHandle, 3000); CDialogEx::OnDestroy(); }这里的关键是closesocket一个正在被阻塞的socket会让recv立即返回且返回值是SOCKET_ERROR从而触发线程的正常退出流程。WaitForSingleObject给线程最多3秒的退出时间3秒不够说明线程卡在别的地方再排查其他阻塞点。5.4 连不上服务端防火墙、回环地址与IP地址填写的坑现象本机运行一个实例连接127.0.0.1能通换到另一台电脑把代码里的IP改成对方机器的IPconnect总是失败。原因排查要分层。第一Windows防火墙默认拦截入站TCP连接第一次运行时会弹提示框如果点了“取消”后续所有外部连接都会被丢包。解决方法是去“高级安全Windows防火墙”里增加入站规则放行该端口或者干脆在代码运行的机器上临时关掉防火墙做验证。第二对方机器如果开了多个网卡比如装了虚拟机的虚拟网卡服务端绑定INADDR_ANY没问题但connect通常应填物理网卡的IP。第三同一台机器两个实例互相连接时填127.0.0.1总是能通这只能证明本机协议栈没问题不能证明局域网可见。解决客户端填IP时用服务端机器上ipconfig命令显示的IPv4地址不要用虚拟网卡的地址防火墙先临时关闭一条规则排除干扰两个实例启动顺序必须是先服务端后客户端。还有一个非常隐蔽的坑服务端如果使用MFC的IP地址控件IPAddress Control获取IP它返回的是四个字节分开的数值需要手动拼成字符串或先用inet_addr转换直接强转会得到错误结果。5.5 多客户端广播给别人发送/接收线程share同一socket的竞争现象UDP版本里多个客户端同时广播时某个客户端的消息偶尔丢失或者程序偶尔报错“socket operation on non-socket”。原因UDP的socket可以同时收发但如果发送和接收分别在不同的线程里调用同一个socket的sendto/recvfromWindows不允许两个线程同时对同一个socket执行重叠操作。默认情况下UDP发送很快但极端情况下recvfrom还在执行时sendto来了会返回WSAEINVAL或WSAENETDOWN。解决别让收发同时跑。课程设计里可以把发送放在主线程按钮事件里接收放在工作线程。sendto的调用非常快和recvfrom撞在一起的概率极低。如果追求严谨给socket对象加一个临界区发送前进入、发送后离开。也可以用重叠I/O或完成端口但这对课程设计来说过度设计了答辩时用临界区来回答“线程安全”问题已经比教程标准答案高一档。6. 加分项把聊天室接上HTTP接口让消息留痕如果课程设计不止于“能聊”想拿高分常见方向是把聊天记录同步到一个HTTP服务端API接口。这正好对应很多人在找工作面试里被问到的“VC如何访问HTTP服务端API”这个点。MFC自带的CHttpFile可以很快实现一个“GET请求发消息记录”的功能// 功能把聊天记录通过HTTP GET请求发送到服务端接口 // 这里的地址是个示例在实际项目中要换成你自己的服务端API CString HttpPostMsg(CString strMsg) { // 用URL编码处理中文和特殊字符防止请求串带空格或中文乱码 CString strEncoded; // 简单处理方式把空格转成%20更完整做法是逐字符URL编码 strEncoded strMsg; strEncoded.Replace(_T( ), _T(%20)); CString strURL; strURL.Format(_T(http://127.0.0.1:8080/api/chat?msg%s), strEncoded); // CHttpFile的OpenURL会用GET方式请求返回的文件指针用于读取响应 CHttpFile* pHttpFile NULL; try { pHttpFile (CHttpFile*)CInternetSession().OpenURL(strURL); // 读取服务端返回内容可用于校验是否存储成功 CString strResponse; char buf[1024]; UINT nRead pHttpFile-Read(buf, sizeof(buf) - 1); if (nRead 0) { buf[nRead] 0; strResponse CA2W(buf); } pHttpFile-Close(); return strResponse; } catch (CInternetException* pEx) { pEx-Delete(); return _T(HTTP请求失败); } }CHttpFile在MFC的afxinet.h头文件里项目属性里确认链接wininet.lib。它的局限性是同步阻塞网络慢时窗口会卡课程设计里只用来“提交一条聊天记录”可以忍受。想做得更专业就换成WinHTTP或WinInet的异步版本但代码至少翻三倍。把聊天记录入库之后可以顺便做一个验证步骤在浏览器里打开服务端API接口的地址能看到自己刚发的消息出现在服务端的日志或者数据库里这就是一次“从Socket到HTTP”的完整链路演示答辩时只要把这条链路的时序图和错误分支列出来课程设计的层次就从“会调API”变成“能讲清底层做了什么”。回到聊天室本身我对这个课设的最大感悟是Socket编程的返回值检查永远是第一位的很多人花一下午调错最后发现是多写了一次WSAStartup或者忘了检查send的返回值。先把错误检查写成习惯再谈界面美化和功能堆料这样程序才不会越改越乱。希望这里的选型思路和踩坑记录能帮你少走一段弯路。本文还有配套的精品资源点击获取