Spaces:
Sleeping
Sleeping
cập nhật status người dùng
Browse files- connexa-parent/connexa-web/docs/chat/API_DOCS_CHAT.md +55 -1
- connexa-parent/connexa-web/src/main/java/org/connexa/connexaweb/chat/ConversationMemberResponseDTO.java +3 -0
- connexa-parent/connexa-web/src/main/java/org/connexa/connexaweb/chat/impl/ChatServiceImpl.java +15 -1
- connexa-parent/connexa-web/src/main/java/org/connexa/connexaweb/chat/realtime/ChatPresenceService.java +123 -8
- doc/README.md +2 -1
- doc/chat-frontend-integration.md +19 -0
- doc/chat-frontend-reconnect.md +28 -0
- doc/chat-websocket-event-classification.md +5 -0
connexa-parent/connexa-web/docs/chat/API_DOCS_CHAT.md
CHANGED
|
@@ -136,11 +136,24 @@ Response `201 Created`: cung shape voi `ConversationResponseDTO`.
|
|
| 136 |
|
| 137 |
Response `200 OK`: `CursorSliceResponse<ConversationResponseDTO>`.
|
| 138 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 139 |
### 1.4 Lay chi tiet conversation
|
| 140 |
|
| 141 |
`GET /api/chat/conversations/{conversationId}`
|
| 142 |
|
| 143 |
-
Response `200 OK`: `ConversationResponseDTO`
|
|
|
|
| 144 |
|
| 145 |
### 1.5 Cap nhat group conversation
|
| 146 |
|
|
@@ -372,6 +385,47 @@ hoac
|
|
| 372 |
`messageId` is treated as a conversation checkpoint: all previous messages in
|
| 373 |
the same conversation for the same user are also marked delivered and seen.
|
| 374 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 375 |
## 3. Chat Attachment APIs
|
| 376 |
|
| 377 |
### 3.1 Presign upload
|
|
|
|
| 136 |
|
| 137 |
Response `200 OK`: `CursorSliceResponse<ConversationResponseDTO>`.
|
| 138 |
|
| 139 |
+
`ConversationMemberResponseDTO` trong response co kem presence fields de
|
| 140 |
+
frontend khong can goi them request khi vao man chat:
|
| 141 |
+
|
| 142 |
+
```json
|
| 143 |
+
{
|
| 144 |
+
"userId": 12,
|
| 145 |
+
"online": true,
|
| 146 |
+
"activeSessionCount": 1,
|
| 147 |
+
"lastSeenAt": "2026-04-30T04:30:00Z"
|
| 148 |
+
}
|
| 149 |
+
```
|
| 150 |
+
|
| 151 |
### 1.4 Lay chi tiet conversation
|
| 152 |
|
| 153 |
`GET /api/chat/conversations/{conversationId}`
|
| 154 |
|
| 155 |
+
Response `200 OK`: `ConversationResponseDTO`, member list cung co presence
|
| 156 |
+
fields `online`, `activeSessionCount`, `lastSeenAt`.
|
| 157 |
|
| 158 |
### 1.5 Cap nhat group conversation
|
| 159 |
|
|
|
|
| 385 |
`messageId` is treated as a conversation checkpoint: all previous messages in
|
| 386 |
the same conversation for the same user are also marked delivered and seen.
|
| 387 |
|
| 388 |
+
### 2.8 My presence
|
| 389 |
+
|
| 390 |
+
`GET /api/chat/presence/me`
|
| 391 |
+
|
| 392 |
+
Response:
|
| 393 |
+
|
| 394 |
+
```json
|
| 395 |
+
{
|
| 396 |
+
"userId": 12,
|
| 397 |
+
"online": true,
|
| 398 |
+
"activeSessionCount": 1,
|
| 399 |
+
"lastSeenAt": "2026-04-30T04:30:00Z"
|
| 400 |
+
}
|
| 401 |
+
```
|
| 402 |
+
|
| 403 |
+
### 2.9 Conversation presence
|
| 404 |
+
|
| 405 |
+
`GET /api/chat/conversations/{conversationId}/presence`
|
| 406 |
+
|
| 407 |
+
Response:
|
| 408 |
+
|
| 409 |
+
```json
|
| 410 |
+
[
|
| 411 |
+
{
|
| 412 |
+
"userId": 12,
|
| 413 |
+
"online": true,
|
| 414 |
+
"activeSessionCount": 1,
|
| 415 |
+
"lastSeenAt": "2026-04-30T04:30:00Z"
|
| 416 |
+
}
|
| 417 |
+
]
|
| 418 |
+
```
|
| 419 |
+
|
| 420 |
+
WebSocket heartbeat destination:
|
| 421 |
+
|
| 422 |
+
```text
|
| 423 |
+
/app/chat.heartbeat
|
| 424 |
+
```
|
| 425 |
+
|
| 426 |
+
Heartbeat chi gia han Redis presence session. No khong cap nhat delivered hay
|
| 427 |
+
seen.
|
| 428 |
+
|
| 429 |
## 3. Chat Attachment APIs
|
| 430 |
|
| 431 |
### 3.1 Presign upload
|
connexa-parent/connexa-web/src/main/java/org/connexa/connexaweb/chat/ConversationMemberResponseDTO.java
CHANGED
|
@@ -27,4 +27,7 @@ public class ConversationMemberResponseDTO {
|
|
| 27 |
private Instant leftAt;
|
| 28 |
private Boolean isMuted;
|
| 29 |
private Integer lastReadMessageId;
|
|
|
|
|
|
|
|
|
|
| 30 |
}
|
|
|
|
| 27 |
private Instant leftAt;
|
| 28 |
private Boolean isMuted;
|
| 29 |
private Integer lastReadMessageId;
|
| 30 |
+
private Boolean online;
|
| 31 |
+
private Integer activeSessionCount;
|
| 32 |
+
private Instant lastSeenAt;
|
| 33 |
}
|
connexa-parent/connexa-web/src/main/java/org/connexa/connexaweb/chat/impl/ChatServiceImpl.java
CHANGED
|
@@ -34,6 +34,8 @@ import org.connexa.connexaweb.chat.MessageResponseDTO;
|
|
| 34 |
import org.connexa.connexaweb.chat.SendMessageRequestDTO;
|
| 35 |
import org.connexa.connexaweb.chat.UpdateGroupConversationRequestDTO;
|
| 36 |
import org.connexa.connexaweb.chat.UpdateMessageRequestDTO;
|
|
|
|
|
|
|
| 37 |
import org.connexa.connexaweb.chat.websocket.MessageReceiptEventResponse;
|
| 38 |
import org.connexa.connexaweb.follow.CursorSliceResponse;
|
| 39 |
import org.connexa.connexaweb.imagekit.MediaRepository;
|
|
@@ -69,6 +71,7 @@ public class ChatServiceImpl implements ChatService {
|
|
| 69 |
private final MessageReceiptRepository messageReceiptRepository;
|
| 70 |
private final UserRepository userRepository;
|
| 71 |
private final MediaRepository mediaRepository;
|
|
|
|
| 72 |
|
| 73 |
public ChatServiceImpl(
|
| 74 |
ConversationRepository conversationRepository,
|
|
@@ -77,7 +80,8 @@ public class ChatServiceImpl implements ChatService {
|
|
| 77 |
MessageAttachmentRepository messageAttachmentRepository,
|
| 78 |
MessageReceiptRepository messageReceiptRepository,
|
| 79 |
UserRepository userRepository,
|
| 80 |
-
MediaRepository mediaRepository
|
|
|
|
| 81 |
) {
|
| 82 |
this.conversationRepository = conversationRepository;
|
| 83 |
this.conversationMemberRepository = conversationMemberRepository;
|
|
@@ -86,6 +90,7 @@ public class ChatServiceImpl implements ChatService {
|
|
| 86 |
this.messageReceiptRepository = messageReceiptRepository;
|
| 87 |
this.userRepository = userRepository;
|
| 88 |
this.mediaRepository = mediaRepository;
|
|
|
|
| 89 |
}
|
| 90 |
|
| 91 |
@Override
|
|
@@ -739,10 +744,16 @@ public class ChatServiceImpl implements ChatService {
|
|
| 739 |
Map<Integer, UserProfile> usersById = new HashMap<>();
|
| 740 |
userRepository.findAllById(members.stream().map(ConversationMember::getUserId).toList())
|
| 741 |
.forEach(user -> usersById.put(user.getId(), user));
|
|
|
|
|
|
|
| 742 |
|
| 743 |
return members.stream()
|
| 744 |
.map(member -> {
|
| 745 |
UserProfile user = usersById.get(member.getUserId());
|
|
|
|
|
|
|
|
|
|
|
|
|
| 746 |
return ConversationMemberResponseDTO.builder()
|
| 747 |
.id(member.getId())
|
| 748 |
.userId(member.getUserId())
|
|
@@ -755,6 +766,9 @@ public class ChatServiceImpl implements ChatService {
|
|
| 755 |
.leftAt(member.getLeftAt())
|
| 756 |
.isMuted(member.getIsMuted())
|
| 757 |
.lastReadMessageId(member.getLastReadMessageId())
|
|
|
|
|
|
|
|
|
|
| 758 |
.build();
|
| 759 |
})
|
| 760 |
.toList();
|
|
|
|
| 34 |
import org.connexa.connexaweb.chat.SendMessageRequestDTO;
|
| 35 |
import org.connexa.connexaweb.chat.UpdateGroupConversationRequestDTO;
|
| 36 |
import org.connexa.connexaweb.chat.UpdateMessageRequestDTO;
|
| 37 |
+
import org.connexa.connexaweb.chat.realtime.ChatPresenceService;
|
| 38 |
+
import org.connexa.connexaweb.chat.realtime.ChatPresenceService.ChatPresenceSnapshot;
|
| 39 |
import org.connexa.connexaweb.chat.websocket.MessageReceiptEventResponse;
|
| 40 |
import org.connexa.connexaweb.follow.CursorSliceResponse;
|
| 41 |
import org.connexa.connexaweb.imagekit.MediaRepository;
|
|
|
|
| 71 |
private final MessageReceiptRepository messageReceiptRepository;
|
| 72 |
private final UserRepository userRepository;
|
| 73 |
private final MediaRepository mediaRepository;
|
| 74 |
+
private final ChatPresenceService chatPresenceService;
|
| 75 |
|
| 76 |
public ChatServiceImpl(
|
| 77 |
ConversationRepository conversationRepository,
|
|
|
|
| 80 |
MessageAttachmentRepository messageAttachmentRepository,
|
| 81 |
MessageReceiptRepository messageReceiptRepository,
|
| 82 |
UserRepository userRepository,
|
| 83 |
+
MediaRepository mediaRepository,
|
| 84 |
+
ChatPresenceService chatPresenceService
|
| 85 |
) {
|
| 86 |
this.conversationRepository = conversationRepository;
|
| 87 |
this.conversationMemberRepository = conversationMemberRepository;
|
|
|
|
| 90 |
this.messageReceiptRepository = messageReceiptRepository;
|
| 91 |
this.userRepository = userRepository;
|
| 92 |
this.mediaRepository = mediaRepository;
|
| 93 |
+
this.chatPresenceService = chatPresenceService;
|
| 94 |
}
|
| 95 |
|
| 96 |
@Override
|
|
|
|
| 744 |
Map<Integer, UserProfile> usersById = new HashMap<>();
|
| 745 |
userRepository.findAllById(members.stream().map(ConversationMember::getUserId).toList())
|
| 746 |
.forEach(user -> usersById.put(user.getId(), user));
|
| 747 |
+
Map<Integer, ChatPresenceSnapshot> presenceByUserId =
|
| 748 |
+
chatPresenceService.getPresence(members.stream().map(ConversationMember::getUserId).toList());
|
| 749 |
|
| 750 |
return members.stream()
|
| 751 |
.map(member -> {
|
| 752 |
UserProfile user = usersById.get(member.getUserId());
|
| 753 |
+
ChatPresenceSnapshot presence = presenceByUserId.getOrDefault(
|
| 754 |
+
member.getUserId(),
|
| 755 |
+
ChatPresenceSnapshot.offline(member.getUserId())
|
| 756 |
+
);
|
| 757 |
return ConversationMemberResponseDTO.builder()
|
| 758 |
.id(member.getId())
|
| 759 |
.userId(member.getUserId())
|
|
|
|
| 766 |
.leftAt(member.getLeftAt())
|
| 767 |
.isMuted(member.getIsMuted())
|
| 768 |
.lastReadMessageId(member.getLastReadMessageId())
|
| 769 |
+
.online(presence.online())
|
| 770 |
+
.activeSessionCount(presence.activeSessionCount())
|
| 771 |
+
.lastSeenAt(presence.lastSeenAt())
|
| 772 |
.build();
|
| 773 |
})
|
| 774 |
.toList();
|
connexa-parent/connexa-web/src/main/java/org/connexa/connexaweb/chat/realtime/ChatPresenceService.java
CHANGED
|
@@ -1,12 +1,21 @@
|
|
| 1 |
package org.connexa.connexaweb.chat.realtime;
|
| 2 |
|
|
|
|
|
|
|
| 3 |
import org.springframework.data.redis.core.StringRedisTemplate;
|
| 4 |
import org.springframework.stereotype.Service;
|
| 5 |
|
| 6 |
import java.time.Duration;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 7 |
|
| 8 |
@Service
|
| 9 |
public class ChatPresenceService {
|
|
|
|
|
|
|
| 10 |
private final StringRedisTemplate stringRedisTemplate;
|
| 11 |
private final ChatRealtimeProperties realtimeProperties;
|
| 12 |
|
|
@@ -20,9 +29,17 @@ public class ChatPresenceService {
|
|
| 20 |
return;
|
| 21 |
}
|
| 22 |
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 26 |
}
|
| 27 |
|
| 28 |
public void removeSession(String sessionId) {
|
|
@@ -30,12 +47,83 @@ public class ChatPresenceService {
|
|
| 30 |
return;
|
| 31 |
}
|
| 32 |
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 37 |
}
|
| 38 |
-
stringRedisTemplate.delete(sessionKey);
|
| 39 |
}
|
| 40 |
|
| 41 |
private String sessionKey(String sessionId) {
|
|
@@ -49,4 +137,31 @@ public class ChatPresenceService {
|
|
| 49 |
private String userSessionKey(String userId, String sessionId) {
|
| 50 |
return realtimeProperties.getRedis().getKeyPrefix() + ":user:" + userId + ":" + sessionId;
|
| 51 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 52 |
}
|
|
|
|
| 1 |
package org.connexa.connexaweb.chat.realtime;
|
| 2 |
|
| 3 |
+
import org.slf4j.Logger;
|
| 4 |
+
import org.slf4j.LoggerFactory;
|
| 5 |
import org.springframework.data.redis.core.StringRedisTemplate;
|
| 6 |
import org.springframework.stereotype.Service;
|
| 7 |
|
| 8 |
import java.time.Duration;
|
| 9 |
+
import java.time.Instant;
|
| 10 |
+
import java.util.Collection;
|
| 11 |
+
import java.util.HashMap;
|
| 12 |
+
import java.util.Map;
|
| 13 |
+
import java.util.Set;
|
| 14 |
|
| 15 |
@Service
|
| 16 |
public class ChatPresenceService {
|
| 17 |
+
private static final Logger LOGGER = LoggerFactory.getLogger(ChatPresenceService.class);
|
| 18 |
+
|
| 19 |
private final StringRedisTemplate stringRedisTemplate;
|
| 20 |
private final ChatRealtimeProperties realtimeProperties;
|
| 21 |
|
|
|
|
| 29 |
return;
|
| 30 |
}
|
| 31 |
|
| 32 |
+
try {
|
| 33 |
+
Duration ttl = Duration.ofSeconds(realtimeProperties.getRedis().getSessionTtlSeconds());
|
| 34 |
+
Instant now = Instant.now();
|
| 35 |
+
stringRedisTemplate.opsForValue().set(sessionKey(sessionId), String.valueOf(userId), ttl);
|
| 36 |
+
stringRedisTemplate.opsForValue().set(userSessionKey(userId, sessionId), "1", ttl);
|
| 37 |
+
stringRedisTemplate.opsForSet().add(userSessionsKey(userId), sessionId);
|
| 38 |
+
stringRedisTemplate.expire(userSessionsKey(userId), ttl);
|
| 39 |
+
stringRedisTemplate.opsForValue().set(lastSeenKey(userId), String.valueOf(now.toEpochMilli()));
|
| 40 |
+
} catch (RuntimeException ex) {
|
| 41 |
+
LOGGER.warn("Unable to touch chat presence session: userId={}, sessionId={}", userId, sessionId, ex);
|
| 42 |
+
}
|
| 43 |
}
|
| 44 |
|
| 45 |
public void removeSession(String sessionId) {
|
|
|
|
| 47 |
return;
|
| 48 |
}
|
| 49 |
|
| 50 |
+
try {
|
| 51 |
+
String sessionKey = sessionKey(sessionId);
|
| 52 |
+
String userId = stringRedisTemplate.opsForValue().get(sessionKey);
|
| 53 |
+
if (userId != null && !userId.isBlank()) {
|
| 54 |
+
stringRedisTemplate.delete(userSessionKey(userId, sessionId));
|
| 55 |
+
stringRedisTemplate.opsForSet().remove(userSessionsKey(userId), sessionId);
|
| 56 |
+
stringRedisTemplate.opsForValue().set(lastSeenKey(userId), String.valueOf(Instant.now().toEpochMilli()));
|
| 57 |
+
}
|
| 58 |
+
stringRedisTemplate.delete(sessionKey);
|
| 59 |
+
} catch (RuntimeException ex) {
|
| 60 |
+
LOGGER.warn("Unable to remove chat presence session: sessionId={}", sessionId, ex);
|
| 61 |
+
}
|
| 62 |
+
}
|
| 63 |
+
|
| 64 |
+
public boolean isOnline(Integer userId) {
|
| 65 |
+
return getPresence(userId).online();
|
| 66 |
+
}
|
| 67 |
+
|
| 68 |
+
public ChatPresenceSnapshot getPresence(Integer userId) {
|
| 69 |
+
if (!realtimeProperties.getRedis().isEnabled() || userId == null) {
|
| 70 |
+
return ChatPresenceSnapshot.offline(userId);
|
| 71 |
+
}
|
| 72 |
+
|
| 73 |
+
try {
|
| 74 |
+
int activeSessionCount = countActiveSessions(userId);
|
| 75 |
+
return new ChatPresenceSnapshot(
|
| 76 |
+
userId,
|
| 77 |
+
activeSessionCount > 0,
|
| 78 |
+
activeSessionCount,
|
| 79 |
+
readLastSeenAt(userId)
|
| 80 |
+
);
|
| 81 |
+
} catch (RuntimeException ex) {
|
| 82 |
+
LOGGER.warn("Unable to read chat presence: userId={}", userId, ex);
|
| 83 |
+
return ChatPresenceSnapshot.offline(userId);
|
| 84 |
+
}
|
| 85 |
+
}
|
| 86 |
+
|
| 87 |
+
public Map<Integer, ChatPresenceSnapshot> getPresence(Collection<Integer> userIds) {
|
| 88 |
+
Map<Integer, ChatPresenceSnapshot> snapshots = new HashMap<>();
|
| 89 |
+
if (userIds == null || userIds.isEmpty()) {
|
| 90 |
+
return snapshots;
|
| 91 |
+
}
|
| 92 |
+
|
| 93 |
+
for (Integer userId : userIds) {
|
| 94 |
+
snapshots.put(userId, getPresence(userId));
|
| 95 |
+
}
|
| 96 |
+
return snapshots;
|
| 97 |
+
}
|
| 98 |
+
|
| 99 |
+
private int countActiveSessions(Integer userId) {
|
| 100 |
+
Set<String> sessionIds = stringRedisTemplate.opsForSet().members(userSessionsKey(userId));
|
| 101 |
+
if (sessionIds == null || sessionIds.isEmpty()) {
|
| 102 |
+
return 0;
|
| 103 |
+
}
|
| 104 |
+
|
| 105 |
+
int activeSessionCount = 0;
|
| 106 |
+
for (String sessionId : sessionIds) {
|
| 107 |
+
if (Boolean.TRUE.equals(stringRedisTemplate.hasKey(sessionKey(sessionId)))) {
|
| 108 |
+
activeSessionCount++;
|
| 109 |
+
} else {
|
| 110 |
+
stringRedisTemplate.opsForSet().remove(userSessionsKey(userId), sessionId);
|
| 111 |
+
stringRedisTemplate.delete(userSessionKey(userId, sessionId));
|
| 112 |
+
}
|
| 113 |
+
}
|
| 114 |
+
return activeSessionCount;
|
| 115 |
+
}
|
| 116 |
+
|
| 117 |
+
private Instant readLastSeenAt(Integer userId) {
|
| 118 |
+
String rawLastSeenAt = stringRedisTemplate.opsForValue().get(lastSeenKey(userId));
|
| 119 |
+
if (rawLastSeenAt == null || rawLastSeenAt.isBlank()) {
|
| 120 |
+
return null;
|
| 121 |
+
}
|
| 122 |
+
try {
|
| 123 |
+
return Instant.ofEpochMilli(Long.parseLong(rawLastSeenAt));
|
| 124 |
+
} catch (NumberFormatException ex) {
|
| 125 |
+
return null;
|
| 126 |
}
|
|
|
|
| 127 |
}
|
| 128 |
|
| 129 |
private String sessionKey(String sessionId) {
|
|
|
|
| 137 |
private String userSessionKey(String userId, String sessionId) {
|
| 138 |
return realtimeProperties.getRedis().getKeyPrefix() + ":user:" + userId + ":" + sessionId;
|
| 139 |
}
|
| 140 |
+
|
| 141 |
+
private String userSessionsKey(Integer userId) {
|
| 142 |
+
return userSessionsKey(String.valueOf(userId));
|
| 143 |
+
}
|
| 144 |
+
|
| 145 |
+
private String userSessionsKey(String userId) {
|
| 146 |
+
return realtimeProperties.getRedis().getKeyPrefix() + ":user:" + userId + ":sessions";
|
| 147 |
+
}
|
| 148 |
+
|
| 149 |
+
private String lastSeenKey(Integer userId) {
|
| 150 |
+
return lastSeenKey(String.valueOf(userId));
|
| 151 |
+
}
|
| 152 |
+
|
| 153 |
+
private String lastSeenKey(String userId) {
|
| 154 |
+
return realtimeProperties.getRedis().getKeyPrefix() + ":user:" + userId + ":last_seen";
|
| 155 |
+
}
|
| 156 |
+
|
| 157 |
+
public record ChatPresenceSnapshot(
|
| 158 |
+
Integer userId,
|
| 159 |
+
boolean online,
|
| 160 |
+
int activeSessionCount,
|
| 161 |
+
Instant lastSeenAt
|
| 162 |
+
) {
|
| 163 |
+
public static ChatPresenceSnapshot offline(Integer userId) {
|
| 164 |
+
return new ChatPresenceSnapshot(userId, false, 0, null);
|
| 165 |
+
}
|
| 166 |
+
}
|
| 167 |
}
|
doc/README.md
CHANGED
|
@@ -9,6 +9,7 @@ Cac tai lieu chinh:
|
|
| 9 |
- [chat-message-media-contract.md](chat-message-media-contract.md): contract rieng cho message dang image, video, file va audio.
|
| 10 |
- [chat-websocket-event-classification.md](chat-websocket-event-classification.md): phan loai cac luong WebSocket cho gui tin, xem tin va receipt/typing.
|
| 11 |
- [chat-frontend-reconnect.md](chat-frontend-reconnect.md): huong dan frontend reconnect WebSocket/STOMP, sync backlog va ack delivered/seen.
|
|
|
|
| 12 |
- [chat-lexical-json-contract.md](chat-lexical-json-contract.md): chuan JSON noi dung chat theo huong Lexical, bam theo cach Post dang luu `contentJSON`.
|
| 13 |
- [chat-test-spec.md](chat-test-spec.md): du lieu mau, input/output, vi du va test case cho toan bo phan chat.
|
| 14 |
- [chat-frontend-integration.md](chat-frontend-integration.md): huong dan frontend web goi API, connect WebSocket/STOMP va upload attachment.
|
|
@@ -18,4 +19,4 @@ Quy uoc da chot:
|
|
| 18 |
- Chat media chi dung Cloudflare R2.
|
| 19 |
- Backend chi presign upload/access URL, client upload truc tiep len R2.
|
| 20 |
- Noi dung text cua message dung `contentJSON` dang Lexical JSON, con `content` la plain text duoc backend trich xuat de search/preview.
|
| 21 |
-
-
|
|
|
|
| 9 |
- [chat-message-media-contract.md](chat-message-media-contract.md): contract rieng cho message dang image, video, file va audio.
|
| 10 |
- [chat-websocket-event-classification.md](chat-websocket-event-classification.md): phan loai cac luong WebSocket cho gui tin, xem tin va receipt/typing.
|
| 11 |
- [chat-frontend-reconnect.md](chat-frontend-reconnect.md): huong dan frontend reconnect WebSocket/STOMP, sync backlog va ack delivered/seen.
|
| 12 |
+
- [chat-presence-architecture.md](chat-presence-architecture.md): kien truc online/presence bang Redis session TTL, heartbeat va API query presence.
|
| 13 |
- [chat-lexical-json-contract.md](chat-lexical-json-contract.md): chuan JSON noi dung chat theo huong Lexical, bam theo cach Post dang luu `contentJSON`.
|
| 14 |
- [chat-test-spec.md](chat-test-spec.md): du lieu mau, input/output, vi du va test case cho toan bo phan chat.
|
| 15 |
- [chat-frontend-integration.md](chat-frontend-integration.md): huong dan frontend web goi API, connect WebSocket/STOMP va upload attachment.
|
|
|
|
| 19 |
- Chat media chi dung Cloudflare R2.
|
| 20 |
- Backend chi presign upload/access URL, client upload truc tiep len R2.
|
| 21 |
- Noi dung text cua message dung `contentJSON` dang Lexical JSON, con `content` la plain text duoc backend trich xuat de search/preview.
|
| 22 |
+
- Redis duoc dung cho chat presence/session TTL; khong dung Redis de quyet dinh delivered.
|
doc/chat-frontend-integration.md
CHANGED
|
@@ -89,6 +89,21 @@ User-level:
|
|
| 89 |
/user/queue/receipts
|
| 90 |
```
|
| 91 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 92 |
## 4. Authentication
|
| 93 |
|
| 94 |
## 4.1. REST
|
|
@@ -748,8 +763,12 @@ Frontend nen:
|
|
| 748 |
/app/chat.typing
|
| 749 |
/app/chat.delivered
|
| 750 |
/app/chat.seen
|
|
|
|
| 751 |
```
|
| 752 |
|
|
|
|
|
|
|
|
|
|
| 753 |
## 9.2. Client subscribe
|
| 754 |
|
| 755 |
Theo room dang mo:
|
|
|
|
| 89 |
/user/queue/receipts
|
| 90 |
```
|
| 91 |
|
| 92 |
+
Conversation/member response tra kem presence:
|
| 93 |
+
|
| 94 |
+
```json
|
| 95 |
+
{
|
| 96 |
+
"userId": 12,
|
| 97 |
+
"online": true,
|
| 98 |
+
"activeSessionCount": 1,
|
| 99 |
+
"lastSeenAt": "2026-04-30T04:30:00Z"
|
| 100 |
+
}
|
| 101 |
+
```
|
| 102 |
+
|
| 103 |
+
Frontend nen dung presence trong response nay khi vao man chat de tranh goi
|
| 104 |
+
them request. Endpoint `/api/chat/conversations/{conversationId}/presence` chi
|
| 105 |
+
can dung khi muon refresh/poll trang thai online ma khong reload conversation.
|
| 106 |
+
|
| 107 |
## 4. Authentication
|
| 108 |
|
| 109 |
## 4.1. REST
|
|
|
|
| 763 |
/app/chat.typing
|
| 764 |
/app/chat.delivered
|
| 765 |
/app/chat.seen
|
| 766 |
+
/app/chat.heartbeat
|
| 767 |
```
|
| 768 |
|
| 769 |
+
`/app/chat.heartbeat` chi giu Redis presence session song. No khong cap nhat
|
| 770 |
+
delivered/seen.
|
| 771 |
+
|
| 772 |
## 9.2. Client subscribe
|
| 773 |
|
| 774 |
Theo room dang mo:
|
doc/chat-frontend-reconnect.md
CHANGED
|
@@ -85,6 +85,7 @@ GET /api/chat/conversations/{conversationId}/messages
|
|
| 85 |
- subscribe `/user/queue/conversations`
|
| 86 |
- subscribe `/user/queue/receipts`
|
| 87 |
- subscribe room dang mo neu co
|
|
|
|
| 88 |
- goi REST sync cho conversation dang mo
|
| 89 |
- flush `pendingDeliveredAck`
|
| 90 |
- flush `pendingSeenAck` neu room van dang visible/active
|
|
@@ -107,6 +108,7 @@ Khi WebSocket disconnect bat thuong:
|
|
| 107 |
4. Khi connect lai thanh cong:
|
| 108 |
- subscribe lai user queue
|
| 109 |
- subscribe lai active room
|
|
|
|
| 110 |
- sync lai conversation dang mo bang REST
|
| 111 |
- ack delivered cho message moi nhat da nhan/load duoc
|
| 112 |
- ack seen neu user van dang mo room do
|
|
@@ -117,6 +119,32 @@ Backoff co jitter de tranh nhieu tab reconnect cung luc:
|
|
| 117 |
delay = min(baseDelay * 2^attempt, 30000) + random(0..500)
|
| 118 |
```
|
| 119 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 120 |
## 6. Sync sau reconnect
|
| 121 |
|
| 122 |
Sau reconnect, frontend can coi WebSocket event trong thoi gian mat ket noi la
|
|
|
|
| 85 |
- subscribe `/user/queue/conversations`
|
| 86 |
- subscribe `/user/queue/receipts`
|
| 87 |
- subscribe room dang mo neu co
|
| 88 |
+
- start heartbeat `/app/chat.heartbeat`
|
| 89 |
- goi REST sync cho conversation dang mo
|
| 90 |
- flush `pendingDeliveredAck`
|
| 91 |
- flush `pendingSeenAck` neu room van dang visible/active
|
|
|
|
| 108 |
4. Khi connect lai thanh cong:
|
| 109 |
- subscribe lai user queue
|
| 110 |
- subscribe lai active room
|
| 111 |
+
- start lai heartbeat
|
| 112 |
- sync lai conversation dang mo bang REST
|
| 113 |
- ack delivered cho message moi nhat da nhan/load duoc
|
| 114 |
- ack seen neu user van dang mo room do
|
|
|
|
| 119 |
delay = min(baseDelay * 2^attempt, 30000) + random(0..500)
|
| 120 |
```
|
| 121 |
|
| 122 |
+
## 5.1. Heartbeat va online state
|
| 123 |
+
|
| 124 |
+
Backend luu online state bang Redis session TTL. Frontend nen gui heartbeat
|
| 125 |
+
dinh ky khi WebSocket connected, dac biet khi user idle khong typing/seen/send.
|
| 126 |
+
|
| 127 |
+
Destination:
|
| 128 |
+
|
| 129 |
+
```text
|
| 130 |
+
/app/chat.heartbeat
|
| 131 |
+
```
|
| 132 |
+
|
| 133 |
+
Payload:
|
| 134 |
+
|
| 135 |
+
```json
|
| 136 |
+
{}
|
| 137 |
+
```
|
| 138 |
+
|
| 139 |
+
Tan suat khuyen dung:
|
| 140 |
+
|
| 141 |
+
```text
|
| 142 |
+
30-60s neu backend TTL = 120s
|
| 143 |
+
```
|
| 144 |
+
|
| 145 |
+
Heartbeat chi giu online session song. Heartbeat khong co nghia la delivered
|
| 146 |
+
va khong cap nhat `delivered_at`.
|
| 147 |
+
|
| 148 |
## 6. Sync sau reconnect
|
| 149 |
|
| 150 |
Sau reconnect, frontend can coi WebSocket event trong thoi gian mat ket noi la
|
doc/chat-websocket-event-classification.md
CHANGED
|
@@ -46,6 +46,7 @@ Day la nhung destination mang prefix `/app`:
|
|
| 46 |
- `/app/chat.delivered`
|
| 47 |
- `/app/chat.seen`
|
| 48 |
- `/app/chat.typing`
|
|
|
|
| 49 |
|
| 50 |
### 3.2. Event backend phat xuong client
|
| 51 |
|
|
@@ -65,6 +66,7 @@ Day la nhung destination de client subscribe:
|
|
| 65 |
| Bao da nhan tin | `/app/chat.delivered` | `/topic/conversations.{conversationId}.receipts` va `/user/queue/receipts` | `MessageReceiptEventResponse` |
|
| 66 |
| Bao da xem tin | `/app/chat.seen` | `/topic/conversations.{conversationId}.receipts` va `/user/queue/receipts` | `MessageReceiptEventResponse` |
|
| 67 |
| Dang go tin | `/app/chat.typing` | `/topic/conversations.{conversationId}.typing` | `TypingEventResponse` |
|
|
|
|
| 68 |
|
| 69 |
## 5. Luong 1: Gui tin nhan
|
| 70 |
|
|
@@ -430,6 +432,8 @@ Quy tac uu tien receipt:
|
|
| 430 |
- `MessageResponseDTO` la event noi dung tin nhan.
|
| 431 |
- `MessageReceiptEventResponse` la event trang thai cua tin nhan.
|
| 432 |
- `TypingEventResponse` la event tam thoi, khong phai state luu tru ben DB.
|
|
|
|
|
|
|
| 433 |
|
| 434 |
## 13. Checklist tich hop
|
| 435 |
|
|
@@ -437,6 +441,7 @@ Quy tac uu tien receipt:
|
|
| 437 |
- Subscribe duoc room topic khi mo conversation
|
| 438 |
- Subscribe duoc user queue cho global state
|
| 439 |
- Gui duoc message qua `/app/chat.send`
|
|
|
|
| 440 |
- Gui duoc delivered qua `/app/chat.delivered`
|
| 441 |
- Gui duoc seen qua `/app/chat.seen`
|
| 442 |
- Phan biet duoc `MessageResponseDTO` va `MessageReceiptEventResponse`
|
|
|
|
| 46 |
- `/app/chat.delivered`
|
| 47 |
- `/app/chat.seen`
|
| 48 |
- `/app/chat.typing`
|
| 49 |
+
- `/app/chat.heartbeat`
|
| 50 |
|
| 51 |
### 3.2. Event backend phat xuong client
|
| 52 |
|
|
|
|
| 66 |
| Bao da nhan tin | `/app/chat.delivered` | `/topic/conversations.{conversationId}.receipts` va `/user/queue/receipts` | `MessageReceiptEventResponse` |
|
| 67 |
| Bao da xem tin | `/app/chat.seen` | `/topic/conversations.{conversationId}.receipts` va `/user/queue/receipts` | `MessageReceiptEventResponse` |
|
| 68 |
| Dang go tin | `/app/chat.typing` | `/topic/conversations.{conversationId}.typing` | `TypingEventResponse` |
|
| 69 |
+
| Giu online session | `/app/chat.heartbeat` | Khong co event response | none |
|
| 70 |
|
| 71 |
## 5. Luong 1: Gui tin nhan
|
| 72 |
|
|
|
|
| 432 |
- `MessageResponseDTO` la event noi dung tin nhan.
|
| 433 |
- `MessageReceiptEventResponse` la event trang thai cua tin nhan.
|
| 434 |
- `TypingEventResponse` la event tam thoi, khong phai state luu tru ben DB.
|
| 435 |
+
- `/app/chat.heartbeat` chi giu Redis presence session song, khong phai receipt
|
| 436 |
+
va khong tao delivered/seen.
|
| 437 |
|
| 438 |
## 13. Checklist tich hop
|
| 439 |
|
|
|
|
| 441 |
- Subscribe duoc room topic khi mo conversation
|
| 442 |
- Subscribe duoc user queue cho global state
|
| 443 |
- Gui duoc message qua `/app/chat.send`
|
| 444 |
+
- Gui heartbeat dinh ky qua `/app/chat.heartbeat` neu app idle lau hon TTL
|
| 445 |
- Gui duoc delivered qua `/app/chat.delivered`
|
| 446 |
- Gui duoc seen qua `/app/chat.seen`
|
| 447 |
- Phan biet duoc `MessageResponseDTO` va `MessageReceiptEventResponse`
|