From 0aaf3c87ac5eb3cba4b2b09e9fad964e8a8e17a9 Mon Sep 17 00:00:00 2001 From: sevineleven <117634128+sevineleven@users.noreply.github.com> Date: Sun, 23 Aug 2026 23:59:50 +0900 Subject: [PATCH 1/3] =?UTF-8?q?feat:=20COMPLETED=20=EC=9D=91=EB=8B=B5?= =?UTF-8?q?=EC=97=90=20=EC=86=8C=EC=85=9C=20=ED=86=A0=EB=84=88=EB=A8=BC?= =?UTF-8?q?=ED=8A=B8=20=EC=97=AC=EB=B6=80(isGroupTournament)=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 그룹 결과 "전체 결과 보기" 배너 노출을 hasGroupResult(완료 ≥ 2) 하나로 제어해, 소셜 토너먼트에서 참여자가 주최자보다 먼저 완주하면 배너가 안 보이고 다른 사람 완주 후 새로고침해야만 나타나던 문제(#975)를 해결 - "노출 여부(참여자 ≥ 2)"와 "조회 가능 여부(완료 ≥ 2)"를 별도 플래그로 분리: isGroupTournament 신규 추가, hasGroupResult 는 현행 유지 - computeHasGroupResult 를 computeGroupFlags 로 교체해 두 플래그를 루트 기준 클론 목록·참여자 수·완료 수를 공유하며 한 번에 계산(쿼리 중복 방지). buildCompleted 5개 호출부 일괄 전환 - 부수 정정: TournamentApi 문서의 hasGroupResult 설명이 실제 계산(완료 2명)과 어긋난("참여자 2명 이상") 것을 바로잡고 isGroupTournament 설명 추가, example 2곳 갱신 --- .../tournament/controller/TournamentApi.kt | 5 +-- .../controller/TournamentApiExamples.kt | 2 ++ .../dto/TournamentDetailResponse.kt | 6 ++++ .../tournament/service/TournamentService.kt | 36 +++++++++++++------ .../service/dto/TournamentDetail.kt | 3 ++ 5 files changed, 39 insertions(+), 13 deletions(-) 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..266ae8e9 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,31 @@ 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, + ) + + // 그룹 결과 관련 두 플래그를 한 번에 구한다 — 루트 기준 클론 목록·참여자 수·완료 수를 공유해 조회를 중복하지 않는다. + // hasGroupResult : 완료 플레이어(완료 TU + 완료 클론) 수 >= 2 → 그룹 결과 "조회 가능"(progressive gate, core#456). + // isGroupTournament : 참여자(전체 TU + 전체 클론) 수 >= 2 → "소셜(그룹) 토너먼트 여부"(완료 무관, core#370 원래 정의). + // 배너 "노출"은 isGroupTournament 로, "활성/비활성"은 hasGroupResult 로 가른다 — 첫 완주자가 누구든 새로고침 없이 + // 배너를 본다(#975). 솔로는 참여자가 항상 정확히 1이라 false. (소셜 멤버가 root TU·clone 에 중복 잡혀도 >=2 boolean 엔 무해) + private fun computeGroupFlags(tournament: Tournament): GroupFlags { val rootId = tournament.sourceTournamentId ?: tournament.getId() - // 루트 토너먼트 내 완료 참여자(TU) + 완료된 클론 토너먼트 수의 합이 2 이상이면 그룹 결과를 조회할 수 있다. + val clones = tournamentRepository.findBySourceTournamentId(rootId) val completedInRoot = tournamentUserRepository.countCompletedByTournamentId(rootId) - val completedClones = tournamentRepository.findBySourceTournamentId(rootId).count { it.isCompleted() } - return completedInRoot + completedClones >= 2 + val participantsInRoot = tournamentUserRepository.countByTournamentId(rootId) + return GroupFlags( + hasGroupResult = completedInRoot + clones.count { it.isCompleted() } >= 2, + isGroupTournament = participantsInRoot + clones.size >= 2, + ) } // ROOT 는 항상 아이템 담기 가능. CLONE 은 소셜 초대로 ROOT 에 TournamentUser 가 있으면 true, @@ -768,7 +781,7 @@ class TournamentService( private fun buildCompleted( tournament: Tournament, histories: List, - hasGroupResult: Boolean, + groupFlags: GroupFlags, isOwner: Boolean, canAddItem: Boolean, ): TournamentDetail.Completed { @@ -794,7 +807,8 @@ class TournamentService( imageUrl = snapshot.imageUrl, ) }, - hasGroupResult = hasGroupResult, + hasGroupResult = groupFlags.hasGroupResult, + isGroupTournament = groupFlags.isGroupTournament, isOwner = isOwner, isRoot = isRoot, canAddItem = canAddItem, 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 소유자 — 결과 화면에서 아이템 담기 허용(위시/링크/이미지). From 028f11e32e1a80ece2056ce92f5e00563260b6ed Mon Sep 17 00:00:00 2001 From: sevineleven <117634128+sevineleven@users.noreply.github.com> Date: Sun, 23 Aug 2026 23:59:50 +0900 Subject: [PATCH 2/3] =?UTF-8?q?test:=20=EC=86=8C=EC=85=9C=20=ED=86=A0?= =?UTF-8?q?=EB=84=88=EB=A8=BC=ED=8A=B8=20=EC=B2=AB=20=EC=99=84=EC=A3=BC?= =?UTF-8?q?=EC=9E=90=20=EB=B0=B0=EB=84=88=20=EB=85=B8=EC=B6=9C=20=ED=9A=8C?= =?UTF-8?q?=EA=B7=80=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 참여자 2명(주최자+초대 멤버)에서 주최자 혼자 먼저 완주 시 isGroupTournament=true(배너 노출)·hasGroupResult=false(비활성·empty state)를 단언 — #975 버그 재현·회귀 방지 - 솔로 완주 테스트에 isGroupTournament=false 단언 추가(소셜 아님 계약) --- .../TournamentMatchIntegrationTest.kt | 72 +++++++++++++++++++ 1 file changed, 72 insertions(+) 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..c47014c7 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,74 @@ 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)) + } + + 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() From c052a2828af346159227988cb1e25179a3026453 Mon Sep 17 00:00:00 2001 From: sevineleven <117634128+sevineleven@users.noreply.github.com> Date: Mon, 24 Aug 2026 00:35:06 +0900 Subject: [PATCH 3/3] =?UTF-8?q?fix:=20=EA=B7=B8=EB=A3=B9=20=EC=B0=B8?= =?UTF-8?q?=EC=97=AC=EC=9E=90=C2=B7=EC=99=84=EB=A3=8C=EC=9E=90=EB=A5=BC=20?= =?UTF-8?q?record=20=EA=B0=80=20=EC=95=84=EB=8B=88=EB=9D=BC=20userId=20?= =?UTF-8?q?=EB=A1=9C=20=EC=A7=91=EA=B3=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 주최자가 자기 플레이링크로 self-clone 을 만들 수 있는데(createFromPlayLink 에 가드 없음) ROOT·CLONE 을 모두 완주하면, record 를 세던 computeGroupFlags·getGroupResult 게이트가 같은 사용자 1명을 2로 잡아 solo 를 그룹으로 오인(isGroupTournament·hasGroupResult 둘 다 true)하던 것을 수정 - 참여자/완료자를 ROOT TU 와 CLONE 소유자의 userId 합집합(고유 사용자 수)으로 계산하도록 통일. getGroupResult 의 조회 게이트와 plays 구성도 같은 기준으로 dedup(같은 사용자의 ROOT·self-clone 플레이가 결과에 중복되지 않게 ROOT 우선) - 회귀 테스트: 주최자가 self-clone 을 만들어 둘 다 완주해도 solo 라 두 플래그가 false. record 카운트로 되돌리면 실패함을 negative control 로 확인 --- .../tournament/service/TournamentService.kt | 41 ++++++++++++----- .../TournamentMatchIntegrationTest.kt | 46 +++++++++++++++++++ 2 files changed, 75 insertions(+), 12 deletions(-) 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 266ae8e9..ab781c62 100644 --- a/src/main/kotlin/com/depromeet/piki/tournament/service/TournamentService.kt +++ b/src/main/kotlin/com/depromeet/piki/tournament/service/TournamentService.kt @@ -753,19 +753,31 @@ class TournamentService( val isGroupTournament: Boolean, ) - // 그룹 결과 관련 두 플래그를 한 번에 구한다 — 루트 기준 클론 목록·참여자 수·완료 수를 공유해 조회를 중복하지 않는다. - // hasGroupResult : 완료 플레이어(완료 TU + 완료 클론) 수 >= 2 → 그룹 결과 "조회 가능"(progressive gate, core#456). - // isGroupTournament : 참여자(전체 TU + 전체 클론) 수 >= 2 → "소셜(그룹) 토너먼트 여부"(완료 무관, core#370 원래 정의). + // 그룹 결과 관련 두 플래그를 한 번에 구한다 — 루트 기준 클론 목록·전체 TU 를 공유해 조회를 중복하지 않는다. + // hasGroupResult : 완료한 고유 사용자 수 >= 2 → 그룹 결과 "조회 가능"(progressive gate, core#456). + // isGroupTournament : 참여한 고유 사용자 수 >= 2 → "소셜(그룹) 토너먼트 여부"(완료 무관, core#370 원래 정의). // 배너 "노출"은 isGroupTournament 로, "활성/비활성"은 hasGroupResult 로 가른다 — 첫 완주자가 누구든 새로고침 없이 - // 배너를 본다(#975). 솔로는 참여자가 항상 정확히 1이라 false. (소셜 멤버가 root TU·clone 에 중복 잡혀도 >=2 boolean 엔 무해) + // 배너를 본다(#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() val clones = tournamentRepository.findBySourceTournamentId(rootId) - val completedInRoot = tournamentUserRepository.countCompletedByTournamentId(rootId) - val participantsInRoot = tournamentUserRepository.countByTournamentId(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 = completedInRoot + clones.count { it.isCompleted() } >= 2, - isGroupTournament = participantsInRoot + clones.size >= 2, + hasGroupResult = completedUserIds.size >= 2, + isGroupTournament = participantUserIds.size >= 2, ) } @@ -1027,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() } @@ -1046,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/test/kotlin/com/depromeet/piki/tournament/controller/TournamentMatchIntegrationTest.kt b/src/test/kotlin/com/depromeet/piki/tournament/controller/TournamentMatchIntegrationTest.kt index c47014c7..42a2d665 100644 --- a/src/test/kotlin/com/depromeet/piki/tournament/controller/TournamentMatchIntegrationTest.kt +++ b/src/test/kotlin/com/depromeet/piki/tournament/controller/TournamentMatchIntegrationTest.kt @@ -264,6 +264,52 @@ class TournamentMatchIntegrationTest : IntegrationTestSupport() { .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