-
Notifications
You must be signed in to change notification settings - Fork 1
feat(game-rythm8beat): 8박자 게임 랭킹 라우트 구현 #308
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<ApiResponse<Void, Void>> submitScore( | ||
| @Valid @RequestBody Rythm8beatScoreRequest request) { | ||
| rythm8beatScoreService.submitScore(request); | ||
| return ResponseEntity.ok(ApiResponse.ok(SCORE_SUBMITTED)); | ||
| } | ||
|
|
||
| @GetMapping("/ranking") | ||
| public ResponseEntity<ApiResponse<Rythm8beatRankingResponse, Void>> getRanking( | ||
| @RequestParam(required = false) String phoneNumber) { | ||
| Rythm8beatRankingResponse response = rythm8beatScoreService.getRanking(phoneNumber); | ||
| return ResponseEntity.ok(ApiResponse.ok(RANKING_RETRIEVED, response)); | ||
| } | ||
|
|
||
| @DeleteMapping("/scores/all") | ||
| public ResponseEntity<ApiResponse<Void, Void>> resetAll() { | ||
| rythm8beatScoreService.resetAll(); | ||
| return ResponseEntity.ok(ApiResponse.ok(SCORES_RESET)); | ||
|
Comment on lines
+43
to
+46
|
||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 = "모든 점수가 초기화되었습니다."; | ||
| } |
| Original file line number | Diff line number | Diff line change | ||||||
|---|---|---|---|---|---|---|---|---|
| @@ -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; | ||||||||
|
|
||||||||
|
||||||||
| @Min(1) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
stageReached 필드에 최소값 검증이 없습니다.
stageReached가 null이 아닌 경우 음수나 0이 입력될 수 있습니다. DB 스키마에서 기본값이 1이고, 서비스 레이어에서 null일 때 1로 처리하지만, 명시적으로 0이나 음수가 전달되면 그대로 저장됩니다.
제안된 수정
+ `@Min`(1)
private Integer stageReached;📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| private Integer stageReached; | |
| `@Min`(1) | |
| private Integer stageReached; |
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@src/main/java/inha/gdgoc/domain/game/dto/request/Rythm8beatScoreRequest.java`
at line 29, The field stageReached in Rythm8beatScoreRequest lacks a
minimum-value validation, so negative or zero values can be accepted; add a
constraint to enforce stageReached >= 1 (e.g., annotate the Integer stageReached
field with `@Min`(1) from javax.validation.constraints) and import the annotation,
and ensure request validation is triggered in the controller (e.g., `@Valid` on
the request parameter) so invalid values are rejected before persisting.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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; | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<Rythm8beatRankItemResponse> top3; | ||
| private Rythm8beatRankItemResponse userRank; | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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; | ||
| } | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<Rythm8beatScore, Long> { | ||
|
|
||
| Optional<Rythm8beatScore> findByPhoneNumber(String phoneNumber); | ||
|
|
||
| List<Rythm8beatScore> findTop3ByOrderByScoreDescUpdatedAtAsc(); | ||
|
|
||
| @Query("SELECT COUNT(r) FROM Rythm8beatScore r WHERE r.score > :score") | ||
| long countByScoreGreaterThan(@Param("score") int score); | ||
|
Comment on lines
+14
to
+17
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Top3 랭킹과 사용자 랭킹 계산 로직 간 불일치가 있습니다.
예시: 사용자 A(100점, 먼저 제출)와 B(100점, 나중 제출)가 있을 때:
동점자 처리를 일관되게 하려면 제안된 수정- `@Query`("SELECT COUNT(r) FROM Rythm8beatScore r WHERE r.score > :score")
- long countByScoreGreaterThan(`@Param`("score") int score);
+ `@Query`("SELECT COUNT(r) FROM Rythm8beatScore r WHERE r.score > :score OR (r.score = :score AND r.updatedAt < :updatedAt)")
+ long countByScoreGreaterThanOrEqualWithEarlierUpdate(`@Param`("score") int score, `@Param`("updatedAt") java.time.Instant updatedAt);🤖 Prompt for AI Agents |
||
| } | ||
| Original file line number | Diff line number | Diff line change | ||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -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<Rythm8beatScore> top3 = rythm8beatScoreRepository.findTop3ByOrderByScoreDescUpdatedAtAsc(); | ||||||||||||||
|
|
||||||||||||||
| List<Rythm8beatRankItemResponse> 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; | ||||||||||||||
|
||||||||||||||
| long rank = rythm8beatScoreRepository.countByScoreGreaterThan(gs.getScore()) + 1; | |
| long rank = rythm8beatScoreRepository | |
| .countByScoreGreaterThanOrScoreEqualsAndUpdatedAtBefore( | |
| gs.getScore(), | |
| gs.getUpdatedAt() | |
| ) + 1; |
| Original file line number | Diff line number | Diff line change | ||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -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" | ||||||||||||||||||
| )); | ||||||||||||||||||
|
Comment on lines
+103
to
105
|
||||||||||||||||||
| "https://*.gdgocinha.com", | |
| "https://smpringles24.github.io" | |
| )); | |
| "https://smpringles24.github.io" | |
| )); | |
| config.setAllowedOriginPatterns(List.of( | |
| "https://*.gdgocinha.com" | |
| )); |
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
| @@ -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); | ||||||
|
Comment on lines
+1
to
+10
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 전화번호(PII) 저장에 대한 개인정보 보호 고려가 필요합니다.
또한 테이블명이 🤖 Prompt for AI Agents
|
||||||
| CREATE INDEX IF NOT EXISTS idx_game_scores_score ON game_scores(score DESC); | |
| CREATE INDEX IF NOT EXISTS idx_game_scores_score ON game_scores(score DESC, updated_at ASC); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
개인정보(전화번호)가 쿼리 파라미터로 노출됨
phoneNumber는 개인식별정보(PII)입니다. 쿼리 파라미터는 서버 액세스 로그, 브라우저 히스토리, 중간 프록시 등에 기록될 수 있어 개인정보 노출 위험이 있습니다.보안을 강화하려면 POST 요청의 body로 전달하거나, 별도의 사용자 식별자(UUID 등)를 사용하는 것을 고려하세요.
🤖 Prompt for AI Agents