From f26c97861e43aef13df12af1d269d2b1838d8d77 Mon Sep 17 00:00:00 2001 From: Klaus Date: Thu, 19 Mar 2026 16:20:47 +0900 Subject: [PATCH 1/3] =?UTF-8?q?feat(live-room):=20=EB=9D=BC=EC=9D=B4?= =?UTF-8?q?=EB=B8=8C=20=EB=A3=B8=20=EC=B1=84=ED=8C=85=20=EC=96=BC=EB=A6=BC?= =?UTF-8?q?=20=EC=83=81=ED=83=9C=20=EC=A0=80=EC=9E=A5/=EC=A1=B0=ED=9A=8C?= =?UTF-8?q?=20=EA=B8=B0=EB=8A=A5=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - `LiveRoomInfo`에 `isChatFrozen` 필드(기본 false) 추가하여 Redis에 상태 저장 가능 - `GetRoomInfoResponse`에 `isChatFrozen` 노출 및 `LiveRoomService.getRoomInfo` 매핑 반영 - 요청 DTO `SetChatFreezeRequest(roomId, isChatFrozen)` 추가 - `PUT /live/room/info/set/chat-freeze` 엔드포인트 추가(크리에이터 권한 검증 포함) --- ...20260319_라이브룸채팅얼림상태저장및조회.md | 32 +++++++++++++++++++ .../sodalive/live/room/LiveRoomController.kt | 11 +++++++ .../sodalive/live/room/LiveRoomService.kt | 22 ++++++++++++- .../live/room/info/GetRoomInfoResponse.kt | 3 +- .../sodalive/live/room/info/LiveRoomInfo.kt | 3 ++ .../live/room/info/SetChatFreezeRequest.kt | 6 ++++ 6 files changed, 75 insertions(+), 2 deletions(-) create mode 100644 docs/20260319_라이브룸채팅얼림상태저장및조회.md create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/live/room/info/SetChatFreezeRequest.kt diff --git a/docs/20260319_라이브룸채팅얼림상태저장및조회.md b/docs/20260319_라이브룸채팅얼림상태저장및조회.md new file mode 100644 index 00000000..295edcc8 --- /dev/null +++ b/docs/20260319_라이브룸채팅얼림상태저장및조회.md @@ -0,0 +1,32 @@ +# 라이브 룸 채팅 얼림 상태 저장/조회 추가 + +## 체크리스트 +- [x] 데이터 모델(LiveRoomInfo)에 `isChatFrozen` 필드(Boolean, 기본 false) 추가 +- [x] 요청 DTO `SetChatFreezeRequest(roomId, isChatFrozen)` 추가 +- [x] 서비스 `setChatFreeze` 구현(권한: 크리에이터만) +- [x] 컨트롤러 `PUT /live/room/info/set/chat-freeze` 엔드포인트 추가 +- [x] `GetRoomInfoResponse`에 `isChatFrozen`(Boolean, 기본 false) 추가 및 조회 응답 포함 +- [x] 단위 테스트는 불필요 판단으로 제거(수동 테스트 가이드로 대체) +- [x] `./gradlew build`로 컴파일 확인 +- [x] `./gradlew ktlintCheck` 실행 및 포맷 확인 + +## 검증 기록 +### 1차 구현 +- 무엇을: 채팅 얼림 상태 저장/조회 기능 구현 +- 왜: 라이브 룸 채팅 제어 기능 제공을 위해 +- 어떻게: + - 빌드/테스트 명령 실행: `./gradlew clean build` 성공, `./gradlew ktlintCheck` 예정 + - API 수동 점검 예정: `PUT /live/room/info/set/chat-freeze` 요청 본문 `{ "roomId": 1, "isChatFrozen": true }` → 200 OK, 이후 `GET /live/room/info/{id}` 응답에 `isChatFrozen: true` 포함 확인 + +### 수동 테스트 방법 +- 사전조건: 방 생성 및 시작되어 Redis에 `LiveRoomInfo`가 존재해야 함 +- 1) 채팅 얼림 설정 + - 요청: `PUT /live/room/info/set/chat-freeze` + - 헤더: `Authorization: Bearer ` + - 바디: `{ "roomId": , "isChatFrozen": true }` + - 기대: 200 OK, 본문은 `ApiResponse.ok` 규격 +- 2) 룸 정보 조회에서 반영 확인 + - 요청: `GET /live/room/info/{roomId}` + - 기대: 응답 JSON 내 `isChatFrozen: true` +- 3) 해제 시나리오 재검증 + - `isChatFrozen`을 false로 요청 후 조회 시 `false` 확인 diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/live/room/LiveRoomController.kt b/src/main/kotlin/kr/co/vividnext/sodalive/live/room/LiveRoomController.kt index 5598a09c..c78d0db7 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/live/room/LiveRoomController.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/live/room/LiveRoomController.kt @@ -5,6 +5,7 @@ import kr.co.vividnext.sodalive.common.SodaException import kr.co.vividnext.sodalive.live.room.cancel.CancelLiveRequest import kr.co.vividnext.sodalive.live.room.donation.DeleteLiveRoomDonationMessage import kr.co.vividnext.sodalive.live.room.donation.LiveRoomDonationRequest +import kr.co.vividnext.sodalive.live.room.info.SetChatFreezeRequest import kr.co.vividnext.sodalive.live.room.like.LiveRoomLikeHeartRequest import kr.co.vividnext.sodalive.live.room.visit.LiveRoomVisitService import kr.co.vividnext.sodalive.member.Member @@ -204,6 +205,16 @@ class LiveRoomController( ApiResponse.ok(service.setManager(request, member)) } + @PutMapping("/info/set/chat-freeze") + fun setChatFreeze( + @RequestBody request: SetChatFreezeRequest, + @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member? + ) = run { + if (member == null) throw SodaException(messageKey = "common.error.bad_credentials") + + ApiResponse.ok(service.setChatFreeze(request, member)) + } + @PostMapping("/donation") fun donation( @RequestBody request: LiveRoomDonationRequest, diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/live/room/LiveRoomService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/live/room/LiveRoomService.kt index 42ee5092..1a49ac8b 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/live/room/LiveRoomService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/live/room/LiveRoomService.kt @@ -44,6 +44,7 @@ import kr.co.vividnext.sodalive.live.room.info.GetRoomInfoResponse import kr.co.vividnext.sodalive.live.room.info.LiveRoomInfo import kr.co.vividnext.sodalive.live.room.info.LiveRoomInfoRedisRepository import kr.co.vividnext.sodalive.live.room.info.LiveRoomMember +import kr.co.vividnext.sodalive.live.room.info.SetChatFreezeRequest import kr.co.vividnext.sodalive.live.room.kickout.LiveRoomKickOutService import kr.co.vividnext.sodalive.live.room.like.GetLiveRoomHeartListResponse import kr.co.vividnext.sodalive.live.room.like.GetLiveRoomHeartTotalResponse @@ -1052,10 +1053,29 @@ class LiveRoomService( creatorLanguageCode = creatorLanguageCode, isPrivateRoom = room.type == LiveRoomType.PRIVATE, password = room.password, - isActiveRoulette = isActiveRoulette + isActiveRoulette = isActiveRoulette, + isChatFrozen = roomInfo.isChatFrozen ) } + fun setChatFreeze(request: SetChatFreezeRequest, member: Member) { + val lock = getOrCreateLock(memberId = member.id!!) + lock.write { + val room = repository.findByIdOrNull(request.roomId) + ?: throw SodaException(messageKey = "live.room.not_found") + + if (room.member!!.id!! != member.id!!) { + throw SodaException(messageKey = "common.error.access_denied") + } + + val roomInfo = roomInfoRepository.findByIdOrNull(request.roomId) + ?: throw SodaException(messageKey = "live.room.info_not_found") + + roomInfo.isChatFrozen = request.isChatFrozen + roomInfoRepository.save(roomInfo) + } + } + fun getDonationMessageList(roomId: Long, member: Member): List { val liveRoomCreatorId = repository.getLiveRoomCreatorId(roomId) ?: throw SodaException(messageKey = "live.room.info_not_found") diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/live/room/info/GetRoomInfoResponse.kt b/src/main/kotlin/kr/co/vividnext/sodalive/live/room/info/GetRoomInfoResponse.kt index f7c6d743..f9c72117 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/live/room/info/GetRoomInfoResponse.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/live/room/info/GetRoomInfoResponse.kt @@ -24,5 +24,6 @@ data class GetRoomInfoResponse( val creatorLanguageCode: String?, val isPrivateRoom: Boolean = false, val password: String? = null, - val isActiveRoulette: Boolean = false + val isActiveRoulette: Boolean = false, + val isChatFrozen: Boolean = false ) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/live/room/info/LiveRoomInfo.kt b/src/main/kotlin/kr/co/vividnext/sodalive/live/room/info/LiveRoomInfo.kt index 6c366d8a..fbe1c6ac 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/live/room/info/LiveRoomInfo.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/live/room/info/LiveRoomInfo.kt @@ -83,6 +83,9 @@ data class LiveRoomInfo( managerCount = managerList.size } + // 채팅 얼림 상태 (기본값: 해제) + var isChatFrozen: Boolean = false + fun addDonationMessage(memberId: Long, nickname: String, isSecret: Boolean, can: Int, donationMessage: String) { val donationMessageSet = donationMessageList.toMutableSet() donationMessageSet.add( diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/live/room/info/SetChatFreezeRequest.kt b/src/main/kotlin/kr/co/vividnext/sodalive/live/room/info/SetChatFreezeRequest.kt new file mode 100644 index 00000000..44f17848 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/live/room/info/SetChatFreezeRequest.kt @@ -0,0 +1,6 @@ +package kr.co.vividnext.sodalive.live.room.info + +data class SetChatFreezeRequest( + val roomId: Long, + val isChatFrozen: Boolean +) -- 2.49.1 From 2e0f0c5044b2696a463768a35e45966df14f728a Mon Sep 17 00:00:00 2001 From: Klaus Date: Thu, 19 Mar 2026 16:34:08 +0900 Subject: [PATCH 2/3] =?UTF-8?q?fix(explorer):=20getCreatorProfile=20?= =?UTF-8?q?=EB=9D=BC=EC=9D=B4=EB=B8=8C=20=EC=9D=91=EB=8B=B5=EC=9D=98=20cov?= =?UTF-8?q?erImageUrl=EC=9D=84=20=ED=81=AC=EB=A6=AC=EC=97=90=EC=9D=B4?= =?UTF-8?q?=ED=84=B0=20=ED=94=84=EB=A1=9C=ED=95=84=20=EC=9D=B4=EB=AF=B8?= =?UTF-8?q?=EC=A7=80=EB=A1=9C=20=EA=B5=90=EC=B2=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ExplorerQueryRepository의 LiveRoomResponse 매핑에서 커버 이미지 → 프로필 이미지로 변경 - 프로필 이미지 URL 규칙 적용: null/빈→기본 이미지, https로 시작 시 원본 유지, 상대 경로는 CloudFront 접두 - 응답 스키마/필드명은 호환성 유지를 위해 그대로 유지 --- .../sodalive/explorer/ExplorerQueryRepository.kt | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/explorer/ExplorerQueryRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/explorer/ExplorerQueryRepository.kt index bd0a9a1f..8182b926 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/explorer/ExplorerQueryRepository.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/explorer/ExplorerQueryRepository.kt @@ -431,10 +431,15 @@ class ExplorerQueryRepository( price = it.price, channelName = it.channelName, managerNickname = it.member!!.nickname, - coverImageUrl = if (it.coverImage!!.startsWith("https://")) { - it.coverImage!! - } else { - "$cloudFrontHost/${it.coverImage!!}" + // 기존: 라이브 방 커버 이미지를 반환 + // 변경: 크리에이터(방 매니저) 프로필 이미지를 반환 + coverImageUrl = run { + val profileImage = it.member!!.profileImage + when { + profileImage.isNullOrBlank() -> "$cloudFrontHost/profile/default-profile.png" + profileImage.startsWith("https://") -> profileImage + else -> "$cloudFrontHost/$profileImage" + } }, isReservation = reservations.isNotEmpty(), isActive = it.isActive, -- 2.49.1 From fe093a942c562a7e49c6fdadaa46e2bb2ca1f67d Mon Sep 17 00:00:00 2001 From: Klaus Date: Thu, 19 Mar 2026 16:45:36 +0900 Subject: [PATCH 3/3] =?UTF-8?q?perf(explorer:creator-profile):=20=EB=9D=BC?= =?UTF-8?q?=EC=9D=B4=EB=B8=8C=EB=B0=A9=20=EB=AA=A9=EB=A1=9D=20N+1=20?= =?UTF-8?q?=EC=A0=9C=EA=B1=B0=20=EB=B0=8F=20=EC=98=88=EC=95=BD/=EA=B2=B0?= =?UTF-8?q?=EC=A0=9C=20=EC=97=AC=EB=B6=80=20=EC=9D=BC=EA=B4=84=20=EC=A1=B0?= =?UTF-8?q?=ED=9A=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - member 연관 로딩에 fetch join 적용으로 N+1 제거 - reservations 컬렉션 접근 제거 → QLiveReservation 기반 방 ID 일괄 조회로 isReservation 계산 - useCan per-room 조회 제거 → 방 ID 집합 일괄 조회(Set)로 isPaid 계산 - 기존 비즈니스 로직(날짜 포맷, 성인/성별 필터, PRIVATE 플래그 등) 유지 --- .../explorer/ExplorerQueryRepository.kt | 62 ++++++++++++------- 1 file changed, 39 insertions(+), 23 deletions(-) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/explorer/ExplorerQueryRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/explorer/ExplorerQueryRepository.kt index 8182b926..4a906d7b 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/explorer/ExplorerQueryRepository.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/explorer/ExplorerQueryRepository.kt @@ -23,6 +23,7 @@ import kr.co.vividnext.sodalive.extensions.removeDeletedNicknamePrefix import kr.co.vividnext.sodalive.i18n.Lang import kr.co.vividnext.sodalive.i18n.LangContext import kr.co.vividnext.sodalive.i18n.SodaMessageSource +import kr.co.vividnext.sodalive.live.reservation.QLiveReservation import kr.co.vividnext.sodalive.live.room.GenderRestriction import kr.co.vividnext.sodalive.live.room.LiveRoom import kr.co.vividnext.sodalive.live.room.LiveRoomType @@ -374,7 +375,7 @@ class ExplorerQueryRepository( result.addAll( queryFactory .selectFrom(liveRoom) - .innerJoin(liveRoom.member, member) + .innerJoin(liveRoom.member, member).fetchJoin() .leftJoin(liveRoom.cancel, liveRoomCancel) .where(where) .orderBy(liveRoom.beginDateTime.asc()) @@ -388,13 +389,43 @@ class ExplorerQueryRepository( val dateTimeFormatter = DateTimeFormatter.ofPattern(dateTimePattern) .withLocale(langContext.lang.locale) + // N+1 방지: 한 번에 필요한 정보 일괄 조회 + val roomIds = result.mapNotNull { it.id }.toSet() + if (roomIds.isEmpty()) { + return emptyList() + } + + // 사용자 예약 여부를 방 ID 기준으로 일괄 조회 + val reservationRoomIdSet: Set = run { + // Q 클래스는 의존 파일들에서 사용되는 패턴을 맞춰 import 없이 정규 참조 + val resIds = queryFactory + .select(QLiveReservation.liveReservation.room.id) + .from(QLiveReservation.liveReservation) + .where( + QLiveReservation.liveReservation.room.id.`in`(roomIds) + .and(QLiveReservation.liveReservation.member.id.eq(userMember.id)) + .and(QLiveReservation.liveReservation.isActive.isTrue) + ) + .fetch() + resIds.filterNotNull().toSet() + } + + // 결제 여부를 방 ID 기준으로 일괄 조회 (CanUsage.LIVE) + val paidRoomIdSet: Set = run { + val ids = queryFactory + .select(useCan.room.id) + .from(useCan) + .where( + useCan.room.id.`in`(roomIds) + .and(useCan.canUsage.eq(CanUsage.LIVE)) + ) + .groupBy(useCan.room.id) + .fetch() + ids.filterNotNull().toSet() + } + return result .map { - val reservations = it.reservations - .filter { reservation -> - reservation.member!!.id!! == userMember.id!! && reservation.isActive - } - val beginDateTime = it.beginDateTime .atZone(ZoneId.of("UTC")) .withZoneSameInstant(ZoneId.of(timezone)) @@ -403,22 +434,7 @@ class ExplorerQueryRepository( val beginDateTimeUtc = it.beginDateTime .format(DateTimeFormatter.ISO_LOCAL_DATE_TIME) - val isPaid = if (it.channelName != null) { - val useCan = queryFactory - .selectFrom(useCan) - .innerJoin(useCan.member, member) - .where( - useCan.member.id.eq(member.id) - .and(useCan.room.id.eq(it.id!!)) - .and(useCan.canUsage.eq(CanUsage.LIVE)) - ) - .orderBy(useCan.id.desc()) - .fetchFirst() - - useCan != null - } else { - false - } + val isPaid = it.channelName != null && paidRoomIdSet.contains(it.id!!) LiveRoomResponse( roomId = it.id!!, @@ -441,7 +457,7 @@ class ExplorerQueryRepository( else -> "$cloudFrontHost/$profileImage" } }, - isReservation = reservations.isNotEmpty(), + isReservation = reservationRoomIdSet.contains(it.id!!), isActive = it.isActive, isPrivateRoom = it.type == LiveRoomType.PRIVATE ) -- 2.49.1