From ea0919789b4e402924845261de5888f79c1fdbb4 Mon Sep 17 00:00:00 2001 From: sevineleven Date: Mon, 12 Oct 2026 14:51:47 +0900 Subject: [PATCH] =?UTF-8?q?refactor:=20=ED=86=A0=EB=84=88=EB=A8=BC?= =?UTF-8?q?=ED=8A=B8=20CLONE=20=EC=9E=94=EC=9E=AC=20=EC=A0=95=EB=A6=AC=20(?= =?UTF-8?q?4-1=EB=8B=A8=EA=B3=84)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 도달 불가 에러 코드 024 · 029 · 031 제거 - 번호는 결번 유지 - 원인: 던지는 곳 0곳. API 문서 예시에만 남음 - findBySourceTournamentId 제거 - 원인: 호출 0곳 - 토너먼트 아이템 조회의 클론 분기 제거 - 원인: 호출부 5곳 모두 ROOT 로 해소한 뒤 호출 - 잔재 클론 id 관측 카운터 추가 (tournament.clone_redirect) - kind=resolved: 리다이렉트 shim 이 ROOT 로 해소 - kind=item_guard: 아이템 쓰기 클론 가드에 막힘 - 원인: 4-2단계 shim · 가드 제거 판단 근거 - API 문서 · 주석을 CLONE 제거 이후 모델로 갱신 - isRoot · sourceTournamentId 는 deprecated 표기. 응답은 그대로 유지 - 방장 · 히스토리를 참여 행 id 로 가리키는 이유를 TournamentUser 주석에 기록 --- .../tournament/controller/TournamentApi.kt | 50 ++++++++-------- .../controller/TournamentApiExamples.kt | 21 ++----- .../controller/TournamentController.kt | 2 +- .../dto/TournamentDetailResponse.kt | 10 ++-- .../piki/tournament/domain/Tournament.kt | 3 + .../tournament/domain/TournamentPlayType.kt | 11 ++-- .../piki/tournament/domain/TournamentUser.kt | 15 ++++- .../tournament/event/TournamentCompleted.kt | 4 +- .../event/TournamentPlayedFromLink.kt | 2 +- .../tournament/event/TournamentResultReady.kt | 6 +- .../repository/TournamentJpaRepository.kt | 3 - .../repository/TournamentRepository.kt | 3 - .../repository/TournamentRepositoryImpl.kt | 4 -- .../service/CloneRedirectMetrics.kt | 25 ++++++++ .../tournament/service/TournamentErrorCode.kt | 7 ++- .../tournament/service/TournamentException.kt | 8 --- .../TournamentItemPersistenceService.kt | 20 ++++++- .../tournament/service/TournamentService.kt | 52 +++++++++++----- .../service/dto/TournamentDetail.kt | 3 +- .../controller/TournamentIntegrationTest.kt | 59 +++++++++++++++++-- 20 files changed, 199 insertions(+), 109 deletions(-) create mode 100644 src/main/kotlin/com/depromeet/piki/tournament/service/CloneRedirectMetrics.kt 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 40e0680ad..07b94c15d 100644 --- a/src/main/kotlin/com/depromeet/piki/tournament/controller/TournamentApi.kt +++ b/src/main/kotlin/com/depromeet/piki/tournament/controller/TournamentApi.kt @@ -41,13 +41,13 @@ interface TournamentApi { status 파라미터로 상태 필터링 가능하며 여러 값을 중복 전달할 수 있다(예: ?status=PENDING&status=IN_PROGRESS). 생략 시 전체 반환. status 값은 대문자(PENDING/IN_PROGRESS/COMPLETED)로 전달해야 한다. limit 파라미터로 최근순 상위 N개만 받을 수 있다(예: 홈 카드용 ?limit=3). 생략 시 전체. - ownedOnly=true 면 내가 owner 인 ROOT·CLONE 만 반환한다(참여만 한 것은 제외) — 홈 카드용. 생략 시 false 로 참여한 것까지 포함. + ownedOnly=true 면 내가 만든 토너먼트만 반환한다(참여만 한 것은 제외) — 홈 카드용. 생략 시 false 로 참여한 것까지 포함. ownedOnly 는 status 와 독립된 필터라 함께 보내면 AND 로 걸린다. 홈이 상태 무관인 것은 status 를 안 보내기 때문이다. playType 파라미터로 솔로/소셜을 나눠 볼 수 있다(SOLO · SOCIAL). 생략 시 전체이며 status 와 AND 로 걸린다 (예: ?status=PENDING&status=IN_PROGRESS&playType=SOCIAL). playType 은 저장된 속성이 아니라 참가 결과로 파생된다 — 혼자면 SOLO, 참여자가 생기면 SOCIAL 이고 - 남의 토너먼트에 참여한 항목은 항상 SOCIAL 이다. 참여는 PENDING 에서만 가능하므로 값이 바뀔 수 있는 구간도 - PENDING 하나이며, 그 사이 같은 토너먼트가 SOLO 목록에서 SOCIAL 목록으로 옮겨갈 수 있다. + 남의 토너먼트에 참여한 항목은 항상 SOCIAL 이다. 참여는 초대 코드(PENDING)와 플레이 링크(COMPLETED)로 일어나므로, + 그 사이 같은 토너먼트가 SOLO 목록에서 SOCIAL 목록으로 옮겨갈 수 있다. 각 항목의 thumbnailUrls 는 카드 대표 썸네일 URL 배열이다 — 최근 등록 아이템 중 이미지가 준비된(READY) 것 최대 2장. 이미지가 준비된 아이템이 없으면 빈 배열이며, limit 유무와 무관하게 항상 포함된다. @@ -56,7 +56,7 @@ interface TournamentApi { - playedCount ("플레이한 N"): 플레이를 끝까지 마친 사람 수. 시작만 하고 이탈한 사람은 빠지며, 아무도 완주 안 했으면 0이다. 전체 결과 영수증에 나오는 인원과 같은 기준이다. - CLONE(플레이 링크로 만든 내 사본)도 두 값은 원본(ROOT) 기준으로 내려간다. 같은 토너먼트를 보는 카드이기 때문이다. + 플레이 링크로 참여한 토너먼트도 같은 토너먼트의 참여자로 세므로 두 값이 주최자 카드와 같다. **participantProfileImages 는 deprecated 다.** 위 두 숫자로 대체됐고, 클라이언트가 전환해 배포되면 서버에서 제거한다. 그때까지는 옛 값 그대로 함께 내려가니 새로 참조하지 않는다. @@ -108,7 +108,7 @@ interface TournamentApi { ) playType: TournamentPlayType?, @Parameter( - description = "내가 생성한 것만 볼지 (true 면 내가 owner 인 ROOT·CLONE 만). 생략 시 false 로 참여한 것까지 포함. status 와는 AND 로 걸린다. 홈 카드용.", + description = "내가 생성한 것만 볼지 (true 면 내가 만든 토너먼트만). 생략 시 false 로 참여한 것까지 포함. status 와는 AND 로 걸린다. 홈 카드용.", example = "true", ) ownedOnly: Boolean, @@ -120,10 +120,10 @@ interface TournamentApi { summary = "토너먼트 단건 조회", description = """ 토너먼트 ID로 상태에 따른 상세 정보를 조회한다. - isOwner: 요청자가 해당 토너먼트 인스턴스(ROOT 또는 CLONE)의 소유자이면 true. - isRoot: 소셜 토너먼트 원본(ROOT)이면 true, 멤버·플레이링크용 복사본(CLONE)이면 false. 솔로 토너먼트는 항상 true. - sourceTournamentId: CLONE 토너먼트이면 원본 ROOT의 id, ROOT이면 null. 게스트는 이 id로 GET /tournaments/{sourceTournamentId}/group-result를 호출한다. - 플레이 링크 공유는 isRoot && isOwner 일 때만 허용된다. isOwner 단독으로 분기하면 CLONE 소유자에게도 공유 버튼이 노출되어 시안 위반이다. + isOwner: 요청자가 이 토너먼트를 만든 주최자이면 true. 플레이 링크 공유는 isOwner 일 때 허용된다. + isRoot: **deprecated.** 항상 true 다. 플레이 링크 참여자에게 사본 토너먼트를 만들던 구조(CLONE)가 사라져(#1027) + 모든 참여자가 같은 원본 토너먼트를 본다. 클라이언트가 참조를 끊으면 서버에서 제거한다. + sourceTournamentId: **deprecated.** 같은 이유로 항상 null 이라 응답에서 키가 생략된다. 그룹 결과는 응답의 tournamentId 로 호출한다. 응답의 status 필드에 따라 포함되는 데이터가 달라진다. - PENDING: pending 필드 (아이템 목록, 참여자 목록) - 각 아이템에 status 포함 (READY / PENDING / PROCESSING / FAILED). PENDING·PROCESSING 이면 name·price·imageUrl 은 null 이라 응답에서 제외됨 @@ -152,7 +152,7 @@ interface TournamentApi { - result: 1위부터 최대 4위까지 순위 아이템 목록 - isGroupTournament: 소셜(그룹) 토너먼트 여부. 참여자 2명 이상이면 true(완료 무관). 클라이언트는 이 값으로 "전체 결과 보기" 배너를 노출한다 - 첫 완주자가 누구든 새로고침 없이 배너가 보인다. - hasGroupResult: 그룹 결과 조회 가능 여부. 완료한 플레이어가 2명 이상이면 true. 클라이언트는 이 값으로 배너 활성/비활성(다른 사람 결과가 아직 없으면 empty state)을 가른다. - - canAddItem: 결과 화면에서 아이템 담기(위시/링크/이미지)가 가능하면 true. ROOT 소유자·소셜 초대 CLONE 소유자는 true, 플레이링크 CLONE 소유자는 false. + - canAddItem: 결과 화면에서 아이템 담기(위시/링크/이미지)가 가능하면 true. 이 토너먼트의 참여자(주최자·초대 멤버·플레이 링크 참여자)면 true. 나머지 필드는 응답에 포함되지 않는다. """, ) @@ -301,7 +301,7 @@ interface TournamentApi { summary = "그룹 결과 조회", description = """ 완료된 토너먼트의 그룹 결과를 조회한다. - 원본 토너먼트와 플레이 링크로 복제된 모든 토너먼트의 결과를 비교해, + 이 토너먼트를 완주한 모든 참여자(주최자·초대 멤버·플레이 링크 참여자)의 결과를 비교해, 각 순위의 아이템마다 동일한 결과를 선택한 참여자 정보를 반환한다. 각 참여자에 isHost 가 포함된다 (이 토너먼트를 만든 주최자면 true). 클라이언트는 프로필 우측 하단에 HostBadge 를 그린다. @@ -331,7 +331,7 @@ interface TournamentApi { ), ApiResponse( responseCode = "403", - description = "권한 없음 (토너먼트 참여자가 아님 · 플레이링크로 참여한 복제 토너먼트는 그룹 결과 조회 불가)", + description = "권한 없음 (토너먼트 참여자가 아님)", content = [Content(mediaType = MediaType.APPLICATION_JSON_VALUE, schema = Schema(implementation = ApiResponseBody::class))], ), ApiResponse( @@ -356,7 +356,7 @@ interface TournamentApi { description = """ 이 토너먼트에서만 보이는 참여 닉네임(표시명)을 바꾼다. 유저 프로필 닉네임은 바뀌지 않는다. 결과·진행 화면의 참여자 이름과 알림(SSE·FCM) 문구에 이 닉네임이 쓰인다. - 요청자가 참여한 토너먼트여야 한다(멤버는 자기 루트 토너먼트 id, 플레이링크 게스트는 자기 복제 토너먼트 id 를 넘긴다). + 요청자가 참여한 토너먼트여야 한다(주최자·멤버·플레이 링크 참여자 모두 그 토너먼트 id 를 넘긴다). """, ) @ApiResponses( @@ -522,7 +522,7 @@ interface TournamentApi { description = "상태 충돌 (PENDING이 아닌 토너먼트 · PROCESSING/FAILED 상품 포함 · " + "값이 비어 있는 미완성 상품 포함 · 가격 정보 없는 상품 포함 · " + - "주최자가 아직 시작하지 않음 · 이미 본인 클론이 있음 — 뒤 둘은 멤버 시작 경로)", + "주최자가 아직 시작하지 않음 — 마지막은 멤버 시작 경로)", content = [ Content( mediaType = MediaType.APPLICATION_JSON_VALUE, @@ -696,7 +696,7 @@ interface TournamentApi { nextMatch 는 같은 라운드에 남은 다음 매치이며, 라운드가 끝났으면 생략된다 (두 필드가 다 비면 data 는 빈 객체다). 이때 클라이언트는 GET /tournaments/{id} 를 다시 호출해 다음 라운드를 받는다. 결승(currentRound=2) 결과 기록 시 completed 에 본인의 순위 결과(1위~최대 4위)가 즉시 담긴다. - 소셜 토너먼트라도 각 인스턴스(ROOT·CLONE)는 해당 인스턴스의 결승이 완료되는 즉시 COMPLETED 로 전환된다. + 소셜 토너먼트라도 진행 상태는 참여자마다 따로라, 내 결승을 기록하는 즉시 나에게는 COMPLETED 로 보인다. 다른 참여자의 진행 여부와 무관하게 내 결과는 바로 확인할 수 있다. 소셜 토너먼트면 완주 즉시 isGroupTournament=true 로 "전체 결과 보기" 배너가 노출되고(새로고침 불필요), 전체 그룹 결과는 2명 이상이 완료한 뒤 hasGroupResult=true 로 활성화된다. """, ) @@ -774,8 +774,8 @@ interface TournamentApi { summary = "플레이 링크 생성 (멱등)", description = """ 완료된 토너먼트의 플레이 링크를 만들거나 갱신한다. 토너먼트 소유자만 호출 가능. - 플레이 링크를 통해 친구들이 동일한 아이템 구성으로 각자 자기 토너먼트를 진행할 수 있다 - (친구마다 원본을 복제한 새 토너먼트가 생긴다 - `POST /{sourceTournamentId}/from-play-link`). + 플레이 링크를 통해 친구들이 같은 토너먼트에 참여해 각자 자기 판을 진행할 수 있다 + (친구마다 이 토너먼트의 참여자가 하나씩 늘어난다 - `POST /{sourceTournamentId}/from-play-link`). **호출 시점의 링크 상태에 따라 동작이 갈리고, 항상 200을 반환한다.** - 아직 만든 적 없으면: 새로 만든다 (생성 시점 + 14일). @@ -784,8 +784,7 @@ interface TournamentApi { - 만료된 링크가 있으면: 새 14일로 갱신한다. 플레이 링크는 **토너먼트당 하나**이고 주소가 `tournamentId` 로 결정된다(별도 토큰 없음) - 같은 - 토너먼트에 여러 개를 만들 수 없다. 링크를 통해 들어오는 인원 제한은 없고, 들어온 사람마다 자기 - 클론이 하나씩 생긴다. + 토너먼트에 여러 개를 만들 수 없다. 링크를 통해 들어오는 인원 제한은 없다. 초대 코드(시작 전, 참여자로 합류)와는 다른 기능이며 동시에 열리는 구간이 없다 - 초대 코드는 PENDING 상태에서만, 플레이 링크는 COMPLETED 상태에서만 쓸 수 있다. @@ -805,7 +804,7 @@ interface TournamentApi { ), ApiResponse( responseCode = "403", - description = "권한 없음 (토너먼트 참여자가 아님 · 소유자가 아님 · 플레이 링크로 참여한 토너먼트는 플레이 링크 생성 불가)", + description = "권한 없음 (토너먼트 참여자가 아님 · 소유자가 아님)", content = [Content(mediaType = MediaType.APPLICATION_JSON_VALUE, schema = Schema(implementation = ApiResponseBody::class))], ), ApiResponse( @@ -828,17 +827,18 @@ interface TournamentApi { @Operation( summary = "플레이 링크로 토너먼트 진행", description = """ - 플레이 링크가 유효한 토너먼트와 동일한 아이템 구성으로 새 토너먼트를 생성한다. - 생성된 토너먼트는 PENDING 상태이며 아이템이 미리 복사되어 있어 바로 시작할 수 있다. - idempotent: 같은 사용자가 같은 원본의 플레이 링크를 다시 호출하면 새로 만들지 않고 - 기존 본인 클론의 tournamentId 를 그대로 반환한다 (원본 링크 만료와 무관하게 이어서 진행 가능). + 플레이 링크가 유효한 토너먼트에 요청자를 참여자로 추가하고, 그 토너먼트 id 를 반환한다. + 새 토너먼트를 만들지 않는다 - 반환 id 는 항상 경로의 원본 토너먼트 id 다. + 참여는 아직 시작 전 상태라, 이어서 POST /tournaments/{id}/start 로 자기 판을 시작한다. + idempotent: 이미 참여 중이면(초대 멤버·재진입) 새로 추가하지 않고 같은 id 를 반환한다 + (링크 만료와 무관하게 이어서 진행 가능). """, ) @ApiResponses( value = [ ApiResponse( responseCode = "200", - description = "복제 토너먼트 생성 또는 기존 본인 클론 반환 (tournamentId 반환)", + description = "참여 시작 또는 기존 참여 이어하기 (원본 tournamentId 반환)", content = [Content(mediaType = MediaType.APPLICATION_JSON_VALUE, schema = Schema(implementation = ApiResponseBody::class))], ), ApiResponse( 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 4d8277655..fd16281c6 100644 --- a/src/main/kotlin/com/depromeet/piki/tournament/controller/TournamentApiExamples.kt +++ b/src/main/kotlin/com/depromeet/piki/tournament/controller/TournamentApiExamples.kt @@ -247,9 +247,8 @@ class TournamentApiExamples( add(TournamentException.itemNotReadyToStart(), name = "아직 준비 중인 상품 포함") add(TournamentException.itemIncompleteToStart(), name = "정보가 일부만 채워진(INCOMPLETE) 상품 포함") add(TournamentException.itemPriceRequired(), name = "가격 정보 없는 상품 포함") - // 아래 둘은 멤버 시작 경로(startAsMember) — 주최자가 ROOT 를 시작해야 멤버가 본인 클론을 만들 수 있다. + // 멤버 시작 경로(startAsMember) — 주최자가 시작해야 멤버가 자기 판을 시작할 수 있다. add(TournamentException.notInProgressTournament(), name = "주최자가 아직 시작하지 않음 (멤버 시작)") - add(TournamentException.alreadyCloned(), name = "이미 본인 클론이 있음 (멤버 시작)") } handlerMethod.binds(TournamentController::recordMatch) -> @@ -469,16 +468,16 @@ class TournamentApiExamples( ) add( status = HttpStatus.OK, - name = "IN_PROGRESS - 진행 중 복원 (CLONE)", + name = "IN_PROGRESS - 진행 중 복원 (참여자)", payload = ApiResponseBody.ok( TournamentDetailResponse( - tournamentId = 2, + tournamentId = 1, name = "내 토너먼트", status = TournamentStatus.IN_PROGRESS, isOwner = false, - isRoot = false, - sourceTournamentId = 1, + isRoot = true, + sourceTournamentId = null, pending = null, inProgress = TournamentDetailResponse.InProgressData( @@ -681,10 +680,6 @@ class TournamentApiExamples( ) unauthorized() add(TournamentException.forbiddenTournament(), name = "권한 없음 (참여자 아님 · 소유자 아님)") - add( - TournamentException.clonedTournamentCannotSharePlayLink(), - name = "플레이 링크로 참여한 토너먼트 (재공유 불가)", - ) add(TournamentException.notFoundTournament(), name = "토너먼트를 찾을 수 없음") add(TournamentException.notCompletedTournament(), name = "완료되지 않은 토너먼트") } @@ -713,7 +708,7 @@ class TournamentApiExamples( operation.examples(openApiObjectMapper.delegate) { add( status = HttpStatus.OK, - name = "복제 토너먼트 생성 또는 기존 본인 클론 반환", + name = "참여 시작 또는 기존 참여 이어하기 (항상 원본 토너먼트 id)", payload = ApiResponseBody.ok(42L), ) unauthorized() @@ -724,10 +719,6 @@ class TournamentApiExamples( handlerMethod.binds(TournamentController::getGroupResult) -> operation.examples(openApiObjectMapper.delegate) { - add( - TournamentException.clonedTournamentCannotViewGroupResult(), - name = "플레이링크 복제 토너먼트에서 그룹 결과 조회 불가", - ) add( status = HttpStatus.OK, name = "그룹 결과 조회 성공 (회원)", diff --git a/src/main/kotlin/com/depromeet/piki/tournament/controller/TournamentController.kt b/src/main/kotlin/com/depromeet/piki/tournament/controller/TournamentController.kt index 63395b054..e8a27b832 100644 --- a/src/main/kotlin/com/depromeet/piki/tournament/controller/TournamentController.kt +++ b/src/main/kotlin/com/depromeet/piki/tournament/controller/TournamentController.kt @@ -173,7 +173,7 @@ class TournamentController( @AuthenticationPrincipal userId: UUID, @PathVariable sourceTournamentId: Long, ): ApiResponseBody { - // idempotent get-or-create: 신규 생성·기존 클론 반환 모두 200. 항상 "생성"이 아니므로 201 을 쓰지 않는다. + // idempotent get-or-create: 신규 참여·기존 참여 이어하기 모두 200. 항상 "생성"이 아니므로 201 을 쓰지 않는다. val tournamentId = tournamentService.createFromPlayLink(userId, sourceTournamentId) return ApiResponseBody.ok(tournamentId) } 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 11f901740..d3390371e 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 @@ -13,11 +13,10 @@ data class TournamentDetailResponse( val name: String, val status: TournamentStatus, val isOwner: Boolean, - // ROOT(소셜 토너먼트 원본) 이면 true, CLONE(멤버·플레이링크용 복사본) 이면 false. - // 플레이 링크 공유는 isRoot && isOwner 일 때만 허용된다. + // deprecated(#1027) — CLONE 이 사라져 항상 true. 옛 클라가 공유 버튼을 isRoot && isOwner 로 갈라 계약으로 남긴다. + // 클라가 참조를 끊으면 제거한다. val isRoot: Boolean, - // CLONE 토너먼트이면 원본 ROOT id, ROOT 토너먼트이면 null. - // 게스트 클라이언트는 이 id 로 GET /tournaments/{sourceTournamentId}/group-result 를 호출한다. + // deprecated(#1027) — 같은 이유로 항상 null 이라 NON_NULL 로 키가 생략된다. 클라가 참조를 끊으면 제거한다. val sourceTournamentId: Long?, val pending: PendingData?, val inProgress: InProgressData?, @@ -113,8 +112,7 @@ data class TournamentDetailResponse( // 소셜(그룹) 토너먼트 여부 — 참여자 수가 2명 이상이면 true(완료 무관). 클라는 이 값으로 "전체 결과 보기" 배너 // 노출을 가른다 — 첫 완주자가 누구든 새로고침 없이 배너를 본다(#975). val isGroupTournament: Boolean, - // true: ROOT 소유자 또는 소셜 초대 CLONE 소유자 — 아이템 담기 허용(위시/링크/이미지). - // false: 플레이링크 CLONE 소유자 — 아이템 담기 불가. + // 결과 화면의 아이템 담기(위시/링크/이미지) 허용 여부 — 이 토너먼트의 참여자면 true. val canAddItem: Boolean, val playLinkExpiresAt: LocalDateTime?, ) { diff --git a/src/main/kotlin/com/depromeet/piki/tournament/domain/Tournament.kt b/src/main/kotlin/com/depromeet/piki/tournament/domain/Tournament.kt index ec7885a9a..77667a5a6 100644 --- a/src/main/kotlin/com/depromeet/piki/tournament/domain/Tournament.kt +++ b/src/main/kotlin/com/depromeet/piki/tournament/domain/Tournament.kt @@ -22,11 +22,14 @@ class Tournament( @Enumerated(value = EnumType.STRING) @Column(columnDefinition = "varchar(50)") var status: TournamentStatus = TournamentStatus.PENDING, + // CLONE 제거(#1027) 뒤 새로 채워지지 않는다. 남은 값은 옛 클론 행(리다이렉트 껍데기)이 원본을 가리키는 것뿐이다. + // 옛 클론 id 요청이 끊긴 것을 CloneRedirectMetrics 로 확인한 뒤 매핑을 걷어내고(4-2단계) 컬럼을 드롭한다(4-3단계). @Column(name = "source_tournament_id") val sourceTournamentId: Long? = null, ) : LongBaseEntity() { // open class에서 private set이 금지되므로 backing field로 캡슐화한다. // Hibernate는 field access로 직접 접근하고, 외부에서는 getter만 노출된다. + // 방장은 userId 가 아니라 참여 행 id 로 가리킨다 — 이유는 TournamentUser 상단 주석. @Column(name = "owner_tournament_user_id", nullable = false) private var _ownerTournamentUserId: Long = ownerTournamentUserId diff --git a/src/main/kotlin/com/depromeet/piki/tournament/domain/TournamentPlayType.kt b/src/main/kotlin/com/depromeet/piki/tournament/domain/TournamentPlayType.kt index 264ffb811..025b104e8 100644 --- a/src/main/kotlin/com/depromeet/piki/tournament/domain/TournamentPlayType.kt +++ b/src/main/kotlin/com/depromeet/piki/tournament/domain/TournamentPlayType.kt @@ -1,13 +1,12 @@ package com.depromeet.piki.tournament.domain // 솔로/소셜은 tournaments 에 저장된 컬럼이 아니라 참가 결과로 파생되는 현재 상태다. -// 참여는 PENDING 에서만 가능하므로(checkJoinable) 값이 변할 수 있는 구간도 PENDING 하나이고, -// IN_PROGRESS 이후로는 참가자가 늘지 않아 고정된다. 생성 직후엔 SOLO 이고 누군가 참여하면 SOCIAL 이 된다. +// 생성 직후엔 SOLO 이고 누군가 참여하면 SOCIAL 이 된다. 참여 경로는 초대 코드(PENDING 에서만, checkJoinable)와 +// 플레이 링크(COMPLETED 에서, createFromPlayLink) 둘이라, 완료된 SOLO 도 링크로 누가 들어오면 SOCIAL 이 된다. // -// 판정은 두 갈래다 (SOLO 와 SOCIAL 은 서로 여집합). -// CLONE(sourceTournamentId 있음) → SOCIAL. 남의 토너먼트에 참여한 사본이라 그 자체가 소셜 관계다. -// CLONE 은 소유자 1명만 tournament_users 행을 가져, 참가자 수 규칙을 그대로 적용하면 SOLO 로 오분류된다. -// ROOT → 참가자 2명 이상이면 SOCIAL, 혼자면 SOLO. +// 판정은 참가자 수 하나다 (SOLO 와 SOCIAL 은 서로 여집합). 2명 이상이면 SOCIAL, 혼자면 SOLO. +// CLONE 이 있던 시절엔 "사본은 소유자 1명뿐이라 수로 세면 SOLO 로 오분류" 되는 예외 갈래가 있었으나, CLONE 제거(#1027)로 +// 모든 참여자가 같은 토너먼트의 참여 행을 가져 수만으로 판정된다. enum class TournamentPlayType { SOLO, SOCIAL, diff --git a/src/main/kotlin/com/depromeet/piki/tournament/domain/TournamentUser.kt b/src/main/kotlin/com/depromeet/piki/tournament/domain/TournamentUser.kt index 71df43353..9551f0baa 100644 --- a/src/main/kotlin/com/depromeet/piki/tournament/domain/TournamentUser.kt +++ b/src/main/kotlin/com/depromeet/piki/tournament/domain/TournamentUser.kt @@ -9,8 +9,19 @@ import jakarta.persistence.Table import java.time.LocalDateTime import java.util.UUID -// 어떤 유저가 어떤 토너먼트에 참여했는지를 명시 관리하는 매핑 테이블. +// 어떤 유저가 어떤 토너먼트에 참여했는지를 명시 관리하는 매핑 테이블. 한 사람의 "이 방에서의 참여" 하나가 한 행이다. // userId 는 게스트·회원 모두 수용 (현재는 Guest 의 UUID). +// +// 토너먼트 도메인은 User 를 끌어오지 않고, 사람을 가리킬 때 userId 가 아니라 이 행의 id 를 쓴다 +// (tournaments.owner_tournament_user_id · tournament_histories.tournament_user_id). User 와의 접점은 이 행의 userId 하나다. +// userId 로 바꾸지 않는 이유(#1188): +// - userId 는 바뀐다. 게스트가 기존 회원 계정으로 로그인하면 userId 가 회원 것으로 교체된다(#1081 GuestPlayTakeover). +// 행 id 를 가리키면 이 행의 userId 하나만 고치면 되고, 방장·히스토리는 행 id 를 따라 그대로 넘어간다. +// - 승계 중 같은 방에 게스트·회원 행이 둘 다 있으면 게스트 행을 접는다. 히스토리가 행 id 에 묶여 있어 두 사람의 +// 선택 기록이 한 키로 섞이지 않는다. +// - 대진표 seed 가 seedOf(tournamentUserId, round)다(RoundBracket). 승계로 userId 가 바뀌어도 진행 중 대진표가 그대로다. +// - 표시명은 nickname 스냅샷(#1018)이 여기 있어, 화면을 그릴 때 User 를 다시 읽지 않아도 된다. +// 예외로 tournament_items.user_id 는 "아이템을 담은 사람" 이라 userId 를 쓰고, 승계 때 따로 옮긴다. @Entity @Table(name = "tournament_users") class TournamentUser( @@ -43,7 +54,7 @@ class TournamentUser( nickname = newNickname } - // 플레이 시작 — 명단(PENDING)에서 진행으로. 멤버·게스트가 자기 판을 시작하는 순간(과거의 CLONE 생성 지점). + // 플레이 시작 — 명단(PENDING)에서 진행으로. 멤버·게스트가 자기 판을 시작하는 순간. fun startPlaying() { check(status == TournamentStatus.PENDING) { "startPlaying 은 PENDING 에서만 호출 가능: $status" } status = TournamentStatus.IN_PROGRESS diff --git a/src/main/kotlin/com/depromeet/piki/tournament/event/TournamentCompleted.kt b/src/main/kotlin/com/depromeet/piki/tournament/event/TournamentCompleted.kt index 11ff507eb..e465943cf 100644 --- a/src/main/kotlin/com/depromeet/piki/tournament/event/TournamentCompleted.kt +++ b/src/main/kotlin/com/depromeet/piki/tournament/event/TournamentCompleted.kt @@ -3,8 +3,8 @@ package com.depromeet.piki.tournament.event import com.depromeet.piki.common.event.NotificationEvent import java.util.UUID -// 멤버/게스트가 자기 클론(=내 토너먼트의 클론)을 끝까지 플레이해 완료한 사실 — 도메인 사실. recordMatch 결승 → complete() 시점이며, -// 완료된 토너먼트가 CLONE(sourceTournamentId 있음)일 때만 발행한다. rootTournamentId 는 원본(ROOT), actorId 는 완료한 사람. +// 멤버/게스트가 내 토너먼트에서 자기 판을 끝까지 플레이해 완료한 사실 — 도메인 사실. recordMatch 결승 시점이며, +// 완료한 참여 행이 주최자 행이 아닐 때만 발행한다. rootTournamentId 는 그 토너먼트, actorId 는 완료한 사람. // 수신자(ROOT 주최자)에게 "OO님이 완료했어요" 를 알린다. (ROOT 자체 완료=주최자 본인 완료는 TournamentResultReady 로 간다.) data class TournamentCompleted( val rootTournamentId: Long, diff --git a/src/main/kotlin/com/depromeet/piki/tournament/event/TournamentPlayedFromLink.kt b/src/main/kotlin/com/depromeet/piki/tournament/event/TournamentPlayedFromLink.kt index ce20afe59..1ac809d60 100644 --- a/src/main/kotlin/com/depromeet/piki/tournament/event/TournamentPlayedFromLink.kt +++ b/src/main/kotlin/com/depromeet/piki/tournament/event/TournamentPlayedFromLink.kt @@ -3,7 +3,7 @@ package com.depromeet.piki.tournament.event import com.depromeet.piki.common.event.NotificationEvent import java.util.UUID -// 플레이링크로 누군가 내 토너먼트를 플레이하기 시작한 사실 — 도메인 사실. createFromPlayLink 의 신규 클론 생성 시점이다. +// 플레이링크로 누군가 내 토너먼트를 플레이하기 시작한 사실 — 도메인 사실. createFromPlayLink 가 새 참여 행을 만드는 시점이다. // rootTournamentId 는 원본(ROOT) 토너먼트, actorId 는 링크로 플레이를 시작한 사람. 수신자(ROOT 주최자)에게 알려, // 링크를 뿌린 주최자가 누가 참여했는지 앱을 일일이 안 열어봐도 알게 한다. data class TournamentPlayedFromLink( diff --git a/src/main/kotlin/com/depromeet/piki/tournament/event/TournamentResultReady.kt b/src/main/kotlin/com/depromeet/piki/tournament/event/TournamentResultReady.kt index 65f1a56c2..50c55dbf4 100644 --- a/src/main/kotlin/com/depromeet/piki/tournament/event/TournamentResultReady.kt +++ b/src/main/kotlin/com/depromeet/piki/tournament/event/TournamentResultReady.kt @@ -3,9 +3,9 @@ package com.depromeet.piki.tournament.event import com.depromeet.piki.common.event.NotificationEvent import java.util.UUID -// 주최자가 자기 토너먼트(ROOT)를 완료해 결과가 나온 사실 — 도메인 사실. recordMatch 결승 → complete() 시점이며, -// 완료된 토너먼트가 ROOT(sourceTournamentId 없음=주최자 본인 진행)일 때만 발행한다. rootTournamentId 는 그 ROOT, actorId 는 주최자. -// 수신자(참여자 − 주최자: ROOT 참가자 + 플레이링크 클론 소유자)에게 "참여하신 OO님의 토너먼트 결과가 나왔어요" 를 알린다. +// 주최자가 자기 토너먼트에서 자기 판을 완료해 결과가 나온 사실 — 도메인 사실. recordMatch 결승 시점이며, +// 완료한 참여 행이 주최자 행일 때만 발행한다. rootTournamentId 는 그 토너먼트, actorId 는 주최자. +// 수신자(참여자 − 주최자: 초대 멤버 + 플레이 링크 참여자)에게 "참여하신 OO님의 토너먼트 결과가 나왔어요" 를 알린다. data class TournamentResultReady( val rootTournamentId: Long, val actorId: UUID, diff --git a/src/main/kotlin/com/depromeet/piki/tournament/repository/TournamentJpaRepository.kt b/src/main/kotlin/com/depromeet/piki/tournament/repository/TournamentJpaRepository.kt index 1da6e8fa0..3235b85ca 100644 --- a/src/main/kotlin/com/depromeet/piki/tournament/repository/TournamentJpaRepository.kt +++ b/src/main/kotlin/com/depromeet/piki/tournament/repository/TournamentJpaRepository.kt @@ -73,9 +73,6 @@ interface TournamentJpaRepository : JpaRepository { pageable: Pageable, ): List - fun findBySourceTournamentIdAndDeletedAtIsNull(sourceTournamentId: Long): List - - // 활성 초대코드 조회는 base 컬럼 invite_code 가 아니라 generated 컬럼 active_invite_code 로 한다. // uk_tournaments_active_invite_code 유니크 인덱스가 이 컬럼에만 걸려 있어, invite_code 로 조회하면 // MySQL 8 이 인덱스를 못 써 tournaments 풀스캔이 된다. 삭제행은 active_invite_code 가 NULL 이라 diff --git a/src/main/kotlin/com/depromeet/piki/tournament/repository/TournamentRepository.kt b/src/main/kotlin/com/depromeet/piki/tournament/repository/TournamentRepository.kt index 9e7b50af4..a7f5d3248 100644 --- a/src/main/kotlin/com/depromeet/piki/tournament/repository/TournamentRepository.kt +++ b/src/main/kotlin/com/depromeet/piki/tournament/repository/TournamentRepository.kt @@ -32,9 +32,6 @@ interface TournamentRepository { limit: Int?, ): List - fun findBySourceTournamentId(sourceTournamentId: Long): List - - fun findTournamentByInviteCode(code: String): Tournament? fun existsTournamentByInviteCode(code: String): Boolean diff --git a/src/main/kotlin/com/depromeet/piki/tournament/repository/TournamentRepositoryImpl.kt b/src/main/kotlin/com/depromeet/piki/tournament/repository/TournamentRepositoryImpl.kt index 9ba8f9642..ac3b7484e 100644 --- a/src/main/kotlin/com/depromeet/piki/tournament/repository/TournamentRepositoryImpl.kt +++ b/src/main/kotlin/com/depromeet/piki/tournament/repository/TournamentRepositoryImpl.kt @@ -63,10 +63,6 @@ class TournamentRepositoryImpl( ) } - override fun findBySourceTournamentId(sourceTournamentId: Long): List = - tournamentJpaRepository.findBySourceTournamentIdAndDeletedAtIsNull(sourceTournamentId) - - override fun findTournamentByInviteCode(code: String): Tournament? = tournamentJpaRepository.findFirstByActiveInviteCode(code) diff --git a/src/main/kotlin/com/depromeet/piki/tournament/service/CloneRedirectMetrics.kt b/src/main/kotlin/com/depromeet/piki/tournament/service/CloneRedirectMetrics.kt new file mode 100644 index 000000000..966c03207 --- /dev/null +++ b/src/main/kotlin/com/depromeet/piki/tournament/service/CloneRedirectMetrics.kt @@ -0,0 +1,25 @@ +package com.depromeet.piki.tournament.service + +import io.micrometer.core.instrument.MeterRegistry + +// 잔재 CLONE id 로 들어온 요청을 센다(#1188). CLONE 제거(#1027) 뒤 클론 행은 옛 URL 을 ROOT 로 잇는 껍데기로만 남아 있다. +// 이 카운터가 prod 에서 충분히 오래 0 이면 껍데기·shim·클론 가드를 지워도(4-2단계) 아무도 404 를 받지 않는다. +// 정상(ROOT id) 요청은 세지 않는다 — 관심사는 "옛 클론 id 가 아직 오는가" 하나다. +// 메트릭 이름·태그 키를 한 곳에 고정해 emit 경로가 같은 키 집합을 쓰게 한다(키 어긋나면 Prometheus 가 시계열을 드롭). +object CloneRedirectMetrics { + const val METRIC = "tournament.clone_redirect" + const val TAG_KIND = "kind" + + // 클론 id 를 ROOT 로 해소해 정상 응답한 경우(조회·플레이·닉네임 등). + const val KIND_RESOLVED = "resolved" + + // 클론 id 로 아이템을 추가·수정·삭제하려다 클론 가드(032·038)에 막힌 경우. + const val KIND_ITEM_GUARD = "item_guard" + + fun record( + registry: MeterRegistry, + kind: String, + ) { + registry.counter(METRIC, TAG_KIND, kind).increment() + } +} diff --git a/src/main/kotlin/com/depromeet/piki/tournament/service/TournamentErrorCode.kt b/src/main/kotlin/com/depromeet/piki/tournament/service/TournamentErrorCode.kt index f6582ca8f..ead1ff48f 100644 --- a/src/main/kotlin/com/depromeet/piki/tournament/service/TournamentErrorCode.kt +++ b/src/main/kotlin/com/depromeet/piki/tournament/service/TournamentErrorCode.kt @@ -11,6 +11,10 @@ import com.depromeet.piki.common.exception.ErrorCode // 검증이 추가돼 append 로 붙였다(소스 선언 위치는 003 뒤지만 번호 안정성 위해 뒤에 부여). // 이 도메인 이관으로 409(CONFLICT) 응답이 처음으로 도메인 code(TOURNAMENT-0xx)를 싣게 된다 — // #744 는 CONFLICT 에 공통 code 를 두지 않아 그동안 code=null 이었다. +// +// **결번**: 024(CLONED_TOURNAMENT_CANNOT_SHARE_PLAY_LINK)·029(ALREADY_CLONED)·031(CLONED_TOURNAMENT_CANNOT_VIEW_GROUP_RESULT) +// 은 CLONE 제거(#1027)로 던지는 곳이 사라져 #1188 에서 폐기했다. 셋 다 클론 id 로만 닿던 사유이고, 1-3단계 이후 +// 서비스가 클론 id 를 ROOT 로 해소하므로 도달할 수 없다. 번호는 재사용하지 않는다. enum class TournamentErrorCode( override val code: String, override val category: ErrorCategory, @@ -39,7 +43,6 @@ enum class TournamentErrorCode( INVITE_EXPIRED("TOURNAMENT-021", ErrorCategory.CONFLICT, "초대 링크가 만료됐어요."), ALREADY_PARTICIPANT("TOURNAMENT-022", ErrorCategory.CONFLICT, "이미 참여 중인 토너먼트예요."), NOT_COMPLETED_TOURNAMENT("TOURNAMENT-023", ErrorCategory.CONFLICT, "완료된 토너먼트에서만 할 수 있어요."), - CLONED_TOURNAMENT_CANNOT_SHARE_PLAY_LINK("TOURNAMENT-024", ErrorCategory.FORBIDDEN, "플레이 링크로 참여한 토너먼트는 공유 링크를 만들 수 없어요."), // 025 는 #980 에서 발생을 멈췄다. createPlayLink 가 멱등해지며(유효하면 기존 값 반환, 만료됐으면 재발급) // "이미 있으면 무조건 거부" 하던 이 사유가 사라졌다 — 그 거부가 만료 후 영구 재발급 불가로 이어졌었다. // 번호는 재사용하지 않고 결번으로 남긴다(코드가 클라 계약). GUEST_CANNOT_CREATE_TOURNAMENT(036)와 같은 결. @@ -47,9 +50,7 @@ enum class TournamentErrorCode( PLAY_LINK_NOT_CREATED("TOURNAMENT-026", ErrorCategory.NOT_FOUND, "아직 플레이 링크가 만들어지지 않은 토너먼트예요."), PLAY_LINK_EXPIRED("TOURNAMENT-027", ErrorCategory.CONFLICT, "플레이 링크가 만료됐어요."), GROUP_RESULT_NOT_AVAILABLE("TOURNAMENT-028", ErrorCategory.CONFLICT, "완료된 토너먼트에서만 결과를 볼 수 있어요."), - ALREADY_CLONED("TOURNAMENT-029", ErrorCategory.CONFLICT, "이미 이 플레이 링크로 만든 토너먼트가 있어요."), PARTICIPANT_LIMIT_EXCEEDED("TOURNAMENT-030", ErrorCategory.CONFLICT, "참여 인원이 가득 찼어요."), - CLONED_TOURNAMENT_CANNOT_VIEW_GROUP_RESULT("TOURNAMENT-031", ErrorCategory.FORBIDDEN, "플레이 링크로 참여한 토너먼트에서는 친구 결과를 볼 수 없어요."), CLONED_TOURNAMENT_CANNOT_ADD_ITEMS("TOURNAMENT-032", ErrorCategory.FORBIDDEN, "플레이 링크로 만든 토너먼트에는 아이템을 추가할 수 없어요."), INVALID_LIMIT("TOURNAMENT-033", ErrorCategory.INVALID_INPUT, "조회 개수는 1 이상이어야 해요."), diff --git a/src/main/kotlin/com/depromeet/piki/tournament/service/TournamentException.kt b/src/main/kotlin/com/depromeet/piki/tournament/service/TournamentException.kt index 705154143..318157788 100644 --- a/src/main/kotlin/com/depromeet/piki/tournament/service/TournamentException.kt +++ b/src/main/kotlin/com/depromeet/piki/tournament/service/TournamentException.kt @@ -81,9 +81,6 @@ class TournamentException private constructor( fun notCompletedTournament(): TournamentException = TournamentException(TournamentErrorCode.NOT_COMPLETED_TOURNAMENT) - fun clonedTournamentCannotSharePlayLink(): TournamentException = - TournamentException(TournamentErrorCode.CLONED_TOURNAMENT_CANNOT_SHARE_PLAY_LINK) - fun playLinkAlreadyCreated(): TournamentException = TournamentException(TournamentErrorCode.PLAY_LINK_ALREADY_CREATED) fun playLinkNotCreated(): TournamentException = TournamentException(TournamentErrorCode.PLAY_LINK_NOT_CREATED) @@ -92,13 +89,8 @@ class TournamentException private constructor( fun groupResultNotAvailable(): TournamentException = TournamentException(TournamentErrorCode.GROUP_RESULT_NOT_AVAILABLE) - fun alreadyCloned(): TournamentException = TournamentException(TournamentErrorCode.ALREADY_CLONED) - fun participantLimitExceeded(): TournamentException = TournamentException(TournamentErrorCode.PARTICIPANT_LIMIT_EXCEEDED) - fun clonedTournamentCannotViewGroupResult(): TournamentException = - TournamentException(TournamentErrorCode.CLONED_TOURNAMENT_CANNOT_VIEW_GROUP_RESULT) - fun clonedTournamentCannotAddItems(): TournamentException = TournamentException(TournamentErrorCode.CLONED_TOURNAMENT_CANNOT_ADD_ITEMS) diff --git a/src/main/kotlin/com/depromeet/piki/tournament/service/TournamentItemPersistenceService.kt b/src/main/kotlin/com/depromeet/piki/tournament/service/TournamentItemPersistenceService.kt index dcc359287..e29d75622 100644 --- a/src/main/kotlin/com/depromeet/piki/tournament/service/TournamentItemPersistenceService.kt +++ b/src/main/kotlin/com/depromeet/piki/tournament/service/TournamentItemPersistenceService.kt @@ -19,6 +19,7 @@ import com.depromeet.piki.tournament.repository.TournamentItemRepository import com.depromeet.piki.tournament.repository.TournamentRepository import com.depromeet.piki.tournament.repository.TournamentUserRepository import com.depromeet.piki.tournament.service.dto.PersistedTournamentItem +import io.micrometer.core.instrument.MeterRegistry import org.springframework.context.ApplicationEventPublisher import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional @@ -41,6 +42,7 @@ class TournamentItemPersistenceService( private val itemSharingService: ItemSharingService, private val itemDisplayService: ItemDisplayService, private val parsingEnqueuer: ParsingEnqueuer, + private val meterRegistry: MeterRegistry, ) { // 이 토너먼트에 이미 출전 중인 item → 그 tournament_item id. 중복 판정이 "무엇과 겹치는지"까지 답해야 해서(#973) // 존재 여부가 아니라 매핑으로 만든다. snapshot 을 못 찾는 행은 자연히 빠지는데, 그런 행은 정원·중복 어느 쪽에도 셀 수 없다. @@ -192,7 +194,8 @@ class TournamentItemPersistenceService( tournamentRepository.findTournamentById(tournamentId) ?: throw TournamentException.notFoundTournament() // 클론은 원본 아이템을 이어받을 뿐 소유 행이 없다 — 수정 시 원본을 건드리므로 막는다(#977, 추가 금지 032 와 같은 결). - tournament.sourceTournamentId?.let { throw TournamentException.clonedTournamentCannotModifyItems() } + // 잔재 클론 id 로만 닿는 가드라 닿을 때마다 센다(#1188). 0 으로 머물면 4-2단계에서 가드째 지운다. + rejectCloneId(tournament) { TournamentException.clonedTournamentCannotModifyItems() } if (!tournament.isPending()) throw TournamentException.notPendingTournament() tournamentUserRepository.findByTournamentIdAndUserId(tournamentId, userId) ?: throw TournamentException.forbiddenTournament() @@ -232,7 +235,7 @@ class TournamentItemPersistenceService( val tournament = tournamentRepository.findTournamentById(tournamentId) ?: throw TournamentException.notFoundTournament() - tournament.sourceTournamentId?.let { throw TournamentException.clonedTournamentCannotModifyItems() } + rejectCloneId(tournament) { TournamentException.clonedTournamentCannotModifyItems() } if (!tournament.isPending()) throw TournamentException.notPendingTournament() val tournamentItem = tournamentItemRepository.findById(tournamentItemId) @@ -282,11 +285,22 @@ class TournamentItemPersistenceService( tournamentId: Long, ) { if (!tournament.isPending()) throw TournamentException.notPendingTournament() - tournament.sourceTournamentId?.let { throw TournamentException.clonedTournamentCannotAddItems() } + rejectCloneId(tournament) { TournamentException.clonedTournamentCannotAddItems() } tournamentUserRepository.findByTournamentIdAndUserId(tournamentId, userId) ?: throw TournamentException.forbiddenTournament() } + // 잔재 클론 id 로 온 아이템 쓰기를 막고, 닿은 횟수를 센다(#1188). 클론 행은 CLONE 제거(#1027) 뒤 옛 URL 을 잇는 + // 껍데기로만 남아 있어, 이 가드는 옛 클론 id 를 들고 온 요청에만 닿는다. + private fun rejectCloneId( + tournament: Tournament, + exception: () -> TournamentException, + ) { + tournament.sourceTournamentId ?: return + CloneRedirectMetrics.record(meterRegistry, CloneRedirectMetrics.KIND_ITEM_GUARD) + throw exception() + } + private fun hasRoomFor( tournamentId: Long, incomingCount: Int, 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 bca292068..1e1ea2ea1 100644 --- a/src/main/kotlin/com/depromeet/piki/tournament/service/TournamentService.kt +++ b/src/main/kotlin/com/depromeet/piki/tournament/service/TournamentService.kt @@ -44,6 +44,7 @@ import com.depromeet.piki.user.domain.UserException import com.depromeet.piki.user.repository.UserRepository import com.depromeet.piki.user.service.DefaultProfileImages import com.depromeet.piki.wishlist.repository.WishRepository +import io.micrometer.core.instrument.MeterRegistry import org.springframework.context.ApplicationEventPublisher import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional @@ -62,6 +63,7 @@ class TournamentService( private val wishRepository: WishRepository, private val defaultProfileImages: DefaultProfileImages, private val eventPublisher: ApplicationEventPublisher, + private val meterRegistry: MeterRegistry, ) { // 게스트 차단은 아이템 등록 한도(ItemQuotaGuard)가 오너 계정 키라, 무한 발급되는 게스트가 오너면 계정 갈아타기로 리셋되기 때문(#904). // 탈퇴 차단은 anonymize 가 행을 남겨 토큰 무효화 부분 실패 창에서 죽은 계정이 생성할 수 있기 때문(#691). @@ -109,25 +111,32 @@ class TournamentService( private fun nicknameOf(userId: UUID): String? = userRepository.findById(userId)?.nickname // 리다이렉트 shim(#1027 Phase 3) — 클론 id 로 온 요청을 ROOT 로 해소한다. Phase 2 백필로 모든 플레이가 ROOT - // 참여 행으로 평탄화됐고, 클론 행은 Phase 4 제거 전까지 리다이렉트 껍데기로만 남는다. 클라이언트가 공유·이전 + // 참여 행으로 평탄화됐고, 클론 행은 4-2단계 제거 전까지 리다이렉트 껍데기로만 남는다. 클라이언트가 공유·이전 // 세션 URL 로 클론 id 를 보내도 ROOT 로 이어지게 한다. sourceTournamentId 가 없으면(ROOT) 자기 자신이다. + // 해소할 때마다 CloneRedirectMetrics 를 올린다(#1188) — 이 값이 0 으로 머물러야 shim 을 지울 수 있다. private fun rootOf(tournament: Tournament): Tournament = tournament.sourceTournamentId - ?.let { tournamentRepository.findTournamentById(it) ?: throw TournamentException.notFoundTournament() } + ?.let { + recordCloneResolved() + tournamentRepository.findTournamentById(it) ?: throw TournamentException.notFoundTournament() + } ?: tournament // rootOf 의 for-update 판(상태 전이 경로용). ROOT 행에 락을 잡는다. private fun rootForUpdate(tournament: Tournament): Tournament = tournament.sourceTournamentId ?.let { + recordCloneResolved() tournamentRepository.findTournamentByIdForUpdate(it) ?: throw TournamentException.notFoundTournament() } ?: tournament + private fun recordCloneResolved() = CloneRedirectMetrics.record(meterRegistry, CloneRedirectMetrics.KIND_RESOLVED) + // 이 토너먼트에서 쓸 참여 닉네임만 바꾼다(#1018) — 유저 프로필(users.nickname)은 건드리지 않는다. // 요청자가 참여한 토너먼트여야 한다(그 tournamentId 의 TU 소유자). 아니면 접근 불가(403). - // (게스트/멤버 공통: 각자 자기 TU — 멤버는 루트 TU, 플레이링크 게스트는 자기 클론 TU — 를 tournamentId 로 가리켜 부른다.) + // 주최자·멤버·플레이 링크 참여자 모두 그 토너먼트의 참여 행을 가지므로 같은 tournamentId 로 부른다(#1027). @Transactional fun updateNickname( userId: UUID, @@ -138,7 +147,11 @@ class TournamentService( // 바꾸면 ROOT 참여자여도 못 찾는다. 존재하는 토너먼트의 sourceTournamentId 로만 해소해 미존재는 기존대로 // forbidden(403) 을 유지한다(신규 404 계약을 만들지 않음). val effectiveTournamentId = - tournamentRepository.findTournamentById(tournamentId)?.sourceTournamentId ?: tournamentId + tournamentRepository + .findTournamentById(tournamentId) + ?.sourceTournamentId + ?.also { recordCloneResolved() } + ?: tournamentId val tournamentUser = tournamentUserRepository.findByTournamentIdAndUserId(effectiveTournamentId, userId) ?: throw TournamentException.forbiddenTournament() @@ -197,7 +210,10 @@ class TournamentService( tournamentRepository.findTournamentByIdForUpdate(command.tournamentId) ?: throw TournamentException.notFoundTournament() if (!tournament.isPending()) throw TournamentException.notPendingTournament() - if (!tournament.isRoot()) throw TournamentException.clonedTournamentCannotAddItems() + if (!tournament.isRoot()) { + CloneRedirectMetrics.record(meterRegistry, CloneRedirectMetrics.KIND_ITEM_GUARD) + throw TournamentException.clonedTournamentCannotAddItems() + } tournamentUserRepository.findByTournamentIdAndUserId(command.tournamentId, userId) ?: throw TournamentException.forbiddenTournament() // 토너먼트에 이미 출전한 item 들 — tournament_item 의 고정 snapshot 에서 itemId 를 모은다(snapshot 단일 출처). @@ -283,7 +299,7 @@ class TournamentService( tournamentId: Long, ): StartResult { if (!tournament.isPending()) throw TournamentException.notPendingTournament() - val tournamentItems = getEffectiveTournamentItems(tournament) + val tournamentItems = tournamentItemsOf(tournament) if (tournamentItems.size !in TOURNAMENT_MIN_ITEM_COUNT..TOURNAMENT_MAX_ITEM_COUNT) { throw TournamentException.invalidItemCount() } @@ -346,7 +362,7 @@ class TournamentService( // 주최자가 구성을 잠그고 시작한(PENDING 아님) 뒤에만 멤버가 자기 플레이를 시작할 수 있다. if (root.isPending()) throw TournamentException.notInProgressTournament() - val effectiveItems = getEffectiveTournamentItems(root) + val effectiveItems = tournamentItemsOf(root) require(effectiveItems.isNotEmpty()) { "ROOT 토너먼트에 아이템 없음 — tournamentId=${root.getId()}" } // 아직 시작 전(PENDING)이면 플레이 시작으로 전이한다. 이미 시작·완료했으면(재호출) 그대로 두고 아이템만 @@ -423,7 +439,7 @@ class TournamentService( ownerStarted: Boolean, viewerId: UUID, ): TournamentDetail.Pending { - val tournamentItems = getEffectiveTournamentItems(tournament) + val tournamentItems = tournamentItemsOf(tournament) val snapshotById = displayedSnapshotsOf(tournamentItems) val tournamentUsers = tournamentUserRepository.findByTournamentId(tournament.getId()) val itemCountByUserId = tournamentItems.groupingBy { it.userId }.eachCount() @@ -502,7 +518,7 @@ class TournamentService( histories .maxByOrNull { it.getId() } ?.let { TournamentDetail.HistoryEntry.from(it) } - val allTournamentItems = getEffectiveTournamentItems(tournament) + val allTournamentItems = tournamentItemsOf(tournament) val currentRound = computeExpectedRound(allTournamentItems.size, histories) // 브래킷 파생이 라운드 시작 시점 집합(= remainingItems 의 상위집합)을 쓰므로 전체 아이템의 snapshot 을 잡는다. val snapshotById = snapshotsOf(allTournamentItems) @@ -593,8 +609,8 @@ class TournamentService( ): List { limit?.let { if (it < 1) throw TournamentException.invalidLimit() } - // 가시성 필터·playType·최근순·limit 을 쿼리가 끝낸다. 가시성은 per-user effective status 로 판정한다(#882): - // owner(내가 만든 ROOT·내 CLONE)는 전역 status 그대로, 참여자(클론 없는 ROOT)는 완료돼도 나에겐 IN_PROGRESS 로 캡한다. + // 가시성 필터·playType·최근순·limit 을 쿼리가 끝낸다. 가시성·상태는 전역 status 가 아니라 내 참여 행 status 로 + // 판정한다(#882·#1027) — 방장이 완료해도 아직 안 끝낸 참여자에게는 진행중으로 보인다. // ownedOnly=true(홈)는 참여 갈래를 꺼 "내가 owner 인 것" 만 노출한다. status 와는 AND 로 걸린다. // playType 은 파생 상태라 앱에서 거르면 limit 이 먼저 걸려 요구한 개수보다 적게 나온다 (쿼리에서 함께 판정). // 참가자·썸네일은 남은 토너먼트에 대해서만 읽는다 (홈 카드 limit=3 이 내 전체 이력을 선로드하지 않게). @@ -704,7 +720,7 @@ class TournamentService( throw TournamentException.invalidWinner() } - val allTournamentItems = getEffectiveTournamentItems(tournament) + val allTournamentItems = tournamentItemsOf(tournament) val tournamentItemIds = allTournamentItems.map { it.getId() }.toSet() if (command.firstTournamentItemId !in tournamentItemIds || command.secondTournamentItemId !in tournamentItemIds @@ -1205,7 +1221,10 @@ class TournamentService( tournamentRepository.findTournamentById(tournamentId) ?: throw TournamentException.notFoundTournament() // 클론은 원본 아이템을 이어받을 뿐 소유 행이 없다 — 삭제 시 원본을 건드리므로 막는다(#977, 추가 금지 032 와 같은 결). - tournament.sourceTournamentId?.let { throw TournamentException.clonedTournamentCannotModifyItems() } + tournament.sourceTournamentId?.let { + CloneRedirectMetrics.record(meterRegistry, CloneRedirectMetrics.KIND_ITEM_GUARD) + throw TournamentException.clonedTournamentCannotModifyItems() + } if (!tournament.isPending()) throw TournamentException.notPendingTournament() val tournamentItem = tournamentItemRepository.findById(tournamentItemId) @@ -1318,9 +1337,10 @@ class TournamentService( return TournamentDetail.MatchDetail(first = detailOf(pair.first), second = detailOf(pair.second)) } - // CLONE 토너먼트는 DB 에 아이템 행이 없고, ROOT 의 아이템을 sourceTournamentId 로 공유한다. - private fun getEffectiveTournamentItems(tournament: Tournament): List = - tournamentItemRepository.findAllByTournamentId(tournament.sourceTournamentId ?: tournament.getId()) + // 호출부는 전부 rootOf·rootForUpdate 로 ROOT 를 해소한 뒤 넘기므로, 아이템은 언제나 그 토너먼트 자신의 것이다. + // (CLONE 이 있던 시절엔 sourceTournamentId 로 ROOT 아이템을 빌려 왔다 — #1188 에서 그 분기를 걷어냈다.) + private fun tournamentItemsOf(tournament: Tournament): List = + tournamentItemRepository.findAllByTournamentId(tournament.getId()) /** * 출전 가능 판정의 단일 출처. 담기와 시작이 같은 질문("이 아이템들로 겨룰 수 있나")에 답하므로 한 벌만 둔다 — 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 cbcd12110..d3d55477f 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 @@ -43,8 +43,7 @@ sealed class TournamentDetail { val isGroupTournament: Boolean, val isOwner: Boolean, val isRoot: Boolean, - // true: ROOT 소유자 또는 소셜 초대로 참여한 CLONE 소유자 — 결과 화면에서 아이템 담기 허용(위시/링크/이미지). - // false: 플레이링크 CLONE 소유자 — 소셜 초대 없이 진입한 사용자로, 아이템 담기 불가. + // 결과 화면의 아이템 담기(위시/링크/이미지) 허용 여부 — 이 토너먼트의 참여자면 true(#1027). val canAddItem: Boolean, val playLinkExpiresAt: java.time.LocalDateTime?, val sourceTournamentId: Long? = null, diff --git a/src/test/kotlin/com/depromeet/piki/tournament/controller/TournamentIntegrationTest.kt b/src/test/kotlin/com/depromeet/piki/tournament/controller/TournamentIntegrationTest.kt index 705342e46..fb925ff36 100644 --- a/src/test/kotlin/com/depromeet/piki/tournament/controller/TournamentIntegrationTest.kt +++ b/src/test/kotlin/com/depromeet/piki/tournament/controller/TournamentIntegrationTest.kt @@ -31,6 +31,7 @@ import com.depromeet.piki.tournament.repository.TournamentHistoryJpaRepository import com.depromeet.piki.tournament.repository.TournamentItemJpaRepository import com.depromeet.piki.tournament.repository.TournamentJpaRepository import com.depromeet.piki.tournament.repository.TournamentUserJpaRepository +import com.depromeet.piki.tournament.service.CloneRedirectMetrics import com.depromeet.piki.tournament.service.PLAY_LINK_DURATION_DAYS import com.depromeet.piki.tournament.service.TournamentErrorCode import com.depromeet.piki.user.domain.IdentityType @@ -41,6 +42,7 @@ import com.depromeet.piki.wishlist.domain.Wish import com.depromeet.piki.wishlist.repository.WishJpaRepository import com.depromeet.piki.wishlist.repository.WishRepository import com.depromeet.piki.wishlist.service.WishPersistenceService +import io.micrometer.core.instrument.MeterRegistry import org.hamcrest.Matchers.nullValue import org.junit.jupiter.api.Test import org.springframework.beans.factory.annotation.Autowired @@ -123,6 +125,7 @@ class TournamentIntegrationTest : IntegrationTestSupport() { // 발행 검증용 — 같은 스레드(MockMvc 컨트롤러 실행)에서 publish 된 도메인 이벤트를 기록한다. // AFTER_COMMIT 리스너 발화(별도 스레드, 트랜잭션 롤백 무관) 여부와 독립적으로 "서비스가 이벤트를 쐈는가" 만 본다. @Autowired private lateinit var applicationEvents: ApplicationEvents + @Autowired private lateinit var meterRegistry: MeterRegistry private val userId: UUID = UUID.fromString("11111111-2222-3333-4444-555555555555") private val otherUserId: UUID = UUID.fromString("99999999-8888-7777-6666-555555555555") @@ -493,7 +496,7 @@ class TournamentIntegrationTest : IntegrationTestSupport() { } // 삭제(#1027 Phase 3): "플레이링크 복제 토너먼트에 아이템 추가 403(TOURNAMENT-032)" 은 클론 id 로만 닿던 가드다. - // 클론이 사라져 from-play-link 가 ROOT id 를 돌려주므로 이 사유에 도달할 수 없다. 가드 코드·에러코드 제거는 Phase 4. + // 클론이 사라져 from-play-link 가 ROOT id 를 돌려주므로 이 사유에 도달할 수 없다. 가드 코드·에러코드 제거는 4-2단계(#1188 후속). @Test fun `POST tournaments-id-start 는 아이템이 있는 PENDING 토너먼트를 시작하고 가격 오름차순 정렬된 아이템 목록을 반환한다`() { @@ -2066,7 +2069,7 @@ class TournamentIntegrationTest : IntegrationTestSupport() { // 삭제(#1027 Phase 3): "CLONE 아이템 PATCH·DELETE 403(TOURNAMENT-038)" 3종(PENDING·시작된 클론 포함)은 // 클론 id 로만 닿던 가드다. 클론이 사라져 from-play-link 가 ROOT id 를 돌려주므로 이 사유에 도달할 수 없다. - // 가드 코드(clonedTournamentCannotModifyItems)·에러코드 제거는 Phase 4. + // 가드 코드(clonedTournamentCannotModifyItems)·에러코드 제거는 4-2단계(#1188 후속). // 완료된 ROOT + 그 플레이링크로 참여한 게스트(otherUserId)를 만들어 (rootId, joinedId(=rootId), 원본 tournamentItemId) 를 준다. private fun cloneFromCompletedRoot(mockMvc: MockMvc): Triple { @@ -2736,7 +2739,7 @@ class TournamentIntegrationTest : IntegrationTestSupport() { } // 삭제(#1027 Phase 3): "링크 아이템 추가 시 복제 토너먼트면 403(TOURNAMENT-032)" 은 클론 id 로만 닿던 가드다. - // 클론이 사라져 from-play-link 가 ROOT id 를 돌려주므로 도달할 수 없다. 가드 코드·에러코드 제거는 Phase 4. + // 클론이 사라져 from-play-link 가 ROOT id 를 돌려주므로 도달할 수 없다. 가드 코드·에러코드 제거는 4-2단계(#1188 후속). @Test fun `게스트 합류 시 TournamentJoined 이벤트가 발행된다`() { @@ -2799,7 +2802,7 @@ class TournamentIntegrationTest : IntegrationTestSupport() { } // 삭제(#1027 Phase 3): "이미지 아이템 추가 presigned 발급 시 복제 토너먼트면 403(TOURNAMENT-032)" 은 클론 id 로만 - // 닿던 가드다. 클론이 사라져 from-play-link 가 ROOT id 를 돌려주므로 도달할 수 없다. 가드 코드·에러코드 제거는 Phase 4. + // 닿던 가드다. 클론이 사라져 from-play-link 가 ROOT id 를 돌려주므로 도달할 수 없다. 가드 코드·에러코드 제거는 4-2단계(#1188 후속). @Test fun `POST tournaments-id-items 에서 위시리스트에 없는 아이템이면 403 을 반환한다`() { @@ -3734,7 +3737,7 @@ class TournamentIntegrationTest : IntegrationTestSupport() { // 삭제(#1027 Phase 3): "완료된 CLONE 의 공유 링크 생성 403(TOURNAMENT-024)" 은 클론 id 로만 닿던 가드다. // 클론이 사라져 from-play-link 가 ROOT id 를 돌려주고 play-link 는 ROOT 로 해소되므로 도달할 수 없다. - // 가드 코드(clonedTournamentCannotSharePlayLink)·에러코드 제거는 Phase 4. + // 가드 코드·에러코드(TOURNAMENT-024)는 #1188 에서 제거하고 결번 처리했다. @Test fun `POST play-link 는 소유자가 아닌 참여자면 403 을 반환한다`() { @@ -4036,6 +4039,7 @@ class TournamentIntegrationTest : IntegrationTestSupport() { val mockMvc = buildMockMvc() val (rootId) = completeTournamentWith2Items(mockMvc) val cloneId = seedLingeringCloneShell(rootId) + val before = cloneRedirectCount(CloneRedirectMetrics.KIND_RESOLVED) // 요청자(owner)는 ROOT 참여 행이 COMPLETED. 클론 id 로 와도 404/403 이 아니라 ROOT 완료 화면을 준다. mockMvc @@ -4047,8 +4051,51 @@ class TournamentIntegrationTest : IntegrationTestSupport() { .andExpect(jsonPath("$.data.sourceTournamentId").doesNotExist()) .andExpect(jsonPath("$.data.status").value("COMPLETED")) .andExpect(jsonPath("$.data.completed.result.length()").value(2)) + + // 해소가 일어났으니 관측 카운터가 하나 오른다(#1188) — 4-2단계 shim 제거 판단 근거. + assertEquals(before + 1, cloneRedirectCount(CloneRedirectMetrics.KIND_RESOLVED)) + } + + @Test + fun `GET tournaments-id 는 ROOT id 로 오면 클론 해소 카운터를 올리지 않는다`() { + val mockMvc = buildMockMvc() + val (rootId) = completeTournamentWith2Items(mockMvc) + val before = cloneRedirectCount(CloneRedirectMetrics.KIND_RESOLVED) + + mockMvc + .perform( + get("/api/v1/tournaments/$rootId") + .header(HttpHeaders.AUTHORIZATION, authHeader(userId)), + ).andExpect(status().isOk) + .andExpect(jsonPath("$.data.tournamentId").value(rootId)) + + // 정상 요청까지 세면 카운터가 0 으로 내려오지 않아 4-2단계 판단에 쓸 수 없다. + assertEquals(before, cloneRedirectCount(CloneRedirectMetrics.KIND_RESOLVED)) } + @Test + fun `DELETE items 는 잔재 CLONE id 로 오면 403 TOURNAMENT-038 이고 클론 가드 카운터가 오른다`() { + val mockMvc = buildMockMvc() + val (rootId, rootTi) = completeTournamentWith2Items(mockMvc) + val cloneId = seedLingeringCloneShell(rootId) + val before = cloneRedirectCount(CloneRedirectMetrics.KIND_ITEM_GUARD) + + // 클론 가드는 4-1단계에서 응답을 그대로 지킨다 — 옛 클론 id 요청의 계약이 바뀌지 않는다. + mockMvc + .perform( + delete("/api/v1/tournaments/$cloneId/items/$rootTi") + .header(HttpHeaders.AUTHORIZATION, authHeader(userId)), + ).andExpect(status().isForbidden) + .andExpect(jsonPath("$.code").value("TOURNAMENT-038")) + + assertEquals(before + 1, cloneRedirectCount(CloneRedirectMetrics.KIND_ITEM_GUARD)) + // 원본 아이템은 그대로 남는다. + assertTrue(tournamentItemJpaRepository.findAllByTournamentIdAndNotDeleted(rootId).any { it.getId() == rootTi }) + } + + private fun cloneRedirectCount(kind: String): Double = + meterRegistry.counter(CloneRedirectMetrics.METRIC, CloneRedirectMetrics.TAG_KIND, kind).count() + @Test fun `POST matches 는 잔재 CLONE id 로 와도 ROOT 참여로 매치를 ROOT 에 기록한다`() { val mockMvc = buildMockMvc() @@ -4105,7 +4152,7 @@ class TournamentIntegrationTest : IntegrationTestSupport() { } // 잔재 CLONE 껍데기를 DB 에 직접 심는다(#1027 Phase 3) — source_tournament_id=ROOT, 자기 참여 행 없음. - // 백필로 평탄화된 뒤 Phase 4 제거 전까지 남는 리다이렉트 전용 행을 모사한다. + // 백필로 평탄화된 뒤 4-2단계 제거 전까지 남는 리다이렉트 전용 행을 모사한다. private fun seedLingeringCloneShell(rootId: Long): Long = tournamentJpaRepository .save(