Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package com.depromeet.piki.tournament.controller

import com.depromeet.piki.auth.web.ClientType
import com.depromeet.piki.common.response.ApiResponseBody
import com.depromeet.piki.tournament.controller.dto.CreateFromPlayCodeRequest
import com.depromeet.piki.tournament.controller.dto.CreateTournamentRequest
import com.depromeet.piki.tournament.controller.dto.CreateTournamentResponse
import com.depromeet.piki.tournament.controller.dto.GroupResultResponse
Expand Down Expand Up @@ -853,7 +854,7 @@ interface TournamentApi {
),
ApiResponse(
responseCode = "409",
description = "상태 충돌 (플레이 링크 만료)",
description = "상태 충돌 (플레이 링크 만료 `TOURNAMENT-027` · 참여 인원 초과 `TOURNAMENT-030`)",
content = [Content(mediaType = MediaType.APPLICATION_JSON_VALUE, schema = Schema(implementation = ApiResponseBody::class))],
),
],
Expand All @@ -863,6 +864,58 @@ interface TournamentApi {
@Parameter(description = "원본 토너먼트 ID", example = "1") sourceTournamentId: Long,
): ApiResponseBody<Long>

@Operation(
summary = "완료된 토너먼트에 코드로 입장",
description =
"완료된 토너먼트의 6자리 코드로 입장해 내 플레이를 만든다. `from-play-link` 와 같은 일을 하고 **주소만 다르다** " +
"- 링크를 열 수 없는 자리(오프라인에서 코드를 불러주는 등)를 위한 경로다.\n\n" +
"**코드는 초대 코드와 같은 값이다.** 토너먼트 생성 시점에 발급돼 바뀌지 않으며, 상태에 따라 다른 문을 연다.\n\n" +
"| 대상 상태 | 이 API | 게이트 |\n" +
"|---|---|---|\n" +
"| PENDING | **409** (`TOURNAMENT-023`) — `POST /{id}/join` 을 쓴다 | - |\n" +
"| COMPLETED | 플레이 생성 | 공유 링크가 유효해야 한다 (14일) |\n\n" +
"**공유를 켜지 않은 토너먼트는 코드로도 열리지 않는다** - 주최자가 공유 링크를 만들지 않았으면 404(`TOURNAMENT-026`). " +
"주최자가 나가 링크가 무효화된 경우도 같은 게이트에 걸린다. 만료 후 재발급하면 코드도 함께 살아난다.\n\n" +
"**멱등하다.** 이미 참여 중이면 기존 토너먼트 id 를 그대로 200 으로 돌려준다(링크 만료와 무관). " +
"그래서 201 이 아니라 200 이다.\n\n" +
"**정원은 8명이다**(주최자 포함, 참여 행 기준). 초대·링크·코드가 같은 제한을 공유한다.",
)
@ApiResponses(
value = [
ApiResponse(
responseCode = "200",
description = "입장 성공 또는 기존 참여 반환 (플레이할 토너먼트 id)",
content = [Content(mediaType = MediaType.APPLICATION_JSON_VALUE, schema = Schema(implementation = ApiResponseBody::class))],
),
ApiResponse(
responseCode = "400",
description = "코드 형식이 아님 (영문 대문자 3 + 숫자 3) · 해당 코드의 토너먼트 없음(`TOURNAMENT-020`)",
content = [Content(mediaType = MediaType.APPLICATION_JSON_VALUE, schema = Schema(implementation = ApiResponseBody::class))],
),
ApiResponse(
responseCode = "401",
description = "미인증 (JWT 토큰 없음 또는 유효하지 않음)",
content = [Content(mediaType = MediaType.APPLICATION_JSON_VALUE, schema = Schema(implementation = ApiResponseBody::class))],
),
ApiResponse(
responseCode = "404",
description = "공유 링크가 만들어지지 않은 토너먼트 (`TOURNAMENT-026`)",
content = [Content(mediaType = MediaType.APPLICATION_JSON_VALUE, schema = Schema(implementation = ApiResponseBody::class))],
),
ApiResponse(
responseCode = "409",
description =
"완료되지 않은 토너먼트(`TOURNAMENT-023`) · 공유 링크 만료(`TOURNAMENT-027`) · " +
"참여 인원 초과(`TOURNAMENT-030`)",
content = [Content(mediaType = MediaType.APPLICATION_JSON_VALUE, schema = Schema(implementation = ApiResponseBody::class))],
),
],
)
fun createFromPlayCode(
@Parameter(hidden = true) userId: UUID,
request: CreateFromPlayCodeRequest,
): ApiResponseBody<Long>

