本文基于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>