Spring Boot + WebSocket 实现单聊已读未读(四)

本文基于Spring Boot 整合 WebSocket + Redis 实现离线消息(三)实现,涉及修改内容如下:

1. 消息实体

java 复制代码
@Data
public class Message implements Serializable {
    private static final long serialVersionUID = 1L;
    private String messageId; // 唯一ID,用于已读回执
    private String fromUserId;
    private String toUserId;
    private String content;
    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
    private LocalDateTime timestamp;
    private Boolean offline; // 是否为离线消息
    private Integer status; // 0-未读,1-已读
    private String type; // "chat" / "ack" / "status"
}

2. 离线消息服务

java 复制代码
@Slf4j
@Service
@RequiredArgsConstructor
public class OfflineMessageService {

    private final RedisTemplate<String, Object> redisTemplate;
    private static final String OFFLINE_KEY_PREFIX = "offline:msg:";
    private static final String READ_KEY_PREFIX = "read:";
    private static final Duration MESSAGE_TTL = Duration.ofDays(7);

    // 保存离线消息(初始状态为未读)
    public void saveOfflineMessage(String userId, Message message) {
        message.setOffline(true);
        message.setStatus(0); // 未读
        String key = OFFLINE_KEY_PREFIX + userId;
        redisTemplate.opsForList().rightPush(key, message);
        redisTemplate.expire(key, MESSAGE_TTL);
        log.info("离线消息已存 -> 用户: {}, 内容: {}", userId, message.getContent());
    }

    // 获取并清除离线消息(仅返回未读的消息,已读的不再返回)
    public List<Message> getAndClearOfflineMessages(String userId) {
        String key = OFFLINE_KEY_PREFIX + userId;
        List<Object> rawList = redisTemplate.opsForList().range(key, 0, -1);
        if (rawList == null || rawList.isEmpty()) {
            return new ArrayList<>();
        }

        // 获取该用户已读的消息ID集合
        Set<String> readIds = getReadMessageIds(userId);

        List<Message> unreadMessages = new ArrayList<>();
        for (Object obj : rawList) {
            if (obj instanceof Message) {
                Message msg = (Message) obj;
                if (!readIds.contains(msg.getMessageId())) {
                    unreadMessages.add(msg);
                }
            }
        }
        // 删除整个离线队列(简单粗暴,因为已读的我们已经不需要再推送了)
        redisTemplate.delete(key);
        log.info("拉取并删除离线消息,用户: {}, 未读条数: {}", userId, unreadMessages.size());
        return unreadMessages;
    }

    // 标记消息为已读(对方看了你的消息后调用)
    public void markMessageAsRead(String userId, String messageId) {
        String readKey = READ_KEY_PREFIX + userId;
        redisTemplate.opsForSet().add(readKey, messageId);
        redisTemplate.expire(readKey, MESSAGE_TTL);
        log.info("消息已读标记: userId={}, messageId={}", userId, messageId);
    }

    // 批量标记已读(比如进入聊天窗口时)
    public void markMessagesAsRead(String userId, List<String> messageIds) {
        if (messageIds == null || messageIds.isEmpty()) return;
        String readKey = READ_KEY_PREFIX + userId;
        redisTemplate.opsForSet().add(readKey, messageIds.toArray(new Object[0]));
        redisTemplate.expire(readKey, MESSAGE_TTL);
    }

    // 获取用户已读的消息ID集合
    public Set<String> getReadMessageIds(String userId) {
        String readKey = READ_KEY_PREFIX + userId;
        Set<Object> members = redisTemplate.opsForSet().members(readKey);
        if (members == null) return new HashSet<>();
        return members.stream().map(String::valueOf).collect(Collectors.toSet());
    }

    // 查询离线消息数量(只统计未读)
    public Long getOfflineMessageCount(String userId) {
        String key = OFFLINE_KEY_PREFIX + userId;
        Long size = redisTemplate.opsForList().size(key);
        return size != null ? size : 0L;
    }
}

3. WebSocket 核心处理器

