diff --git a/backend/src/main/java/org/sejongisc/backend/betting/controller/BettingController.java b/backend/src/main/java/org/sejongisc/backend/betting/controller/BettingController.java index cc18db6a..3a4b0700 100644 --- a/backend/src/main/java/org/sejongisc/backend/betting/controller/BettingController.java +++ b/backend/src/main/java/org/sejongisc/backend/betting/controller/BettingController.java @@ -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; @@ -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 getTodayBetRound( - @PathVariable @Pattern(regexp = "daily|weekly") String scope){ - - Scope scopeEnum = Scope.valueOf(scope.toUpperCase()); - - Optional betRound = bettingService.getActiveRound(scopeEnum); + @Parameter(description = "라운드 범위 (Scope): DAILY 또는 WEEKLY", example = "DAILY") + @PathVariable Scope scope + ) { + Optional betRound = bettingService.getActiveRound(scope); return betRound .map(ResponseEntity::ok) .orElseGet(() -> ResponseEntity.notFound().build()); } + + @Operation( + summary = "전체 베팅 라운드 이력 조회", + description = """ + 지금까지 생성된 모든 베팅 라운드 이력을 최신 정산일(`settleAt`) 기준으로 내림차순 정렬하여 반환합니다. + (필요 시 추후 정렬·검색 기능이 추가될 수 있습니다.) + """, + responses = { + @ApiResponse(responseCode = "200", description = "모든 베팅 라운드 조회 성공") + } + ) @GetMapping("/bet-rounds/history") - public ResponseEntity> getAllBetRounds(){ + public ResponseEntity> getAllBetRounds() { List 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 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 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> getAllUserBets( - @AuthenticationPrincipal CustomUserDetails principal){ - List userBets = bettingService.getAllMyBets(principal.getUserId()); + @Parameter(hidden = true) + @AuthenticationPrincipal CustomUserDetails principal) { + List userBets = bettingService.getAllMyBets(principal.getUserId()); return ResponseEntity.ok(userBets); } } diff --git a/backend/src/main/java/org/sejongisc/backend/betting/dto/UserBetRequest.java b/backend/src/main/java/org/sejongisc/backend/betting/dto/UserBetRequest.java index 954bc817..e3a4fc81 100644 --- a/backend/src/main/java/org/sejongisc/backend/betting/dto/UserBetRequest.java +++ b/backend/src/main/java/org/sejongisc/backend/betting/dto/UserBetRequest.java @@ -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; @@ -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); diff --git a/backend/src/main/java/org/sejongisc/backend/betting/entity/BetRound.java b/backend/src/main/java/org/sejongisc/backend/betting/entity/BetRound.java index 80f14156..7e9aa446 100644 --- a/backend/src/main/java/org/sejongisc/backend/betting/entity/BetRound.java +++ b/backend/src/main/java/org/sejongisc/backend/betting/entity/BetRound.java @@ -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; @@ -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); @@ -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; } - } diff --git a/backend/src/main/java/org/sejongisc/backend/betting/entity/Scope.java b/backend/src/main/java/org/sejongisc/backend/betting/entity/Scope.java index 802aa57c..1916da03 100644 --- a/backend/src/main/java/org/sejongisc/backend/betting/entity/Scope.java +++ b/backend/src/main/java/org/sejongisc/backend/betting/entity/Scope.java @@ -1,5 +1,9 @@ package org.sejongisc.backend.betting.entity; +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonValue; + +import java.time.DayOfWeek; import java.time.LocalDateTime; public enum Scope { @@ -16,16 +20,27 @@ public LocalDateTime getLockAt(LocalDateTime base) { WEEKLY { @Override public LocalDateTime getOpenAt(LocalDateTime base) { - return base.with(java.time.DayOfWeek.MONDAY) + return base.with(DayOfWeek.MONDAY) .withHour(9).withMinute(0).withSecond(0).withNano(0); } @Override public LocalDateTime getLockAt(LocalDateTime base) { - return base.with(java.time.DayOfWeek.FRIDAY) + return base.with(DayOfWeek.FRIDAY) .withHour(22).withMinute(0).withSecond(0).withNano(0); } }; public abstract LocalDateTime getOpenAt(LocalDateTime base); public abstract LocalDateTime getLockAt(LocalDateTime base); + + @JsonCreator + public static Scope from(String value) { + if (value == null) return null; + return Scope.valueOf(value.toUpperCase()); + } + + @JsonValue + public String toValue() { + return this.name(); + } }