diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index 671eecb..6472a20 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -6,4 +6,4 @@ - 어떤 작업을 했는지 요약해서 적어주세요. - ex) Spring Boot Security 설정 변경 # 📸 결과 캡쳐화면 -- 실행한 결과를 캡쳐하여 올려주세요. \ No newline at end of file +- 실행한 결과를 캡쳐하여 올려주세요. / 확인 \ No newline at end of file diff --git a/build.gradle b/build.gradle index 58d3f03..26ff18c 100644 --- a/build.gradle +++ b/build.gradle @@ -18,6 +18,12 @@ repositories { mavenCentral() } +dependencyManagement { + imports { + mavenBom "org.springframework.cloud:spring-cloud-dependencies:2025.0.0" + } +} + dependencies { implementation 'org.springframework.boot:spring-boot-starter-web' implementation 'org.springframework.boot:spring-boot-starter-data-jpa' @@ -38,6 +44,16 @@ dependencies { // Redis implementation 'org.springframework.boot:spring-boot-starter-data-redis' + // FeignClient + implementation 'org.springframework.cloud:spring-cloud-starter-openfeign' + + // Excel + implementation 'org.apache.poi:poi-ooxml:5.5.1' + + // AWS S3 + implementation 'software.amazon.awssdk:s3:2.25.26' + implementation 'software.amazon.awssdk:url-connection-client:2.25.26' + runtimeOnly 'com.mysql:mysql-connector-j' compileOnly 'org.projectlombok:lombok' diff --git a/src/main/java/com/example/demo/DemoApplication.java b/src/main/java/com/example/demo/DemoApplication.java index 094d95b..9b3cff2 100644 --- a/src/main/java/com/example/demo/DemoApplication.java +++ b/src/main/java/com/example/demo/DemoApplication.java @@ -2,8 +2,12 @@ import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.cloud.openfeign.EnableFeignClients; +import org.springframework.scheduling.annotation.EnableScheduling; @SpringBootApplication +@EnableFeignClients +@EnableScheduling public class DemoApplication { public static void main(String[] args) { diff --git a/src/main/java/com/example/demo/api/favoriteStock/dto/FavoriteStockRequestDto.java b/src/main/java/com/example/demo/api/favoriteStock/dto/FavoriteStockRequestDto.java index 662cb16..ecb5b8e 100644 --- a/src/main/java/com/example/demo/api/favoriteStock/dto/FavoriteStockRequestDto.java +++ b/src/main/java/com/example/demo/api/favoriteStock/dto/FavoriteStockRequestDto.java @@ -7,6 +7,5 @@ @Builder public class FavoriteStockRequestDto { private String stockCode; - private String ticker; - private Integer days; + private String stockName; } diff --git a/src/main/java/com/example/demo/api/favoriteStock/dto/FavoriteStockResponseDto.java b/src/main/java/com/example/demo/api/favoriteStock/dto/FavoriteStockResponseDto.java index 1222ff0..b9ce8c0 100644 --- a/src/main/java/com/example/demo/api/favoriteStock/dto/FavoriteStockResponseDto.java +++ b/src/main/java/com/example/demo/api/favoriteStock/dto/FavoriteStockResponseDto.java @@ -17,7 +17,7 @@ public static class FavoriteStockDto { private Long favoriteStockId; private Long userId; private String stockCode; - private String ticker; + private String stockName; private Boolean isAlertEnabled; } diff --git a/src/main/java/com/example/demo/api/favoriteStock/mapper/FavoriteStockConverter.java b/src/main/java/com/example/demo/api/favoriteStock/mapper/FavoriteStockConverter.java index d95e75a..e36206c 100644 --- a/src/main/java/com/example/demo/api/favoriteStock/mapper/FavoriteStockConverter.java +++ b/src/main/java/com/example/demo/api/favoriteStock/mapper/FavoriteStockConverter.java @@ -14,8 +14,7 @@ public static FavoriteStock toFavoriteStock(User user, FavoriteStockRequestDto r return FavoriteStock.builder() .user(user) .stockCode(request.getStockCode()) - .ticker(request.getTicker()) - .days(request.getDays()) + .stockName(request.getStockName()) .isAlertEnabled(false) .build(); } @@ -25,7 +24,7 @@ public static FavoriteStockDto toFavoriteStockDto(FavoriteStock favoriteStock) { .favoriteStockId(favoriteStock.getId()) .userId(favoriteStock.getUser().getId()) .stockCode(favoriteStock.getStockCode()) - .ticker(favoriteStock.getTicker()) + .stockName(favoriteStock.getStockName()) .isAlertEnabled(favoriteStock.getIsAlertEnabled()) .build(); } diff --git a/src/main/java/com/example/demo/api/kis/client/KisFeignClient.java b/src/main/java/com/example/demo/api/kis/client/KisFeignClient.java new file mode 100644 index 0000000..2eead9c --- /dev/null +++ b/src/main/java/com/example/demo/api/kis/client/KisFeignClient.java @@ -0,0 +1,32 @@ +package com.example.demo.api.kis.client; + +import com.example.demo.api.kis.dto.KisResponseDto; +import com.example.demo.api.kis.dto.KisTokenRequestDto; +import com.example.demo.api.kis.dto.KisTokenResponseDto; +import org.springframework.cloud.openfeign.FeignClient; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestHeader; +import org.springframework.web.bind.annotation.RequestParam; + +@FeignClient(name = "kisClient", url = "${kis.base-url}") +public interface KisFeignClient { + + @PostMapping("/oauth2/tokenP") + KisTokenResponseDto issueToken(@RequestBody KisTokenRequestDto request); + + @GetMapping("/uapi/domestic-stock/v1/quotations/inquire-daily-itemchartprice") + KisResponseDto getDailyStockPrice( + @RequestHeader("authorization") String authorization, + @RequestHeader("appkey") String appKey, + @RequestHeader("appsecret") String appSecret, + @RequestHeader("tr_id") String trId, + @RequestParam("FID_COND_MRKT_DIV_CODE") String fidCondMrktDivCode, + @RequestParam("FID_INPUT_ISCD") String fidInputIscd, + @RequestParam("FID_INPUT_DATE_1") String fidInputDate1, + @RequestParam("FID_INPUT_DATE_2") String fidInputDate2, + @RequestParam("FID_PERIOD_DIV_CODE") String fidPeriodDivCode, + @RequestParam("FID_ORG_ADJ_PRC") String fidOrgAdjPrc + ); +} \ No newline at end of file diff --git a/src/main/java/com/example/demo/api/kis/dto/KisResponseDto.java b/src/main/java/com/example/demo/api/kis/dto/KisResponseDto.java new file mode 100644 index 0000000..fcde416 --- /dev/null +++ b/src/main/java/com/example/demo/api/kis/dto/KisResponseDto.java @@ -0,0 +1,34 @@ +package com.example.demo.api.kis.dto; + +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.util.List; + +@Getter +@NoArgsConstructor +public class KisResponseDto { + + @JsonProperty("rt_cd") + private String rtCd; + + @JsonProperty("msg1") + private String msg1; + + @JsonProperty("output2") + private List output2; + + @Getter + @NoArgsConstructor + public static class DailyData { + @JsonProperty("stck_bsop_date") + private String date; + + @JsonProperty("stck_oprc") + private String openPrice; + + @JsonProperty("stck_clpr") + private String closePrice; + } +} diff --git a/src/main/java/com/example/demo/api/kis/dto/KisTokenRequestDto.java b/src/main/java/com/example/demo/api/kis/dto/KisTokenRequestDto.java new file mode 100644 index 0000000..950ec54 --- /dev/null +++ b/src/main/java/com/example/demo/api/kis/dto/KisTokenRequestDto.java @@ -0,0 +1,19 @@ +package com.example.demo.api.kis.dto; + +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +public class KisTokenRequestDto { + + @JsonProperty("grant_type") + private String grantType; + + @JsonProperty("appkey") + private String appKey; + + @JsonProperty("appsecret") + private String appSecret; +} \ No newline at end of file diff --git a/src/main/java/com/example/demo/api/kis/dto/KisTokenResponseDto.java b/src/main/java/com/example/demo/api/kis/dto/KisTokenResponseDto.java new file mode 100644 index 0000000..b2f8f4f --- /dev/null +++ b/src/main/java/com/example/demo/api/kis/dto/KisTokenResponseDto.java @@ -0,0 +1,22 @@ +package com.example.demo.api.kis.dto; + +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor +public class KisTokenResponseDto { + + @JsonProperty("access_token") + private String accessToken; + + @JsonProperty("token_type") + private String tokenType; + + @JsonProperty("expires_in") + private Long expiresIn; + + @JsonProperty("access_token_token_expired") + private String accessTokenTokenExpired; +} diff --git a/src/main/java/com/example/demo/api/kis/service/KisService.java b/src/main/java/com/example/demo/api/kis/service/KisService.java new file mode 100644 index 0000000..372af9f --- /dev/null +++ b/src/main/java/com/example/demo/api/kis/service/KisService.java @@ -0,0 +1,57 @@ +package com.example.demo.api.kis.service; + +import com.example.demo.api.kis.client.KisFeignClient; +import com.example.demo.api.kis.dto.KisResponseDto; +import com.example.demo.domain.stock.exception.StockHandler; +import feign.FeignException; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; + +import java.util.Objects; + +@Slf4j +@Service +@RequiredArgsConstructor +public class KisService { + + private final KisFeignClient kisFeignClient; + private final KisTokenService kisTokenService; + + @Value("${kis.app-key}") + private String appKey; + + @Value("${kis.app-secret}") + private String appSecret; + + public KisResponseDto getDailyStockPrice(String stockCode, String startDate, String endDate) { + String token = kisTokenService.getAccessToken(); + + try { + KisResponseDto response = kisFeignClient.getDailyStockPrice( + "Bearer " + token, + appKey, + appSecret, + "FHKST03010100", + "J", + stockCode, + startDate, + endDate, + "D", + "0" + ); + + if (response == null || !"0".equals(response.getRtCd())) { + log.error("KIS API 응답 오류. rt_cd={}, msg={}", Objects.requireNonNull(response).getRtCd(), response.getMsg1()); + throw StockHandler.kisApiError(); + } + + return response; + + } catch (FeignException e) { + log.error("KIS API 호출 실패. status={}, stockCode={}", e.status(), stockCode); + throw StockHandler.kisApiError(); + } + } +} \ No newline at end of file diff --git a/src/main/java/com/example/demo/api/kis/service/KisTokenService.java b/src/main/java/com/example/demo/api/kis/service/KisTokenService.java new file mode 100644 index 0000000..289cec3 --- /dev/null +++ b/src/main/java/com/example/demo/api/kis/service/KisTokenService.java @@ -0,0 +1,76 @@ +package com.example.demo.api.kis.service; + +import com.example.demo.api.kis.client.KisFeignClient; +import com.example.demo.api.kis.dto.KisTokenRequestDto; +import com.example.demo.api.kis.dto.KisTokenResponseDto; +import com.example.demo.common.service.RedisService; +import com.example.demo.common.util.RedisUtil; +import com.example.demo.domain.stock.exception.StockHandler; +import feign.FeignException; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; + +import java.time.Duration; + +import static com.example.demo.common.consts.StaticVariable.GRANT_TYPE; +import static com.example.demo.common.consts.StaticVariable.TOKEN_KEY; + +@Slf4j +@Service +@RequiredArgsConstructor +public class KisTokenService { + + private final KisFeignClient kisFeignClient; + private final RedisService redisService; + private final RedisUtil redisUtil; + + @Value("${kis.app-key}") + private String appKey; + + @Value("${kis.app-secret}") + private String appSecret; + + public String getAccessToken() { + log.info("[KIS 토큰] 1. Redis 캐시 조회 시작. key={}", TOKEN_KEY); + String cached = redisService.getValue(TOKEN_KEY); + + if (cached != null && !cached.isBlank()) { + log.info("[KIS 토큰] 2. 캐시 히트 - 저장된 토큰 반환"); + return cached; + } + log.info("[KIS 토큰] 2. 캐시 미스 - 신규 발급 시작"); + + log.info("[KIS 토큰] 3. KIS API 토큰 발급 요청. appKey={}", appKey); + try { + KisTokenResponseDto tokenResponse = kisFeignClient.issueToken( + new KisTokenRequestDto(GRANT_TYPE, appKey, appSecret) + ); + log.info("[KIS 토큰] 4. KIS API 응답 수신. response={}", tokenResponse); + + if (tokenResponse == null || tokenResponse.getAccessToken() == null + || tokenResponse.getAccessToken().isBlank()) { + log.error("[KIS 토큰] 5. 응답 토큰 값 없음 (null 또는 blank)"); + throw StockHandler.kisApiError(); + } + log.info("[KIS 토큰] 5. 응답 토큰 정상 확인. expiredAt={}", tokenResponse.getAccessTokenTokenExpired()); + + String token = tokenResponse.getAccessToken(); + + log.info("[KIS 토큰] 6. TTL 계산 시작. expiredAt={}", tokenResponse.getAccessTokenTokenExpired()); + Duration ttl = redisUtil.calculateTtl(tokenResponse.getAccessTokenTokenExpired()); + log.info("[KIS 토큰] 7. TTL 계산 완료. ttl={}s", ttl.getSeconds()); + + log.info("[KIS 토큰] 8. Redis 저장 시작. key={}, ttl={}s", TOKEN_KEY, ttl.getSeconds()); + redisService.setKisTokenExpiresValueWithTtl(TOKEN_KEY, token, ttl); + log.info("[KIS 토큰] 9. Redis 저장 완료"); + + return token; + + } catch (FeignException e) { + log.error("[KIS 토큰] FeignException 발생. status={}, message={}", e.status(), e.getMessage()); + throw StockHandler.kisApiError(); + } + } +} \ No newline at end of file diff --git a/src/main/java/com/example/demo/api/prediction/controller/PredictionController.java b/src/main/java/com/example/demo/api/prediction/controller/PredictionController.java new file mode 100644 index 0000000..fb583ee --- /dev/null +++ b/src/main/java/com/example/demo/api/prediction/controller/PredictionController.java @@ -0,0 +1,65 @@ +package com.example.demo.api.prediction.controller; + +import com.example.demo.api.common.dto.ApiResponseDto; +import com.example.demo.api.prediction.dto.PredictionRequestDto.CreatePrediction; +import com.example.demo.api.prediction.dto.PredictionRequestDto.GradePrediction; +import com.example.demo.api.prediction.dto.PredictionResponseDto.CreatePredictionResult; +import com.example.demo.api.prediction.dto.PredictionResponseDto.PredictionResult; +import com.example.demo.api.prediction.scheduler.PredictionGradingScheduler; +import com.example.demo.api.prediction.service.PredictionUseCase; +import com.example.demo.common.annotation.AuthUser; +import com.example.demo.domain.user.entity.User; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; + +@Tag(name = "[예측] 주가 예측 게임 API.") +@Validated +@RestController +@RequestMapping("/api/v1/predictions") +@RequiredArgsConstructor +public class PredictionController { + + private final PredictionUseCase predictionUseCase; + private final PredictionGradingScheduler predictionGradingScheduler; + + @Operation(summary = "주가 예측 제출", + description = "종목/예측기간/상승폭을 받아 예측을 등록하고 획득 가능 포인트를 반환합니다. (결과는 만기 후 채점)") + @PostMapping + public ApiResponseDto createPrediction( + @AuthUser User user, + @Valid @RequestBody CreatePrediction request) { + return ApiResponseDto.onSuccess(predictionUseCase.createPrediction(user, request)); + } + + @Operation(summary = "예측 결과 조회", + description = "예측 결과(적중/실패/대기)와 실제 등락률, 원인을 반환합니다.") + @GetMapping("/{predictionId}/result") + public ApiResponseDto getResult( + @AuthUser User user, + @PathVariable Long predictionId) { + return ApiResponseDto.onSuccess(predictionUseCase.getResult(user, predictionId)); + } + + @Operation(summary = "[데모/테스트] 수동 채점", + description = "만기 종가를 직접 넣어 즉시 채점합니다. 실서비스는 스케줄러가 자동 처리하므로 데모용입니다.") + @PostMapping("/{predictionId}/grade") + public ApiResponseDto gradeManually( + @AuthUser User user, + @PathVariable Long predictionId, + @Valid @RequestBody GradePrediction request) { + return ApiResponseDto.onSuccess( + predictionUseCase.gradeManually(user, predictionId, request.getMaturityPrice())); + } + + @Operation(summary = "[데모/테스트] 스케줄러 수동 실행", + description = "만기 도래한 PENDING 예측을 즉시 일괄 채점합니다. 실서비스에서는 평일 16:30 자동 실행됩니다.") + @PostMapping("/scheduler/run") + public ApiResponseDto runScheduler() { + predictionGradingScheduler.gradeMaturedPredictions(); + return ApiResponseDto.onSuccess("스케줄러 실행 완료"); + } +} diff --git a/src/main/java/com/example/demo/api/prediction/dto/PredictionRequestDto.java b/src/main/java/com/example/demo/api/prediction/dto/PredictionRequestDto.java new file mode 100644 index 0000000..9438b4b --- /dev/null +++ b/src/main/java/com/example/demo/api/prediction/dto/PredictionRequestDto.java @@ -0,0 +1,37 @@ +package com.example.demo.api.prediction.dto; + +import com.example.demo.domain.prediction.entity.PredictionDuration; +import com.example.demo.domain.prediction.entity.PredictionTarget; +import jakarta.validation.constraints.NotNull; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.math.BigDecimal; + +public class PredictionRequestDto { + + @Getter + @NoArgsConstructor + public static class CreatePrediction { + + @NotNull(message = "종목코드는 필수입니다.") + private String stockCode; + + @NotNull(message = "예측 기간은 필수입니다.") + private PredictionDuration duration; + + @NotNull(message = "예측 상승폭은 필수입니다.") + private PredictionTarget target; + } + + /** + * 데모/테스트용 수동 채점 요청. 실서비스는 스케줄러가 처리하므로 이 값 불필요. + */ + @Getter + @NoArgsConstructor + public static class GradePrediction { + + @NotNull(message = "만기 종가는 필수입니다.") + private BigDecimal maturityPrice; + } +} \ No newline at end of file diff --git a/src/main/java/com/example/demo/api/prediction/dto/PredictionResponseDto.java b/src/main/java/com/example/demo/api/prediction/dto/PredictionResponseDto.java new file mode 100644 index 0000000..ab82a4b --- /dev/null +++ b/src/main/java/com/example/demo/api/prediction/dto/PredictionResponseDto.java @@ -0,0 +1,54 @@ +package com.example.demo.api.prediction.dto; + +import com.example.demo.domain.prediction.entity.PredictionStatus; +import lombok.Builder; +import lombok.Getter; + +import java.math.BigDecimal; +import java.util.List; + +public class PredictionResponseDto { + + /** 예측 화면 진입 - 종목 정보 (Image 1 상단 카드) */ + @Getter + @Builder + public static class PredictionInfo { + private String stockCode; + private String stockName; + private BigDecimal currentPrice; // 현재가 + private BigDecimal changeAmount; // 전일 대비 상승분 + private BigDecimal changeRate; // 상승률(%) + } + + /** 예측 제출 완료 응답 (아직 결과 아님) */ + @Getter + @Builder + public static class CreatePredictionResult { + private Long predictionId; + private Integer possiblePoint; // 획득 가능 포인트 + private PredictionStatus status; // PENDING + } + + /** 예측 결과 조회 (Image 2 / Image 3) */ + @Getter + @Builder + public static class PredictionResult { + private Long predictionId; + private PredictionStatus status; // PENDING / CORRECT / WRONG + private String myPredictionText; // "3%미만 상승" + private BigDecimal actualRate; // 실제 등락률(%). PENDING이면 null + private String actualRateText; // "2.6% 상승" 형태. PENDING이면 null + private Integer earnedPoint; // 지급 포인트. PENDING이면 null + private String title; // "예측 적중!" / "예측이 빗나갔어요..." + private String subtitle; + private String reasonHeader; // "왜 올랐을까?" / "왜 떨어졌을까?" + private List reasons; // 더미 데이터 (2-C) + } + + @Getter + @Builder + public static class Reason { + private String title; + private String content; + } +} diff --git a/src/main/java/com/example/demo/api/prediction/mapper/PredictionConverter.java b/src/main/java/com/example/demo/api/prediction/mapper/PredictionConverter.java new file mode 100644 index 0000000..2e35299 --- /dev/null +++ b/src/main/java/com/example/demo/api/prediction/mapper/PredictionConverter.java @@ -0,0 +1,122 @@ +package com.example.demo.api.prediction.mapper; + +import com.example.demo.api.prediction.dto.PredictionResponseDto.*; +import com.example.demo.domain.prediction.entity.Prediction; +import com.example.demo.domain.prediction.entity.PredictionDuration; +import com.example.demo.domain.prediction.entity.PredictionStatus; +import com.example.demo.domain.prediction.entity.PredictionTarget; +import com.example.demo.domain.stock.entity.Stock; +import com.example.demo.domain.user.entity.User; + +import java.math.BigDecimal; +import java.math.RoundingMode; +import java.time.DayOfWeek; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.LocalTime; +import java.util.List; + +public class PredictionConverter { + + private static final int BASE_POINT = 1; + private static final LocalTime MARKET_CLOSE = LocalTime.of(15, 30); + + /** + * 제출일 기준 N 캘린더 일 뒤 15:30을 만기로 설정. + * 만기일이 토요일이면 -1일(금), 일요일이면 -2일(금)로 당김. + */ + private static LocalDateTime calcMaturityAt(int days) { + LocalDate date = LocalDate.now().plusDays(days); + if (date.getDayOfWeek() == DayOfWeek.SATURDAY) { + date = date.minusDays(1); + } else if (date.getDayOfWeek() == DayOfWeek.SUNDAY) { + date = date.minusDays(2); + } + return date.atTime(MARKET_CLOSE); + } + + /** 포인트 = 기본점수 × duration 가중치 × target 가중치 (문구: 목표치↑, duration↓ 일수록 高) */ + public static int calcPossiblePoint(PredictionDuration duration, PredictionTarget target) { + return BASE_POINT * duration.getWeight() * target.getWeight(); + } + + /** 제출 시 Prediction 엔티티 생성 */ + public static Prediction toPrediction(User user, Stock stock, PredictionDuration duration, + PredictionTarget target, BigDecimal basePrice) { + int possiblePoint = calcPossiblePoint(duration, target); + return Prediction.builder() + .user(user) + .stock(stock) + .duration(duration) + .target(target) + .status(PredictionStatus.PENDING) + .possiblePoint(possiblePoint) + .basePrice(basePrice) + .maturityAt(calcMaturityAt(duration.getDays())) + .build(); + } + + public static CreatePredictionResult toCreateResult(Prediction p) { + return CreatePredictionResult.builder() + .predictionId(p.getId()) + .possiblePoint(p.getPossiblePoint()) + .status(p.getStatus()) + .build(); + } + + public static PredictionResult toResult(Prediction p) { + boolean pending = p.isPending(); + boolean correct = p.getStatus() == PredictionStatus.CORRECT; + boolean rose = !pending && p.getActualRate() != null + && p.getActualRate().signum() > 0; + + String title; + String subtitle; + if (pending) { + title = "채점 대기중"; + subtitle = "만기가 도래하면 결과를 확인할 수 있어요!"; + } else if (correct) { + title = "예측 적중!"; + subtitle = "시장의 흐름을 완벽하게 읽었어요!"; + } else { + title = "예측이 빗나갔어요..."; + subtitle = "시장의 흐름을 다시 한번 살펴보세요!"; + } + + return PredictionResult.builder() + .predictionId(p.getId()) + .status(p.getStatus()) + .myPredictionText(p.getTarget().getDescription()) + .actualRate(p.getActualRate()) + .actualRateText(pending ? null : toChangeRateText(p.getActualRate())) + .earnedPoint(p.getEarnedPoint()) + .title(title) + .subtitle(subtitle) + .reasonHeader(pending ? null : (rose ? "왜 올랐을까?" : "왜 떨어졌을까?")) + .reasons(pending ? List.of() : dummyReasons()) + .build(); + } + + private static String toChangeRateText(BigDecimal actualRate) { + if (actualRate == null) return null; + String abs = actualRate.abs().setScale(1, RoundingMode.HALF_UP).toPlainString() + "%"; + int sign = actualRate.signum(); + if (sign > 0) return abs + " 상승"; + if (sign < 0) return abs + " 하락"; + return "변동 없음"; + } + + // 2-C: 더미 원인 데이터 + private static List dummyReasons() { + return List.of( + Reason.builder() + .title("AI 메모리 분야 역대 최고 매출 기록") + .content("HBM3 등 고부가 가치 제품의 수요급증으로 반도체 부문 영업이익이 크게 개선되었습니다.") + .build(), + Reason.builder() + .title("파운드리 수주 확대 및 수율 안정화") + .content("글로벌 빅테크 기업들의 선단 공정 수주가 늘어나며 미래 성장 동력을 확보했습니다.") + .build() + ); + } +} diff --git a/src/main/java/com/example/demo/api/prediction/scheduler/PredictionGradingScheduler.java b/src/main/java/com/example/demo/api/prediction/scheduler/PredictionGradingScheduler.java new file mode 100644 index 0000000..43fc27c --- /dev/null +++ b/src/main/java/com/example/demo/api/prediction/scheduler/PredictionGradingScheduler.java @@ -0,0 +1,70 @@ +package com.example.demo.api.prediction.scheduler; + +import com.example.demo.api.prediction.service.KisPricePort; +import com.example.demo.api.prediction.service.UserPointPort; +import com.example.demo.domain.prediction.entity.Prediction; +import com.example.demo.domain.prediction.service.PredictionCommandService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +import java.math.BigDecimal; +import java.util.List; + +/** + * 만기 도래 예측을 자동으로 채점하는 스케줄러. + * 평일 장 마감 후 16:30(KST)에 실행. + * KIS API가 연동되면 KisPricePort.getClosePriceAt()이 실제 종가를 반환합니다. + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class PredictionGradingScheduler { + + private final PredictionCommandService predictionCommandService; + private final KisPricePort kisPricePort; + private final UserPointPort userPointPort; + + /** + * 평일 16:30 실행 (KST = UTC+9, 서버가 UTC라면 "0 30 7 * * MON-FRI"). + * 서버 TimeZone이 Asia/Seoul이면 "0 30 16 * * MON-FRI" 사용. + */ + @Scheduled(cron = "0 30 16 * * MON-FRI", zone = "Asia/Seoul") + @Transactional + public void gradeMaturedPredictions() { + List targets = predictionCommandService.findMaturedPendings(); + if (targets.isEmpty()) { + log.info("[PredictionScheduler] 채점 대상 없음."); + return; + } + + log.info("[PredictionScheduler] 채점 시작. 대상 수={}", targets.size()); + int successCount = 0; + int failCount = 0; + + for (Prediction prediction : targets) { + try { + String stockCode = prediction.getStock().getStockCode(); + BigDecimal maturityPrice = kisPricePort.getClosePriceAt( + stockCode, prediction.getMaturityAt().toLocalDate()); + + int earned = predictionCommandService.grade(prediction, maturityPrice); + if (earned > 0) { + userPointPort.addPoint(prediction.getUser(), earned); + } + successCount++; + log.debug("[PredictionScheduler] 채점 완료. predictionId={}, status={}, earnedPoint={}", + prediction.getId(), prediction.getStatus(), earned); + + } catch (Exception e) { + failCount++; + log.error("[PredictionScheduler] 채점 실패. predictionId={}, error={}", + prediction.getId(), e.getMessage(), e); + } + } + + log.info("[PredictionScheduler] 채점 완료. 성공={}, 실패={}", successCount, failCount); + } +} \ No newline at end of file diff --git a/src/main/java/com/example/demo/api/prediction/service/KisPricePort.java b/src/main/java/com/example/demo/api/prediction/service/KisPricePort.java new file mode 100644 index 0000000..78aa172 --- /dev/null +++ b/src/main/java/com/example/demo/api/prediction/service/KisPricePort.java @@ -0,0 +1,56 @@ +package com.example.demo.api.prediction.service; + +import com.example.demo.api.kis.dto.KisResponseDto; +import com.example.demo.api.kis.service.KisService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.time.format.DateTimeFormatter; +import java.util.List; + +/** + * KIS 가격 조회 어댑터. + * KIS 연동이 아직 403이라, getCurrentPrice는 고정값을 반환 중. + * getClosePriceAt은 KIS 일봉 API를 실제 호출하되, 실패 시 고정값으로 fallback. + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class KisPricePort { + + private static final DateTimeFormatter KIS_DATE_FORMAT = DateTimeFormatter.ofPattern("yyyyMMdd"); + private static final BigDecimal FALLBACK_PRICE = new BigDecimal("218000"); + + private final KisService kisService; + + /** 현재가 조회 (예측 제출 시점용). KIS 연동 전까지 고정값 반환. */ + public BigDecimal getCurrentPrice(String stockCode) { + // TODO: KIS 현재가 API 연동 후 실제 호출로 교체 + return FALLBACK_PRICE; + } + + /** + * 특정 날짜의 종가 조회 (스케줄러 채점용). + * KIS 일봉 API 호출 → output2[0].closePrice 사용. + * 실패 시 fallback 가격 반환 후 로그 출력. + */ + public BigDecimal getClosePriceAt(String stockCode, LocalDate date) { + String dateStr = date.format(KIS_DATE_FORMAT); + try { + KisResponseDto response = kisService.getDailyStockPrice(stockCode, dateStr, dateStr); + List output = response.getOutput2(); + if (output != null && !output.isEmpty()) { + String closePrice = output.get(0).getClosePrice(); + return new BigDecimal(closePrice); + } + log.warn("KIS 일봉 데이터 없음. stockCode={}, date={}", stockCode, dateStr); + } catch (Exception e) { + log.warn("KIS 종가 조회 실패 - fallback 사용. stockCode={}, date={}, error={}", + stockCode, dateStr, e.getMessage()); + } + return FALLBACK_PRICE; + } +} diff --git a/src/main/java/com/example/demo/api/prediction/service/PredictionUseCase.java b/src/main/java/com/example/demo/api/prediction/service/PredictionUseCase.java new file mode 100644 index 0000000..4afe270 --- /dev/null +++ b/src/main/java/com/example/demo/api/prediction/service/PredictionUseCase.java @@ -0,0 +1,61 @@ +package com.example.demo.api.prediction.service; + +import com.example.demo.api.prediction.dto.PredictionRequestDto.CreatePrediction; +import com.example.demo.api.prediction.dto.PredictionResponseDto.CreatePredictionResult; +import com.example.demo.api.prediction.dto.PredictionResponseDto.PredictionResult; +import com.example.demo.api.prediction.mapper.PredictionConverter; +import com.example.demo.domain.prediction.entity.Prediction; +import com.example.demo.domain.prediction.service.PredictionCommandService; +import com.example.demo.domain.prediction.service.PredictionQueryService; +import com.example.demo.domain.stock.entity.Stock; +import com.example.demo.domain.user.entity.User; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.math.BigDecimal; + +@Service +@RequiredArgsConstructor +@Transactional +public class PredictionUseCase { + + private final PredictionCommandService predictionCommandService; + private final PredictionQueryService predictionQueryService; + + // 기존 서비스에 붙이는 얇은 어댑터 3개 + private final StockQueryPort stockQueryPort; + private final KisPricePort kisPricePort; + private final UserPointPort userPointPort; + + /** 예측 제출: 기준가 확보 → 저장 → 획득가능 포인트 응답 */ + public CreatePredictionResult createPrediction(User user, CreatePrediction req) { + Stock stock = stockQueryPort.getByStockCode(req.getStockCode()); + + // 제출 시점 기준가 (KIS 현재가). KIS 막혀있으면 KisPricePort에서 고정값 반환 중. + BigDecimal basePrice = kisPricePort.getCurrentPrice(req.getStockCode()); + + Prediction prediction = PredictionConverter.toPrediction( + user, stock, req.getDuration(), req.getTarget(), basePrice); + + Prediction saved = predictionCommandService.save(prediction); + return PredictionConverter.toCreateResult(saved); + } + + /** 결과 조회 */ + @Transactional(readOnly = true) + public PredictionResult getResult(User user, Long predictionId) { + Prediction prediction = predictionQueryService.getByIdAndUser(predictionId, user.getId()); + return PredictionConverter.toResult(prediction); + } + + /** 데모/테스트용 수동 채점: 종가를 직접 받아 즉시 채점 + 포인트 지급 */ + public PredictionResult gradeManually(User user, Long predictionId, BigDecimal maturityPrice) { + Prediction prediction = predictionQueryService.getByIdAndUser(predictionId, user.getId()); + int earned = predictionCommandService.grade(prediction, maturityPrice); + if (earned > 0) { + userPointPort.addPoint(prediction.getUser(), earned); + } + return PredictionConverter.toResult(prediction); + } +} diff --git a/src/main/java/com/example/demo/api/prediction/service/StockQueryPort.java b/src/main/java/com/example/demo/api/prediction/service/StockQueryPort.java new file mode 100644 index 0000000..668556e --- /dev/null +++ b/src/main/java/com/example/demo/api/prediction/service/StockQueryPort.java @@ -0,0 +1,24 @@ +package com.example.demo.api.prediction.service; + +import com.example.demo.domain.stock.entity.Stock; +import com.example.demo.domain.stock.repository.StockRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +/** + * Stock 조회 어댑터. + * ★ findByStockCode 는 StockRepository의 실제 메서드명에 맞추세요. + * (컨트롤러가 stockCode 를 쓰므로 컬럼/필드명 stockCode 로 가정) + * 이미 stock 도메인에 QueryService가 있으면 이 파일 지우고 그걸 UseCase에 직접 주입해도 됩니다. + */ +@Component +@RequiredArgsConstructor +public class StockQueryPort { + + private final StockRepository stockRepository; + + public Stock getByStockCode(String stockCode) { + return stockRepository.findByStockCode(stockCode) + .orElseThrow(() -> new IllegalArgumentException("stock을 찾지 못 했습니다: " + stockCode)); + } +} diff --git a/src/main/java/com/example/demo/api/prediction/service/UserPointPort.java b/src/main/java/com/example/demo/api/prediction/service/UserPointPort.java new file mode 100644 index 0000000..1e5e467 --- /dev/null +++ b/src/main/java/com/example/demo/api/prediction/service/UserPointPort.java @@ -0,0 +1,31 @@ +package com.example.demo.api.prediction.service; + +import com.example.demo.domain.user.entity.User; +import com.example.demo.domain.user.repository.UserRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +/** + * User 포인트 지급 어댑터 (3-A). + * ★ 전제: User 엔티티에 아래가 추가되어 있어야 함 + * private Integer point; + * public void addPoint(int amount) { this.point = (point == null ? 0 : point) + amount; } + */ +@Component +@RequiredArgsConstructor +public class UserPointPort { + + private final UserRepository userRepository; + + @Transactional(readOnly = true) + public User getUser(Long userId) { + return userRepository.findById(userId) + .orElseThrow(() -> new IllegalArgumentException("user를 찾지 못 했습니다: " + userId)); + } + + @Transactional + public void addPoint(User user, int amount) { + user.addPoint(amount); // dirty checking 으로 반영 + } +} diff --git a/src/main/java/com/example/demo/api/stock/controller/StockController.java b/src/main/java/com/example/demo/api/stock/controller/StockController.java new file mode 100644 index 0000000..d3b30ec --- /dev/null +++ b/src/main/java/com/example/demo/api/stock/controller/StockController.java @@ -0,0 +1,64 @@ +package com.example.demo.api.stock.controller; + +import com.example.demo.api.common.dto.ApiResponseDto; +import com.example.demo.api.stock.dto.StockResponseDto.ImportStockResponse; +import com.example.demo.api.stock.dto.StockResponseDto.StockPageResponse; +import com.example.demo.api.stock.dto.StockResponseDto.StockPriceResponse; +import com.example.demo.api.stock.dto.StockResponseDto.UploadExcelResponse; +import com.example.demo.api.stock.mapper.StockConverter; +import com.example.demo.api.stock.service.StockUseCase; +import com.example.demo.domain.stock.entity.Stock; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.constraints.Pattern; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.multipart.MultipartFile; + + +@Tag(name = "[주식] 주식 종목 API.") +@Validated +@RestController +@RequestMapping("/api/v1/stocks") +@RequiredArgsConstructor +public class StockController { + + private final StockUseCase stockUseCase; + + @Operation(summary = "코스피200 엑셀 파일 S3 업로드", + description = "로컬에서 다운로드한 KRX 코스피200 엑셀 파일을 S3에 업로드합니다.") + @PostMapping(value = "/excel/upload", consumes = "multipart/form-data") + public ApiResponseDto uploadStockExcel(@RequestPart("file") MultipartFile file) { + return ApiResponseDto.onSuccess(stockUseCase.uploadStockExcel(file)); + } + + @Operation(summary = "S3 엑셀 파일 DB 저장", + description = "S3에 업로드된 코스피200 엑셀 파일을 읽어 종목 정보를 DB에 저장합니다.") + @PostMapping("/excel/import") + public ApiResponseDto importStockFromS3() { + return ApiResponseDto.onSuccess(stockUseCase.importStockFromS3()); + } + + @Operation(summary = "전일(가장 최근 일자) 시가/종가 조회", + description = "종목코드로 전날의(가장 최근 날짜) 시가와 종가를 KIS API에서 조회하고 DB에 저장합니다.") + @GetMapping("/{stockCode}/latest-price") + public ApiResponseDto getLatestStockPrice( + @PathVariable @Pattern(regexp = "\\d{6}", message = "종목코드는 6자리 숫자입니다.") String stockCode) { + return ApiResponseDto.onSuccess(stockUseCase.getLatestStockPriceByStockCode(stockCode)); + } + + @Operation(summary = "모든 종목 조회", description = "Stock에 저장된 모든 종목의 코드와 이름을 페이지 단위로 조회합니다.") + @GetMapping + public ApiResponseDto getAllStocks( + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "20") int pageSize) { + Pageable pageable = PageRequest.of(page, pageSize, Sort.by(Sort.Order.asc("stockCode"))); + Page stockPage = stockUseCase.getAllStocks(pageable); + return ApiResponseDto.onSuccess(StockConverter.toStockPageResponse(stockPage)); + } +} diff --git a/src/main/java/com/example/demo/api/stock/dto/StockResponseDto.java b/src/main/java/com/example/demo/api/stock/dto/StockResponseDto.java new file mode 100644 index 0000000..5fc5a8c --- /dev/null +++ b/src/main/java/com/example/demo/api/stock/dto/StockResponseDto.java @@ -0,0 +1,64 @@ +package com.example.demo.api.stock.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.util.List; + +public class StockResponseDto { + + @Getter + @Builder + @NoArgsConstructor + @AllArgsConstructor + public static class StockPriceResponse { + private String stockCode; + private String stockName; + private BigDecimal openPrice; + private BigDecimal closePrice; + private LocalDate inquiryDate; + } + + @Getter + @Builder + @NoArgsConstructor + @AllArgsConstructor + public static class UploadExcelResponse { + private String s3Uri; + } + + @Getter + @Builder + @NoArgsConstructor + @AllArgsConstructor + public static class ImportStockResponse { + private int savedCount; + private int updatedCount; + private int deletedCount; + } + + @Getter + @Builder + @NoArgsConstructor + @AllArgsConstructor + public static class StockItemResponse { + private String stockCode; + private String stockName; + } + + @Getter + @Builder + @NoArgsConstructor + @AllArgsConstructor + public static class StockPageResponse { + private List content; + private int page; + private long totalElements; + private int totalPages; + private boolean hasNext; + } +} diff --git a/src/main/java/com/example/demo/api/stock/dto/StockSyncResultDto.java b/src/main/java/com/example/demo/api/stock/dto/StockSyncResultDto.java new file mode 100644 index 0000000..59c4eb0 --- /dev/null +++ b/src/main/java/com/example/demo/api/stock/dto/StockSyncResultDto.java @@ -0,0 +1,8 @@ +package com.example.demo.api.stock.dto; + +public record StockSyncResultDto( + int addedCount, + int updatedCount, + int deletedCount +) { +} diff --git a/src/main/java/com/example/demo/api/stock/mapper/StockConverter.java b/src/main/java/com/example/demo/api/stock/mapper/StockConverter.java new file mode 100644 index 0000000..1b2bbf4 --- /dev/null +++ b/src/main/java/com/example/demo/api/stock/mapper/StockConverter.java @@ -0,0 +1,47 @@ +package com.example.demo.api.stock.mapper; + +import com.example.demo.api.stock.dto.StockResponseDto.StockItemResponse; +import com.example.demo.api.stock.dto.StockResponseDto.StockPageResponse; +import com.example.demo.api.stock.dto.StockResponseDto.StockPriceResponse; +import com.example.demo.domain.stock.entity.Stock; +import org.springframework.data.domain.Page; + +import java.util.List; +import java.util.stream.Collectors; + +public class StockConverter { + + public static StockPriceResponse toStockPriceResponse(Stock stock) { + return StockPriceResponse.builder() + .stockCode(stock.getStockCode()) + .stockName(stock.getStockName()) + .openPrice(stock.getOpenPrice()) + .closePrice(stock.getClosePrice()) + .inquiryDate(stock.getInquiryDate()) + .build(); + } + + public static StockItemResponse toStockItemResponse(Stock stock) { + return StockItemResponse.builder() + .stockCode(stock.getStockCode()) + .stockName(stock.getStockName()) + .build(); + } + + public static List toStockItemResponseList(List stocks) { + return stocks.stream() + .map(StockConverter::toStockItemResponse) + .collect(Collectors.toList()); + } + + public static StockPageResponse toStockPageResponse(Page page) { + List content = toStockItemResponseList(page.getContent()); + return StockPageResponse.builder() + .content(content) + .page(page.getNumber()) + .totalElements(page.getTotalElements()) + .totalPages(page.getTotalPages()) + .hasNext(page.hasNext()) + .build(); + } +} diff --git a/src/main/java/com/example/demo/api/stock/service/StockExcelService.java b/src/main/java/com/example/demo/api/stock/service/StockExcelService.java new file mode 100644 index 0000000..1472af6 --- /dev/null +++ b/src/main/java/com/example/demo/api/stock/service/StockExcelService.java @@ -0,0 +1,74 @@ +package com.example.demo.api.stock.service; + +import com.example.demo.api.stock.dto.StockSyncResultDto; +import com.example.demo.common.service.S3Service; +import com.example.demo.common.util.ExcelUtil; +import com.example.demo.domain.stock.exception.StockHandler; +import com.example.demo.domain.stock.service.StockCommandService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.apache.poi.ss.usermodel.Row; +import org.apache.poi.ss.usermodel.Sheet; +import org.apache.poi.ss.usermodel.Workbook; +import org.apache.poi.xssf.usermodel.XSSFWorkbook; +import org.springframework.stereotype.Service; +import software.amazon.awssdk.core.exception.SdkException; + +import java.io.IOException; +import java.io.InputStream; +import java.util.*; + +@Slf4j +@Service +@RequiredArgsConstructor +public class StockExcelService { + + private final StockCommandService stockCommandService; + private final S3Service s3Service; + private final ExcelUtil excelUtil; + + public StockSyncResultDto syncKOSPI200FromExcel(String s3Uri) { + Map excelStocks = parseExcelFromS3(s3Uri); + return stockCommandService.syncStocks(excelStocks); + } + + private Map parseExcelFromS3(String s3Uri) { + Map excelStocks = new LinkedHashMap<>(); + + try (InputStream inputStream = s3Service.downloadFile(s3Uri); + Workbook workbook = new XSSFWorkbook(inputStream)) { + + Sheet sheet = workbook.getSheetAt(0); + + for (int i = 1; i <= sheet.getLastRowNum(); i++) { + Row row = sheet.getRow(i); + if (row == null) continue; + + String stockCode = excelUtil.getCellStringValue(row.getCell(0)); + String stockName = excelUtil.getCellStringValue(row.getCell(1)); + + if (stockCode.isEmpty() || stockName.isEmpty()) continue; + + if (excelStocks.containsKey(stockCode)) { + log.info("파일 내 중복 종목 스킵: {}", stockCode); + continue; + } + + excelStocks.put(stockCode, stockName); + } + + } catch (SdkException e) { + log.error("S3 다운로드 실패. uri={}", s3Uri, e); + throw StockHandler.s3FileIoError(); + } catch (IOException | IllegalArgumentException e) { + log.error("유효하지 않은 엑셀 파일. uri={}", s3Uri, e); + throw StockHandler.invalidFile(); + } + + if (excelStocks.isEmpty()) { + throw StockHandler.emptyStock(); + } + + return excelStocks; + } +} diff --git a/src/main/java/com/example/demo/api/stock/service/StockUseCase.java b/src/main/java/com/example/demo/api/stock/service/StockUseCase.java new file mode 100644 index 0000000..8088ca8 --- /dev/null +++ b/src/main/java/com/example/demo/api/stock/service/StockUseCase.java @@ -0,0 +1,114 @@ +package com.example.demo.api.stock.service; + +import com.example.demo.api.kis.dto.KisResponseDto; +import com.example.demo.api.kis.service.KisService; +import com.example.demo.api.stock.dto.StockResponseDto.*; +import com.example.demo.api.stock.dto.StockSyncResultDto; +import com.example.demo.api.stock.mapper.StockConverter; +import com.example.demo.common.annotation.UseCase; +import com.example.demo.common.service.S3Service; +import com.example.demo.common.util.DateUtil; +import com.example.demo.common.util.ExcelUtil; +import com.example.demo.domain.stock.entity.Stock; +import com.example.demo.domain.stock.exception.StockHandler; +import com.example.demo.domain.stock.service.StockCommandService; +import com.example.demo.domain.stock.service.StockQueryService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.web.multipart.MultipartFile; +import software.amazon.awssdk.core.exception.SdkException; + +import java.io.IOException; +import java.math.BigDecimal; +import java.time.LocalDate; +import java.util.List; + +import static com.example.demo.common.consts.StaticVariable.*; + +@Slf4j +@UseCase +@RequiredArgsConstructor +public class StockUseCase { + + private final StockCommandService stockCommandService; + private final StockQueryService stockQueryService; + private final KisService kisService; + private final S3Service s3Service; + private final StockExcelService stockExcelService; + private final DateUtil dateUtil; + private final ExcelUtil excelUtil; + + @Value("${cloud.aws.s3.bucket.kospi200}") + private String bucketName; + + public UploadExcelResponse uploadStockExcel(MultipartFile file) { + excelUtil.validateExcelFile(file); + + String s3Uri = "s3://" + bucketName + "/" + KOSPI200_FILE_KEY; + + try { + s3Service.uploadFile(file, s3Uri); + return UploadExcelResponse.builder() + .s3Uri(s3Uri) + .build(); + } catch (IOException | SdkException e) { + throw StockHandler.s3FileIoError(); + } + } + + public ImportStockResponse importStockFromS3() { + String s3Uri = "s3://" + bucketName + "/" + KOSPI200_FILE_KEY; + StockSyncResultDto result = stockExcelService.syncKOSPI200FromExcel(s3Uri); + + return ImportStockResponse.builder() + .savedCount(result.addedCount()) + .updatedCount(result.updatedCount()) + .deletedCount(result.deletedCount()) + .build(); + } + + public StockPriceResponse getLatestStockPriceByStockCode(String stockCode) { + Stock stock = stockQueryService.getStockByCode(stockCode); + + if (stock == null) { + throw StockHandler.notFound(); + } + + for (int i = 1; i <= 10; i++) { + LocalDate targetDate = LocalDate.now(SEOUL_ZONE).minusDays(i); + + if (dateUtil.isWeekend(targetDate)) continue; + + String dateStr = targetDate.format(DATE_FORMATTER); + KisResponseDto response = kisService.getDailyStockPrice(stockCode, dateStr, dateStr); + if(response == null) continue; + + List output2 = response.getOutput2(); + if (output2 == null || output2.isEmpty()) continue; + + String rawOpen = output2.get(0).getOpenPrice(); + String rawClose = output2.get(0).getClosePrice(); + if (rawOpen == null || rawOpen.isBlank() || rawClose == null || rawClose.isBlank()) continue; + + try { + BigDecimal openPrice = new BigDecimal(rawOpen.trim()); + BigDecimal closePrice = new BigDecimal(rawClose.trim()); + + Stock updatedStockPrice = stockCommandService.updateStockPrice(stockCode, openPrice, closePrice, targetDate); + return StockConverter.toStockPriceResponse(updatedStockPrice); + } catch (NumberFormatException e) { + log.warn("KIS 가격 데이터 파싱 실패. stockCode={}, date={}, open={}, close={}", + stockCode, dateStr, rawOpen, rawClose); + } + } + + throw StockHandler.stockPriceResponseEmpty(); + } + + public Page getAllStocks(Pageable pageable) { + return stockQueryService.getAllStocks(pageable); + } +} diff --git a/src/main/java/com/example/demo/api/user/controller/UserController.java b/src/main/java/com/example/demo/api/user/controller/UserController.java index 05871c1..1ff4612 100644 --- a/src/main/java/com/example/demo/api/user/controller/UserController.java +++ b/src/main/java/com/example/demo/api/user/controller/UserController.java @@ -6,6 +6,7 @@ import com.example.demo.api.user.mapper.UserConverter; import com.example.demo.api.user.service.UserUseCase; import com.example.demo.common.annotation.AuthUser; +import com.example.demo.domain.prediction.service.PredictionQueryService; import com.example.demo.domain.user.entity.User; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; @@ -19,6 +20,7 @@ @RequiredArgsConstructor public class UserController { private final UserUseCase userUseCase; + private final PredictionQueryService predictionQueryService; // 테스트용 api. 카카오 OAuth 쪽에서 진행하는게 맞음. @Operation(summary = "회원 가입", description = "사용자를 등록하고 포트폴리오를 자동 생성합니다.") @@ -33,7 +35,8 @@ public ApiResponseDto registerUserAccount(@RequestBody @Va @Operation(summary = "사용자 정보 조회", description = "user의 정보를 조회합니다.") @GetMapping public ApiResponseDto getUserAccountInfo(@AuthUser User user) { - return ApiResponseDto.onSuccess(UserConverter.toUserResponse(user)); + long playCount = predictionQueryService.countByUserId(user.getId()); + return ApiResponseDto.onSuccess(UserConverter.toUserResponse(user, playCount)); } @Operation(summary = "사용자 정보 수정", description = "user의 정보를 수정합니다.") diff --git a/src/main/java/com/example/demo/api/user/dto/UserResponseDto.java b/src/main/java/com/example/demo/api/user/dto/UserResponseDto.java index 0509091..81ac99a 100644 --- a/src/main/java/com/example/demo/api/user/dto/UserResponseDto.java +++ b/src/main/java/com/example/demo/api/user/dto/UserResponseDto.java @@ -34,6 +34,8 @@ public static class UserResponse { private String accountNumber; private UserStatus status; private SocialProvider provider; + private Integer point; + private long playCount; } @Getter diff --git a/src/main/java/com/example/demo/api/user/mapper/UserConverter.java b/src/main/java/com/example/demo/api/user/mapper/UserConverter.java index 50f52ff..c8b506a 100644 --- a/src/main/java/com/example/demo/api/user/mapper/UserConverter.java +++ b/src/main/java/com/example/demo/api/user/mapper/UserConverter.java @@ -34,7 +34,7 @@ public static RegisterUserResponse toRegisterResponse(User user) { .build(); } - public static UserResponse toUserResponse(User user) { + public static UserResponse toUserResponse(User user, long playCount) { return UserResponse.builder() .userId(user.getId()) .kakaoEmail(user.getKakaoEmail()) @@ -47,6 +47,8 @@ public static UserResponse toUserResponse(User user) { .accountNumber(user.getAccountNumber()) .status(user.getStatus()) .provider(user.getProvider()) + .point(user.getPoint()) + .playCount(playCount) .build(); } diff --git a/src/main/java/com/example/demo/api/userPortfolio/controller/UserPortfolioController.java b/src/main/java/com/example/demo/api/userPortfolio/controller/UserPortfolioController.java index a3b689f..a38df7a 100644 --- a/src/main/java/com/example/demo/api/userPortfolio/controller/UserPortfolioController.java +++ b/src/main/java/com/example/demo/api/userPortfolio/controller/UserPortfolioController.java @@ -1,7 +1,6 @@ package com.example.demo.api.userPortfolio.controller; import com.example.demo.api.common.dto.ApiResponseDto; -import com.example.demo.api.userPortfolio.dto.UserPortfolioRequestDto.*; import com.example.demo.api.userPortfolio.dto.UserPortfolioResponseDto.UserPortfolioResponse; import com.example.demo.api.userPortfolio.mapper.UserPortfolioConverter; import com.example.demo.api.userPortfolio.service.UserPortfolioUseCase; @@ -10,7 +9,6 @@ import com.example.demo.domain.userPortfolio.entity.UserPortfolio; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; -import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import org.springframework.web.bind.annotation.*; diff --git a/src/main/java/com/example/demo/common/config/S3Config.java b/src/main/java/com/example/demo/common/config/S3Config.java new file mode 100644 index 0000000..45b9282 --- /dev/null +++ b/src/main/java/com/example/demo/common/config/S3Config.java @@ -0,0 +1,21 @@ +package com.example.demo.common.config; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import software.amazon.awssdk.regions.Region; +import software.amazon.awssdk.services.s3.S3Client; + +@Configuration +public class S3Config { + + @Value("${cloud.aws.region.static}") + private String region; + + @Bean + public S3Client s3Client() { + return S3Client.builder() + .region(Region.of(region)) + .build(); + } +} diff --git a/src/main/java/com/example/demo/common/consts/StaticVariable.java b/src/main/java/com/example/demo/common/consts/StaticVariable.java index 9535d33..280cf1d 100644 --- a/src/main/java/com/example/demo/common/consts/StaticVariable.java +++ b/src/main/java/com/example/demo/common/consts/StaticVariable.java @@ -1,5 +1,8 @@ package com.example.demo.common.consts; +import java.time.ZoneId; +import java.time.format.DateTimeFormatter; + public class StaticVariable { public static final String USER = "type_user"; public static final String EXPERT = "type_expert"; @@ -7,7 +10,7 @@ public class StaticVariable { public static final String SWAGGER_BEARER = "Bearer"; public static final String BEARER = "Bearer "; public static final String AUTHORIZATION = "Authorization"; - public static final String GRANT_TYPE = "authorization_code"; + public static final String GRANT_TYPE = "client_credentials"; public static final String REISSUE_ENDPOINT = "/api/v1/tokens/reissue"; public static final String HEALTH_CHECK_ENDPOINT = "/api/v1/test/health-check"; public static final String CREATED_DATE = "createdDate"; @@ -15,6 +18,12 @@ public class StaticVariable { public static final String ADVICE_ID = "id"; public static final String NOTIFICATION_READ = "isRead"; public static final String PAGINATION_SORTING_BY_ID = "id"; + public static final DateTimeFormatter DATE_FORMATTER = DateTimeFormatter.ofPattern("yyyyMMdd"); + public static final long MAX_FILE_SIZE = 10 * 1024 * 1024L; + public static final String XLSX_EXTENSION = ".xlsx"; + public static final String XLSX_CONTENT_TYPE = + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"; + public static final String KOSPI200_FILE_KEY = "kospi200.xlsx"; //OAuth2 public static final String KAKAO_OAUTH2_AUTHORIZATION_URI = "/oauth2/authorization/kakao"; @@ -23,5 +32,11 @@ public class StaticVariable { public static final long ACCESS_TOKEN_EXPIRE_TIME = 1000 * 60 * 60 * 24; // 1일 public static final long REFRESH_TOKEN_EXPIRE_TIME = 1000L * 60 * 60 * 24 * 7; // 7일 public static final String REFRESH_TOKEN_COOKIE = "refreshToken"; + + //KIS + public static final String TOKEN_KEY = "kis:access_token"; + public static final DateTimeFormatter EXPIRED_FORMATTER = + DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"); + public static final ZoneId SEOUL_ZONE = ZoneId.of("Asia/Seoul"); } diff --git a/src/main/java/com/example/demo/common/service/RedisService.java b/src/main/java/com/example/demo/common/service/RedisService.java index ffb6713..7591b80 100644 --- a/src/main/java/com/example/demo/common/service/RedisService.java +++ b/src/main/java/com/example/demo/common/service/RedisService.java @@ -27,6 +27,13 @@ public String getValue(String token) { return values.get(token); } + // KIS Access 토큰 유효기간 + // key-value + 커스텀 TTL 설정 + public void setKisTokenExpiresValueWithTtl(String key, String value, Duration ttl) { + ValueOperations values = redisTemplate.opsForValue(); + values.set(key, value, ttl); + } + public void deleteValue(String token) { if (token != null && token.startsWith("Bearer ")) { // "Bearer " 접두사 제거 diff --git a/src/main/java/com/example/demo/common/service/S3Service.java b/src/main/java/com/example/demo/common/service/S3Service.java new file mode 100644 index 0000000..85c9e4d --- /dev/null +++ b/src/main/java/com/example/demo/common/service/S3Service.java @@ -0,0 +1,65 @@ +package com.example.demo.common.service; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.web.multipart.MultipartFile; +import software.amazon.awssdk.core.sync.RequestBody; +import software.amazon.awssdk.services.s3.S3Client; +import software.amazon.awssdk.services.s3.model.GetObjectRequest; +import software.amazon.awssdk.services.s3.model.PutObjectRequest; + +import java.io.IOException; +import java.io.InputStream; +import java.net.URI; + +@Slf4j +@Service +@RequiredArgsConstructor +public class S3Service { + + private final S3Client s3Client; + + /** + * S3에 파일 업로드 + * + * @param file 업로드할 파일 + * @param s3Uri 저장할 S3 URI (s3://bucket/path/to/file.xlsx) + */ + public void uploadFile(MultipartFile file, String s3Uri) throws IOException { + URI uri = URI.create(s3Uri); + String bucket = uri.getHost(); + String key = uri.getPath().substring(1); + + s3Client.putObject( + PutObjectRequest.builder() + .bucket(bucket) + .key(key) + .contentType(file.getContentType()) + .contentLength(file.getSize()) + .build(), + RequestBody.fromInputStream(file.getInputStream(), file.getSize()) + ); + + log.info("S3 업로드 완료. uri={}", s3Uri); + } + + /** + * S3에서 파일 다운로드 + * @param s3Uri 다운로드할 S3 URI (s3://bucket/path/to/file.xlsx) + */ + public InputStream downloadFile(String s3Uri) { + URI uri = URI.create(s3Uri); + String bucket = uri.getHost(); + String key = uri.getPath().substring(1); + + log.info("S3 다운로드 요청. uri={}", s3Uri); + + return s3Client.getObject( + GetObjectRequest.builder() + .bucket(bucket) + .key(key) + .build() + ); + } +} diff --git a/src/main/java/com/example/demo/common/util/DateUtil.java b/src/main/java/com/example/demo/common/util/DateUtil.java new file mode 100644 index 0000000..d502b75 --- /dev/null +++ b/src/main/java/com/example/demo/common/util/DateUtil.java @@ -0,0 +1,13 @@ +package com.example.demo.common.util; + +import org.springframework.stereotype.Component; + +import java.time.DayOfWeek; +import java.time.LocalDate; + +@Component +public class DateUtil { + public boolean isWeekend(LocalDate date) { + return date.getDayOfWeek() == DayOfWeek.SATURDAY || date.getDayOfWeek() == DayOfWeek.SUNDAY; + } +} diff --git a/src/main/java/com/example/demo/common/util/ExcelUtil.java b/src/main/java/com/example/demo/common/util/ExcelUtil.java new file mode 100644 index 0000000..38df11f --- /dev/null +++ b/src/main/java/com/example/demo/common/util/ExcelUtil.java @@ -0,0 +1,48 @@ +package com.example.demo.common.util; + +import com.example.demo.domain.stock.exception.StockHandler; +import lombok.extern.slf4j.Slf4j; +import org.apache.poi.ss.usermodel.Cell; +import org.springframework.stereotype.Component; +import org.springframework.web.multipart.MultipartFile; + +import java.util.Locale; + +import static com.example.demo.common.consts.StaticVariable.*; + +@Slf4j +@Component +public class ExcelUtil { + + /** 엑셀 파일에서 특정 셀을 파싱합니다. **/ + public String getCellStringValue(Cell cell) { + if (cell == null) return ""; + return switch (cell.getCellType()) { + case STRING -> cell.getStringCellValue().trim(); + case NUMERIC -> String.format("%06d", (long) cell.getNumericCellValue()); + default -> ""; + }; + } + + /** 엑셀 파일이 유효한지 검증한다. **/ + public void validateExcelFile(MultipartFile file) { + if (file == null || file.isEmpty()) { + throw StockHandler.invalidFile(); + } + + String filename = file.getOriginalFilename(); + if (filename == null || !filename.trim().toLowerCase(Locale.ROOT).endsWith(XLSX_EXTENSION)) { + throw StockHandler.invalidFile(); + } + + if (file.getSize() > MAX_FILE_SIZE) { + throw StockHandler.fileSizeIsToLarge(); + } + + String contentType = file.getContentType(); + if (contentType != null && !contentType.equals(XLSX_CONTENT_TYPE)) { + log.warn("예상과 다른 Content-Type. filename={}, contentType={}", filename, contentType); + throw StockHandler.invalidFile(); + } + } +} diff --git a/src/main/java/com/example/demo/common/util/RedisUtil.java b/src/main/java/com/example/demo/common/util/RedisUtil.java new file mode 100644 index 0000000..ef7bbc9 --- /dev/null +++ b/src/main/java/com/example/demo/common/util/RedisUtil.java @@ -0,0 +1,40 @@ +package com.example.demo.common.util; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +import java.time.Duration; +import java.time.LocalDateTime; +import java.time.ZonedDateTime; +import java.time.format.DateTimeParseException; + +import static com.example.demo.common.consts.StaticVariable.EXPIRED_FORMATTER; +import static com.example.demo.common.consts.StaticVariable.SEOUL_ZONE; + +@Slf4j +@Component +public class RedisUtil { + + /** 토큰의 TTL을 계산해서 반환한다. 반환 형식: "yyyy-MM-dd HH:mm:ss" **/ + public Duration calculateTtl(String expiredAt) { + if (expiredAt == null || expiredAt.isBlank()) { + return Duration.ofHours(23); + } + + try { + ZonedDateTime expiry = LocalDateTime.parse(expiredAt, EXPIRED_FORMATTER) + .atZone(SEOUL_ZONE); + + Duration ttl = Duration.between( + ZonedDateTime.now(SEOUL_ZONE), + expiry + ).minusMinutes(5); + + return ttl.compareTo(Duration.ZERO) > 0 ? ttl : Duration.ZERO; + + } catch (DateTimeParseException e) { + log.warn("토큰 만료 시각 파싱 실패. expiredAt={}", expiredAt, e); + return Duration.ofHours(23); + } + } +} diff --git a/src/main/java/com/example/demo/domain/favoriteStock/entity/FavoriteStock.java b/src/main/java/com/example/demo/domain/favoriteStock/entity/FavoriteStock.java index 8d6fc84..e915297 100644 --- a/src/main/java/com/example/demo/domain/favoriteStock/entity/FavoriteStock.java +++ b/src/main/java/com/example/demo/domain/favoriteStock/entity/FavoriteStock.java @@ -34,8 +34,8 @@ public class FavoriteStock extends BaseTimeEntity { @Column(name = "stock_code", nullable = false, length = 20) private String stockCode; - @Column(name = "ticker", nullable = false, length = 20) - private String ticker; + @Column(name = "stock_name", nullable = false, length = 100) + private String stockName; @Column(name = "days", nullable = false) private Integer days; diff --git a/src/main/java/com/example/demo/domain/prediction/entity/Prediction.java b/src/main/java/com/example/demo/domain/prediction/entity/Prediction.java new file mode 100644 index 0000000..bed931f --- /dev/null +++ b/src/main/java/com/example/demo/domain/prediction/entity/Prediction.java @@ -0,0 +1,99 @@ +package com.example.demo.domain.prediction.entity; + +import com.example.demo.domain.model.entity.BaseTimeEntity; +import com.example.demo.domain.stock.entity.Stock; +import com.example.demo.domain.user.entity.User; +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.experimental.SuperBuilder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.math.BigDecimal; +import java.math.RoundingMode; +import java.time.LocalDateTime; + +@Entity +@Getter +@SuperBuilder +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor +@Table(name = "prediction") +public class Prediction extends BaseTimeEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id", nullable = false) + private User user; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "stock_id", nullable = false) + private Stock stock; + + @Enumerated(EnumType.STRING) + @Column(nullable = false) + private PredictionDuration duration; + + @Enumerated(EnumType.STRING) + @Column(nullable = false) + private PredictionTarget target; + + @Enumerated(EnumType.STRING) + @Column(nullable = false) + @Builder.Default + private PredictionStatus status = PredictionStatus.PENDING; + + @Column(nullable = false) + private Integer possiblePoint; // 제출 시점에 계산해 저장 (적중 시 지급액) + + @Column(nullable = false, precision = 15, scale = 2) + private BigDecimal basePrice; // 예측 제출 시점의 기준가 + + @Column(nullable = false) + private LocalDateTime maturityAt; // 채점 만기 시각 + + // ---- 채점 후 채워지는 값 ---- + @Column(precision = 15, scale = 2) + private BigDecimal maturityPrice; // 만기 시점 종가 + + @Column(precision = 6, scale = 2) + private BigDecimal actualRate; // 실제 등락률 (%) + + private Integer earnedPoint; // 실제 지급된 포인트 + + private LocalDateTime gradedAt; // 채점 완료 시각 + + /** + * 만기 종가로 채점한다. basePrice 대비 등락률을 구해 target 구간과 비교. + * @return 지급할 포인트 (적중 시 possiblePoint, 실패 시 0) + */ + public int grade(BigDecimal maturityPrice) { + this.maturityPrice = maturityPrice; + + double rate = maturityPrice.subtract(basePrice) + .divide(basePrice, 6, RoundingMode.HALF_UP) + .multiply(BigDecimal.valueOf(100)) + .doubleValue(); + + this.actualRate = BigDecimal.valueOf(rate).setScale(2, RoundingMode.HALF_UP); + this.gradedAt = LocalDateTime.now(); + + if (target.matches(rate)) { + this.status = PredictionStatus.CORRECT; + this.earnedPoint = this.possiblePoint; + } else { + this.status = PredictionStatus.WRONG; + this.earnedPoint = 0; + } + return this.earnedPoint; + } + + public boolean isPending() { + return this.status == PredictionStatus.PENDING; + } +} diff --git a/src/main/java/com/example/demo/domain/prediction/entity/PredictionDuration.java b/src/main/java/com/example/demo/domain/prediction/entity/PredictionDuration.java new file mode 100644 index 0000000..ec452f9 --- /dev/null +++ b/src/main/java/com/example/demo/domain/prediction/entity/PredictionDuration.java @@ -0,0 +1,19 @@ +package com.example.demo.domain.prediction.entity; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public enum PredictionDuration { + + ONE_DAY("1일 뒤", 1, 5), + THREE_DAY("3일 뒤", 3, 4), + FIVE_DAY("5일 뒤", 5, 3), + ONE_WEEK("1주일", 7, 2), + TWO_WEEK("2주일", 14, 1); + + private final String description; + private final int days; // 제출일 기준 캘린더 일수 + private final int weight; // duration 짧을수록 가중치 ↑ +} diff --git a/src/main/java/com/example/demo/domain/prediction/entity/PredictionStatus.java b/src/main/java/com/example/demo/domain/prediction/entity/PredictionStatus.java new file mode 100644 index 0000000..c774455 --- /dev/null +++ b/src/main/java/com/example/demo/domain/prediction/entity/PredictionStatus.java @@ -0,0 +1,7 @@ +package com.example.demo.domain.prediction.entity; + +public enum PredictionStatus { + PENDING, // 만기 전, 채점 대기 + CORRECT, // 적중 + WRONG // 실패 +} diff --git a/src/main/java/com/example/demo/domain/prediction/entity/PredictionTarget.java b/src/main/java/com/example/demo/domain/prediction/entity/PredictionTarget.java new file mode 100644 index 0000000..0c7cbf2 --- /dev/null +++ b/src/main/java/com/example/demo/domain/prediction/entity/PredictionTarget.java @@ -0,0 +1,28 @@ +package com.example.demo.domain.prediction.entity; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public enum PredictionTarget { + + UNDER_3("3% 미만 상승", null, 3.0, 1), + BETWEEN_3_5("3%이상 - 5%미만 상승", 3.0, 5.0, 2), + OVER_5("5%이상 급상승", 5.0, null, 3); + + private final String description; + private final Double minRate; // 하한(포함). null이면 하한 없음 + private final Double maxRate; // 상한(미포함). null이면 상한 없음 + private final int weight; // 목표치 높을수록 가중치 ↑ + + /** + * 실제 등락률이 이 구간에 들어오면 적중. + * 화면 기준: 세 구간 모두 "상승" 예측이므로 상승분(양수) 기준으로 판정. + */ + public boolean matches(double actualRate) { + boolean overLower = (minRate == null) || actualRate >= minRate; + boolean underUpper = (maxRate == null) || actualRate < maxRate; + return overLower && underUpper; + } +} diff --git a/src/main/java/com/example/demo/domain/prediction/exception/PredictionErrorStatus.java b/src/main/java/com/example/demo/domain/prediction/exception/PredictionErrorStatus.java new file mode 100644 index 0000000..614a6f2 --- /dev/null +++ b/src/main/java/com/example/demo/domain/prediction/exception/PredictionErrorStatus.java @@ -0,0 +1,53 @@ +package com.example.demo.domain.prediction.exception; + +import com.example.demo.common.annotation.ExplainError; +import com.example.demo.common.exception.BaseErrorCode; +import com.example.demo.common.exception.Reason; +import lombok.AllArgsConstructor; +import lombok.Getter; +import org.springframework.http.HttpStatus; + +import java.lang.reflect.Field; +import java.util.Objects; + +@Getter +@AllArgsConstructor +public enum PredictionErrorStatus implements BaseErrorCode { + + // Entity Prediction (4360~4399) + PREDICTION_NOT_FOUND(HttpStatus.NOT_FOUND, 4360, "예측을 찾지 못 했습니다."), + PREDICTION_ALREADY_GRADED(HttpStatus.BAD_REQUEST, 4361, "이미 채점된 예측입니다."), + PREDICTION_NOT_MATURED(HttpStatus.BAD_REQUEST, 4362, "아직 만기가 도래하지 않은 예측입니다."), + PREDICTION_FORBIDDEN(HttpStatus.FORBIDDEN, 4363, "본인의 예측만 조회할 수 있습니다."), + INVALID_PRICE(HttpStatus.BAD_REQUEST, 4364, "유효하지 않은 종가입니다."); + + private final HttpStatus httpStatus; + private final Integer code; + private final String message; + + @Override + public Reason getReason() { + return Reason.builder() + .message(message) + .code(code) + .isSuccess(false) + .build(); + } + + @Override + public Reason getReasonHttpStatus() { + return Reason.builder() + .message(message) + .code(code) + .isSuccess(false) + .httpStatus(httpStatus) + .build(); + } + + @Override + public String getExplainError() throws NoSuchFieldException { + Field field = this.getClass().getField(this.name()); + ExplainError annotation = field.getAnnotation(ExplainError.class); + return Objects.nonNull(annotation) ? annotation.value() : this.getMessage(); + } +} diff --git a/src/main/java/com/example/demo/domain/prediction/exception/PredictionHandler.java b/src/main/java/com/example/demo/domain/prediction/exception/PredictionHandler.java new file mode 100644 index 0000000..3871617 --- /dev/null +++ b/src/main/java/com/example/demo/domain/prediction/exception/PredictionHandler.java @@ -0,0 +1,26 @@ +package com.example.demo.domain.prediction.exception; + +import com.example.demo.common.exception.BaseErrorCode; +import com.example.demo.common.exception.GeneralException; + +public class PredictionHandler extends GeneralException { + + public static final GeneralException NOT_FOUND + = new PredictionHandler(PredictionErrorStatus.PREDICTION_NOT_FOUND); + + public static final GeneralException ALREADY_GRADED + = new PredictionHandler(PredictionErrorStatus.PREDICTION_ALREADY_GRADED); + + public static final GeneralException NOT_MATURED + = new PredictionHandler(PredictionErrorStatus.PREDICTION_NOT_MATURED); + + public static final GeneralException FORBIDDEN + = new PredictionHandler(PredictionErrorStatus.PREDICTION_FORBIDDEN); + + public static final GeneralException INVALID_PRICE + = new PredictionHandler(PredictionErrorStatus.INVALID_PRICE); + + public PredictionHandler(BaseErrorCode errorCode) { + super(errorCode); + } +} diff --git a/src/main/java/com/example/demo/domain/prediction/repository/PredictionRepository.java b/src/main/java/com/example/demo/domain/prediction/repository/PredictionRepository.java new file mode 100644 index 0000000..b638204 --- /dev/null +++ b/src/main/java/com/example/demo/domain/prediction/repository/PredictionRepository.java @@ -0,0 +1,17 @@ +package com.example.demo.domain.prediction.repository; + +import com.example.demo.domain.prediction.entity.Prediction; +import com.example.demo.domain.prediction.entity.PredictionStatus; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.time.LocalDateTime; +import java.util.List; + +public interface PredictionRepository extends JpaRepository { + + // 스케줄러: 만기 지난 PENDING 예측 채점 대상 조회 + List findByStatusAndMaturityAtBefore(PredictionStatus status, LocalDateTime now); + + // 마이페이지: 사용자 예측 게임 플레이 횟수 + long countByUserId(Long userId); +} diff --git a/src/main/java/com/example/demo/domain/prediction/service/PredictionCommandService.java b/src/main/java/com/example/demo/domain/prediction/service/PredictionCommandService.java new file mode 100644 index 0000000..bf04979 --- /dev/null +++ b/src/main/java/com/example/demo/domain/prediction/service/PredictionCommandService.java @@ -0,0 +1,18 @@ +package com.example.demo.domain.prediction.service; + +import com.example.demo.domain.prediction.entity.Prediction; + +import java.math.BigDecimal; +import java.util.List; + +public interface PredictionCommandService { + + // 예측 저장 + Prediction save(Prediction prediction); + + // 단건 채점 (수동/스케줄러 공용). 반환: 지급 포인트 + int grade(Prediction prediction, BigDecimal maturityPrice); + + // 만기 도래 PENDING 예측 일괄 조회 (스케줄러용) + List findMaturedPendings(); +} diff --git a/src/main/java/com/example/demo/domain/prediction/service/PredictionCommandServiceImpl.java b/src/main/java/com/example/demo/domain/prediction/service/PredictionCommandServiceImpl.java new file mode 100644 index 0000000..9e18461 --- /dev/null +++ b/src/main/java/com/example/demo/domain/prediction/service/PredictionCommandServiceImpl.java @@ -0,0 +1,46 @@ +package com.example.demo.domain.prediction.service; + +import com.example.demo.domain.prediction.entity.Prediction; +import com.example.demo.domain.prediction.entity.PredictionStatus; +import com.example.demo.domain.prediction.exception.PredictionErrorStatus; +import com.example.demo.domain.prediction.exception.PredictionHandler; +import com.example.demo.domain.prediction.repository.PredictionRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.util.List; + +@Service +@RequiredArgsConstructor +@Transactional +public class PredictionCommandServiceImpl implements PredictionCommandService { + + private final PredictionRepository predictionRepository; + + @Override + public Prediction save(Prediction prediction) { + return predictionRepository.save(prediction); + } + + @Override + public int grade(Prediction prediction, BigDecimal maturityPrice) { + if (!prediction.isPending()) { + throw new PredictionHandler(PredictionErrorStatus.PREDICTION_ALREADY_GRADED); + } + if (maturityPrice == null || maturityPrice.signum() <= 0) { + throw new PredictionHandler(PredictionErrorStatus.INVALID_PRICE); + } + // 영속 상태 엔티티라 dirty checking 으로 반영됨 + return prediction.grade(maturityPrice); + } + + @Override + @Transactional(readOnly = true) + public List findMaturedPendings() { + return predictionRepository.findByStatusAndMaturityAtBefore( + PredictionStatus.PENDING, LocalDateTime.now()); + } +} diff --git a/src/main/java/com/example/demo/domain/prediction/service/PredictionQueryService.java b/src/main/java/com/example/demo/domain/prediction/service/PredictionQueryService.java new file mode 100644 index 0000000..8399e8c --- /dev/null +++ b/src/main/java/com/example/demo/domain/prediction/service/PredictionQueryService.java @@ -0,0 +1,13 @@ +package com.example.demo.domain.prediction.service; + +import com.example.demo.domain.prediction.entity.Prediction; + +public interface PredictionQueryService { + + // 결과 조회 (본인 검증 포함) + Prediction getByIdAndUser(Long predictionId, Long userId); + + Prediction getById(Long predictionId); + + long countByUserId(Long userId); +} diff --git a/src/main/java/com/example/demo/domain/prediction/service/PredictionQueryServiceImpl.java b/src/main/java/com/example/demo/domain/prediction/service/PredictionQueryServiceImpl.java new file mode 100644 index 0000000..a1958f5 --- /dev/null +++ b/src/main/java/com/example/demo/domain/prediction/service/PredictionQueryServiceImpl.java @@ -0,0 +1,37 @@ +package com.example.demo.domain.prediction.service; + +import com.example.demo.domain.prediction.entity.Prediction; +import com.example.demo.domain.prediction.exception.PredictionErrorStatus; +import com.example.demo.domain.prediction.exception.PredictionHandler; +import com.example.demo.domain.prediction.repository.PredictionRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class PredictionQueryServiceImpl implements PredictionQueryService { + + private final PredictionRepository predictionRepository; + + @Override + public Prediction getById(Long predictionId) { + return predictionRepository.findById(predictionId) + .orElseThrow(() -> new PredictionHandler(PredictionErrorStatus.PREDICTION_NOT_FOUND)); + } + + @Override + public Prediction getByIdAndUser(Long predictionId, Long userId) { + Prediction prediction = getById(predictionId); + if (!prediction.getUser().getId().equals(userId)) { + throw new PredictionHandler(PredictionErrorStatus.PREDICTION_FORBIDDEN); + } + return prediction; + } + + @Override + public long countByUserId(Long userId) { + return predictionRepository.countByUserId(userId); + } +} diff --git a/src/main/java/com/example/demo/domain/report/entity/Report.java b/src/main/java/com/example/demo/domain/report/entity/Report.java index 31a865d..d8f979e 100644 --- a/src/main/java/com/example/demo/domain/report/entity/Report.java +++ b/src/main/java/com/example/demo/domain/report/entity/Report.java @@ -25,7 +25,4 @@ public class Report extends BaseTimeEntity { @Column(name = "report_html_url", nullable = false) private String reportHtmlUrl; - - @Column(name = "report_pdf_url", nullable = false) - private String reportPdfUrl; } diff --git a/src/main/java/com/example/demo/domain/stock/entity/Stock.java b/src/main/java/com/example/demo/domain/stock/entity/Stock.java new file mode 100644 index 0000000..95717f0 --- /dev/null +++ b/src/main/java/com/example/demo/domain/stock/entity/Stock.java @@ -0,0 +1,50 @@ +package com.example.demo.domain.stock.entity; + +import com.example.demo.domain.model.entity.BaseTimeEntity; +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.experimental.SuperBuilder; + +import java.math.BigDecimal; +import java.time.LocalDate; + +@Entity +@Getter +@SuperBuilder +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor +public class Stock extends BaseTimeEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "stock_id") + private Long id; + + @Column(name = "stock_code", nullable = false, unique = true, length = 20) + private String stockCode; + + @Column(name = "stock_name", nullable = false, length = 100) + private String stockName; + + @Column(name = "open_price") + private BigDecimal openPrice; + + @Column(name = "close_price") + private BigDecimal closePrice; + + @Column(name = "inquiry_date") + private LocalDate inquiryDate; + + public void updatePrice(BigDecimal openPrice, BigDecimal closePrice, LocalDate inquiryDate) { + this.openPrice = openPrice; + this.closePrice = closePrice; + this.inquiryDate = inquiryDate; + } + + public void updateName(String stockName) { + this.stockName = stockName; + } +} diff --git a/src/main/java/com/example/demo/domain/stock/exception/StockErrorStatus.java b/src/main/java/com/example/demo/domain/stock/exception/StockErrorStatus.java new file mode 100644 index 0000000..7d38dec --- /dev/null +++ b/src/main/java/com/example/demo/domain/stock/exception/StockErrorStatus.java @@ -0,0 +1,59 @@ +package com.example.demo.domain.stock.exception; + +import com.example.demo.common.annotation.ExplainError; +import com.example.demo.common.exception.BaseErrorCode; +import com.example.demo.common.exception.Reason; +import lombok.AllArgsConstructor; +import lombok.Getter; +import org.springframework.http.HttpStatus; + +import java.lang.reflect.Field; +import java.util.Objects; + +@Getter +@AllArgsConstructor +public enum StockErrorStatus implements BaseErrorCode { + + // Entity Stock(4350~4399) + STOCK_NOT_FOUND(HttpStatus.NOT_FOUND, 4350, "stock을 찾지 못 했습니다."), + STOCK_ALREADY_EXISTS(HttpStatus.CONFLICT, 4351, "이미 등록된 stock입니다."), + STOCK_PRICE_NOT_AVAILABLE(HttpStatus.BAD_GATEWAY, 4352, "시가/종가 데이터가 없습니다. 공휴일이거나 거래 정지 종목일 수 있습니다."), + STOCK_PRICE_RESPONSE_EMPTY(HttpStatus.BAD_GATEWAY, 4353, "KIS API 응답에 데이터가 없습니다."), + KIS_API_ERROR(HttpStatus.BAD_GATEWAY, 4354, "KIS API 호출에 실패했습니다."), + S3_FILE_IO_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, 4355, "S3 파일 처리 중 오류가 발생했습니다."), + INVALID_FILE(HttpStatus.BAD_REQUEST, 4356, "유효하지 않은 파일입니다. 비어있거나 .xlsx 파일이 아닙니다."), + FILE_TOO_LARGE(HttpStatus.BAD_REQUEST, 4357, "파일의 크기가 너무 큽니다."), + STOCK_CODE_ERROR(HttpStatus.BAD_REQUEST, 4358, "유효하지 않은 종목 코드 형식입니다."), + STOCK_IS_EMPTY(HttpStatus.NOT_FOUND, 4359, "종목 정보가 비어있습니다."); + + private final HttpStatus httpStatus; + private final Integer code; + private final String message; + + @Override + public Reason getReason() { + return Reason.builder() + .message(message) + .code(code) + .isSuccess(false) + .build(); + } + + @Override + public Reason getReasonHttpStatus() { + return Reason.builder() + .message(message) + .code(code) + .isSuccess(false) + .httpStatus(httpStatus) + .build(); + } + + @Override + public String getExplainError() throws NoSuchFieldException { + Field field = this.getClass().getField(this.name()); + ExplainError annotation = field.getAnnotation(ExplainError.class); + return Objects.nonNull(annotation) ? annotation.value() : this.getMessage(); + } +} + diff --git a/src/main/java/com/example/demo/domain/stock/exception/StockHandler.java b/src/main/java/com/example/demo/domain/stock/exception/StockHandler.java new file mode 100644 index 0000000..dad96c5 --- /dev/null +++ b/src/main/java/com/example/demo/domain/stock/exception/StockHandler.java @@ -0,0 +1,51 @@ +package com.example.demo.domain.stock.exception; + +import com.example.demo.common.exception.BaseErrorCode; +import com.example.demo.common.exception.GeneralException; + +public class StockHandler extends GeneralException { + + public StockHandler(BaseErrorCode baseErrorCode) { + super(baseErrorCode); + } + + public static StockHandler notFound() { + return new StockHandler(StockErrorStatus.STOCK_NOT_FOUND); + } + + public static StockHandler stockAlreadyExists() { + return new StockHandler(StockErrorStatus.STOCK_ALREADY_EXISTS); + } + + public static StockHandler stockPriceNotAvailable() { + return new StockHandler(StockErrorStatus.STOCK_PRICE_NOT_AVAILABLE); + } + + public static StockHandler stockPriceResponseEmpty() { + return new StockHandler(StockErrorStatus.STOCK_PRICE_RESPONSE_EMPTY); + } + + public static StockHandler kisApiError() { + return new StockHandler(StockErrorStatus.KIS_API_ERROR); + } + + public static StockHandler s3FileIoError() { + return new StockHandler(StockErrorStatus.S3_FILE_IO_ERROR); + } + + public static StockHandler invalidFile() { + return new StockHandler(StockErrorStatus.INVALID_FILE); + } + + public static StockHandler invalidStockCode() { + return new StockHandler(StockErrorStatus.STOCK_CODE_ERROR); + } + + public static StockHandler fileSizeIsToLarge() { + return new StockHandler(StockErrorStatus.FILE_TOO_LARGE); + } + + public static StockHandler emptyStock() { + return new StockHandler(StockErrorStatus.STOCK_IS_EMPTY); + } +} diff --git a/src/main/java/com/example/demo/domain/stock/repository/StockRepository.java b/src/main/java/com/example/demo/domain/stock/repository/StockRepository.java new file mode 100644 index 0000000..a8c26bf --- /dev/null +++ b/src/main/java/com/example/demo/domain/stock/repository/StockRepository.java @@ -0,0 +1,24 @@ +package com.example.demo.domain.stock.repository; + +import com.example.demo.domain.stock.entity.Stock; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.util.Collection; +import java.util.List; +import java.util.Optional; + +public interface StockRepository extends JpaRepository { + Optional findByStockCode(String stockCode); + + @Query("SELECT s.stockCode FROM Stock s") + List findAllStockCodes(); + + List findAllByStockCodeIn(Collection stockCodes); + + @Modifying(clearAutomatically = true) + @Query("DELETE FROM Stock s WHERE s.stockCode IN :codes") + void deleteAllByStockCodeIn(@Param("codes") Collection codes); +} diff --git a/src/main/java/com/example/demo/domain/stock/service/StockCommandService.java b/src/main/java/com/example/demo/domain/stock/service/StockCommandService.java new file mode 100644 index 0000000..084ee30 --- /dev/null +++ b/src/main/java/com/example/demo/domain/stock/service/StockCommandService.java @@ -0,0 +1,18 @@ +package com.example.demo.domain.stock.service; + +import com.example.demo.api.stock.dto.StockSyncResultDto; +import com.example.demo.domain.stock.entity.Stock; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.util.List; +import java.util.Map; +import java.util.Set; + +public interface StockCommandService { + Stock updateStockPrice(String stockCode, BigDecimal openPrice, BigDecimal closePrice, LocalDate inquiryDate); + StockSyncResultDto syncStocks(Map excelStocks); + void saveAllStocks(List stocks); + void updateStockNames(Map codeToName); + void deleteByStockCodes(Set stockCodes); +} diff --git a/src/main/java/com/example/demo/domain/stock/service/StockCommandServiceImpl.java b/src/main/java/com/example/demo/domain/stock/service/StockCommandServiceImpl.java new file mode 100644 index 0000000..cdad3e3 --- /dev/null +++ b/src/main/java/com/example/demo/domain/stock/service/StockCommandServiceImpl.java @@ -0,0 +1,97 @@ +package com.example.demo.domain.stock.service; + +import com.example.demo.api.stock.dto.StockSyncResultDto; +import com.example.demo.domain.stock.entity.Stock; +import com.example.demo.domain.stock.exception.StockHandler; +import com.example.demo.domain.stock.repository.StockRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.util.*; +import java.util.stream.Collectors; + +@Service +@Transactional +@RequiredArgsConstructor +public class StockCommandServiceImpl implements StockCommandService { + + private final StockRepository stockRepository; + + @Override + public Stock updateStockPrice( + String stockCode, + BigDecimal openPrice, + BigDecimal closePrice, + LocalDate inquiryDate) { + Stock stock = stockRepository.findByStockCode(stockCode) + .orElseThrow(StockHandler::notFound); + stock.updatePrice(openPrice, closePrice, inquiryDate); + + return stock; + } + + @Override + public StockSyncResultDto syncStocks(Map excelStocks) { + Map dbStocks = stockRepository.findAll() + .stream() + .collect(Collectors.toMap( + Stock::getStockCode, + Stock::getStockName + )); + + Set excelCodes = excelStocks.keySet(); + Set dbCodes = dbStocks.keySet(); + + Set toAdd = new HashSet<>(excelCodes); + toAdd.removeAll(dbCodes); + + Set toDelete = new HashSet<>(dbCodes); + toDelete.removeAll(excelCodes); + + Map toUpdate = excelCodes.stream() + .filter(dbCodes::contains) + .filter(code -> !excelStocks.get(code).equals(dbStocks.get(code))) + .collect(Collectors.toMap(code -> code, excelStocks::get)); + + List newStocks = toAdd.stream() + .map(code -> Stock.builder() + .stockCode(code) + .stockName(excelStocks.get(code)) + .build()) + .collect(Collectors.toList()); + + saveAllStocks(newStocks); + updateStockNames(toUpdate); + deleteByStockCodes(toDelete); + + return new StockSyncResultDto( + toAdd.size(), + toUpdate.size(), + toDelete.size() + ); + } + + @Override + public void saveAllStocks(List stocks) { + if (stocks.isEmpty()) return; + stockRepository.saveAll(stocks); + } + + @Override + public void updateStockNames(Map codeToName) { + if (codeToName.isEmpty()) return; + List stocks = stockRepository.findAllByStockCodeIn(codeToName.keySet()); + stocks.forEach(stock -> + stock.updateName(codeToName.get(stock.getStockCode())) + ); + } + + @Override + public void deleteByStockCodes(Set stockCodes) { + if (stockCodes.isEmpty()) return; + stockRepository.deleteAllByStockCodeIn(stockCodes); + } +} diff --git a/src/main/java/com/example/demo/domain/stock/service/StockQueryService.java b/src/main/java/com/example/demo/domain/stock/service/StockQueryService.java new file mode 100644 index 0000000..21ba91d --- /dev/null +++ b/src/main/java/com/example/demo/domain/stock/service/StockQueryService.java @@ -0,0 +1,14 @@ +package com.example.demo.domain.stock.service; + +import com.example.demo.domain.stock.entity.Stock; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; + +import java.util.Map; +import java.util.Set; + +public interface StockQueryService { + Stock getStockByCode(String stockCode); + Set getAllStockCodes(); + Page getAllStocks(Pageable pageable); +} diff --git a/src/main/java/com/example/demo/domain/stock/service/StockQueryServiceImpl.java b/src/main/java/com/example/demo/domain/stock/service/StockQueryServiceImpl.java new file mode 100644 index 0000000..356dd78 --- /dev/null +++ b/src/main/java/com/example/demo/domain/stock/service/StockQueryServiceImpl.java @@ -0,0 +1,40 @@ +package com.example.demo.domain.stock.service; + +import com.example.demo.domain.stock.entity.Stock; +import com.example.demo.domain.stock.exception.StockHandler; +import com.example.demo.domain.stock.repository.StockRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; + +import java.util.HashSet; +import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; + +@Service +@Transactional(readOnly = true) +@RequiredArgsConstructor +public class StockQueryServiceImpl implements StockQueryService { + + private final StockRepository stockRepository; + + @Override + public Stock getStockByCode(String stockCode) { + return stockRepository.findByStockCode(stockCode) + .orElseThrow(StockHandler::notFound); + } + + @Override + public Set getAllStockCodes() { + return new HashSet<>(stockRepository.findAllStockCodes()); + } + + @Override + public Page getAllStocks(Pageable pageable) { + return stockRepository.findAll(pageable); + } +} diff --git a/src/main/java/com/example/demo/domain/user/entity/User.java b/src/main/java/com/example/demo/domain/user/entity/User.java index c77dcf3..eb38325 100644 --- a/src/main/java/com/example/demo/domain/user/entity/User.java +++ b/src/main/java/com/example/demo/domain/user/entity/User.java @@ -75,6 +75,14 @@ public class User extends BaseTimeEntity implements UserDetails { @Column(name = "provider_user_id", length = 100) private String providerUserId; + @Column(nullable = false) + @Builder.Default + private Integer point = 0; + + public void addPoint(int amount) { + this.point = (this.point == null ? 0 : this.point) + amount; + } + public void updateUserInfo(String nickname, String profileImg, String accountNumber) { if (nickname != null && !nickname.trim().isEmpty()) this.nickname = nickname; if (profileImg != null && !profileImg.trim().isEmpty()) this.profileImg = profileImg; diff --git a/src/main/java/com/example/demo/security/controller/TokenApiController.java b/src/main/java/com/example/demo/security/controller/TokenApiController.java index 712041a..9acca9f 100644 --- a/src/main/java/com/example/demo/security/controller/TokenApiController.java +++ b/src/main/java/com/example/demo/security/controller/TokenApiController.java @@ -58,4 +58,4 @@ public ApiResponseDto logout(@RequestBody RefreshTokenRequest request) { tokenService.logout(request.getRefreshToken()); return ApiResponseDto.onSuccess(null); } -} \ No newline at end of file +} diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index d1ffaf8..b0e4efe 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -48,3 +48,27 @@ oauth2: cors: allowed-origins: ${CORS_ALLOWED_ORIGINS:http://localhost:3000} # 기본값은 http://localhost:3000 (로컬 프론트), 운영/배포 시 환경변수 CORS_ALLOWED_ORIGINS로 주입 + +kis: + base-url: https://openapi.koreainvestment.com:9443 + app-key: ${KIS_APP_KEY} + app-secret: ${KIS_APP_SECRET} + +cloud: + aws: + region: + static: ap-northeast-2 + auto: false + stack: + auto: false + s3: + bucket: + kospi200: ${S3_BUCKET_KOSPI200:treat-kospi200} + #finance-reports: ${S3_BUCKET_FINANCE_REPORTS:treat-finance-reports} + #static: ${S3_BUCKET_STATIC:treat-static} + openfeign: + client: + config: + kisClient: + connect-timeout: 3000 # 3초 + read-timeout: 5000 # 5초