From f2836b43e8185641c9b2810248cec47e5e39ab78 Mon Sep 17 00:00:00 2001 From: Dongwoo Go Date: Mon, 18 May 2026 14:56:16 +0900 Subject: [PATCH 01/24] =?UTF-8?q?feat:=20KIS=20api=EB=A5=BC=20=EC=82=AC?= =?UTF-8?q?=EC=9A=A9=ED=95=98=EC=97=AC=20=EC=A3=BC=EC=8B=9D=20=EC=A2=85?= =?UTF-8?q?=EB=AA=A9=EC=9D=98=20=EC=8B=9C=EA=B0=80,=20=EC=A2=85=EA=B0=80?= =?UTF-8?q?=EB=A5=BC=20=EC=A1=B0=ED=9A=8C=ED=95=B4=EC=84=9C=20DB=EC=97=90?= =?UTF-8?q?=20=EC=A0=80=EC=9E=A5=ED=95=98=EB=8A=94=20=EA=B8=B0=EB=8A=A5=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- build.gradle | 6 ++ .../api/kis/controller/KisController.java | 5 ++ .../demo/api/kis/dto/KisResponseDto.java | 32 ++++++++ .../demo/api/kis/service/KisService.java | 49 ++++++++++++ .../demo/api/kis/service/KisTokenService.java | 37 +++++++++ .../service/NotificationUseCase.java | 1 - .../api/stock/controller/StockController.java | 26 +++++++ .../demo/api/stock/dto/StockResponseDto.java | 24 ++++++ .../demo/api/stock/mapper/StockConverter.java | 17 ++++ .../api/stock/service/StockExcelService.java | 78 +++++++++++++++++++ .../demo/api/stock/service/StockUseCase.java | 64 +++++++++++++++ .../controller/UserPortfolioController.java | 2 - .../demo/common/config/WebClientConfig.java | 23 ++++++ .../demo/domain/stock/entity/Stock.java | 46 +++++++++++ .../stock/exception/StockErrorStatus.java | 53 +++++++++++++ .../domain/stock/exception/StockHandler.java | 22 ++++++ .../stock/repository/StockRepository.java | 10 +++ .../stock/service/StockCommandService.java | 9 +++ .../service/StockCommandServiceImpl.java | 32 ++++++++ .../stock/service/StockQueryService.java | 7 ++ .../stock/service/StockQueryServiceImpl.java | 23 ++++++ src/main/resources/application.yml | 7 +- 22 files changed, 569 insertions(+), 4 deletions(-) create mode 100644 src/main/java/com/example/demo/api/kis/controller/KisController.java create mode 100644 src/main/java/com/example/demo/api/kis/dto/KisResponseDto.java create mode 100644 src/main/java/com/example/demo/api/kis/service/KisService.java create mode 100644 src/main/java/com/example/demo/api/kis/service/KisTokenService.java create mode 100644 src/main/java/com/example/demo/api/stock/controller/StockController.java create mode 100644 src/main/java/com/example/demo/api/stock/dto/StockResponseDto.java create mode 100644 src/main/java/com/example/demo/api/stock/mapper/StockConverter.java create mode 100644 src/main/java/com/example/demo/api/stock/service/StockExcelService.java create mode 100644 src/main/java/com/example/demo/api/stock/service/StockUseCase.java create mode 100644 src/main/java/com/example/demo/common/config/WebClientConfig.java create mode 100644 src/main/java/com/example/demo/domain/stock/entity/Stock.java create mode 100644 src/main/java/com/example/demo/domain/stock/exception/StockErrorStatus.java create mode 100644 src/main/java/com/example/demo/domain/stock/exception/StockHandler.java create mode 100644 src/main/java/com/example/demo/domain/stock/repository/StockRepository.java create mode 100644 src/main/java/com/example/demo/domain/stock/service/StockCommandService.java create mode 100644 src/main/java/com/example/demo/domain/stock/service/StockCommandServiceImpl.java create mode 100644 src/main/java/com/example/demo/domain/stock/service/StockQueryService.java create mode 100644 src/main/java/com/example/demo/domain/stock/service/StockQueryServiceImpl.java diff --git a/build.gradle b/build.gradle index 58d3f03..7d8d789 100644 --- a/build.gradle +++ b/build.gradle @@ -38,6 +38,12 @@ dependencies { // Redis implementation 'org.springframework.boot:spring-boot-starter-data-redis' + // WebClient + implementation 'org.springframework.boot:spring-boot-starter-webflux' + + // Excel + implementation 'org.apache.poi:poi-ooxml:5.5.1' + runtimeOnly 'com.mysql:mysql-connector-j' compileOnly 'org.projectlombok:lombok' diff --git a/src/main/java/com/example/demo/api/kis/controller/KisController.java b/src/main/java/com/example/demo/api/kis/controller/KisController.java new file mode 100644 index 0000000..0d4cc7f --- /dev/null +++ b/src/main/java/com/example/demo/api/kis/controller/KisController.java @@ -0,0 +1,5 @@ +package com.example.demo.api.kis.controller; + +public class KisController { + +} 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..472d0c8 --- /dev/null +++ b/src/main/java/com/example/demo/api/kis/dto/KisResponseDto.java @@ -0,0 +1,32 @@ +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("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/service/KisService.java b/src/main/java/com/example/demo/api/kis/service/KisService.java new file mode 100644 index 0000000..7ce160d --- /dev/null +++ b/src/main/java/com/example/demo/api/kis/service/KisService.java @@ -0,0 +1,49 @@ +package com.example.demo.api.kis.service; + +import com.example.demo.api.kis.dto.KisResponseDto; +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; +import org.springframework.web.reactive.function.client.WebClient; + +@Service +@RequiredArgsConstructor +public class KisService { + + private final WebClient webClient; + private final KisTokenService kisTokenService; + + @Value("${kis.app-key}") + private String appKey; + + @Value("${kis.app-secret}") + private String appSecret; + + /** + * 일별 시가/종가 조회 + * @param stockCode 종목코드 (예: 005930) + * @param startDate 조회 시작일 (YYYYMMDD) + * @param endDate 조회 종료일 (YYYYMMDD) + */ + public KisResponseDto getDailyStockPrice(String stockCode, String startDate, String endDate) { + String token = kisTokenService.getAccessToken(); + + return webClient.get() + .uri(uriBuilder -> uriBuilder + .path("/uapi/domestic-stock/v1/quotations/inquire-daily-itemchartprice") + .queryParam("FID_COND_MRKT_DIV_CODE", "J") + .queryParam("FID_INPUT_ISCD", stockCode) + .queryParam("FID_INPUT_DATE_1", startDate) + .queryParam("FID_INPUT_DATE_2", endDate) + .queryParam("FID_PERIOD_DIV_CODE", "D") + .queryParam("FID_ORG_ADJ_PRC", "0") + .build()) + .header("authorization", "Bearer " + token) + .header("appkey", appKey) + .header("appsecret", appSecret) + .header("tr_id", "FHKST03010100") // 실전: FHKST03010100 / 모의: VFHKST03010100 + .retrieve() + .bodyToMono(KisResponseDto.class) + .block(); + } +} 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..5845538 --- /dev/null +++ b/src/main/java/com/example/demo/api/kis/service/KisTokenService.java @@ -0,0 +1,37 @@ +package com.example.demo.api.kis.service; + +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; +import org.springframework.web.reactive.function.client.WebClient; + +import java.util.Map; + +@Service +@RequiredArgsConstructor +public class KisTokenService { + + private final WebClient webClient; + + @Value("${kis.app-key}") + private String appKey; + + @Value("${kis.app-secret}") + private String appSecret; + + public String getAccessToken() { + Map body = Map.of( + "grant_type", "client_credentials", + "appkey", appKey, + "appsecret", appSecret + ); + + return webClient.post() + .uri("/oauth2/tokenP") + .bodyValue(body) + .retrieve() + .bodyToMono(Map.class) + .map(res -> (String) res.get("access_token")) + .block(); + } +} diff --git a/src/main/java/com/example/demo/api/notification/service/NotificationUseCase.java b/src/main/java/com/example/demo/api/notification/service/NotificationUseCase.java index af2c371..cda07c9 100644 --- a/src/main/java/com/example/demo/api/notification/service/NotificationUseCase.java +++ b/src/main/java/com/example/demo/api/notification/service/NotificationUseCase.java @@ -8,7 +8,6 @@ import com.example.demo.domain.notification.service.NotificationCommandService; import com.example.demo.domain.notification.service.NotificationQueryService; import com.example.demo.domain.user.entity.User; -import com.example.demo.domain.user.service.UserQueryService; import lombok.RequiredArgsConstructor; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; 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..6a933cd --- /dev/null +++ b/src/main/java/com/example/demo/api/stock/controller/StockController.java @@ -0,0 +1,26 @@ +package com.example.demo.api.stock.controller; + +import com.example.demo.api.common.dto.ApiResponseDto; +import com.example.demo.api.stock.dto.StockResponseDto.StockPriceResponse; +import com.example.demo.api.stock.service.StockUseCase; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@Tag(name = "[주식] 주식 종목 조회 API") +@RestController +@RequestMapping("/api/v1/stocks") +@RequiredArgsConstructor +public class StockController { + private final StockUseCase stockUseCase; + + @Operation(summary = "전일(가장 최근 일자) 시가/종가 조회", description = "종목코드로 전날의(가장 최근 날짜) 시가와 종가를 KIS API에서 조회하고 DB에 저장합니다.") + @GetMapping("/{stockCode}/today-price") + public ApiResponseDto getLatestStockPrice(@PathVariable String stockCode) { + return ApiResponseDto.onSuccess(stockUseCase.getLatestStockPriceByStockCode(stockCode)); + } +} 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..7f30587 --- /dev/null +++ b/src/main/java/com/example/demo/api/stock/dto/StockResponseDto.java @@ -0,0 +1,24 @@ +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; + +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; + } +} 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..b41e083 --- /dev/null +++ b/src/main/java/com/example/demo/api/stock/mapper/StockConverter.java @@ -0,0 +1,17 @@ +package com.example.demo.api.stock.mapper; + +import com.example.demo.api.stock.dto.StockResponseDto.StockPriceResponse; +import com.example.demo.domain.stock.entity.Stock; + +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(); + } +} 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..75536e4 --- /dev/null +++ b/src/main/java/com/example/demo/api/stock/service/StockExcelService.java @@ -0,0 +1,78 @@ +package com.example.demo.api.stock.service; + +import com.example.demo.domain.stock.entity.Stock; +import com.example.demo.domain.stock.repository.StockRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.apache.poi.openxml4j.exceptions.InvalidFormatException; +import org.apache.poi.ss.usermodel.Cell; +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 java.io.File; +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; + +@Slf4j +@Service +@RequiredArgsConstructor +public class StockExcelService { + + private final StockRepository stockRepository; + + public int importFromExcel(String filePath) throws IOException, InvalidFormatException { + File file = new File(filePath); + List stocks = new ArrayList<>(); + //String fileName = file.getName(); + //String dateStr = fileName.replaceAll(".*_(\\d{8})\\.xlsx", "$1"); + //LocalDate inquiryDate = LocalDate.parse(dateStr, DateTimeFormatter.ofPattern("yyyyMMdd")); + + try (Workbook workbook = new XSSFWorkbook(file)) { + Sheet sheet = workbook.getSheetAt(0); + + for (int i = 1; i <= sheet.getLastRowNum(); i++) { + Row row = sheet.getRow(i); + if (row == null) continue; + + String stockCode = getCellStringValue(row.getCell(0)); + String stockName = getCellStringValue(row.getCell(1)); + //BigDecimal closePrice = getCellNumericValue(row.getCell(2)); + + if (stockCode.isEmpty() || stockName.isEmpty()) continue; + + if (stockRepository.existsByStockCode(stockCode)) { + log.info("이미 존재하는 종목 스킵: {}", stockCode); + continue; + } + + Stock stock = Stock.builder() + .stockCode(stockCode) + .stockName(stockName) + .closePrice(null) + .openPrice(null) + .inquiryDate(null) + .build(); + + stocks.add(stock); + } + } + + stockRepository.saveAll(stocks); + log.info("총 {}개 종목 저장 완료", stocks.size()); + + return stocks.size(); + } + + private String getCellStringValue(Cell cell) { + if (cell == null) return ""; + return switch (cell.getCellType()) { + case STRING -> cell.getStringCellValue().trim(); + case NUMERIC -> String.valueOf((long) cell.getNumericCellValue()); + default -> ""; + }; + } +} 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..ef4c45f --- /dev/null +++ b/src/main/java/com/example/demo/api/stock/service/StockUseCase.java @@ -0,0 +1,64 @@ +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.StockPriceResponse; +import com.example.demo.api.stock.mapper.StockConverter; +import com.example.demo.common.annotation.UseCase; +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 lombok.RequiredArgsConstructor; +import org.springframework.transaction.annotation.Transactional; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.time.format.DateTimeFormatter; +import java.util.List; + +@UseCase +@Transactional(readOnly = true) +@RequiredArgsConstructor +public class StockUseCase { + + private final StockCommandService stockCommandService; + private final KisService kisService; + + private static final DateTimeFormatter DATE_FORMATTER = DateTimeFormatter.ofPattern("yyyyMMdd"); + + @Transactional + public StockPriceResponse getLatestStockPriceByStockCode(String stockCode) { + LocalDate targetDate = getPreviousBusinessDay(LocalDate.now()); + String dateStr = targetDate.format(DATE_FORMATTER); + + KisResponseDto response = kisService.getDailyStockPrice(stockCode, dateStr, dateStr); + + List output2 = response.getOutput2(); + if (output2 == null || output2.isEmpty()) { + throw StockHandler.STOCK_PRICE_RESPONSE_EMPTY; + } + + String rawOpen = output2.get(0).getOpenPrice(); + String rawClose = output2.get(0).getClosePrice(); + + if (rawOpen == null || rawOpen.isBlank() || rawClose == null || rawClose.isBlank()) { + throw StockHandler.STOCK_PRICE_NOT_AVAILABLE; + } + + BigDecimal openPrice = new BigDecimal(rawOpen); + BigDecimal closePrice = new BigDecimal(rawClose); + + Stock stock = stockCommandService.updateStockPrice(stockCode, openPrice, closePrice, targetDate); + + return StockConverter.toStockPriceResponse(stock); + } + + private LocalDate getPreviousBusinessDay(LocalDate date) { + LocalDate previous = date.minusDays(1); + return switch (previous.getDayOfWeek()) { + case SATURDAY -> previous.minusDays(1); + case SUNDAY -> previous.minusDays(2); + default -> previous; + }; + } +} 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/WebClientConfig.java b/src/main/java/com/example/demo/common/config/WebClientConfig.java new file mode 100644 index 0000000..929f5b3 --- /dev/null +++ b/src/main/java/com/example/demo/common/config/WebClientConfig.java @@ -0,0 +1,23 @@ +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 org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.web.reactive.function.client.WebClient; + +@Configuration +public class WebClientConfig { + + @Value("${kis.base-url}") + private String baseUrl; + + @Bean + public WebClient webClient() { + return WebClient.builder() + .baseUrl(baseUrl) + .defaultHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE) + .build(); + } +} 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..d989278 --- /dev/null +++ b/src/main/java/com/example/demo/domain/stock/entity/Stock.java @@ -0,0 +1,46 @@ +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) + private String stockCode; + + @Column(name = "stock_name", nullable = false) + 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; + } +} 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..20f59e7 --- /dev/null +++ b/src/main/java/com/example/demo/domain/stock/exception/StockErrorStatus.java @@ -0,0 +1,53 @@ +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 응답에 데이터가 없습니다."); + + 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..be42bd5 --- /dev/null +++ b/src/main/java/com/example/demo/domain/stock/exception/StockHandler.java @@ -0,0 +1,22 @@ +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 static final GeneralException NOT_FOUND + = new StockHandler(StockErrorStatus.STOCK_NOT_FOUND); + + public static final GeneralException STOCK_ALREADY_EXISTS + = new StockHandler(StockErrorStatus.STOCK_ALREADY_EXISTS); + + public static final GeneralException STOCK_PRICE_NOT_AVAILABLE + = new StockHandler(StockErrorStatus.STOCK_PRICE_NOT_AVAILABLE); + + public static final GeneralException STOCK_PRICE_RESPONSE_EMPTY + = new StockHandler(StockErrorStatus.STOCK_PRICE_RESPONSE_EMPTY); + + public StockHandler(BaseErrorCode baseErrorCode) { + super(baseErrorCode); + } +} 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..6a3245b --- /dev/null +++ b/src/main/java/com/example/demo/domain/stock/repository/StockRepository.java @@ -0,0 +1,10 @@ +package com.example.demo.domain.stock.repository; + +import com.example.demo.domain.stock.entity.Stock; +import org.springframework.data.jpa.repository.JpaRepository; +import java.util.Optional; + +public interface StockRepository extends JpaRepository { + boolean existsByStockCode(String stockCode); + Optional findByStockCode(String stockCode); +} 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..ec0f94b --- /dev/null +++ b/src/main/java/com/example/demo/domain/stock/service/StockCommandService.java @@ -0,0 +1,9 @@ +package com.example.demo.domain.stock.service; + +import com.example.demo.domain.stock.entity.Stock; +import java.math.BigDecimal; +import java.time.LocalDate; + +public interface StockCommandService { + Stock updateStockPrice(String stockCode, BigDecimal openPrice, BigDecimal closePrice, LocalDate inquiryDate); +} 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..8de19a1 --- /dev/null +++ b/src/main/java/com/example/demo/domain/stock/service/StockCommandServiceImpl.java @@ -0,0 +1,32 @@ +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 java.math.BigDecimal; +import java.time.LocalDate; + +@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.NOT_FOUND); + stock.updatePrice(openPrice, closePrice, inquiryDate); + + return stock; + } +} 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..a8c9fc6 --- /dev/null +++ b/src/main/java/com/example/demo/domain/stock/service/StockQueryService.java @@ -0,0 +1,7 @@ +package com.example.demo.domain.stock.service; + +import com.example.demo.domain.stock.entity.Stock; + +public interface StockQueryService { + Stock getStockByCode(String stockCode); +} 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..4139f29 --- /dev/null +++ b/src/main/java/com/example/demo/domain/stock/service/StockQueryServiceImpl.java @@ -0,0 +1,23 @@ +package com.example.demo.domain.stock.service; + +import com.example.demo.domain.stock.entity.Stock; +import com.example.demo.domain.stock.exception.StockErrorStatus; +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; + +@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.NOT_FOUND); + } +} diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 630c2db..35abcdf 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -47,4 +47,9 @@ oauth2: redirect-uri: ${OAUTH2_REDIRECT_URI:http://localhost:8080/api/auth/kakao/callback} cors: - allowed-origins: ${CORS_ALLOWED_ORIGINS:http://localhost:3000} # 기본값은 http://localhost:3000 (로컬 프론트), 운영/배포 시 환경변수 CORS_ALLOWED_ORIGINS로 주입 \ No newline at end of file + 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} \ No newline at end of file From e4523460ab36603285ac593765e79ba62c2c6f3f Mon Sep 17 00:00:00 2001 From: Dongwoo Go Date: Wed, 20 May 2026 14:44:42 +0900 Subject: [PATCH 02/24] =?UTF-8?q?feat:=20kis=20=EC=97=91=EC=84=B8=EC=8A=A4?= =?UTF-8?q?=20=ED=86=A0=ED=81=B0=EC=9D=84=20redis=EC=97=90=20=EC=BA=90?= =?UTF-8?q?=EC=8B=B1=20=EA=B8=B0=EB=8A=A5=20=EC=B6=94=EA=B0=80.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../demo/api/kis/dto/KisResponseDto.java | 4 +- .../demo/api/kis/dto/KisTokenResponseDto.java | 22 ++++++++ .../demo/api/kis/service/KisService.java | 16 ++++++ .../demo/api/kis/service/KisTokenService.java | 52 +++++++++++++++++-- .../api/stock/service/StockExcelService.java | 22 +++++--- .../demo/api/stock/service/StockUseCase.java | 43 +++++++-------- .../demo/common/service/RedisService.java | 7 +++ .../demo/domain/stock/entity/Stock.java | 2 +- .../stock/exception/StockErrorStatus.java | 3 +- .../domain/stock/exception/StockHandler.java | 29 +++++++---- .../stock/repository/StockRepository.java | 7 ++- .../stock/service/StockCommandService.java | 3 ++ .../service/StockCommandServiceImpl.java | 8 ++- .../stock/service/StockQueryService.java | 3 ++ .../stock/service/StockQueryServiceImpl.java | 11 +++- 15 files changed, 182 insertions(+), 50 deletions(-) create mode 100644 src/main/java/com/example/demo/api/kis/dto/KisTokenResponseDto.java 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 index 472d0c8..fcde416 100644 --- a/src/main/java/com/example/demo/api/kis/dto/KisResponseDto.java +++ b/src/main/java/com/example/demo/api/kis/dto/KisResponseDto.java @@ -13,13 +13,15 @@ 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; 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 index 7ce160d..cf1e5b1 100644 --- a/src/main/java/com/example/demo/api/kis/service/KisService.java +++ b/src/main/java/com/example/demo/api/kis/service/KisService.java @@ -1,10 +1,15 @@ package com.example.demo.api.kis.service; import com.example.demo.api.kis.dto.KisResponseDto; +import com.example.demo.domain.stock.exception.StockHandler; import lombok.RequiredArgsConstructor; import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.HttpStatusCode; import org.springframework.stereotype.Service; import org.springframework.web.reactive.function.client.WebClient; +import reactor.core.publisher.Mono; + +import java.time.Duration; @Service @RequiredArgsConstructor @@ -43,7 +48,18 @@ public KisResponseDto getDailyStockPrice(String stockCode, String startDate, Str .header("appsecret", appSecret) .header("tr_id", "FHKST03010100") // 실전: FHKST03010100 / 모의: VFHKST03010100 .retrieve() + .onStatus(HttpStatusCode::is4xxClientError, + res -> Mono.error(StockHandler.kisApiError())) + .onStatus(HttpStatusCode::is5xxServerError, + res -> Mono.error(StockHandler.kisApiError())) .bodyToMono(KisResponseDto.class) + .map(response -> { + if (!"0".equals(response.getRtCd())) { + throw StockHandler.kisApiError(); + } + return response; + }) + .timeout(Duration.ofSeconds(5)) .block(); } } 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 index 5845538..ee1d54e 100644 --- a/src/main/java/com/example/demo/api/kis/service/KisTokenService.java +++ b/src/main/java/com/example/demo/api/kis/service/KisTokenService.java @@ -1,17 +1,33 @@ package com.example.demo.api.kis.service; +import com.example.demo.api.kis.dto.KisTokenResponseDto; +import com.example.demo.common.service.RedisService; +import com.example.demo.domain.stock.exception.StockHandler; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.HttpStatusCode; import org.springframework.stereotype.Service; import org.springframework.web.reactive.function.client.WebClient; +import reactor.core.publisher.Mono; +import java.time.Duration; +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; import java.util.Map; +import java.util.Objects; +@Slf4j @Service @RequiredArgsConstructor public class KisTokenService { + private static final String TOKEN_KEY = "kis:access_token"; + private static final DateTimeFormatter EXPIRED_FORMATTER = + DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"); + private final WebClient webClient; + private final RedisService redisService; @Value("${kis.app-key}") private String appKey; @@ -20,18 +36,48 @@ public class KisTokenService { private String appSecret; public String getAccessToken() { + String cached = redisService.getValue(TOKEN_KEY); + if (cached != null) { + log.debug("KIS 토큰 캐시 히트"); + return cached; + } + + log.info("KIS 토큰 신규 발급 요청"); Map body = Map.of( "grant_type", "client_credentials", "appkey", appKey, "appsecret", appSecret ); - return webClient.post() + KisTokenResponseDto tokenResponse = webClient.post() .uri("/oauth2/tokenP") .bodyValue(body) .retrieve() - .bodyToMono(Map.class) - .map(res -> (String) res.get("access_token")) + .onStatus(HttpStatusCode::is4xxClientError, + res -> Mono.error(StockHandler.kisApiError())) + .onStatus(HttpStatusCode::is5xxServerError, + res -> Mono.error(StockHandler.kisApiError())) + .bodyToMono(KisTokenResponseDto.class) + .timeout(Duration.ofSeconds(5)) .block(); + + String token = Objects.requireNonNull(tokenResponse).getAccessToken(); + + Duration ttl = calculateTtl(tokenResponse.getAccessTokenTokenExpired()); + redisService.setKisTokenExpiresValueWithTtl(TOKEN_KEY, token, ttl); + log.info("KIS 토큰 Redis 저장 완료. TTL={}s", ttl.getSeconds()); + + return token; + } + + private Duration calculateTtl(String expiredAt) { + try { + LocalDateTime expiry = LocalDateTime.parse(expiredAt, EXPIRED_FORMATTER); + Duration ttl = Duration.between(LocalDateTime.now(), expiry).minusMinutes(5); + return ttl.isNegative() ? Duration.ofMinutes(1) : ttl; + } catch (Exception e) { + log.warn("KIS 토큰 만료 시각 파싱 실패. 기본값 23시간 적용. expiredAt={}", expiredAt); + return Duration.ofHours(23); + } } } 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 index 75536e4..819a9e3 100644 --- a/src/main/java/com/example/demo/api/stock/service/StockExcelService.java +++ b/src/main/java/com/example/demo/api/stock/service/StockExcelService.java @@ -1,7 +1,8 @@ package com.example.demo.api.stock.service; import com.example.demo.domain.stock.entity.Stock; -import com.example.demo.domain.stock.repository.StockRepository; +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.apache.poi.openxml4j.exceptions.InvalidFormatException; @@ -15,14 +16,17 @@ import java.io.File; import java.io.IOException; import java.util.ArrayList; +import java.util.HashSet; import java.util.List; +import java.util.Set; @Slf4j @Service @RequiredArgsConstructor public class StockExcelService { - private final StockRepository stockRepository; + private final StockQueryService stockQueryService; + private final StockCommandService stockCommandService; public int importFromExcel(String filePath) throws IOException, InvalidFormatException { File file = new File(filePath); @@ -31,6 +35,9 @@ public int importFromExcel(String filePath) throws IOException, InvalidFormatExc //String dateStr = fileName.replaceAll(".*_(\\d{8})\\.xlsx", "$1"); //LocalDate inquiryDate = LocalDate.parse(dateStr, DateTimeFormatter.ofPattern("yyyyMMdd")); + Set existingCodes = stockQueryService.getAllStockCodes(); + Set seenInFile = new HashSet<>(); + try (Workbook workbook = new XSSFWorkbook(file)) { Sheet sheet = workbook.getSheetAt(0); @@ -43,8 +50,11 @@ public int importFromExcel(String filePath) throws IOException, InvalidFormatExc //BigDecimal closePrice = getCellNumericValue(row.getCell(2)); if (stockCode.isEmpty() || stockName.isEmpty()) continue; - - if (stockRepository.existsByStockCode(stockCode)) { + if (!seenInFile.add(stockCode)) { + log.info("파일 내 중복 종목 스킵: {}", stockCode); + continue; + } + if (existingCodes.contains(stockCode)) { log.info("이미 존재하는 종목 스킵: {}", stockCode); continue; } @@ -61,7 +71,7 @@ public int importFromExcel(String filePath) throws IOException, InvalidFormatExc } } - stockRepository.saveAll(stocks); + stockCommandService.saveAllStocks(stocks); log.info("총 {}개 종목 저장 완료", stocks.size()); return stocks.size(); @@ -71,7 +81,7 @@ private String getCellStringValue(Cell cell) { if (cell == null) return ""; return switch (cell.getCellType()) { case STRING -> cell.getStringCellValue().trim(); - case NUMERIC -> String.valueOf((long) cell.getNumericCellValue()); + case NUMERIC -> String.format("%06d", (long) cell.getNumericCellValue()); default -> ""; }; } 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 index ef4c45f..638b027 100644 --- a/src/main/java/com/example/demo/api/stock/service/StockUseCase.java +++ b/src/main/java/com/example/demo/api/stock/service/StockUseCase.java @@ -12,12 +12,12 @@ import org.springframework.transaction.annotation.Transactional; import java.math.BigDecimal; +import java.time.DayOfWeek; import java.time.LocalDate; import java.time.format.DateTimeFormatter; import java.util.List; @UseCase -@Transactional(readOnly = true) @RequiredArgsConstructor public class StockUseCase { @@ -28,37 +28,32 @@ public class StockUseCase { @Transactional public StockPriceResponse getLatestStockPriceByStockCode(String stockCode) { - LocalDate targetDate = getPreviousBusinessDay(LocalDate.now()); - String dateStr = targetDate.format(DATE_FORMATTER); + for (int i = 1; i <= 10; i++) { + LocalDate targetDate = LocalDate.now().minusDays(i); - KisResponseDto response = kisService.getDailyStockPrice(stockCode, dateStr, dateStr); + if (isWeekend(targetDate)) continue; - List output2 = response.getOutput2(); - if (output2 == null || output2.isEmpty()) { - throw StockHandler.STOCK_PRICE_RESPONSE_EMPTY; - } + String dateStr = targetDate.format(DATE_FORMATTER); + KisResponseDto response = kisService.getDailyStockPrice(stockCode, dateStr, dateStr); - String rawOpen = output2.get(0).getOpenPrice(); - String rawClose = output2.get(0).getClosePrice(); + List output2 = response.getOutput2(); + if (output2 == null || output2.isEmpty()) continue; - if (rawOpen == null || rawOpen.isBlank() || rawClose == null || rawClose.isBlank()) { - throw StockHandler.STOCK_PRICE_NOT_AVAILABLE; - } + String rawOpen = output2.get(0).getOpenPrice(); + String rawClose = output2.get(0).getClosePrice(); + if (rawOpen == null || rawOpen.isBlank() || rawClose == null || rawClose.isBlank()) continue; - BigDecimal openPrice = new BigDecimal(rawOpen); - BigDecimal closePrice = new BigDecimal(rawClose); + BigDecimal openPrice = new BigDecimal(rawOpen); + BigDecimal closePrice = new BigDecimal(rawClose); - Stock stock = stockCommandService.updateStockPrice(stockCode, openPrice, closePrice, targetDate); + Stock stock = stockCommandService.updateStockPrice(stockCode, openPrice, closePrice, targetDate); + return StockConverter.toStockPriceResponse(stock); + } - return StockConverter.toStockPriceResponse(stock); + throw StockHandler.stockPriceResponseEmpty(); } - private LocalDate getPreviousBusinessDay(LocalDate date) { - LocalDate previous = date.minusDays(1); - return switch (previous.getDayOfWeek()) { - case SATURDAY -> previous.minusDays(1); - case SUNDAY -> previous.minusDays(2); - default -> previous; - }; + private boolean isWeekend(LocalDate date) { + return date.getDayOfWeek() == DayOfWeek.SATURDAY || date.getDayOfWeek() == DayOfWeek.SUNDAY; } } 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 3e0d7e1..26eb362 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/domain/stock/entity/Stock.java b/src/main/java/com/example/demo/domain/stock/entity/Stock.java index d989278..0c82023 100644 --- a/src/main/java/com/example/demo/domain/stock/entity/Stock.java +++ b/src/main/java/com/example/demo/domain/stock/entity/Stock.java @@ -23,7 +23,7 @@ public class Stock extends BaseTimeEntity { @Column(name = "stock_id") private Long id; - @Column(name = "stock_code", nullable = false) + @Column(name = "stock_code", nullable = false, unique = true) private String stockCode; @Column(name = "stock_name", nullable = false) 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 index 20f59e7..52f50f6 100644 --- a/src/main/java/com/example/demo/domain/stock/exception/StockErrorStatus.java +++ b/src/main/java/com/example/demo/domain/stock/exception/StockErrorStatus.java @@ -18,7 +18,8 @@ public enum StockErrorStatus implements BaseErrorCode { 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 응답에 데이터가 없습니다."); + STOCK_PRICE_RESPONSE_EMPTY(HttpStatus.BAD_GATEWAY, 4353, "KIS API 응답에 데이터가 없습니다."), + KIS_API_ERROR(HttpStatus.BAD_GATEWAY, 4354, "KIS API 호출에 실패했습니다."); private final HttpStatus httpStatus; private final Integer code; 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 index be42bd5..ba82b78 100644 --- a/src/main/java/com/example/demo/domain/stock/exception/StockHandler.java +++ b/src/main/java/com/example/demo/domain/stock/exception/StockHandler.java @@ -4,19 +4,28 @@ import com.example.demo.common.exception.GeneralException; public class StockHandler extends GeneralException { - public static final GeneralException NOT_FOUND - = new StockHandler(StockErrorStatus.STOCK_NOT_FOUND); - public static final GeneralException STOCK_ALREADY_EXISTS - = new StockHandler(StockErrorStatus.STOCK_ALREADY_EXISTS); + public StockHandler(BaseErrorCode baseErrorCode) { + super(baseErrorCode); + } - public static final GeneralException STOCK_PRICE_NOT_AVAILABLE - = new StockHandler(StockErrorStatus.STOCK_PRICE_NOT_AVAILABLE); + public static StockHandler notFound() { + return new StockHandler(StockErrorStatus.STOCK_NOT_FOUND); + } - public static final GeneralException STOCK_PRICE_RESPONSE_EMPTY - = new StockHandler(StockErrorStatus.STOCK_PRICE_RESPONSE_EMPTY); + public static StockHandler stockAlreadyExists() { + return new StockHandler(StockErrorStatus.STOCK_ALREADY_EXISTS); + } - public StockHandler(BaseErrorCode baseErrorCode) { - super(baseErrorCode); + 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); } } 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 index 6a3245b..09e490d 100644 --- a/src/main/java/com/example/demo/domain/stock/repository/StockRepository.java +++ b/src/main/java/com/example/demo/domain/stock/repository/StockRepository.java @@ -2,9 +2,14 @@ import com.example.demo.domain.stock.entity.Stock; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; + +import java.util.List; import java.util.Optional; public interface StockRepository extends JpaRepository { - boolean existsByStockCode(String stockCode); Optional findByStockCode(String stockCode); + + @Query("SELECT s.stockCode FROM Stock s") + List findAllStockCodes(); } 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 index ec0f94b..eed7a31 100644 --- a/src/main/java/com/example/demo/domain/stock/service/StockCommandService.java +++ b/src/main/java/com/example/demo/domain/stock/service/StockCommandService.java @@ -1,9 +1,12 @@ package com.example.demo.domain.stock.service; import com.example.demo.domain.stock.entity.Stock; + import java.math.BigDecimal; import java.time.LocalDate; +import java.util.List; public interface StockCommandService { Stock updateStockPrice(String stockCode, BigDecimal openPrice, BigDecimal closePrice, LocalDate inquiryDate); + void saveAllStocks(List stocks); } 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 index 8de19a1..e72e08a 100644 --- a/src/main/java/com/example/demo/domain/stock/service/StockCommandServiceImpl.java +++ b/src/main/java/com/example/demo/domain/stock/service/StockCommandServiceImpl.java @@ -9,6 +9,7 @@ import java.math.BigDecimal; import java.time.LocalDate; +import java.util.List; @Service @Transactional @@ -24,9 +25,14 @@ public Stock updateStockPrice( BigDecimal closePrice, LocalDate inquiryDate) { Stock stock = stockRepository.findByStockCode(stockCode) - .orElseThrow(() -> StockHandler.NOT_FOUND); + .orElseThrow(StockHandler::notFound); stock.updatePrice(openPrice, closePrice, inquiryDate); return stock; } + + @Override + public void saveAllStocks(List stocks) { + stockRepository.saveAll(stocks); + } } 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 index a8c9fc6..dc7dcbe 100644 --- a/src/main/java/com/example/demo/domain/stock/service/StockQueryService.java +++ b/src/main/java/com/example/demo/domain/stock/service/StockQueryService.java @@ -2,6 +2,9 @@ import com.example.demo.domain.stock.entity.Stock; +import java.util.Set; + public interface StockQueryService { Stock getStockByCode(String stockCode); + Set getAllStockCodes(); } 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 index 4139f29..b001d51 100644 --- a/src/main/java/com/example/demo/domain/stock/service/StockQueryServiceImpl.java +++ b/src/main/java/com/example/demo/domain/stock/service/StockQueryServiceImpl.java @@ -1,13 +1,15 @@ package com.example.demo.domain.stock.service; import com.example.demo.domain.stock.entity.Stock; -import com.example.demo.domain.stock.exception.StockErrorStatus; 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.util.HashSet; +import java.util.Set; + @Service @Transactional(readOnly = true) @RequiredArgsConstructor @@ -18,6 +20,11 @@ public class StockQueryServiceImpl implements StockQueryService { @Override public Stock getStockByCode(String stockCode) { return stockRepository.findByStockCode(stockCode) - .orElseThrow(() -> StockHandler.NOT_FOUND); + .orElseThrow(StockHandler::notFound); + } + + @Override + public Set getAllStockCodes() { + return new HashSet<>(stockRepository.findAllStockCodes()); } } From 7da4babd9ad83f8adf7745e61b2dcce36daa2715 Mon Sep 17 00:00:00 2001 From: Dongwoo Go Date: Wed, 20 May 2026 15:05:08 +0900 Subject: [PATCH 03/24] =?UTF-8?q?chore:=20TTL=200=EC=B4=88=20=EC=BC=80?= =?UTF-8?q?=EC=9D=B4=EC=8A=A4=20=EC=98=88=EC=99=B8=EC=B2=98=EB=A6=AC=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/com/example/demo/api/kis/service/KisTokenService.java | 2 +- .../com/example/demo/api/stock/controller/StockController.java | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) 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 index ee1d54e..9ba0fae 100644 --- a/src/main/java/com/example/demo/api/kis/service/KisTokenService.java +++ b/src/main/java/com/example/demo/api/kis/service/KisTokenService.java @@ -74,7 +74,7 @@ private Duration calculateTtl(String expiredAt) { try { LocalDateTime expiry = LocalDateTime.parse(expiredAt, EXPIRED_FORMATTER); Duration ttl = Duration.between(LocalDateTime.now(), expiry).minusMinutes(5); - return ttl.isNegative() ? Duration.ofMinutes(1) : ttl; + return (ttl.isNegative() || ttl.isZero()) ? Duration.ofMinutes(1) : ttl; } catch (Exception e) { log.warn("KIS 토큰 만료 시각 파싱 실패. 기본값 23시간 적용. expiredAt={}", expiredAt); return Duration.ofHours(23); 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 index 6a933cd..a3a8842 100644 --- a/src/main/java/com/example/demo/api/stock/controller/StockController.java +++ b/src/main/java/com/example/demo/api/stock/controller/StockController.java @@ -17,7 +17,7 @@ @RequiredArgsConstructor public class StockController { private final StockUseCase stockUseCase; - + @Operation(summary = "전일(가장 최근 일자) 시가/종가 조회", description = "종목코드로 전날의(가장 최근 날짜) 시가와 종가를 KIS API에서 조회하고 DB에 저장합니다.") @GetMapping("/{stockCode}/today-price") public ApiResponseDto getLatestStockPrice(@PathVariable String stockCode) { From a6cb008106d7d757c8f95862ee004b1e0b472af4 Mon Sep 17 00:00:00 2001 From: Dongwoo Go Date: Wed, 20 May 2026 15:05:28 +0900 Subject: [PATCH 04/24] =?UTF-8?q?chore:=20=EB=88=84=EB=9D=BD=20=EC=BB=A4?= =?UTF-8?q?=EB=B0=8B.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/example/demo/api/stock/controller/StockController.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 index a3a8842..6a933cd 100644 --- a/src/main/java/com/example/demo/api/stock/controller/StockController.java +++ b/src/main/java/com/example/demo/api/stock/controller/StockController.java @@ -17,7 +17,7 @@ @RequiredArgsConstructor public class StockController { private final StockUseCase stockUseCase; - + @Operation(summary = "전일(가장 최근 일자) 시가/종가 조회", description = "종목코드로 전날의(가장 최근 날짜) 시가와 종가를 KIS API에서 조회하고 DB에 저장합니다.") @GetMapping("/{stockCode}/today-price") public ApiResponseDto getLatestStockPrice(@PathVariable String stockCode) { From 004c11a417852b90771f09a3e2ced55228ac27f8 Mon Sep 17 00:00:00 2001 From: Dongwoo Go Date: Thu, 21 May 2026 16:57:19 +0900 Subject: [PATCH 05/24] =?UTF-8?q?feat:=20krx=EC=97=90=EC=84=9C=20=EB=8B=A4?= =?UTF-8?q?=EC=9A=B4=ED=95=9C=20=EC=BD=94=EC=8A=A4=ED=94=BC=20200=20?= =?UTF-8?q?=EC=97=91=EC=85=80=20=ED=8C=8C=EC=9D=BC=EC=9D=84=20S3=EC=97=90?= =?UTF-8?q?=20=EC=97=85=EB=A1=9C=EB=93=9C=ED=95=98=EB=8A=94=20=EA=B8=B0?= =?UTF-8?q?=EB=8A=A5,=20S3=EC=97=90=20=EC=97=85=EB=A1=9C=EB=93=9C=EB=90=9C?= =?UTF-8?q?=20=EC=BD=94=EC=8A=A4=ED=94=BC=20200=20=EC=97=91=EC=85=80=20?= =?UTF-8?q?=ED=8C=8C=EC=9D=BC=EC=9D=84=20DB=EC=97=90=20=EC=A0=80=EC=9E=A5?= =?UTF-8?q?=ED=95=98=EB=8A=94=20=EA=B8=B0=EB=8A=A5=20=EA=B5=AC=ED=98=84.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- build.gradle | 3 ++ .../dto/FavoriteStockRequestDto.java | 2 +- .../dto/FavoriteStockResponseDto.java | 2 +- .../mapper/FavoriteStockConverter.java | 4 +- .../api/stock/controller/StockController.java | 31 ++++++++++- .../demo/api/stock/dto/StockRequestDto.java | 15 ++++++ .../demo/api/stock/dto/StockResponseDto.java | 16 ++++++ .../api/stock/service/StockExcelService.java | 16 +++--- .../demo/api/stock/service/StockUseCase.java | 47 ++++++++++++++++ .../example/demo/common/config/S3Config.java | 31 +++++++++++ .../demo/common/service/S3Service.java | 54 +++++++++++++++++++ .../favoriteStock/entity/FavoriteStock.java | 4 +- .../demo/domain/report/entity/Report.java | 3 -- .../stock/exception/StockErrorStatus.java | 4 +- .../domain/stock/exception/StockHandler.java | 8 +++ src/main/resources/application.yml | 18 ++++++- 16 files changed, 235 insertions(+), 23 deletions(-) create mode 100644 src/main/java/com/example/demo/api/stock/dto/StockRequestDto.java create mode 100644 src/main/java/com/example/demo/common/config/S3Config.java create mode 100644 src/main/java/com/example/demo/common/service/S3Service.java diff --git a/build.gradle b/build.gradle index 7d8d789..4d311f3 100644 --- a/build.gradle +++ b/build.gradle @@ -44,6 +44,9 @@ dependencies { // Excel implementation 'org.apache.poi:poi-ooxml:5.5.1' + // AWS S3 + implementation 'com.amazonaws:aws-java-sdk-s3:1.12.261' + runtimeOnly 'com.mysql:mysql-connector-j' compileOnly 'org.projectlombok:lombok' 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 b98e1a6..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,5 +7,5 @@ @Builder public class FavoriteStockRequestDto { private String stockCode; - private String ticker; + 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 9a392ba..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,7 +14,7 @@ public static FavoriteStock toFavoriteStock(User user, FavoriteStockRequestDto r return FavoriteStock.builder() .user(user) .stockCode(request.getStockCode()) - .ticker(request.getTicker()) + .stockName(request.getStockName()) .isAlertEnabled(false) .build(); } @@ -24,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/stock/controller/StockController.java b/src/main/java/com/example/demo/api/stock/controller/StockController.java index 6a933cd..aec8b57 100644 --- a/src/main/java/com/example/demo/api/stock/controller/StockController.java +++ b/src/main/java/com/example/demo/api/stock/controller/StockController.java @@ -1,26 +1,53 @@ package com.example.demo.api.stock.controller; import com.example.demo.api.common.dto.ApiResponseDto; +import com.example.demo.api.stock.dto.StockRequestDto.ImportStockRequest; +import com.example.demo.api.stock.dto.StockResponseDto.ImportStockResponse; import com.example.demo.api.stock.dto.StockResponseDto.StockPriceResponse; +import com.example.demo.api.stock.dto.StockResponseDto.UploadExcelResponse; import com.example.demo.api.stock.service.StockUseCase; 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.GetMapping; import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestPart; import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.multipart.MultipartFile; -@Tag(name = "[주식] 주식 종목 조회 API") + +@Tag(name = "[주식] 주식 종목 API") @RestController @RequestMapping("/api/v1/stocks") @RequiredArgsConstructor public class StockController { + private final StockUseCase stockUseCase; - @Operation(summary = "전일(가장 최근 일자) 시가/종가 조회", description = "종목코드로 전날의(가장 최근 날짜) 시가와 종가를 KIS API에서 조회하고 DB에 저장합니다.") + @Operation(summary = "전일(가장 최근 일자) 시가/종가 조회", + description = "종목코드로 전날의(가장 최근 날짜) 시가와 종가를 KIS API에서 조회하고 DB에 저장합니다.") @GetMapping("/{stockCode}/today-price") public ApiResponseDto getLatestStockPrice(@PathVariable String stockCode) { return ApiResponseDto.onSuccess(stockUseCase.getLatestStockPriceByStockCode(stockCode)); } + + @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( + @RequestBody @Valid ImportStockRequest request) { + return ApiResponseDto.onSuccess(stockUseCase.importStockFromS3(request.getS3Uri())); + } } diff --git a/src/main/java/com/example/demo/api/stock/dto/StockRequestDto.java b/src/main/java/com/example/demo/api/stock/dto/StockRequestDto.java new file mode 100644 index 0000000..704c119 --- /dev/null +++ b/src/main/java/com/example/demo/api/stock/dto/StockRequestDto.java @@ -0,0 +1,15 @@ +package com.example.demo.api.stock.dto; + +import jakarta.validation.constraints.NotBlank; +import lombok.Getter; +import lombok.NoArgsConstructor; + +public class StockRequestDto { + + @Getter + @NoArgsConstructor + public static class ImportStockRequest { + @NotBlank(message = "S3 URI는 필수입니다.") + private String s3Uri; + } +} 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 index 7f30587..e7d328b 100644 --- a/src/main/java/com/example/demo/api/stock/dto/StockResponseDto.java +++ b/src/main/java/com/example/demo/api/stock/dto/StockResponseDto.java @@ -21,4 +21,20 @@ public static class StockPriceResponse { 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; + } } 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 index 819a9e3..2183aa4 100644 --- a/src/main/java/com/example/demo/api/stock/service/StockExcelService.java +++ b/src/main/java/com/example/demo/api/stock/service/StockExcelService.java @@ -1,11 +1,11 @@ package com.example.demo.api.stock.service; +import com.example.demo.common.service.S3Service; import com.example.demo.domain.stock.entity.Stock; 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.apache.poi.openxml4j.exceptions.InvalidFormatException; import org.apache.poi.ss.usermodel.Cell; import org.apache.poi.ss.usermodel.Row; import org.apache.poi.ss.usermodel.Sheet; @@ -13,8 +13,8 @@ import org.apache.poi.xssf.usermodel.XSSFWorkbook; import org.springframework.stereotype.Service; -import java.io.File; import java.io.IOException; +import java.io.InputStream; import java.util.ArrayList; import java.util.HashSet; import java.util.List; @@ -27,18 +27,15 @@ public class StockExcelService { private final StockQueryService stockQueryService; private final StockCommandService stockCommandService; + private final S3Service s3Service; - public int importFromExcel(String filePath) throws IOException, InvalidFormatException { - File file = new File(filePath); + public int importFromExcel(String s3Uri) throws IOException { List stocks = new ArrayList<>(); - //String fileName = file.getName(); - //String dateStr = fileName.replaceAll(".*_(\\d{8})\\.xlsx", "$1"); - //LocalDate inquiryDate = LocalDate.parse(dateStr, DateTimeFormatter.ofPattern("yyyyMMdd")); - Set existingCodes = stockQueryService.getAllStockCodes(); Set seenInFile = new HashSet<>(); - try (Workbook workbook = new XSSFWorkbook(file)) { + try (InputStream inputStream = s3Service.downloadFile(s3Uri); + Workbook workbook = new XSSFWorkbook(inputStream)) { Sheet sheet = workbook.getSheetAt(0); for (int i = 1; i <= sheet.getLastRowNum(); i++) { @@ -47,7 +44,6 @@ public int importFromExcel(String filePath) throws IOException, InvalidFormatExc String stockCode = getCellStringValue(row.getCell(0)); String stockName = getCellStringValue(row.getCell(1)); - //BigDecimal closePrice = getCellNumericValue(row.getCell(2)); if (stockCode.isEmpty() || stockName.isEmpty()) continue; if (!seenInFile.add(stockCode)) { 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 index 638b027..4404b66 100644 --- a/src/main/java/com/example/demo/api/stock/service/StockUseCase.java +++ b/src/main/java/com/example/demo/api/stock/service/StockUseCase.java @@ -2,15 +2,21 @@ import com.example.demo.api.kis.dto.KisResponseDto; import com.example.demo.api.kis.service.KisService; +import com.example.demo.api.stock.dto.StockResponseDto.ImportStockResponse; 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.common.annotation.UseCase; +import com.example.demo.common.service.S3Service; 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 lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.multipart.MultipartFile; +import java.io.IOException; import java.math.BigDecimal; import java.time.DayOfWeek; import java.time.LocalDate; @@ -23,6 +29,11 @@ public class StockUseCase { private final StockCommandService stockCommandService; private final KisService kisService; + private final S3Service s3Service; + private final StockExcelService stockExcelService; + + @Value("${cloud.aws.s3.bucket.kospi200}") + private String bucketName; private static final DateTimeFormatter DATE_FORMATTER = DateTimeFormatter.ofPattern("yyyyMMdd"); @@ -56,4 +67,40 @@ public StockPriceResponse getLatestStockPriceByStockCode(String stockCode) { private boolean isWeekend(LocalDate date) { return date.getDayOfWeek() == DayOfWeek.SATURDAY || date.getDayOfWeek() == DayOfWeek.SUNDAY; } + + @Transactional + public UploadExcelResponse uploadStockExcel(MultipartFile file) { + validateExcelFile(file); + try { + String s3Uri = "s3://" + bucketName + "/" + file.getOriginalFilename(); + String uploadedUri = s3Service.uploadFile(file, s3Uri); + return UploadExcelResponse.builder() + .s3Uri(uploadedUri) + .build(); + } catch (IOException e) { + throw StockHandler.s3FileIoError(); + } + } + + private void validateExcelFile(MultipartFile file) { + if (file == null || file.isEmpty()) { + throw StockHandler.invalidFile(); + } + String filename = file.getOriginalFilename(); + if (filename == null || !filename.toLowerCase().endsWith(".xlsx")) { + throw StockHandler.invalidFile(); + } + } + + @Transactional + public ImportStockResponse importStockFromS3(String s3Uri) { + try { + int savedCount = stockExcelService.importFromExcel(s3Uri); + return ImportStockResponse.builder() + .savedCount(savedCount) + .build(); + } catch (IOException e) { + throw StockHandler.s3FileIoError(); + } + } } 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..463341f --- /dev/null +++ b/src/main/java/com/example/demo/common/config/S3Config.java @@ -0,0 +1,31 @@ +package com.example.demo.common.config; + +import com.amazonaws.auth.AWSStaticCredentialsProvider; +import com.amazonaws.auth.BasicAWSCredentials; +import com.amazonaws.services.s3.AmazonS3; +import com.amazonaws.services.s3.AmazonS3ClientBuilder; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class S3Config { + + @Value("${cloud.aws.credentials.access-key}") + private String accessKey; + + @Value("${cloud.aws.credentials.secret-key}") + private String secretKey; + + @Value("${cloud.aws.region.static}") + private String region; + + @Bean + public AmazonS3 amazonS3() { + BasicAWSCredentials credentials = new BasicAWSCredentials(accessKey, secretKey); + return AmazonS3ClientBuilder.standard() + .withCredentials(new AWSStaticCredentialsProvider(credentials)) + .withRegion(region) + .build(); + } +} 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..e651137 --- /dev/null +++ b/src/main/java/com/example/demo/common/service/S3Service.java @@ -0,0 +1,54 @@ +package com.example.demo.common.service; + +import com.amazonaws.services.s3.AmazonS3; +import com.amazonaws.services.s3.model.ObjectMetadata; +import com.amazonaws.services.s3.model.PutObjectRequest; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.web.multipart.MultipartFile; + +import java.io.IOException; +import java.io.InputStream; +import java.net.URI; + +@Slf4j +@Service +@RequiredArgsConstructor +public class S3Service { + + private final AmazonS3 amazonS3; + + /** + * S3에 파일 업로드 + * @param file 업로드할 파일 + * @param s3Uri 저장할 S3 URI (s3://bucket/path/to/file.xlsx) + */ + public String uploadFile(MultipartFile file, String s3Uri) throws IOException { + URI uri = URI.create(s3Uri); + String bucket = uri.getHost(); + String key = uri.getPath().substring(1); + + ObjectMetadata metadata = new ObjectMetadata(); + metadata.setContentLength(file.getSize()); + metadata.setContentType(file.getContentType()); + + amazonS3.putObject(new PutObjectRequest(bucket, key, file.getInputStream(), metadata)); + log.info("S3 업로드 완료. uri={}", s3Uri); + + return 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 amazonS3.getObject(bucket, key).getObjectContent(); + } +} 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 cf149b9..fb894c8 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 = 20) + private String stockName; @Builder.Default @Column(name = "is_alert_enabled", nullable = false) 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/exception/StockErrorStatus.java b/src/main/java/com/example/demo/domain/stock/exception/StockErrorStatus.java index 52f50f6..7040e6c 100644 --- a/src/main/java/com/example/demo/domain/stock/exception/StockErrorStatus.java +++ b/src/main/java/com/example/demo/domain/stock/exception/StockErrorStatus.java @@ -19,7 +19,9 @@ public enum StockErrorStatus implements BaseErrorCode { 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 호출에 실패했습니다."); + 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 파일이 아닙니다."); private final HttpStatus httpStatus; private final Integer code; 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 index ba82b78..8718cbf 100644 --- a/src/main/java/com/example/demo/domain/stock/exception/StockHandler.java +++ b/src/main/java/com/example/demo/domain/stock/exception/StockHandler.java @@ -28,4 +28,12 @@ public static StockHandler stockPriceResponseEmpty() { 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); + } } diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 35abcdf..fa0c14f 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -52,4 +52,20 @@ cors: kis: base-url: https://openapi.koreainvestment.com:9443 app-key: ${KIS_APP_KEY} - app-secret: ${KIS_APP_SECRET} \ No newline at end of file + app-secret: ${KIS_APP_SECRET} + +cloud: + aws: + credentials: + access-key: ${AWS_ACCESS_KEY} + secret-key: ${AWS_SECRET_KEY} + 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} \ No newline at end of file From 0179dbaab8baad69b61ac6d97219176b0126d572 Mon Sep 17 00:00:00 2001 From: Dongwoo Go Date: Thu, 21 May 2026 19:04:38 +0900 Subject: [PATCH 06/24] =?UTF-8?q?feat:=20=EC=BD=94=EC=8A=A4=ED=94=BC=20200?= =?UTF-8?q?=20=EC=A2=85=EB=AA=A9=20=EC=97=85=EB=A1=9C=EB=93=9C=20=EB=B0=8F?= =?UTF-8?q?=20=EC=A0=80=EC=9E=A5=20=EA=B8=B0=EB=8A=A5=20=EC=88=98=EC=A0=95?= =?UTF-8?q?.=20=EC=A0=84=EC=A0=9C=20=EC=A2=85=EB=AA=A9=20pagenation?= =?UTF-8?q?=EC=9C=BC=EB=A1=9C=20=EC=A1=B0=ED=9A=8C=20api=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- build.gradle | 2 +- .../demo/api/kis/service/KisTokenService.java | 9 ++++- .../api/stock/controller/StockController.java | 40 ++++++++++++------- .../demo/api/stock/dto/StockResponseDto.java | 22 ++++++++++ .../demo/api/stock/mapper/StockConverter.java | 30 ++++++++++++++ .../demo/api/stock/service/StockUseCase.java | 9 +++++ .../example/demo/common/config/S3Config.java | 20 +++------- .../demo/common/service/S3Service.java | 39 ++++++++++++------ .../favoriteStock/entity/FavoriteStock.java | 2 +- .../demo/domain/stock/entity/Stock.java | 4 +- .../stock/service/StockQueryService.java | 3 ++ .../stock/service/StockQueryServiceImpl.java | 8 ++++ src/main/resources/application.yml | 3 -- 13 files changed, 140 insertions(+), 51 deletions(-) diff --git a/build.gradle b/build.gradle index 4d311f3..b908f0f 100644 --- a/build.gradle +++ b/build.gradle @@ -45,7 +45,7 @@ dependencies { implementation 'org.apache.poi:poi-ooxml:5.5.1' // AWS S3 - implementation 'com.amazonaws:aws-java-sdk-s3:1.12.261' + implementation 'software.amazon.awssdk:s3:2.25.26' runtimeOnly 'com.mysql:mysql-connector-j' 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 index 9ba0fae..d597af2 100644 --- a/src/main/java/com/example/demo/api/kis/service/KisTokenService.java +++ b/src/main/java/com/example/demo/api/kis/service/KisTokenService.java @@ -13,6 +13,8 @@ import java.time.Duration; import java.time.LocalDateTime; +import java.time.ZoneId; +import java.time.ZonedDateTime; import java.time.format.DateTimeFormatter; import java.util.Map; import java.util.Objects; @@ -72,8 +74,11 @@ public String getAccessToken() { private Duration calculateTtl(String expiredAt) { try { - LocalDateTime expiry = LocalDateTime.parse(expiredAt, EXPIRED_FORMATTER); - Duration ttl = Duration.between(LocalDateTime.now(), expiry).minusMinutes(5); + ZonedDateTime expiry = LocalDateTime.parse(expiredAt, EXPIRED_FORMATTER) + .atZone(ZoneId.of("Asia/Seoul")); + ZonedDateTime now = ZonedDateTime.now(ZoneId.of("Asia/Seoul")); + Duration ttl = Duration.between(now, expiry).minusMinutes(5); + return (ttl.isNegative() || ttl.isZero()) ? Duration.ofMinutes(1) : ttl; } catch (Exception e) { log.warn("KIS 토큰 만료 시각 파싱 실패. 기본값 23시간 적용. expiredAt={}", expiredAt); 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 index aec8b57..b060282 100644 --- a/src/main/java/com/example/demo/api/stock/controller/StockController.java +++ b/src/main/java/com/example/demo/api/stock/controller/StockController.java @@ -3,20 +3,22 @@ import com.example.demo.api.common.dto.ApiResponseDto; import com.example.demo.api.stock.dto.StockRequestDto.ImportStockRequest; 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.Parameter; import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.PathVariable; -import org.springframework.web.bind.annotation.PostMapping; -import org.springframework.web.bind.annotation.RequestBody; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RequestPart; -import org.springframework.web.bind.annotation.RestController; +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.web.bind.annotation.*; import org.springframework.web.multipart.MultipartFile; @@ -28,13 +30,6 @@ public class StockController { private final StockUseCase stockUseCase; - @Operation(summary = "전일(가장 최근 일자) 시가/종가 조회", - description = "종목코드로 전날의(가장 최근 날짜) 시가와 종가를 KIS API에서 조회하고 DB에 저장합니다.") - @GetMapping("/{stockCode}/today-price") - public ApiResponseDto getLatestStockPrice(@PathVariable String stockCode) { - return ApiResponseDto.onSuccess(stockUseCase.getLatestStockPriceByStockCode(stockCode)); - } - @Operation(summary = "코스피200 엑셀 파일 S3 업로드", description = "로컬에서 다운로드한 KRX 코스피200 엑셀 파일을 S3에 업로드합니다.") @PostMapping(value = "/excel/upload", consumes = "multipart/form-data") @@ -50,4 +45,21 @@ public ApiResponseDto importStockFromS3( @RequestBody @Valid ImportStockRequest request) { return ApiResponseDto.onSuccess(stockUseCase.importStockFromS3(request.getS3Uri())); } + + @Operation(summary = "전일(가장 최근 일자) 시가/종가 조회", + description = "종목코드로 전날의(가장 최근 날짜) 시가와 종가를 KIS API에서 조회하고 DB에 저장합니다.") + @GetMapping("/{stockCode}/today-price") + public ApiResponseDto getLatestStockPrice(@PathVariable 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 index e7d328b..6e4b297 100644 --- a/src/main/java/com/example/demo/api/stock/dto/StockResponseDto.java +++ b/src/main/java/com/example/demo/api/stock/dto/StockResponseDto.java @@ -7,6 +7,7 @@ import java.math.BigDecimal; import java.time.LocalDate; +import java.util.List; public class StockResponseDto { @@ -37,4 +38,25 @@ public static class UploadExcelResponse { public static class ImportStockResponse { private int savedCount; } + + @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/mapper/StockConverter.java b/src/main/java/com/example/demo/api/stock/mapper/StockConverter.java index b41e083..1b2bbf4 100644 --- a/src/main/java/com/example/demo/api/stock/mapper/StockConverter.java +++ b/src/main/java/com/example/demo/api/stock/mapper/StockConverter.java @@ -1,7 +1,13 @@ 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 { @@ -14,4 +20,28 @@ public static StockPriceResponse toStockPriceResponse(Stock stock) { .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/StockUseCase.java b/src/main/java/com/example/demo/api/stock/service/StockUseCase.java index 4404b66..97dfd4d 100644 --- a/src/main/java/com/example/demo/api/stock/service/StockUseCase.java +++ b/src/main/java/com/example/demo/api/stock/service/StockUseCase.java @@ -11,8 +11,11 @@ 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 org.springframework.beans.factory.annotation.Value; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; import org.springframework.transaction.annotation.Transactional; import org.springframework.web.multipart.MultipartFile; @@ -28,6 +31,7 @@ public class StockUseCase { private final StockCommandService stockCommandService; + private final StockQueryService stockQueryService; private final KisService kisService; private final S3Service s3Service; private final StockExcelService stockExcelService; @@ -103,4 +107,9 @@ public ImportStockResponse importStockFromS3(String s3Uri) { throw StockHandler.s3FileIoError(); } } + + @Transactional(readOnly = true) + public Page getAllStocks(Pageable pageable) { + return stockQueryService.getAllStocks(pageable); + } } diff --git a/src/main/java/com/example/demo/common/config/S3Config.java b/src/main/java/com/example/demo/common/config/S3Config.java index 463341f..45b9282 100644 --- a/src/main/java/com/example/demo/common/config/S3Config.java +++ b/src/main/java/com/example/demo/common/config/S3Config.java @@ -1,31 +1,21 @@ package com.example.demo.common.config; -import com.amazonaws.auth.AWSStaticCredentialsProvider; -import com.amazonaws.auth.BasicAWSCredentials; -import com.amazonaws.services.s3.AmazonS3; -import com.amazonaws.services.s3.AmazonS3ClientBuilder; 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.credentials.access-key}") - private String accessKey; - - @Value("${cloud.aws.credentials.secret-key}") - private String secretKey; - @Value("${cloud.aws.region.static}") private String region; @Bean - public AmazonS3 amazonS3() { - BasicAWSCredentials credentials = new BasicAWSCredentials(accessKey, secretKey); - return AmazonS3ClientBuilder.standard() - .withCredentials(new AWSStaticCredentialsProvider(credentials)) - .withRegion(region) + public S3Client s3Client() { + return S3Client.builder() + .region(Region.of(region)) .build(); } } diff --git a/src/main/java/com/example/demo/common/service/S3Service.java b/src/main/java/com/example/demo/common/service/S3Service.java index e651137..8f7c2ff 100644 --- a/src/main/java/com/example/demo/common/service/S3Service.java +++ b/src/main/java/com/example/demo/common/service/S3Service.java @@ -1,12 +1,15 @@ package com.example.demo.common.service; -import com.amazonaws.services.s3.AmazonS3; -import com.amazonaws.services.s3.model.ObjectMetadata; -import com.amazonaws.services.s3.model.PutObjectRequest; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; import org.springframework.web.multipart.MultipartFile; +import software.amazon.awssdk.core.ResponseInputStream; +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.GetObjectResponse; +import software.amazon.awssdk.services.s3.model.PutObjectRequest; import java.io.IOException; import java.io.InputStream; @@ -17,31 +20,35 @@ @RequiredArgsConstructor public class S3Service { - private final AmazonS3 amazonS3; + private final S3Client s3Client; /** * S3에 파일 업로드 - * @param file 업로드할 파일 - * @param s3Uri 저장할 S3 URI (s3://bucket/path/to/file.xlsx) + * @param file 업로드할 파일 + * @param s3Uri 저장할 S3 URI (s3://bucket/path/to/file.xlsx) */ public String uploadFile(MultipartFile file, String s3Uri) throws IOException { URI uri = URI.create(s3Uri); String bucket = uri.getHost(); String key = uri.getPath().substring(1); - ObjectMetadata metadata = new ObjectMetadata(); - metadata.setContentLength(file.getSize()); - metadata.setContentType(file.getContentType()); + s3Client.putObject( + PutObjectRequest.builder() + .bucket(bucket) + .key(key) + .contentType(file.getContentType()) + .contentLength(file.getSize()) + .build(), + RequestBody.fromInputStream(file.getInputStream(), file.getSize()) + ); - amazonS3.putObject(new PutObjectRequest(bucket, key, file.getInputStream(), metadata)); log.info("S3 업로드 완료. uri={}", s3Uri); - return s3Uri; } /** * S3에서 파일 다운로드 - * @param s3Uri 다운로드할 S3 URI (s3://bucket/path/to/file.xlsx) + * @param s3Uri 다운로드할 S3 URI (s3://bucket/path/to/file.xlsx) */ public InputStream downloadFile(String s3Uri) { URI uri = URI.create(s3Uri); @@ -49,6 +56,12 @@ public InputStream downloadFile(String s3Uri) { String key = uri.getPath().substring(1); log.info("S3 다운로드 요청. uri={}", s3Uri); - return amazonS3.getObject(bucket, key).getObjectContent(); + + return s3Client.getObject( + GetObjectRequest.builder() + .bucket(bucket) + .key(key) + .build() + ); } } 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 fb894c8..3cc1557 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,7 +34,7 @@ public class FavoriteStock extends BaseTimeEntity { @Column(name = "stock_code", nullable = false, length = 20) private String stockCode; - @Column(name = "stock_name", nullable = false, length = 20) + @Column(name = "stock_name", nullable = false, length = 100) private String stockName; @Builder.Default 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 index 0c82023..0a64a4c 100644 --- a/src/main/java/com/example/demo/domain/stock/entity/Stock.java +++ b/src/main/java/com/example/demo/domain/stock/entity/Stock.java @@ -23,10 +23,10 @@ public class Stock extends BaseTimeEntity { @Column(name = "stock_id") private Long id; - @Column(name = "stock_code", nullable = false, unique = true) + @Column(name = "stock_code", nullable = false, unique = true, length = 20) private String stockCode; - @Column(name = "stock_name", nullable = false) + @Column(name = "stock_name", nullable = false, length = 100) private String stockName; @Column(name = "open_price") 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 index dc7dcbe..a11154f 100644 --- a/src/main/java/com/example/demo/domain/stock/service/StockQueryService.java +++ b/src/main/java/com/example/demo/domain/stock/service/StockQueryService.java @@ -1,10 +1,13 @@ 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.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 index b001d51..0d902ef 100644 --- a/src/main/java/com/example/demo/domain/stock/service/StockQueryServiceImpl.java +++ b/src/main/java/com/example/demo/domain/stock/service/StockQueryServiceImpl.java @@ -7,6 +7,9 @@ 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.Set; @@ -27,4 +30,9 @@ public Stock getStockByCode(String stockCode) { public Set getAllStockCodes() { return new HashSet<>(stockRepository.findAllStockCodes()); } + + @Override + public Page getAllStocks(Pageable pageable) { + return stockRepository.findAll(pageable); + } } diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index fa0c14f..7eae799 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -56,9 +56,6 @@ kis: cloud: aws: - credentials: - access-key: ${AWS_ACCESS_KEY} - secret-key: ${AWS_SECRET_KEY} region: static: ap-northeast-2 auto: false From b8ba56e60dcff2a8a27a113a8771b5c63d5dc369 Mon Sep 17 00:00:00 2001 From: Dongwoo Go Date: Thu, 21 May 2026 19:14:44 +0900 Subject: [PATCH 07/24] =?UTF-8?q?chore:=20=EC=98=A4=ED=83=80=20=EC=88=98?= =?UTF-8?q?=EC=A0=95.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/example/demo/api/stock/controller/StockController.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 index b060282..a4e77e8 100644 --- a/src/main/java/com/example/demo/api/stock/controller/StockController.java +++ b/src/main/java/com/example/demo/api/stock/controller/StockController.java @@ -48,7 +48,7 @@ public ApiResponseDto importStockFromS3( @Operation(summary = "전일(가장 최근 일자) 시가/종가 조회", description = "종목코드로 전날의(가장 최근 날짜) 시가와 종가를 KIS API에서 조회하고 DB에 저장합니다.") - @GetMapping("/{stockCode}/today-price") + @GetMapping("/{stockCode}/latest-price") public ApiResponseDto getLatestStockPrice(@PathVariable String stockCode) { return ApiResponseDto.onSuccess(stockUseCase.getLatestStockPriceByStockCode(stockCode)); } From ee01d2dc59bc3675885703c10a02837fc4db37de Mon Sep 17 00:00:00 2001 From: Dongwoo Go Date: Wed, 27 May 2026 17:03:26 +0900 Subject: [PATCH 08/24] =?UTF-8?q?chore:=20KisController=20=EC=82=AD?= =?UTF-8?q?=EC=A0=9C,=20importStockFromS3=20description=20=EC=88=98?= =?UTF-8?q?=EC=A0=95.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/example/demo/api/kis/controller/KisController.java | 5 ----- .../example/demo/api/stock/controller/StockController.java | 2 +- 2 files changed, 1 insertion(+), 6 deletions(-) delete mode 100644 src/main/java/com/example/demo/api/kis/controller/KisController.java diff --git a/src/main/java/com/example/demo/api/kis/controller/KisController.java b/src/main/java/com/example/demo/api/kis/controller/KisController.java deleted file mode 100644 index 0d4cc7f..0000000 --- a/src/main/java/com/example/demo/api/kis/controller/KisController.java +++ /dev/null @@ -1,5 +0,0 @@ -package com.example.demo.api.kis.controller; - -public class KisController { - -} 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 index a4e77e8..5b6f01f 100644 --- a/src/main/java/com/example/demo/api/stock/controller/StockController.java +++ b/src/main/java/com/example/demo/api/stock/controller/StockController.java @@ -39,7 +39,7 @@ public ApiResponseDto uploadStockExcel( } @Operation(summary = "S3 엑셀 파일 DB 저장", - description = "S3에 업로드된 코스피200 엑셀 파일을 읽어 종목 정보를 DB에 저장합니다.") + description = "S3에 업로드된 코스피200 엑셀 파일을 읽어 종목 정보를 DB에 저장합니다. \n/stocks/excel/upload에서 응답받은 uri를 입력해주세요.") @PostMapping("/excel/import") public ApiResponseDto importStockFromS3( @RequestBody @Valid ImportStockRequest request) { From 65a431d77de67b40806a38a879645ab75f36cb07 Mon Sep 17 00:00:00 2001 From: Dongwoo Go Date: Mon, 1 Jun 2026 21:56:40 +0900 Subject: [PATCH 09/24] =?UTF-8?q?refactor:=20util=20=EA=B4=80=EB=A0=A8=20?= =?UTF-8?q?=ED=95=A8=EC=88=98=20=EB=A6=AC=ED=8E=99=ED=84=B0=EB=A7=81.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../demo/api/kis/service/KisTokenService.java | 29 +++------------ .../demo/common/consts/StaticVariable.java | 10 ++++++ .../example/demo/common/util/DateUtil.java | 13 +++++++ .../example/demo/common/util/ExcelUtil.java | 31 ++++++++++++++++ .../example/demo/common/util/RedisUtil.java | 36 +++++++++++++++++++ 5 files changed, 95 insertions(+), 24 deletions(-) create mode 100644 src/main/java/com/example/demo/common/util/DateUtil.java create mode 100644 src/main/java/com/example/demo/common/util/ExcelUtil.java create mode 100644 src/main/java/com/example/demo/common/util/RedisUtil.java 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 index d597af2..13e5dcc 100644 --- a/src/main/java/com/example/demo/api/kis/service/KisTokenService.java +++ b/src/main/java/com/example/demo/api/kis/service/KisTokenService.java @@ -2,6 +2,7 @@ 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 lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -12,24 +13,18 @@ import reactor.core.publisher.Mono; import java.time.Duration; -import java.time.LocalDateTime; -import java.time.ZoneId; -import java.time.ZonedDateTime; -import java.time.format.DateTimeFormatter; import java.util.Map; import java.util.Objects; +import static com.example.demo.common.consts.StaticVariable.TOKEN_KEY; + @Slf4j @Service @RequiredArgsConstructor public class KisTokenService { - - private static final String TOKEN_KEY = "kis:access_token"; - private static final DateTimeFormatter EXPIRED_FORMATTER = - DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"); - private final WebClient webClient; private final RedisService redisService; + private final RedisUtil redisUtil; @Value("${kis.app-key}") private String appKey; @@ -65,24 +60,10 @@ public String getAccessToken() { String token = Objects.requireNonNull(tokenResponse).getAccessToken(); - Duration ttl = calculateTtl(tokenResponse.getAccessTokenTokenExpired()); + Duration ttl = redisUtil.calculateTtl(tokenResponse.getAccessTokenTokenExpired()); redisService.setKisTokenExpiresValueWithTtl(TOKEN_KEY, token, ttl); log.info("KIS 토큰 Redis 저장 완료. TTL={}s", ttl.getSeconds()); return token; } - - private Duration calculateTtl(String expiredAt) { - try { - ZonedDateTime expiry = LocalDateTime.parse(expiredAt, EXPIRED_FORMATTER) - .atZone(ZoneId.of("Asia/Seoul")); - ZonedDateTime now = ZonedDateTime.now(ZoneId.of("Asia/Seoul")); - Duration ttl = Duration.between(now, expiry).minusMinutes(5); - - return (ttl.isNegative() || ttl.isZero()) ? Duration.ofMinutes(1) : ttl; - } catch (Exception e) { - log.warn("KIS 토큰 만료 시각 파싱 실패. 기본값 23시간 적용. expiredAt={}", expiredAt); - return Duration.ofHours(23); - } - } } 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..7a791b1 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"; @@ -15,6 +18,7 @@ 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"); //OAuth2 public static final String KAKAO_OAUTH2_AUTHORIZATION_URI = "/oauth2/authorization/kakao"; @@ -23,5 +27,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/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..3c32d70 --- /dev/null +++ b/src/main/java/com/example/demo/common/util/ExcelUtil.java @@ -0,0 +1,31 @@ +package com.example.demo.common.util; + +import com.example.demo.domain.stock.exception.StockHandler; +import org.apache.poi.ss.usermodel.Cell; +import org.springframework.stereotype.Component; +import org.springframework.web.multipart.MultipartFile; + +@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.toLowerCase().endsWith(".xlsx")) { + 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..354dd1f --- /dev/null +++ b/src/main/java/com/example/demo/common/util/RedisUtil.java @@ -0,0 +1,36 @@ +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) { + 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); + } + } +} From 6d4b3b8c6af140cdc4f6b13b096db9cf05b399e1 Mon Sep 17 00:00:00 2001 From: Dongwoo Go Date: Wed, 3 Jun 2026 15:45:29 +0900 Subject: [PATCH 10/24] =?UTF-8?q?refactor:=20=EC=BD=94=EC=8A=A4=ED=94=BC?= =?UTF-8?q?=20200=20=EC=97=91=EC=85=80=20=ED=8C=8C=EC=9D=BC=20S3=20?= =?UTF-8?q?=EC=97=85=EB=A1=9C=EB=93=9C=20uri=20=EB=B0=A9=EC=8B=9D=20?= =?UTF-8?q?=EC=88=98=EC=A0=95.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../demo/api/stock/dto/StockRequestDto.java | 2 + .../demo/api/stock/service/StockUseCase.java | 86 ++++++++----------- .../demo/common/consts/StaticVariable.java | 5 ++ .../demo/common/service/S3Service.java | 6 +- .../stock/service/StockQueryService.java | 1 + .../stock/service/StockQueryServiceImpl.java | 2 + .../controller/TokenApiController.java | 2 - 7 files changed, 48 insertions(+), 56 deletions(-) diff --git a/src/main/java/com/example/demo/api/stock/dto/StockRequestDto.java b/src/main/java/com/example/demo/api/stock/dto/StockRequestDto.java index 704c119..5a4743b 100644 --- a/src/main/java/com/example/demo/api/stock/dto/StockRequestDto.java +++ b/src/main/java/com/example/demo/api/stock/dto/StockRequestDto.java @@ -6,10 +6,12 @@ public class StockRequestDto { + /* @Getter @NoArgsConstructor public static class ImportStockRequest { @NotBlank(message = "S3 URI는 필수입니다.") private String s3Uri; } + */ } 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 index 97dfd4d..a223d0b 100644 --- a/src/main/java/com/example/demo/api/stock/service/StockUseCase.java +++ b/src/main/java/com/example/demo/api/stock/service/StockUseCase.java @@ -2,12 +2,13 @@ import com.example.demo.api.kis.dto.KisResponseDto; import com.example.demo.api.kis.service.KisService; -import com.example.demo.api.stock.dto.StockResponseDto.ImportStockResponse; -import com.example.demo.api.stock.dto.StockResponseDto.StockPriceResponse; -import com.example.demo.api.stock.dto.StockResponseDto.UploadExcelResponse; +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; @@ -16,16 +17,17 @@ import org.springframework.beans.factory.annotation.Value; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; -import org.springframework.transaction.annotation.Transactional; import org.springframework.web.multipart.MultipartFile; +import software.amazon.awssdk.core.exception.SdkException; import java.io.IOException; import java.math.BigDecimal; -import java.time.DayOfWeek; import java.time.LocalDate; -import java.time.format.DateTimeFormatter; import java.util.List; +import static com.example.demo.common.consts.StaticVariable.DATE_FORMATTER; +import static com.example.demo.common.consts.StaticVariable.KOSPI200_FILE_KEY; + @UseCase @RequiredArgsConstructor public class StockUseCase { @@ -35,18 +37,43 @@ public class StockUseCase { 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; - private static final DateTimeFormatter DATE_FORMATTER = DateTimeFormatter.ofPattern("yyyyMMdd"); + 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(); + } - @Transactional public StockPriceResponse getLatestStockPriceByStockCode(String stockCode) { for (int i = 1; i <= 10; i++) { LocalDate targetDate = LocalDate.now().minusDays(i); - if (isWeekend(targetDate)) continue; + if (dateUtil.isWeekend(targetDate)) continue; String dateStr = targetDate.format(DATE_FORMATTER); KisResponseDto response = kisService.getDailyStockPrice(stockCode, dateStr, dateStr); @@ -68,47 +95,6 @@ public StockPriceResponse getLatestStockPriceByStockCode(String stockCode) { throw StockHandler.stockPriceResponseEmpty(); } - private boolean isWeekend(LocalDate date) { - return date.getDayOfWeek() == DayOfWeek.SATURDAY || date.getDayOfWeek() == DayOfWeek.SUNDAY; - } - - @Transactional - public UploadExcelResponse uploadStockExcel(MultipartFile file) { - validateExcelFile(file); - try { - String s3Uri = "s3://" + bucketName + "/" + file.getOriginalFilename(); - String uploadedUri = s3Service.uploadFile(file, s3Uri); - return UploadExcelResponse.builder() - .s3Uri(uploadedUri) - .build(); - } catch (IOException e) { - throw StockHandler.s3FileIoError(); - } - } - - private void validateExcelFile(MultipartFile file) { - if (file == null || file.isEmpty()) { - throw StockHandler.invalidFile(); - } - String filename = file.getOriginalFilename(); - if (filename == null || !filename.toLowerCase().endsWith(".xlsx")) { - throw StockHandler.invalidFile(); - } - } - - @Transactional - public ImportStockResponse importStockFromS3(String s3Uri) { - try { - int savedCount = stockExcelService.importFromExcel(s3Uri); - return ImportStockResponse.builder() - .savedCount(savedCount) - .build(); - } catch (IOException e) { - throw StockHandler.s3FileIoError(); - } - } - - @Transactional(readOnly = true) public Page getAllStocks(Pageable pageable) { return stockQueryService.getAllStocks(pageable); } 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 7a791b1..f7d3def 100644 --- a/src/main/java/com/example/demo/common/consts/StaticVariable.java +++ b/src/main/java/com/example/demo/common/consts/StaticVariable.java @@ -19,6 +19,11 @@ public class StaticVariable { 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"; diff --git a/src/main/java/com/example/demo/common/service/S3Service.java b/src/main/java/com/example/demo/common/service/S3Service.java index 8f7c2ff..85c9e4d 100644 --- a/src/main/java/com/example/demo/common/service/S3Service.java +++ b/src/main/java/com/example/demo/common/service/S3Service.java @@ -4,11 +4,9 @@ import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; import org.springframework.web.multipart.MultipartFile; -import software.amazon.awssdk.core.ResponseInputStream; 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.GetObjectResponse; import software.amazon.awssdk.services.s3.model.PutObjectRequest; import java.io.IOException; @@ -24,10 +22,11 @@ public class S3Service { /** * S3에 파일 업로드 + * * @param file 업로드할 파일 * @param s3Uri 저장할 S3 URI (s3://bucket/path/to/file.xlsx) */ - public String uploadFile(MultipartFile file, String s3Uri) throws IOException { + public void uploadFile(MultipartFile file, String s3Uri) throws IOException { URI uri = URI.create(s3Uri); String bucket = uri.getHost(); String key = uri.getPath().substring(1); @@ -43,7 +42,6 @@ public String uploadFile(MultipartFile file, String s3Uri) throws IOException { ); log.info("S3 업로드 완료. uri={}", s3Uri); - return s3Uri; } /** 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 index a11154f..21ba91d 100644 --- a/src/main/java/com/example/demo/domain/stock/service/StockQueryService.java +++ b/src/main/java/com/example/demo/domain/stock/service/StockQueryService.java @@ -4,6 +4,7 @@ import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; +import java.util.Map; import java.util.Set; public interface StockQueryService { 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 index 0d902ef..356dd78 100644 --- a/src/main/java/com/example/demo/domain/stock/service/StockQueryServiceImpl.java +++ b/src/main/java/com/example/demo/domain/stock/service/StockQueryServiceImpl.java @@ -11,7 +11,9 @@ 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) 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 df36399..5726ed4 100644 --- a/src/main/java/com/example/demo/security/controller/TokenApiController.java +++ b/src/main/java/com/example/demo/security/controller/TokenApiController.java @@ -69,6 +69,4 @@ public ApiResponseDto logout(HttpServletRequest request, HttpServletRespon cookieUtil.expireRefreshTokenCookie(response); return ApiResponseDto.onSuccess(null); } - - } From 072f2008b28b5ccdc635d498a9438d6cb400faa7 Mon Sep 17 00:00:00 2001 From: Dongwoo Go Date: Wed, 3 Jun 2026 15:46:19 +0900 Subject: [PATCH 11/24] =?UTF-8?q?refactor:=20=EC=BD=94=EC=8A=A4=ED=94=BC?= =?UTF-8?q?=20200=20=EC=A2=85=EB=AA=A9=EC=9D=84=20=EC=97=85=EB=A1=9C?= =?UTF-8?q?=EB=93=9C=20=ED=95=A0=20=EB=95=8C,=20=ED=95=84=EC=9A=94?= =?UTF-8?q?=ED=95=9C=20=EC=BC=80=EC=9D=B4=EC=8A=A4(=EC=8B=A0=EA=B7=9C=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80,=20=EC=82=AD=EC=A0=9C,=20=EC=97=85=EB=8D=B0?= =?UTF-8?q?=EC=9D=B4=ED=8A=B8)=EB=A5=BC=20=EC=B6=94=EA=B0=80=ED=95=98?= =?UTF-8?q?=EC=97=AC=20=EB=A6=AC=ED=8C=A9=ED=86=A0=EB=A7=81.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../api/stock/controller/StockController.java | 10 +-- .../demo/api/stock/dto/StockResponseDto.java | 2 + .../api/stock/dto/StockSyncResultDto.java | 8 +++ .../api/stock/service/StockExcelService.java | 70 ++++++++----------- .../example/demo/common/util/ExcelUtil.java | 18 ++++- .../demo/domain/stock/entity/Stock.java | 4 ++ .../stock/repository/StockRepository.java | 9 +++ .../stock/service/StockCommandService.java | 6 ++ .../service/StockCommandServiceImpl.java | 61 +++++++++++++++- 9 files changed, 139 insertions(+), 49 deletions(-) create mode 100644 src/main/java/com/example/demo/api/stock/dto/StockSyncResultDto.java 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 index 5b6f01f..6516ea2 100644 --- a/src/main/java/com/example/demo/api/stock/controller/StockController.java +++ b/src/main/java/com/example/demo/api/stock/controller/StockController.java @@ -1,7 +1,6 @@ package com.example.demo.api.stock.controller; import com.example.demo.api.common.dto.ApiResponseDto; -import com.example.demo.api.stock.dto.StockRequestDto.ImportStockRequest; 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; @@ -10,9 +9,7 @@ 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.Parameter; import io.swagger.v3.oas.annotations.tags.Tag; -import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import org.springframework.data.domain.Page; import org.springframework.data.domain.PageRequest; @@ -39,11 +36,10 @@ public ApiResponseDto uploadStockExcel( } @Operation(summary = "S3 엑셀 파일 DB 저장", - description = "S3에 업로드된 코스피200 엑셀 파일을 읽어 종목 정보를 DB에 저장합니다. \n/stocks/excel/upload에서 응답받은 uri를 입력해주세요.") + description = "S3에 업로드된 코스피200 엑셀 파일을 읽어 종목 정보를 DB에 저장합니다.") @PostMapping("/excel/import") - public ApiResponseDto importStockFromS3( - @RequestBody @Valid ImportStockRequest request) { - return ApiResponseDto.onSuccess(stockUseCase.importStockFromS3(request.getS3Uri())); + public ApiResponseDto importStockFromS3() { + return ApiResponseDto.onSuccess(stockUseCase.importStockFromS3()); } @Operation(summary = "전일(가장 최근 일자) 시가/종가 조회", 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 index 6e4b297..5fc5a8c 100644 --- a/src/main/java/com/example/demo/api/stock/dto/StockResponseDto.java +++ b/src/main/java/com/example/demo/api/stock/dto/StockResponseDto.java @@ -37,6 +37,8 @@ public static class UploadExcelResponse { @AllArgsConstructor public static class ImportStockResponse { private int savedCount; + private int updatedCount; + private int deletedCount; } @Getter 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/service/StockExcelService.java b/src/main/java/com/example/demo/api/stock/service/StockExcelService.java index 2183aa4..48ccc03 100644 --- a/src/main/java/com/example/demo/api/stock/service/StockExcelService.java +++ b/src/main/java/com/example/demo/api/stock/service/StockExcelService.java @@ -1,84 +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.domain.stock.entity.Stock; +import com.example.demo.common.util.ExcelUtil; +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.apache.poi.ss.usermodel.Cell; 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.ArrayList; -import java.util.HashSet; -import java.util.List; -import java.util.Set; +import java.util.*; @Slf4j @Service @RequiredArgsConstructor public class StockExcelService { - private final StockQueryService stockQueryService; private final StockCommandService stockCommandService; private final S3Service s3Service; + private final ExcelUtil excelUtil; - public int importFromExcel(String s3Uri) throws IOException { - List stocks = new ArrayList<>(); - Set existingCodes = stockQueryService.getAllStockCodes(); - Set seenInFile = new HashSet<>(); + 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 = getCellStringValue(row.getCell(0)); - String stockName = getCellStringValue(row.getCell(1)); + String stockCode = excelUtil.getCellStringValue(row.getCell(0)); + String stockName = excelUtil.getCellStringValue(row.getCell(1)); if (stockCode.isEmpty() || stockName.isEmpty()) continue; - if (!seenInFile.add(stockCode)) { + + if (excelStocks.containsKey(stockCode)) { log.info("파일 내 중복 종목 스킵: {}", stockCode); continue; } - if (existingCodes.contains(stockCode)) { - log.info("이미 존재하는 종목 스킵: {}", stockCode); - continue; - } - Stock stock = Stock.builder() - .stockCode(stockCode) - .stockName(stockName) - .closePrice(null) - .openPrice(null) - .inquiryDate(null) - .build(); - - stocks.add(stock); + excelStocks.put(stockCode, stockName); } - } - stockCommandService.saveAllStocks(stocks); - log.info("총 {}개 종목 저장 완료", stocks.size()); + } catch (SdkException e) { + log.error("S3 다운로드 실패. uri={}", s3Uri, e); + throw StockHandler.s3FileIoError(); + } catch (IOException | IllegalArgumentException e) { + log.error("유효하지 않은 엑셀 파일. uri={}", s3Uri, e); + throw StockHandler.invalidFile(); + } - return stocks.size(); - } + if (excelStocks.isEmpty()) { + throw StockHandler.invalidFile(); + } - private 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 -> ""; - }; + return excelStocks; } } diff --git a/src/main/java/com/example/demo/common/util/ExcelUtil.java b/src/main/java/com/example/demo/common/util/ExcelUtil.java index 3c32d70..ca78a3e 100644 --- a/src/main/java/com/example/demo/common/util/ExcelUtil.java +++ b/src/main/java/com/example/demo/common/util/ExcelUtil.java @@ -1,10 +1,16 @@ 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 { @@ -23,9 +29,19 @@ public void validateExcelFile(MultipartFile file) { if (file == null || file.isEmpty()) { throw StockHandler.invalidFile(); } + String filename = file.getOriginalFilename(); - if (filename == null || !filename.toLowerCase().endsWith(".xlsx")) { + if (filename == null || !filename.trim().toLowerCase(Locale.ROOT).endsWith(XLSX_EXTENSION)) { + throw StockHandler.invalidFile(); + } + + if (file.getSize() > MAX_FILE_SIZE) { throw StockHandler.invalidFile(); } + + String contentType = file.getContentType(); + if (contentType != null && !contentType.equals(XLSX_CONTENT_TYPE)) { + log.warn("예상과 다른 Content-Type. filename={}, contentType={}", filename, contentType); + } } } 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 index 0a64a4c..95717f0 100644 --- a/src/main/java/com/example/demo/domain/stock/entity/Stock.java +++ b/src/main/java/com/example/demo/domain/stock/entity/Stock.java @@ -43,4 +43,8 @@ public void updatePrice(BigDecimal openPrice, BigDecimal closePrice, LocalDate i 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/repository/StockRepository.java b/src/main/java/com/example/demo/domain/stock/repository/StockRepository.java index 09e490d..a8c26bf 100644 --- a/src/main/java/com/example/demo/domain/stock/repository/StockRepository.java +++ b/src/main/java/com/example/demo/domain/stock/repository/StockRepository.java @@ -2,8 +2,11 @@ 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; @@ -12,4 +15,10 @@ public interface StockRepository extends JpaRepository { @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 index eed7a31..084ee30 100644 --- a/src/main/java/com/example/demo/domain/stock/service/StockCommandService.java +++ b/src/main/java/com/example/demo/domain/stock/service/StockCommandService.java @@ -1,12 +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 index e72e08a..cdad3e3 100644 --- a/src/main/java/com/example/demo/domain/stock/service/StockCommandServiceImpl.java +++ b/src/main/java/com/example/demo/domain/stock/service/StockCommandServiceImpl.java @@ -1,5 +1,6 @@ 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; @@ -9,7 +10,8 @@ import java.math.BigDecimal; import java.time.LocalDate; -import java.util.List; +import java.util.*; +import java.util.stream.Collectors; @Service @Transactional @@ -31,8 +33,65 @@ public Stock updateStockPrice( 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); + } } From 6aab2a487fa1167a3f336e84200c245440701196 Mon Sep 17 00:00:00 2001 From: Dongwoo Go Date: Wed, 3 Jun 2026 19:35:56 +0900 Subject: [PATCH 12/24] =?UTF-8?q?refactor:=20=ED=95=9C=EA=B5=AD=ED=88=AC?= =?UTF-8?q?=EC=9E=90=EC=A6=9D=EA=B6=8C=20api=EB=A5=BC=20=EC=82=AC=EC=9A=A9?= =?UTF-8?q?=ED=95=B4=20=EC=A2=85=EB=AA=A9=EC=9D=98=20=EC=8B=9C=EA=B0=80,?= =?UTF-8?q?=20=EC=A2=85=EA=B0=80=EB=A5=BC=20=EC=A1=B0=ED=9A=8C=ED=95=98?= =?UTF-8?q?=EB=8A=94=20=EA=B8=B0=EB=8A=A5=EC=9D=84=20WebClient=EC=97=90?= =?UTF-8?q?=EC=84=9C=20FeignClient=EB=A1=9C=20=EB=B3=80=EA=B2=BD.=20?= =?UTF-8?q?=EC=98=88=EC=99=B8=EC=B2=98=EB=A6=AC=20=EC=BC=80=EC=9D=B4?= =?UTF-8?q?=EC=8A=A4=20=EC=B6=94=EA=B0=80.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- build.gradle | 10 ++- .../com/example/demo/DemoApplication.java | 2 + .../demo/api/kis/client/KisFeignClient.java | 32 +++++++++ .../demo/api/kis/dto/KisTokenRequestDto.java | 19 +++++ .../demo/api/kis/service/KisService.java | 72 ++++++++----------- .../demo/api/kis/service/KisTokenService.java | 55 +++++++------- .../api/stock/controller/StockController.java | 6 +- .../demo/api/stock/service/StockUseCase.java | 22 ++++-- .../demo/common/config/WebClientConfig.java | 23 ------ .../example/demo/common/util/RedisUtil.java | 4 ++ .../stock/exception/StockErrorStatus.java | 3 +- .../domain/stock/exception/StockHandler.java | 4 ++ src/main/resources/application.yml | 6 ++ 13 files changed, 154 insertions(+), 104 deletions(-) create mode 100644 src/main/java/com/example/demo/api/kis/client/KisFeignClient.java create mode 100644 src/main/java/com/example/demo/api/kis/dto/KisTokenRequestDto.java delete mode 100644 src/main/java/com/example/demo/common/config/WebClientConfig.java diff --git a/build.gradle b/build.gradle index b908f0f..1e383b7 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,8 +44,8 @@ dependencies { // Redis implementation 'org.springframework.boot:spring-boot-starter-data-redis' - // WebClient - implementation 'org.springframework.boot:spring-boot-starter-webflux' + // FeignClient + implementation 'org.springframework.cloud:spring-cloud-starter-openfeign' // Excel implementation 'org.apache.poi:poi-ooxml:5.5.1' diff --git a/src/main/java/com/example/demo/DemoApplication.java b/src/main/java/com/example/demo/DemoApplication.java index 094d95b..3f596e9 100644 --- a/src/main/java/com/example/demo/DemoApplication.java +++ b/src/main/java/com/example/demo/DemoApplication.java @@ -2,8 +2,10 @@ import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.cloud.openfeign.EnableFeignClients; @SpringBootApplication +@EnableFeignClients public class DemoApplication { public static void main(String[] args) { 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/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/service/KisService.java b/src/main/java/com/example/demo/api/kis/service/KisService.java index cf1e5b1..4f85ac7 100644 --- a/src/main/java/com/example/demo/api/kis/service/KisService.java +++ b/src/main/java/com/example/demo/api/kis/service/KisService.java @@ -1,21 +1,20 @@ 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.http.HttpStatusCode; import org.springframework.stereotype.Service; -import org.springframework.web.reactive.function.client.WebClient; -import reactor.core.publisher.Mono; - -import java.time.Duration; +@Slf4j @Service @RequiredArgsConstructor public class KisService { - private final WebClient webClient; + private final KisFeignClient kisFeignClient; private final KisTokenService kisTokenService; @Value("${kis.app-key}") @@ -24,42 +23,33 @@ public class KisService { @Value("${kis.app-secret}") private String appSecret; - /** - * 일별 시가/종가 조회 - * @param stockCode 종목코드 (예: 005930) - * @param startDate 조회 시작일 (YYYYMMDD) - * @param endDate 조회 종료일 (YYYYMMDD) - */ public KisResponseDto getDailyStockPrice(String stockCode, String startDate, String endDate) { String token = kisTokenService.getAccessToken(); - return webClient.get() - .uri(uriBuilder -> uriBuilder - .path("/uapi/domestic-stock/v1/quotations/inquire-daily-itemchartprice") - .queryParam("FID_COND_MRKT_DIV_CODE", "J") - .queryParam("FID_INPUT_ISCD", stockCode) - .queryParam("FID_INPUT_DATE_1", startDate) - .queryParam("FID_INPUT_DATE_2", endDate) - .queryParam("FID_PERIOD_DIV_CODE", "D") - .queryParam("FID_ORG_ADJ_PRC", "0") - .build()) - .header("authorization", "Bearer " + token) - .header("appkey", appKey) - .header("appsecret", appSecret) - .header("tr_id", "FHKST03010100") // 실전: FHKST03010100 / 모의: VFHKST03010100 - .retrieve() - .onStatus(HttpStatusCode::is4xxClientError, - res -> Mono.error(StockHandler.kisApiError())) - .onStatus(HttpStatusCode::is5xxServerError, - res -> Mono.error(StockHandler.kisApiError())) - .bodyToMono(KisResponseDto.class) - .map(response -> { - if (!"0".equals(response.getRtCd())) { - throw StockHandler.kisApiError(); - } - return response; - }) - .timeout(Duration.ofSeconds(5)) - .block(); + 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={}", 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 index 13e5dcc..b623cfe 100644 --- a/src/main/java/com/example/demo/api/kis/service/KisTokenService.java +++ b/src/main/java/com/example/demo/api/kis/service/KisTokenService.java @@ -1,28 +1,28 @@ 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.http.HttpStatusCode; import org.springframework.stereotype.Service; -import org.springframework.web.reactive.function.client.WebClient; -import reactor.core.publisher.Mono; import java.time.Duration; -import java.util.Map; -import java.util.Objects; +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 WebClient webClient; + + private final KisFeignClient kisFeignClient; // ✅ WebClient → FeignClient private final RedisService redisService; private final RedisUtil redisUtil; @@ -34,36 +34,33 @@ public class KisTokenService { public String getAccessToken() { String cached = redisService.getValue(TOKEN_KEY); - if (cached != null) { + if (cached != null && !cached.isBlank()) { log.debug("KIS 토큰 캐시 히트"); return cached; } log.info("KIS 토큰 신규 발급 요청"); - Map body = Map.of( - "grant_type", "client_credentials", - "appkey", appKey, - "appsecret", appSecret - ); - KisTokenResponseDto tokenResponse = webClient.post() - .uri("/oauth2/tokenP") - .bodyValue(body) - .retrieve() - .onStatus(HttpStatusCode::is4xxClientError, - res -> Mono.error(StockHandler.kisApiError())) - .onStatus(HttpStatusCode::is5xxServerError, - res -> Mono.error(StockHandler.kisApiError())) - .bodyToMono(KisTokenResponseDto.class) - .timeout(Duration.ofSeconds(5)) - .block(); + try { + KisTokenResponseDto tokenResponse = kisFeignClient.issueToken( + new KisTokenRequestDto(GRANT_TYPE, appKey, appSecret) + ); + + if (tokenResponse == null || tokenResponse.getAccessToken() == null + || tokenResponse.getAccessToken().isBlank()) { + throw StockHandler.kisApiError(); + } - String token = Objects.requireNonNull(tokenResponse).getAccessToken(); + String token = tokenResponse.getAccessToken(); + Duration ttl = redisUtil.calculateTtl(tokenResponse.getAccessTokenTokenExpired()); + redisService.setKisTokenExpiresValueWithTtl(TOKEN_KEY, token, ttl); + log.info("KIS 토큰 Redis 저장 완료. TTL={}s", ttl.getSeconds()); - Duration ttl = redisUtil.calculateTtl(tokenResponse.getAccessTokenTokenExpired()); - redisService.setKisTokenExpiresValueWithTtl(TOKEN_KEY, token, ttl); - log.info("KIS 토큰 Redis 저장 완료. TTL={}s", ttl.getSeconds()); + return token; - return token; + } catch (FeignException e) { + log.error("KIS 토큰 발급 실패. status={}", e.status()); + throw StockHandler.kisApiError(); + } } -} +} \ No newline at end of file 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 index 6516ea2..15a115e 100644 --- a/src/main/java/com/example/demo/api/stock/controller/StockController.java +++ b/src/main/java/com/example/demo/api/stock/controller/StockController.java @@ -10,11 +10,13 @@ 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; @@ -42,10 +44,12 @@ public ApiResponseDto importStockFromS3() { return ApiResponseDto.onSuccess(stockUseCase.importStockFromS3()); } + @Validated @Operation(summary = "전일(가장 최근 일자) 시가/종가 조회", description = "종목코드로 전날의(가장 최근 날짜) 시가와 종가를 KIS API에서 조회하고 DB에 저장합니다.") @GetMapping("/{stockCode}/latest-price") - public ApiResponseDto getLatestStockPrice(@PathVariable String stockCode) { + public ApiResponseDto getLatestStockPrice( + @PathVariable @Pattern(regexp = "\\d{6}", message = "종목코드는 6자리 숫자입니다.") String stockCode) { return ApiResponseDto.onSuccess(stockUseCase.getLatestStockPriceByStockCode(stockCode)); } 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 index a223d0b..facf72a 100644 --- a/src/main/java/com/example/demo/api/stock/service/StockUseCase.java +++ b/src/main/java/com/example/demo/api/stock/service/StockUseCase.java @@ -14,6 +14,7 @@ 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; @@ -25,9 +26,9 @@ import java.time.LocalDate; import java.util.List; -import static com.example.demo.common.consts.StaticVariable.DATE_FORMATTER; -import static com.example.demo.common.consts.StaticVariable.KOSPI200_FILE_KEY; +import static com.example.demo.common.consts.StaticVariable.*; +@Slf4j @UseCase @RequiredArgsConstructor public class StockUseCase { @@ -70,13 +71,16 @@ public ImportStockResponse importStockFromS3() { } public StockPriceResponse getLatestStockPriceByStockCode(String stockCode) { + stockQueryService.getStockByCode(stockCode); + for (int i = 1; i <= 10; i++) { - LocalDate targetDate = LocalDate.now().minusDays(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; @@ -85,11 +89,15 @@ public StockPriceResponse getLatestStockPriceByStockCode(String stockCode) { String rawClose = output2.get(0).getClosePrice(); if (rawOpen == null || rawOpen.isBlank() || rawClose == null || rawClose.isBlank()) continue; - BigDecimal openPrice = new BigDecimal(rawOpen); - BigDecimal closePrice = new BigDecimal(rawClose); + try { + BigDecimal openPrice = new BigDecimal(rawOpen.trim()); + BigDecimal closePrice = new BigDecimal(rawClose.trim()); - Stock stock = stockCommandService.updateStockPrice(stockCode, openPrice, closePrice, targetDate); - return StockConverter.toStockPriceResponse(stock); + Stock stock = stockCommandService.updateStockPrice(stockCode, openPrice, closePrice, targetDate); + return StockConverter.toStockPriceResponse(stock); + } catch (NumberFormatException e) { + continue; + } } throw StockHandler.stockPriceResponseEmpty(); diff --git a/src/main/java/com/example/demo/common/config/WebClientConfig.java b/src/main/java/com/example/demo/common/config/WebClientConfig.java deleted file mode 100644 index 929f5b3..0000000 --- a/src/main/java/com/example/demo/common/config/WebClientConfig.java +++ /dev/null @@ -1,23 +0,0 @@ -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 org.springframework.http.HttpHeaders; -import org.springframework.http.MediaType; -import org.springframework.web.reactive.function.client.WebClient; - -@Configuration -public class WebClientConfig { - - @Value("${kis.base-url}") - private String baseUrl; - - @Bean - public WebClient webClient() { - return WebClient.builder() - .baseUrl(baseUrl) - .defaultHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE) - .build(); - } -} diff --git a/src/main/java/com/example/demo/common/util/RedisUtil.java b/src/main/java/com/example/demo/common/util/RedisUtil.java index 354dd1f..ef7bbc9 100644 --- a/src/main/java/com/example/demo/common/util/RedisUtil.java +++ b/src/main/java/com/example/demo/common/util/RedisUtil.java @@ -17,6 +17,10 @@ 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); 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 index 7040e6c..e97cffa 100644 --- a/src/main/java/com/example/demo/domain/stock/exception/StockErrorStatus.java +++ b/src/main/java/com/example/demo/domain/stock/exception/StockErrorStatus.java @@ -21,7 +21,8 @@ public enum StockErrorStatus implements BaseErrorCode { 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 파일이 아닙니다."); + INVALID_FILE(HttpStatus.BAD_REQUEST, 4356, "유효하지 않은 파일입니다. 비어있거나 .xlsx 파일이 아닙니다."), + STOCK_CODE_ERROR(HttpStatus.BAD_REQUEST, 4357, "유효하지 않은 종목 코드 형식입니다."); private final HttpStatus httpStatus; private final Integer code; 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 index 8718cbf..2e6d28a 100644 --- a/src/main/java/com/example/demo/domain/stock/exception/StockHandler.java +++ b/src/main/java/com/example/demo/domain/stock/exception/StockHandler.java @@ -36,4 +36,8 @@ public static StockHandler s3FileIoError() { public static StockHandler invalidFile() { return new StockHandler(StockErrorStatus.INVALID_FILE); } + + public static StockHandler invalidStockCode() { + return new StockHandler(StockErrorStatus.STOCK_CODE_ERROR); + } } diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 708fdf4..37df0b0 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -66,3 +66,9 @@ cloud: 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초 From c3b9cbeb39e468622d90f0f6b51941a9f0f79059 Mon Sep 17 00:00:00 2001 From: Dongwoo Go Date: Wed, 3 Jun 2026 19:49:05 +0900 Subject: [PATCH 13/24] =?UTF-8?q?chore:=20=EC=9E=90=EC=9E=98=ED=95=9C=20?= =?UTF-8?q?=EC=88=98=EC=A0=95.=20=ED=95=9C=EA=B5=AD=ED=88=AC=EC=9E=90?= =?UTF-8?q?=EC=A6=9D=EA=B6=8C=20=EC=A2=85=EB=AA=A9=20=EC=A1=B0=ED=9A=8C=20?= =?UTF-8?q?=EC=8B=A4=ED=8C=A8=20=EC=8B=9C,=20=EB=8B=A4=EC=9D=8C=20?= =?UTF-8?q?=EB=82=A0=EC=A7=9C=EB=A1=9C=20=EC=A7=84=ED=96=89=20=EC=88=98?= =?UTF-8?q?=EC=A0=95.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/example/demo/api/stock/controller/StockController.java | 3 +-- .../java/com/example/demo/api/stock/service/StockUseCase.java | 3 ++- 2 files changed, 3 insertions(+), 3 deletions(-) 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 index 15a115e..db6b40a 100644 --- a/src/main/java/com/example/demo/api/stock/controller/StockController.java +++ b/src/main/java/com/example/demo/api/stock/controller/StockController.java @@ -32,8 +32,7 @@ public class StockController { @Operation(summary = "코스피200 엑셀 파일 S3 업로드", description = "로컬에서 다운로드한 KRX 코스피200 엑셀 파일을 S3에 업로드합니다.") @PostMapping(value = "/excel/upload", consumes = "multipart/form-data") - public ApiResponseDto uploadStockExcel( - @RequestPart("file") MultipartFile file) { + public ApiResponseDto uploadStockExcel(@RequestPart("file") MultipartFile file) { return ApiResponseDto.onSuccess(stockUseCase.uploadStockExcel(file)); } 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 index facf72a..286bd29 100644 --- a/src/main/java/com/example/demo/api/stock/service/StockUseCase.java +++ b/src/main/java/com/example/demo/api/stock/service/StockUseCase.java @@ -96,7 +96,8 @@ public StockPriceResponse getLatestStockPriceByStockCode(String stockCode) { Stock stock = stockCommandService.updateStockPrice(stockCode, openPrice, closePrice, targetDate); return StockConverter.toStockPriceResponse(stock); } catch (NumberFormatException e) { - continue; + log.warn("KIS 가격 데이터 파싱 실패. stockCode={}, date={}, open={}, close={}", + stockCode, dateStr, rawOpen, rawClose); } } From 470c5903d9373f1e1c72556e7b0aca3b8460bf91 Mon Sep 17 00:00:00 2001 From: Dongwoo Go Date: Thu, 4 Jun 2026 13:24:23 +0900 Subject: [PATCH 14/24] =?UTF-8?q?chore:=20=EC=96=B4=EB=85=B8=ED=85=8C?= =?UTF-8?q?=EC=9D=B4=EC=85=98=20=EC=A0=95=EB=A6=AC.=20=ED=95=84=EC=9A=94?= =?UTF-8?q?=EC=97=86=EB=8A=94=20DTO=20=EC=A0=95=EB=A6=AC.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../api/stock/controller/StockController.java | 4 ++-- .../demo/api/stock/dto/StockRequestDto.java | 17 ----------------- 2 files changed, 2 insertions(+), 19 deletions(-) delete mode 100644 src/main/java/com/example/demo/api/stock/dto/StockRequestDto.java 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 index db6b40a..d3b30ec 100644 --- a/src/main/java/com/example/demo/api/stock/controller/StockController.java +++ b/src/main/java/com/example/demo/api/stock/controller/StockController.java @@ -21,7 +21,8 @@ import org.springframework.web.multipart.MultipartFile; -@Tag(name = "[주식] 주식 종목 API") +@Tag(name = "[주식] 주식 종목 API.") +@Validated @RestController @RequestMapping("/api/v1/stocks") @RequiredArgsConstructor @@ -43,7 +44,6 @@ public ApiResponseDto importStockFromS3() { return ApiResponseDto.onSuccess(stockUseCase.importStockFromS3()); } - @Validated @Operation(summary = "전일(가장 최근 일자) 시가/종가 조회", description = "종목코드로 전날의(가장 최근 날짜) 시가와 종가를 KIS API에서 조회하고 DB에 저장합니다.") @GetMapping("/{stockCode}/latest-price") diff --git a/src/main/java/com/example/demo/api/stock/dto/StockRequestDto.java b/src/main/java/com/example/demo/api/stock/dto/StockRequestDto.java deleted file mode 100644 index 5a4743b..0000000 --- a/src/main/java/com/example/demo/api/stock/dto/StockRequestDto.java +++ /dev/null @@ -1,17 +0,0 @@ -package com.example.demo.api.stock.dto; - -import jakarta.validation.constraints.NotBlank; -import lombok.Getter; -import lombok.NoArgsConstructor; - -public class StockRequestDto { - - /* - @Getter - @NoArgsConstructor - public static class ImportStockRequest { - @NotBlank(message = "S3 URI는 필수입니다.") - private String s3Uri; - } - */ -} From 3093e60f671fccfb26a3f94410b12f2ef4c1cf7e Mon Sep 17 00:00:00 2001 From: Dongwoo Go Date: Thu, 4 Jun 2026 13:49:00 +0900 Subject: [PATCH 15/24] =?UTF-8?q?refactor:=20stock=20=EA=B4=80=EB=A0=A8=20?= =?UTF-8?q?=EC=98=88=EC=99=B8=EC=B2=98=EB=A6=AC=20handler=20=EB=A6=AC?= =?UTF-8?q?=ED=8E=99=ED=84=B0.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../demo/api/stock/service/StockExcelService.java | 2 +- .../example/demo/api/stock/service/StockUseCase.java | 10 +++++++--- .../java/com/example/demo/common/util/ExcelUtil.java | 3 ++- .../demo/domain/stock/exception/StockErrorStatus.java | 4 +++- .../demo/domain/stock/exception/StockHandler.java | 8 ++++++++ 5 files changed, 21 insertions(+), 6 deletions(-) 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 index 48ccc03..1472af6 100644 --- a/src/main/java/com/example/demo/api/stock/service/StockExcelService.java +++ b/src/main/java/com/example/demo/api/stock/service/StockExcelService.java @@ -66,7 +66,7 @@ private Map parseExcelFromS3(String s3Uri) { } if (excelStocks.isEmpty()) { - throw StockHandler.invalidFile(); + 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 index 286bd29..8088ca8 100644 --- a/src/main/java/com/example/demo/api/stock/service/StockUseCase.java +++ b/src/main/java/com/example/demo/api/stock/service/StockUseCase.java @@ -71,7 +71,11 @@ public ImportStockResponse importStockFromS3() { } public StockPriceResponse getLatestStockPriceByStockCode(String stockCode) { - stockQueryService.getStockByCode(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); @@ -93,8 +97,8 @@ public StockPriceResponse getLatestStockPriceByStockCode(String stockCode) { BigDecimal openPrice = new BigDecimal(rawOpen.trim()); BigDecimal closePrice = new BigDecimal(rawClose.trim()); - Stock stock = stockCommandService.updateStockPrice(stockCode, openPrice, closePrice, targetDate); - return StockConverter.toStockPriceResponse(stock); + 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); diff --git a/src/main/java/com/example/demo/common/util/ExcelUtil.java b/src/main/java/com/example/demo/common/util/ExcelUtil.java index ca78a3e..38df11f 100644 --- a/src/main/java/com/example/demo/common/util/ExcelUtil.java +++ b/src/main/java/com/example/demo/common/util/ExcelUtil.java @@ -36,12 +36,13 @@ public void validateExcelFile(MultipartFile file) { } if (file.getSize() > MAX_FILE_SIZE) { - throw StockHandler.invalidFile(); + 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/domain/stock/exception/StockErrorStatus.java b/src/main/java/com/example/demo/domain/stock/exception/StockErrorStatus.java index e97cffa..7d38dec 100644 --- a/src/main/java/com/example/demo/domain/stock/exception/StockErrorStatus.java +++ b/src/main/java/com/example/demo/domain/stock/exception/StockErrorStatus.java @@ -22,7 +22,9 @@ public enum StockErrorStatus implements BaseErrorCode { 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 파일이 아닙니다."), - STOCK_CODE_ERROR(HttpStatus.BAD_REQUEST, 4357, "유효하지 않은 종목 코드 형식입니다."); + 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; 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 index 2e6d28a..dad96c5 100644 --- a/src/main/java/com/example/demo/domain/stock/exception/StockHandler.java +++ b/src/main/java/com/example/demo/domain/stock/exception/StockHandler.java @@ -40,4 +40,12 @@ public static StockHandler invalidFile() { 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); + } } From 88a3ebb4354061cfb826732df5166348a4790fb0 Mon Sep 17 00:00:00 2001 From: Dongwoo Go Date: Mon, 8 Jun 2026 17:11:55 +0900 Subject: [PATCH 16/24] =?UTF-8?q?chore:=20=EC=98=88=EC=99=B8=20=EC=B2=98?= =?UTF-8?q?=EB=A6=AC.=20=EC=A3=BC=EC=84=9D=20=EC=82=AD=EC=A0=9C.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/com/example/demo/api/kis/service/KisService.java | 4 +++- .../com/example/demo/api/kis/service/KisTokenService.java | 2 +- 2 files changed, 4 insertions(+), 2 deletions(-) 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 index 4f85ac7..372af9f 100644 --- a/src/main/java/com/example/demo/api/kis/service/KisService.java +++ b/src/main/java/com/example/demo/api/kis/service/KisService.java @@ -9,6 +9,8 @@ import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Service; +import java.util.Objects; + @Slf4j @Service @RequiredArgsConstructor @@ -41,7 +43,7 @@ public KisResponseDto getDailyStockPrice(String stockCode, String startDate, Str ); if (response == null || !"0".equals(response.getRtCd())) { - log.error("KIS API 응답 오류. rt_cd={}, msg={}", response.getRtCd(), response.getMsg1()); + log.error("KIS API 응답 오류. rt_cd={}, msg={}", Objects.requireNonNull(response).getRtCd(), response.getMsg1()); throw StockHandler.kisApiError(); } 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 index b623cfe..28a361d 100644 --- a/src/main/java/com/example/demo/api/kis/service/KisTokenService.java +++ b/src/main/java/com/example/demo/api/kis/service/KisTokenService.java @@ -22,7 +22,7 @@ @RequiredArgsConstructor public class KisTokenService { - private final KisFeignClient kisFeignClient; // ✅ WebClient → FeignClient + private final KisFeignClient kisFeignClient; private final RedisService redisService; private final RedisUtil redisUtil; From 3e56ebc4c1e074814e88a80d1a2093c24309a713 Mon Sep 17 00:00:00 2001 From: pyun seona Date: Sun, 12 Jul 2026 14:47:48 +0900 Subject: [PATCH 17/24] =?UTF-8?q?feat:=20prediction=20=EB=8F=84=EB=A9=94?= =?UTF-8?q?=EC=9D=B8=20=EC=97=94=ED=8B=B0=ED=8B=B0=20=EB=B0=8F=20=EC=B1=84?= =?UTF-8?q?=EC=A0=90=20=EA=B8=B0=EB=B0=98=20=EA=B5=AC=EC=A1=B0=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/prediction/entity/Prediction.java | 98 +++++++++++++++++++ .../prediction/entity/PredictionDuration.java | 19 ++++ .../prediction/entity/PredictionStatus.java | 7 ++ .../prediction/entity/PredictionTarget.java | 28 ++++++ .../exception/PredictionErrorStatus.java | 53 ++++++++++ .../exception/PredictionHandler.java | 26 +++++ .../repository/PredictionRepository.java | 14 +++ 7 files changed, 245 insertions(+) create mode 100644 src/main/java/com/example/demo/domain/prediction/entity/Prediction.java create mode 100644 src/main/java/com/example/demo/domain/prediction/entity/PredictionDuration.java create mode 100644 src/main/java/com/example/demo/domain/prediction/entity/PredictionStatus.java create mode 100644 src/main/java/com/example/demo/domain/prediction/entity/PredictionTarget.java create mode 100644 src/main/java/com/example/demo/domain/prediction/exception/PredictionErrorStatus.java create mode 100644 src/main/java/com/example/demo/domain/prediction/exception/PredictionHandler.java create mode 100644 src/main/java/com/example/demo/domain/prediction/repository/PredictionRepository.java 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..fc806e1 --- /dev/null +++ b/src/main/java/com/example/demo/domain/prediction/entity/Prediction.java @@ -0,0 +1,98 @@ +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.Getter; +import lombok.NoArgsConstructor; + +import java.math.BigDecimal; +import java.math.RoundingMode; +import java.time.LocalDateTime; + +@Entity +@Getter +@Builder +@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..311b1da --- /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..042c5fd --- /dev/null +++ b/src/main/java/com/example/demo/domain/prediction/repository/PredictionRepository.java @@ -0,0 +1,14 @@ +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); +} From d7f860fb552745fd0021d72c3a0d4baeb9be6dec Mon Sep 17 00:00:00 2001 From: pyun seona Date: Sun, 12 Jul 2026 14:51:12 +0900 Subject: [PATCH 18/24] =?UTF-8?q?feat:=20prediction=20=EB=8F=84=EB=A9=94?= =?UTF-8?q?=EC=9D=B8=20=EC=84=9C=EB=B9=84=EC=8A=A4(CQRS)=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/PredictionCommandService.java | 18 ++++++++ .../service/PredictionCommandServiceImpl.java | 46 +++++++++++++++++++ .../service/PredictionQueryService.java | 11 +++++ .../service/PredictionQueryServiceImpl.java | 32 +++++++++++++ 4 files changed, 107 insertions(+) create mode 100644 src/main/java/com/example/demo/domain/prediction/service/PredictionCommandService.java create mode 100644 src/main/java/com/example/demo/domain/prediction/service/PredictionCommandServiceImpl.java create mode 100644 src/main/java/com/example/demo/domain/prediction/service/PredictionQueryService.java create mode 100644 src/main/java/com/example/demo/domain/prediction/service/PredictionQueryServiceImpl.java 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..8ea2e8a --- /dev/null +++ b/src/main/java/com/example/demo/domain/prediction/service/PredictionQueryService.java @@ -0,0 +1,11 @@ +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); +} 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..4a111db --- /dev/null +++ b/src/main/java/com/example/demo/domain/prediction/service/PredictionQueryServiceImpl.java @@ -0,0 +1,32 @@ +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; + } +} From d3864eb7fb8f0688ee7de1e6d32885e860438977 Mon Sep 17 00:00:00 2001 From: pyun seona Date: Sun, 12 Jul 2026 14:57:18 +0900 Subject: [PATCH 19/24] =?UTF-8?q?feat:=20prediction=20API=20DTO=20?= =?UTF-8?q?=EB=B0=8F=20Converter=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../prediction/dto/PredictionRequestDto.java | 37 ++++++++ .../prediction/dto/PredictionResponseDto.java | 53 +++++++++++ .../mapper/PredictionConverter.java | 93 +++++++++++++++++++ 3 files changed, 183 insertions(+) create mode 100644 src/main/java/com/example/demo/api/prediction/dto/PredictionRequestDto.java create mode 100644 src/main/java/com/example/demo/api/prediction/dto/PredictionResponseDto.java create mode 100644 src/main/java/com/example/demo/api/prediction/mapper/PredictionConverter.java 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..fea2dae --- /dev/null +++ b/src/main/java/com/example/demo/api/prediction/dto/PredictionResponseDto.java @@ -0,0 +1,53 @@ +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 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..cf285e2 --- /dev/null +++ b/src/main/java/com/example/demo/api/prediction/mapper/PredictionConverter.java @@ -0,0 +1,93 @@ +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.time.LocalDateTime; +import java.util.List; + +public class PredictionConverter { + + private static final int BASE_POINT = 1; + + /** 포인트 = 기본점수 × 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(LocalDateTime.now().plusDays(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()) + .earnedPoint(p.getEarnedPoint()) + .title(title) + .subtitle(subtitle) + .reasonHeader(pending ? null : (rose ? "왜 올랐을까?" : "왜 떨어졌을까?")) + .reasons(pending ? List.of() : dummyReasons()) + .build(); + } + + // 2-C: 더미 원인 데이터 + private static List dummyReasons() { + return List.of( + Reason.builder() + .title("AI 메모리 분야 역대 최고 매출 기록") + .content("HBM3 등 고부가 가치 제품의 수요급증으로 반도체 부문 영업이익이 크게 개선되었습니다.") + .build(), + Reason.builder() + .title("파운드리 수주 확대 및 수율 안정화") + .content("글로벌 빅테크 기업들의 선단 공정 수주가 늘어나며 미래 성장 동력을 확보했습니다.") + .build() + ); + } +} From fc63665b32014d96452b9d829f0577fd5dea0614 Mon Sep 17 00:00:00 2001 From: pyun seona Date: Sun, 12 Jul 2026 15:35:19 +0900 Subject: [PATCH 20/24] =?UTF-8?q?feat:=20prediction=20API=20UseCase=20?= =?UTF-8?q?=EB=B0=8F=20Controller=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../demo/api/kis/service/KisTokenService.java | 20 ++++-- .../controller/PredictionController.java | 55 +++++++++++++++++ .../api/prediction/service/KisPricePort.java | 25 ++++++++ .../prediction/service/PredictionUseCase.java | 61 +++++++++++++++++++ .../prediction/service/StockQueryPort.java | 24 ++++++++ .../api/prediction/service/UserPointPort.java | 31 ++++++++++ .../demo/common/consts/StaticVariable.java | 2 +- .../domain/prediction/entity/Prediction.java | 3 +- .../example/demo/domain/user/entity/User.java | 8 +++ 9 files changed, 222 insertions(+), 7 deletions(-) create mode 100644 src/main/java/com/example/demo/api/prediction/controller/PredictionController.java create mode 100644 src/main/java/com/example/demo/api/prediction/service/KisPricePort.java create mode 100644 src/main/java/com/example/demo/api/prediction/service/PredictionUseCase.java create mode 100644 src/main/java/com/example/demo/api/prediction/service/StockQueryPort.java create mode 100644 src/main/java/com/example/demo/api/prediction/service/UserPointPort.java 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 index 28a361d..289cec3 100644 --- a/src/main/java/com/example/demo/api/kis/service/KisTokenService.java +++ b/src/main/java/com/example/demo/api/kis/service/KisTokenService.java @@ -33,33 +33,43 @@ public class KisTokenService { 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.debug("KIS 토큰 캐시 히트"); + log.info("[KIS 토큰] 2. 캐시 히트 - 저장된 토큰 반환"); return cached; } + log.info("[KIS 토큰] 2. 캐시 미스 - 신규 발급 시작"); - log.info("KIS 토큰 신규 발급 요청"); - + 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 토큰 Redis 저장 완료. TTL={}s", ttl.getSeconds()); + log.info("[KIS 토큰] 9. Redis 저장 완료"); return token; } catch (FeignException e) { - log.error("KIS 토큰 발급 실패. status={}", e.status()); + log.error("[KIS 토큰] FeignException 발생. status={}, message={}", e.status(), e.getMessage()); throw StockHandler.kisApiError(); } } 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..dd5f1e4 --- /dev/null +++ b/src/main/java/com/example/demo/api/prediction/controller/PredictionController.java @@ -0,0 +1,55 @@ +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.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; + + @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())); + } +} 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..ea4388f --- /dev/null +++ b/src/main/java/com/example/demo/api/prediction/service/KisPricePort.java @@ -0,0 +1,25 @@ +package com.example.demo.api.prediction.service; + +import com.example.demo.api.kis.service.KisService; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +import java.math.BigDecimal; + +/** + * KIS 현재가 조회 어댑터. + * ★ KIS 연동이 아직 403이라, 지금은 고정값(218000)을 반환해 전체 플로우가 돌아가게 해둠. + * 연동 풀리면 아래 주석의 실제 호출로 교체 예정 (KisService 실제 메서드명 확인 필요) + */ +@Component +@RequiredArgsConstructor +public class KisPricePort { + + private final KisService kisService; + + public BigDecimal getCurrentPrice(String stockCode) { + // TODO: 실제 KisService 메서드로 교체 + // return kisService.getCurrentPrice(stockCode); + return new BigDecimal("218000"); // 임시 고정값 (Image 1의 현재가) + } +} 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/common/consts/StaticVariable.java b/src/main/java/com/example/demo/common/consts/StaticVariable.java index f7d3def..280cf1d 100644 --- a/src/main/java/com/example/demo/common/consts/StaticVariable.java +++ b/src/main/java/com/example/demo/common/consts/StaticVariable.java @@ -10,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"; 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 index fc806e1..bed931f 100644 --- a/src/main/java/com/example/demo/domain/prediction/entity/Prediction.java +++ b/src/main/java/com/example/demo/domain/prediction/entity/Prediction.java @@ -7,6 +7,7 @@ import lombok.AccessLevel; import lombok.AllArgsConstructor; import lombok.Builder; +import lombok.experimental.SuperBuilder; import lombok.Getter; import lombok.NoArgsConstructor; @@ -16,7 +17,7 @@ @Entity @Getter -@Builder +@SuperBuilder @NoArgsConstructor(access = AccessLevel.PROTECTED) @AllArgsConstructor @Table(name = "prediction") 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; From 38afb416bbca5ad6d5c3bf9ac16d94fde41ee5d3 Mon Sep 17 00:00:00 2001 From: pyun seona Date: Wed, 5 Aug 2026 20:03:43 +0900 Subject: [PATCH 21/24] =?UTF-8?q?feat=20:=20=ED=8F=AC=EC=9D=B8=ED=8A=B8=20?= =?UTF-8?q?=EA=B7=9C=EC=B9=99=20=EB=B0=8F=20=EC=8A=A4=EC=BC=80=EC=A4=84?= =?UTF-8?q?=EB=9F=AC=20=EA=B4=80=EB=A0=A8=20api=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- build.gradle | 1 + .../com/example/demo/DemoApplication.java | 2 + .../controller/PredictionController.java | 10 +++ .../mapper/PredictionConverter.java | 20 +++++- .../scheduler/PredictionGradingScheduler.java | 70 +++++++++++++++++++ .../api/prediction/service/KisPricePort.java | 43 ++++++++++-- .../prediction/entity/PredictionDuration.java | 4 +- 7 files changed, 141 insertions(+), 9 deletions(-) create mode 100644 src/main/java/com/example/demo/api/prediction/scheduler/PredictionGradingScheduler.java diff --git a/build.gradle b/build.gradle index 1e383b7..26ff18c 100644 --- a/build.gradle +++ b/build.gradle @@ -52,6 +52,7 @@ dependencies { // 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' diff --git a/src/main/java/com/example/demo/DemoApplication.java b/src/main/java/com/example/demo/DemoApplication.java index 3f596e9..9b3cff2 100644 --- a/src/main/java/com/example/demo/DemoApplication.java +++ b/src/main/java/com/example/demo/DemoApplication.java @@ -3,9 +3,11 @@ 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/prediction/controller/PredictionController.java b/src/main/java/com/example/demo/api/prediction/controller/PredictionController.java index dd5f1e4..fb583ee 100644 --- a/src/main/java/com/example/demo/api/prediction/controller/PredictionController.java +++ b/src/main/java/com/example/demo/api/prediction/controller/PredictionController.java @@ -5,6 +5,7 @@ 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; @@ -23,6 +24,7 @@ public class PredictionController { private final PredictionUseCase predictionUseCase; + private final PredictionGradingScheduler predictionGradingScheduler; @Operation(summary = "주가 예측 제출", description = "종목/예측기간/상승폭을 받아 예측을 등록하고 획득 가능 포인트를 반환합니다. (결과는 만기 후 채점)") @@ -52,4 +54,12 @@ public ApiResponseDto gradeManually( 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/mapper/PredictionConverter.java b/src/main/java/com/example/demo/api/prediction/mapper/PredictionConverter.java index cf285e2..ef90417 100644 --- a/src/main/java/com/example/demo/api/prediction/mapper/PredictionConverter.java +++ b/src/main/java/com/example/demo/api/prediction/mapper/PredictionConverter.java @@ -9,12 +9,30 @@ import com.example.demo.domain.user.entity.User; import java.math.BigDecimal; +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) { @@ -33,7 +51,7 @@ public static Prediction toPrediction(User user, Stock stock, PredictionDuration .status(PredictionStatus.PENDING) .possiblePoint(possiblePoint) .basePrice(basePrice) - .maturityAt(LocalDateTime.now().plusDays(duration.getDays())) + .maturityAt(calcMaturityAt(duration.getDays())) .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 index ea4388f..78aa172 100644 --- a/src/main/java/com/example/demo/api/prediction/service/KisPricePort.java +++ b/src/main/java/com/example/demo/api/prediction/service/KisPricePort.java @@ -1,25 +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이라, 지금은 고정값(218000)을 반환해 전체 플로우가 돌아가게 해둠. - * 연동 풀리면 아래 주석의 실제 호출로 교체 예정 (KisService 실제 메서드명 확인 필요) + * 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: 실제 KisService 메서드로 교체 - // return kisService.getCurrentPrice(stockCode); - return new BigDecimal("218000"); // 임시 고정값 (Image 1의 현재가) + // 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/domain/prediction/entity/PredictionDuration.java b/src/main/java/com/example/demo/domain/prediction/entity/PredictionDuration.java index 311b1da..ec452f9 100644 --- a/src/main/java/com/example/demo/domain/prediction/entity/PredictionDuration.java +++ b/src/main/java/com/example/demo/domain/prediction/entity/PredictionDuration.java @@ -14,6 +14,6 @@ public enum PredictionDuration { TWO_WEEK("2주일", 14, 1); private final String description; - private final int days; // 만기까지 일수 - private final int weight; // duration 짧을수록 가중치 ↑ + private final int days; // 제출일 기준 캘린더 일수 + private final int weight; // duration 짧을수록 가중치 ↑ } From 642e1e81701af457b853e88c988d1033ef2f04c6 Mon Sep 17 00:00:00 2001 From: pyun seona Date: Wed, 5 Aug 2026 20:37:51 +0900 Subject: [PATCH 22/24] =?UTF-8?q?fix=20:=20get=20user=20api=20=EB=94=94?= =?UTF-8?q?=EC=9E=90=EC=9D=B8=EC=97=90=20=EB=A7=9E=EA=B2=8C=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/example/demo/api/user/controller/UserController.java | 5 ++++- .../java/com/example/demo/api/user/dto/UserResponseDto.java | 2 ++ .../java/com/example/demo/api/user/mapper/UserConverter.java | 4 +++- .../domain/prediction/repository/PredictionRepository.java | 3 +++ .../domain/prediction/service/PredictionQueryService.java | 2 ++ .../prediction/service/PredictionQueryServiceImpl.java | 5 +++++ 6 files changed, 19 insertions(+), 2 deletions(-) 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/domain/prediction/repository/PredictionRepository.java b/src/main/java/com/example/demo/domain/prediction/repository/PredictionRepository.java index 042c5fd..b638204 100644 --- a/src/main/java/com/example/demo/domain/prediction/repository/PredictionRepository.java +++ b/src/main/java/com/example/demo/domain/prediction/repository/PredictionRepository.java @@ -11,4 +11,7 @@ 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/PredictionQueryService.java b/src/main/java/com/example/demo/domain/prediction/service/PredictionQueryService.java index 8ea2e8a..8399e8c 100644 --- a/src/main/java/com/example/demo/domain/prediction/service/PredictionQueryService.java +++ b/src/main/java/com/example/demo/domain/prediction/service/PredictionQueryService.java @@ -8,4 +8,6 @@ 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 index 4a111db..a1958f5 100644 --- a/src/main/java/com/example/demo/domain/prediction/service/PredictionQueryServiceImpl.java +++ b/src/main/java/com/example/demo/domain/prediction/service/PredictionQueryServiceImpl.java @@ -29,4 +29,9 @@ public Prediction getByIdAndUser(Long predictionId, Long userId) { } return prediction; } + + @Override + public long countByUserId(Long userId) { + return predictionRepository.countByUserId(userId); + } } From 5d552f7f7512a42839f257b854bfe82dafb74f87 Mon Sep 17 00:00:00 2001 From: pyun seona Date: Wed, 5 Aug 2026 20:41:41 +0900 Subject: [PATCH 23/24] =?UTF-8?q?feat=20:=20=EC=98=88=EC=B8=A1=20=EA=B2=B0?= =?UTF-8?q?=EA=B3=BC=20actualRateText(=EC=8B=A4=EC=A0=9C=20=EB=93=B1?= =?UTF-8?q?=EB=9D=BD=EB=A5=A0=20=EA=B8=B0=EB=B0=98=20=ED=85=8D=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8)=20=ED=95=84=EB=93=9C=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../api/prediction/dto/PredictionResponseDto.java | 1 + .../api/prediction/mapper/PredictionConverter.java | 11 +++++++++++ 2 files changed, 12 insertions(+) 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 index fea2dae..ab82a4b 100644 --- a/src/main/java/com/example/demo/api/prediction/dto/PredictionResponseDto.java +++ b/src/main/java/com/example/demo/api/prediction/dto/PredictionResponseDto.java @@ -37,6 +37,7 @@ public static class PredictionResult { 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; 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 index ef90417..2e35299 100644 --- a/src/main/java/com/example/demo/api/prediction/mapper/PredictionConverter.java +++ b/src/main/java/com/example/demo/api/prediction/mapper/PredictionConverter.java @@ -9,6 +9,7 @@ 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; @@ -87,6 +88,7 @@ public static PredictionResult toResult(Prediction p) { .status(p.getStatus()) .myPredictionText(p.getTarget().getDescription()) .actualRate(p.getActualRate()) + .actualRateText(pending ? null : toChangeRateText(p.getActualRate())) .earnedPoint(p.getEarnedPoint()) .title(title) .subtitle(subtitle) @@ -95,6 +97,15 @@ public static PredictionResult toResult(Prediction p) { .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( From 3ec69b6f43a4283756a28d52aa1d10aaafb1c50a Mon Sep 17 00:00:00 2001 From: pyun seona Date: Mon, 24 Aug 2026 17:15:12 +0900 Subject: [PATCH 24/24] =?UTF-8?q?=ED=8A=B8=EB=A6=AC=EA=B1=B0=20=ED=99=95?= =?UTF-8?q?=EC=9D=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/pull_request_template.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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