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,40 @@
package org.sejongisc.backend.betting.controller;

import lombok.RequiredArgsConstructor;
import org.sejongisc.backend.betting.entity.Scope;
import org.sejongisc.backend.betting.service.BettingService;
import org.sejongisc.backend.betting.service.BettingScheduler;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;

@RestController
@RequiredArgsConstructor
@RequestMapping("/api/admin/bet-rounds")
public class BettingAdminController {

private final BettingService bettingService;

@PostMapping("/daily/open")
public ResponseEntity<String> createDailyRound() {
bettingService.createBetRound(Scope.DAILY);
return ResponseEntity.ok("DAILY 라운드 생성");
}

@PostMapping("/weekly/open")
public ResponseEntity<String> createWeeklyRound() {
bettingService.createBetRound(Scope.WEEKLY);
return ResponseEntity.ok("WEEKLY 라운드 생성");
}

@PostMapping("/close")
public ResponseEntity<String> closeRounds() {
bettingService.closeBetRound();
return ResponseEntity.ok("라운드 종료");
}

@PostMapping("/settle")
public ResponseEntity<String> settleRounds() {
bettingService.settleUserBets();
return ResponseEntity.ok("정산");
}
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
package org.sejongisc.backend.betting.controller;


import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import jakarta.validation.constraints.Pattern;
import lombok.RequiredArgsConstructor;
Expand All @@ -23,54 +26,120 @@
@RequiredArgsConstructor
@Validated
@RequestMapping("/api")
@Tag(name = "Betting API", description = "베팅 관련 기능을 제공합니다.")
public class BettingController {

private final BettingService bettingService;

@Operation(
summary = "오늘의 베팅 라운드 조회",
description = """
요청된 범위(`daily` 또는 `weekly`)에 해당하는 현재 활성화된 베팅 라운드를 조회합니다.
라운드가 없을 경우 404(Not Found)를 반환합니다.

내부 로직:
- `Scope` 값(daily/weekly)에 맞는 라운드 중 `status = true`인 것을 반환
- 없으면 `Optional.empty()` 처리
""",
responses = {
@ApiResponse(responseCode = "200", description = "활성 라운드 조회 성공"),
@ApiResponse(responseCode = "404", description = "활성 라운드가 존재하지 않음")
}
)

@GetMapping("/bet-rounds/{scope}")
public ResponseEntity<BetRound> getTodayBetRound(
@PathVariable @Pattern(regexp = "daily|weekly") String scope){

Scope scopeEnum = Scope.valueOf(scope.toUpperCase());

Optional<BetRound> betRound = bettingService.getActiveRound(scopeEnum);
@Parameter(description = "라운드 범위 (Scope): DAILY 또는 WEEKLY", example = "DAILY")
@PathVariable Scope scope
) {
Optional<BetRound> betRound = bettingService.getActiveRound(scope);

return betRound
.map(ResponseEntity::ok)
.orElseGet(() -> ResponseEntity.notFound().build());
}
Comment on lines 50 to 60
Copy link

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion | 🟠 Major

🧩 Analysis chain

컨트롤러에서 엔티티 객체를 직접 반환하고 있습니다.

현재 모든 엔드포인트에서 BetRoundUserBet 엔티티를 직접 반환하고 있습니다. 이는 다음과 같은 문제를 야기합니다:

