From a87e5b09ac52c0a7b2cf42adbe7d8dfb596cd954 Mon Sep 17 00:00:00 2001 From: JM Kim <106949557+CSE-Shaco@users.noreply.github.com> Date: Sun, 15 Mar 2026 13:46:54 +0900 Subject: [PATCH] feat: add bingo board management api --- build.gradle | 3 +- .../core/service/RecruitCoreAdminService.java | 5 +- .../auth/controller/AuthController.java | 9 + .../auth/controller/message/AuthMessage.java | 1 + .../domain/auth/service/AuthService.java | 25 +++ .../controller/AdminBingoBoardController.java | 48 +++++ .../game/controller/BingoBoardController.java | 37 ++++ .../controller/message/GameUserMessage.java | 3 + .../dto/request/BingoBoardUpdateRequest.java | 11 ++ .../game/dto/response/BingoBoardResponse.java | 24 +++ .../gdgoc/domain/game/entity/BingoBoard.java | 36 ++++ .../domain/game/entity/BingoInteraction.java | 35 ++++ .../game/repository/BingoBoardRepository.java | 11 ++ .../BingoInteractionRepository.java | 7 + .../game/service/BingoBoardService.java | 169 ++++++++++++++++++ .../gdgoc/domain/user/enums/TeamType.java | 15 +- .../global/config/jwt/TokenProvider.java | 4 +- .../V20260314__create_bingo_tables.sql | 22 +++ 18 files changed, 455 insertions(+), 10 deletions(-) create mode 100644 src/main/java/inha/gdgoc/domain/game/controller/AdminBingoBoardController.java create mode 100644 src/main/java/inha/gdgoc/domain/game/controller/BingoBoardController.java create mode 100644 src/main/java/inha/gdgoc/domain/game/dto/request/BingoBoardUpdateRequest.java create mode 100644 src/main/java/inha/gdgoc/domain/game/dto/response/BingoBoardResponse.java create mode 100644 src/main/java/inha/gdgoc/domain/game/entity/BingoBoard.java create mode 100644 src/main/java/inha/gdgoc/domain/game/entity/BingoInteraction.java create mode 100644 src/main/java/inha/gdgoc/domain/game/repository/BingoBoardRepository.java create mode 100644 src/main/java/inha/gdgoc/domain/game/repository/BingoInteractionRepository.java create mode 100644 src/main/java/inha/gdgoc/domain/game/service/BingoBoardService.java create mode 100644 src/main/resources/db/migration/V20260314__create_bingo_tables.sql diff --git a/build.gradle b/build.gradle index 4a2d9e8..09dfbdf 100644 --- a/build.gradle +++ b/build.gradle @@ -85,6 +85,7 @@ dependencies { // --- AWS (S3) --- implementation 'io.awspring.cloud:spring-cloud-aws-starter-s3' + implementation 'software.amazon.awssdk:url-connection-client' // --- Flyway --- implementation "org.flywaydb:flyway-core:${flywayVersion}" @@ -101,7 +102,7 @@ dependencies { testImplementation 'org.springframework.boot:spring-boot-starter-test' testImplementation 'org.mockito:mockito-core:5.6.0' testImplementation 'org.mockito:mockito-junit-jupiter:5.6.0' - testImplementation 'org.assertj:assertj-core:3.24.2' + testImplementation 'org.assertj:assertj-core:3.27.7' testRuntimeOnly 'org.junit.platform:junit-platform-launcher' //Google API Client diff --git a/src/main/java/inha/gdgoc/domain/admin/recruit/core/service/RecruitCoreAdminService.java b/src/main/java/inha/gdgoc/domain/admin/recruit/core/service/RecruitCoreAdminService.java index ec13eac..19d957e 100644 --- a/src/main/java/inha/gdgoc/domain/admin/recruit/core/service/RecruitCoreAdminService.java +++ b/src/main/java/inha/gdgoc/domain/admin/recruit/core/service/RecruitCoreAdminService.java @@ -108,11 +108,8 @@ private void ensureDecidable(RecruitCoreApplication application) { } private TeamType teamTypeOf(String team) { - if (team == null) { - return null; - } try { - return TeamType.valueOf(team); + return TeamType.from(team); } catch (IllegalArgumentException ex) { return null; } diff --git a/src/main/java/inha/gdgoc/domain/auth/controller/AuthController.java b/src/main/java/inha/gdgoc/domain/auth/controller/AuthController.java index 0cdc1ae..8078e68 100644 --- a/src/main/java/inha/gdgoc/domain/auth/controller/AuthController.java +++ b/src/main/java/inha/gdgoc/domain/auth/controller/AuthController.java @@ -23,6 +23,7 @@ import lombok.extern.slf4j.Slf4j; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.util.StringUtils; import org.springframework.web.bind.annotation.*; @@ -114,6 +115,14 @@ public ResponseEntity logout(@RequestBody(required = false) TokenRefreshReque return ResponseEntity.ok().body(ApiResponse.ok(LOGOUT_SUCCESS)); } + @GetMapping("/me") + @PreAuthorize("isAuthenticated()") + public ResponseEntity> me( + @AuthenticationPrincipal TokenProvider.CustomUserDetails me + ) { + return ResponseEntity.ok(ApiResponse.ok(ME_RETRIEVED_SUCCESS, authService.getCurrentUser(me))); + } + // 5. 권한 체크 (Role or Team) @GetMapping("/{role}") public ResponseEntity> checkRoleOrTeam( diff --git a/src/main/java/inha/gdgoc/domain/auth/controller/message/AuthMessage.java b/src/main/java/inha/gdgoc/domain/auth/controller/message/AuthMessage.java index 6a6ef8f..5196885 100644 --- a/src/main/java/inha/gdgoc/domain/auth/controller/message/AuthMessage.java +++ b/src/main/java/inha/gdgoc/domain/auth/controller/message/AuthMessage.java @@ -9,6 +9,7 @@ public class AuthMessage { public static final String SIGNUP_SUCCESS = "회원가입에 성공하였습니다."; public static final String ACCESS_TOKEN_REFRESH_SUCCESS = "토큰 재발급에 성공하였습니다."; public static final String LOGOUT_SUCCESS = "로그아웃에 성공하였습니다."; + public static final String ME_RETRIEVED_SUCCESS = "현재 사용자 정보를 성공적으로 조회했습니다."; public static final String OAUTH_LOGIN_SIGNUP_SUCCESS = "OAuth 로그인/회원가입에 성공하였습니다."; public static final String STUDENT_ID_DUPLICATION_CHECK_SUCCESS = "학번 중복 확인에 성공하였습니다."; public static final String PHONE_NUMBER_DUPLICATION_CHECK_SUCCESS = "전화번호 중복 확인에 성공하였습니다."; diff --git a/src/main/java/inha/gdgoc/domain/auth/service/AuthService.java b/src/main/java/inha/gdgoc/domain/auth/service/AuthService.java index 312ddfa..a30332d 100644 --- a/src/main/java/inha/gdgoc/domain/auth/service/AuthService.java +++ b/src/main/java/inha/gdgoc/domain/auth/service/AuthService.java @@ -150,6 +150,31 @@ public boolean hasRequiredAccess(CustomUserDetails me, UserRole role, TeamType r return accessGuard.check(me, conditions.toArray(AccessGuard.AccessCondition[]::new)); } + @Transactional(readOnly = true) + public AuthUserResponse getCurrentUser(CustomUserDetails me) { + if (me == null) { + throw new IllegalArgumentException("인증 정보가 없습니다."); + } + + if (me.getRole() == UserRole.ADMIN && me.getUserId() == null) { + String username = me.getUsername(); + String loginId = username != null && username.startsWith("admin:") + ? username.substring("admin:".length()) + : username; + return AuthUserResponse.admin(loginId); + } + + if (me.getUserId() == null) { + throw new IllegalArgumentException("존재하지 않는 사용자입니다."); + } + + User user = userRepository + .findById(me.getUserId()) + .orElseThrow(() -> new IllegalArgumentException("존재하지 않는 사용자입니다.")); + + return AuthUserResponse.from(user); + } + public RefreshResult refresh(String refreshToken) { RefreshSession session = resolveRefreshSession(refreshToken); if (session.principalType() == PrincipalType.ADMIN) { diff --git a/src/main/java/inha/gdgoc/domain/game/controller/AdminBingoBoardController.java b/src/main/java/inha/gdgoc/domain/game/controller/AdminBingoBoardController.java new file mode 100644 index 0000000..4ad8aae --- /dev/null +++ b/src/main/java/inha/gdgoc/domain/game/controller/AdminBingoBoardController.java @@ -0,0 +1,48 @@ +package inha.gdgoc.domain.game.controller; + +import static inha.gdgoc.domain.game.controller.message.GameUserMessage.BINGO_BOARD_UPDATED_SUCCESS; + +import inha.gdgoc.domain.game.dto.request.BingoBoardUpdateRequest; +import inha.gdgoc.domain.game.dto.response.BingoBoardResponse; +import inha.gdgoc.domain.game.service.BingoBoardService; +import inha.gdgoc.domain.user.enums.UserRole; +import inha.gdgoc.global.config.jwt.TokenProvider; +import inha.gdgoc.global.dto.response.ApiResponse; +import inha.gdgoc.global.security.AccessGuard; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RequestMapping("/api/v1/admin/game/bingo") +@RequiredArgsConstructor +@RestController +@Slf4j +public class AdminBingoBoardController { + + private final BingoBoardService bingoBoardService; + private final AccessGuard accessGuard; + + @PutMapping("/boards/{teamNumber}") + public ResponseEntity> updateBoard( + @AuthenticationPrincipal TokenProvider.CustomUserDetails me, + @PathVariable Integer teamNumber, + @RequestBody BingoBoardUpdateRequest request + ) { + log.info( + "빙고 수정 권한 검사: username={}, role={}, team={}", + me != null ? me.getUsername() : null, + me != null ? me.getRole() : null, + me != null ? me.getTeam() : null + ); + accessGuard.require(me, AccessGuard.AccessCondition.atLeast(UserRole.CORE)); + + BingoBoardResponse response = bingoBoardService.updateBoard(teamNumber, request); + return ResponseEntity.ok(ApiResponse.ok(BINGO_BOARD_UPDATED_SUCCESS, response)); + } +} diff --git a/src/main/java/inha/gdgoc/domain/game/controller/BingoBoardController.java b/src/main/java/inha/gdgoc/domain/game/controller/BingoBoardController.java new file mode 100644 index 0000000..6a6767c --- /dev/null +++ b/src/main/java/inha/gdgoc/domain/game/controller/BingoBoardController.java @@ -0,0 +1,37 @@ +package inha.gdgoc.domain.game.controller; + +import static inha.gdgoc.domain.game.controller.message.GameUserMessage.BINGO_BOARD_RETRIEVED_SUCCESS; +import static inha.gdgoc.domain.game.controller.message.GameUserMessage.BINGO_BOARDS_RETRIEVED_SUCCESS; + +import inha.gdgoc.domain.game.dto.response.BingoBoardResponse; +import inha.gdgoc.domain.game.service.BingoBoardService; +import inha.gdgoc.global.dto.response.ApiResponse; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RequestMapping("/api/v1/game/bingo") +@RequiredArgsConstructor +@RestController +public class BingoBoardController { + + private final BingoBoardService bingoBoardService; + + @GetMapping("/boards") + public ResponseEntity, Void>> getBoards() { + List response = bingoBoardService.findAllBoards(); + return ResponseEntity.ok(ApiResponse.ok(BINGO_BOARDS_RETRIEVED_SUCCESS, response)); + } + + @GetMapping("/boards/{teamNumber}") + public ResponseEntity> getBoard( + @PathVariable Integer teamNumber + ) { + BingoBoardResponse response = bingoBoardService.findBoard(teamNumber); + return ResponseEntity.ok(ApiResponse.ok(BINGO_BOARD_RETRIEVED_SUCCESS, response)); + } +} diff --git a/src/main/java/inha/gdgoc/domain/game/controller/message/GameUserMessage.java b/src/main/java/inha/gdgoc/domain/game/controller/message/GameUserMessage.java index 9938b57..377ca85 100644 --- a/src/main/java/inha/gdgoc/domain/game/controller/message/GameUserMessage.java +++ b/src/main/java/inha/gdgoc/domain/game/controller/message/GameUserMessage.java @@ -3,6 +3,9 @@ public class GameUserMessage { public static final String GAME_RANK_SAVE_SUCCESS = "성공적으로 유저 랭킹 정보를 저장했습니다."; public static final String GAME_RANK_RETRIEVED_SUCCESS = "성공적으로 랭킹 정보를 반환했습니다."; + public static final String BINGO_BOARDS_RETRIEVED_SUCCESS = "성공적으로 빙고 보드 목록을 반환했습니다."; + public static final String BINGO_BOARD_RETRIEVED_SUCCESS = "성공적으로 빙고 보드를 반환했습니다."; + public static final String BINGO_BOARD_UPDATED_SUCCESS = "성공적으로 빙고 보드를 저장했습니다."; public static final String MBTI_RESULT_UPSERT_SUCCESS = "성공적으로 MBTI 결과를 저장했습니다."; public static final String MBTI_RESULT_STATS_RETRIEVED_SUCCESS = "성공적으로 MBTI 통계를 반환했습니다."; } diff --git a/src/main/java/inha/gdgoc/domain/game/dto/request/BingoBoardUpdateRequest.java b/src/main/java/inha/gdgoc/domain/game/dto/request/BingoBoardUpdateRequest.java new file mode 100644 index 0000000..0dac8cb --- /dev/null +++ b/src/main/java/inha/gdgoc/domain/game/dto/request/BingoBoardUpdateRequest.java @@ -0,0 +1,11 @@ +package inha.gdgoc.domain.game.dto.request; + +import java.util.List; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor +public class BingoBoardUpdateRequest { + private List marks; +} diff --git a/src/main/java/inha/gdgoc/domain/game/dto/response/BingoBoardResponse.java b/src/main/java/inha/gdgoc/domain/game/dto/response/BingoBoardResponse.java new file mode 100644 index 0000000..dac19dd --- /dev/null +++ b/src/main/java/inha/gdgoc/domain/game/dto/response/BingoBoardResponse.java @@ -0,0 +1,24 @@ +package inha.gdgoc.domain.game.dto.response; + +import java.util.List; +import lombok.Getter; + +@Getter +public class BingoBoardResponse { + private final Integer teamNumber; + private final List marks; + private final int checkedCount; + private final int rank; + + public BingoBoardResponse( + Integer teamNumber, + List marks, + int checkedCount, + int rank + ) { + this.teamNumber = teamNumber; + this.marks = marks; + this.checkedCount = checkedCount; + this.rank = rank; + } +} diff --git a/src/main/java/inha/gdgoc/domain/game/entity/BingoBoard.java b/src/main/java/inha/gdgoc/domain/game/entity/BingoBoard.java new file mode 100644 index 0000000..4223b34 --- /dev/null +++ b/src/main/java/inha/gdgoc/domain/game/entity/BingoBoard.java @@ -0,0 +1,36 @@ +package inha.gdgoc.domain.game.entity; + +import inha.gdgoc.global.entity.BaseEntity; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor +@Getter +@Builder +public class BingoBoard extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "id") + private Long id; + + @Column(name = "team_number", nullable = false, unique = true) + private Integer teamNumber; + + @Column(name = "marks", nullable = false, length = 160) + private String marks; + + public void updateMarks(String marks) { + this.marks = marks; + } +} diff --git a/src/main/java/inha/gdgoc/domain/game/entity/BingoInteraction.java b/src/main/java/inha/gdgoc/domain/game/entity/BingoInteraction.java new file mode 100644 index 0000000..871694e --- /dev/null +++ b/src/main/java/inha/gdgoc/domain/game/entity/BingoInteraction.java @@ -0,0 +1,35 @@ +package inha.gdgoc.domain.game.entity; + +import inha.gdgoc.global.entity.BaseEntity; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor +@Getter +@Builder +public class BingoInteraction extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "id") + private Long id; + + @Column(name = "team_number", nullable = false) + private Integer teamNumber; + + @Column(name = "cell_index", nullable = false) + private Integer cellIndex; + + @Column(name = "mark", nullable = false, length = 16) + private String mark; +} diff --git a/src/main/java/inha/gdgoc/domain/game/repository/BingoBoardRepository.java b/src/main/java/inha/gdgoc/domain/game/repository/BingoBoardRepository.java new file mode 100644 index 0000000..ec9698b --- /dev/null +++ b/src/main/java/inha/gdgoc/domain/game/repository/BingoBoardRepository.java @@ -0,0 +1,11 @@ +package inha.gdgoc.domain.game.repository; + +import inha.gdgoc.domain.game.entity.BingoBoard; +import java.util.List; +import java.util.Optional; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface BingoBoardRepository extends JpaRepository { + Optional findByTeamNumber(Integer teamNumber); + List findAllByOrderByTeamNumberAsc(); +} diff --git a/src/main/java/inha/gdgoc/domain/game/repository/BingoInteractionRepository.java b/src/main/java/inha/gdgoc/domain/game/repository/BingoInteractionRepository.java new file mode 100644 index 0000000..2398dc5 --- /dev/null +++ b/src/main/java/inha/gdgoc/domain/game/repository/BingoInteractionRepository.java @@ -0,0 +1,7 @@ +package inha.gdgoc.domain.game.repository; + +import inha.gdgoc.domain.game.entity.BingoInteraction; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface BingoInteractionRepository extends JpaRepository { +} diff --git a/src/main/java/inha/gdgoc/domain/game/service/BingoBoardService.java b/src/main/java/inha/gdgoc/domain/game/service/BingoBoardService.java new file mode 100644 index 0000000..e6c8f61 --- /dev/null +++ b/src/main/java/inha/gdgoc/domain/game/service/BingoBoardService.java @@ -0,0 +1,169 @@ +package inha.gdgoc.domain.game.service; + +import inha.gdgoc.domain.game.dto.request.BingoBoardUpdateRequest; +import inha.gdgoc.domain.game.dto.response.BingoBoardResponse; +import inha.gdgoc.domain.game.entity.BingoBoard; +import inha.gdgoc.domain.game.entity.BingoInteraction; +import inha.gdgoc.domain.game.repository.BingoBoardRepository; +import inha.gdgoc.domain.game.repository.BingoInteractionRepository; +import jakarta.transaction.Transactional; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; +import java.util.stream.IntStream; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Service; +import org.springframework.web.server.ResponseStatusException; + +@RequiredArgsConstructor +@Service +public class BingoBoardService { + + private static final int TEAM_COUNT = 10; + private static final int CELL_COUNT = 16; + private static final String EMPTY_MARK = "empty"; + private static final String CHECKED_MARK = "x"; + + private final BingoBoardRepository bingoBoardRepository; + private final BingoInteractionRepository bingoInteractionRepository; + + public List findAllBoards() { + return buildRankedResponses(loadBoardStates()); + } + + public BingoBoardResponse findBoard(Integer teamNumber) { + validateTeamNumber(teamNumber); + + List boardStates = loadBoardStates(); + return buildRankedResponses(boardStates).stream() + .filter(response -> response.getTeamNumber().equals(teamNumber)) + .findFirst() + .orElseGet(() -> new BingoBoardResponse(teamNumber, createEmptyMarks(), 0, teamNumber)); + } + + @Transactional + public BingoBoardResponse updateBoard(Integer teamNumber, BingoBoardUpdateRequest request) { + validateTeamNumber(teamNumber); + + List nextMarks = normalizeMarks(request.getMarks()); + BingoBoard bingoBoard = bingoBoardRepository.findByTeamNumber(teamNumber) + .orElseGet(() -> BingoBoard.builder() + .teamNumber(teamNumber) + .marks(serializeMarks(createEmptyMarks())) + .build()); + + List previousMarks = deserializeMarks(bingoBoard.getMarks()); + saveInteractions(teamNumber, previousMarks, nextMarks); + + bingoBoard.updateMarks(serializeMarks(nextMarks)); + bingoBoardRepository.save(bingoBoard); + + return findBoard(teamNumber); + } + + private List loadBoardStates() { + List savedBoards = bingoBoardRepository.findAllByOrderByTeamNumberAsc(); + + return IntStream.rangeClosed(1, TEAM_COUNT) + .mapToObj(teamNumber -> savedBoards.stream() + .filter(board -> board.getTeamNumber().equals(teamNumber)) + .findFirst() + .map(board -> new BoardState(teamNumber, deserializeMarks(board.getMarks()))) + .orElseGet(() -> new BoardState(teamNumber, createEmptyMarks()))) + .toList(); + } + + private List buildRankedResponses(List boardStates) { + List sortedStates = new ArrayList<>(boardStates); + sortedStates.sort(Comparator.comparingInt(BoardState::checkedCount).reversed() + .thenComparingInt(BoardState::teamNumber)); + + List responses = new ArrayList<>(); + Integer previousCheckedCount = null; + int previousRank = 0; + + for (int index = 0; index < sortedStates.size(); index++) { + BoardState state = sortedStates.get(index); + int rank = previousCheckedCount != null && previousCheckedCount == state.checkedCount() + ? previousRank + : index + 1; + + responses.add(new BingoBoardResponse( + state.teamNumber(), + state.marks(), + state.checkedCount(), + rank + )); + + previousCheckedCount = state.checkedCount(); + previousRank = rank; + } + + return responses; + } + + private void saveInteractions(Integer teamNumber, List previousMarks, List nextMarks) { + List interactions = IntStream.range(0, CELL_COUNT) + .filter(index -> !previousMarks.get(index).equals(nextMarks.get(index))) + .mapToObj(index -> BingoInteraction.builder() + .teamNumber(teamNumber) + .cellIndex(index + 1) + .mark(nextMarks.get(index)) + .build()) + .toList(); + + if (!interactions.isEmpty()) { + bingoInteractionRepository.saveAll(interactions); + } + } + + private List normalizeMarks(List marks) { + if (marks == null || marks.size() != CELL_COUNT) { + return createEmptyMarks(); + } + + return marks.stream() + .map(mark -> CHECKED_MARK.equals(mark) ? CHECKED_MARK : EMPTY_MARK) + .toList(); + } + + private List deserializeMarks(String marks) { + if (marks == null || marks.isBlank()) { + return createEmptyMarks(); + } + + String[] split = marks.split(",", -1); + if (split.length != CELL_COUNT) { + return createEmptyMarks(); + } + + List normalizedMarks = new ArrayList<>(); + for (String mark : split) { + normalizedMarks.add(CHECKED_MARK.equals(mark) ? CHECKED_MARK : EMPTY_MARK); + } + return normalizedMarks; + } + + private String serializeMarks(List marks) { + return String.join(",", normalizeMarks(marks)); + } + + private List createEmptyMarks() { + return IntStream.range(0, CELL_COUNT) + .mapToObj(index -> EMPTY_MARK) + .toList(); + } + + private void validateTeamNumber(Integer teamNumber) { + if (teamNumber == null || teamNumber < 1 || teamNumber > TEAM_COUNT) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "유효하지 않은 팀 번호입니다."); + } + } + + private record BoardState(Integer teamNumber, List marks) { + private int checkedCount() { + return (int) marks.stream().filter(CHECKED_MARK::equals).count(); + } + } +} diff --git a/src/main/java/inha/gdgoc/domain/user/enums/TeamType.java b/src/main/java/inha/gdgoc/domain/user/enums/TeamType.java index 1840c61..6474d6a 100644 --- a/src/main/java/inha/gdgoc/domain/user/enums/TeamType.java +++ b/src/main/java/inha/gdgoc/domain/user/enums/TeamType.java @@ -18,13 +18,24 @@ public enum TeamType { @JsonCreator public static TeamType from(String raw) { if (raw == null) return null; - return switch (raw) { + + String normalized = raw.trim() + .replace('-', '_') + .replace(' ', '_') + .toUpperCase(); + + if (normalized.isBlank()) { + return null; + } + + return switch (normalized) { case "HQ" -> HQ; case "HR" -> HR; case "TECH" -> TECH; case "BD" -> BD; case "PR_DESIGN" -> PR_DESIGN; - default -> throw new IllegalArgumentException("Unknown team: " + raw); + case "PR/DESIGN" -> PR_DESIGN; + default -> throw new IllegalArgumentException("Unknown team: " + raw); }; } } diff --git a/src/main/java/inha/gdgoc/global/config/jwt/TokenProvider.java b/src/main/java/inha/gdgoc/global/config/jwt/TokenProvider.java index 76a6b89..18f8f92 100644 --- a/src/main/java/inha/gdgoc/global/config/jwt/TokenProvider.java +++ b/src/main/java/inha/gdgoc/global/config/jwt/TokenProvider.java @@ -129,9 +129,7 @@ public Authentication getAuthentication(String token) { String roleRaw = claims.get(CLAIM_ROLE, String.class); userRole = roleRaw != null ? UserRole.valueOf(roleRaw) : UserRole.ADMIN; String teamRaw = claims.get(CLAIM_TEAM, String.class); - if (teamRaw != null && !teamRaw.isBlank()) { - team = TeamType.valueOf(teamRaw); - } + team = TeamType.from(teamRaw); username = claims.getSubject(); } else { log.warn("JWT 검증 실패: uid/aid 클레임이 모두 누락되었습니다."); diff --git a/src/main/resources/db/migration/V20260314__create_bingo_tables.sql b/src/main/resources/db/migration/V20260314__create_bingo_tables.sql new file mode 100644 index 0000000..997cc07 --- /dev/null +++ b/src/main/resources/db/migration/V20260314__create_bingo_tables.sql @@ -0,0 +1,22 @@ +CREATE TABLE IF NOT EXISTS bingo_board ( + id BIGSERIAL PRIMARY KEY, + team_number INT NOT NULL UNIQUE, + marks VARCHAR(160) NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +CREATE TABLE IF NOT EXISTS bingo_interaction ( + id BIGSERIAL PRIMARY KEY, + team_number INT NOT NULL, + cell_index INT NOT NULL, + mark VARCHAR(16) NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX IF NOT EXISTS idx_bingo_interaction_team_number + ON bingo_interaction (team_number); + +CREATE INDEX IF NOT EXISTS idx_bingo_interaction_team_created_at + ON bingo_interaction (team_number, created_at DESC);