From 38df7ee30d52ee72b8e4b1b90ef2de0833365fda Mon Sep 17 00:00:00 2001 From: ff1451 Date: Fri, 16 Oct 2026 16:50:24 +0900 Subject: [PATCH] =?UTF-8?q?fix:=20=EA=B2=8C=EC=9E=84=20=EB=B9=8C=EB=93=9C?= =?UTF-8?q?=20=EC=9B=B9=ED=9B=85=EC=9D=84=20=EC=97=85=EB=A1=9C=EB=93=9C=20?= =?UTF-8?q?=EC=A4=91=EC=9D=B8=20=EB=B9=8C=EB=93=9C=EC=97=90=EB=A7=8C=20?= =?UTF-8?q?=EB=B0=98=EC=98=81=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 완료 웹훅이 빌드의 현재 상태를 보지 않아, 다시 보내거나 늦게 도착한 웹훅이 보관된 옛 빌드를 다시 활성화하거나(현재 빌드는 보관됨), 활성 빌드를 FAILED로 내릴 수 있었다. - 상태 전이 규칙을 GameBuild에 둔다: ACTIVE·FAILED 반영은 PROCESSING에서만, 아니면 409. 같은 결과의 재전송(ACTIVE->ACTIVE, FAILED->FAILED)은 아무것도 바꾸지 않고 204. - 같은 빌드에 대한 동시 웹훅이 엇갈리지 않게 빌드 행을 잠그고 읽는다. --- .../game/model/GameBuild.java | 26 +++++++- .../game/repository/GameBuildRepository.java | 8 +++ .../game/service/GameBuildWebhookService.java | 7 ++- .../game/AdminGameBuildIntegrationTest.java | 61 +++++++++++++++++++ 4 files changed, 99 insertions(+), 3 deletions(-) diff --git a/src/main/java/com/bcsdlab/bcsdinternalapiv2/game/model/GameBuild.java b/src/main/java/com/bcsdlab/bcsdinternalapiv2/game/model/GameBuild.java index ef82003..37260d7 100644 --- a/src/main/java/com/bcsdlab/bcsdinternalapiv2/game/model/GameBuild.java +++ b/src/main/java/com/bcsdlab/bcsdinternalapiv2/game/model/GameBuild.java @@ -1,5 +1,7 @@ package com.bcsdlab.bcsdinternalapiv2.game.model; +import com.bcsdlab.bcsdinternalapiv2.game.exception.GameException; +import com.bcsdlab.bcsdinternalapiv2.game.exception.GameExceptionType; import com.bcsdlab.bcsdinternalapiv2.global.BaseTimeEntity; import com.bcsdlab.bcsdinternalapiv2.member.model.Member; import jakarta.persistence.Column; @@ -86,17 +88,39 @@ public void updateStatus(GameBuildStatus status) { this.status = status; } - public void applyActive(Integer canvasWidth, Integer canvasHeight, Long storageBytes, String buildFileUrl) { + /** + * 완료 웹훅(ACTIVE)을 반영한다. 업로드 중(PROCESSING)인 빌드만 활성화한다 — 늦게 도착하거나 다시 보낸 웹훅이 + * 보관된 옛 빌드를 되살리지 못하게 한다. 이미 ACTIVE면 재전송으로 보고 아무것도 바꾸지 않는다. + * + * @return 이번 호출로 활성화됐으면 true + */ + public boolean applyActive(Integer canvasWidth, Integer canvasHeight, Long storageBytes, String buildFileUrl) { + if (status == GameBuildStatus.ACTIVE) { + return false; + } + requireProcessing(); this.status = GameBuildStatus.ACTIVE; this.canvasWidth = canvasWidth; this.canvasHeight = canvasHeight; this.storageBytes = storageBytes; this.buildFileUrl = buildFileUrl; this.failureReason = null; + return true; } + /** 실패 웹훅(FAILED)을 반영한다. 업로드 중인 빌드만 실패 처리하고, 이미 FAILED면 아무것도 바꾸지 않는다. */ public void markFailed(String failureReason) { + if (status == GameBuildStatus.FAILED) { + return; + } + requireProcessing(); this.status = GameBuildStatus.FAILED; this.failureReason = failureReason; } + + private void requireProcessing() { + if (status != GameBuildStatus.PROCESSING) { + throw new GameException(GameExceptionType.GAME_BUILD_INVALID_STATE); + } + } } diff --git a/src/main/java/com/bcsdlab/bcsdinternalapiv2/game/repository/GameBuildRepository.java b/src/main/java/com/bcsdlab/bcsdinternalapiv2/game/repository/GameBuildRepository.java index a1de086..7255d94 100644 --- a/src/main/java/com/bcsdlab/bcsdinternalapiv2/game/repository/GameBuildRepository.java +++ b/src/main/java/com/bcsdlab/bcsdinternalapiv2/game/repository/GameBuildRepository.java @@ -2,9 +2,13 @@ import com.bcsdlab.bcsdinternalapiv2.game.model.GameBuild; import com.bcsdlab.bcsdinternalapiv2.game.model.GameBuildStatus; +import jakarta.persistence.LockModeType; import java.util.List; import java.util.Optional; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Lock; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; public interface GameBuildRepository extends JpaRepository { @@ -13,4 +17,8 @@ public interface GameBuildRepository extends JpaRepository { List findAllByGame_IdOrderByUploadedAtDesc(Long gameId); List findAllByGame_IdAndStatus(Long gameId, GameBuildStatus status); + + @Lock(LockModeType.PESSIMISTIC_WRITE) + @Query("select b from GameBuild b where b.id = :id") + Optional findByIdForUpdate(@Param("id") Long id); } diff --git a/src/main/java/com/bcsdlab/bcsdinternalapiv2/game/service/GameBuildWebhookService.java b/src/main/java/com/bcsdlab/bcsdinternalapiv2/game/service/GameBuildWebhookService.java index 7f57340..3d90332 100644 --- a/src/main/java/com/bcsdlab/bcsdinternalapiv2/game/service/GameBuildWebhookService.java +++ b/src/main/java/com/bcsdlab/bcsdinternalapiv2/game/service/GameBuildWebhookService.java @@ -28,7 +28,7 @@ public void applyResult(Long buildId, String secretHeader, GameBuildWebhookReque throw new GameException(GameExceptionType.GAME_BUILD_WEBHOOK_UNAUTHORIZED); } - GameBuild build = gameBuildRepository.findById(buildId) + GameBuild build = gameBuildRepository.findByIdForUpdate(buildId) .orElseThrow(() -> new GameException(GameExceptionType.GAME_BUILD_NOT_FOUND)); switch (request.status()) { @@ -39,8 +39,11 @@ public void applyResult(Long buildId, String secretHeader, GameBuildWebhookReque } private void applyActive(GameBuild build, GameBuildWebhookRequest request) { - build.applyActive(request.canvasWidth(), request.canvasHeight(), request.storageBytes(), + boolean activated = build.applyActive(request.canvasWidth(), request.canvasHeight(), request.storageBytes(), request.buildFileUrl()); + if (!activated) { + return; + } Long gameId = build.getGame().getId(); gameBuildRepository.findAllByGame_IdAndStatus(gameId, GameBuildStatus.ACTIVE).stream() diff --git a/src/test/java/com/bcsdlab/bcsdinternalapiv2/game/AdminGameBuildIntegrationTest.java b/src/test/java/com/bcsdlab/bcsdinternalapiv2/game/AdminGameBuildIntegrationTest.java index a2cc288..b46af2a 100644 --- a/src/test/java/com/bcsdlab/bcsdinternalapiv2/game/AdminGameBuildIntegrationTest.java +++ b/src/test/java/com/bcsdlab/bcsdinternalapiv2/game/AdminGameBuildIntegrationTest.java @@ -27,6 +27,7 @@ import org.springframework.http.MediaType; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.test.context.TestPropertySource; +import org.springframework.test.web.servlet.ResultActions; @TestPropertySource(properties = "app.game-build.secret=test-only-game-build-secret") class AdminGameBuildIntegrationTest extends IntegrationTestSupport { @@ -262,4 +263,64 @@ private String login(String studentNumber) throws Exception { .andReturn().getResponse().getContentAsString(); return objectMapper.readTree(body).get("accessToken").asText(); } + + @Test + @DisplayName("보관된 옛 빌드의 ACTIVE 웹훅이 다시 와도 409이고 현재 활성 빌드는 그대로다") + void 보관된_빌드의_ACTIVE_재전송은_409() throws Exception { + long firstBuildId = createBuild("1.0.0"); + issueUploadToken(firstBuildId); + activateBuild(firstBuildId, "https://bcsdlab.com/games/neon-drift/index.html"); + long secondBuildId = createBuild("1.1.0"); + issueUploadToken(secondBuildId); + activateBuild(secondBuildId, "https://bcsdlab.com/games/neon-drift/index.html"); + + postWebhook(firstBuildId, "{\"status\":\"ACTIVE\"}").andExpect(status().isConflict()); + + mockMvc.perform(get("/v1/games/neon-drift")) + .andExpect(jsonPath("$.activeBuild.version").value("1.1.0")); + } + + @Test + @DisplayName("활성 빌드에 늦게 도착한 FAILED 웹훅은 409이고 빌드는 활성 상태로 남는다") + void 활성_빌드의_늦은_FAILED는_409() throws Exception { + long buildId = createBuild("1.0.0"); + issueUploadToken(buildId); + activateBuild(buildId, "https://bcsdlab.com/games/neon-drift/index.html"); + + postWebhook(buildId, "{\"status\":\"FAILED\",\"failureReason\":\"late\"}") + .andExpect(status().isConflict()); + + mockMvc.perform(get("/v1/admin/games/" + game.getId() + "/builds") + .header("Authorization", "Bearer " + adminToken)) + .andExpect(jsonPath("$[0].status").value("ACTIVE")); + } + + @Test + @DisplayName("같은 ACTIVE 웹훅을 다시 보내면 204이고 아무것도 바뀌지 않는다") + void 같은_ACTIVE_재전송은_멱등하다() throws Exception { + long buildId = createBuild("1.0.0"); + issueUploadToken(buildId); + activateBuild(buildId, "https://bcsdlab.com/games/neon-drift/index.html"); + + activateBuild(buildId, "https://bcsdlab.com/games/neon-drift/other.html"); + + mockMvc.perform(get("/v1/games/neon-drift")) + .andExpect(jsonPath("$.activeBuild.buildFileUrl") + .value("https://bcsdlab.com/games/neon-drift/index.html")); + } + + @Test + @DisplayName("업로드 토큰을 받지 않은 PENDING 빌드에는 ACTIVE 웹훅이 409다") + void PENDING_빌드의_ACTIVE는_409() throws Exception { + long buildId = createBuild("1.0.0"); + + postWebhook(buildId, "{\"status\":\"ACTIVE\"}").andExpect(status().isConflict()); + } + + private ResultActions postWebhook(long buildId, String body) throws Exception { + return mockMvc.perform(post("/v1/games/builds/" + buildId + "/webhook") + .header("X-Game-Build-Secret", gameBuildProperties.secret()) + .contentType(MediaType.APPLICATION_JSON) + .content(body)); + } }