  1. 영속성 계층과 API 계층의 결합: 엔티티 구조 변경 시 API 스펙이 자동으로 변경됩니다
  2. 보안 위험: 의도하지 않은 필드(예: 내부 상태, 민감 정보)가 노출될 수 있습니다
  3. 순환 참조 위험: JPA 관계(@onetomany, @manytoone)가 있을 경우 JSON 직렬화 시 무한 루프 발생 가능
  4. API 버전 관리 어려움: 엔티티 변경 없이 API만 변경하기 어렵습니다

DTO 패턴을 도입하여 각 엔드포인트에 맞는 응답 클래스를 만드는 것을 강력히 권장합니다.

다음 스크립트로 엔티티에 JPA 관계 어노테이션이 있는지 확인하여 순환 참조 위험을 검증합니다:

Also applies to: 74-77, 94-101, 117-125, 138-144


🏁 Script executed:

#!/bin/bash
# Description: Check for JPA relationship annotations in entities that could cause circular reference issues

# Search for JPA relationship annotations in entity classes
rg -n --type java '@(OneToMany|ManyToOne|OneToOne|ManyToMany)' backend/src/main/java/org/sejongisc/backend/betting/entity/

Length of output: 131


추가 검증이 필요합니다. BetRound와 다른 엔티티의 양방향 관계를 확인하겠습니다.


---

🏁 Script executed:

```shell
#!/bin/bash
# Check for @OneToMany in BetRound entity and see relationship configuration
fd -e java --path backend/src/main/java/org/sejongisc/backend/betting/entity -exec grep -l "BetRound\|UserBet" {} \;

Length of output: 233


🏁 Script executed:

#!/bin/bash
# Check for all @OneToMany and bidirectional relationships
rg -n '@OneToMany' backend/src/main/java/org/sejongisc/backend/betting/entity/

Length of output: 0


🏁 Script executed:

#!/bin/bash
# Check if serialization control annotations exist
rg -n '@(JsonIgnore|JsonBackReference|JsonManagedReference)' backend/src/main/java/org/sejongisc/backend/betting/entity/

Length of output: 0


🏁 Script executed:

#!/bin/bash
# Show UserBet.java full context around the @ManyToOne annotation
cat backend/src/main/java/org/sejongisc/backend/betting/entity/UserBet.java | head -40

Length of output: 1118



---

🏁 Script executed:

```shell
#!/bin/bash
# Find and show BetRound entity
find backend/src/main/java/org/sejongisc/backend/betting/entity -name "BetRound.java" -exec cat {} \;

Length of output: 3523


🏁 Script executed:

#!/bin/bash
# Show the full BettingController to see all endpoints mentioned in review
cat backend/src/main/java/org/sejongisc/backend/betting/controller/BettingController.java

Length of output: 5591


🏁 Script executed:

#!/bin/bash
# Check for any Jackson configuration that might control serialization
rg -n '@JsonInclude|@JsonProperty|@JsonView|@JsonSerialize' backend/src/main/java/org/sejongisc/backend/betting/ -A2

Length of output: 291


엔티티 객체의 직접 반환에 따른 아키텍처 위험 개선 필요

현재 모든 엔드포인트에서 BetRoundUserBet 엔티티를 직접 반환하고 있습니다. 순환 참조 위험은 현재 설정(unidirectional relationship + LAZY fetch)에서 즉각적인 문제는 아니지만, 다음의 아키텍처적 문제가 존재합니다:

