From 752100bd1a7383bfdb2dbfb83fa877aab69cabde Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EC=A4=80=EC=98=81?= Date: Sun, 30 Aug 2026 14:52:28 +0900 Subject: [PATCH] =?UTF-8?q?T-39=20=EA=B2=8C=EC=9E=84=20=EB=B9=8C=EB=93=9C?= =?UTF-8?q?=20=EB=A9=94=ED=83=80=20=EB=93=B1=EB=A1=9D=20(=ED=8C=8C?= =?UTF-8?q?=EC=9D=BC=20=EC=97=85=EB=A1=9C=EB=93=9C=20=EC=A0=9C=EC=99=B8)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit POST/GET/DELETE로 빌드 버전 메타만 등록·조회·삭제한다. 실제 ZIP 수신·압축 해제·서빙은 이번 범위 밖이다 — 다루면 ADR-023 범위를 벗어난다. 후속 태스크(T-41)가 홈페이지 서버 쪽 설계와 함께 구현한다. - POST /v1/admin/games/{id}/builds — 버전만 받아 status=PENDING으로 등록, uploadedBy는 JWT subject(관리자 본인) - GET /v1/admin/games/{id}/builds — 최신순 목록 - DELETE /v1/admin/games/{id}/builds/{buildId} — 메타 삭제 테스트: AdminGameBuildIntegrationTest(신규 4개) — 등록 시 PENDING·buildFileUrl 없음(ADR-023), 활성 빌드가 없으면 공개 응답의 activeBuild가 null이고 500이 아님(AC-9.10), 삭제 후 목록에서 사라짐, 존재하지 않는 게임에 등록하면 404. ./gradlew test 전체 통과(164개, 실패 0). 신규 마이그레이션 없음. Refs #111 --- .../game/controller/AdminGameBuildApi.java | 45 ++++++ .../controller/AdminGameBuildController.java | 50 ++++++ .../dto/request/GameBuildCreateRequest.java | 15 ++ .../dto/response/AdminGameBuildResponse.java | 28 ++++ .../game/exception/GameExceptionType.java | 1 + .../game/service/AdminGameBuildService.java | 70 ++++++++ .../game/AdminGameBuildIntegrationTest.java | 151 ++++++++++++++++++ 7 files changed, 360 insertions(+) create mode 100644 src/main/java/com/bcsdlab/bcsdinternalapiv2/game/controller/AdminGameBuildApi.java create mode 100644 src/main/java/com/bcsdlab/bcsdinternalapiv2/game/controller/AdminGameBuildController.java create mode 100644 src/main/java/com/bcsdlab/bcsdinternalapiv2/game/controller/dto/request/GameBuildCreateRequest.java create mode 100644 src/main/java/com/bcsdlab/bcsdinternalapiv2/game/controller/dto/response/AdminGameBuildResponse.java create mode 100644 src/main/java/com/bcsdlab/bcsdinternalapiv2/game/service/AdminGameBuildService.java create mode 100644 src/test/java/com/bcsdlab/bcsdinternalapiv2/game/AdminGameBuildIntegrationTest.java diff --git a/src/main/java/com/bcsdlab/bcsdinternalapiv2/game/controller/AdminGameBuildApi.java b/src/main/java/com/bcsdlab/bcsdinternalapiv2/game/controller/AdminGameBuildApi.java new file mode 100644 index 0000000..01f64f0 --- /dev/null +++ b/src/main/java/com/bcsdlab/bcsdinternalapiv2/game/controller/AdminGameBuildApi.java @@ -0,0 +1,45 @@ +package com.bcsdlab.bcsdinternalapiv2.game.controller; + +import com.bcsdlab.bcsdinternalapiv2.game.controller.dto.request.GameBuildCreateRequest; +import com.bcsdlab.bcsdinternalapiv2.game.controller.dto.response.AdminGameBuildResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.security.SecurityRequirement; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import java.util.List; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.security.oauth2.jwt.Jwt; + +@Tag(name = "관리자 - 게임 빌드 API") +@SecurityRequirement(name = "JWT") +public interface AdminGameBuildApi { + + @ApiResponses(value = { + @ApiResponse(responseCode = "200"), + @ApiResponse(responseCode = "404", content = @Content(schema = @Schema(hidden = true))), + }) + @Operation(summary = "빌드 메타 목록", description = "버전·상태·업로드 일시, 최신순.") + List getBuilds(Long gameId); + + @ApiResponses(value = { + @ApiResponse(responseCode = "201"), + @ApiResponse(responseCode = "404", content = @Content(schema = @Schema(hidden = true))), + }) + @Operation(summary = "빌드 메타 등록", + description = "1차: 버전만 등록하고 status=PENDING으로 시작한다. " + + "실제 ZIP 업로드·압축해제·서빙은 범위 밖이다(ADR-023, T-41).") + ResponseEntity createBuild(Long gameId, @Valid GameBuildCreateRequest request, + @AuthenticationPrincipal Jwt jwt); + + @ApiResponses(value = { + @ApiResponse(responseCode = "204"), + @ApiResponse(responseCode = "404", content = @Content(schema = @Schema(hidden = true))), + }) + @Operation(summary = "빌드 메타 삭제") + ResponseEntity deleteBuild(Long gameId, Long buildId); +} diff --git a/src/main/java/com/bcsdlab/bcsdinternalapiv2/game/controller/AdminGameBuildController.java b/src/main/java/com/bcsdlab/bcsdinternalapiv2/game/controller/AdminGameBuildController.java new file mode 100644 index 0000000..81f9ce0 --- /dev/null +++ b/src/main/java/com/bcsdlab/bcsdinternalapiv2/game/controller/AdminGameBuildController.java @@ -0,0 +1,50 @@ +package com.bcsdlab.bcsdinternalapiv2.game.controller; + +import com.bcsdlab.bcsdinternalapiv2.game.controller.dto.request.GameBuildCreateRequest; +import com.bcsdlab.bcsdinternalapiv2.game.controller.dto.response.AdminGameBuildResponse; +import com.bcsdlab.bcsdinternalapiv2.game.service.AdminGameBuildService; +import jakarta.validation.Valid; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.security.oauth2.jwt.Jwt; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/v1/admin/games/{gameId}/builds") +@RequiredArgsConstructor +public class AdminGameBuildController implements AdminGameBuildApi { + + private final AdminGameBuildService adminGameBuildService; + + @Override + @GetMapping + public List getBuilds(@PathVariable Long gameId) { + return adminGameBuildService.getBuilds(gameId); + } + + @Override + @PostMapping + public ResponseEntity createBuild(@PathVariable Long gameId, + @Valid @RequestBody GameBuildCreateRequest request, + @AuthenticationPrincipal Jwt jwt) { + Long uploadedByMemberId = Long.valueOf(jwt.getSubject()); + return ResponseEntity.status(HttpStatus.CREATED) + .body(adminGameBuildService.createBuild(gameId, request, uploadedByMemberId)); + } + + @Override + @DeleteMapping("/{buildId}") + public ResponseEntity deleteBuild(@PathVariable Long gameId, @PathVariable Long buildId) { + adminGameBuildService.deleteBuild(gameId, buildId); + return ResponseEntity.noContent().build(); + } +} diff --git a/src/main/java/com/bcsdlab/bcsdinternalapiv2/game/controller/dto/request/GameBuildCreateRequest.java b/src/main/java/com/bcsdlab/bcsdinternalapiv2/game/controller/dto/request/GameBuildCreateRequest.java new file mode 100644 index 0000000..42dff98 --- /dev/null +++ b/src/main/java/com/bcsdlab/bcsdinternalapiv2/game/controller/dto/request/GameBuildCreateRequest.java @@ -0,0 +1,15 @@ +package com.bcsdlab.bcsdinternalapiv2.game.controller.dto.request; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; + +/** + * 빌드 메타 등록(FR-7.6). 이번 1차는 버전만 받는다 — 실제 ZIP 업로드·압축해제·서빙은 + * 후속 태스크(T-41, ADR-023)에서 다룬다. + */ +public record GameBuildCreateRequest( + @NotBlank + @Size(max = 30) + String version +) { +} diff --git a/src/main/java/com/bcsdlab/bcsdinternalapiv2/game/controller/dto/response/AdminGameBuildResponse.java b/src/main/java/com/bcsdlab/bcsdinternalapiv2/game/controller/dto/response/AdminGameBuildResponse.java new file mode 100644 index 0000000..a8271e3 --- /dev/null +++ b/src/main/java/com/bcsdlab/bcsdinternalapiv2/game/controller/dto/response/AdminGameBuildResponse.java @@ -0,0 +1,28 @@ +package com.bcsdlab.bcsdinternalapiv2.game.controller.dto.response; + +import com.bcsdlab.bcsdinternalapiv2.game.model.GameBuild; +import java.time.Instant; + +public record AdminGameBuildResponse( + Long id, + String version, + String status, + Integer canvasWidth, + Integer canvasHeight, + Long storageBytes, + String buildFileUrl, + Instant uploadedAt +) { + public static AdminGameBuildResponse from(GameBuild build) { + return new AdminGameBuildResponse( + build.getId(), + build.getVersion(), + build.getStatus().name(), + build.getCanvasWidth(), + build.getCanvasHeight(), + build.getStorageBytes(), + build.getBuildFileUrl(), + build.getUploadedAt() + ); + } +} diff --git a/src/main/java/com/bcsdlab/bcsdinternalapiv2/game/exception/GameExceptionType.java b/src/main/java/com/bcsdlab/bcsdinternalapiv2/game/exception/GameExceptionType.java index 3ec9fb4..6088f8a 100644 --- a/src/main/java/com/bcsdlab/bcsdinternalapiv2/game/exception/GameExceptionType.java +++ b/src/main/java/com/bcsdlab/bcsdinternalapiv2/game/exception/GameExceptionType.java @@ -7,6 +7,7 @@ public enum GameExceptionType implements BcsdExceptionType { GAME_NOT_FOUND(HttpStatus.NOT_FOUND, "존재하지 않는 게임입니다."), GAME_SLUG_DUPLICATED(HttpStatus.CONFLICT, "이미 사용 중인 주소입니다."), + GAME_BUILD_NOT_FOUND(HttpStatus.NOT_FOUND, "존재하지 않는 빌드입니다."), INVALID_CONTENT_DESCRIPTOR(HttpStatus.BAD_REQUEST, "내용정보 항목이 올바르지 않습니다."), MEMBER_NOT_FOUND(HttpStatus.NOT_FOUND, "존재하지 않는 부원입니다."), MEMBER_ALREADY_ASSIGNED(HttpStatus.CONFLICT, "이미 이 게임에 배정된 부원입니다."), diff --git a/src/main/java/com/bcsdlab/bcsdinternalapiv2/game/service/AdminGameBuildService.java b/src/main/java/com/bcsdlab/bcsdinternalapiv2/game/service/AdminGameBuildService.java new file mode 100644 index 0000000..7814cbc --- /dev/null +++ b/src/main/java/com/bcsdlab/bcsdinternalapiv2/game/service/AdminGameBuildService.java @@ -0,0 +1,70 @@ +package com.bcsdlab.bcsdinternalapiv2.game.service; + +import com.bcsdlab.bcsdinternalapiv2.game.controller.dto.request.GameBuildCreateRequest; +import com.bcsdlab.bcsdinternalapiv2.game.controller.dto.response.AdminGameBuildResponse; +import com.bcsdlab.bcsdinternalapiv2.game.exception.GameException; +import com.bcsdlab.bcsdinternalapiv2.game.exception.GameExceptionType; +import com.bcsdlab.bcsdinternalapiv2.game.model.Game; +import com.bcsdlab.bcsdinternalapiv2.game.model.GameBuild; +import com.bcsdlab.bcsdinternalapiv2.game.model.GameBuildStatus; +import com.bcsdlab.bcsdinternalapiv2.game.repository.GameBuildRepository; +import com.bcsdlab.bcsdinternalapiv2.game.repository.GameRepository; +import com.bcsdlab.bcsdinternalapiv2.member.model.Member; +import com.bcsdlab.bcsdinternalapiv2.member.repository.MemberRepository; +import java.time.Instant; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +/** + * 게임 빌드 메타데이터 등록(T-39). 이번 1차는 버전·상태만 다룬다 — 실제 ZIP + * 업로드·압축해제·서빙은 후속 태스크(T-41, ADR-023)에서 홈페이지 서버 쪽 설계와 + * 함께 구현한다. 여기서 만드는 빌드는 항상 {@code status=PENDING}이고 + * {@code buildFileUrl}은 null이다. + */ +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class AdminGameBuildService { + + private final GameRepository gameRepository; + private final GameBuildRepository gameBuildRepository; + private final MemberRepository memberRepository; + + public List getBuilds(Long gameId) { + findGameOrThrow(gameId); + return gameBuildRepository.findAllByGame_IdOrderByUploadedAtDesc(gameId).stream() + .map(AdminGameBuildResponse::from) + .toList(); + } + + @Transactional + public AdminGameBuildResponse createBuild(Long gameId, GameBuildCreateRequest request, Long uploadedByMemberId) { + Game game = findGameOrThrow(gameId); + Member uploadedBy = memberRepository.findById(uploadedByMemberId).orElse(null); + + GameBuild build = gameBuildRepository.save(GameBuild.builder() + .game(game) + .version(request.version()) + .status(GameBuildStatus.PENDING) + .uploadedBy(uploadedBy) + .uploadedAt(Instant.now()) + .build()); + return AdminGameBuildResponse.from(build); + } + + @Transactional + public void deleteBuild(Long gameId, Long buildId) { + findGameOrThrow(gameId); + GameBuild build = gameBuildRepository.findById(buildId) + .filter(b -> b.getGame().getId().equals(gameId)) + .orElseThrow(() -> new GameException(GameExceptionType.GAME_BUILD_NOT_FOUND)); + gameBuildRepository.delete(build); + } + + private Game findGameOrThrow(Long id) { + return gameRepository.findById(id) + .orElseThrow(() -> new GameException(GameExceptionType.GAME_NOT_FOUND)); + } +} diff --git a/src/test/java/com/bcsdlab/bcsdinternalapiv2/game/AdminGameBuildIntegrationTest.java b/src/test/java/com/bcsdlab/bcsdinternalapiv2/game/AdminGameBuildIntegrationTest.java new file mode 100644 index 0000000..c17b62b --- /dev/null +++ b/src/test/java/com/bcsdlab/bcsdinternalapiv2/game/AdminGameBuildIntegrationTest.java @@ -0,0 +1,151 @@ +package com.bcsdlab.bcsdinternalapiv2.game; + +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import com.bcsdlab.bcsdinternalapiv2.IntegrationTestSupport; +import com.bcsdlab.bcsdinternalapiv2.auth.repository.RefreshTokenRepository; +import com.bcsdlab.bcsdinternalapiv2.game.model.Game; +import com.bcsdlab.bcsdinternalapiv2.game.repository.GameBuildRepository; +import com.bcsdlab.bcsdinternalapiv2.game.repository.GameRepository; +import com.bcsdlab.bcsdinternalapiv2.member.model.Member; +import com.bcsdlab.bcsdinternalapiv2.member.model.MemberRole; +import com.bcsdlab.bcsdinternalapiv2.member.model.MemberStatus; +import com.bcsdlab.bcsdinternalapiv2.member.model.MemberType; +import com.bcsdlab.bcsdinternalapiv2.member.repository.MemberRepository; +import com.bcsdlab.bcsdinternalapiv2.track.model.TrackMaster; +import com.bcsdlab.bcsdinternalapiv2.track.repository.TrackMasterRepository; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.MediaType; +import org.springframework.security.crypto.password.PasswordEncoder; + +class AdminGameBuildIntegrationTest extends IntegrationTestSupport { + + private static final String RAW_PASSWORD = "Temp1234"; + + @Autowired + private MemberRepository memberRepository; + + @Autowired + private RefreshTokenRepository refreshTokenRepository; + + @Autowired + private TrackMasterRepository trackMasterRepository; + + @Autowired + private GameRepository gameRepository; + + @Autowired + private GameBuildRepository gameBuildRepository; + + @Autowired + private PasswordEncoder passwordEncoder; + + private final ObjectMapper objectMapper = new ObjectMapper(); + + private String adminToken; + private Game game; + + @BeforeEach + void setUp() throws Exception { + gameRepository.deleteAll(); + refreshTokenRepository.deleteAll(); + memberRepository.deleteAll(); + TrackMaster gameTrack = trackMasterRepository.findByCode("GAME").orElseThrow(); + + Member admin = memberRepository.save(Member.builder() + .studentNumber("20261111") + .password(passwordEncoder.encode(RAW_PASSWORD)) + .name("테스트") + .track(gameTrack) + .generation("24-하") + .memberType(MemberType.REGULAR) + .university("OO대학교") + .email("admin@bcsd.club") + .status(MemberStatus.ACTIVE) + .build()); + adminToken = login("20261111"); + memberRepository.updateRole(admin.getId(), MemberRole.ADMIN); + + game = gameRepository.save(Game.builder() + .slug("neon-drift").name("Neon Drift").oneLiner("한 줄 소개") + .track(gameTrack).displayOrder(0).published(true).build()); + } + + @Test + @DisplayName("빌드 메타를 등록하면 status=PENDING이고 buildFileUrl이 없다(ADR-023)") + void 빌드_메타_등록은_PENDING_상태다() throws Exception { + mockMvc.perform(post("/v1/admin/games/" + game.getId() + "/builds") + .header("Authorization", "Bearer " + adminToken) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"version\":\"1.2.0\"}")) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.version").value("1.2.0")) + .andExpect(jsonPath("$.status").value("PENDING")) + .andExpect(jsonPath("$.buildFileUrl").doesNotExist()); + + mockMvc.perform(get("/v1/admin/games/" + game.getId() + "/builds") + .header("Authorization", "Bearer " + adminToken)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.length()").value(1)); + } + + @Test + @DisplayName("AC-9.10 활성 빌드가 없으면 게임을 공개해도 500이 아니고 activeBuild가 null이다") + void 빌드가_PENDING이면_공개_응답에_노출되지_않는다() throws Exception { + mockMvc.perform(post("/v1/admin/games/" + game.getId() + "/builds") + .header("Authorization", "Bearer " + adminToken) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"version\":\"1.2.0\"}")) + .andExpect(status().isCreated()); + + mockMvc.perform(get("/v1/games/neon-drift")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.activeBuild").doesNotExist()); + } + + @Test + @DisplayName("빌드 메타를 삭제하면 목록에서 사라진다") + void 빌드_메타_삭제() throws Exception { + String body = mockMvc.perform(post("/v1/admin/games/" + game.getId() + "/builds") + .header("Authorization", "Bearer " + adminToken) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"version\":\"1.0.0\"}")) + .andReturn().getResponse().getContentAsString(); + long buildId = objectMapper.readTree(body).get("id").asLong(); + + mockMvc.perform(delete("/v1/admin/games/" + game.getId() + "/builds/" + buildId) + .header("Authorization", "Bearer " + adminToken)) + .andExpect(status().isNoContent()); + + mockMvc.perform(get("/v1/admin/games/" + game.getId() + "/builds") + .header("Authorization", "Bearer " + adminToken)) + .andExpect(jsonPath("$.length()").value(0)); + } + + @Test + @DisplayName("존재하지 않는 게임에 빌드를 등록하면 404다") + void 존재하지_않는_게임에_빌드_등록은_404() throws Exception { + mockMvc.perform(post("/v1/admin/games/999999/builds") + .header("Authorization", "Bearer " + adminToken) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"version\":\"1.0.0\"}")) + .andExpect(status().isNotFound()); + } + + private String login(String studentNumber) throws Exception { + String body = mockMvc.perform(post("/v1/auth/login") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"studentNumber\":\"%s\",\"password\":\"%s\",\"rememberMe\":false}" + .formatted(studentNumber, RAW_PASSWORD))) + .andReturn().getResponse().getContentAsString(); + return objectMapper.readTree(body).get("accessToken").asText(); + } +}