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)); + } }