@Operation(
summary = "친구 초대 마감 시각 수정",
description = "PENDING 상태 토너먼트의 초대 마감 시각을 재설정한다. 주최자만 가능. 현재 시각 이후, 최대 24시간 이내의 절대 시각을 입력한다.",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -715,6 +715,33 @@ class TournamentApiExamples(
add(TournamentException.notFoundTournament(), name = "토너먼트를 찾을 수 없음")
add(TournamentException.playLinkNotCreated(), name = "플레이 링크가 생성되지 않은 토너먼트")
add(TournamentException.playLinkExpired(), name = "플레이 링크 만료")
// 정원을 링크 경로에도 걸었다(#1013) — 코드 경로만 막으면 제한이 이름뿐이 된다.
add(TournamentException.participantLimitExceeded(), name = "참여 인원 초과")
}

handlerMethod.binds(TournamentController::createFromPlayCode) ->
operation.examples(openApiObjectMapper.delegate) {
add(
status = HttpStatus.OK,
// 링크 경로와 같은 일을 하고 주소만 다르므로 예시 이름도 나란히 둔다.
name = "참여 시작 또는 기존 참여 이어하기 (항상 원본 토너먼트 id)",
payload = ApiResponseBody.ok(42L),
)
add(
status = HttpStatus.BAD_REQUEST,
name = "코드 형식 오류",
payload =
ApiResponseBody.fail<Unit>(
CommonErrorCode.INVALID_INPUT,
detail = JoinTournamentAsGuestRequest.INVITE_CODE_PATTERN_MESSAGE,
),
)
add(TournamentException.invalidInviteCode(), name = "해당 코드의 토너먼트 없음")
unauthorized()
add(TournamentException.notCompletedTournament(), name = "완료되지 않은 토너먼트 (대기실은 join 을 쓴다)")
add(TournamentException.playLinkNotCreated(), name = "공유를 켜지 않은 토너먼트")
add(TournamentException.playLinkExpired(), name = "공유 링크 만료")
add(TournamentException.participantLimitExceeded(), name = "참여 인원 초과")
}

handlerMethod.binds(TournamentController::getGroupResult) ->
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package com.depromeet.piki.tournament.controller

import com.depromeet.piki.common.response.ApiResponseBody
import com.depromeet.piki.tournament.controller.dto.CreateFromPlayCodeRequest
import com.depromeet.piki.tournament.controller.dto.CreateTournamentRequest
import com.depromeet.piki.tournament.controller.dto.CreateTournamentResponse
import com.depromeet.piki.tournament.controller.dto.GroupResultResponse
Expand Down Expand Up @@ -178,6 +179,17 @@ class TournamentController(
return ApiResponseBody.ok(tournamentId)
}

// 경로에 id 를 두지 않는다 - 코드로 들어오는 사람은 토너먼트 id 를 모른다. 그게 이 경로의 요점이다.
@PostMapping("/from-play-code")
override fun createFromPlayCode(
@AuthenticationPrincipal userId: UUID,
@Valid @RequestBody request: CreateFromPlayCodeRequest,
): ApiResponseBody<Long> {
// from-play-link 와 같은 idempotent get-or-create 라 201 이 아니라 200 이다.
val tournamentId = tournamentService.createFromPlayCode(userId, request.code)
return ApiResponseBody.ok(tournamentId)
}

@PatchMapping("/{tournamentId}/invite")
override fun updateInviteExpiry(
@AuthenticationPrincipal userId: UUID,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
package com.depromeet.piki.tournament.controller.dto

import io.swagger.v3.oas.annotations.media.Schema
import jakarta.validation.constraints.NotBlank
import jakarta.validation.constraints.Pattern

@Schema(description = "완료된 토너먼트에 코드로 입장 (#1013)")
data class CreateFromPlayCodeRequest(
// 초대 코드와 같은 값이다. 완료 상태에서는 그 코드가 플레이 입장 주소로도 쓰인다 - 코드는
// 생성 시점에 발급돼 바뀌지 않으므로 새로 발급할 것이 없다.
//
// 형식 검증을 여기 두면 오타가 서비스에 닿기 전에 걸리고, 로그에도 "형식은 맞는데 없는 코드"
// 와 "형식부터 틀린 값" 이 구분돼 남는다.
@field:NotBlank(message = JoinTournamentAsGuestRequest.INVITE_CODE_PATTERN_MESSAGE)
@field:Pattern(regexp = "[A-Z]{3}\\d{3}", message = JoinTournamentAsGuestRequest.INVITE_CODE_PATTERN_MESSAGE)
@field:Schema(description = "6자리 코드 (영문 대문자 3 + 숫자 3)", example = "ABC123")
val code: String,
)
Original file line number Diff line number Diff line change
Expand Up @@ -1001,7 +1001,17 @@ class TournamentService(
val tournament =
tournamentRepository.findTournamentByInviteCode(code)
?: throw TournamentException.invalidInviteCode()
tournament.checkJoinable(null)
// 코드 하나가 상태에 따라 다른 문을 연다(#1013). 대기실이면 합류, 주최자가 완주했으면 플레이
// 생성이다. 그래서 미리보기도 상태별로 게이트가 갈린다 - 예전엔 checkJoinable(null) 하나라
// 완주한 토너먼트가 409 로 막혀, 코드로 들어온 사람이 무엇을 보게 될지 미리 볼 수 없었다.
//
// 입장 게이트(createFromPlayCode)와 같은 조건을 써야 "보이는데 못 들어가는" 어긋남이 안 생긴다.
if (isOwnerCompleted(tournament)) {
tournament.playLinkExpiresAt ?: throw TournamentException.playLinkNotCreated()
if (!tournament.isPlayLinkValid()) throw TournamentException.playLinkExpired()
} else {
tournament.checkJoinable(null)
}
val itemCount = tournamentItemRepository.countByTournamentId(tournament.getId())
val participantCount = tournamentUserRepository.countByTournamentId(tournament.getId())
val joined =
Expand Down Expand Up @@ -1084,11 +1094,76 @@ class TournamentService(
root.playLinkExpiresAt ?: throw TournamentException.playLinkNotCreated()
if (!root.isPlayLinkValid()) throw TournamentException.playLinkExpired()

return joinAsPlayer(root.getId(), userId)
}

// 완료된 토너먼트에 **코드로** 입장한다(#1013).
//
// 링크 경로(createFromPlayLink)와 같은 일을 하고 주소만 다르다. 코드는 생성 시점에 발급돼
// 바뀌지 않으므로 새 컬럼도 새 발급도 필요 없고, uk_tournaments_active_invite_code 가 활성
// 토너먼트 전체에서 코드 유일성을 보장하므로 "코드 하나가 토너먼트 하나를 가리킨다" 가 이미
// 성립한다(삭제행은 generated 컬럼이 NULL 이라 코드가 재활용된다).
//
// create 와 달리 회원 게이트를 두지 않는다 - 여기서 만들어지는 것은 참여 행이고, 참여자는
// 아이템 추가가 막혀 있어 추출·LLM 비용을 만들 수 없다(링크 경로와 같은 판단).
@Transactional
fun createFromPlayCode(
userId: UUID,
code: String,
): Long {
val tournament =
tournamentRepository.findTournamentByInviteCode(code)
?: throw TournamentException.invalidInviteCode()
val root = tournamentRepository.findTournamentByIdForUpdate(tournament.getId())
?: throw TournamentException.notFoundTournament()

// 이미 참여 행이 있으면 그대로 이어서 진행한다. 링크 경로와 같은 재진입 동작이다.
tournamentUserRepository.findByTournamentIdAndUserId(root.getId(), userId)?.let { return root.getId() }

// 상태가 게이트를 정한다. 아직 공유할 단계가 아니면 초대 합류 경로(join)를 쓰라는 뜻으로
// 막는다 - 그쪽은 정원·중복 검사와 발행 이벤트가 다르다.
//
// **"완료" 는 전역 status 가 아니라 주최자 참여 행으로 판정한다(#1027 Phase 3).** 진행 상태가
// 참여 행으로 옮겨져 tournaments.status 는 더 이상 주최자의 완주를 뜻하지 않는다.
// createPlayLink 가 공유 가능 여부를 판정하는 기준과 같아야, "링크는 만들어졌는데 코드로는
// 아직 못 들어가는" 어긋남이 안 생긴다.
if (!isOwnerCompleted(root)) throw TournamentException.notCompletedTournament()
root.playLinkExpiresAt ?: throw TournamentException.playLinkNotCreated()
if (!root.isPlayLinkValid()) throw TournamentException.playLinkExpired()

return joinAsPlayer(root.getId(), userId)
}

// 주최자가 자기 판을 완주했는지 — 곧 "공유 가능한 상태인지" 다.
//
// **전역 status 로 판정하지 않는다(#1027 Phase 3).** 진행 상태가 참여 행으로 옮겨져
// tournaments.status 는 더 이상 주최자의 완주를 뜻하지 않는다. createPlayLink 가 쓰는 기준과
// 같은 것을 써야 "링크는 만들어졌는데 코드로는 못 들어가는" 어긋남이 안 생긴다.
private fun isOwnerCompleted(tournament: Tournament): Boolean =
tournamentUserRepository
.findByIds(setOf(tournament.ownerTournamentUserId))
.firstOrNull()
?.isCompleted() ?: false

// 링크·코드가 공유하는 참여 행 생성. 두 경로가 같은 일을 하므로 정원 검사와 알림도 같아야 한다.
//
// **정원을 링크 경로에도 건다(#1013).** 예전엔 초대 합류만 8명 제한이고 플레이 링크는 무제한이라,
// 코드 경로만 막으면 "코드로는 막히는데 링크를 달라고 하면 들어가지는" 상태가 되어 제한이 이름뿐이
// 된다. 같은 동작이므로 주소와 무관하게 같은 제한을 건다.
//
// 실측상 기존 사용은 깨지지 않는다 - 공유된 토너먼트 37개의 입장자가 최대 4명이었다.
private fun joinAsPlayer(
rootId: Long,
userId: UUID,
): Long {
if (tournamentUserRepository.countByTournamentId(rootId) >= TOURNAMENT_MAX_PARTICIPANT_COUNT) {
throw TournamentException.participantLimitExceeded()
}
// 참여 행은 PENDING 으로 붙인다 — 이후 start(startAsMember)가 플레이 시작으로 전이한다.
tournamentUserRepository.save(TournamentUser(root.getId(), userId, nicknameOf(userId)))
// 플레이링크로 새로 참여해 플레이를 시작한 사실을 ROOT 주최자에게 알린다(#473). 신규 생성 분기에서만 발행한다.
eventPublisher.publishEvent(TournamentPlayedFromLink(rootTournamentId = root.getId(), actorId = userId))
return root.getId()
tournamentUserRepository.save(TournamentUser(rootId, userId, nicknameOf(userId)))
// 새로 참여해 플레이를 시작한 사실을 ROOT 주최자에게 알린다(#473). 신규 생성 분기에서만 발행한다.
eventPublisher.publishEvent(TournamentPlayedFromLink(rootTournamentId = rootId, actorId = userId))
return rootId
}

@Transactional(readOnly = true)
Expand Down Expand Up @@ -1469,3 +1544,4 @@ internal fun Tournament.checkJoinable(inviteCode: String?) {
if (!isInviteValid()) throw TournamentException.inviteExpired()
inviteCode?.let { if (this.inviteCode != it) throw TournamentException.invalidInviteCode() }
}

Loading
Loading