  1. 영속성 계층과 API 계층의 결합: 엔티티 구조 변경 시 API 스펙이 자동으로 변경됩니다
  2. 보안 위험: 의도하지 않은 필드(예: 내부 상태, 민감 정보)가 노출될 수 있습니다
  3. API 버전 관리 어려움: 엔티티 변경 없이 API만 변경하기 어렵습니다
  4. 직렬화 동작의 불확실성: 미래 fetch 전략 변경 시 예상치 못한 동작 발생 가능

각 엔드포인트에 맞는 DTO 클래스를 작성하여 응답 데이터를 제어하기를 권장합니다 (50-60, 74-77, 94-101, 138-144 라인).

🤖 Prompt for AI Agents
backend/src/main/java/org/sejongisc/backend/betting/controller/BettingController.java
(around lines 50-60, and also adjust endpoints at 74-77, 94-101, 138-144):
currently the controller returns JPA entities (BetRound/UserBet) directly;
replace those with dedicated response DTOs to decouple API from persistence.
Create BetRoundDto and UserBetDto containing only the fields the API should
expose, implement simple mapping from entity-to-DTO (in service or controller)
or use a mapper (MapStruct/constructors), change method signatures to return
ResponseEntity<BetRoundDto> / ResponseEntity<List<UserBetDto>> (or
Optional-wrapped DTO) and map Optional<Entity> to Optional<Dto> before building
the ResponseEntity, and ensure no lazy/internal/sensitive fields are exposed;
update all referenced endpoints at the listed line ranges to use the DTOs and
mapping.



@Operation(
summary = "전체 베팅 라운드 이력 조회",
description = """
지금까지 생성된 모든 베팅 라운드 이력을 최신 정산일(`settleAt`) 기준으로 내림차순 정렬하여 반환합니다.
(필요 시 추후 정렬·검색 기능이 추가될 수 있습니다.)
""",
responses = {
@ApiResponse(responseCode = "200", description = "모든 베팅 라운드 조회 성공")
}
)
@GetMapping("/bet-rounds/history")
public ResponseEntity<List<BetRound>> getAllBetRounds(){
public ResponseEntity<List<BetRound>> getAllBetRounds() {
List<BetRound> betRounds = bettingService.getAllBetRounds();

return ResponseEntity.ok(betRounds);
}

@Operation(
summary = "유저 베팅 등록",
description = """
현재 로그인된 사용자가 선택한 옵션(상승/하락 등)에 대해 베팅을 등록합니다.
무료 베팅(`isFree = true`)인 경우 포인트 차감이 없으며,
유료 베팅(`isFree = false`)일 경우 포인트가 차감되어 `PointHistory`에 기록됩니다.
""",
responses = {
@ApiResponse(responseCode = "200", description = "베팅 등록 성공"),
@ApiResponse(responseCode = "401", description = "인증되지 않은 사용자"),
@ApiResponse(responseCode = "404", description = "존재하지 않는 라운드"),
@ApiResponse(responseCode = "409", description = "중복 베팅, 베팅 시간 아님, 또는 포인트 부족")
}
)
@PostMapping("/user-bets")
public ResponseEntity<UserBet> postUserBet(
@Parameter(hidden = true)
@AuthenticationPrincipal CustomUserDetails principal,
@RequestBody @Valid UserBetRequest userBetRequest){
@Valid @RequestBody UserBetRequest userBetRequest) {

UserBet userBet = bettingService.postUserBet(principal.getUserId(), userBetRequest);

return ResponseEntity.ok(userBet);
}

@Operation(
summary = "유저 베팅 취소",
description = """
자신이 등록한 베팅을 취소합니다.
단, 해당 라운드의 `lockAt` 시간 이전까지만 취소 가능하며,
포인트가 사용된 베팅의 경우 취소 시 포인트가 복원됩니다.
""",
responses = {
@ApiResponse(responseCode = "204", description = "베팅 취소 성공"),
@ApiResponse(responseCode = "404", description = "해당 베팅이 존재하지 않음"),
@ApiResponse(responseCode = "409", description = "라운드가 이미 마감되어 취소 불가")
}
)
@DeleteMapping("/user-bets/{userBetId}")
public ResponseEntity<Void> cancelUserBet(
@Parameter(hidden = true)
@AuthenticationPrincipal CustomUserDetails principal,
@PathVariable UUID userBetId){
@Parameter(description = "취소할 베팅 ID", example = "3f57bcdc-7c4a-49a1-a1cb-0c2f8a5ef9ab")
@PathVariable UUID userBetId) {

bettingService.cancelUserBet(principal.getUserId(), userBetId);
return ResponseEntity.noContent().build();
}

@Operation(
summary = "내 베팅 이력 조회",
description = """
로그인된 사용자의 모든 베팅 이력을 최신 라운드 순으로 조회합니다.
추후 특정 기간, 상태(진행중/정산완료) 등으로 필터링 기능이 추가될 수 있습니다.
""",
responses = {
@ApiResponse(responseCode = "200", description = "조회 성공")
}
)
@GetMapping("/user-bets/history")
public ResponseEntity<List<UserBet>> getAllUserBets(
@AuthenticationPrincipal CustomUserDetails principal){
List<UserBet> userBets = bettingService.getAllMyBets(principal.getUserId());
@Parameter(hidden = true)
@AuthenticationPrincipal CustomUserDetails principal) {

List<UserBet> userBets = bettingService.getAllMyBets(principal.getUserId());
return ResponseEntity.ok(userBets);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
package org.sejongisc.backend.betting.dto;

import io.swagger.v3.oas.annotations.media.Schema;
import lombok.*;
import org.sejongisc.backend.betting.entity.MarketType;
import org.sejongisc.backend.stock.entity.PriceData;

import java.math.BigDecimal;

@Getter
@Builder
@NoArgsConstructor
@AllArgsConstructor
@Schema(description = "시세 응답 DTO (PriceData 기반)")
public class PriceResponse {

@Schema(description = "티커 이름 (예: KOSPI, BTC, AAPL)")
private String name;

@Schema(description = "심볼 (거래 대상의 식별자)")
private String symbol;

@Schema(description = "시장 구분 (예: KOREA, US, CRYPTO)")
private MarketType market;

@Schema(description = "이전 종가 (기준가)")
private BigDecimal previousClosePrice;

@Schema(description = "정산 종가 (결과 비교용)")
private BigDecimal settleClosePrice;


public static PriceResponse from(PriceData entity, MarketType marketName) {
return PriceResponse.builder()
.name(entity.getTicker())
.symbol(entity.getTicker())
.market(marketName)
.previousClosePrice(entity.getClosePrice())
.settleClosePrice(entity.getAdjustedClose())
.build();
}
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package org.sejongisc.backend.betting.dto;

import com.fasterxml.jackson.annotation.JsonProperty;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.AssertTrue;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotNull;
Expand All @@ -13,20 +14,28 @@
import java.util.UUID;

@Getter
@Builder @AllArgsConstructor @NoArgsConstructor
@Builder
@AllArgsConstructor
@NoArgsConstructor
@Schema(description = "유저 베팅 요청 DTO")
public class UserBetRequest {

@Schema(description = "베팅할 라운드의 ID", requiredMode = Schema.RequiredMode.REQUIRED)
@NotNull(message = "라운드 ID는 필수입니다.")
private UUID roundId;

@Schema(description = "베팅 옵션 (상승/하락)", example = "RISE", requiredMode = Schema.RequiredMode.REQUIRED)
@NotNull(message = "베팅 옵션은 필수입니다.")
private BetOption option;

@Schema(description = "무료 베팅 여부", example = "true")
@JsonProperty("isFree")
private boolean free;

@Schema(description = "베팅에 사용할 포인트 (무료 베팅 시 null 또는 0)", example = "100")
private Integer stakePoints;

@Schema(description = "포인트 유효성 검증용 필드 (내부 검증 전용, 클라이언트가 직접 전송할 필요 없음)", hidden = true)
@AssertTrue(message = "베팅 시 포인트는 10 이상이어야 합니다.")
public boolean isStakePointsValid() {
return free || (stakePoints != null && stakePoints >= 10);
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package org.sejongisc.backend.betting.entity;

import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.persistence.*;
import lombok.*;
import org.sejongisc.backend.common.entity.postgres.BasePostgresEntity;
Expand All @@ -20,69 +21,89 @@ public class BetRound extends BasePostgresEntity {
@Id
@GeneratedValue(strategy = GenerationType.UUID)
@Column(columnDefinition = "uuid")
@Schema(description = "베팅 라운드의 고유 식별자")
private UUID betRoundID;

@Enumerated(EnumType.STRING)
@Column(nullable = false)
@Schema(description = "라운드 단위")
private Scope scope;

@Column(nullable = false, length = 100)
@Schema(description = "라운드 제목")
private String title;

@Column(nullable = false, length = 50)
@Schema(description = "베팅 대상 심볼")
private String symbol;

@Column(nullable = false)
@Schema(description = "무료 베팅 허용 여부")
private boolean allowFree;

@Column(precision = 6, scale = 3)
@Schema(description = "기본 배당 배율")
private BigDecimal baseMultiplier;

@Column(nullable = false)
private boolean status = false; // Todo : Enum 클래스로 수정
@Schema(description = "라운드 진행 상태", defaultValue = "false")
private boolean status = false; // Todo : Enum 클래스로 변경 고려

@Schema(description = "베팅이 열리는 시각 (유저 참여 시작 시점)")
private LocalDateTime openAt;

@Schema(description = "베팅이 잠기는 시각")
private LocalDateTime lockAt;

@Schema(description = "결과 정산 시각")
private LocalDateTime settleAt;

@Enumerated(EnumType.STRING)
@Column(nullable = true)
@Schema(description = "최종 결과")
private BetOption resultOption;

@Enumerated(EnumType.STRING)
@Column(nullable = false)
@Schema(description = "시장 구분")
private MarketType market;

@Column(precision = 15, scale = 2, nullable = false)
@Schema(description = "이전 종가 (베팅 기준 가격)")
private BigDecimal previousClosePrice;

@Column(precision = 15, scale = 2)
@Schema(description = "정산 종가 (결과 비교용)")
private BigDecimal settleClosePrice;

// 라운드가 현재 진행 중인지 여부 반환
public boolean isOpen() {
return this.status;
}

// 라운드가 종료되었는지 여부 반환
public boolean isClosed() {
return !this.status;
}

// 베팅 시작
public void open() {
this.status = true;
}

// 베팅 불가
public void close() {
this.status = false;
}

// "베팅 가능한 상태인지 검증
public void validate() {
if (isClosed() || (lockAt != null && LocalDateTime.now().isAfter(lockAt))) {
throw new CustomException(ErrorCode.BET_ROUND_CLOSED);
}
}

// 정산 로직 수행
public void settle(BigDecimal finalPrice) {
if (isOpen()) {
throw new CustomException(ErrorCode.BET_ROUND_NOT_CLOSED);
Expand All @@ -98,11 +119,10 @@ public void settle(BigDecimal finalPrice) {
this.settleAt = LocalDateTime.now();
}

// 결과 판정 로직 - 이전 종가와 비교하여 상승/하락 결정
private BetOption determineResult(BigDecimal finalPrice) {
int compare = finalPrice.compareTo(previousClosePrice);

if (compare >= 0) return BetOption.RISE;
return BetOption.FALL;
}

}
Loading