-
Notifications
You must be signed in to change notification settings - Fork 2
feat(broker): KIS 모의주문 연동 1차 #7
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: develop
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,12 @@ | ||
| package trademill.apiserver.broker; | ||
|
|
||
| import java.math.BigDecimal; | ||
|
|
||
|
|
||
| // BrokerGateway : '증권사와 어떻게 대화할지' 계약서(포트) 역할 | ||
| public interface BrokerGateway { | ||
| String placeMarketBuy(String symbol, BigDecimal quantity); | ||
| String placeMarketSell(String symbol, BigDecimal quantity); | ||
| String placeLimitBuy(String symbol, BigDecimal quantity, BigDecimal price); | ||
| String placeLimitSell(String symbol, BigDecimal quantity, BigDecimal price); | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,20 @@ | ||
| package trademill.apiserver.broker; | ||
|
|
||
| import org.springframework.context.annotation.Profile; | ||
| import org.springframework.stereotype.Component; | ||
|
|
||
| import java.math.BigDecimal; | ||
| import java.util.UUID; | ||
|
|
||
| @Profile({"local","default"}) | ||
| @Component | ||
|
|
||
| // FakeBrokerGateway : 실제 증권사 호출 대신 로컬 개발/테스트용으로 응답을 꾸며주는 구현체(어댑터) | ||
| public class FakeBrokerGateway implements BrokerGateway { | ||
| private String id(){ return "SIM-" + UUID.randomUUID(); } | ||
|
|
||
| public String placeMarketBuy(String s, BigDecimal q){ return id(); } | ||
| public String placeMarketSell(String s, BigDecimal q){ return id(); } | ||
| public String placeLimitBuy(String s, BigDecimal q, BigDecimal p){ return id(); } | ||
| public String placeLimitSell(String s, BigDecimal q, BigDecimal p){ return id(); } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,136 @@ | ||
| package trademill.apiserver.broker.kis; | ||
|
|
||
| import lombok.RequiredArgsConstructor; | ||
| import org.slf4j.Logger; | ||
| import org.slf4j.LoggerFactory; | ||
| import org.springframework.context.annotation.Profile; | ||
| import org.springframework.http.MediaType; | ||
| import org.springframework.stereotype.Component; | ||
| import org.springframework.web.reactive.function.client.WebClient; | ||
| import trademill.apiserver.broker.BrokerGateway; | ||
| import trademill.apiserver.config.KisProperties; | ||
| import trademill.apiserver.order.OrderSide; | ||
| import trademill.apiserver.broker.kis.dto.KisResponse; | ||
| import trademill.apiserver.broker.kis.dto.OrderCashOutput; | ||
|
|
||
| import java.math.BigDecimal; | ||
| import java.util.LinkedHashMap; | ||
| import java.util.Map; | ||
|
|
||
| @Component | ||
| @Profile("kis") | ||
| @RequiredArgsConstructor | ||
| public class KisBrokerGateway implements BrokerGateway { | ||
|
|
||
| private static final Logger log = LoggerFactory.getLogger(KisBrokerGateway.class); | ||
|
|
||
| private final WebClient kisWebClient; | ||
| private final KisTokenService tokenService; | ||
| private final KisProperties props; | ||
|
|
||
| @Override | ||
| public String placeMarketBuy(String symbol, BigDecimal quantity) { | ||
| return place(OrderSide.BUY, "MARKET", symbol, quantity, null); | ||
| } | ||
|
|
||
| @Override | ||
| public String placeLimitBuy(String symbol, BigDecimal quantity, BigDecimal price) { | ||
| return place(OrderSide.BUY, "LIMIT", symbol, quantity, price); | ||
| } | ||
|
|
||
| @Override | ||
| public String placeMarketSell(String symbol, BigDecimal quantity) { | ||
| return place(OrderSide.SELL, "MARKET", symbol, quantity, null); | ||
| } | ||
|
|
||
| @Override | ||
| public String placeLimitSell(String symbol, BigDecimal quantity, BigDecimal price) { | ||
| return place(OrderSide.SELL, "LIMIT", symbol, quantity, price); | ||
| } | ||
|
Comment on lines
+31
to
+49
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 💡 Verification agent 🧩 Analysis chainString 기반 orderType 제거 → Enum 사용 및 수량/응답 유효성 강화.
import trademill.apiserver.config.KisProperties;
import trademill.apiserver.order.OrderSide;
+import trademill.apiserver.order.OrderType;
@@
@Override
public String placeMarketBuy(String symbol, BigDecimal quantity) {
- return place(OrderSide.BUY, "MARKET", symbol, quantity, null);
+ return place(OrderSide.BUY, OrderType.MARKET, symbol, quantity, null);
}
@@
@Override
public String placeLimitBuy(String symbol, BigDecimal quantity, BigDecimal price) {
- return place(OrderSide.BUY, "LIMIT", symbol, quantity, price);
+ return place(OrderSide.BUY, OrderType.LIMIT, symbol, quantity, price);
}
@@
@Override
public String placeMarketSell(String symbol, BigDecimal quantity) {
- return place(OrderSide.SELL, "MARKET", symbol, quantity, null);
+ return place(OrderSide.SELL, OrderType.MARKET, symbol, quantity, null);
}
@@
@Override
public String placeLimitSell(String symbol, BigDecimal quantity, BigDecimal price) {
- return place(OrderSide.SELL, "LIMIT", symbol, quantity, price);
+ return place(OrderSide.SELL, OrderType.LIMIT, symbol, quantity, price);
}
@@
- private String place(OrderSide side, String orderType, String symbol, BigDecimal qty, BigDecimal price) {
+ private String place(OrderSide side, OrderType orderType, String symbol, BigDecimal qty, BigDecimal price) {
@@
- // ORD_DVSN: 00=지정가, 01=시장가
- final String ordDvsn = "LIMIT".equals(orderType) ? "00" : "01";
- final String ordUnpr = "MARKET".equals(orderType) ? "0" : price.toPlainString();
+ // ORD_DVSN: 00=지정가, 01=시장가
+ final String ordDvsn = (orderType == OrderType.LIMIT) ? "00" : "01";
+ final String ordUnpr = (orderType == OrderType.MARKET) ? "0" : price.toPlainString();
@@
- body.put("ORD_QTY", qty.toPlainString());
+ // 주식 수량은 정수만 허용됨. 소수일 경우 예외 발생시켜 조기 실패.
+ final String ordQty = qty.toBigIntegerExact().toString();
+ body.put("ORD_QTY", ordQty);
@@
- String ordNo = resp.getOutput() != null ? resp.getOutput().getORD_NO() : null;
- log.info("[KIS] rt_cd=0, ORD_NO={}", ordNo);
- return ordNo != null ? ordNo : "KIS-NO-ORDER-NO";
+ String ordNo = resp.getOutput() != null ? resp.getOutput().getORD_NO() : null;
+ if (ordNo == null || ordNo.isBlank()) {
+ throw new IllegalStateException("KIS success response but missing ORD_NO");
+ }
+ log.info("[KIS] rt_cd=0, ORD_NO={}", ordNo);
+ return ordNo;검증 요청: KIS 현금주문 API 스펙에서 ORD_QTY가 정수만 허용되는지(소수 허용 여부)를 확인해 주세요. Also applies to: 51-61, 67-69, 96-99 🌐 Web query: 💡 Result: 간단히: ORD_QTY는 정수(주 단위, 전수)로 입력해야 합니다 — 한국투자증권 오픈API 문서와 샘플은 주문수량을 정수형(주 수)으로 취급하고 있으며, 서드파티 SDK도 ord_qty를 int로 정의합니다. [1][2] 참고자료
(원하시면 국내/해외 주식별 소수주(부분주) 지원 여부도 확인해 드리겠습니다.) OrderType enum 사용·수량/응답 검증 강화 필요
🤖 Prompt for AI Agents |
||
|
|
||
| private String place(OrderSide side, String orderType, String symbol, BigDecimal qty, BigDecimal price) { | ||
| log.info("[KIS:{} {}] symbol={}, qty={}, price={}", side, orderType, symbol, qty, price); | ||
|
|
||
| // 0) 토큰 | ||
| String token = tokenService.getAccessToken(); | ||
|
|
||
| // 1) KIS 주문 바디 구성 | ||
| // ORD_DVSN: 00=지정가, 01=시장가 | ||
| final String ordDvsn = "LIMIT".equals(orderType) ? "00" : "01"; | ||
| final String ordUnpr = "MARKET".equals(orderType) ? "0" : price.toPlainString(); | ||
|
|
||
| Map<String, Object> body = new LinkedHashMap<>(); | ||
| body.put("CANO", props.getCano()); // 계좌번호(앞 8자리) | ||
| body.put("ACNT_PRDT_CD", props.getAcntPrdtCd()); // 상품코드(보통 01) | ||
| body.put("PDNO", symbol); // 종목코드 | ||
| body.put("ORD_DVSN", ordDvsn); | ||
| body.put("ORD_QTY", qty.toPlainString()); | ||
| body.put("ORD_UNPR", ordUnpr); | ||
|
|
||
| // 2) 해시키 요청 | ||
| String hashKey = requestHashKey(token, body); | ||
|
|
||
| // 3) 주문 요청 | ||
| String trId = mapTrId(side, props.isVirtual()); // KIS는 현금주문 TR이 매수/매도로 구분됨 | ||
| KisResponse<OrderCashOutput> resp = kisWebClient.post() | ||
| .uri("/uapi/domestic-stock/v1/trading/order-cash") | ||
| .contentType(MediaType.APPLICATION_JSON) | ||
| .headers(h -> { | ||
| h.add("authorization", "Bearer " + token); | ||
| h.add("appkey", props.getAppKey()); | ||
| h.add("appsecret", props.getAppSecret()); | ||
| h.add("tr_id", trId); | ||
| h.add("custtype", "P"); // 개인 | ||
| h.add("hashkey", hashKey); | ||
| }) | ||
| .bodyValue(body) | ||
| .retrieve() | ||
| .bodyToMono(ParameterizedTypes.orderCashResponse()) | ||
| .block(); | ||
|
|
||
| if (resp == null) throw new IllegalStateException("KIS response is null"); | ||
| if (!"0".equals(resp.getRt_cd())) { | ||
| throw new IllegalStateException("KIS error: " + resp.getMsg_cd() + " - " + resp.getMsg1()); | ||
| } | ||
|
|
||
| String ordNo = resp.getOutput() != null ? resp.getOutput().getORD_NO() : null; | ||
| log.info("[KIS] rt_cd=0, ORD_NO={}", ordNo); | ||
| return ordNo != null ? ordNo : "KIS-NO-ORDER-NO"; | ||
| } | ||
|
|
||
| private String requestHashKey(String token, Map<String, Object> body) { | ||
| Map<String, Object> resp = kisWebClient.post() | ||
| .uri("/uapi/hashkey") | ||
| .contentType(MediaType.APPLICATION_JSON) | ||
| .headers(h -> { | ||
| h.add("authorization", "Bearer " + token); | ||
| h.add("appkey", props.getAppKey()); | ||
| h.add("appsecret", props.getAppSecret()); | ||
| }) | ||
| .bodyValue(body) | ||
| .retrieve() | ||
| .bodyToMono(Map.class) | ||
| .block(); | ||
|
|
||
| Object hk = resp != null ? resp.get("HASH") : null; | ||
| if (hk == null) throw new IllegalStateException("Failed to get hashkey from KIS"); | ||
| return hk.toString(); | ||
| } | ||
|
|
||
| /** KIS 현금주문 TR 매핑 (모의/실전, 매수/매도) */ | ||
| private String mapTrId(OrderSide side, boolean virtual) { | ||
| boolean buy = side == OrderSide.BUY; | ||
| if (virtual) { | ||
| return buy ? "VTTC0802U" : "VTTC0801U"; // 모의: 매수/매도 | ||
| } else { | ||
| return buy ? "TTTC0802U" : "TTTC0801U"; // 실전: 매수/매도 | ||
| } | ||
| } | ||
|
|
||
| /** WebClient 제네릭 응답 타입 헬퍼 */ | ||
| static class ParameterizedTypes { | ||
| static org.springframework.core.ParameterizedTypeReference<KisResponse<OrderCashOutput>> orderCashResponse() { | ||
| return new org.springframework.core.ParameterizedTypeReference<>() {}; | ||
| } | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,50 @@ | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| package trademill.apiserver.broker.kis; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| import lombok.RequiredArgsConstructor; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| import org.slf4j.Logger; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| import org.slf4j.LoggerFactory; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| import org.springframework.context.annotation.Profile; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| import org.springframework.http.MediaType; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| import org.springframework.stereotype.Service; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| import org.springframework.web.reactive.function.client.WebClient; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| import trademill.apiserver.config.KisProperties; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| import java.time.Instant; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| import java.util.Map; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| @Service | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| @Profile("kis") | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| @RequiredArgsConstructor | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| public class KisTokenService { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| private static final Logger log = LoggerFactory.getLogger(KisTokenService.class); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| private final WebClient kisWebClient; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| private final KisProperties props; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| private volatile String cachedToken; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| private volatile Instant expiresAt = Instant.EPOCH; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| public String getAccessToken() { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if (cachedToken != null && Instant.now().isBefore(expiresAt.minusSeconds(30))) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return cachedToken; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| Map<String, Object> resp = kisWebClient.post() | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| .uri("/oauth2/tokenP") | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| .contentType(MediaType.APPLICATION_JSON) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| .bodyValue(Map.of( | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| "grant_type", "client_credentials", | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| "appkey", props.getAppKey(), | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| "appsecret", props.getAppSecret() | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| )) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| .retrieve() | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| .bodyToMono(Map.class) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| .block(); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| String token = (String) resp.get("access_token"); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| Number exp = (Number) resp.getOrDefault("expires_in", 0); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| this.cachedToken = token; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| this.expiresAt = Instant.now().plusSeconds(exp.longValue()); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| log.info("[KIS] access_token issued, expires_in={}s", exp); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return token; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+27
to
+49
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 토큰 발급 동시성·에러처리·널가드 보강 필요 현재는 동시 다발 호출 시 중복 발급 가능성이 있고, HTTP 에러/비정상 응답 시 NPE가 발생할 수 있습니다. 동기화와 상태코드 처리, 빈 토큰 가드를 추가하세요. - public String getAccessToken() {
+ public synchronized String getAccessToken() {
if (cachedToken != null && Instant.now().isBefore(expiresAt.minusSeconds(30))) {
return cachedToken;
}
- Map<String, Object> resp = kisWebClient.post()
+ Map<String, Object> resp = kisWebClient.post()
.uri("/oauth2/tokenP")
.contentType(MediaType.APPLICATION_JSON)
.bodyValue(Map.of(
"grant_type", "client_credentials",
"appkey", props.getAppKey(),
"appsecret", props.getAppSecret()
))
- .retrieve()
+ .retrieve()
+ .onStatus(org.springframework.http.HttpStatusCode::isError,
+ clientResponse -> clientResponse.createException())
.bodyToMono(Map.class)
.block();
- String token = (String) resp.get("access_token");
- Number exp = (Number) resp.getOrDefault("expires_in", 0);
+ if (resp == null) {
+ throw new IllegalStateException("[KIS] token response is null");
+ }
+ String token = (String) resp.get("access_token");
+ if (token == null || token.isBlank()) {
+ throw new IllegalStateException("[KIS] access_token missing in response: " + resp);
+ }
+ Number exp = (Number) resp.getOrDefault("expires_in", 0);
this.cachedToken = token;
this.expiresAt = Instant.now().plusSeconds(exp.longValue());
log.info("[KIS] access_token issued, expires_in={}s", exp);
return token;
}추가로, 📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,11 @@ | ||
| package trademill.apiserver.broker.kis.dto; | ||
|
|
||
| import lombok.Data; | ||
|
|
||
| @Data | ||
| public class KisResponse<T> { | ||
| private String rt_cd; // "0" 성공, "1" 실패 | ||
| private String msg_cd; | ||
| private String msg1; | ||
| private T output; | ||
| } |
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,9 @@ | ||||||||||||||||||||||||||||||||||||||
| package trademill.apiserver.broker.kis.dto; | ||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||
| import lombok.Data; | ||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||
| @Data | ||||||||||||||||||||||||||||||||||||||
| public class OrderCashOutput { | ||||||||||||||||||||||||||||||||||||||
| // KIS 주문응답의 주문번호 필드 (문서 기준) | ||||||||||||||||||||||||||||||||||||||
| private String ORD_NO; | ||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+5
to
+9
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🛠️ Refactor suggestion Jackson 매핑 강화: 자바 관례 필드명 + @JsonProperty로 매핑, 알 수 없는 필드 무시
적용 diff: package trademill.apiserver.broker.kis.dto;
-import lombok.Data;
+import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
+import com.fasterxml.jackson.annotation.JsonProperty;
+import lombok.Data;
@Data
-public class OrderCashOutput {
- // KIS 주문응답의 주문번호 필드 (문서 기준)
- private String ORD_NO;
-}
+@JsonIgnoreProperties(ignoreUnknown = true)
+public class OrderCashOutput {
+ // KIS 주문응답의 주문번호 필드 (문서 기준)
+ @JsonProperty("ORD_NO")
+ private String ordNo;
+}📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,27 @@ | ||
| package trademill.apiserver.config; | ||
|
|
||
| import org.springframework.boot.context.properties.EnableConfigurationProperties; | ||
| import org.springframework.context.annotation.Bean; | ||
| import org.springframework.context.annotation.Configuration; | ||
| import org.springframework.context.annotation.Profile; | ||
| import org.springframework.http.MediaType; | ||
| import org.springframework.web.reactive.function.client.ExchangeStrategies; | ||
| import org.springframework.web.reactive.function.client.WebClient; | ||
|
|
||
| @Configuration | ||
| @EnableConfigurationProperties(KisProperties.class) | ||
| @Profile("kis") | ||
| public class KisConfig { | ||
|
|
||
| @Bean | ||
| public WebClient kisWebClient(KisProperties props) { | ||
| return WebClient.builder() | ||
| .baseUrl(props.getBaseUrl()) | ||
| .defaultHeader("Content-Type", MediaType.APPLICATION_JSON_VALUE + "; charset=UTF-8") | ||
| .defaultHeader("Accept", MediaType.APPLICATION_JSON_VALUE) | ||
| .exchangeStrategies(ExchangeStrategies.builder() | ||
| .codecs(c -> c.defaultCodecs().maxInMemorySize(4 * 1024 * 1024)) | ||
| .build()) | ||
| .build(); | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,31 @@ | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| package trademill.apiserver.config; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| import lombok.Getter; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| import lombok.Setter; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| import org.springframework.boot.context.properties.ConfigurationProperties; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| @Getter @Setter | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| @ConfigurationProperties(prefix = "kis") | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| public class KisProperties { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| private String baseUrl; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| private String appKey; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| private String appSecret; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| private String accountNo; // 예: 50151652-01 | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+3
to
+13
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 💡 Verification agent 🧩 Analysis chain환경설정 필수값 검증 추가 (@validated + 제약조건 권장) 런타임에 누락/오타를 조기에 발견하려면 아래처럼 적용 제안드립니다(계좌번호 포맷은 서비스 정책에 맞춰 조정 가능). import lombok.Getter;
import lombok.Setter;
import org.springframework.boot.context.properties.ConfigurationProperties;
+import org.springframework.validation.annotation.Validated;
+import jakarta.validation.constraints.NotBlank;
+import jakarta.validation.constraints.Pattern;
-@Getter @Setter
-@ConfigurationProperties(prefix = "kis")
+@Getter @Setter
+@Validated
+@ConfigurationProperties(prefix = "kis")
public class KisProperties {
- private String baseUrl;
- private String appKey;
- private String appSecret;
- private String accountNo; // 예: 50151652-01
+ @NotBlank private String baseUrl;
+ @NotBlank private String appKey;
+ @NotBlank private String appSecret;
+ @NotBlank
+ @Pattern(regexp = "\\d{8}-\\d{2}", message = "계좌번호는 8자리-2자리 형식이어야 합니다. 예: 50151652-01")
+ private String accountNo; // 예: 50151652-01🏁 Script executed: #!/usr/bin/env bash
# Spring Boot 버전 확인 (pom.xml 또는 build.gradle)
if [ -f pom.xml ]; then
echo "=== pom.xml 부모 정보 ==="
grep -n "<parent>" pom.xml -A3
echo
grep -n "spring-boot-starter-parent" pom.xml -A1
echo
grep -n "<spring-boot.version>" pom.xml
fi
if [ -f build.gradle ]; then
echo "=== build.gradle Spring Boot 플러그인/버전 정보 ==="
grep -n "org.springframework.boot" build.gradle -A1
echo
grep -n "springBootVersion" build.gradle
fi
# @ConfigurationProperties 빈 등록 확인
rg -n "@EnableConfigurationProperties" -t java || echo "@EnableConfigurationProperties 미발견"
rg -n "@ConfigurationPropertiesScan" -t java || echo "@ConfigurationPropertiesScan 미발견"Length of output: 1378 @validated 및 필드 제약조건 추가 적용 import lombok.Getter;
import lombok.Setter;
import org.springframework.boot.context.properties.ConfigurationProperties;
+import org.springframework.validation.annotation.Validated;
+import jakarta.validation.constraints.NotBlank;
+import jakarta.validation.constraints.Pattern;
-@Getter @Setter
-@ConfigurationProperties(prefix = "kis")
+@Getter @Setter
+@Validated
+@ConfigurationProperties(prefix = "kis")
public class KisProperties {
- private String baseUrl;
- private String appKey;
- private String appSecret;
- private String accountNo; // 예: 50151652-01
+ @NotBlank
+ private String baseUrl;
+
+ @NotBlank
+ private String appKey;
+
+ @NotBlank
+ private String appSecret;
+
+ @NotBlank
+ @Pattern(regexp = "\\d{8}-\\d{2}", message = "계좌번호는 8자리-2자리 형식이어야 합니다. 예: 50151652-01")
+ private String accountNo; // 예: 50151652-01📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| public String getCano() { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if (accountNo == null) return null; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| int idx = accountNo.indexOf('-'); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return idx > 0 ? accountNo.substring(0, idx) : accountNo; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| public String getAcntPrdtCd() { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if (accountNo == null) return null; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| int idx = accountNo.indexOf('-'); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return idx > 0 ? accountNo.substring(idx + 1) : "01"; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| public boolean isVirtual() { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // 모의투자 여부 (base-url 에 vts 포함 시 true) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return baseUrl != null && baseUrl.contains("openapivts"); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,42 @@ | ||
| package trademill.apiserver.order; | ||
|
|
||
| import jakarta.persistence.*; | ||
| import lombok.Getter; | ||
| import lombok.NoArgsConstructor; | ||
| import lombok.Setter; | ||
|
|
||
|
Comment on lines
+3
to
+7
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🛠️ Refactor suggestion 엔티티 제약 강화: NOT NULL/Index/낙관적 락 추가. DB 일관성과 조회 성능, 동시성 안전성을 위해 최소한의 제약을 추가하세요. import jakarta.persistence.*;
+import jakarta.validation.constraints.NotBlank;
+import jakarta.validation.constraints.NotNull;
+import jakarta.validation.constraints.Positive;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import java.math.BigDecimal;
import java.time.OffsetDateTime;
@Entity
-@Table(name = "orders")
+@Table(name = "orders", indexes = {
+ @Index(name = "idx_orders_user_created", columnList = "userId,createdAt")
+})
@Getter @Setter @NoArgsConstructor
public class Order {
- @Id @GeneratedValue(strategy = GenerationType.IDENTITY)
+ @Id @GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
- private Long userId;
- private String symbol;
+ @NotNull
+ @Column(nullable = false)
+ private Long userId;
+ @NotBlank
+ @Column(nullable = false, length = 16)
+ private String symbol;
- @Enumerated(EnumType.STRING) private OrderSide side;
- @Enumerated(EnumType.STRING) private OrderType orderType;
+ @Enumerated(EnumType.STRING) @NotNull @Column(nullable = false)
+ private OrderSide side;
+ @Enumerated(EnumType.STRING) @NotNull @Column(nullable = false)
+ private OrderType orderType;
- private BigDecimal quantity;
- private BigDecimal price; // MARKET이면 null 가능
+ @Positive @NotNull
+ @Column(nullable = false, precision = 19, scale = 0) // 주식 수량: 정수
+ private BigDecimal quantity;
+ @Column(precision = 19, scale = 2) // LIMIT일 때만 사용
+ private BigDecimal price; // MARKET이면 null 가능
- @Enumerated(EnumType.STRING) private OrderStatus status;
+ @Enumerated(EnumType.STRING) @NotNull @Column(nullable = false)
+ private OrderStatus status;
private String brokerOrderId;
- private OffsetDateTime createdAt;
- private OffsetDateTime updatedAt;
+ @Column(nullable = false)
+ private OffsetDateTime createdAt;
+ @Column(nullable = false)
+ private OffsetDateTime updatedAt;
+
+ @Version
+ private Long version;Also applies to: 11-14, 16-33 |
||
| import java.math.BigDecimal; | ||
| import java.time.OffsetDateTime; | ||
|
|
||
| @Entity | ||
| @Table(name = "orders") | ||
| @Getter @Setter @NoArgsConstructor | ||
| public class Order { | ||
|
|
||
| @Id @GeneratedValue(strategy = GenerationType.IDENTITY) | ||
| private Long id; | ||
|
|
||
| private Long userId; | ||
| private String symbol; | ||
|
|
||
| @Enumerated(EnumType.STRING) private OrderSide side; | ||
| @Enumerated(EnumType.STRING) private OrderType orderType; | ||
|
|
||
| private BigDecimal quantity; | ||
| private BigDecimal price; // MARKET이면 null 가능 | ||
|
|
||
| @Enumerated(EnumType.STRING) private OrderStatus status; | ||
| private String brokerOrderId; | ||
|
|
||
| private OffsetDateTime createdAt; | ||
| private OffsetDateTime updatedAt; | ||
|
|
||
| @PrePersist | ||
| void onCreate() { | ||
| createdAt = OffsetDateTime.now(); | ||
| updatedAt = createdAt; | ||
| if (status == null) status = OrderStatus.PENDING; | ||
| } | ||
| @PreUpdate | ||
| void onUpdate() { updatedAt = OffsetDateTime.now(); } | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🛠️ Refactor suggestion
포트 인터페이스에 Bean Validation 추가(심볼 공백 금지, 수량/가격 양수)
String대신 도메인 타입(예:BrokerOrderResult{orderId,status,raw...})으로 승격하는 것도 추천합니다.📝 Committable suggestion
🤖 Prompt for AI Agents