Merge pull request 'test' (#405) from test into main

Reviewed-on: #405
This commit is contained in:
2026-03-19 09:33:40 +00:00
7 changed files with 123 additions and 29 deletions

View File

@@ -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 <creator_token>`
- 바디: `{ "roomId": <roomId>, "isChatFrozen": true }`
- 기대: 200 OK, 본문은 `ApiResponse.ok` 규격
- 2) 룸 정보 조회에서 반영 확인
- 요청: `GET /live/room/info/{roomId}`
- 기대: 응답 JSON 내 `isChatFrozen: true`
- 3) 해제 시나리오 재검증
- `isChatFrozen`을 false로 요청 후 조회 시 `false` 확인

View File

@@ -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)
return result
.map {
val reservations = it.reservations
.filter { reservation ->
reservation.member!!.id!! == userMember.id!! && reservation.isActive
// N+1 방지: 한 번에 필요한 정보 일괄 조회
val roomIds = result.mapNotNull { it.id }.toSet()
if (roomIds.isEmpty()) {
return emptyList()
}
// 사용자 예약 여부를 방 ID 기준으로 일괄 조회
val reservationRoomIdSet: Set<Long> = 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<Long> = 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 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!!,
@@ -431,12 +447,17 @@ 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(),
isReservation = reservationRoomIdSet.contains(it.id!!),
isActive = it.isActive,
isPrivateRoom = it.type == LiveRoomType.PRIVATE
)

View File

@@ -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,

View File

@@ -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<LiveRoomDonationMessage> {
val liveRoomCreatorId = repository.getLiveRoomCreatorId(roomId)
?: throw SodaException(messageKey = "live.room.info_not_found")

View File

@@ -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
)

View File

@@ -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(

View File

@@ -0,0 +1,6 @@
package kr.co.vividnext.sodalive.live.room.info
data class SetChatFreezeRequest(
val roomId: Long,
val isChatFrozen: Boolean
)