java 复制代码
@Slf4j
@Component
@RequiredArgsConstructor
public class MyWebSocketHandler extends TextWebSocketHandler {

    private final OfflineMessageService offlineMessageService;
    private final ObjectMapper objectMapper;
    private final ConcurrentHashMap<String, WebSocketSession> sessionMap = new ConcurrentHashMap<>();

    @Override
    public void afterConnectionEstablished(WebSocketSession session) {
        String userId = getUserIdFromSession(session);
        sessionMap.put(userId, session);
        log.info("用户上线: {}, 当前在线人数: {}", userId, sessionMap.size());

        // 推送离线消息(仅未读)
        List<Message> offlineMsgs = offlineMessageService.getAndClearOfflineMessages(userId);
        for (Message msg : offlineMsgs) {
            sendToSession(session, msg);
        }
    }

    @Override
    protected void handleTextMessage(WebSocketSession session, TextMessage message) throws Exception {
        String fromUserId = getUserIdFromSession(session);
        String payload = message.getPayload();
        JsonNode json = objectMapper.readTree(payload);

        // 处理已读回执
        if (json.has("type") && "read".equals(json.get("type").asText())) {
            String messageId = json.get("messageId").asText();
            String senderUserId = json.get("senderUserId").asText(); // 消息的发送者
            offlineMessageService.markMessageAsRead(senderUserId, messageId);

            // 通知原发送方:你的消息已被读
            Message readReceipt = new Message();
            readReceipt.setMessageId(messageId);
            readReceipt.setStatus(1);
            readReceipt.setContent(""); // 不需要内容,只表示状态变更
            readReceipt.setType("status"); // 自定义类型,前端用来更新状态
            sendToUser(senderUserId, readReceipt);
            log.info("已读回执已发送给 {}", senderUserId);
            return;
        }

        // 普通聊天消息
        Message msg = objectMapper.readValue(payload, Message.class);
        msg.setFromUserId(fromUserId);
        msg.setTimestamp(LocalDateTime.now());
        msg.setMessageId(UUID.randomUUID().toString());
        msg.setStatus(0); // 未读

        // 先给发送方回一个 ACK,携带 messageId 和初始状态,让前端显示"未读"
        Message ack = new Message();
        ack.setMessageId(msg.getMessageId());
        ack.setStatus(0);
        ack.setContent(msg.getContent()); // 可选
        ack.setFromUserId(fromUserId);
        ack.setType("ack"); // 标识这是发送确认
        sendToSession(session, ack);

        String toUserId = msg.getToUserId();
        WebSocketSession toSession = sessionMap.get(toUserId);

        if (toSession != null && toSession.isOpen()) {
            // 在线:直接推送给接收方
            sendToSession(toSession, msg);
            log.info("实时消息 {} -> {}: {}", fromUserId, toUserId, msg.getContent());
        } else {
            // 离线:存 Redis
            offlineMessageService.saveOfflineMessage(toUserId, msg);
            log.info("用户 {} 离线,消息已存储", toUserId);
        }
    }

    private void sendToSession(WebSocketSession session, Message msg) {
        try {
            String json = objectMapper.writeValueAsString(msg);
            session.sendMessage(new TextMessage(json));
        } catch (Exception e) {
            log.error("发送消息给 {} 失败: {}", getUserIdFromSession(session), e.getMessage());
        }
    }

    private void sendToUser(String userId, Message msg) {
        WebSocketSession session = sessionMap.get(userId);
        if (session != null && session.isOpen()) {
            sendToSession(session, msg);
        } else {
            log.warn("用户 {} 不在线,无法发送状态通知", userId);
        }
    }

    @Override
    public void afterConnectionClosed(WebSocketSession session, CloseStatus status) {
        String userId = getUserIdFromSession(session);
        sessionMap.remove(userId);
        log.info("用户下线: {}", userId);
    }

    private String getUserIdFromSession(WebSocketSession session) {
        String path = session.getUri().getPath();
        return path.substring(path.lastIndexOf('/') + 1);
    }
}

4. 前端页面

