diff --git a/docs/20260330_live_room_capture_recording_ddl.sql b/docs/20260330_live_room_capture_recording_ddl.sql new file mode 100644 index 00000000..d1f22c04 --- /dev/null +++ b/docs/20260330_live_room_capture_recording_ddl.sql @@ -0,0 +1,2 @@ +ALTER TABLE live_room + ADD COLUMN is_capture_recording_available TINYINT(1) NOT NULL DEFAULT 0 COMMENT '캡쳐/녹화 가능 여부'; diff --git a/docs/20260330_라이브캡쳐녹화설정추가.md b/docs/20260330_라이브캡쳐녹화설정추가.md new file mode 100644 index 00000000..3f6cd373 --- /dev/null +++ b/docs/20260330_라이브캡쳐녹화설정추가.md @@ -0,0 +1,20 @@ +# 라이브 캡쳐/녹화 설정 추가 + +## 구현 항목 +- [x] 라이브 생성/수정/조회 관련 기존 필드 및 흐름 분석 +- [x] 라이브 정보에 캡쳐/녹화 단일 가능 여부 플래그 추가 +- [x] 라이브 생성 시에만 캡쳐/녹화 가능 여부를 설정하도록 반영 +- [x] DB 컬럼 추가 DDL 작성 +- [x] 관련 테스트 코드 보강 +- [x] 정적 진단/테스트/빌드 검증 수행 + +## 검증 기록 + +### 1차 구현 +- 무엇을: 라이브 생성 요청(`CreateLiveRoomRequest`)과 라이브 엔티티(`LiveRoom`)에 `isCaptureRecordingAvailable` 단일 플래그를 추가하고, 라이브 정보 응답(`GetRoomInfoResponse`)에 동일 플래그를 노출하도록 반영했다. +- 왜: 캡쳐/녹화를 분리하지 않고 하나의 설정값으로 관리하면서, 해당 값이 생성 시점에만 결정되도록 하기 위해서다. +- 어떻게: + - `./gradlew test --tests "kr.co.vividnext.sodalive.live.room.LiveRoomServiceAdultVisibilityPolicyTest"` 실행 결과: 성공 + - `./gradlew build` 실행 결과: 성공 + - 수동 QA(서비스 단위): `shouldPersistCaptureAndRecordingAvailabilityOnCreate`, `shouldIncludeCaptureAndRecordingAvailabilityInRoomInfo` 테스트로 생성 저장값/정보 응답값 확인 + - `lsp_diagnostics` 실행 결과: `.kt` LSP 서버 미구성으로 실행 불가(대신 Gradle 컴파일·ktlint·test·build 통과로 검증) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/live/room/CreateLiveRoomRequest.kt b/src/main/kotlin/kr/co/vividnext/sodalive/live/room/CreateLiveRoomRequest.kt index 3d8f56c8..4fbadf41 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/live/room/CreateLiveRoomRequest.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/live/room/CreateLiveRoomRequest.kt @@ -16,5 +16,6 @@ data class CreateLiveRoomRequest( val menuPan: String = "", val isActiveMenuPan: Boolean = false, val isAvailableJoinCreator: Boolean = true, - val genderRestriction: GenderRestriction = GenderRestriction.ALL + val genderRestriction: GenderRestriction = GenderRestriction.ALL, + val isCaptureRecordingAvailable: Boolean = false ) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/live/room/LiveRoom.kt b/src/main/kotlin/kr/co/vividnext/sodalive/live/room/LiveRoom.kt index 2ca56c9c..07ab6ef2 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/live/room/LiveRoom.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/live/room/LiveRoom.kt @@ -34,7 +34,8 @@ data class LiveRoom( @Column(nullable = true) var password: String? = null, @Enumerated(value = EnumType.STRING) - var genderRestriction: GenderRestriction = GenderRestriction.ALL + var genderRestriction: GenderRestriction = GenderRestriction.ALL, + val isCaptureRecordingAvailable: Boolean = false ) : BaseEntity() { @OneToOne(fetch = FetchType.LAZY) @JoinColumn(name = "member_id", nullable = false) 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 ded3c47f..92eac271 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 @@ -427,7 +427,8 @@ class LiveRoomService( type = request.type, password = request.password, isAvailableJoinCreator = request.isAvailableJoinCreator, - genderRestriction = request.genderRestriction + genderRestriction = request.genderRestriction, + isCaptureRecordingAvailable = request.isCaptureRecordingAvailable ) room.member = member @@ -1066,6 +1067,7 @@ class LiveRoomService( }, isFollowing = isFollowing, isAdult = room.isAdult, + isCaptureRecordingAvailable = room.isCaptureRecordingAvailable, participantsCount = roomInfo.listenerCount + roomInfo.speakerCount + roomInfo.managerCount, totalAvailableParticipantsCount = room.numberOfPeople, speakerList = roomInfo.speakerList, 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 f9c72117..b968ff49 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 @@ -14,6 +14,7 @@ data class GetRoomInfoResponse( val creatorProfileUrl: String, val isFollowing: Boolean, val isAdult: Boolean, + val isCaptureRecordingAvailable: Boolean, val participantsCount: Int, val totalAvailableParticipantsCount: Int, val speakerList: List, diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/live/room/LiveRoomServiceAdultVisibilityPolicyTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/live/room/LiveRoomServiceAdultVisibilityPolicyTest.kt index acc0daae..5abbbeb2 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/live/room/LiveRoomServiceAdultVisibilityPolicyTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/live/room/LiveRoomServiceAdultVisibilityPolicyTest.kt @@ -16,6 +16,7 @@ import kr.co.vividnext.sodalive.i18n.LangContext import kr.co.vividnext.sodalive.i18n.SodaMessageSource import kr.co.vividnext.sodalive.live.reservation.LiveReservationRepository import kr.co.vividnext.sodalive.live.room.cancel.LiveRoomCancelRepository +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.kickout.LiveRoomKickOutService import kr.co.vividnext.sodalive.live.room.menu.LiveRoomMenuService @@ -33,9 +34,12 @@ import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.DisplayName import org.junit.jupiter.api.Test +import org.mockito.ArgumentCaptor import org.mockito.Mockito import org.springframework.context.ApplicationEventPublisher import org.springframework.data.domain.PageRequest +import java.time.LocalDateTime +import java.util.Optional class LiveRoomServiceAdultVisibilityPolicyTest { private lateinit var menuService: LiveRoomMenuService @@ -240,6 +244,86 @@ class LiveRoomServiceAdultVisibilityPolicyTest { ) } + @Test + @DisplayName("라이브 생성 시 캡쳐/녹화 가능 여부를 저장한다") + fun shouldPersistCaptureAndRecordingAvailabilityOnCreate() { + val member = createMember(id = 300L) + val request = CreateLiveRoomRequest( + title = "title", + content = "notice", + coverImageUrl = "https://image.example.com/cover.png", + isAdult = false, + tags = emptyList(), + numberOfPeople = 10, + timezone = "Asia/Seoul", + isCaptureRecordingAvailable = false + ) + + Mockito.`when`(repository.getActiveRoomIdList(memberId = 300L)).thenReturn(0) + Mockito.`when`(repository.getRoomActiveAndChannelNameIsNotNull(memberId = 300L)).thenReturn(emptyList()) + Mockito.`when`(objectMapper.readValue("request-json", CreateLiveRoomRequest::class.java)).thenReturn(request) + Mockito.`when`(repository.save(Mockito.any(LiveRoom::class.java))).thenAnswer { invocation -> + invocation.arguments[0] as LiveRoom + } + + service.createLiveRoom(coverImage = null, requestString = "request-json", member = member) + + val roomCaptor = ArgumentCaptor.forClass(LiveRoom::class.java) + Mockito.verify(repository).save(roomCaptor.capture()) + assertEquals(false, roomCaptor.value.isCaptureRecordingAvailable) + } + + @Test + @DisplayName("라이브 정보 조회에 캡쳐/녹화 가능 여부를 포함한다") + fun shouldIncludeCaptureAndRecordingAvailabilityInRoomInfo() { + val viewer = createMember(id = 400L) + val creator = createMember(id = 401L) + creator.isVisibleDonationRank = false + + val room = LiveRoom( + title = "title", + notice = "notice", + beginDateTime = LocalDateTime.now(), + numberOfPeople = 10, + coverImage = "cover/image.png", + isAdult = false, + isCaptureRecordingAvailable = false + ) + room.id = 999L + room.member = creator + room.channelName = "SODA_LIVE_CHANNEL_TEST" + + val roomInfo = LiveRoomInfo(roomId = 999L) + + Mockito.`when`(roomInfoRepository.findById(999L)).thenReturn(Optional.of(roomInfo)) + Mockito.`when`(repository.findById(999L)).thenReturn(Optional.of(room)) + Mockito.`when`(blockMemberRepository.isBlocked(Mockito.anyLong(), Mockito.anyLong())).thenReturn(false) + Mockito.`when`( + rtcTokenBuilder.buildTokenWithUid( + Mockito.anyString(), + Mockito.anyString(), + Mockito.anyString(), + Mockito.anyString(), + Mockito.anyInt() + ) + ).thenReturn("rtc-token") + Mockito.`when`( + rtmTokenBuilder.buildToken( + Mockito.anyString(), + Mockito.anyString(), + Mockito.anyString(), + Mockito.anyInt() + ) + ).thenReturn("rtm-token") + Mockito.`when`(explorerQueryRepository.getNotificationUserIds(creator.id!!)).thenReturn(emptyList()) + Mockito.`when`(rouletteRepository.findByCreatorId(creator.id!!)).thenReturn(emptyList()) + Mockito.`when`(pushTokenRepository.findByMemberId(creator.id!!)).thenReturn(emptyList()) + + val response = service.getRoomInfo(roomId = 999L, member = viewer) + + assertEquals(false, response.isCaptureRecordingAvailable) + } + private fun createMember(id: Long): Member { val member = Member( email = "member$id@test.com",