Skip to content
Merged
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,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));
}
Comment on lines +36 to +41

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

개인정보(전화번호)가 쿼리 파라미터로 노출됨

phoneNumber는 개인식별정보(PII)입니다. 쿼리 파라미터는 서버 액세스 로그, 브라우저 히스토리, 중간 프록시 등에 기록될 수 있어 개인정보 노출 위험이 있습니다.

보안을 강화하려면 POST 요청의 body로 전달하거나, 별도의 사용자 식별자(UUID 등)를 사용하는 것을 고려하세요.

🤖 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/controller/Rythm8beatScoreController.java`
around lines 36 - 41, The getRanking endpoint in Rythm8beatScoreController
currently accepts a sensitive phoneNumber via `@RequestParam`; change it to accept
a POST with a request body DTO (e.g., GetRankingRequest with an optional
phoneNumber or better a non-PII userId/UUID) and update the mapping from
`@GetMapping`("/ranking") to `@PostMapping`("/ranking"); adjust the controller
method signature and call to rythm8beatScoreService.getRanking(...) to accept
the DTO (or UUID) and update Rythm8beatRankingResponse usage accordingly so
phoneNumber is no longer exposed in query strings or logs.


@DeleteMapping("/scores/all")
public ResponseEntity<ApiResponse<Void, Void>> resetAll() {
rythm8beatScoreService.resetAll();
return ResponseEntity.ok(ApiResponse.ok(SCORES_RESET));
Comment on lines +43 to +46

Copilot AI Mar 9, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

/scores/all은 인증/권한 체크 없이 전체 점수를 삭제할 수 있어 누구나 랭킹 데이터를 초기화할 수 있습니다(현재 SecurityConfig에서 /api/v1/game/**가 permitAll). 운영 환경에서 치명적이므로 이 엔드포인트를 제거하거나, 관리자 전용으로 보호(@PreAuthorize 등)하고 별도의 보호수단(예: 내부망/환경별 비활성화)을 추가해주세요.

Copilot uses AI. Check for mistakes.
}
}
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;

Copilot AI Mar 9, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

stageReached는 null일 때 1로 보정하고 DB 기본값도 1로 두는 것으로 보아 1 이상의 값만 의미가 있는 필드로 보입니다. 그런데 현재는 값이 들어오는 경우에 대한 검증이 없어(음수/0 등) 잘못된 데이터가 저장될 수 있으니 @Min(1) 같은 validation을 추가해주세요(필요하면 상한도 함께).

Suggested change
@Min(1)

Copilot uses AI. Check for mistakes.
private Integer stageReached;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

stageReached 필드에 최소값 검증이 없습니다.

stageReachednull이 아닌 경우 음수나 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.

Suggested change
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;
}
48 changes: 48 additions & 0 deletions src/main/java/inha/gdgoc/domain/game/entity/Rythm8beatScore.java
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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Top3 랭킹과 사용자 랭킹 계산 로직 간 불일치가 있습니다.

findTop3ByOrderByScoreDescUpdatedAtAsc()는 동점 시 updatedAt 오름차순으로 순위를 결정하지만, countByScoreGreaterThan()은 점수만 비교합니다.

예시: 사용자 A(100점, 먼저 제출)와 B(100점, 나중 제출)가 있을 때:

  • Top3에서는 A가 1위, B가 2위
  • 하지만 B의 countByScoreGreaterThan(100) + 1 = 1이므로 B도 1위로 표시됨

동점자 처리를 일관되게 하려면 countByScoreGreaterThan 쿼리에 updatedAt 조건을 추가하거나, 별도의 순위 계산 로직이 필요합니다.

제안된 수정
-    `@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
Verify each finding against the current code and only fix it if needed.

In
`@src/main/java/inha/gdgoc/domain/game/repository/Rythm8beatScoreRepository.java`
around lines 14 - 17, Top3 ranking uses updatedAt to break ties but
countByScoreGreaterThan only checks score, causing inconsistent ranks; update
countByScoreGreaterThan to accept an additional updatedAt parameter and change
its query to "SELECT COUNT(r) FROM Rythm8beatScore r WHERE r.score > :score OR
(r.score = :score AND r.updatedAt < :updatedAt)" (use `@Param`("score") and
`@Param`("updatedAt")) so ranking uses the same tie-breaker as
findTop3ByOrderByScoreDescUpdatedAtAsc(), and adjust any callers to pass the
contestant's updatedAt (LocalDateTime/Timestamp) when computing rank.

}
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;

