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
@@ -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());
}


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

}
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -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();
}
}