diff --git a/src/main/kotlin/com/depromeet/piki/tournament/controller/TournamentApi.kt b/src/main/kotlin/com/depromeet/piki/tournament/controller/TournamentApi.kt index ce1df337..1c832596 100644 --- a/src/main/kotlin/com/depromeet/piki/tournament/controller/TournamentApi.kt +++ b/src/main/kotlin/com/depromeet/piki/tournament/controller/TournamentApi.kt @@ -135,7 +135,8 @@ interface TournamentApi { - pending.inviteCode, pending.inviteExpiresAt 은 null (초대 기간 종료) - COMPLETED: completed 필드 - result: 1위부터 최대 4위까지 순위 아이템 목록 - - hasGroupResult: 참여자 2명 이상이면 true. 클라이언트는 이 값으로 친구 토너먼트 결과 보기 버튼을 제어한다. + - isGroupTournament: 소셜(그룹) 토너먼트 여부. 참여자 2명 이상이면 true(완료 무관). 클라이언트는 이 값으로 "전체 결과 보기" 배너를 노출한다 - 첫 완주자가 누구든 새로고침 없이 배너가 보인다. + - hasGroupResult: 그룹 결과 조회 가능 여부. 완료한 플레이어가 2명 이상이면 true. 클라이언트는 이 값으로 배너 활성/비활성(다른 사람 결과가 아직 없으면 empty state)을 가른다. - canAddItem: 결과 화면에서 아이템 담기(위시/링크/이미지)가 가능하면 true. ROOT 소유자·소셜 초대 CLONE 소유자는 true, 플레이링크 CLONE 소유자는 false. 나머지 필드는 응답에 포함되지 않는다. """, @@ -615,7 +616,7 @@ interface TournamentApi { 이때 클라이언트는 GET /tournaments/{id} 를 다시 호출해 다음 라운드를 받는다. 결승(currentRound=2) 결과 기록 시 completed 에 본인의 순위 결과(1위~최대 4위)가 즉시 담긴다. 소셜 토너먼트라도 각 인스턴스(ROOT·CLONE)는 해당 인스턴스의 결승이 완료되는 즉시 COMPLETED 로 전환된다. - 다른 참여자의 진행 여부와 무관하게 내 결과는 바로 확인할 수 있으며, 전체 그룹 결과는 2명 이상이 완료한 뒤 hasGroupResult=true 로 활성화된다. + 다른 참여자의 진행 여부와 무관하게 내 결과는 바로 확인할 수 있다. 소셜 토너먼트면 완주 즉시 isGroupTournament=true 로 "전체 결과 보기" 배너가 노출되고(새로고침 불필요), 전체 그룹 결과는 2명 이상이 완료한 뒤 hasGroupResult=true 로 활성화된다. """, ) @ApiResponses( diff --git a/src/main/kotlin/com/depromeet/piki/tournament/controller/TournamentApiExamples.kt b/src/main/kotlin/com/depromeet/piki/tournament/controller/TournamentApiExamples.kt index 8c05a71a..7f2dddae 100644 --- a/src/main/kotlin/com/depromeet/piki/tournament/controller/TournamentApiExamples.kt +++ b/src/main/kotlin/com/depromeet/piki/tournament/controller/TournamentApiExamples.kt @@ -314,6 +314,7 @@ class TournamentApiExamples( ), ), hasGroupResult = true, + isGroupTournament = true, canAddItem = true, playLinkExpiresAt = LocalDateTime.of(2026, 6, 20, 22, 0, 0), ), @@ -563,6 +564,7 @@ class TournamentApiExamples( ), ), hasGroupResult = true, + isGroupTournament = true, canAddItem = true, playLinkExpiresAt = LocalDateTime.of(2026, 6, 20, 22, 0, 0), ), diff --git a/src/main/kotlin/com/depromeet/piki/tournament/controller/dto/TournamentDetailResponse.kt b/src/main/kotlin/com/depromeet/piki/tournament/controller/dto/TournamentDetailResponse.kt index 993b6c07..88f6fddf 100644 --- a/src/main/kotlin/com/depromeet/piki/tournament/controller/dto/TournamentDetailResponse.kt +++ b/src/main/kotlin/com/depromeet/piki/tournament/controller/dto/TournamentDetailResponse.kt @@ -104,7 +104,12 @@ data class TournamentDetailResponse( @JsonInclude(JsonInclude.Include.NON_NULL) data class CompletedData( val result: List, + // 그룹 결과 "조회 가능" 여부 — 완료 플레이어 수가 2명 이상이면 true. 클라는 이 값으로 배너 활성/비활성(다른 + // 사람 결과가 아직 없으면 empty state)을 가른다. val hasGroupResult: Boolean, + // 소셜(그룹) 토너먼트 여부 — 참여자 수가 2명 이상이면 true(완료 무관). 클라는 이 값으로 "전체 결과 보기" 배너 + // 노출을 가른다 — 첫 완주자가 누구든 새로고침 없이 배너를 본다(#975). + val isGroupTournament: Boolean, // true: ROOT 소유자 또는 소셜 초대 CLONE 소유자 — 아이템 담기 허용(위시/링크/이미지). // false: 플레이링크 CLONE 소유자 — 아이템 담기 불가. val canAddItem: Boolean, @@ -115,6 +120,7 @@ data class TournamentDetailResponse( CompletedData( result = completed.result.map { RankedItemResponse.from(it) }, hasGroupResult = completed.hasGroupResult, + isGroupTournament = completed.isGroupTournament, canAddItem = completed.canAddItem, playLinkExpiresAt = completed.playLinkExpiresAt, ) diff --git a/src/main/kotlin/com/depromeet/piki/tournament/service/TournamentService.kt b/src/main/kotlin/com/depromeet/piki/tournament/service/TournamentService.kt index b58ddfc4..ab781c62 100644 --- a/src/main/kotlin/com/depromeet/piki/tournament/service/TournamentService.kt +++ b/src/main/kotlin/com/depromeet/piki/tournament/service/TournamentService.kt @@ -376,7 +376,7 @@ class TournamentService( val userHistories = tournamentRepository.findHistoriesByTournamentIdAndTournamentUserId( tournamentId, currentUser.getId(), ) - return buildCompleted(tournament, userHistories, computeHasGroupResult(tournament), isOwner, canAddItemForTournament(tournament, userId)) + return buildCompleted(tournament, userHistories, computeGroupFlags(tournament), isOwner, canAddItemForTournament(tournament, userId)) } // 본인 history만 사용 — 다른 참여자의 매치는 본인 진행 상태에 영향을 주지 않는다. @@ -452,9 +452,9 @@ class TournamentService( val cloneHistories = tournamentRepository.findHistoriesByTournamentIdAndTournamentUserId( myClone.getId(), myCloneOwnerTU.getId(), ) - return buildCompleted(myClone, cloneHistories, computeHasGroupResult(tournament), false, true) + return buildCompleted(myClone, cloneHistories, computeGroupFlags(tournament), false, true) } - buildCompleted(tournament, histories, computeHasGroupResult(tournament), isOwner, canAddItemForTournament(tournament, userId)) + buildCompleted(tournament, histories, computeGroupFlags(tournament), isOwner, canAddItemForTournament(tournament, userId)) } } } @@ -672,7 +672,7 @@ class TournamentService( return RecordMatchResult( nextMatch = null, completed = buildCompleted( - tournament, histories, computeHasGroupResult(tournament), + tournament, histories, computeGroupFlags(tournament), tournamentUser.getId() == tournament.ownerTournamentUserId, canAddItemForTournament(tournament, userId), ), @@ -742,18 +742,43 @@ class TournamentService( return RecordMatchResult( nextMatch = null, completed = buildCompleted( - tournament, histories + newHistory, computeHasGroupResult(tournament), isOwner, + tournament, histories + newHistory, computeGroupFlags(tournament), isOwner, canAddItemForTournament(tournament, userId), ), ) } - private fun computeHasGroupResult(tournament: Tournament): Boolean { + private data class GroupFlags( + val hasGroupResult: Boolean, + val isGroupTournament: Boolean, + ) + + // 그룹 결과 관련 두 플래그를 한 번에 구한다 — 루트 기준 클론 목록·전체 TU 를 공유해 조회를 중복하지 않는다. + // hasGroupResult : 완료한 고유 사용자 수 >= 2 → 그룹 결과 "조회 가능"(progressive gate, core#456). + // isGroupTournament : 참여한 고유 사용자 수 >= 2 → "소셜(그룹) 토너먼트 여부"(완료 무관, core#370 원래 정의). + // 배너 "노출"은 isGroupTournament 로, "활성/비활성"은 hasGroupResult 로 가른다 — 첫 완주자가 누구든 새로고침 없이 + // 배너를 본다(#975). 솔로는 참여자가 항상 정확히 1이라 false. + // record 가 아니라 userId 로 센다 — 같은 사용자가 ROOT TU 와 자기 CLONE 을 모두 가질 수 있어서다(주최자가 자기 + // 플레이링크로 self-clone 을 만드는 경로에 가드가 없다). 그대로 record 를 세면 1명이 2로 잡혀 solo 가 그룹으로 오인된다. + private fun computeGroupFlags(tournament: Tournament): GroupFlags { val rootId = tournament.sourceTournamentId ?: tournament.getId() - // 루트 토너먼트 내 완료 참여자(TU) + 완료된 클론 토너먼트 수의 합이 2 이상이면 그룹 결과를 조회할 수 있다. - val completedInRoot = tournamentUserRepository.countCompletedByTournamentId(rootId) - val completedClones = tournamentRepository.findBySourceTournamentId(rootId).count { it.isCompleted() } - return completedInRoot + completedClones >= 2 + val clones = tournamentRepository.findBySourceTournamentId(rootId) + val rootUsers = tournamentUserRepository.findByTournamentId(rootId) + val cloneOwnerById = tournamentUserRepository + .findByIds(clones.map { it.ownerTournamentUserId }.toSet()) + .associateBy { it.getId() } + val participantUserIds = buildSet { + rootUsers.forEach { add(it.userId) } + clones.forEach { clone -> cloneOwnerById[clone.ownerTournamentUserId]?.let { add(it.userId) } } + } + val completedUserIds = buildSet { + rootUsers.filter { it.isCompleted() }.forEach { add(it.userId) } + clones.filter { it.isCompleted() }.forEach { clone -> cloneOwnerById[clone.ownerTournamentUserId]?.let { add(it.userId) } } + } + return GroupFlags( + hasGroupResult = completedUserIds.size >= 2, + isGroupTournament = participantUserIds.size >= 2, + ) } // ROOT 는 항상 아이템 담기 가능. CLONE 은 소셜 초대로 ROOT 에 TournamentUser 가 있으면 true, @@ -768,7 +793,7 @@ class TournamentService( private fun buildCompleted( tournament: Tournament, histories: List, - hasGroupResult: Boolean, + groupFlags: GroupFlags, isOwner: Boolean, canAddItem: Boolean, ): TournamentDetail.Completed { @@ -794,7 +819,8 @@ class TournamentService( imageUrl = snapshot.imageUrl, ) }, - hasGroupResult = hasGroupResult, + hasGroupResult = groupFlags.hasGroupResult, + isGroupTournament = groupFlags.isGroupTournament, isOwner = isOwner, isRoot = isRoot, canAddItem = canAddItem, @@ -1013,11 +1039,16 @@ class TournamentService( requesterOwnedClone?.isCompleted() ?: false } // completedRootTUs·completedClones 는 아래 plays 빌드에도 쓰이므로 미리 구해 게이트와 공유한다. - // computeHasGroupResult 를 별도 호출하면 findBySourceTournamentId 와 countCompletedByTournamentId 를 - // 중복 조회하게 되므로 인라인으로 처리한다. + // computeGroupFlags 를 별도 호출하면 findBySourceTournamentId 등을 중복 조회하게 되므로 인라인으로 처리한다. val completedRootTUs = tournamentUserRepository.findCompletedByTournamentId(tournamentId) val completedClones = allClones.filter { it.isCompleted() } - if (!requesterHasCompleted || completedRootTUs.size + completedClones.size < 2) { + // 완료자는 record 가 아니라 userId 로 센다 — 주최자가 자기 self-clone 을 완주하면 ROOT·CLONE 두 record 가 + // 같은 사용자다(computeGroupFlags 와 동일 기준). record 로 세면 solo 가 게이트를 통과해버린다. + val completedUserIds = buildSet { + completedRootTUs.forEach { add(it.userId) } + completedClones.forEach { clone -> cloneOwnerTUById[clone.ownerTournamentUserId]?.let { add(it.userId) } } + } + if (!requesterHasCompleted || completedUserIds.size < 2) { throw TournamentException.groupResultNotAvailable() } @@ -1032,7 +1063,7 @@ class TournamentService( val ownerTU = cloneOwnerTUById[clone.ownerTournamentUserId] ?: return@forEach add(Play(clone.getId(), ownerTU.getId(), ownerTU.userId)) } - } + }.distinctBy { it.userUUID } // 같은 사용자의 ROOT·self-clone 플레이가 결과에 두 번 실리지 않게 dedup (ROOT 플레이 우선). val userById = userRepository .findByIds(plays.map { it.userUUID }.toSet()) diff --git a/src/main/kotlin/com/depromeet/piki/tournament/service/dto/TournamentDetail.kt b/src/main/kotlin/com/depromeet/piki/tournament/service/dto/TournamentDetail.kt index 8b30d11b..8735d9cd 100644 --- a/src/main/kotlin/com/depromeet/piki/tournament/service/dto/TournamentDetail.kt +++ b/src/main/kotlin/com/depromeet/piki/tournament/service/dto/TournamentDetail.kt @@ -37,7 +37,10 @@ sealed class TournamentDetail { val tournamentId: Long, val name: String, val result: List, + // 그룹 결과 "조회 가능" 여부 — 완료 플레이어 수 >= 2 (progressive gate). val hasGroupResult: Boolean, + // 소셜(그룹) 토너먼트 여부 — 참여자 수 >= 2 (완료 무관). 결과 화면 배너 "노출" 을 이 값으로 가른다(#975). + val isGroupTournament: Boolean, val isOwner: Boolean, val isRoot: Boolean, // true: ROOT 소유자 또는 소셜 초대로 참여한 CLONE 소유자 — 결과 화면에서 아이템 담기 허용(위시/링크/이미지). diff --git a/src/test/kotlin/com/depromeet/piki/tournament/controller/TournamentMatchIntegrationTest.kt b/src/test/kotlin/com/depromeet/piki/tournament/controller/TournamentMatchIntegrationTest.kt index 1e6981ce..42a2d665 100644 --- a/src/test/kotlin/com/depromeet/piki/tournament/controller/TournamentMatchIntegrationTest.kt +++ b/src/test/kotlin/com/depromeet/piki/tournament/controller/TournamentMatchIntegrationTest.kt @@ -9,6 +9,8 @@ import com.depromeet.piki.tournament.repository.TournamentHistoryJpaRepository import com.depromeet.piki.tournament.repository.TournamentItemJpaRepository import com.depromeet.piki.tournament.service.TournamentErrorCode import com.depromeet.piki.user.domain.IdentityType +import com.depromeet.piki.user.domain.User +import com.depromeet.piki.user.repository.UserJpaRepository import com.depromeet.piki.wishlist.service.WishPersistenceService import org.springframework.beans.factory.annotation.Autowired import org.springframework.http.HttpHeaders @@ -50,8 +52,12 @@ class TournamentMatchIntegrationTest : IntegrationTestSupport() { @Autowired private lateinit var jwtProvider: JwtProvider + @Autowired private lateinit var userJpaRepository: UserJpaRepository + private val userId: UUID = UUID.fromString("11111111-2222-3333-4444-555555555555") + private val memberId: UUID = UUID.fromString("99999999-8888-7777-6666-555555555555") + @Test fun `GET tournaments-id 는 서버가 브래킷에서 파생한 currentMatch 를 아이템 정보까지 담아 내려준다`() { val mockMvc = buildMockMvc() @@ -212,8 +218,120 @@ class TournamentMatchIntegrationTest : IntegrationTestSupport() { .andExpect(jsonPath("$.data.completed.result[1].rank").value(2)) .andExpect(jsonPath("$.data.completed.result[1].tournamentItemId").value(items[1])) .andExpect(jsonPath("$.data.completed.hasGroupResult").value(false)) + // 솔로(참여자 1명)라 소셜 토너먼트가 아니다 → "전체 결과 보기" 배너 미노출. + .andExpect(jsonPath("$.data.completed.isGroupTournament").value(false)) + } + + // #975 회귀: 참여자가 2명 이상이면(소셜) 주최자가 혼자 먼저 완주해도 isGroupTournament=true 로 배너가 노출되고, + // 아직 완료 플레이어가 1명뿐이라 hasGroupResult=false(비활성·empty state)로 내려온다. 예전엔 노출을 hasGroupResult + // 하나로 제어해 이 시점에 배너가 아예 안 보였고, 다른 참여자 완주 후 새로고침해야 나타났다. + @Test + fun `소셜 토너먼트는 주최자 혼자 먼저 완주해도 isGroupTournament=true 이고 완료자 부족이라 hasGroupResult=false 다`() { + val mockMvc = buildMockMvc() + val (tournamentId, inviteCode) = createTournamentWithInviteCode(mockMvc) + // 멤버 1명이 초대로 참여 → 루트 참여자 2명(주최자 + 멤버). 멤버는 아직 완주하지 않는다. + saveUser(memberId, "https://cdn.example.com/member.jpg", "멤버") + mockMvc + .perform( + post("/api/v1/tournaments/$tournamentId/join") + .header(HttpHeaders.AUTHORIZATION, authHeader(memberId)) + .contentType(MediaType.APPLICATION_JSON) + .content("""{"inviteCode":"$inviteCode"}"""), + ).andExpect(status().isOk) + + // 주최자가 아이템을 담고 시작해 혼자 먼저 완주한다. + val itemIds = (1..2).map { saveWishItem(name = "아이템$it", price = it * 10_000) } + mockMvc.perform( + post("/api/v1/tournaments/$tournamentId/items/wish") + .header(HttpHeaders.AUTHORIZATION, authHeader(userId)) + .contentType(MediaType.APPLICATION_JSON) + .content("""{"itemIds":${itemIds.joinToString(",", "[", "]")}}"""), + ) + mockMvc + .perform( + post("/api/v1/tournaments/$tournamentId/start") + .header(HttpHeaders.AUTHORIZATION, authHeader(userId)), + ).andExpect(status().isOk) + val items = tournamentItemIdsOf(tournamentId) + + mockMvc + .perform(recordMatch(tournamentId, items[0], items[1], winner = items[0], round = 2)) + .andExpect(status().isOk) + .andExpect(jsonPath("$.data.completed.result[0].rank").value(1)) + // 참여자 2명이라 소셜 토너먼트로 인식 → 배너 노출. + .andExpect(jsonPath("$.data.completed.isGroupTournament").value(true)) + // 아직 주최자 혼자만 완주 → 그룹 결과 조회 불가(비활성·empty state). + .andExpect(jsonPath("$.data.completed.hasGroupResult").value(false)) } + // #975(CodeRabbit): 참여자·완료자는 record 가 아니라 userId 로 센다. 주최자가 자기 플레이링크로 self-clone 을 + // 만들 수 있는데(createFromPlayLink 에 가드 없음), ROOT·CLONE 을 모두 완주해도 실제 사용자는 1명이므로 solo 여야 한다. + // record 로 세던 옛 로직은 이 경우 2로 잡아 solo 를 그룹으로 오인했다(isGroupTournament·hasGroupResult 둘 다 true). + @Test + fun `주최자가 자기 플레이링크로 self-clone 을 만들어 둘 다 완주해도 solo 라 두 그룹 플래그가 false 다`() { + val mockMvc = buildMockMvc() + // 주최자가 ROOT 를 완주한다. + val tournamentId = startTournament(mockMvc, itemCount = 2) + val rootItems = tournamentItemIdsOf(tournamentId) + mockMvc + .perform(recordMatch(tournamentId, rootItems[0], rootItems[1], winner = rootItems[0], round = 2)) + .andExpect(status().isOk) + + // 자기 토너먼트의 플레이링크를 만들고, 그 링크로 self-clone 을 생성한다(주최자 self-clone 가드 없음). + mockMvc + .perform( + post("/api/v1/tournaments/$tournamentId/play-link") + .header(HttpHeaders.AUTHORIZATION, authHeader(userId)) + .contentType(MediaType.APPLICATION_JSON) + .content("{}"), + ).andExpect(status().isOk) + val cloneResult = + mockMvc + .perform( + post("/api/v1/tournaments/$tournamentId/from-play-link") + .header(HttpHeaders.AUTHORIZATION, authHeader(userId)), + ).andExpect(status().isOk) + .andReturn() + val cloneId = objectMapper.readTree(cloneResult.response.contentAsString)["data"].asLong() + + // self-clone 을 시작해 완주한다. + mockMvc + .perform( + post("/api/v1/tournaments/$cloneId/start") + .header(HttpHeaders.AUTHORIZATION, authHeader(userId)), + ).andExpect(status().isOk) + val cloneMatch = currentMatchOf(mockMvc, cloneId) + + mockMvc + .perform(recordMatch(cloneId, cloneMatch.first, cloneMatch.second, winner = cloneMatch.first, round = 2)) + .andExpect(status().isOk) + // ROOT·CLONE 두 record 지만 같은 사용자 1명 → solo. 배너 미노출·비활성. + .andExpect(jsonPath("$.data.completed.isGroupTournament").value(false)) + .andExpect(jsonPath("$.data.completed.hasGroupResult").value(false)) + } + + private fun createTournamentWithInviteCode(mockMvc: MockMvc): Pair { + val result = + mockMvc + .perform( + post("/api/v1/tournaments") + .header(HttpHeaders.AUTHORIZATION, authHeader(userId)) + .contentType(MediaType.APPLICATION_JSON) + .content("""{"name":"매치 테스트 토너먼트"}"""), + ).andReturn() + val data = objectMapper.readTree(result.response.contentAsString)["data"] + return data["tournamentId"].asLong() to data["inviteCode"].asText() + } + + private fun saveUser( + id: UUID, + profileImage: String, + nickname: String, + ): User = + userJpaRepository.save( + User(id = id, nickname = nickname, profileImage = profileImage, identityType = IdentityType.MEMBER), + ) + @Test fun `결승을 재전송하면 COMPLETED 여도 409 가 아니라 같은 순위 결과를 다시 받는다`() { val mockMvc = buildMockMvc()