Copilot AI Mar 9, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

userRank 계산이 top3 정렬 기준(score DESC, updatedAt ASC)과 일치하지 않습니다. 동점(같은 score)일 때는 countByScoreGreaterThan(score) + 1이 항상 1로 계산되어, top3에서 2~3등인 유저도 1등으로 표시될 수 있습니다. 동점 처리까지 포함해 동일한 정렬 기준으로 rank를 계산하도록(예: score가 더 크거나, score가 같고 updatedAt이 더 빠른 레코드 수를 카운트) 쿼리/로직을 맞춰주세요.

Suggested change
long rank = rythm8beatScoreRepository.countByScoreGreaterThan(gs.getScore()) + 1;
long rank = rythm8beatScoreRepository
.countByScoreGreaterThanOrScoreEqualsAndUpdatedAtBefore(
gs.getScore(),
gs.getUpdatedAt()
) + 1;

Copilot uses AI. Check for mistakes.
return new Rythm8beatRankItemResponse((int) rank, gs.getNickname(), gs.getScore());
})
.orElse(null);
}

return new Rythm8beatRankingResponse(top3Response, userRank);
}

public void resetAll() {
rythm8beatScoreRepository.deleteAll();
}
}
3 changes: 2 additions & 1 deletion src/main/java/inha/gdgoc/global/security/SecurityConfig.java
Original file line number Diff line number Diff line change
Expand Up @@ -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

Copilot AI Mar 9, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

CorsConfiguration#setAllowedOrigins does not support wildcard subdomains (e.g., https://*.gdgocinha.com). With the current setup, requests from subdomains may still fail CORS validation. Use setAllowedOriginPatterns for wildcard patterns (and keep setAllowedOrigins for exact matches), or list the exact origins you intend to allow.

Suggested change
"https://*.gdgocinha.com",
"https://smpringles24.github.io"
));
"https://smpringles24.github.io"
));
config.setAllowedOriginPatterns(List.of(
"https://*.gdgocinha.com"
));

Copilot uses AI. Check for mistakes.
config.setAllowedMethods(List.of("GET","POST","PUT","DELETE","OPTIONS","PATCH"));
config.setAllowedHeaders(List.of("Origin","X-Requested-With","Content-Type","Accept","Authorization"));
Expand Down
10 changes: 10 additions & 0 deletions src/main/resources/db/migration/V20260309__create_game_scores.sql
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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

전화번호(PII) 저장에 대한 개인정보 보호 고려가 필요합니다.

phone_number 컬럼에 개인 식별 정보(PII)가 평문으로 저장됩니다. GDPR/개인정보보호법 준수를 위해 다음 사항을 검토하세요:

  1. 데이터 보존 정책 수립 필요
  2. 필요시 해시 처리 또는 암호화 고려
  3. 접근 로깅 및 감사 추적

또한 테이블명이 game_scores로 범용적인데, 엔티티명은 Rythm8beatScore입니다. 향후 다른 게임 추가 시 혼란을 피하려면 테이블명을 rythm8beat_scores로 변경하는 것을 고려해 보세요.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/main/resources/db/migration/V20260309__create_game_scores.sql` around
lines 1 - 10, The migration stores PII in plain text: update the CREATE TABLE
for game_scores to avoid storing raw phone_number by either replacing
phone_number VARCHAR(20) with a non-reversible hash column (e.g., phone_hash
VARCHAR(64) NOT NULL UNIQUE) or an encrypted column and add metadata for
retention (e.g., retention_policy or deleted_at) plus auditing fields; also
rename the table from game_scores to rythm8beat_scores to match the entity
Rythm8beatScore (update any references to game_scores in your
codebase/migrations) and ensure any application code that inserts/queries uses
the chosen hashing/encryption method and logs access for audit.

Copilot AI Mar 9, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

랭킹 조회가 ORDER BY score DESC, updated_at ASC 기준을 사용하므로(Repository 메서드 기준) score DESC 단일 인덱스만으로는 정렬을 충분히 지원하지 못해 테이블이 커지면 정렬 비용이 커질 수 있습니다. (score DESC, updated_at ASC) 복합 인덱스로 바꾸거나 추가하는 것을 고려해주세요.

Suggested change
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);

Copilot uses AI. Check for mistakes.
Loading