From 0ccb9440d48d1475e0946ec7473708d25696de41 Mon Sep 17 00:00:00 2001 From: Runea Date: Sun, 2 Nov 2025 01:40:27 +0900 Subject: [PATCH 1/5] =?UTF-8?q?[BE]=20SISC1-52=20[FIX]=20=EB=B2=A0?= =?UTF-8?q?=ED=8C=85=20=EA=B2=8C=EC=9E=84=20=EB=B2=84=EA=B7=B8=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/org/sejongisc/backend/BackendApplication.java | 2 ++ .../org/sejongisc/backend/betting/dto/UserBetRequest.java | 7 ++++++- .../sejongisc/backend/betting/service/BettingService.java | 3 +++ .../org/sejongisc/backend/common/exception/ErrorCode.java | 3 ++- 4 files changed, 13 insertions(+), 2 deletions(-) diff --git a/backend/src/main/java/org/sejongisc/backend/BackendApplication.java b/backend/src/main/java/org/sejongisc/backend/BackendApplication.java index 17a1ee53..2e9b11af 100644 --- a/backend/src/main/java/org/sejongisc/backend/BackendApplication.java +++ b/backend/src/main/java/org/sejongisc/backend/BackendApplication.java @@ -4,7 +4,9 @@ import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.data.jpa.repository.config.EnableJpaAuditing; import org.springframework.retry.annotation.EnableRetry; +import org.springframework.scheduling.annotation.EnableScheduling; +@EnableScheduling @EnableJpaAuditing @EnableRetry @SpringBootApplication 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 c91770ac..954bc817 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 jakarta.validation.constraints.AssertTrue; import jakarta.validation.constraints.Min; import jakarta.validation.constraints.NotNull; import lombok.AllArgsConstructor; @@ -24,6 +25,10 @@ public class UserBetRequest { @JsonProperty("isFree") private boolean free; - @Min(value = 10, message = "베팅 포인트는 10 이상이어야 합니다.") private Integer stakePoints; + + @AssertTrue(message = "베팅 시 포인트는 10 이상이어야 합니다.") + public boolean isStakePointsValid() { + return free || (stakePoints != null && stakePoints >= 10); + } } diff --git a/backend/src/main/java/org/sejongisc/backend/betting/service/BettingService.java b/backend/src/main/java/org/sejongisc/backend/betting/service/BettingService.java index 638408e2..68c5ac08 100644 --- a/backend/src/main/java/org/sejongisc/backend/betting/service/BettingService.java +++ b/backend/src/main/java/org/sejongisc/backend/betting/service/BettingService.java @@ -102,6 +102,9 @@ public UserBet postUserBet(UUID userId, UserBetRequest userBetRequest) { int stake = 0; if (!userBetRequest.isFree()) { + if (!userBetRequest.isStakePointsValid()) { + throw new CustomException(ErrorCode.BET_POINT_TOO_LOW); + } pointHistoryService.createPointHistory( userId, -userBetRequest.getStakePoints(), diff --git a/backend/src/main/java/org/sejongisc/backend/common/exception/ErrorCode.java b/backend/src/main/java/org/sejongisc/backend/common/exception/ErrorCode.java index 0d00f552..a59edf29 100644 --- a/backend/src/main/java/org/sejongisc/backend/common/exception/ErrorCode.java +++ b/backend/src/main/java/org/sejongisc/backend/common/exception/ErrorCode.java @@ -65,7 +65,8 @@ public enum ErrorCode { BET_DUPLICATE(HttpStatus.CONFLICT, "이미 이 라운드에 베팅했습니다."), BET_TIME_INVALID(HttpStatus.CONFLICT, "베팅 가능 시간이 아닙니다."), BET_NOT_FOUND(HttpStatus.NOT_FOUND, "해당 베팅을 찾을 수 없습니다."), - BET_ROUND_CLOSED(HttpStatus.CONFLICT, "이미 마감된 라운드입니다."); + BET_ROUND_CLOSED(HttpStatus.CONFLICT, "이미 마감된 라운드입니다."), + BET_POINT_TOO_LOW(HttpStatus.CONFLICT, "베팅 포인트는 10 이상이어야 합니다."); private final HttpStatus status; private final String message; From 08c400d1f37258baaed7f7c0541bfedad926956f Mon Sep 17 00:00:00 2001 From: Runea Date: Wed, 12 Nov 2025 00:48:43 +0900 Subject: [PATCH 2/5] =?UTF-8?q?[BE]=20SISC1-52=20[DOCS]=20Swagger=EC=97=90?= =?UTF-8?q?=20API=20=EC=A3=BC=EC=84=9D=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../betting/controller/BettingController.java | 88 ++++++++++++++++--- 1 file changed, 78 insertions(+), 10 deletions(-) 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..04023260 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,16 +26,32 @@ @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){ + @Parameter(description = "라운드 범위: 'daily' 또는 'weekly'", example = "daily") + @PathVariable @Pattern(regexp = "daily|weekly") String scope) { Scope scopeEnum = Scope.valueOf(scope.toUpperCase()); - Optional betRound = bettingService.getActiveRound(scopeEnum); return betRound @@ -40,37 +59,86 @@ public ResponseEntity getTodayBetRound( .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); } } From 8e01a8fb38e0095e905d31756144244de98ebfdc Mon Sep 17 00:00:00 2001 From: Runea Date: Wed, 12 Nov 2025 00:51:10 +0900 Subject: [PATCH 3/5] =?UTF-8?q?[BE]=20SISC1-52=20[DOCS]=20Swagger=EC=97=90?= =?UTF-8?q?=20Request=20=EC=A3=BC=EC=84=9D=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../sejongisc/backend/betting/dto/UserBetRequest.java | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) 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); From 4feba2c567d33c6764cf100589147555a714705c Mon Sep 17 00:00:00 2001 From: Runea Date: Wed, 12 Nov 2025 22:57:28 +0900 Subject: [PATCH 4/5] =?UTF-8?q?[BE]=20SISC1-52=20[FIX]=20500=20=EC=97=90?= =?UTF-8?q?=EB=9F=AC=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../betting/controller/BettingController.java | 11 ++++++----- .../backend/betting/entity/Scope.java | 19 +++++++++++++++++-- .../backend/common/exception/ErrorCode.java | 3 +-- 3 files changed, 24 insertions(+), 9 deletions(-) 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 04023260..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 @@ -46,19 +46,20 @@ public class BettingController { @ApiResponse(responseCode = "404", description = "활성 라운드가 존재하지 않음") } ) + @GetMapping("/bet-rounds/{scope}") public ResponseEntity getTodayBetRound( - @Parameter(description = "라운드 범위: 'daily' 또는 'weekly'", example = "daily") - @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 = """ 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(); + } } diff --git a/backend/src/main/java/org/sejongisc/backend/common/exception/ErrorCode.java b/backend/src/main/java/org/sejongisc/backend/common/exception/ErrorCode.java index f8f3a731..53058874 100644 --- a/backend/src/main/java/org/sejongisc/backend/common/exception/ErrorCode.java +++ b/backend/src/main/java/org/sejongisc/backend/common/exception/ErrorCode.java @@ -71,8 +71,7 @@ public enum ErrorCode { BET_DUPLICATE(HttpStatus.CONFLICT, "이미 이 라운드에 베팅했습니다."), BET_ROUND_CLOSED(HttpStatus.CONFLICT, "베팅 가능 시간이 아닙니다."), BET_NOT_FOUND(HttpStatus.NOT_FOUND, "해당 베팅을 찾을 수 없습니다."), - BET_ROUND_CLOSED(HttpStatus.CONFLICT, "이미 마감된 라운드입니다."), - BET_POINT_TOO_LOW(HttpStatus.CONFLICT, "베팅 포인트는 10 이상이어야 합니다."); + BET_POINT_TOO_LOW(HttpStatus.CONFLICT, "베팅 포인트는 10 이상이어야 합니다."), BET_ROUND_NOT_CLOSED(HttpStatus.CONFLICT, "닫히지 않은 배팅입니다."), // BOARD From 757fe9c7f03fd3d2c63d982fb9b3ddfd74a6a07d Mon Sep 17 00:00:00 2001 From: Runea Date: Wed, 12 Nov 2025 22:57:48 +0900 Subject: [PATCH 5/5] =?UTF-8?q?[BE]=20SISC1-52=20[DOCS]=20Swagger=EC=97=90?= =?UTF-8?q?=20BetRound=20=EC=A3=BC=EC=84=9D=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../backend/betting/entity/BetRound.java | 26 ++++++++++++++++--- 1 file changed, 23 insertions(+), 3 deletions(-) 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; } - }