
简介基于QT C实现的即时通讯系统课程设计资源包面向计算机、软件工程、通信工程等专业大学生适合在计算机网络课程设计或毕业设计中作为完整参考项目。包内共160个文件涵盖17个cpp源码、15个头文件、6个ui界面设计及2个pro工程文件配套60个dll运行库与42个qm翻译文件确保程序可直接运行另有2个docx课程设计报告和1个mp4演示视频整体压缩包约66.58MB。该资源完整呈现了从登录验证、在线用户列表到线程间通信与MySQL数据存储的常见实现思路代码结构与界面模块划分清晰便于理解客户端/服务器架构和QT网络编程流程。已有444人学习下载适合需要快速搭建项目框架或撰写设计文档的同学参考。1. 这个课程设计不是让你做出一个能聊天的界面一门计算机网络课想要拿高分通常靠的不是界面做得有多花而是把你对 TCP、socket、应用层协议这些概念的理解变成一台机器上真正能跑、能演示、能讲清楚的东西。基于 QT C 的实现即时通讯系统正好是这类课程设计里最经典的一道题它把 socket 编程、TCP 状态迁移、应用层协议设计、并发连接处理全部串在了一个看得见摸得着的桌面程序里。很多同学一上来就四处找带课程设计报告和演示视频的成品压缩包却忽略了答辩时老师最常追问的三个问题你的协议是怎么定义的粘包怎么处理服务端同时撑多少个连接这篇文章把从头自己做一遍的完整路线拆开讲清楚架构怎么选、客户端和服务端各自怎么落、报告和演示视频怎么准备以及我见过最多的翻车点。2. 架构与协议设计动手写代码前先画这两张图2.1 客户端/服务端怎么拆决定了答辩时你能讲多深这个项目既然叫“即时通讯系统”它的网络拓扑必须有一个中心节点。别想着让两个客户端直接建连那叫 P2P需要在真实网络环境里穿透 NAT还要考虑双方所在地域名解析课程设计里一旦涉及这个答辩难度直接从本科毕业设计跳到研究生课题。最稳妥也最贴合计算机网络课程知识点的方案是纯 C/S 架构一个服务端进程负责监听端口、维护在线客户端列表、转发消息、暂存离线消息两个及以上的客户端进程负责登录、发送、接收和展示。我习惯把模块拆成下面这张图在心里先挂起来服务端QTcpServer 监听 每个连接一个封装对象保存 socket、用户名、缓冲区、登录状态客户端网络层QTcpSocket 封装 一个收包缓冲区 断线重连逻辑客户端界面层登录窗口 主聊天窗口会话列表、消息区、输入区公共协议层消息帧定义、消息类型枚举、JSON 序列化/反序列化端口号建议固定在一个不常用的高位端口比如 8600。低于 1024 的端口需要管理员权限在实验室机器上经常因为权限不足而直接启动失败8000 又容易被本机其他调试服务占用。选 8600 这类四位数的非标准端口最省事。服务端和客户端的连接管理要定一个明确的“生命周期”。客户端启动后主动 connectToHost连接建立后第一件事是发送登录包服务端校验用户名密码后回一个登录应答之后客户端定期发心跳服务端超过一定时间没有收到心跳就主动断开这个连接并广播在线列表变化。这些逻辑在协议设计阶段就要定好不要等代码写完再补不然客户端和服务端的协议会越改越对不上。2.2 应用层协议定义一个带长度头的 JSON 帧计算机网络这门课里最常考的“应用层协议设计”在这个项目里就是你的消息格式。TCP 是流式协议它只保证字节按顺序到达不保证一次 write 的数据能被对端一次 read 收到也不保证两次 write 的数据在接收端不合并。所以你必须自己在应用层给消息切出边界。我的推荐方案是“4 字节长度头 JSON 包体”。偏移长度内容说明0-34 字节length包体长度网络字节序大端不含这 4 字节4lengthbodyUTF-8 编码的 JSON 文本包体 JSON 里统一带上这几个字段字段类型说明typestringlogin / login_ack / chat / system / heartbeat / online_listfromstring发送者用户名tostring接收者用户名群聊可以约定一个特殊前缀contentstring消息正文tsint时间戳用于排序和显示为什么选 JSON 而不是 Qt 自带的QDataStream直接序列化QString和QByteArray因为答辩老师大概率会问你“如果客户端和服务端用不同语言实现你的协议还能通用吗”。JSON 是跨语言的抓包能看到明文方便演示也方便写测试脚本。QDataStream 里面带一个 Qt 版本号收发双方如果 Qt 版本不同反序列化会直接失败这在后面的避坑章节里会细说。JSON 唯一的缺点是体积比二进制大但对课程设计阶段的聊天系统来说完全够用。真要优化可以定一个二进制协议第一个字节是消息类型后面按固定格式放字段但那会牺牲可读性我不建议在课程设计里给自己找这个麻烦。发包函数我一般做成一个公共工具函数客户端和服务端共用// protocol.h static void writePacked(QTcpSocket* socket, const QJsonObject msg) { // 先序列化成紧凑 JSON去掉多余空格减少传输体积 QByteArray body QJsonDocument(msg).toJson(QJsonDocument::Compact); // 手动拼 4 字节大端长度头 QByteArray head; head.resize(4); quint32 len static_castquint32(body.size()); head[0] static_castchar((len 24) 0xFF); head[1] static_castchar((len 16) 0xFF); head[2] static_castchar((len 8) 0xFF); head[3] static_castchar(len 0xFF); socket-write(head); socket-write(body); }这段代码的逻辑说明QJsonDocument::toJson返回的是 UTF-8 编码的字节数组Compact模式会去掉换行和空格一个普普通通的聊天消息大概一百字节以内。长度头用手工移位而不是memcpy是为了明确表达“网络字节序”也给答辩时讲大小端留一个发挥点。head[0]存最高 8 位head[3]存最低 8 位这就是大端序。参数说明resolveTimeout这类参数不在这个函数里这个函数唯一的隐式参数是QJsonDocument::Compact如果消息里含大量换行这个参数能显著减少包体大小。每个 socket 的写入缓冲由 Qt 管理write 之后不需要立刻 flushTCP 会自己合并小包应用层不用关心。3. 客户端实现QTcpSocket 的收发框架与登录流程落地3.1 用 QTcpSocket 搭连接层别把逻辑全塞进登录按钮客户端的网络层应该是一个独立的类比如ChatClient负责持有QTcpSocket、维护接收缓冲区、解析消息帧、发出信号让界面更新。不要让登录窗口的按钮槽函数里直接写 socket 读写那样一旦界面重新开发网络层就要重写。先看连接建立部分// ChatClient.cpp 构造函数里的初始化 sessionSocket_ new QTcpSocket(this); connect(sessionSocket_, QTcpSocket::connected, this, ChatClient::onConnected); connect(sessionSocket_, QTcpSocket::readyRead, this, ChatClient::onReadyRead); // Qt 5.14 中用 QOverload 绑定带参数的 error 信号 connect(sessionSocket_, QOverloadQAbstractSocket::SocketError::of(QAbstractSocket::error), this, ChatClient::onSocketError); connect(sessionSocket_, QTcpSocket::disconnected, this, ChatClient::onDisconnected); void ChatClient::connectToServer(const QString host, quint16 port) { if (sessionSocket_-state() ! QAbstractSocket::UnconnectedState) { sessionSocket_-abort(); } sessionSocket_-connectToHost(host, port); }逻辑说明connectToHost是异步的调用后立即返回真正连接建立成功会触发connected信号。所以不要在connectToHost后面紧接着去判断state()那是很多新手会犯的错。abort()会把当前 socket 状态强制复位这样重连时不会因为上一次连接还没断干净而出问题。参数说明host按局域网内服务端 IP 填课程设计演示时服务端和客户端一般在同一台或同一网段内的两台电脑。如果服务端监听的是QHostAddress::Any客户端填127.0.0.1即可。port必须和服务端listen的端口一致这是客户端连不上的第一排查点。登录流程在网络层里的做法是连接成功后立刻组装登录包不需要等用户再点一次“登录”。void ChatClient::onConnected() { QJsonObject loginMsg; loginMsg[type] login; loginMsg[from] username_; loginMsg[pass] password_; loginMsg[ts] QDateTime::currentSecsSinceEpoch(); writePacked(sessionSocket_, loginMsg); }onConnected里不会立刻弹出主窗口而是等服务端回login_ack确认密码正确之后客户端才进入聊天主界面。这个细节很重要如果连接一建立就认为登录成功那密码错误时你还要再退回去交互逻辑会很别扭。3.2 接收缓冲区把 TCP 字节流一条一条切出来客户端收到任何网络数据都会触发readyRead。最稳妥的写法是每次把readAll()追加到自己的缓冲区然后在一个循环里不断尝试解析完整的帧。这就是处理粘包和拆包的核心。void ChatClient::onReadyRead() { recvBuffer_.append(sessionSocket_-readAll()); // 循环解析直到缓冲区里不足一个完整帧 while (recvBuffer_.size() 4) { quint32 len 0; len | (static_castquint32(recvBuffer_[0]) 0xFF) 24; len | (static_castquint32(recvBuffer_[1]) 0xFF) 16; len | (static_castquint32(recvBuffer_[2]) 0xFF) 8; len | (static_castquint32(recvBuffer_[3]) 0xFF); if (recvBuffer_.size() static_castint(len 4)) { // 数据还没收齐等下一次 readyRead 再处理 return; } QByteArray body recvBuffer_.mid(4, static_castint(len)); recvBuffer_.remove(0, static_castint(len 4)); handleMessage(QJsonDocument::fromJson(body).object()); } }逻辑说明每次从缓冲区头部取 4 字节算出包体长度如果缓冲区里已经有完整的包体就切出来解析如果没有立即返回剩下的数据保留在recvBuffer_里等待下个readyRead信号。这个while循环能同时处理“一次收到多条消息”和“一条消息分多次到达”两种场景。参数说明这里没有用QDataStream去读长度头是为了避免编译器自动做本机字节序转换。手工按位拼接时必须用 0xFF把char提升为无符号值否则负数位移运算会得到错误结果。这是 C 里一个非常经典的有符号数坑。handleMessage按type分发void ChatClient::handleMessage(const QJsonObject msg) { if (msg[type] login_ack) { if (msg[ok].toBool()) { emit loginSucceeded(); } else { emit loginFailed(msg[reason].toString()); } } else if (msg[type] chat) { // 来自其他用户的消息 emit messageReceived(msg[from].toString(), msg[content].toString(), msg[ts].toInt()); } else if (msg[type] online_list) { emit onlineListChanged(msg[users].toArray()); } }这里用emit信号把网络层和界面层解耦。主窗口只需连接messageReceived信号把消息追加到聊天记录控件里。界面层不需要知道底层网络细节网络层也不需要知道消息显示成什么样。3.3 主窗口的会话区、输入区与在线列表怎么联动主窗口我建议用QTextEdit当聊天记录显示区只读模式QLineEdit或QPlainTextEdit当输入区右侧用QListWidget显示在线用户。不要一上来就用QTableWidget在线列表就是“用户名 在线状态”两列信息QListWidget轻量得多还能用setToolTip显示更多细节。只有当你在线人数几千上万时才需要考虑QTableView 自定义QAbstractTableModel来避免界面卡顿课程设计阶段用不上。发送消息的槽函数void MainWindow::onSendClicked() { QString text inputEdit_-toPlainText().trimmed(); if (text.isEmpty()) return; QJsonObject chatMsg; chatMsg[type] chat; chatMsg[from] username_; chatMsg[to] peerName_; chatMsg[content] text; chatMsg[ts] QDateTime::currentSecsSinceEpoch(); writePacked(client_-socket(), chatMsg); // 自己的消息立刻显示在聊天区里 appendMessage(username_, text); inputEdit_-clear(); }自己的消息先显示到聊天区是即时通讯的常见体验等服务器转发回来再显示会让人感觉延迟很高。这里需要注意“先发送再显示”的顺序writePacked只是把数据写进 socket 缓冲真正发出由事件循环控制所以显示逻辑在 write 之后没有任何问题。服务端应答可以用来做“消息送达”标记但课程设计里不必做这么细。4. 避坑网络编程与 Qt 线程里最常见的 6 个翻车点4.1 消息串台两条消息粘在一起或者一条消息被切碎了现象聊天记录里出现“A 的消息后半截显示成了 B 的内容”或者接收到的 JSON 每次都报解析错误末尾多了一截。原因TCP 是字节流。你调用两次write发送两条消息服务端很可能一次readyRead就把两段数据全部收完了反过来一条消息在网络上被分成多个包服务端需要三次readyRead才能收齐。不按帧切分直接拿readAll()去解析必然出错。解决用第 3.2 节里的长度头 缓冲区循环解析。核心是把“读数据”和“解析消息”彻底分离缓冲区里凑不齐一帧就等着凑齐了就切出来。4.2 程序在回收 socket 时崩溃现象客户端退出或关闭窗口时程序卡住或直接崩Qt 控制台输出QObject: Cannot create children for a parent that is in a different thread一类的提示。原因很常见的写法是在主窗体析构函数里delete client_而client_内部又持有QTcpSocketsocket 断开信号触发了一个正在处理的槽函数形成竞态。或者你让ChatClient对象的父对象在事件循环还没结束时就被销毁了。解决在ChatClient的析构函数里先abort()socket再删除信号连接ChatClient::~ChatClient() { if (sessionSocket_) { sessionSocket_-abort(); // 立即终止不再等待三次握手挥手 delete sessionSocket_; sessionSocket_ nullptr; } }abort()会立刻断开并丢弃缓冲区数据不会像disconnectFromHost()那样等对方响应所以析构过程不会触发额外的网络事件。4.3 主界面卡死在槽函数里做了阻塞操作现象发送一条大文件消息时整个窗口无响应鼠标变成转圈状态几秒后才恢复。原因我在代码里见过最典型的是在发送槽里调用了waitForBytesWritten(3000)或者waitForConnected(3000),这些waitFor*函数都阻塞当前线程的事件循环。Qt 的主线程还负责处理 UI 重绘和鼠标键盘事件一被阻塞窗口自然就“死了”。解决所有 socket 的读写都不调用waitFor*系列函数。用write后不立即确认数据的实际发送由 Qt 底层完成。如果一定要知道发送完成的时机可以用bytesWritten信号。界面只负责把数据交给 socket 再返回事件循环。4.4 跨线程访问界面控件导致崩溃或显示错乱现象客户端把网络层放在一个QThread里在线程里直接调mainWindow-appendMessage(...)结果有时界面能显示有时崩溃而且崩溃位置随机。原因Qt 的 UI 类不是线程安全的任何对QWidget的访问必须在主线程。工作线程里直接调用槽函数相当于把 UI 更新并发到了主线程正在绘制控件的时间点这个冲突不可预测。解决工作线程只发信号不调界面方法。Qt 信号槽默认在线程之间是队列连接emit signal后槽函数会在目标线程的事件循环里执行// 在线程类中 emit receiveMessage(from, content, timestamp); // 在主窗口中 connect(worker, NetworkWorker::receiveMessage, this, MainWindow::appendMessage);只要 connect 的第五个参数没改Qt 自动用Qt::QueuedConnection把调用排到主线程安全可靠。这么做之后网络线程里禁止再出现ui-xxx-setText(...)这类代码。4.5 QDataStream 换台电脑就读不了现象代码在自己电脑上收发优先换到教室电脑或打包给别人演示时接收到的第一个数字就异常整个消息解析失败程序直接退出。原因QDataStream写入字符串时默认带一个 Qt 版本标记而且默认版本跟当前编译的 Qt 版本挂钩。写端用 Qt 5.14读端用 Qt 5.14没问题读端换 Qt 5.9字节流解析就会错乱。解决要么用 QDataStream 时在两端强制setVersion(QDataStream::Qt_5_14)要么像我推荐的那样干脆不用 QDataStream 做消息帧改成手工拼字节的长度头。这样协议完全由你控制不依赖 Qt 的序列化版本换任何语言都能对接。4.6 拔网线之后服务端还认为客户端在线现象客户端直接关机或断网服务端客户端列表里还显示这个用户在线广播在线列表永远带着这个名字。原因TCP 连接的断开检测依赖对端回包。客户端拔网线后没有正常发disconnect包服务端收不到任何消息除非 TCP keepalive 超时否则它永远不会发现连接已经死了。解决客户端每 15 秒发一个心跳包服务端记录每个连接的最后活跃时间超过 45 秒没收到包就主动断开并广播下线通知。心跳包只需要一个最简单的 JSONQJsonObject hb; hb[type] heartbeat; hb[from] username_; hb[ts] QDateTime::currentSecsSinceEpoch(); writePacked(sessionSocket_, hb);服务端用一个 QTimer 定期遍历所有连接检查时间差别再依赖 TCP 自己发现死链。5. 服务端实现QTcpServer 接收连接与并发转发的落地方式5.1 一个连接一个上下文对象别裸着管理 socket服务端我建议用一个ChatServer类持有QTcpServer每个新连接进来时创建一个封装了 socket、用户名、缓冲区、上次心跳时间的ClientContext对象。这样比到处存QTcpSocket*干净得多。void ChatServer::onNewConnection() { while (server_-hasPendingConnections()) { QTcpSocket* socket server_-nextPendingConnection(); auto* ctx new ClientContext(socket, this); clients_.insert(socket, ctx); connect(socket, QTcpSocket::readyRead, ctx, ClientContext::onReadyRead); connect(socket, QTcpSocket::disconnected, ctx, ClientContext::onDisconnected); log(QString(client connected: %1:%2) .arg(socket-peerAddress().toString()) .arg(socket-peerPort())); } }逻辑说明hasPendingConnections加nextPendingConnection的模式必须在newConnection信号里循环调用因为一个信号可能对应多个排队连接。clientContext对象直接挂在this服务端下面socket 挂载在 context 下整个对象树只要服务端窗口不销毁就不会提前释放。参数说明peerAddress返回的地址IPv6 环境下可能是一长串::ffff:192.168.x.x控制台倒无所谓如果写日志文件记得做一次去前缀处理。peerPort是客户端的临时端口每次连接不固定只能作为日志参考不能当身份标识。客户端断开时void ClientContext::onDisconnected() { if (!username_.isEmpty()) { // 登录过的连接才需要广播离线消息 emit server_-userOffline(username_); } emit server_-removeClient(this); deleteLater(); }deleteLater()是 Qt 里安全的“稍后删除”机制它会把删除操作排到下一次事件循环里执行不会在当前信号处理中半途销毁对象。5.2 并发模型课程设计用单线程事件循环就可以了这是很多人的纠结所在QTcpServer 是不是每个连接要起一个线程不是。Qt 的 socket 是非阻塞的QTcpServer监听、QTcpSocket读写都在同一个事件循环里也能正常工作。服务端只需要做到“每个 readyRead 里尽量快速处理完数据”不要做耗时操作比如把消息写入数据库时用 SQLite 的批量事务避免每一条消息都触发一次磁盘刷新。我这些年做过多个这类系统后的经验单线程事件循环撑个 200 个在线连接毫无压力不需要多线程。只有当服务端要处理文件传输、视频流这类持续高带宽场景或者要同步查询外部数据库才需要考虑把阻塞操作丢给工作线程。课程设计答辩现场老师更关心的是“为什么这样设计是有道理的”你能说出“IO 复用 事件驱动单线程足以支撑当前并发量”这句话已经比盲目上多线程讲不清要高分。如果你坚持体验多线程正确姿势是把ClientContext对象moveToThread到工作线程然后它包含的 socket 和定时器都跟随过去QThread* t new QThread(this); ctx-moveToThread(t); connect(t, QThread::started, ctx, ClientContext::startHeartbeatTimer); t-start();这会引入额外的复杂度线程对象的生命周期、socket 从旧线程移出必须在其disconnected之后操作、向 UI 线程发信号时要小心排队连接。我的建议仍然是在主干先跑通单线程版本确认全部功能稳定了再碰线程千万不要一上来就线程套 socket。5.3 消息转发、在线列表广播与离线消息服务端处理chat消息的核心逻辑void ClientContext::handleChat(const QJsonObject msg) { QString to msg[to].toString(); if (to.isEmpty() || to username_) { // 给自己发或目标为空直接忽略 return; } if (server_-isOnline(to)) { server_-forwardMessage(msg); } else { server_-saveOfflineMessage(msg); } } void ChatServer::forwardMessage(const QJsonObject msg) { QString to msg[to].toString(); ClientContext* target clients_by_name_.value(to, nullptr); if (target target-isLoggedIn()) { writePacked(target-socket(), msg); } }逻辑说明目标在线就转发目标不在线就存离线表。clients_by_name_是一个QMapQString, ClientContext*用户名和连接建立映射关系。用户登录时插入断开时删除。整个服务端就靠两张表clients_按 socket 指针索引clients_by_name_按用户名索引。参数说明离线消息这里存内存够用进程重启就消失想做得严谨一点用 QSQLite 存到本地文件一张offline_msg表字段from_user, to_user, content, ts。用户登录成功后服务端查询这张表把未读消息逐条推给客户端然后删除已推送记录。这个功能在课程设计报告里是很好的加分项实现成本也很低。注意在推离线消息时每条消息的to字段必须保留原目标用户客户端才会把它们显示在正确的会话里。心跳检测放在服务端void ChatServer::onHeartbeatCheck() { qint64 now QDateTime::currentMSecsSinceEpoch(); for (auto it clients_.begin(); it ! clients_.end();) { ClientContext* ctx it.value(); if (ctx-isLoggedIn() now - ctx-lastHeartbeatMs() 45000) { ctx-socket()-abort(); it clients_.begin(); // 列表变化之后重新遍历 } else { it; } } }如果列表里删除了元素直接在循环过程对QMap的遍历要小心。上述代码用it clients_.begin()重新从一个已经变动的容器开始遍历是不够严谨的更稳的写法是先把超时的 socket 收集到数组遍历结束后统一断开。这个细节出现在答辩里老师会认为你考虑问题很周全QListQTcpSocket* expired; for (auto it clients_.begin(); it ! clients_.end(); it) { if (it.value()-isLoggedIn() now - it.value()-lastHeartbeatMs() 45000) { expired.append(it.key()); } } for (QTcpSocket* socket : expired) { socket-abort(); }6. 课程设计报告和演示视频把亮点讲出来的一半功夫6.1 报告章节骨架每个部分写多少页、重点放哪课程设计报告不需要长篇大论但要保证每一部分都在回答“我做了什么、为什么这么做、怎么验证的”。我的习惯是六个部分对应正文表格报告部分需要写到的内容建议篇幅需求分析用户角色、功能点列表登录/私聊/群聊/离线消息/心跳1-2 页总体设计客户端与服务端模块图连接与消息流向图2-3 页协议设计帧格式、字段表、每种消息的 JSON 示例、时序说明3-4 页详细实现关键类和核心函数选 2-3 段代码贴进去并逐行解释4-5 页测试过程测试用例表 5-8 张运行截图包含错误场景截图2-3 页遇到的问题从避坑章节里挑 3 个真实的按“现象-原因-解决”写2 页协议设计这一章是整个报告的题眼。让老师一眼看到你定义了长度头、处理了粘包、设计了心跳这比你在总体设计里写十页 UML 图更要命。测试部分不要只贴“成功登录”的截图一定要贴一个“断线重连”和“离线消息推送”的截图证明你考虑过异常场景。6.2 演示视频四分半钟讲完一个完整闭环演示视频不要录半小时的完整开发过程那是浪费老师时间也容易暴露你自己没准备的边角问题。按下面这个分镜来安排片段内容时长片段一服务端启动监听 8600 端口控制台输出日志30 秒片段二客户端 A 登录服务端日志出现登录信息40 秒片段三客户端 B 登录A 的在线列表即时刷新40 秒片段四A 给 B 发消息两条消息内容一致、时序正确1 分 30 秒片段五B 退出登录A 发消息触发离线存储随后 B 重新登录收到离线消息1 分钟片段六关闭服务端客户端提示连接断开并自动重连30 秒录制时用屏幕录制工具分辨率 1080p 就够了关键是不要录到微信 QQ 的弹窗、不要录到文件路径里含自己中文名的敏感目录。声音可以不加但最好在关键操作时打上简单字标注或者用鼠标工具高亮点击位置。视频长度务必控制在四分钟到五分钟之间答辩现场老师没有耐心看完十分钟。我个人的收尾习惯是视频录完后自己不看代码照着分镜重新操作一遍一旦发现某个步骤与脚本不一致回到代码里去排查绝不抱着“反正演示时能过”的侥幸。课程设计答辩翻车十次里有八次是演示前没有重新跑过完整流程另外两次是换了一台没有配好 Qt 运行环境的电脑。把我上面这套“单线程事件循环 → 长度头协议 → 心跳检测 → 离线消息”的路线完整走一遍你不仅有了一个能答辩的即时通讯系统计算机网络的几个主要知识点也会真正落到你的肌肉记忆里。希望帮到你。本文还有配套的精品资源点击获取