autdant commited on
Commit
07bff0f
·
1 Parent(s): bd9181f

cập nhật status người dùng

Browse files
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
- Duration ttl = Duration.ofSeconds(realtimeProperties.getRedis().getSessionTtlSeconds());
24
- stringRedisTemplate.opsForValue().set(sessionKey(sessionId), String.valueOf(userId), ttl);
25
- stringRedisTemplate.opsForValue().set(userSessionKey(userId, sessionId), "1", ttl);
 
 
 
 
 
 
 
 
26
  }
27
 
28
  public void removeSession(String sessionId) {
@@ -30,12 +47,83 @@ public class ChatPresenceService {
30
  return;
31
  }
32
 
33
- String sessionKey = sessionKey(sessionId);
34
- String userId = stringRedisTemplate.opsForValue().get(sessionKey);
35
- if (userId != null && !userId.isBlank()) {
36
- stringRedisTemplate.delete(userSessionKey(userId, sessionId));
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- - Giai doan hien tai chua dung Redis.
 
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`