Skip to content

Commit c9a79d7

Browse files
authored
Sisc1 52 be 주식 베팅 게임 구현 (#95)
* [BE] SISC1-52 [FIX] 베팅 게임 버그 수정 * [BE] SISC1-52 [DOCS] Swagger에 API 주석 추가 * [BE] SISC1-52 [DOCS] Swagger에 Request 주석 추가 * [BE] SISC1-52 [FIX] 500 에러 수정 * [BE] SISC1-52 [DOCS] Swagger에 BetRound 주석 추가
1 parent 18d028f commit c9a79d7

4 files changed

Lines changed: 132 additions & 19 deletions

File tree

backend/src/main/java/org/sejongisc/backend/betting/controller/BettingController.java

Lines changed: 82 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
package org.sejongisc.backend.betting.controller;
22

3-
3+
import io.swagger.v3.oas.annotations.Operation;
4+
import io.swagger.v3.oas.annotations.Parameter;
5+
import io.swagger.v3.oas.annotations.responses.ApiResponse;
6+
import io.swagger.v3.oas.annotations.tags.Tag;
47
import jakarta.validation.Valid;
58
import jakarta.validation.constraints.Pattern;
69
import lombok.RequiredArgsConstructor;
@@ -23,54 +26,120 @@
2326
@RequiredArgsConstructor
2427
@Validated
2528
@RequestMapping("/api")
29+
@Tag(name = "Betting API", description = "베팅 관련 기능을 제공합니다.")
2630
public class BettingController {
2731

2832
private final BettingService bettingService;
2933

34+
@Operation(
35+
summary = "오늘의 베팅 라운드 조회",
36+
description = """
37+
요청된 범위(`daily` 또는 `weekly`)에 해당하는 현재 활성화된 베팅 라운드를 조회합니다.
38+
라운드가 없을 경우 404(Not Found)를 반환합니다.
39+
40+
내부 로직:
41+
- `Scope` 값(daily/weekly)에 맞는 라운드 중 `status = true`인 것을 반환
42+
- 없으면 `Optional.empty()` 처리
43+
""",
44+
responses = {
45+
@ApiResponse(responseCode = "200", description = "활성 라운드 조회 성공"),
46+
@ApiResponse(responseCode = "404", description = "활성 라운드가 존재하지 않음")
47+
}
48+
)
49+
3050
@GetMapping("/bet-rounds/{scope}")
3151
public ResponseEntity<BetRound> getTodayBetRound(
32-
@PathVariable @Pattern(regexp = "daily|weekly") String scope){
33-
34-
Scope scopeEnum = Scope.valueOf(scope.toUpperCase());
35-
36-
Optional<BetRound> betRound = bettingService.getActiveRound(scopeEnum);
52+
@Parameter(description = "라운드 범위 (Scope): DAILY 또는 WEEKLY", example = "DAILY")
53+
@PathVariable Scope scope
54+
) {
55+
Optional<BetRound> betRound = bettingService.getActiveRound(scope);
3756

3857
return betRound
3958
.map(ResponseEntity::ok)
4059
.orElseGet(() -> ResponseEntity.notFound().build());
4160
}
4261

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

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

5599
UserBet userBet = bettingService.postUserBet(principal.getUserId(), userBetRequest);
56-
57100
return ResponseEntity.ok(userBet);
58101
}
59102

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

65123
bettingService.cancelUserBet(principal.getUserId(), userBetId);
66124
return ResponseEntity.noContent().build();
67125
}
68126

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

142+
List<UserBet> userBets = bettingService.getAllMyBets(principal.getUserId());
74143
return ResponseEntity.ok(userBets);
75144
}
76145
}

backend/src/main/java/org/sejongisc/backend/betting/dto/UserBetRequest.java

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package org.sejongisc.backend.betting.dto;
22

