[BE-03b] RoomParticipant 확장 (게스트·읽음 커서 필드)

작업 내용 (설계 의도)

변경 사항

근거 TDD: 20260704-채팅시스템고도화-tdd.md (FR-7/13/14, 실패 경로·동시성).

RoomParticipant에 참여 스코프 속성과 읽음 커서를 additive 추가하고, Rich Domain 메서드로 캡슐화한다.

  • RoomParticipant.kt: participantType: ParticipantType, canSpeak: Boolean, expiresAt: ZonedDateTime?, lastReadMessageId: Long? 추가.
    • 팩토리 create(room, userId)는 MEMBER·canSpeak=true·expiresAt=null 유지(기존 호환), forGuest(room, userId, canSpeak, expiresInDays) 추가 (시간은 메서드 내부 ZonedDateTime.now()로 해결 — no-time-parameter).
    • markReadUpTo(messageId): forward-only 단조 증가(lastReadMessageId == null || messageId > lastReadMessageId일 때만 갱신).
    • validateCanSpeak(): 읽기 전용 게스트면 예외.
    • validateNotExpired(): expiresAt != null && now > expiresAt이면 예외.
    • evict(): 방출 표시(soft-delete는 DomainService가 수행).
  • RoomParticipantRepository.kt + RoomParticipantCustomRepository.kt: findExpiredGuestsBefore(threshold): List, findActiveByUserId(userId): List 추가.
  • infrastructure impl 2종에 QueryDSL 구현.
  • 파일 범위: RoomParticipant.kt, RoomParticipantRepository.kt, RoomParticipantCustomRepository.kt + impl 2종. Room·MessageDomainService 무수정.
  • 롤백: BE-01 nullable/DEFAULT 컬럼이라 코드 롤백만으로 안전.

의존

  • BE-01 (room_participants 확장 컬럼)
  • BE-02 (ParticipantType)

다이어그램

클래스 의존

flowchart LR
    RoomParticipant --> ParticipantType
    RoomParticipantRepository --> RoomParticipant
    RoomParticipantCustomRepository --> RoomParticipant
    RoomParticipantRepositoryImpl -.implements.-> RoomParticipantRepository

테스트 케이스

  • forGuest(room, 5, canSpeak=true, expiresInDays=7)는 GUEST·expiresAt=now+7d 참여자를 만든다.
  • markReadUpTo(100)markReadUpTo(50)을 호출해도 lastReadMessageId는 100으로 유지된다 (forward-only).
  • 읽기 전용 게스트(canSpeak=false)의 validateCanSpeak()는 예외를 던진다.
  • expiresAt이 과거인 게스트의 validateNotExpired()는 예외를 던진다.
  • 만료 시각이 지나지 않은 게스트는 findExpiredGuestsBefore(now)에 포함되지 않는다.
  • MEMBER 참여자는 expiresAt=null이라 findExpiredGuestsBefore에서 제외된다.