可直接复制到 src/main/resources/templates/demo.html

html 复制代码
<!DOCTYPE html>
<html lang="zh-CN">
<head>
    <meta charset="UTF-8">
    <title>WebSocket 已读未读 Demo</title>
    <style>
        * {
            box-sizing: border-box;
        }
        body {
            font-family: system-ui, -apple-system, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
            background: #f0f2f5;
            margin: 0;
            padding: 20px;
        }
        .container {
            max-width: 500px;
            margin: 0 auto;
            background: white;
            border-radius: 16px;
            box-shadow: 0 2px 10px rgba(0,0,0,0.1);
            overflow: hidden;
        }
        .header {
            background: #2c3e50;
            color: white;
            padding: 16px 20px;
            font-size: 18px;
            font-weight: 600;
            display: flex;
            justify-content: space-between;
            align-items: center;
        }
        .status {
            font-size: 12px;
            background: #e67e22;
            padding: 4px 8px;
            border-radius: 20px;
        }
        .status.connected {
            background: #27ae60;
        }
        .user-info {
            padding: 12px 20px;
            background: #ecf0f1;
            display: flex;
            gap: 12px;
            align-items: center;
            border-bottom: 1px solid #ddd;
        }
        .user-info label {
            font-weight: 600;
        }
        .user-info input {
            flex: 1;
            padding: 8px;
            border: 1px solid #ccc;
            border-radius: 8px;
        }
        .user-info button {
            background: #3498db;
            color: white;
            border: none;
            padding: 8px 16px;
            border-radius: 8px;
            cursor: pointer;
        }
        .chat-panel {
            display: flex;
            flex-direction: column;
            height: 500px;
        }
        .message-list {
            flex: 1;
            overflow-y: auto;
            padding: 16px;
            display: flex;
            flex-direction: column;
            gap: 8px;
        }
        .message {
            max-width: 80%;
            padding: 8px 12px;
            border-radius: 12px;
            font-size: 14px;
            word-wrap: break-word;
            position: relative;
        }
        .message.sent {
            background: #3498db;
            color: white;
            align-self: flex-end;
            border-bottom-right-radius: 4px;
        }
        .message.received {
            background: #e9ecef;
            color: #2c3e50;
            align-self: flex-start;
            border-bottom-left-radius: 4px;
        }
        .read-status {
            font-size: 10px;
            margin-left: 8px;
            color: #e67e22; /* 未读橙色 */
            font-weight: 500;
        }
        .read-status.read {
            color: #2ecc71; /* 已读绿色 */
            font-weight: normal;
        }
        .offline-tag {
            font-size: 10px;
            opacity: 0.7;
            margin-top: 4px;
        }
        .input-area {
            display: flex;
            padding: 12px;
            border-top: 1px solid #ddd;
            background: white;
            gap: 8px;
        }
        .input-area input {
            flex: 1;
            padding: 10px;
            border: 1px solid #ccc;
            border-radius: 24px;
            outline: none;
        }
        .input-area button {
            background: #2ecc71;
            border: none;
            padding: 0 20px;
            border-radius: 24px;
            color: white;
            font-weight: bold;
            cursor: pointer;
        }
        .info {
            font-size: 12px;
            color: #7f8c8d;
            text-align: center;
            padding: 8px;
            border-top: 1px solid #eee;
        }
    </style>
</head>
<body>
<div class="container">
    <div class="header">
        <span>💬 已读未读 Demo</span>
        <span class="status" id="statusText">未连接</span>
    </div>
    <div class="user-info">
        <label>我的ID:</label>
        <input type="text" id="myUserId" placeholder="例如 1001" value="1001">
        <button id="connectBtn">连接/重连</button>
    </div>
    <div class="chat-panel">
        <div class="message-list" id="messageList">
            <div class="info">👈 连接后即可收发消息</div>
        </div>
        <div class="input-area">
            <input type="text" id="targetUserId" placeholder="对方ID" value="1002">
            <input type="text" id="messageInput" placeholder="输入消息..." onkeypress="if(event.key==='Enter') sendMessage()">
            <button id="sendBtn" disabled>发送</button>
        </div>
    </div>
    <div class="info">
        💡 提示:关闭页面 = 离线,再次打开自动拉取离线消息<br>
        🎨 ✗未读(橙色) ✓已读(绿色)
    </div>