33
import com.fasterxml.jackson.annotation.JsonProperty;
4+
import io.swagger.v3.oas.annotations.media.Schema;
45
import jakarta.validation.constraints.AssertTrue;
56
import jakarta.validation.constraints.Min;
67
import jakarta.validation.constraints.NotNull;
@@ -13,20 +14,28 @@
1314
import java.util.UUID;
1415

1516
@Getter
16-
@Builder @AllArgsConstructor @NoArgsConstructor
17+
@Builder
18+
@AllArgsConstructor
19+
@NoArgsConstructor
20+
@Schema(description = "유저 베팅 요청 DTO")
1721
public class UserBetRequest {
1822

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

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

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

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

38+
@Schema(description = "포인트 유효성 검증용 필드 (내부 검증 전용, 클라이언트가 직접 전송할 필요 없음)", hidden = true)
3039
@AssertTrue(message = "베팅 시 포인트는 10 이상이어야 합니다.")
3140
public boolean isStakePointsValid() {
3241
return free || (stakePoints != null && stakePoints >= 10);

backend/src/main/java/org/sejongisc/backend/betting/entity/BetRound.java

Lines changed: 23 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
package org.sejongisc.backend.betting.entity;
22

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

2527
@Enumerated(EnumType.STRING)
2628
@Column(nullable = false)
29+
@Schema(description = "라운드 단위")
2730
private Scope scope;
2831

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

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

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

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

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

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

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

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

5061
@Enumerated(EnumType.STRING)
5162
@Column(nullable = true)
63+
@Schema(description = "최종 결과")
5264
private BetOption resultOption;
5365

5466
@Enumerated(EnumType.STRING)
5567
@Column(nullable = false)
68+
@Schema(description = "시장 구분")
5669
private MarketType market;
5770

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

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

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

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

89+
// 베팅 시작
7290
public void open() {
7391
this.status = true;
7492
}
7593

94+
// 베팅 불가
7695
public void close() {
7796
this.status = false;
7897
}
7998

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

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

122+
// 결과 판정 로직 - 이전 종가와 비교하여 상승/하락 결정
101123
private BetOption determineResult(BigDecimal finalPrice) {
102124
int compare = finalPrice.compareTo(previousClosePrice);
103-
104125
if (compare >= 0) return BetOption.RISE;
105126
return BetOption.FALL;
106127
}
107-
108128
}

backend/src/main/java/org/sejongisc/backend/betting/entity/Scope.java

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
11
package org.sejongisc.backend.betting.entity;
22

3+
import com.fasterxml.jackson.annotation.JsonCreator;
4+
import com.fasterxml.jackson.annotation.JsonValue;
5+
6+
import java.time.DayOfWeek;
37
import java.time.LocalDateTime;
48

59
public enum Scope {
@@ -16,16 +20,27 @@ public LocalDateTime getLockAt(LocalDateTime base) {
1620
WEEKLY {
1721
@Override
1822
public LocalDateTime getOpenAt(LocalDateTime base) {
19-
return base.with(java.time.DayOfWeek.MONDAY)
23+
return base.with(DayOfWeek.MONDAY)
2024
.withHour(9).withMinute(0).withSecond(0).withNano(0);
2125
}
2226
@Override
2327
public LocalDateTime getLockAt(LocalDateTime base) {
24-
return base.with(java.time.DayOfWeek.FRIDAY)
28+
return base.with(DayOfWeek.FRIDAY)
2529
.withHour(22).withMinute(0).withSecond(0).withNano(0);
2630
}
2731
};
2832

2933
public abstract LocalDateTime getOpenAt(LocalDateTime base);
3034
public abstract LocalDateTime getLockAt(LocalDateTime base);
35+
36+
@JsonCreator
37+
public static Scope from(String value) {
38+
if (value == null) return null;
39+
return Scope.valueOf(value.toUpperCase());
40+
}
41+
42+
@JsonValue
43+
public String toValue() {
44+
return this.name();
45+
}
3146
}

0 commit comments

Comments
 (0)