记一次 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);
    }
}

 

posted @ 2025-05-13 14:21  SHACK元  阅读(84)  评论(0)    收藏  举报