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..9fe9c6e --- /dev/null +++ b/src/main/java/inha/gdgoc/domain/game/controller/Rythm8beatScoreController.java @@ -0,0 +1,48 @@ +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; + + @PostMapping("/scores") + public ResponseEntity> submitScore( + @Valid @RequestBody Rythm8beatScoreRequest request) { + rythm8beatScoreService.submitScore(request); + return ResponseEntity.ok(ApiResponse.ok(SCORE_SUBMITTED)); + } + + @GetMapping("/ranking") + public ResponseEntity> getRanking( + @RequestParam(required = false) String phoneNumber) { + Rythm8beatRankingResponse response = rythm8beatScoreService.getRanking(phoneNumber); + return ResponseEntity.ok(ApiResponse.ok(RANKING_RETRIEVED, response)); + } + + @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/controller/message/Rythm8beatScoreMessage.java b/src/main/java/inha/gdgoc/domain/game/controller/message/Rythm8beatScoreMessage.java new file mode 100644 index 0000000..b3e04ea --- /dev/null +++ b/src/main/java/inha/gdgoc/domain/game/controller/message/Rythm8beatScoreMessage.java @@ -0,0 +1,7 @@ +package inha.gdgoc.domain.game.controller.message; + +public class Rythm8beatScoreMessage { + public static final String SCORE_SUBMITTED = "점수가 등록되었습니다."; + public static final String RANKING_RETRIEVED = "랭킹을 조회했습니다."; + public static final String SCORES_RESET = "모든 점수가 초기화되었습니다."; +} diff --git a/src/main/java/inha/gdgoc/domain/game/dto/request/Rythm8beatScoreRequest.java b/src/main/java/inha/gdgoc/domain/game/dto/request/Rythm8beatScoreRequest.java new file mode 100644 index 0000000..76088e7 --- /dev/null +++ b/src/main/java/inha/gdgoc/domain/game/dto/request/Rythm8beatScoreRequest.java @@ -0,0 +1,30 @@ +package inha.gdgoc.domain.game.dto.request; + +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Pattern; +import jakarta.validation.constraints.Size; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor +public class Rythm8beatScoreRequest { + + @NotBlank + @Pattern(regexp = "^01[0-9]{8,9}$", message = "올바른 휴대폰 번호 형식이 아닙니다.") + private String phoneNumber; + + @NotBlank + @Size(max = 20) + private String nickname; + + @NotNull + @Min(0) + @Max(10000) + private Integer score; + + private Integer stageReached; +} diff --git a/src/main/java/inha/gdgoc/domain/game/dto/response/Rythm8beatRankItemResponse.java b/src/main/java/inha/gdgoc/domain/game/dto/response/Rythm8beatRankItemResponse.java new file mode 100644 index 0000000..ac4b035 --- /dev/null +++ b/src/main/java/inha/gdgoc/domain/game/dto/response/Rythm8beatRankItemResponse.java @@ -0,0 +1,12 @@ +package inha.gdgoc.domain.game.dto.response; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +public class Rythm8beatRankItemResponse { + private int rank; + private String nickname; + private int score; +} diff --git a/src/main/java/inha/gdgoc/domain/game/dto/response/Rythm8beatRankingResponse.java b/src/main/java/inha/gdgoc/domain/game/dto/response/Rythm8beatRankingResponse.java new file mode 100644 index 0000000..913b5e6 --- /dev/null +++ b/src/main/java/inha/gdgoc/domain/game/dto/response/Rythm8beatRankingResponse.java @@ -0,0 +1,12 @@ +package inha.gdgoc.domain.game.dto.response; + +import java.util.List; +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +public class Rythm8beatRankingResponse { + private List top3; + private Rythm8beatRankItemResponse userRank; +} 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..87f6bac --- /dev/null +++ b/src/main/java/inha/gdgoc/domain/game/entity/Rythm8beatScore.java @@ -0,0 +1,48 @@ +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; + + 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..7286bdf --- /dev/null +++ b/src/main/java/inha/gdgoc/domain/game/repository/Rythm8beatScoreRepository.java @@ -0,0 +1,18 @@ +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 { + + Optional findByPhoneNumber(String phoneNumber); + + List findTop3ByOrderByScoreDescUpdatedAtAsc(); + + @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..a0642b2 --- /dev/null +++ b/src/main/java/inha/gdgoc/domain/game/service/Rythm8beatScoreService.java @@ -0,0 +1,62 @@ +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; + + 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()) + ); + } + + @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); + } + + 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..1691bb5 100644 --- a/src/main/java/inha/gdgoc/global/security/SecurityConfig.java +++ b/src/main/java/inha/gdgoc/global/security/SecurityConfig.java @@ -100,7 +100,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")); diff --git a/src/main/resources/db/migration/V20260309__create_game_scores.sql b/src/main/resources/db/migration/V20260309__create_game_scores.sql new file mode 100644 index 0000000..0ca4422 --- /dev/null +++ b/src/main/resources/db/migration/V20260309__create_game_scores.sql @@ -0,0 +1,10 @@ +CREATE TABLE IF NOT EXISTS game_scores ( + id BIGSERIAL PRIMARY KEY, + phone_number VARCHAR(20) NOT NULL UNIQUE, + nickname VARCHAR(20) NOT NULL, + score INT NOT NULL DEFAULT 0, + stage_reached INT NOT NULL DEFAULT 1, + created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP +); +CREATE INDEX IF NOT EXISTS idx_game_scores_score ON game_scores(score DESC);