Skip to content
Open
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
@@ -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<AdminGameBuildResponse> 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<AdminGameBuildResponse> 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<Void> deleteBuild(Long gameId, Long buildId);
}
Original file line number Diff line number Diff line change
@@ -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<AdminGameBuildResponse> getBuilds(@PathVariable Long gameId) {
return adminGameBuildService.getBuilds(gameId);
}

@Override
@PostMapping
public ResponseEntity<AdminGameBuildResponse> 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<Void> deleteBuild(@PathVariable Long gameId, @PathVariable Long buildId) {
adminGameBuildService.deleteBuild(gameId, buildId);
return ResponseEntity.noContent().build();
}
}
Original file line number Diff line number Diff line change
@@ -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
) {
}
Original file line number Diff line number Diff line change
@@ -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()
);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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, "이미 이 게임에 배정된 부원입니다."),
Expand Down
Original file line number Diff line number Diff line change
@@ -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<AdminGameBuildResponse> 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));
}
}
Original file line number Diff line number Diff line change
@@ -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();
}
}