</div>

<script>
    document.addEventListener('DOMContentLoaded', function() {
        let ws = null;
        let currentUserId = null;
        // 存储我发送的消息 { messageId: statusSpan }
        let sentMessages = new Map();

        const messageList = document.getElementById('messageList');
        const statusSpan = document.getElementById('statusText');
        const sendBtn = document.getElementById('sendBtn');
        const connectBtn = document.getElementById('connectBtn');
        const myUserIdInput = document.getElementById('myUserId');
        const targetUserIdInput = document.getElementById('targetUserId');
        const messageInput = document.getElementById('messageInput');

        // 添加一条消息到界面
        function appendMessage(msg, isSent, isOffline = false) {
            const msgDiv = document.createElement('div');
            msgDiv.className = `message ${isSent ? 'sent' : 'received'}`;
            if (msg.messageId) msgDiv.setAttribute('data-mid', msg.messageId);

            let content = msg.content || '';
            if (msg.fromUserId && msg.fromUserId !== currentUserId) {
                content = `[${msg.fromUserId}] ${content}`;
            }
            msgDiv.innerText = content;

            if (isSent && msg.messageId) {
                const statusSpanElem = document.createElement('span');
                statusSpanElem.className = 'read-status';
                // 未读显示 ✗,已读显示 ✓
                statusSpanElem.innerText = (msg.status === 1) ? '✓已读' : '✗未读';
                if (msg.status === 1) statusSpanElem.classList.add('read');
                msgDiv.appendChild(statusSpanElem);
                sentMessages.set(msg.messageId, statusSpanElem);
            }

            if (isOffline) {
                const tag = document.createElement('div');
                tag.className = 'offline-tag';
                tag.innerText = '📦 离线消息';
                msgDiv.appendChild(tag);
            }

            if (msg.timestamp) {
                const time = new Date(msg.timestamp).toLocaleTimeString();
                const timeSpan = document.createElement('div');
                timeSpan.style.fontSize = '10px';
                timeSpan.style.opacity = '0.6';
                timeSpan.innerText = time;
                msgDiv.appendChild(timeSpan);
            }

            messageList.appendChild(msgDiv);
            messageList.scrollTop = messageList.scrollHeight;
            return msgDiv;
        }

        // 更新已读状态
        function updateMessageStatus(messageId, isRead) {
            const statusSpanElem = sentMessages.get(messageId);
            if (statusSpanElem) {
                statusSpanElem.innerText = isRead ? '✓已读' : '✗未读';
                if (isRead) {
                    statusSpanElem.classList.add('read');
                } else {
                    statusSpanElem.classList.remove('read');
                }
            }
        }

        function setConnected(connected) {
            if (connected) {
                statusSpan.innerText = '已连接';
                statusSpan.classList.add('connected');
                sendBtn.disabled = false;
                connectBtn.innerText = '断开连接';
            } else {
                statusSpan.innerText = '未连接';
                statusSpan.classList.remove('connected');
                sendBtn.disabled = true;
                connectBtn.innerText = '连接';
            }
        }

        function connect() {
            const userId = myUserIdInput.value.trim();
            if (!userId) {
                alert('请输入用户ID');
                return;
            }
            if (ws && ws.readyState === WebSocket.OPEN) {
                ws.close();
            }
            currentUserId = userId;
            const wsUrl = `ws://localhost:8080/ws/${userId}`;
            console.log('尝试连接: ' + wsUrl);
            try {
                ws = new WebSocket(wsUrl);
            } catch (e) {
                console.error('创建 WebSocket 失败:', e);
                alert('浏览器不支持 WebSocket 或 URL 无效');
                return;
            }

            ws.onopen = () => {
                console.log('WebSocket 已连接');
                setConnected(true);
                appendMessage({ content: '✅ 连接成功,离线消息会自动拉取', fromUserId: '系统' }, false);
            };

            ws.onmessage = (event) => {
                console.log('收到消息:', event.data);
                try {
                    const data = JSON.parse(event.data);
                    if (data.type === 'ack') {
                        appendMessage({
                            content: data.content,
                            fromUserId: currentUserId,
                            messageId: data.messageId,
                            status: data.status,
                            timestamp: new Date().toISOString()
                        }, true);
                    }
                    else if (data.type === 'status') {
                        updateMessageStatus(data.messageId, data.status === 1);
                    }
                    else {
                        const isOffline = data.offline === true;
                        appendMessage(data, false, isOffline);
                        if (data.messageId && data.fromUserId !== currentUserId) {
                            sendReadReceipt(data.messageId, data.fromUserId);
                        }
                    }
                } catch (e) {
                    console.error('解析消息失败', e);
                }
            };

            ws.onerror = (error) => {
                console.error('WebSocket 错误:', error);
                appendMessage({ content: '❌ 连接出错,请检查后端是否启动、端口是否正确', fromUserId: '系统' }, false);
                setConnected(false);
            };

            ws.onclose = () => {
                console.log('WebSocket 已关闭');
                setConnected(false);
            };
        }

        function disconnect() {
            if (ws && ws.readyState === WebSocket.OPEN) {
                ws.close();
            }
        }

        function sendReadReceipt(messageId, senderUserId) {
            if (ws && ws.readyState === WebSocket.OPEN) {
                const receipt = {
                    type: 'read',
                    messageId: messageId,
                    senderUserId: senderUserId
                };
                ws.send(JSON.stringify(receipt));
                console.log(`已读回执已发送: messageId=${messageId}, sender=${senderUserId}`);
            }
        }

        function sendMessage() {
            if (!ws || ws.readyState !== WebSocket.OPEN) {
                alert('未连接到服务器,请先连接');
                return;
            }
            const toUserId = targetUserIdInput.value.trim();
            const content = messageInput.value.trim();
            if (!toUserId || !content) {
                alert('请填写对方ID和消息内容');
                return;
            }
            const msg = {
                type: 'chat',
                toUserId: toUserId,
                content: content
            };
            ws.send(JSON.stringify(msg));
            messageInput.value = '';
        }

        connectBtn.onclick = () => {
            if (ws && ws.readyState === WebSocket.OPEN) {
                disconnect();
            } else {
                connect();
            }
        };
        sendBtn.onclick = sendMessage;

        window.addEventListener('beforeunload', () => {
            if (ws && ws.readyState === WebSocket.OPEN) {
                ws.close();
            }
        });
    });
