diff --git a/src/backend/stock_server/src/main/java/com/jootalkpia/stock_server/stocks/advice/ControllerAdvice.java b/src/backend/stock_server/src/main/java/com/jootalkpia/stock_server/stocks/advice/ControllerAdvice.java new file mode 100644 index 00000000..c2e7f337 --- /dev/null +++ b/src/backend/stock_server/src/main/java/com/jootalkpia/stock_server/stocks/advice/ControllerAdvice.java @@ -0,0 +1,16 @@ +package com.jootalkpia.stock_server.stocks.advice; + +import com.jootalkpia.stock_server.stocks.advice.exception.BadRequestException; +import com.jootalkpia.stock_server.stocks.advice.exception.ErrorResponse; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; + +@RestControllerAdvice +public class ControllerAdvice { + + @ExceptionHandler(BadRequestException.class) + public ResponseEntity handleBadRequestException(BadRequestException e) { + return ResponseEntity.badRequest().body(new ErrorResponse(e.getMessage())); + } +} diff --git a/src/backend/stock_server/src/main/java/com/jootalkpia/stock_server/stocks/advice/StockCaller.java b/src/backend/stock_server/src/main/java/com/jootalkpia/stock_server/stocks/advice/caller/StockCaller.java similarity index 96% rename from src/backend/stock_server/src/main/java/com/jootalkpia/stock_server/stocks/advice/StockCaller.java rename to src/backend/stock_server/src/main/java/com/jootalkpia/stock_server/stocks/advice/caller/StockCaller.java index 8ffde041..048267cd 100644 --- a/src/backend/stock_server/src/main/java/com/jootalkpia/stock_server/stocks/advice/StockCaller.java +++ b/src/backend/stock_server/src/main/java/com/jootalkpia/stock_server/stocks/advice/caller/StockCaller.java @@ -1,4 +1,4 @@ -package com.jootalkpia.stock_server.stocks.advice; +package com.jootalkpia.stock_server.stocks.advice.caller; import com.jootalkpia.stock_server.stocks.dto.request.TokenRequestBody; import com.jootalkpia.stock_server.stocks.dto.response.MinutePriceDetailedResponse; diff --git a/src/backend/stock_server/src/main/java/com/jootalkpia/stock_server/stocks/advice/exception/BadRequestException.java b/src/backend/stock_server/src/main/java/com/jootalkpia/stock_server/stocks/advice/exception/BadRequestException.java new file mode 100644 index 00000000..45339afb --- /dev/null +++ b/src/backend/stock_server/src/main/java/com/jootalkpia/stock_server/stocks/advice/exception/BadRequestException.java @@ -0,0 +1,8 @@ +package com.jootalkpia.stock_server.stocks.advice.exception; + +public class BadRequestException extends BusinessException { + + public BadRequestException(String message) { + super(message); + } +} diff --git a/src/backend/stock_server/src/main/java/com/jootalkpia/stock_server/stocks/advice/exception/BusinessException.java b/src/backend/stock_server/src/main/java/com/jootalkpia/stock_server/stocks/advice/exception/BusinessException.java new file mode 100644 index 00000000..1843bec7 --- /dev/null +++ b/src/backend/stock_server/src/main/java/com/jootalkpia/stock_server/stocks/advice/exception/BusinessException.java @@ -0,0 +1,8 @@ +package com.jootalkpia.stock_server.stocks.advice.exception; + +public class BusinessException extends RuntimeException { + + public BusinessException(String message) { + super(message); + } +} diff --git a/src/backend/stock_server/src/main/java/com/jootalkpia/stock_server/stocks/advice/exception/ErrorResponse.java b/src/backend/stock_server/src/main/java/com/jootalkpia/stock_server/stocks/advice/exception/ErrorResponse.java new file mode 100644 index 00000000..ab5aabe5 --- /dev/null +++ b/src/backend/stock_server/src/main/java/com/jootalkpia/stock_server/stocks/advice/exception/ErrorResponse.java @@ -0,0 +1,4 @@ +package com.jootalkpia.stock_server.stocks.advice.exception; + +public record ErrorResponse(String message) { +} diff --git a/src/backend/stock_server/src/main/java/com/jootalkpia/stock_server/stocks/advice/exception/InvalidObjectIdFormatException.java b/src/backend/stock_server/src/main/java/com/jootalkpia/stock_server/stocks/advice/exception/InvalidObjectIdFormatException.java new file mode 100644 index 00000000..324d6f98 --- /dev/null +++ b/src/backend/stock_server/src/main/java/com/jootalkpia/stock_server/stocks/advice/exception/InvalidObjectIdFormatException.java @@ -0,0 +1,10 @@ +package com.jootalkpia.stock_server.stocks.advice.exception; + +public class InvalidObjectIdFormatException extends BadRequestException { + + private static final String MESSAGE = "ObjectId는 24자리의 16진수 문자열이어야 합니다: "; + + public InvalidObjectIdFormatException(String cursorId) { + super(MESSAGE + cursorId); + } +} diff --git a/src/backend/stock_server/src/main/java/com/jootalkpia/stock_server/stocks/advice/exception/NoSuchMinutePriceException.java b/src/backend/stock_server/src/main/java/com/jootalkpia/stock_server/stocks/advice/exception/NoSuchMinutePriceException.java new file mode 100644 index 00000000..86b080d4 --- /dev/null +++ b/src/backend/stock_server/src/main/java/com/jootalkpia/stock_server/stocks/advice/exception/NoSuchMinutePriceException.java @@ -0,0 +1,10 @@ +package com.jootalkpia.stock_server.stocks.advice.exception; + +public class NoSuchMinutePriceException extends BadRequestException { + + private static final String MESSAGE = "조회된 분봉 데이터가 없습니다."; + + public NoSuchMinutePriceException() { + super(MESSAGE); + } +} diff --git a/src/backend/stock_server/src/main/java/com/jootalkpia/stock_server/stocks/advice/util/StockValidationUtils.java b/src/backend/stock_server/src/main/java/com/jootalkpia/stock_server/stocks/advice/util/StockValidationUtils.java new file mode 100644 index 00000000..f8a04690 --- /dev/null +++ b/src/backend/stock_server/src/main/java/com/jootalkpia/stock_server/stocks/advice/util/StockValidationUtils.java @@ -0,0 +1,34 @@ +package com.jootalkpia.stock_server.stocks.advice.util; + +import com.jootalkpia.stock_server.stocks.advice.exception.InvalidObjectIdFormatException; +import com.jootalkpia.stock_server.stocks.advice.exception.NoSuchMinutePriceException; +import com.jootalkpia.stock_server.stocks.dto.MinutePrice; + +import java.util.List; +import java.util.NoSuchElementException; + +public class StockValidationUtils { + private StockValidationUtils() { + // private 생성자로 인스턴스화 방지 + } + + public static void validateChartSize(List slicedMinutePriceChart) { + if (slicedMinutePriceChart.isEmpty()) { + throw new NoSuchMinutePriceException(); + } + } + + public static void validateObjectId(String cursorId) { + if (!isValidObjectId(cursorId)) { + throw new InvalidObjectIdFormatException(cursorId); + } + } + + private static boolean isValidObjectId(String cursorId) { + if (cursorId.length() != 24) { + return false; + } + + return cursorId.matches("[0-9a-fA-F]{24}"); + } +} diff --git a/src/backend/stock_server/src/main/java/com/jootalkpia/stock_server/stocks/controller/StockController.java b/src/backend/stock_server/src/main/java/com/jootalkpia/stock_server/stocks/controller/StockController.java index a7320a2b..5402124b 100644 --- a/src/backend/stock_server/src/main/java/com/jootalkpia/stock_server/stocks/controller/StockController.java +++ b/src/backend/stock_server/src/main/java/com/jootalkpia/stock_server/stocks/controller/StockController.java @@ -3,11 +3,10 @@ import com.jootalkpia.stock_server.stocks.dto.response.CandlePriceHistoryResponse; import com.jootalkpia.stock_server.stocks.service.StockService; import lombok.RequiredArgsConstructor; -import org.springframework.data.domain.Pageable; -import org.springframework.data.web.PageableDefault; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; @RequiredArgsConstructor @@ -16,7 +15,7 @@ public class StockController { private final StockService stockService; @GetMapping("/api/v1/stock/candlesticks/{stock_code}") - public ResponseEntity handleCandlePriceHistory(@PathVariable("stock_code") String code, @PageableDefault(size = 180) Pageable pageable) { - return ResponseEntity.ok(stockService.getCandlePriceHistoryByCode(pageable, code)); + public ResponseEntity handleCandlePriceHistory(@PathVariable(name = "stock_code") String code,@RequestParam(required = false) String cursorId, @RequestParam(defaultValue = "120") int size) { + return ResponseEntity.ok(stockService.getCandlePriceHistoryByCode(code, cursorId, size)); } } diff --git a/src/backend/stock_server/src/main/java/com/jootalkpia/stock_server/stocks/dto/MinutePrice.java b/src/backend/stock_server/src/main/java/com/jootalkpia/stock_server/stocks/dto/MinutePrice.java index a0d98585..84326471 100644 --- a/src/backend/stock_server/src/main/java/com/jootalkpia/stock_server/stocks/dto/MinutePrice.java +++ b/src/backend/stock_server/src/main/java/com/jootalkpia/stock_server/stocks/dto/MinutePrice.java @@ -1,17 +1,13 @@ package com.jootalkpia.stock_server.stocks.dto; -import com.fasterxml.jackson.databind.PropertyNamingStrategies; -import com.fasterxml.jackson.databind.annotation.JsonNaming; import lombok.Getter; import org.bson.types.ObjectId; import org.springframework.data.annotation.Id; import org.springframework.data.mongodb.core.index.Indexed; import org.springframework.data.mongodb.core.mapping.Document; -import org.springframework.data.mongodb.core.mapping.Field; @Getter -@JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class) -@Document(collection = "minute_price") +@Document(collection = "minute_prices") public class MinutePrice { @Id private ObjectId minutePriceId; @@ -19,31 +15,22 @@ public class MinutePrice { @Indexed(background = true) private String code; - @Field("stock_name") private String stockName; - @Field("business_date") private String businessDate; - @Field("trading_time") private String tradingTime; - @Field("current_price") private String currentPrice; - @Field("open_price") private String openPrice; - @Field("high_price") private String highPrice; - @Field("low_price") private String lowPrice; - @Field("trading_volume") private String tradingVolume; - @Field("total_trade_amount") private String totalTradeAmount; protected MinutePrice() { diff --git a/src/backend/stock_server/src/main/java/com/jootalkpia/stock_server/stocks/dto/response/CandlePriceHistoryResponse.java b/src/backend/stock_server/src/main/java/com/jootalkpia/stock_server/stocks/dto/response/CandlePriceHistoryResponse.java index dd4ee73b..47fe49c9 100644 --- a/src/backend/stock_server/src/main/java/com/jootalkpia/stock_server/stocks/dto/response/CandlePriceHistoryResponse.java +++ b/src/backend/stock_server/src/main/java/com/jootalkpia/stock_server/stocks/dto/response/CandlePriceHistoryResponse.java @@ -2,7 +2,6 @@ import com.jootalkpia.stock_server.stocks.domain.StockCode; import com.jootalkpia.stock_server.stocks.dto.MinutePrice; -import org.springframework.data.domain.Page; import java.util.List; @@ -10,17 +9,19 @@ public record CandlePriceHistoryResponse( String code, String stockName, List output, - long totalCount + boolean hasNext, + String lastObjectId ) { - public static CandlePriceHistoryResponse of(Page minutePricePage, String code) { + public static CandlePriceHistoryResponse of(List minutePriceChart, String code, boolean hasNext, String lastObjectId) { return new CandlePriceHistoryResponse( code, StockCode.getNameByCode(code), - minutePricePage.getContent().stream() + minutePriceChart.stream() .map(Output::of) .toList(), - minutePricePage.getTotalElements() + hasNext, + lastObjectId ); } diff --git a/src/backend/stock_server/src/main/java/com/jootalkpia/stock_server/stocks/repository/MinutePriceRepository.java b/src/backend/stock_server/src/main/java/com/jootalkpia/stock_server/stocks/repository/MinutePriceRepository.java index eebc3b0b..0f5a53ae 100644 --- a/src/backend/stock_server/src/main/java/com/jootalkpia/stock_server/stocks/repository/MinutePriceRepository.java +++ b/src/backend/stock_server/src/main/java/com/jootalkpia/stock_server/stocks/repository/MinutePriceRepository.java @@ -1,10 +1,21 @@ package com.jootalkpia.stock_server.stocks.repository; import com.jootalkpia.stock_server.stocks.dto.MinutePrice; -import org.springframework.data.domain.Page; +import org.bson.types.ObjectId; import org.springframework.data.domain.Pageable; import org.springframework.data.mongodb.repository.MongoRepository; +import java.util.List; + public interface MinutePriceRepository extends MongoRepository { - Page findAllByCode(Pageable pageable, String code); + List findByCodeOrderByMinutePriceIdAsc( + String code, + Pageable pageable + ); + + List findByCodeAndMinutePriceIdGreaterThanOrderByMinutePriceIdAsc( + String code, + ObjectId minutePriceId, + Pageable pageable + ); } diff --git a/src/backend/stock_server/src/main/java/com/jootalkpia/stock_server/stocks/service/StockService.java b/src/backend/stock_server/src/main/java/com/jootalkpia/stock_server/stocks/service/StockService.java index c020b6d4..e9350514 100644 --- a/src/backend/stock_server/src/main/java/com/jootalkpia/stock_server/stocks/service/StockService.java +++ b/src/backend/stock_server/src/main/java/com/jootalkpia/stock_server/stocks/service/StockService.java @@ -1,7 +1,7 @@ package com.jootalkpia.stock_server.stocks.service; import com.google.gson.Gson; -import com.jootalkpia.stock_server.stocks.advice.StockCaller; +import com.jootalkpia.stock_server.stocks.advice.caller.StockCaller; import com.jootalkpia.stock_server.stocks.domain.Schedule; import com.jootalkpia.stock_server.stocks.domain.StockCode; import com.jootalkpia.stock_server.stocks.dto.MinutePrice; @@ -18,8 +18,8 @@ import jakarta.annotation.PostConstruct; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; -import org.springframework.data.domain.Page; -import org.springframework.data.domain.Pageable; +import org.bson.types.ObjectId; +import org.springframework.data.domain.PageRequest; import org.springframework.kafka.core.KafkaTemplate; import org.springframework.scheduling.config.CronTask; import org.springframework.scheduling.config.ScheduledTaskRegistrar; @@ -27,12 +27,17 @@ import java.time.LocalDateTime; import java.time.format.DateTimeFormatter; +import java.util.List; + +import static com.jootalkpia.stock_server.stocks.advice.util.StockValidationUtils.validateChartSize; +import static com.jootalkpia.stock_server.stocks.advice.util.StockValidationUtils.validateObjectId; @Slf4j @RequiredArgsConstructor @Service public class StockService { private static final String TOKEN_SEPARATOR = " "; + private static final int CURSOR_PAGE_NUMBER = 0; private String token; @@ -105,8 +110,52 @@ private MinutePriceSimpleResponse getStockPrice(String code) { return MinutePriceSimpleResponse.from(response, code); } - public CandlePriceHistoryResponse getCandlePriceHistoryByCode(Pageable pageable, String code) { - Page minutePricePage = minutePriceRepository.findAllByCode(pageable, code); - return CandlePriceHistoryResponse.of(minutePricePage, code); + public CandlePriceHistoryResponse getCandlePriceHistoryByCode(String code, String cursorId, int size) { + List minutePriceChart = findMinutePriceChart(code, cursorId, size); + boolean hasNext = checkHasNext(minutePriceChart, size); + List slicedMinutePriceChart = sliceBySize(minutePriceChart, size, hasNext); + + return CandlePriceHistoryResponse.of(slicedMinutePriceChart, code, hasNext, getLastObjectId(slicedMinutePriceChart)); + } + + private List findMinutePriceChart(String code, String cursorId, int size) { + if (cursorId == null || cursorId.isEmpty()) { + return findFirstPage(code, size); + } + return findNextPage(code, cursorId, size); + } + + private List findFirstPage(String code, int size) { + return minutePriceRepository.findByCodeOrderByMinutePriceIdAsc( + code, + PageRequest.of(CURSOR_PAGE_NUMBER, size + 1)); + } + + private List findNextPage(String code, String cursorId, int size) { + validateObjectId(cursorId); + ObjectId objectId = new ObjectId(cursorId); + + return minutePriceRepository.findByCodeAndMinutePriceIdGreaterThanOrderByMinutePriceIdAsc( + code, + objectId, + PageRequest.of(CURSOR_PAGE_NUMBER, size + 1) + ); + } + + private boolean checkHasNext(List minutePriceChart, int size) { + return minutePriceChart.size() > size; + } + + private List sliceBySize(List minutePriceChart, int size, boolean hasNext) { + if (!hasNext) { + return minutePriceChart; + } + return minutePriceChart.subList(0, size); + } + + private String getLastObjectId(List slicedMinutePriceChart) { + validateChartSize(slicedMinutePriceChart); + + return String.valueOf(slicedMinutePriceChart.get(slicedMinutePriceChart.size() - 1).getMinutePriceId()); } }