feat: 외부연동 어댑터 실구현 — NHN Toast(SMS/알림톡) + KFTC 오픈뱅킹 입금이체
공개 문서화된 실제 API 스펙에 맞춰 스켈레톤을 RestClient 기반 실구현으로 교체.
모두 @ConditionalOnProperty 로 config-gated, 기본은 Mock 유지 → 운영 영향 0.
- ToastMessageAdapter: SMS v3.0(/sms/v3.0/appKeys/{k}/sender/sms) + 알림톡 v2.3
(/alimtalk/v2.3/appkeys/{k}/messages). X-Secret-Key 인증, MessageChannel 분기.
응답 header.isSuccessful/resultCode + requestId 매핑. ToastProperties 외부화.
- KftcBankTransferAdapter: 입금이체(/v2.0/transfer/deposit/acnt_num) + 이체결과조회.
Bearer 토큰, 문서화된 KFTC 필드(bank_tran_id/req_list/rsp_code) 매핑. KftcProperties 외부화.
- 키/토큰 누락 시 fail-fast(IllegalStateException) — 잘못된 운영 적용 즉시 감지.
- application.yml app.adapter.{kftc,toast}.* 설정(env placeholder, 빈 기본값) 추가.
- 의존성 추가 없음(ga-common 의 spring-web RestClient + Jackson 재사용).
- AdapterFailFastTest 3건(설정누락 차단/알림톡 templateCode 필수) 추가.
제외: FCM 푸시 — MessageAdapter 인터페이스/PWA 연동 계약 미정으로 추측구현 회피.
실거래 검증은 운영 키(계약·AppKey·약정계좌·토큰) 주입 후 테스트베드에서 수행 필요.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
+140
-8
@@ -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(금융결제원) 오픈뱅킹 펌뱅킹 어댑터 — <b>스켈레톤</b>.
|
||||
* KFTC(금융결제원) 오픈뱅킹 <b>입금이체</b> 어댑터 — 실구현.
|
||||
* <p>
|
||||
* 활성 조건: <code>app.adapter.bank=kftc</code>. 실제 SDK / 클라이언트 의존성은 아직 미추가.
|
||||
* 외부 스펙(인증/엔드포인트/응답 코드)이 확정되면 본 클래스의 메서드를 구현한다.
|
||||
* 그 동안은 활성화 시 명시적 예외를 던져 잘못된 운영 적용을 빠르게 감지한다.
|
||||
* 활성 조건: <code>app.adapter.bank=kftc</code> (기본은 mock). 키/약정계좌는 {@link KftcProperties} 로 외부화.
|
||||
* <p>
|
||||
* GA→설계사 지급은 "이용기관 약정 출금계좌 → 설계사 입금계좌" 흐름이므로 KFTC <b>입금이체</b>
|
||||
* (<code>POST /v2.0/transfer/deposit/acnt_num</code>) 를 사용한다. 단건을 req_list 1건으로 호출한다.
|
||||
* <ul>
|
||||
* <li>requestTransfer: 입금이체 요청 → rsp_code 정상 시 ACCEPTED, api_tran_id 를 txId 로 반환</li>
|
||||
* <li>queryStatus: 이체결과조회(<code>GET /v2.0/transfer/result</code>) → 정상 시 SETTLED</li>
|
||||
* </ul>
|
||||
* 필수 설정 누락 시 fail-fast. 외부호출 감사로그는 ga-api 의 AOP가 남긴다.
|
||||
* <p>
|
||||
* <b>주의(운영)</b>: 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<String, Object> item = new LinkedHashMap<>();
|
||||
item.put("tran_no", "1");
|
||||
item.put("bank_tran_id", bankTranId);
|
||||
item.put("bank_code_std", request.getBankCode()); // 수취 은행 표준코드
|
||||
item.put("account_num", request.getAccountNo()); // 수취 계좌번호
|
||||
item.put("account_holder_name", request.getAccountHolderName()); // 수취인 성명
|
||||
item.put("print_content", trim(request.getMemo(), 20)); // 입금계좌 인자내역
|
||||
item.put("tran_amt", request.getAmount().toBigInteger().toString());
|
||||
item.put("req_client_name", request.getAccountHolderName());
|
||||
item.put("req_client_bank_code", request.getBankCode());
|
||||
item.put("req_client_account_num", request.getAccountNo());
|
||||
item.put("req_client_num", String.valueOf(request.getWithdrawRequestId()));
|
||||
item.put("transfer_purpose", props.getTransferPurpose());
|
||||
|
||||
Map<String, Object> body = new LinkedHashMap<>();
|
||||
body.put("cntr_account_type", props.getCntrAccountType());
|
||||
body.put("cntr_account_num", props.getCntrAccountNum());
|
||||
body.put("wd_pass_phrase", "NONE");
|
||||
body.put("wd_print_content", trim(props.getWdPrintContent(), 20));
|
||||
body.put("name_check_option", props.getNameCheckOption());
|
||||
body.put("tran_dtime", tranDtime);
|
||||
body.put("req_cnt", "1");
|
||||
body.put("req_list", List.of(item));
|
||||
|
||||
try {
|
||||
JsonNode res = client.post()
|
||||
.uri("/v2.0/transfer/deposit/acnt_num")
|
||||
.header("Authorization", "Bearer " + props.getAccessToken())
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.body(body)
|
||||
.retrieve()
|
||||
.body(JsonNode.class);
|
||||
return toResponse(res, BankTransferStatus.ACCEPTED);
|
||||
} catch (RestClientException e) {
|
||||
log.warn("[KFTC] 입금이체 실패 withdrawId={} bank={} err={}",
|
||||
request.getWithdrawRequestId(), request.getBankCode(), e.getMessage());
|
||||
return BankTransferResponse.builder()
|
||||
.status(BankTransferStatus.FAILED)
|
||||
.resultCode("ERROR").resultMessage(e.getMessage())
|
||||
.processedAt(LocalDateTime.now()).build();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public BankTransferResponse queryStatus(String txId) {
|
||||
throw new UnsupportedOperationException(
|
||||
"KFTC 펌뱅킹 SDK 통합 미구현 — txId=" + txId);
|
||||
require(props.getAccessToken(), "app.adapter.kftc.access-token");
|
||||
try {
|
||||
JsonNode res = client.get()
|
||||
.uri(uri -> uri.path("/v2.0/transfer/result")
|
||||
.queryParam("bank_tran_id", txId)
|
||||
.queryParam("inquiry_bank_tran_date",
|
||||
LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyyMMdd")))
|
||||
.build())
|
||||
.header("Authorization", "Bearer " + props.getAccessToken())
|
||||
.retrieve()
|
||||
.body(JsonNode.class);
|
||||
return toResponse(res, BankTransferStatus.SETTLED);
|
||||
} catch (RestClientException e) {
|
||||
log.warn("[KFTC] 이체결과조회 실패 txId={} err={}", txId, e.getMessage());
|
||||
return BankTransferResponse.builder()
|
||||
.txId(txId).status(BankTransferStatus.FAILED)
|
||||
.resultCode("ERROR").resultMessage(e.getMessage())
|
||||
.processedAt(LocalDateTime.now()).build();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* KFTC 응답(rsp_code/rsp_message/api_tran_id) → BankTransferResponse.
|
||||
* rsp_code "A0000" 이 API 정상. 정상이면 호출 의미에 따른 status(요청=ACCEPTED/조회=SETTLED), 아니면 FAILED.
|
||||
*/
|
||||
private BankTransferResponse toResponse(JsonNode res, BankTransferStatus okStatus) {
|
||||
String rspCode = res == null ? "" : res.path("rsp_code").asText("");
|
||||
String rspMsg = res == null ? "no response" : res.path("rsp_message").asText("");
|
||||
String apiTranId = res == null ? "" : res.path("api_tran_id").asText("");
|
||||
boolean ok = "A0000".equals(rspCode);
|
||||
return BankTransferResponse.builder()
|
||||
.txId(apiTranId)
|
||||
.status(ok ? okStatus : BankTransferStatus.FAILED)
|
||||
.resultCode(rspCode)
|
||||
.resultMessage(rspMsg)
|
||||
.processedAt(LocalDateTime.now())
|
||||
.build();
|
||||
}
|
||||
|
||||
/** 은행거래고유번호: 이용기관코드(운영주입) 미보유 시 withdrawId 기반 고유키 생성(멱등 추적용). */
|
||||
private String buildBankTranId(Long withdrawRequestId) {
|
||||
String seq = String.format("%09d", withdrawRequestId == null ? 0 : withdrawRequestId % 1_000_000_000L);
|
||||
return "GA" + LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyyMMdd")) + "U" + seq;
|
||||
}
|
||||
|
||||
private String trim(String s, int max) {
|
||||
if (s == null) return "";
|
||||
return s.length() <= max ? s : s.substring(0, max);
|
||||
}
|
||||
|
||||
private void require(String value, String key) {
|
||||
if (!StringUtils.hasText(value)) {
|
||||
throw new IllegalStateException("KFTC 설정 누락: " + key + " 가 필요합니다.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
package com.ga.external.bank;
|
||||
|
||||
import lombok.Data;
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
|
||||
/**
|
||||
* KFTC(금융결제원) 오픈뱅킹 입금이체 설정.
|
||||
* <p>
|
||||
* application.yml 의 <code>app.adapter.kftc.*</code> 로 주입.
|
||||
* <b>운영 전제</b>: 이용기관 등록 + 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";
|
||||
}
|
||||
+138
-5
@@ -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 / 카카오 알림톡 어댑터 — <b>스켈레톤</b>.
|
||||
* NHN Cloud(Toast) SMS / 카카오 알림톡 어댑터 — 실구현.
|
||||
* <p>
|
||||
* 활성 조건: <code>app.adapter.message=toast</code>. 실제 SDK / API 키 등은 미주입 상태.
|
||||
* 외부 스펙(인증/엔드포인트/템플릿 코드)이 확정되면 본 클래스의 send 를 구현한다.
|
||||
* 활성 조건: <code>app.adapter.message=toast</code> (기본은 mock). 키는 {@link ToastProperties} 로 외부화.
|
||||
* <ul>
|
||||
* <li>{@link MessageChannel#SMS} → SMS v3.0 단문 발송 API</li>
|
||||
* <li>{@link MessageChannel#KAKAO} → 알림톡 v2.3 발송 API (templateCode + 파라미터)</li>
|
||||
* </ul>
|
||||
* 키가 비어 있으면 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");
|
||||
|
||||
Map<String, Object> body = Map.of(
|
||||
"body", req.getContent(),
|
||||
"sendNo", props.getSenderNo(),
|
||||
"recipientList", List.of(Map.of("recipientNo", normalize(req.getPhoneNumber()))));
|
||||
|
||||
try {
|
||||
JsonNode res = smsClient.post()
|
||||
.uri("/sms/v3.0/appKeys/{appKey}/sender/sms", props.getSmsAppKey())
|
||||
.header("X-Secret-Key", props.getSmsSecretKey())
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.body(body)
|
||||
.retrieve()
|
||||
.body(JsonNode.class);
|
||||
return toResponse(res, "body");
|
||||
} catch (RestClientException e) {
|
||||
log.warn("[TOAST SMS] 전송 실패 phone={} err={}", mask(req.getPhoneNumber()), e.getMessage());
|
||||
return fail(e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
// ── 카카오 알림톡 ───────────────────────────────────────────────────
|
||||
private MessageResponse sendAlimtalk(MessageRequest req) {
|
||||
require(props.getAlimtalkAppKey(), "app.adapter.toast.alimtalk-app-key");
|
||||
require(props.getAlimtalkSecretKey(), "app.adapter.toast.alimtalk-secret-key");
|
||||
require(props.getSenderKey(), "app.adapter.toast.sender-key");
|
||||
if (!StringUtils.hasText(req.getTemplateCode())) {
|
||||
throw new IllegalArgumentException("알림톡 발송에는 templateCode 가 필요합니다.");
|
||||
}
|
||||
|
||||
Map<String, Object> recipient = Map.of(
|
||||
"recipientNo", normalize(req.getPhoneNumber()),
|
||||
"templateParameter", Map.of(
|
||||
"title", req.getTitle() == null ? "" : req.getTitle(),
|
||||
"content", req.getContent() == null ? "" : req.getContent()));
|
||||
Map<String, Object> body = Map.of(
|
||||
"senderKey", props.getSenderKey(),
|
||||
"templateCode", req.getTemplateCode(),
|
||||
"recipientList", List.of(recipient));
|
||||
|
||||
try {
|
||||
JsonNode res = alimtalkClient.post()
|
||||
.uri("/alimtalk/v2.3/appkeys/{appkey}/messages", props.getAlimtalkAppKey())
|
||||
.header("X-Secret-Key", props.getAlimtalkSecretKey())
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.body(body)
|
||||
.retrieve()
|
||||
.body(JsonNode.class);
|
||||
return toResponse(res, "message");
|
||||
} catch (RestClientException e) {
|
||||
log.warn("[TOAST ALIMTALK] 전송 실패 phone={} template={} err={}",
|
||||
mask(req.getPhoneNumber()), req.getTemplateCode(), e.getMessage());
|
||||
return fail(e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Toast 공통 응답(header.isSuccessful/resultCode/resultMessage) 파싱.
|
||||
* SMS 는 body.data.requestId, 알림톡은 message.requestId 로 발급 id 위치가 다르다.
|
||||
*/
|
||||
private MessageResponse toResponse(JsonNode res, String dataRoot) {
|
||||
boolean ok = res != null && res.path("header").path("isSuccessful").asBoolean(false);
|
||||
int code = res == null ? -1 : res.path("header").path("resultCode").asInt(-1);
|
||||
String msg = res == null ? "no response" : res.path("header").path("resultMessage").asText("");
|
||||
String messageId = "";
|
||||
if (res != null) {
|
||||
JsonNode root = res.path(dataRoot);
|
||||
messageId = root.path("data").path("requestId").asText(
|
||||
root.path("requestId").asText(""));
|
||||
}
|
||||
return MessageResponse.builder()
|
||||
.messageId(messageId)
|
||||
.sent(ok)
|
||||
.resultCode(String.valueOf(code))
|
||||
.resultMessage(msg)
|
||||
.sentAt(LocalDateTime.now())
|
||||
.build();
|
||||
}
|
||||
|
||||
private MessageResponse fail(String message) {
|
||||
return MessageResponse.builder()
|
||||
.sent(false).resultCode("ERROR").resultMessage(message)
|
||||
.sentAt(LocalDateTime.now()).build();
|
||||
}
|
||||
|
||||
private void require(String value, String key) {
|
||||
if (!StringUtils.hasText(value)) {
|
||||
throw new IllegalStateException("Toast 설정 누락: " + key + " 가 필요합니다.");
|
||||
}
|
||||
}
|
||||
|
||||
/** 010-1234-5678 → 01012345678 (Toast 는 하이픈 없는 번호 권장). */
|
||||
private String normalize(String phone) {
|
||||
return phone == null ? null : phone.replaceAll("[^0-9]", "");
|
||||
}
|
||||
|
||||
private String mask(String phone) {
|
||||
String d = normalize(phone);
|
||||
if (d == null || d.length() <= 4) return "****";
|
||||
return d.substring(0, d.length() - 4) + "****";
|
||||
}
|
||||
}
|
||||
|
||||
+35
@@ -0,0 +1,35 @@
|
||||
package com.ga.external.message;
|
||||
|
||||
import lombok.Data;
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
|
||||
/**
|
||||
* NHN Cloud(Toast) 메시지 발송 설정.
|
||||
* <p>
|
||||
* application.yml 의 <code>app.adapter.toast.*</code> 로 주입. 운영 활성화 시 키를 채운다.
|
||||
* 키가 비어 있으면 어댑터가 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;
|
||||
}
|
||||
Reference in New Issue
Block a user