[BE-05] 읽음 커서 + 안읽은 수

작업 내용 (설계 의도)

변경 사항

근거 TDD: 20260704-채팅시스템고도화-tdd.md (FR-7/9, 읽음 커서 경합 last-write-wins forward-only).

참여자별 읽음 커서를 갱신하고 안읽은 수를 집계한다. 멀티 디바이스 경합은 forward-only 단조 증가로 처리(Non-Goal: 정교한 기기 동기화 제외).

  • domain/message/service/ReadCursorDomainService.kt (신규): markRead(roomId, userId, lastReadMessageId) — 참여자 조회 → markReadUpTo → 저장 → 읽음 브로드캐스트(MessageBroadcastGateway.broadcastRead). unreadCount(roomId, userId), unreadForMyRooms(userId).
  • domain/message/repository/MessageCustomRepository.kt + Impl: countUnread(roomId, afterMessageId, excludeUserId): Long 추가 (id > afterMessageId AND user_id != me AND deleted_at IS NULL). — MessageCustomRepository 계열은 본 티켓 단독 수정.
  • application/message/usecase/MarkReadUseCase.kt, GetMyUnreadUseCase.kt (신규): DomainService만 호출, execute 10줄 이내.
  • application/message/dto/RoomUnreadResponse.kt (신규): {roomId, unreadCount} — TDD 응답 DTO 표대로.
  • presentation/message/controller/ReadCursorApiController.kt (신규 파일): POST /rooms/{roomId}/read(→ RoomUnreadResponse), GET /rooms/me/unread. 인증: @AuthenticationPrincipal UserPrincipal로 userId(Bearer JWT). MessageApiController 무수정.
  • 상태 보호: 읽음 반영은 forward-only라 역행 요청은 무시(멱등).

의존

  • BE-03b (RoomParticipant.lastReadMessageId, markReadUpTo)
  • BE-04 (MessageBroadcastGateway.broadcastRead — 읽음 실시간 표시)

다이어그램

클래스 의존

flowchart LR
    ReadCursorApiController --> MarkReadUseCase
    ReadCursorApiController --> GetMyUnreadUseCase
    MarkReadUseCase --> ReadCursorDomainService
    ReadCursorDomainService --> RoomParticipantRepository
    ReadCursorDomainService --> MessageCustomRepository
    ReadCursorDomainService --> MessageBroadcastGateway

테스트 케이스

  • lastReadMessageId=50 참여자가 방에 id 51~60의 상대 메시지가 있으면 unreadCount=10이다.
  • 본인이 보낸 메시지는 안읽은 수에서 제외된다.
  • 읽음 요청 시 /topic/rooms/{id}/read로 상대에게 읽음 이벤트가 브로드캐스트된다.
  • lastReadMessageId=60 상태에서 read(30)을 보내면 커서는 60으로 유지된다 (역행 무시·멱등).
  • 안읽은 메시지가 0건이면 unreadCount=0을 반환한다 (빈 상태).
  • soft-delete된 메시지는 안읽은 수에 포함되지 않는다.