diff --git a/src/main/java/inha/gdgoc/domain/game/controller/Rythm8beatScoreController.java b/src/main/java/inha/gdgoc/domain/game/controller/Rythm8beatScoreController.java new file mode 100644 index 0000000..ad6ba0b --- /dev/null +++ b/src/main/java/inha/gdgoc/domain/game/controller/Rythm8beatScoreController.java @@ -0,0 +1,65 @@ +package inha.gdgoc.domain.game.controller; + +import static inha.gdgoc.domain.game.controller.message.Rythm8beatScoreMessage.RANKING_RETRIEVED; +import static inha.gdgoc.domain.game.controller.message.Rythm8beatScoreMessage.SCORES_RESET; +import static inha.gdgoc.domain.game.controller.message.Rythm8beatScoreMessage.SCORE_SUBMITTED; + +import inha.gdgoc.domain.game.dto.request.Rythm8beatScoreRequest; +import inha.gdgoc.domain.game.dto.response.Rythm8beatRankingResponse; +import inha.gdgoc.domain.game.service.Rythm8beatScoreService; +import inha.gdgoc.global.dto.response.ApiResponse; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +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.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@RequestMapping("/api/v1/game/rythm8beat") +@RestController +@RequiredArgsConstructor +public class Rythm8beatScoreController { + + private final Rythm8beatScoreService rythm8beatScoreService; + + /** + * 리듬8비트 점수 제출 요청을 처리한다. + * + * @param request 제출할 점수 정보를 담은 요청 본문(유효성 검증됨) + * @return 성공 처리 결과를 담은 ApiResponse. 페이로드는 비어있고 메시지는 SCORE_SUBMITTED + */ + @PostMapping("/scores") + public ResponseEntity> submitScore( + @Valid @RequestBody Rythm8beatScoreRequest request) { + rythm8beatScoreService.submitScore(request); + return ResponseEntity.ok(ApiResponse.ok(SCORE_SUBMITTED)); + } + + /** + * 리듬8비트 게임의 전체 랭킹 및 (선택적으로) 특정 사용자 랭킹을 조회합니다. + * + * @param phoneNumber 조회할 사용자의 전화번호(선택). 제공하면 해당 사용자의 순위 정보가 응답에 포함됩니다. + * @return 랭킹 정보를 담은 Rythm8beatRankingResponse를 페이로드로 포함하는 ApiResponse를 담은 ResponseEntity + */ + @GetMapping("/ranking") + public ResponseEntity> getRanking( + @RequestParam(required = false) String phoneNumber) { + Rythm8beatRankingResponse response = rythm8beatScoreService.getRanking(phoneNumber); + return ResponseEntity.ok(ApiResponse.ok(RANKING_RETRIEVED, response)); + } + + /** + * 모든 리듬8비트 점수 데이터를 초기화합니다. + * + * @return ApiResponse에 빈 페이로드와 SCORES_RESET 메시지를 포함한 HTTP 200 OK 응답 + */ + @DeleteMapping("/scores/all") + public ResponseEntity> resetAll() { + rythm8beatScoreService.resetAll(); + return ResponseEntity.ok(ApiResponse.ok(SCORES_RESET)); + } +} diff --git a/src/main/java/inha/gdgoc/domain/game/entity/Rythm8beatScore.java b/src/main/java/inha/gdgoc/domain/game/entity/Rythm8beatScore.java new file mode 100644 index 0000000..5fc2a60 --- /dev/null +++ b/src/main/java/inha/gdgoc/domain/game/entity/Rythm8beatScore.java @@ -0,0 +1,55 @@ +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 jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Table(name = "game_scores") +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor +@Getter +@Builder +public class Rythm8beatScore extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "id") + private Long id; + + @Column(name = "phone_number", nullable = false, unique = true, length = 20) + private String phoneNumber; + + @Column(name = "nickname", nullable = false, length = 20) + private String nickname; + + @Column(name = "score", nullable = false) + private int score; + + @Column(name = "stage_reached", nullable = false) + private int stageReached; + + /** + * 새 점수가 현재 점수보다 높을 경우 엔티티의 닉네임, 점수 및 도달 스테이지를 갱신한다. + * + * @param nickname 갱신할 닉네임 + * @param newScore 비교할 새 점수(현재 점수보다 클 때 적용됨) + * @param newStageReached 갱신할 도달한 스테이지 + */ + public void updateIfHigherScore(String nickname, int newScore, int newStageReached) { + if (this.score < newScore) { + this.nickname = nickname; + this.score = newScore; + this.stageReached = newStageReached; + } + } +} diff --git a/src/main/java/inha/gdgoc/domain/game/repository/Rythm8beatScoreRepository.java b/src/main/java/inha/gdgoc/domain/game/repository/Rythm8beatScoreRepository.java new file mode 100644 index 0000000..fdac46a --- /dev/null +++ b/src/main/java/inha/gdgoc/domain/game/repository/Rythm8beatScoreRepository.java @@ -0,0 +1,35 @@ +package inha.gdgoc.domain.game.repository; + +import inha.gdgoc.domain.game.entity.Rythm8beatScore; +import java.util.List; +import java.util.Optional; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +public interface Rythm8beatScoreRepository extends JpaRepository { + + /** + * 주어진 휴대전화 번호로 저장된 점수 엔티티를 조회합니다. + * + * @param phoneNumber 조회할 휴대전화 번호 + * @return `Optional`에 조회된 Rythm8beatScore가 포함되어 있으면 해당 엔티티, 없으면 비어 있음 + */ +Optional findByPhoneNumber(String phoneNumber); + + /** + * 상위 3개의 Rythm8beatScore 엔티티를 점수 내림차순으로, 동점일 경우 업데이트 시간 오름차순으로 조회합니다. + * + * @return 정렬된 최대 3개의 Rythm8beatScore 목록. 일치하는 엔티티가 없으면 빈 리스트를 반환합니다. + */ +List findTop3ByOrderByScoreDescUpdatedAtAsc(); + + /** + * 지정한 점수보다 큰 Rythm8beatScore 엔티티의 개수를 계산합니다. + * + * @param score 비교 기준이 되는 점수(이 값을 초과하는 항목을 셀 임계값) + * @return 지정한 `score`보다 큰 점수를 가진 엔티티의 수 + */ + @Query("SELECT COUNT(r) FROM Rythm8beatScore r WHERE r.score > :score") + long countByScoreGreaterThan(@Param("score") int score); +} diff --git a/src/main/java/inha/gdgoc/domain/game/service/Rythm8beatScoreService.java b/src/main/java/inha/gdgoc/domain/game/service/Rythm8beatScoreService.java new file mode 100644 index 0000000..083e940 --- /dev/null +++ b/src/main/java/inha/gdgoc/domain/game/service/Rythm8beatScoreService.java @@ -0,0 +1,76 @@ +package inha.gdgoc.domain.game.service; + +import inha.gdgoc.domain.game.dto.request.Rythm8beatScoreRequest; +import inha.gdgoc.domain.game.dto.response.Rythm8beatRankItemResponse; +import inha.gdgoc.domain.game.dto.response.Rythm8beatRankingResponse; +import inha.gdgoc.domain.game.entity.Rythm8beatScore; +import inha.gdgoc.domain.game.repository.Rythm8beatScoreRepository; +import java.util.List; +import java.util.stream.IntStream; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +@Transactional +public class Rythm8beatScoreService { + + private final Rythm8beatScoreRepository rythm8beatScoreRepository; + + /** + * 플레이어의 점수를 제출하고, 동일 전화번호의 기존 기록이 있으면 더 높은 점수로만 갱신하며 없으면 새 기록을 저장한다. + * + * @param request 제출할 점수 정보. phoneNumber, nickname, score 필드를 사용하며 stageReached가 null이면 1로 간주한다. + */ + public void submitScore(Rythm8beatScoreRequest request) { + rythm8beatScoreRepository.findByPhoneNumber(request.getPhoneNumber()) + .ifPresentOrElse( + entity -> entity.updateIfHigherScore( + request.getNickname(), + request.getScore(), + request.getStageReached() != null ? request.getStageReached() : 1 + ), + () -> rythm8beatScoreRepository.save(Rythm8beatScore.builder() + .phoneNumber(request.getPhoneNumber()) + .nickname(request.getNickname()) + .score(request.getScore()) + .stageReached(request.getStageReached() != null ? request.getStageReached() : 1) + .build()) + ); + } + + /** + * 상위 3명의 점수와 요청된 사용자의 랭킹 정보를 제공한다. + * + * @param phoneNumber 조회할 사용자의 전화번호. null 또는 공백이면 사용자 랭킹 항목은 포함되지 않는다. + * @return 상위 3명 항목 리스트와 요청 사용자의 랭킹 항목(존재하지 않으면 null)을 포함한 랭킹 응답 객체. + */ + @Transactional(readOnly = true) + public Rythm8beatRankingResponse getRanking(String phoneNumber) { + List top3 = rythm8beatScoreRepository.findTop3ByOrderByScoreDescUpdatedAtAsc(); + + List top3Response = IntStream.range(0, top3.size()) + .mapToObj(i -> new Rythm8beatRankItemResponse(i + 1, top3.get(i).getNickname(), top3.get(i).getScore())) + .toList(); + + Rythm8beatRankItemResponse userRank = null; + if (phoneNumber != null && !phoneNumber.isBlank()) { + userRank = rythm8beatScoreRepository.findByPhoneNumber(phoneNumber) + .map(gs -> { + long rank = rythm8beatScoreRepository.countByScoreGreaterThan(gs.getScore()) + 1; + return new Rythm8beatRankItemResponse((int) rank, gs.getNickname(), gs.getScore()); + }) + .orElse(null); + } + + return new Rythm8beatRankingResponse(top3Response, userRank); + } + + /** + * 저장된 모든 리듬8비트 점수 엔티티를 삭제합니다. + */ + public void resetAll() { + rythm8beatScoreRepository.deleteAll(); + } +} diff --git a/src/main/java/inha/gdgoc/global/security/SecurityConfig.java b/src/main/java/inha/gdgoc/global/security/SecurityConfig.java index 7f624e8..e1ee4e5 100644 --- a/src/main/java/inha/gdgoc/global/security/SecurityConfig.java +++ b/src/main/java/inha/gdgoc/global/security/SecurityConfig.java @@ -90,6 +90,13 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { return http.build(); } + /** + * 애플리케이션 전체 경로에 대한 CORS 설정을 생성하여 등록한다. + * + * 생성된 CORS 구성은 지정된 출처 목록, 허용 HTTP 메서드, 허용/노출 헤더, 자격증명 허용 및 프리플라이트 캐시(maxAge)를 포함한다. + * + * @return 모든 경로("/**")에 등록된 CorsConfiguration을 제공하는 CorsConfigurationSource 객체 + */ @Bean public CorsConfigurationSource corsConfigurationSource() { CorsConfiguration config = new CorsConfiguration(); @@ -100,7 +107,8 @@ public CorsConfigurationSource corsConfigurationSource() { "https://www.gdgocinha.com", "https://typing-game-alpha-umber.vercel.app", "https://api.gdgocinha.com", - "https://*.gdgocinha.com" + "https://*.gdgocinha.com", + "https://smpringles24.github.io" )); config.setAllowedMethods(List.of("GET","POST","PUT","DELETE","OPTIONS","PATCH")); config.setAllowedHeaders(List.of("Origin","X-Requested-With","Content-Type","Accept","Authorization"));