Skip to content
Open
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,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<ApiResponse<Void, Void>> 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<ApiResponse<Rythm8beatRankingResponse, Void>> 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<ApiResponse<Void, Void>> resetAll() {
rythm8beatScoreService.resetAll();
return ResponseEntity.ok(ApiResponse.ok(SCORES_RESET));
}
}
55 changes: 55 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,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;
}
}
}
Original file line number Diff line number Diff line change
@@ -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<Rythm8beatScore, Long> {

/**
* 주어진 휴대전화 번호로 저장된 점수 엔티티를 조회합니다.
*
* @param phoneNumber 조회할 휴대전화 번호
* @return `Optional`에 조회된 Rythm8beatScore가 포함되어 있으면 해당 엔티티, 없으면 비어 있음
*/
Optional<Rythm8beatScore> findByPhoneNumber(String phoneNumber);

/**
* 상위 3개의 Rythm8beatScore 엔티티를 점수 내림차순으로, 동점일 경우 업데이트 시간 오름차순으로 조회합니다.
*
* @return 정렬된 최대 3개의 Rythm8beatScore 목록. 일치하는 엔티티가 없으면 빈 리스트를 반환합니다.
*/
List<Rythm8beatScore> findTop3ByOrderByScoreDescUpdatedAtAsc();

/**
* 지정한 점수보다 큰 Rythm8beatScore 엔티티의 개수를 계산합니다.
*
* @param score 비교 기준이 되는 점수(이 값을 초과하는 항목을 셀 임계값)
* @return 지정한 `score`보다 큰 점수를 가진 엔티티의 수
*/
@Query("SELECT COUNT(r) FROM Rythm8beatScore r WHERE r.score > :score")
long countByScoreGreaterThan(@Param("score") int score);
}
Original file line number Diff line number Diff line change
@@ -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<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;
return new Rythm8beatRankItemResponse((int) rank, gs.getNickname(), gs.getScore());
})
.orElse(null);
}

return new Rythm8beatRankingResponse(top3Response, userRank);
}

/**
* 저장된 모든 리듬8비트 점수 엔티티를 삭제합니다.
*/
public void resetAll() {
rythm8beatScoreRepository.deleteAll();
}
}
10 changes: 9 additions & 1 deletion src/main/java/inha/gdgoc/global/security/SecurityConfig.java
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -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"));
Expand Down
Loading