1. 项目概述与核心定位
1.1 这个东西到底是做什么的
先聊清楚这个项目的本质。大学生社交实时聊天系统,说白了就是做了一个面向高校场景的即时通讯加社交产品,区别于微信、QQ这种通用IM,它的核心场景是校园内的人际连接,比如按院系找同门、按兴趣爱好找人组队、按课程表约自习、按社团标签发现同好。
技术栈落在 ThinkPHP + Vue 上,属于典型的前后端分离架构。ThinkPHP负责后端业务逻辑和接口输出,Vue负责前端交互和页面渲染。三端是指Web端(桌面浏览器访问)、H5端(移动端浏览器访问)、小程序端(微信小程序),三端共用同一套后端接口,前端各自实现。
这类系统在毕设、课设、个人项目里出现频率极高,原因是它同时覆盖了用户管理、好友关系、消息通讯、内容动态、个性化推荐、多端适配这些模块,几乎把Web开发的核心知识点都串起来了。如果你正在评估要不要拿这个题目做毕业设计,或者想做一个能放到简历上的社交类练手项目,这个方向是值得投入的。
1.2 三端到底指哪三端
这里必须先说清楚,因为很多人被"三端"这个表述搞懵过。这个项目里的三端不是指三套完全独立的系统,而是同一套业务逻辑的三个客户端入口:
- Web端:基于 Vue + Element UI / Ant Design Vue 开发的桌面网页,在电脑浏览器里打开,适合宿舍、图书馆场景下长时间挂机聊天。
- H5端:同样是 Vue 技术栈,但针对移动端浏览器做了响应式适配,手机直接访问网址就能用,免安装。
- 小程序端:基于 uni-app 或原生微信小程序开发,发布到微信平台,通过小程序入口进入,适合校园内快速扫码拉起聊天。
后端只有一套 ThinkPHP 接口,三个端通过 HTTP 请求 + WebSocket 长连接与服务器通信。这样设计的好处是业务逻辑全部收敛在后端,前端只管渲染和交互,后面想加 App 端(Android/iOS)时,只需要再造一个客户端,后端基本不用动。
1.3 这个项目适合谁
如果你是以下几类人,这篇内容对你会有实际参考价值:
- 计算机相关专业的毕业生,正在找毕设题目,想做一个"有深度、能讲清楚架构逻辑"的系统。
- 想积累社交/IM类项目经验的开发者,这类项目在简历上比普通CRUD管理系统有辨识度得多。
- 自学了 Vue 和 PHP 但不知道怎么做完整项目的初学者,可以通过这个实战项目串起前后端知识。
- 想要搭建校园社区、班级通讯工具的学生组织或开发者,这套系统的业务模型可以直接复用。
下面我从整体设计、实时通讯核心、三端实现细节、常见坑点这几个维度逐一拆解,全部基于我实际做过类似项目的经验。
2. 整体设计与技术选型思路
2.1 为什么是 ThinkPHP 而不是其他后端方案
很多人在选后端时纠结:用Java Spring Boot?用Go?用Node.js?用Python Flask?如果你的核心诉求是"快速完成、逻辑清晰、资料好找、答辩好讲",ThinkPHP确实有自己的位置。
先看事实:ThinkPHP是国内使用率很高的PHP框架,它的文档是全中文的,社区问答沉淀非常厚,遇到问题搜一下基本都有答案。这一点对毕设项目来说极其重要——你卡住的时候,资料的可获取性直接决定开发效率。
再看性能:有人质疑PHP的并发能力,但一个校园规模的聊天交友系统,注册用户撑死几千到几万,同时在线聊天的人数可能就几百。一台普通云服务器跑ThinkPHP完全够用。实时消息推送我们用的是Workerman,它是PHP写的常驻内存框架,专门解决PHP传统CGI模式下"请求结束进程就销毁"的问题。
相对Java系的Spring Boot,ThinkPHP的学习曲线更平缓。Java要理解IOC容器、AOP切面、Maven依赖管理这一整套东西,对没接触过企业级框架的学生来说,上手成本偏高。PHP天然适合做这种单体应用,写起来快,调起来也快。
如果你的项目不是毕设而是想真上线运营,我会建议你考虑门槛稍高但架构更稳的方案。但在这类校园社交场景下,ThinkPHP是一个务实的选择。
2.2 Vue 在前端的作用与版本选择
前端选择Vue是因为它的生态成熟度。Vue 2 还是 Vue 3?我的建议直接上 Vue 3 + Composition API。
原因很直接:如果你未来找工作,现在主流项目已经在向 Vue 3 迁移,学 Vue 2 等于学一套即将过时的写法。如果你做毕设,答辩老师大概率会问你"为什么用 Vue 3 而不用 Vue 2",这是一个很好的展示点——你可以回答:Vue 3 的 Composition API 更适合复杂业务逻辑的复用,性能上虚拟DOM重写后渲染效率更高,而且生态已经成熟,Element Plus、Vant 等组件库都已全面兼容。
关于 Vue Router:三端都会用到路由。Web端用 Vue Router 的 history 模式做页面跳转,H5端和小程序端用各自的页面路由机制。有一个点要注意:页面路由参数在聊天系统中常用来传递会话对象ID,比如跳转到聊天窗口时 URL 里带上 friend_id。开发时注意路由参数的类型——从 URL 取出来永远是字符串,你要在业务代码里转成数字类型,否则可能踩到"=== 比较永远为 false"的坑。
2.3 实时通讯方案对比与选择
实时聊天是这类系统的核心,这部分怎么做决定整个系统的高度。
轮询是最简单的方案:前端每隔几秒请求一次服务器,拉取新消息。问题很明显——浪费流量、消息延迟高、服务器压力大。短轮询在用户量小的时候能用,但体验很差,消息要等好几秒才到,根本谈不上"实时"。
长轮询是改良版:客户端发起请求后,服务器不立即返回,挂起连接直到有新消息才响应。比短轮询好一些,但HTTP连接资源占用问题依然存在。
WebSocket 是正解:它通过一次HTTP握手升级为TCP长连接,之后客户端和服务器可以在任意时刻互相推送数据。延迟能做到毫秒级,而且是全双工通信,聊天体验和微信差不多。
这个项目里,Web端和H5端直接使用浏览器原生 WebSocket 对象连接 Workerman 服务。小程序端要留意一个问题:微信小程序的 WebSocket API 和浏览器不完全一样,但基本用法一致,封装一层即可。
2.4 三端共用接口的约定与规范
三端共用一套后端接口,意味着接口设计必须非常规范,否则会出现"Web端能用但小程序端取不到数据"之类的诡异问题。
我在这类项目中固定使用统一的响应结构:
{ "code": 0, "msg": "success", "data": {} }code 为 0 表示业务成功,非 0 是业务错误码,msg 是给用户看的提示信息,data 是业务数据。所有接口都遵循这个范式,三端在各自的前端代码里封装一个公共请求函数,只处理这一种结构。
接口路径也有讲究。按照资源维度组织:
POST /api/user/login POST /api/user/register GET /api/user/profile PUT /api/user/profile GET /api/friend/list POST /api/friend/add GET /api/message/history POST /api/message/send GET /api/moment/list POST /api/moment/create这样做的核心价值是:后端同学可以按模块写接口文档,前端三个端对照文档各自开发,互不阻塞。小程序端和H5端可以同时推进,不用等Web端做完再做。
3. 实时聊天通讯的架构设计与实现
3.1 Workerman 通讯服务器的搭建
PHP 做 WebSocket 服务端,绕不开 Workerman。原理上它解决了一个关键痛点:PHP 脚本默认生命周期短,一个请求处理完就退出,但 WebSocket 服务需要进程常驻,持续维护客户端连接。
基础搭建分几步:
第一步,用 Composer 安装 Workerman:
composer require workerman/workerman第二步,编写 WebSocket 服务入口文件,比如chat_server.php:
<?php use Workerman\Worker; require_once __DIR__ . '/vendor/autoload.php'; $ws_worker = new Worker("websocket://0.0.0.0:8282"); $ws_worker->count = 4; $ws_worker->onConnect = function ($connection) { echo "new connection from " . $connection->getRemoteIp() . "\n"; }; $ws_worker->onMessage = function ($connection, $data) { // data 是客户端发来的 JSON 字符串 $msg = json_decode($data, true); // 根据 msg.type 分发处理 }; $ws_worker->onClose = function ($connection) { echo "connection closed\n"; }; Worker::runAll();第三步,在项目目录启动服务:
php chat_server.php start重点讲一下进程模型:$ws_worker->count = 4;表示启动4个Worker进程。每个进程独立维护一部分连接。当客户端A连接到进程1、客户端B连接到进程2时,A给B发消息,进程1要把消息转发给进程2,这需要进程间通信。Workerman内部自带了一套消息转发机制,你只要调用$connection->send(),框架会自己找对连接并推送,不用手动处理进程间通信细节。
实际生产环境里,如果把消息都打在GatewayWorker里,我会用 GatewayWorker 来做分布式消息推送。但毕设和中小型项目,直接 Workerman 就够。GatewayWorker 是 Workerman 的分布式扩展,复杂度高一些,适合将来需要横向扩展的场景。
3.2 客户端连接与登录态绑定
WebSocket 建立连接后,服务器并不知道是哪个用户。必须有一个步骤把 TCP 连接和用户身份绑定起来。
我采用的方案是:用户在完成 HTTP 登录后拿到 token,前端建立 WebSocket 连接时,把 token 作为参数传过去:
const socket = new WebSocket(`ws://your-server:8282/?token=${token}`);Workerman 在onConnect回调里虽然有连接对象,但此刻握手还没有完全结束,直接通过 GET 参数拿token有轻微的不确定因素。稳妥做法是:连接建立后,客户端主动发送一条认证消息:
socket.onopen = function() { socket.send(JSON.stringify({ type: 'auth', token: '用户的token' })); };Workerman 的onMessage里收到 type 为 auth 的消息后,解析token,确认用户身份,然后在服务端维护一个 uid 到 connection 的映射:
$userConnections[$uid] = $connection;有了这张映射表,给用户发消息就变成:
if (isset($userConnections[$uid])) { $userConnections[$uid]->send(json_encode($msg)); }这个映射表是内存级的,进程重启就丢,所以登录态丢失后前端要能感知并自动重连。聊天窗口里"掉线重连"是刚需功能,这个后面讲。
3.3 私聊消息的发送、存储与推送链路
私聊消息的完整链路是:用户A在输入框打字,点击发送,前端把消息内容发给后端HTTP接口,后端落库,然后通过WebSocket推送给好友B,同时在A自己的聊天窗口里做本地回显。
我的做法是发送消息走HTTP接口,而不是直接通过WebSocket发送。为什么?因为消息需要落库。先保存再推送,能保证消息不丢。HTTP接口负责两件事:
// 1. 保存消息记录 $messageId = Message::create([ 'from_uid' => $userId, 'to_uid' => $toUid, 'content' => $content, 'type' => 1, // 1文本 2图片 3语音 'create_time' => time() ]); // 2. 推送实时消息给对方 sendWebSocketMessage($toUid, [ 'type' => 'new_message', 'message_id' => $messageId, 'from_uid' => $userId, 'content' => $content, 'create_time' => time() ]);推送成功后,前端收到 new_message 消息类型,把它加到聊天记录尾部,同时更新会话列表的未读计数。
这里有一个细节很重要:A发送消息后,A自己也要收到自己发出的消息。一般做法是给A的聊天窗口做本地回显(发送完直接把消息写入DOM)。因为如果A的网络有延迟,等服务器广播回来再显示,用户会觉得"界面卡了一下"。
关于消息内容显示顺序,以前端维护的发送时间戳为准,不要依赖服务器返回的创建时间。原因在于网络延迟会导致消息到达顺序和用户实际操作顺序不一致,强制按照服务器时间排序会导致消息"跳动"。
3.4 心跳机制与掉线重连
WebSocket 有个常见问题:连接如果长时间没有数据流动,可能被中间的网络设备(NAT、代理)静默切断。客户端不知道连接已经死了,服务器也不知道,两边都认为连接还在,结果就是消息发出去没有响应,界面没有错误提示但就是收不到消息。
解决办法就是心跳包。客户端每30秒发一个 ping 给服务器,服务器收到后回一个 pong。如果连续3次没有收到pong,客户端就判定连接已经断开,触发重连。
前端的心跳逻辑:
let heartbeatTimer = null; let reconnectCount = 0; function startHeartbeat() { heartbeatTimer = setInterval(() => { if (socket.readyState === WebSocket.OPEN) { socket.send(JSON.stringify({ type: 'ping' })); } }, 30000); }后端在 Workerman 的onMessage里处理 ping 类型,直接回 pong 消息。Workerman 还提供onWebSocketPing回调,但这个作用于协议层的控制帧,建议统一用业务层ping做心跳,更可控。
断线重连的策略是:指数退避,不要每秒都重新尝试连接。重连间隔从1秒开始,失败后依次拉长到2秒、4秒、8秒,最大不超过30秒。重连成功后重新做认证,并拉取离线期间错过的消息。
3.5 离线消息的拉取方案
用户下线后,别人给他发的好友请求、聊天消息、系统通知,都必须能在下次登录时补拉。
我采用的方案简单有效:所有消息都先落库,是否推送成功另说。用户登录成功后,前端主动请求一个"离线消息"接口:
GET /api/message/offline?last_id=10086这个接口返回所有比 last_id 大的、发给当前用户的消息记录。前端把它们展示在聊天记录里,未读数也根据这些离线消息统计。
last_id 的用途是增量拉取,避免每次都全量拉消息。前端把本地最新一条消息的消息ID上传,后端只返回这个ID之后的新消息,性能压力小很多。
对离线消息拉取,还要注意消息顺序问题。后端返回时统一按 create_time 升序排列,前端直接用就行。不要在前端自己做排序,否则多端会行为不一致。
4. 个性化社交功能模块详解
4.1 用户画像与标签体系建设
社交系统区别于聊天工具的关键在"交友匹配"。大学生社交的个性化体现在:按学院、专业、年级、爱好、社团、作息习惯等维度找到匹配的人。
标签体系是这套系统的基础。注册时除了基本账号信息,要引导用户打标签:
- 基本信息:学校、学院、年级、专业
- 兴趣标签:运动(篮球/跑步/健身)、游戏(电竞/主机/手游)、学习(考研/编程/外语)、生活(动漫/电影/音乐)
- 性格标签:E人/I人(MBTI)、夜猫子/早鸟、社牛/慢热
数据表设计上,用户表存基本信息,用户标签表存一对多关系:
CREATE TABLE `user_tags` ( `id` int(11) unsigned NOT NULL AUTO_INCREMENT, `uid` int(11) NOT NULL COMMENT '用户ID', `tag_name` varchar(50) NOT NULL COMMENT '标签名', `tag_type` tinyint(1) NOT NULL DEFAULT 0 COMMENT '标签类型:1兴趣 2性格 3其他', PRIMARY KEY (`id`), KEY `idx_uid` (`uid`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;4.2 推荐匹配的实现思路
很多同学会把"个性化推荐"想得很复杂,上来就搞推荐算法、协同过滤、机器学习。一个校园社交系统其实用不上这些。最实用的匹配方案是"基于共同标签的加权评分"。
比如系统推荐"可能感兴趣的人"时,候选集的生成逻辑是:取当前用户的标签集合 T,找出所有拥有至少一个共同标签的其他用户,然后计算匹配分数:
function calculateMatchScore($myTags, $otherTags) { $score = 0; foreach ($myTags as $tag) { if (in_array($tag, $otherTags)) { $score += 10; } } // 加分项:同院系 +20,同年级 +5 return $score; }最终按分数从高到低返回推荐列表。这个逻辑不复杂,但很有意思:它能解释为什么给你推荐这个人——"你们都喜欢打篮球"。推荐理由写在用户卡片上,这比单纯的随机推荐体验好得多。
这个匹配逻辑的取舍很明确:沟通成本低、调试容易、答辩时好讲。你可以在论文里写"本系统采用基于多属性标签的加权相似度匹配算法",措辞听起来专业,实现又不会超出自己的能力范围。
4.3 动态广场与好友关系链
除了聊天,社交系统还需要用户能"刷内容"。动态广场即用户发布图文动态,所有用户可以浏览、点赞、评论。它解决的一个核心问题是:让用户在没有私聊之前也能感知系统的活跃度,提高留存。
动态模块的实现不难,但要注意几个设计细节:
- 发布内容支持图片上传,后端用 ThinkPHP 的上传组件存储文件,返回 URL 给前端展示。
- 动态列表按时间倒序,分页用游标分页而不是页码分页。因为不断有新动态产生,页码分页会出现新内容把旧内容顶掉、重复读到已读内容的问题。
- 点赞功能要支持取消,前端用乐观更新:点击后立即改变样式,请求失败再回滚。
好友关系是三张表:
-- 好友请求表 CREATE TABLE `friend_requests` ( `id` int(11) unsigned NOT NULL AUTO_INCREMENT, `from_uid` int(11) NOT NULL, `to_uid` int(11) NOT NULL, `status` tinyint(1) NOT NULL DEFAULT 0 COMMENT '0待处理 1同意 2拒绝', `created_at` int(11) NOT NULL, PRIMARY KEY (`id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- 好友表 CREATE TABLE `friends` ( `id` int(11) unsigned NOT NULL AUTO_INCREMENT, `user_id` int(11) NOT NULL, `friend_id` int(11) NOT NULL, `created_at` int(11) NOT NULL, PRIMARY KEY (`id`), UNIQUE KEY `uk_user_friend` (`user_id`, `friend_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;好友请求通过后,请求记录状态改为"同意",同时往好友表里插入两条记录(A加B,B加A)。这是为了后面查询"我的好友列表"时,只需要select * from friends where user_id = 当前用户,不用判断方向。
4.4 聊天窗口与会话列表的设计
聊天窗口的WebSocket消息流前文讲了,这里补充会话列表的设计。会话列表展示的是:当前用户和每个好友的最后一条聊天消息、未读数、时间。
后端提供一个聚合接口:
GET /api/conversation/list返回每个好友的最后一条消息记录:
[ { "friend_id": 101, "friend_name": "张三", "friend_avatar": "http://...", "last_message": "你明天去图书馆吗", "last_time": 1714548800, "unread_count": 3 } ]实现方法是根据messages表的from_uid + to_uid组合,查找当前用户参与的所有会话,每组取最新一条。SQL里用GROUP BY加子查询排序,稍微绕一点。我当时用ThinkPHP的查询构造器加原生子查询解决的:
$list = Db::name('message') ->where('from_uid', '=', $uid) ->whereOr('to_uid', '=', $uid) ->order('create_time', 'desc') ->group('conversation_key') ->select();这里有个关键点:为了保证"同一组聊天"能被分组聚合,我在写入消息时给每条记录加了一个conversation_key字段,规则是min(from_uid, to_uid)_max(from_uid, to_uid)。比如用户1和用户2之间的所有消息,conversation_key 统一是1_2。这样分组就非常方便,不用复杂的JOIN。
5. 三端实现的差异化细节
5.1 Web端的实现要点
Web端是功能最全、开发最舒服的一端。桌面浏览器屏幕大、性能好,页面可以放更多信息,交互可以用更丰富的组件。
界面结构上,我采用的是左侧会话列表、右侧聊天窗口的经典布局:
- 顶部:个人信息入口、搜索框、系统通知按钮
- 左侧:会话列表,按最后消息时间倒序
- 右侧:聊天窗口,内容包括好友的基础信息卡片、消息气泡列表、底部输入工具栏
- 其他页面:发现页(推荐匹配的人)、动态广场页、个人中心页
Web端开发时要重点关注内存泄漏问题。单页应用里频繁切换路由,组件会被反复挂载和销毁。聊天组件里创建的 WebSocket 连接如果不在组件销毁时关闭,会导致连接数疯狂增长。
正确做法:
onUnmounted(() => { socket.close(); clearInterval(heartbeatTimer); });这是很多初学者会忽略的细节。大家只顾着看功能能不能通,忽略了资源释放。不做清理在开发环境可能感觉不到,但用户挂机一整天后,浏览器内存和连接数都会涨,页面变卡。这种问题很难排查,最好从一开始就把生命周期管理写对。
5.2 H5端的适配处理
H5端复用Web端的Vue代码,但需要做两件事:响应式布局和移动端交互适配。
响应式布局我采用的方案是手写媒体查询加移动端优先的CSS结构。聊天系统的布局天然适合"会话列表和聊天窗口上下切换"的移动端模式,而不是桌面端的左右分栏。具体做法:H5端检测到窄屏时,只显示会话列表;点击某个会话后,跳转到独立的聊天页面。
这个设计的关键在路由层面区分:
- Web端:
/chat/:friend_id直接渲染聊天窗口组件在当前页 - H5端:
/chat/:friend_id作为一个独立页面路由渲染
移动端还有几个交互细节:
- 输入框在手机上会弹起软键盘,应该用
fixed定位并监听window.visualViewport的高度变化,避免键盘遮住输入框。 - 触摸发送按钮时会有300毫秒点击延迟,现代浏览器通过
<meta name="viewport" content="width=device-width, initial-scale=1.0">基本已经消除。 - 图片消息在手机端要做压缩预览,原图走点击查看的大图模式,否则流量吃不消。
5.3 小程序端的实现与坑
小程序端我推荐用 uni-app 开发,它的最大优势是一套代码可以编译到微信小程序、H5甚至App。如果我们已经用Vue写了H5端,那利用 uni-app 的 Vue 语法兼容性,不少业务逻辑代码可以直接复用。
但别期待"一套代码跑三端完全不用改",现实是每端都要做兼容处理:
- 网络请求:小程序的
wx.request不支持同步,所有请求都要走回调或Promise封装。我封装了一个 uni.request 的统一工具函数。 - 本地存储:小程序没有 localStorage,要用
uni.setStorageSync/uni.getStorageSync。 - 实时通讯:小程序的 WebSocket API 是
wx.connectSocket,和浏览器的WebSocket对象不一样。uni-app 里用uni.connectSocket,API 设计上对齐了微信原生。 - 登录态:小程序调用
uni.login获取临时 code,后端拿 code 换 openid 然后生成自定义登录态。整套逻辑和Web端的账号密码登录完全不同。
小程序最容踩的坑是证书问题。微信小程序要求 WebSocket 连接必须是 wss:// 协议,不能是 ws://(除非在开发者工具里勾选"不校验合法域名")。你要去云服务商那里给域名配置SSL证书,并且在小程序后台把 wss 域名加入白名单。
如果没有备案域名也不打算上SSL证书,你依然可以在微信开发者工具里联调,但真机预览就废了。这个坑我在项目验收前遇到过,差点翻车。
5.4 三端通讯数据格式的差异统一
三个端的WebSocket消息结构我尽量做到完全一致。不管哪个端收到 new_message 类型消息,消息结构都是:
{ "type": "new_message", "data": { "message_id": 100111, "from_uid": 2001, "to_uid": 3002, "content": "晚上一起自习?", "msg_type": 1, "create_time": 1714548800 } }把业务数据包裹在 data 字段里,type 是消息类型标记,这样前端处理逻辑只需写一次:
function handleSocketMessage(rawData) { const msg = JSON.parse(rawData); switch (msg.type) { case 'new_message': appendMessage(msg.data); break; case 'friend_request': handleFriendRequest(msg.data); break; default: break; } }三端共用同一套数据结构约定,是保证开发效率的核心。比如好友请求的推送、系统通知的推送、被挤下线的强制下线通知,都用类似结构。
6. 常见问题与排错实录
6.1 WebSocket 连不上或频繁断开
这个问题的排查面比较广。我的排查顺序固定是:先看网络通不通,再看认证成不成功,最后看心跳有没有。
第一步,浏览器开发者工具切换到 Network 面板,刷新页面观察 WebSocket 连接是否有 101 切换协议状态码。如果没有这个状态码,说明握手阶段就失败了,要检查服务器地址、端口是否放行、协议是否写对(ws 还是 wss)。
第二步,确认 Workerman 服务进程是否常驻。Workerman 用php chat_server.php start启动后,终端会显示监听端口。如果你在云服务器上操作,记得安全组要放行8282端口。很多人的问题是服务器安全组没放行,本地测试正常,一上服务器就废。
第三步,观察心跳。如果连接几秒就断开,大概率是心跳逻辑有bug。一种常见情况是服务器主动断开了不活跃连接,Workerman 默认有$ws_worker->pingInterval设置,如果服务端设置的 pingInterval 小于客户端的30秒,服务端可能会认为客户端不活跃而下线。配置建议优先看一遍 Workerman 文档的 pingInterval 说明。
6.2 消息发送成功但对方收不到
这类问题的排查路径比较固定,用"是不是"逐层判断:
- 对方是否在线?如果对方不在线,消息走的是离线拉取,推送不触发。你在测试时两个账号都在线,才能验证推送链路。
- 服务端的 uid 到 connection 映射表是否还能查到对方?如果对方WebSocket已经断开但映射表没清理,消息会发给一个死连接,send 方法静默失败,不会报错。解决方案是在 onClose 回调里删掉映射,并把"发送失败"的状态回传触发重新推送。
- 消息是否落库成功?落库失败说明可能字段长度超限或者数据库连接异常。检查 MySQL 日志能看到线索。
我的经验是:这类问题90%出在"连接映射表状态错误"。不要在 send 失败时只打印日志不管,必须做重发机制。简单做法:如果推送失败,把消息存到一个 pending 表,等对方下次上线时合并拉取。
6.3 小程序真机预览时 WebSocket 连不上
大概率是协议、域名、端口三件事中的一个。
小程序真机要求使用 wss:// 协议,而且服务器域名必须在小程序后台配置为 socket 合法域名。云服务器上你的 WebSocket 服务如果监听的是 8282 端口,域名+端口要一起加进合法域名列表里。比如你是wss://api.yourdomain.com:8282,合法域名要填wss://api.yourdomain.com:8282而不是只填域名。
还有一种情况:你已经在服务器上配了 Nginx,WebSocket 服务挂在8282端口,但域名解析到服务器后,如果 Nginx 没有配置对应端口的转发规则,外网也访问不到。在 Nginx 配置中加一段:
location /ws { proxy_pass http://127.0.0.1:8282; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection "upgrade"; }然后通过wss://你的域名/ws路径去连接。这里有个容易踩的坑:Nginx 的proxy_set_header Connection "upgrade"是必需的,不写这一句,很多人会卡在连接不断重连上。
6.4 三端消息不同步、显示顺序错乱
如果你在Web端聊天,然后切到小程序端,发现聊天记录缺失或者双方消息顺序不对,原因基本出在消息拉取接口没有统一排序。
后端拉取历史消息时,必须确保from_uid和to_uid是同一个会话双向命中的。这里我踩过一个非常隐蔽的坑:在消息表里分别查"我发给你的"和"你发给我的",然后合并排序。因为两条查询是分开的,各按自己的时间倒序,合并后顺序就乱了。
正确写法是两条查询都按create_time asc取,然后合并后按create_time asc排序一次。前端的局部变量如果被后端返回的重复消息覆盖,还会导致显示跳动,这里推荐幂等处理:前端维护一个已存在消息ID的Set,新消息到达时先判断ID是否已存在,存在则丢弃。
7. 项目打包部署与上线体会
7.1 后端的部署流程
ThinkPHP项目的部署不算复杂,用 Nginx + PHP-FPM 的组合。
步骤按顺序做:
- 安装 PHP 7.4 或 8.0 和 Nginx。
- 把 ThinkPHP 项目代码上传到服务器,比如
/var/www/chat。 - 项目根目录下配置 Nginx 站点,把 document root 指向
public目录(ThinkPHP 的入口目录)。
一个标准的 ThinkPHP Nginx 配置:
server { listen 80; server_name yourdomain.com; root /var/www/chat/public; index index.php; location / { if (!-e $request_filename) { rewrite ^(.*)$ /index.php?s=$1 last; } } location ~ \.php$ { fastcgi_pass 127.0.0.1:9000; fastcgi_index index.php; include fastcgi_params; } }启动 Workerman 服务时,如果直接挂在终端运行,关闭SSH窗口服务就停了,所以要用start -d模式后台运行。
php chat_server.php start -d要让 Workerman 在服务器重启后还能自动运行,可以做 systemd 服务配置或借助 Supervisor 进程守护。我这个项目用 Supervisor 比较多,配置简单,还能在进程崩溃时自动拉起。
7.2 三端构建与发布
Web端和H5端构建:
npm run build构建产物是 dist 目录下的静态文件,Web端直接上传到 Nginx 静态目录即可。H5端如果同一个域名下访问,我习惯把 H5 的构建产物放在子路径,或者用不同端口区分。
小程序端的发布流程比较特殊。微信开发者工具中导入 uni-app 编译产出的小程序源码目录,上传代码,然后在微信公众平台提交审核。审核期间需要留意隐私保护协议是否填写完整,因为聊天系统涉及用户上传的文本、图片,这些用户隐私需要在微信后台配置声明,否则审核会以"涉及收集用户隐私"为由驳回。
我当时在这个环节被驳回过一次,原因是隐私保护指引里未声明"存储用户上传的图片素材"。重新填表后第二天就过审了。
7.3 上线后还需要关注的几个点
上线只是开始。校园社交产品有特别典型的非技术问题要提前想清楚:
- 垃圾注册和广告骚扰:需要接入简单的验证码和发言频率限制。后端对同一用户发送消息做限流,比如1秒内最多发3条,否则返回"发言太频繁"。
- 敏感词过滤:虽然是校园产品,但聊天内容里的违规词过滤不能省。维护一个敏感词表,消息入库前做检测,命中则替换为 *。
- 服务器成本控制:平时启动一台2核4G的小规格云服务器、带宽3M左右即可。如果做了图片存储,记得考虑对象存储的费用,或直接限制图片大小在2M以内,减少流量花销。
8. 最后的几个实操建议
按我个人做类似项目的体会,如果你真要动手实现这个系统,有几个优先级建议:
第一,先把"实时通讯最小闭环"跑通。不要一上来就做所有功能。我建议的最小目标:注册登录、加好友、聊天窗口收发消息,用两个浏览器窗口开着两个账号互聊。这个闭环通了,整个系统八成的工作量就已经干完了。
第二,前端代码中,WebSocket连接管理一定要抽成独立模块。不要在每个组件里各建一个连接。用一个全局单例管理连接状态、心跳、重连逻辑,组件只是订阅消息事件。这个设计决定了你后期新增功能改动的效率。
第三,消息数据结构,从一开始就把 message_id 作为全局唯一标识,用它做消息去重和增量拉取的锚点。不要用自增ID和本地时间戳混合,后面会很痛苦。
第四,答辩或演示前,务必准备一份"系统演示脚本"。不要上线后临场翻页面。设计好演示流程:注册一个新用户、完善标签、查看推荐列表、发动态、添加好友、发起聊天、多端同步,一气呵成。社交类项目最大的演示加分项是"三端联动",把手机小程序和电脑Web端同时打开互发消息,观感上很有说服力。
这个项目做完之后,我在代码里沉淀下来最值钱的东西其实不是功能本身,而是对"前端展示层、后端业务层、长连通讯层"三层架构的理解。以后再去做任何带实时交互的业务系统,这套思考路径都是直接可复用的。你如果打算拿这个题目做毕设,或者想做成自己的作品集项目,现在可以动手了。