diff --git a/ga-api/src/main/resources/application.yml b/ga-api/src/main/resources/application.yml index fa1153c..ab7e96d 100644 --- a/ga-api/src/main/resources/application.yml +++ b/ga-api/src/main/resources/application.yml @@ -61,6 +61,25 @@ app: adapter: bank: ${ADAPTER_BANK:mock} message: ${ADAPTER_MESSAGE:mock} + # KFTC 오픈뱅킹 입금이체 (bank=kftc 일 때 사용). 운영 시 토큰/약정계좌 주입. + kftc: + base-url: ${KFTC_BASE_URL:https://openapi.openbanking.or.kr} + access-token: ${KFTC_ACCESS_TOKEN:} + cntr-account-type: ${KFTC_CNTR_ACCOUNT_TYPE:N} + cntr-account-num: ${KFTC_CNTR_ACCOUNT_NUM:} + wd-print-content: ${KFTC_WD_PRINT:수수료정산} + name-check-option: ${KFTC_NAME_CHECK:off} + transfer-purpose: ${KFTC_TRANSFER_PURPOSE:TR} + # NHN Cloud(Toast) SMS/알림톡 (message=toast 일 때 사용). 운영 시 AppKey/SecretKey 주입. + toast: + sms-base-url: ${TOAST_SMS_BASE_URL:https://api-sms.cloud.toast.com} + sms-app-key: ${TOAST_SMS_APP_KEY:} + sms-secret-key: ${TOAST_SMS_SECRET_KEY:} + sender-no: ${TOAST_SENDER_NO:} + alimtalk-base-url: ${TOAST_ALIMTALK_BASE_URL:https://kakaotalk-bizmessage.api.nhncloudservice.com} + alimtalk-app-key: ${TOAST_ALIMTALK_APP_KEY:} + alimtalk-secret-key: ${TOAST_ALIMTALK_SECRET_KEY:} + sender-key: ${TOAST_SENDER_KEY:} # 펌뱅킹 재시도 배치 bank-retry: limit: ${BANK_RETRY_LIMIT:50} diff --git a/ga-api/src/test/java/com/ga/external/AdapterFailFastTest.java b/ga-api/src/test/java/com/ga/external/AdapterFailFastTest.java new file mode 100644 index 0000000..c79f485 --- /dev/null +++ b/ga-api/src/test/java/com/ga/external/AdapterFailFastTest.java @@ -0,0 +1,72 @@ +package com.ga.external; + +import com.ga.common.adapter.bank.BankTransferRequest; +import com.ga.common.adapter.message.MessageChannel; +import com.ga.common.adapter.message.MessageRequest; +import com.ga.external.bank.KftcBankTransferAdapter; +import com.ga.external.bank.KftcProperties; +import com.ga.external.message.ToastMessageAdapter; +import com.ga.external.message.ToastProperties; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.math.BigDecimal; + +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +/** + * 실 KFTC/Toast 어댑터의 fail-fast 동작 검증. + * 자격증명(키/토큰)이 비어 있으면 외부 호출 전에 IllegalStateException 으로 즉시 차단되어야 한다. + * (실 HTTP 는 운영 키 주입 후 테스트베드에서 검증) + */ +class AdapterFailFastTest { + + @Test + @DisplayName("Toast SMS: 설정 누락 시 fail-fast") + void toastSms_failFast() { + ToastMessageAdapter adapter = new ToastMessageAdapter(new ToastProperties()); + MessageRequest req = MessageRequest.builder() + .channel(MessageChannel.SMS) + .phoneNumber("010-1234-5678") + .content("테스트") + .build(); + assertThatThrownBy(() -> adapter.send(req)) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("app.adapter.toast"); + } + + @Test + @DisplayName("Toast 알림톡: templateCode 없으면 차단") + void toastAlimtalk_requiresTemplate() { + ToastProperties p = new ToastProperties(); + p.setAlimtalkAppKey("k"); + p.setAlimtalkSecretKey("s"); + p.setSenderKey("sk"); + ToastMessageAdapter adapter = new ToastMessageAdapter(p); + MessageRequest req = MessageRequest.builder() + .channel(MessageChannel.KAKAO) + .phoneNumber("01012345678") + .content("내용") + .build(); + assertThatThrownBy(() -> adapter.send(req)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("templateCode"); + } + + @Test + @DisplayName("KFTC 입금이체: 토큰/약정계좌 누락 시 fail-fast") + void kftc_failFast() { + KftcBankTransferAdapter adapter = new KftcBankTransferAdapter(new KftcProperties()); + BankTransferRequest req = BankTransferRequest.builder() + .withdrawRequestId(1L) + .bankCode("097") + .accountNo("1101230000678") + .accountHolderName("홍길동") + .amount(new BigDecimal("10000")) + .memo("정산") + .build(); + assertThatThrownBy(() -> adapter.requestTransfer(req)) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("app.adapter.kftc"); + } +} diff --git a/ga-external-adapter/src/main/java/com/ga/external/bank/KftcBankTransferAdapter.java b/ga-external-adapter/src/main/java/com/ga/external/bank/KftcBankTransferAdapter.java index 91b1251..68fc2f3 100644 --- a/ga-external-adapter/src/main/java/com/ga/external/bank/KftcBankTransferAdapter.java +++ b/ga-external-adapter/src/main/java/com/ga/external/bank/KftcBankTransferAdapter.java @@ -1,33 +1,165 @@ package com.ga.external.bank; +import com.fasterxml.jackson.databind.JsonNode; import com.ga.common.adapter.bank.BankTransferAdapter; import com.ga.common.adapter.bank.BankTransferRequest; import com.ga.common.adapter.bank.BankTransferResponse; +import com.ga.common.adapter.bank.BankTransferStatus; import lombok.extern.slf4j.Slf4j; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.http.MediaType; import org.springframework.stereotype.Component; +import org.springframework.util.StringUtils; +import org.springframework.web.client.RestClient; +import org.springframework.web.client.RestClientException; + +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; /** - * KFTC(금융결제원) 오픈뱅킹 펌뱅킹 어댑터 — 스켈레톤. + * KFTC(금융결제원) 오픈뱅킹 입금이체 어댑터 — 실구현. *
- * 활성 조건: app.adapter.bank=kftc. 실제 SDK / 클라이언트 의존성은 아직 미추가.
- * 외부 스펙(인증/엔드포인트/응답 코드)이 확정되면 본 클래스의 메서드를 구현한다.
- * 그 동안은 활성화 시 명시적 예외를 던져 잘못된 운영 적용을 빠르게 감지한다.
+ * 활성 조건: app.adapter.bank=kftc (기본은 mock). 키/약정계좌는 {@link KftcProperties} 로 외부화.
+ *
+ * GA→설계사 지급은 "이용기관 약정 출금계좌 → 설계사 입금계좌" 흐름이므로 KFTC 입금이체
+ * (POST /v2.0/transfer/deposit/acnt_num) 를 사용한다. 단건을 req_list 1건으로 호출한다.
+ *
GET /v2.0/transfer/result) → 정상 시 SETTLED
+ * 주의(운영): Access Token(2-legged 이용기관 토큰) 재발급, 약정계좌 등록, 실명확인 옵션 정책은
+ * 금융결제원 계약·이용기관 설정에 따른다. 본 어댑터는 문서화된 API 형식에 맞춰 호출하며 실거래 검증은
+ * 테스트베드/운영 키 주입 후 수행한다.
*/
@Slf4j
@Component
@ConditionalOnProperty(prefix = "app.adapter", name = "bank", havingValue = "kftc")
+@EnableConfigurationProperties(KftcProperties.class)
public class KftcBankTransferAdapter implements BankTransferAdapter {
+ private static final DateTimeFormatter TS = DateTimeFormatter.ofPattern("yyyyMMddHHmmss");
+
+ private final KftcProperties props;
+ private final RestClient client;
+
+ public KftcBankTransferAdapter(KftcProperties props) {
+ this.props = props;
+ this.client = RestClient.builder().baseUrl(props.getBaseUrl()).build();
+ }
+
@Override
public BankTransferResponse requestTransfer(BankTransferRequest request) {
- throw new UnsupportedOperationException(
- "KFTC 펌뱅킹 SDK 통합 미구현 — application.yml 의 app.adapter.bank=mock 으로 되돌리거나 본 어댑터를 완성하세요.");
+ require(props.getAccessToken(), "app.adapter.kftc.access-token");
+ require(props.getCntrAccountNum(), "app.adapter.kftc.cntr-account-num");
+
+ String bankTranId = buildBankTranId(request.getWithdrawRequestId());
+ String tranDtime = LocalDateTime.now().format(TS);
+
+ // 입금이체 단건 (req_list 1건). 문서화된 KFTC 입금이체 필드명 사용.
+ Map
+ * application.yml 의
- * 활성 조건:
+ * application.yml 의 app.adapter.kftc.* 로 주입.
+ * 운영 전제: 이용기관 등록 + 2-legged Access Token(이용기관 토큰) 발급 + 약정 출금계좌 등록이 선행되어야 한다.
+ * Access Token 의 주기적 재발급은 운영(스케줄러/Secret 관리) 책임이며, 본 어댑터는 주입된 토큰을 사용한다.
+ * 필수 값이 비어 있으면 어댑터가 fail-fast 한다.
+ */
+@Data
+@ConfigurationProperties(prefix = "app.adapter.kftc")
+public class KftcProperties {
+
+ /** 오픈뱅킹 API 베이스 URL (운영 https://openapi.openbanking.or.kr / 테스트베드 https://developers.openbanking.or.kr). */
+ private String baseUrl = "https://openapi.openbanking.or.kr";
+
+ /** 이용기관 Access Token (Bearer). 운영에서 주기적으로 재발급해 주입. */
+ private String accessToken;
+
+ /** 이용기관 약정 출금계좌 구분 (N: 계좌). */
+ private String cntrAccountType = "N";
+
+ /** 이용기관 약정 출금계좌 번호. */
+ private String cntrAccountNum;
+
+ /** 출금계좌 인자내역(통장 적요). */
+ private String wdPrintContent = "수수료정산";
+
+ /** 수취인 실명 확인 옵션 (off/on). */
+ private String nameCheckOption = "off";
+
+ /** 이체 용도 코드 (TR: 송금 등). */
+ private String transferPurpose = "TR";
+}
diff --git a/ga-external-adapter/src/main/java/com/ga/external/message/ToastMessageAdapter.java b/ga-external-adapter/src/main/java/com/ga/external/message/ToastMessageAdapter.java
index a9a5d70..5cd7baf 100644
--- a/ga-external-adapter/src/main/java/com/ga/external/message/ToastMessageAdapter.java
+++ b/ga-external-adapter/src/main/java/com/ga/external/message/ToastMessageAdapter.java
@@ -1,26 +1,159 @@
package com.ga.external.message;
+import com.fasterxml.jackson.databind.JsonNode;
import com.ga.common.adapter.message.MessageAdapter;
+import com.ga.common.adapter.message.MessageChannel;
import com.ga.common.adapter.message.MessageRequest;
import com.ga.common.adapter.message.MessageResponse;
import lombok.extern.slf4j.Slf4j;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
+import org.springframework.boot.context.properties.EnableConfigurationProperties;
+import org.springframework.http.MediaType;
import org.springframework.stereotype.Component;
+import org.springframework.util.StringUtils;
+import org.springframework.web.client.RestClient;
+import org.springframework.web.client.RestClientException;
+
+import java.time.LocalDateTime;
+import java.util.List;
+import java.util.Map;
/**
- * NHN Toast SMS / 카카오 알림톡 어댑터 — 스켈레톤.
+ * NHN Cloud(Toast) SMS / 카카오 알림톡 어댑터 — 실구현.
* app.adapter.message=toast. 실제 SDK / API 키 등은 미주입 상태.
- * 외부 스펙(인증/엔드포인트/템플릿 코드)이 확정되면 본 클래스의 send 를 구현한다.
+ * 활성 조건: app.adapter.message=toast (기본은 mock). 키는 {@link ToastProperties} 로 외부화.
+ *
+ *
+ * 키가 비어 있으면 fail-fast 한다(잘못된 운영 적용 즉시 감지). 외부호출 감사로그는 ga-api 의 AOP가 남긴다.
*/
@Slf4j
@Component
@ConditionalOnProperty(prefix = "app.adapter", name = "message", havingValue = "toast")
+@EnableConfigurationProperties(ToastProperties.class)
public class ToastMessageAdapter implements MessageAdapter {
+ private final ToastProperties props;
+ private final RestClient smsClient;
+ private final RestClient alimtalkClient;
+
+ public ToastMessageAdapter(ToastProperties props) {
+ this.props = props;
+ this.smsClient = RestClient.builder().baseUrl(props.getSmsBaseUrl()).build();
+ this.alimtalkClient = RestClient.builder().baseUrl(props.getAlimtalkBaseUrl()).build();
+ }
+
@Override
public MessageResponse send(MessageRequest request) {
- throw new UnsupportedOperationException(
- "Toast / 카카오 비즈메시지 SDK 통합 미구현 — application.yml 의 app.adapter.message=mock 으로 되돌리거나 본 어댑터를 완성하세요.");
+ MessageChannel channel = request.getChannel() == null ? MessageChannel.SMS : request.getChannel();
+ return channel == MessageChannel.KAKAO ? sendAlimtalk(request) : sendSms(request);
+ }
+
+ // ── SMS ───────────────────────────────────────────────────────────
+ private MessageResponse sendSms(MessageRequest req) {
+ require(props.getSmsAppKey(), "app.adapter.toast.sms-app-key");
+ require(props.getSmsSecretKey(), "app.adapter.toast.sms-secret-key");
+ require(props.getSenderNo(), "app.adapter.toast.sender-no");
+
+ Mapapp.adapter.toast.* 로 주입. 운영 활성화 시 키를 채운다.
+ * 키가 비어 있으면 어댑터가 fail-fast(IllegalStateException) 한다.
+ */
+@Data
+@ConfigurationProperties(prefix = "app.adapter.toast")
+public class ToastProperties {
+
+ // ── SMS (api-sms.cloud.toast.com) ─────────────────────────────────
+ /** SMS API 베이스 URL. */
+ private String smsBaseUrl = "https://api-sms.cloud.toast.com";
+ /** SMS 프로젝트 AppKey. */
+ private String smsAppKey;
+ /** SMS Secret Key (X-Secret-Key 헤더). */
+ private String smsSecretKey;
+ /** 사전 등록된 발신번호. */
+ private String senderNo;
+
+ // ── 카카오 알림톡 (kakaotalk-bizmessage.api.nhncloudservice.com) ────
+ /** 알림톡 API 베이스 URL. */
+ private String alimtalkBaseUrl = "https://kakaotalk-bizmessage.api.nhncloudservice.com";
+ /** 알림톡 프로젝트 AppKey. */
+ private String alimtalkAppKey;
+ /** 알림톡 Secret Key. */
+ private String alimtalkSecretKey;
+ /** 카카오 비즈메시지 발신 프로필 SenderKey. */
+ private String senderKey;
+}