</script>
</body>
</html>
相关推荐
AI玫瑰助手2 小时前
Python基础:列表的切片与嵌套列表使用技巧
android·开发语言·python
Rnan-prince2 小时前
Count-Min Sketch:海量数据频率统计的“轻量级计数器“
python·算法
彭于晏Yan2 小时前
Spring Boot 整合 WebSocket + Redis 实现离线消息(三)
spring boot·redis·websocket
yiruwanlu2 小时前
特色美食赋能乡村文旅设计:经典案例落地路径深度解析
大数据·人工智能·python
程序员老邢2 小时前
【产品底稿 06】商助慧V1.2实战复盘:Milvus向量库重构+RAG仿写升级+前端SSE排版彻底修复
java·人工智能·经验分享·spring boot·ai·milvus
阿丰资源2 小时前
基于SpringBoot+MySQL的在线拍卖系统设计与实现(附源码)
spring boot·后端·mysql
Han.miracle2 小时前
Spring Cloud + Nacos 环境切换与配置管理最佳实践
数据库·spring boot·spring cloud·maven
在屏幕前出油2 小时前
08. ORM——快速开始
数据库·后端·python·sql·pycharm·orm
捉鸭子2 小时前
某红书X-s X-s-common VMP逆向(算法还原)
python·web安全·网络安全·node.js·网络爬虫