记一次 docker laravel octane swoole websocket redis 聊天
聊天记录存入Redis(需定时存入关系型数据库)每次只展示部分聊天记录下拉更新聊天记录
redis 聊天记录表名 ws:chat:1_3(存放用户1和用户3 的聊天记录)
存放数据为
{"uuid":"makvl9ai0fok1","dialog_id":"1_3","from_id":1,"from":{"name":"许文强","avatar":"http:\/\/localhost\/images\/avatar\/xwq.png"},"to_id":3,"body":"1","msg_type":1,"created_at":"2025-05-12 17:23:36","has_read":false}
页面展示

1.前端代码
<!DOCTYPE html> <html lang="zh-CN"> <head> <meta charset="UTF-8"> <title>CHAT</title> <style> * { margin: 0; padding: 0; box-sizing: border-box; } body { font-family: 'Helvetica Neue', Arial, sans-serif; height: 100vh; display: flex; background: #f2f2f2; } .sidebar { width: 280px; background: #fff; border-right: 1px solid #ddd; overflow-y: auto; } .sidebar-header { padding: 20px; font-size: 18px; font-weight: bold; border-bottom: 1px solid #ddd; background: #ededed; } .contact-list { list-style: none; } .contact-list li { padding: 15px 20px; border-bottom: 1px solid #f0f0f0; cursor: pointer; display: flex; align-items: center; } .contact-list li:hover { background: #f5f5f5; } .contact-avatar { width: 40px; height: 40px; border-radius: 50%; background: #bbb; margin-right: 10px; flex-shrink: 0; } .contact-name { font-size: 16px; } .chat-container { flex: 1; display: flex; flex-direction: column; } .chat-header { padding: 20px; background: #ededed; border-bottom: 1px solid #ddd; font-size: 18px; font-weight: bold; } .chat-messages { flex: 1; padding: 20px; overflow-y: auto; background: #e5ddd5; display: flex; flex-direction: column; } .message-item { display: flex; margin-bottom: 15px; align-items: flex-end; } .message-item.me { justify-content: flex-end; } .avatar { width: 40px; height: 40px; border-radius: 50%; background: #bbb; margin: 0 10px; flex-shrink: 0; } .message-content { max-width: 60%; display: flex; flex-direction: column; align-items: flex-start; } .message-content .sender { font-size: 12px; color: #666; margin-bottom: 3px; } .message-content .bubble { padding: 10px 15px; border-radius: 10px; font-size: 14px; line-height: 1.5; word-break: break-word; background: #fff; border-bottom-left-radius: 0; } .message-item.me .message-content { align-items: flex-end; } .message-item.me .bubble { background: #9fe658; border-bottom-right-radius: 0; border-bottom-left-radius: 10px; } .chat-input-area { display: flex; padding: 10px; background: #f5f5f5; border-top: 1px solid #ddd; } .chat-input { flex: 1; padding: 10px; font-size: 16px; border: 1px solid #ccc; border-radius: 20px; outline: none; } .send-button { margin-left: 10px; padding: 10px 20px; background: #09bb07; color: white; border: none; border-radius: 20px; font-size: 16px; cursor: pointer; } .send-button:hover { background: #08a205; } .unread-dot { color: red; font-size: 12px; margin-left: 6px; } </style> </head> <body> <div class="sidebar"> <div class="sidebar-header">联系人</div> <ul class="contact-list" id="contact-list"> @foreach($FriendMessage as $friend) <li data-user="{{ $friend->name }}" data-avatar="{{ asset($friend->avatar) }}" data-id="{{ $friend->id }}"> <div class="contact-avatar" style="background-image: url('{{ asset($friend->avatar) }}'); background-size: cover;"></div> <div class="contact-name">{{ $friend->name }}</div> <span class="unread-dot" style="display:none;">🔴</span> <!-- 红点 --> </li> @endforeach </ul> </div> <div class="chat-container"> <div class="chat-header" id="chat-header">请选择联系人</div> <div class="chat-messages" id="chat-messages"></div> <div class="chat-input-area"> <input type="text" id="chat-input" class="chat-input" placeholder="输入消息..." /> <button class="send-button" id="send-btn">发送</button> </div> </div> <script src="https://code.jquery.com/jquery-3.6.0.min.js"></script> <script> let unreadMessages = {} const MselfMessage = @json($MselfMessage); const name = MselfMessage.name; const avatar = window.location.origin + MselfMessage.avatar; var token = localStorage.getItem('token'); const websocket = new WebSocket('ws://127.0.0.1:9501?token=' + token); let currentUser = null; let currentAvatar = null; let toId = null; let currentPage = 1;//第一页 websocket.onmessage = function (evt) { data = JSON.parse(evt.data); if(data.type=='message'){ const message = data.data; if (message.from_id === toId) { // 当前聊天窗口是这个人,直接显示 addMessage('other', message.body, message.from.name, message.from.avatar); //发送的消息直接标记已读 } // else { // // 存储未读消息 // if (!unreadMessages[message.from_id]) { // unreadMessages[message.from_id] = []; // } // unreadMessages[message.from_id].push(message); // // 更新未读数量 // const count = unreadMessages[message.from_id].length; // $(`#contact-list li[data-id="${message.from_id}"] .unread-dot`).text(`🔴${count}`).show(); // } const contactItem = $(`#contact-list li[data-id="${message.from_id}"]`); contactItem.prependTo('#contact-list'); }else if(data.type=='system'){ console.log(data.msg) } else if(data.type=='pong'){ console.log('续命成功') } }; // 点击联系人 $('#contact-list li').on('click', function () { currentAvatar = $(this).data('avatar'); currentUser=$(this).data('user'); $('#chat-header').text(currentUser); toId = $(this).data('id'); // 如果你有 to_id 数据,请确保 HTML 中加上 data-id $('#chat-messages').empty(); loadMessages(1) }); function loadMessages(page){ const container = $('#chat-messages'); //接收数据查询的信息 返回页面展示 $.post("{{url('admin/getChatMessage')}}",{page:page,toId:toId,token:token,_token: '{{ csrf_token() }}'},function (res){ if(res.code==200){ var ChatMessage=res.message; var key=''; ChatMessage.forEach(msg => { if(msg.from_id==MselfMessage.id){ key='me' }else{ key='other' } var fromName=msg.from.name var fromAvatar=msg.from.avatar addMessage(key, msg.body, fromName, fromAvatar,true); }); } },'json') $(this).find('.unread-dot').hide();//未读条数隐藏 // 如果是第一页(点击联系人),滚动到底部 if (page === 1) { setTimeout(() => { container.scrollTop(container[0].scrollHeight); }, 100); } } // 监听上滑事件加载更多 $('#chat-messages').on('scroll', function () { if ($(this).scrollTop() === 0) { currentPage++; loadMessages(currentPage); } }); // 发送按钮 $('#send-btn').on('click', sendMessage); // 回车发送 $('#chat-input').on('keypress', function (e) { if (e.which === 13) { sendMessage(); } }); function sendMessage() { const text = $('#chat-input').val().trim(); if (!text || !currentUser) { alert('请选择联系人并输入消息'); return; } const uuid = generateTimeBasedId(); addMessage('me', text, name, avatar); $('#chat-input').val(''); websocket.send(JSON.stringify({ function: 'say', uuid: uuid, from_id: MselfMessage.id, from: { "name": name, "avatar":avatar }, to_id: toId, body: text, msg_type: 1 })); } function addMessage(who, text, name, avatar,prepend = false) { const $item = $('<div>').addClass(`message-item ${who}`); const $avatar = $('<div>').addClass('avatar').css({ 'background-image': `url('${avatar}')`, 'background-size': 'cover' }); const $content = $('<div>').addClass('message-content'); $content.append($('<div>').addClass('sender').text(name)); $content.append($('<div>').addClass('bubble').text(text)); if (who === 'me') { $item.append($content).append($avatar); } else { $item.append($avatar).append($content); } const container = $('#chat-messages'); if (prepend) { container.prepend($item); } else { container.append($item).scrollTop(container[0].scrollHeight); } } //生成uuid function generateTimeBasedId() { return Date.now().toString(36) + Math.random().toString(36).substr(2, 5); } setInterval(() => { if (websocket.readyState === WebSocket.OPEN) { websocket.send(JSON.stringify({ function: 'ping', token: token })); } else { console.warn("WebSocket not open, cannot send ping."); } }, 30000); // 每 30 秒发一次 </script> </body> </html>
2.服务端代码
<?php namespace App\WebSocket; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Redis; use Swoole\Http\Request; use Swoole\WebSocket\Frame; use Swoole\WebSocket\Server; class WebSocketHandler { public $chatCache; public function __construct() { $this->chatCache = new ChatCache(); } protected array $clients = []; public function onOpen(Server $server, Request $request) { $fd = $request->fd; $token = $request->get['token'] ?? null; //未携带令牌token if (!$token) { $server->close($fd); return; } $userId = Redis::get('token_user:' . $token);//token有设置过期时间 //令牌token过期 if (!$userId) { $server->close($fd); return; } $OldFd=Redis::get('user_to_fd:'.$userId); //生成新的连接,旧链接关闭 if($OldFd){ Redis::del('fd_to_user:'.$OldFd); $server->close($OldFd); } // 记录在线 fd 和心跳时间 Redis::hset('online_users', $userId, $fd); Redis::setex("online:$userId", 70, date("Y-m-d H:i:s")); // 超时时间略长于 idle_time Redis::set('fd_to_user:' . $fd, $userId); Redis::set('user_to_fd:' . $userId, $fd); // 查询用户信息 $user = DB::table('user')->find($userId); $userName = $user->name ?? '未知用户'; // $this->clients[$fd] = true; $Message=$userName.'----'.$fd.'连接上了服务器'; if ($server->isEstablished($fd)) { $server->push($fd, json_encode([ 'type' => 'system', 'msg' => $Message ])); } } public function onMessage(Server $server, Frame $frame) { $data = json_decode($frame->data); $fd = $frame->fd; if (!isset($data->function)) { $server->push($fd, json_encode([ 'type' => 'error', 'msg' => 'Invalid request: function not found' ])); return; } switch ($data->function) { case 'login': Redis::set("ws:uid:{$data->user->id}", $fd); Redis::set("ws:online:{$data->user->id}", now()->timestamp); $server->push($fd, json_encode(['type' => 'login', 'status' => 'ok'])); break; case 'ping': $TokenUserkey='token_user:'.$data->token; $UserId=Redis::get($TokenUserkey); Redis::setex("ws:online:{$UserId}",70, date("Y-m-d H:i:s")); $server->push($fd, json_encode(['type' => 'pong'])); break; case 'say': $dialogId = $this->getDialogId($data->from_id, $data->to_id); $msg = [ 'uuid' => $data->uuid, 'dialog_id' => $dialogId, 'from_id' => $data->from_id, "from"=> [ "name" =>$data->from->name , "avatar" =>$data->from->avatar ], 'to_id' => $data->to_id, 'body' => $data->body, 'msg_type' => $data->msg_type, 'created_at' => date('Y-m-d H:i:s'), 'has_read' => false, ]; // 存入聊天记录 Redis::rpush("ws:chat:{$dialogId}", json_encode($msg,JSON_UNESCAPED_UNICODE)); $UnreadKey="unread:".$data->to_id; Redis::hincrby($UnreadKey, $data->from_id, 1); // 推送消息给对方 $toFd = Redis::get("user_to_fd:{$data->to_id}"); if ($toFd && $server->isEstablished((int)$toFd)) { $server->push((int)$toFd, json_encode(['type' => 'message', 'data' => $msg],JSON_UNESCAPED_UNICODE)); } // 推送给自己,确认消息送达 $server->push($fd, json_encode(['type' => 'said', 'data' => $msg],JSON_UNESCAPED_UNICODE)); break; } } public function onClose(Server $server, int $fd) { unset($this->clients[$fd]); $fdToUserKey='fd_to_user:'.$fd; $UserId=Redis::get($fdToUserKey); if ($UserId) { Redis::hdel('online_users', $UserId); Redis::del('fd_to_user:' . $fd); Redis::del('user_to_fd:' . $UserId); } // echo "Client {$fd} disconnected.\n"; } protected function getDialogId($id1, $id2) { $ids = [$id1, $id2]; sort($ids); return implode('_', $ids); } }
浙公网安备 33010602011771号