diff --git a/ga-api/build.gradle b/ga-api/build.gradle index 55a231d..edf680b 100644 --- a/ga-api/build.gradle +++ b/ga-api/build.gradle @@ -10,5 +10,6 @@ jar.enabled = false dependencies { implementation project(':ga-core') + implementation project(':ga-external-adapter') testImplementation 'org.springframework.security:spring-security-test' } diff --git a/ga-api/src/main/java/com/ga/api/service/approval/ApprovalService.java b/ga-api/src/main/java/com/ga/api/service/approval/ApprovalService.java index 4d4e4c3..a102b7e 100644 --- a/ga-api/src/main/java/com/ga/api/service/approval/ApprovalService.java +++ b/ga-api/src/main/java/com/ga/api/service/approval/ApprovalService.java @@ -1,5 +1,12 @@ package com.ga.api.service.approval; +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 com.ga.common.adapter.message.MessageAdapter; +import com.ga.common.adapter.message.MessageChannel; +import com.ga.common.adapter.message.MessageRequest; import com.ga.common.exception.BizException; import com.ga.common.exception.ErrorCode; import com.ga.common.model.PageResponse; @@ -12,6 +19,7 @@ import com.ga.core.mapper.settle.SettleMasterMapper; import com.ga.core.mapper.withdraw.WithdrawRequestMapper; import com.ga.core.vo.approval.*; import com.ga.core.vo.notice.NotificationRefType; +import com.ga.core.vo.withdraw.WithdrawRequestVO; // Note: ApprovalLineVO kept for createLine/updateLine methods import com.ga.core.vo.notice.UserNotificationVO; import com.ga.core.vo.settle.SettleStatus; @@ -41,6 +49,10 @@ public class ApprovalService { private final CommissionDisputeMapper disputeMapper; private final IncentivePaymentMapper incentivePaymentMapper; + // P6 외부연동 어댑터 — 실제 구현은 ga-external-adapter 모듈 (현재 Mock) + private final BankTransferAdapter bankTransferAdapter; + private final MessageAdapter messageAdapter; + // ── Line ────────────────────────────────────────────────────────────────── public PageResponse listLines(ApprovalLineSearchParam param) { @@ -228,6 +240,7 @@ public class ApprovalService { case "WITHDRAW" -> { withdrawMapper.updateStatus(targetId, "APPROVED", null); log.info("출금신청 승인 완료: requestId={}", targetId); + requestBankTransfer(targetId); } case "SETTLE" -> { settleMasterMapper.updateStatus(targetId, SettleStatus.CONFIRMED.name(), @@ -252,7 +265,7 @@ public class ApprovalService { } } - /** 다음 결재 단계 담당자에게 알림 발송 */ + /** 다음 결재 단계 담당자에게 알림 발송 (DB + 외부채널) */ private void sendStepNotification(Long requestId, Long lineId, int stepNo, String targetType) { try { ApprovalLineStepVO step = stepMapper.selectByLineAndStep(lineId, stepNo); @@ -260,14 +273,79 @@ public class ApprovalService { // roleCode 기반 userId 매핑은 P5에서 고도화 — 현재는 메타만 저장 UserNotificationVO noti = new UserNotificationVO(); noti.setUserId(0L); // 실제 담당자 ID 조회 로직은 P5 - noti.setTitle("[결재요청] " + (targetType != null ? targetType : "") + " 결재 " + stepNo + "단계"); - noti.setContent("결재 요청 ID " + requestId + " 에 대한 " + stepNo + "단계 결재가 필요합니다."); + String title = "[결재요청] " + (targetType != null ? targetType : "") + " 결재 " + stepNo + "단계"; + String content = "결재 요청 ID " + requestId + " 에 대한 " + stepNo + "단계 결재가 필요합니다."; + noti.setTitle(title); + noti.setContent(content); noti.setRefType(NotificationRefType.APPROVAL.name()); noti.setRefId(requestId); noti.setIsRead(false); notificationMapper.insert(noti); + + // SMS/카카오 외부채널 — Mock 단계라 항상 시도. 실패해도 DB 알림은 살아있다. + sendExternalMessage(MessageChannel.SMS, title, content, requestId); } catch (Exception e) { log.warn("결재 알림 발송 실패 (non-critical): requestId={}", requestId, e); } } + + /** + * 출금 결재 최종 승인 → 펌뱅킹 어댑터 요청. + *

+ * 어댑터가 실패 응답을 줘도 결재 자체는 이미 APPROVED 이므로 예외를 던지지 않는다. + * (재시도 배치는 transfer_status='FAILED' 필터로 별도 구현 예정.) + */ + private void requestBankTransfer(Long withdrawId) { + try { + WithdrawRequestVO vo = withdrawMapper.selectVOById(withdrawId); + if (vo == null) { + log.warn("[BANK] withdraw 조회 실패: requestId={}", withdrawId); + return; + } + BankTransferRequest req = BankTransferRequest.builder() + .withdrawRequestId(withdrawId) + .bankCode(vo.getBankCode()) + .accountNo(vo.getAccountNo()) // 복호화 평문 + .amount(vo.getAmount()) + .memo("GA수수료 출금 #" + withdrawId) + .build(); + BankTransferResponse resp = bankTransferAdapter.requestTransfer(req); + + boolean ok = resp != null && resp.getStatus() != BankTransferStatus.FAILED; + String nextStatus = ok ? "SENT" : "APPROVED"; + String failReason = ok ? null : (resp != null ? resp.getResultMessage() : "no response"); + withdrawMapper.updateTransferResult( + withdrawId, + resp != null ? resp.getTxId() : null, + resp != null && resp.getStatus() != null ? resp.getStatus().name() : null, + nextStatus, + failReason + ); + } catch (Exception e) { + // 어댑터 자체가 예외를 던진 경우 — APPROVED 상태로 두고 fail_reason 만 갱신. + log.error("[BANK] 펌뱅킹 어댑터 호출 실패: requestId={}", withdrawId, e); + try { + withdrawMapper.updateTransferResult( + withdrawId, null, BankTransferStatus.FAILED.name(), + "APPROVED", e.getMessage()); + } catch (Exception ignore) { /* 결재 흐름 깨지지 않게 swallow */ } + } + } + + /** SMS/카카오 어댑터로 메시지 전송 — Mock 단계라 PoC 용 phone "010-0000-0000" 고정. */ + private void sendExternalMessage(MessageChannel channel, String title, String content, Long refId) { + try { + MessageRequest req = MessageRequest.builder() + .channel(channel) + .phoneNumber("010-0000-0000") // P6 보강: user.phone 조회로 교체 + .title(title) + .content(content) + .refType(NotificationRefType.APPROVAL.name()) + .refId(refId) + .build(); + messageAdapter.send(req); + } catch (Exception e) { + log.warn("[MSG] {} 발송 실패 (non-critical): refId={}", channel, refId, e); + } + } } diff --git a/ga-common/src/main/java/com/ga/common/adapter/bank/BankTransferAdapter.java b/ga-common/src/main/java/com/ga/common/adapter/bank/BankTransferAdapter.java new file mode 100644 index 0000000..56119c2 --- /dev/null +++ b/ga-common/src/main/java/com/ga/common/adapter/bank/BankTransferAdapter.java @@ -0,0 +1,16 @@ +package com.ga.common.adapter.bank; + +/** + * 펌뱅킹(이체) 외부연동 어댑터. + *

+ * 구현체는 ga-external-adapter 모듈에 위치한다. + * 실제 KFTC/은행 SDK 연동 시 이 인터페이스만 새 구현으로 교체하면 된다. + */ +public interface BankTransferAdapter { + + /** 단건 이체 요청. 외부 시스템 응답을 그대로 반환 (영속화는 호출자 책임). */ + BankTransferResponse requestTransfer(BankTransferRequest request); + + /** 이전 거래의 현재 상태 조회 (대사용). */ + BankTransferResponse queryStatus(String txId); +} diff --git a/ga-common/src/main/java/com/ga/common/adapter/bank/BankTransferRequest.java b/ga-common/src/main/java/com/ga/common/adapter/bank/BankTransferRequest.java new file mode 100644 index 0000000..654b309 --- /dev/null +++ b/ga-common/src/main/java/com/ga/common/adapter/bank/BankTransferRequest.java @@ -0,0 +1,17 @@ +package com.ga.common.adapter.bank; + +import lombok.Builder; +import lombok.Data; + +import java.math.BigDecimal; + +@Data +@Builder +public class BankTransferRequest { + private Long withdrawRequestId; // 원천 출금 신청 id (멱등키) + private String bankCode; + private String accountNo; // 평문 (어댑터 내부에서 마스킹 로그) + private String accountHolderName; + private BigDecimal amount; + private String memo; // 통장 적요 +} diff --git a/ga-common/src/main/java/com/ga/common/adapter/bank/BankTransferResponse.java b/ga-common/src/main/java/com/ga/common/adapter/bank/BankTransferResponse.java new file mode 100644 index 0000000..34e7114 --- /dev/null +++ b/ga-common/src/main/java/com/ga/common/adapter/bank/BankTransferResponse.java @@ -0,0 +1,16 @@ +package com.ga.common.adapter.bank; + +import lombok.Builder; +import lombok.Data; + +import java.time.LocalDateTime; + +@Data +@Builder +public class BankTransferResponse { + private String txId; // 외부 거래 id + private BankTransferStatus status; // ACCEPTED/SETTLED/FAILED 등 + private String resultCode; // 외부 응답 코드 + private String resultMessage; // 외부 응답 메시지 + private LocalDateTime processedAt; +} diff --git a/ga-common/src/main/java/com/ga/common/adapter/bank/BankTransferStatus.java b/ga-common/src/main/java/com/ga/common/adapter/bank/BankTransferStatus.java new file mode 100644 index 0000000..9cf06f3 --- /dev/null +++ b/ga-common/src/main/java/com/ga/common/adapter/bank/BankTransferStatus.java @@ -0,0 +1,7 @@ +package com.ga.common.adapter.bank; + +public enum BankTransferStatus { + ACCEPTED, // 접수 + SETTLED, // 입금 완료 + FAILED // 실패 +} diff --git a/ga-common/src/main/java/com/ga/common/adapter/message/MessageAdapter.java b/ga-common/src/main/java/com/ga/common/adapter/message/MessageAdapter.java new file mode 100644 index 0000000..fba0116 --- /dev/null +++ b/ga-common/src/main/java/com/ga/common/adapter/message/MessageAdapter.java @@ -0,0 +1,11 @@ +package com.ga.common.adapter.message; + +/** + * SMS/카카오 알림톡 외부연동 어댑터. + * 구현체는 ga-external-adapter 모듈에 위치. + */ +public interface MessageAdapter { + + /** 단건 메시지 전송. */ + MessageResponse send(MessageRequest request); +} diff --git a/ga-common/src/main/java/com/ga/common/adapter/message/MessageChannel.java b/ga-common/src/main/java/com/ga/common/adapter/message/MessageChannel.java new file mode 100644 index 0000000..a48a62c --- /dev/null +++ b/ga-common/src/main/java/com/ga/common/adapter/message/MessageChannel.java @@ -0,0 +1,6 @@ +package com.ga.common.adapter.message; + +public enum MessageChannel { + SMS, + KAKAO +} diff --git a/ga-common/src/main/java/com/ga/common/adapter/message/MessageRequest.java b/ga-common/src/main/java/com/ga/common/adapter/message/MessageRequest.java new file mode 100644 index 0000000..0042ae6 --- /dev/null +++ b/ga-common/src/main/java/com/ga/common/adapter/message/MessageRequest.java @@ -0,0 +1,17 @@ +package com.ga.common.adapter.message; + +import lombok.Builder; +import lombok.Data; + +@Data +@Builder +public class MessageRequest { + private MessageChannel channel; + private String phoneNumber; // 수신자 (E.164 또는 010-xxxx-xxxx) + private String title; // 카카오 알림톡 강조 헤더 용 (SMS는 무시 가능) + private String content; + private String templateCode; // 카카오 발신 템플릿 코드 (sms 미사용) + private Long refUserId; // 추적용 — user_notification.user_id + private String refType; // APPROVAL/SETTLE 등 + private Long refId; +} diff --git a/ga-common/src/main/java/com/ga/common/adapter/message/MessageResponse.java b/ga-common/src/main/java/com/ga/common/adapter/message/MessageResponse.java new file mode 100644 index 0000000..bd1d8ad --- /dev/null +++ b/ga-common/src/main/java/com/ga/common/adapter/message/MessageResponse.java @@ -0,0 +1,16 @@ +package com.ga.common.adapter.message; + +import lombok.Builder; +import lombok.Data; + +import java.time.LocalDateTime; + +@Data +@Builder +public class MessageResponse { + private String messageId; // 외부 시스템 발급 id + private boolean sent; + private String resultCode; + private String resultMessage; + private LocalDateTime sentAt; +} diff --git a/ga-common/src/main/resources/db/migration/V82__펌뱅킹_추적컬럼.sql b/ga-common/src/main/resources/db/migration/V82__펌뱅킹_추적컬럼.sql new file mode 100644 index 0000000..ec79e72 --- /dev/null +++ b/ga-common/src/main/resources/db/migration/V82__펌뱅킹_추적컬럼.sql @@ -0,0 +1,15 @@ +-- V82: withdraw_request 에 펌뱅킹 외부연동 결과 추적 컬럼 추가. +-- 어댑터(BankTransferAdapter) 호출 결과(거래ID/상태/처리시각)를 저장하기 위함. + +ALTER TABLE withdraw_request + ADD COLUMN IF NOT EXISTS transfer_tx_id VARCHAR(64), + ADD COLUMN IF NOT EXISTS transfer_status VARCHAR(20), + ADD COLUMN IF NOT EXISTS transfer_at TIMESTAMP; + +COMMENT ON COLUMN withdraw_request.transfer_tx_id IS '펌뱅킹 외부 거래 ID (멱등키)'; +COMMENT ON COLUMN withdraw_request.transfer_status IS 'ACCEPTED/SETTLED/FAILED — BankTransferStatus'; +COMMENT ON COLUMN withdraw_request.transfer_at IS '펌뱅킹 호출/응답 시각'; + +CREATE INDEX IF NOT EXISTS idx_wr_transfer_status + ON withdraw_request(transfer_status) + WHERE transfer_status IS NOT NULL; diff --git a/ga-core/src/main/java/com/ga/core/mapper/withdraw/WithdrawRequestMapper.java b/ga-core/src/main/java/com/ga/core/mapper/withdraw/WithdrawRequestMapper.java index 500bf8b..bb900e5 100644 --- a/ga-core/src/main/java/com/ga/core/mapper/withdraw/WithdrawRequestMapper.java +++ b/ga-core/src/main/java/com/ga/core/mapper/withdraw/WithdrawRequestMapper.java @@ -20,4 +20,11 @@ public interface WithdrawRequestMapper { int markSent(@Param("requestId") Long requestId); int markCompleted(@Param("requestId") Long requestId); int cancel(@Param("requestId") Long requestId); + + /** 펌뱅킹 어댑터 응답을 저장 (transfer_tx_id/status/at + 거래상태에 따른 status 전이). */ + int updateTransferResult(@Param("requestId") Long requestId, + @Param("txId") String txId, + @Param("transferStatus") String transferStatus, + @Param("nextStatus") String nextStatus, + @Param("failReason") String failReason); } diff --git a/ga-core/src/main/resources/mapper/withdraw/WithdrawRequestMapper.xml b/ga-core/src/main/resources/mapper/withdraw/WithdrawRequestMapper.xml index 9e08331..8a7380a 100644 --- a/ga-core/src/main/resources/mapper/withdraw/WithdrawRequestMapper.xml +++ b/ga-core/src/main/resources/mapper/withdraw/WithdrawRequestMapper.xml @@ -140,4 +140,17 @@ AND status IN ('REQUESTED', 'APPROVED') + + + UPDATE withdraw_request + SET transfer_tx_id = #{txId,jdbcType=VARCHAR}, + transfer_status = #{transferStatus,jdbcType=VARCHAR}, + transfer_at = NOW(), + status = #{nextStatus}, + fail_reason = #{failReason,jdbcType=VARCHAR}, + sent_at = NOW(), + updated_at = NOW() + WHERE request_id = #{requestId} + + diff --git a/ga-external-adapter/build.gradle b/ga-external-adapter/build.gradle new file mode 100644 index 0000000..e48cb2b --- /dev/null +++ b/ga-external-adapter/build.gradle @@ -0,0 +1,6 @@ +// ga-external-adapter: 외부 시스템 연동 어댑터 구현체 모듈 (라이브러리) +// - 펌뱅킹/SMS/카카오 등 외부 SDK 의존성을 이 모듈에 격리한다. +// - PoC 단계: Mock 구현만 제공. 실제 SDK 통합 시 이 모듈에서만 의존성/구현 교체. +dependencies { + api project(':ga-common') +} diff --git a/ga-external-adapter/src/main/java/com/ga/external/bank/MockBankTransferAdapter.java b/ga-external-adapter/src/main/java/com/ga/external/bank/MockBankTransferAdapter.java new file mode 100644 index 0000000..0462c72 --- /dev/null +++ b/ga-external-adapter/src/main/java/com/ga/external/bank/MockBankTransferAdapter.java @@ -0,0 +1,57 @@ +package com.ga.external.bank; + +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.stereotype.Component; + +import java.time.LocalDateTime; +import java.util.UUID; + +/** + * 펌뱅킹 Mock 구현. + *

+ * 실제 KFTC/은행 SDK 가 들어오기 전까지 사용. 결과는 항상 ACCEPTED 반환 + 로그. + * 계좌번호는 뒷 4자리 외 마스킹해 로그에 남긴다. + */ +@Slf4j +@Component +public class MockBankTransferAdapter implements BankTransferAdapter { + + @Override + public BankTransferResponse requestTransfer(BankTransferRequest request) { + String masked = maskAccount(request.getAccountNo()); + String txId = "MOCK-" + UUID.randomUUID(); + log.info("[MOCK BANK] requestTransfer withdrawId={} bank={} acc={} amount={} txId={}", + request.getWithdrawRequestId(), request.getBankCode(), masked, request.getAmount(), txId); + + return BankTransferResponse.builder() + .txId(txId) + .status(BankTransferStatus.ACCEPTED) + .resultCode("0000") + .resultMessage("MOCK accepted") + .processedAt(LocalDateTime.now()) + .build(); + } + + @Override + public BankTransferResponse queryStatus(String txId) { + log.info("[MOCK BANK] queryStatus txId={}", txId); + return BankTransferResponse.builder() + .txId(txId) + .status(BankTransferStatus.SETTLED) + .resultCode("0000") + .resultMessage("MOCK settled") + .processedAt(LocalDateTime.now()) + .build(); + } + + private String maskAccount(String accountNo) { + if (accountNo == null) return null; + String digits = accountNo.replaceAll("[^0-9]", ""); + if (digits.length() <= 4) return "****"; + return "****" + digits.substring(digits.length() - 4); + } +} diff --git a/ga-external-adapter/src/main/java/com/ga/external/message/MockMessageAdapter.java b/ga-external-adapter/src/main/java/com/ga/external/message/MockMessageAdapter.java new file mode 100644 index 0000000..835c512 --- /dev/null +++ b/ga-external-adapter/src/main/java/com/ga/external/message/MockMessageAdapter.java @@ -0,0 +1,45 @@ +package com.ga.external.message; + +import com.ga.common.adapter.message.MessageAdapter; +import com.ga.common.adapter.message.MessageRequest; +import com.ga.common.adapter.message.MessageResponse; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +import java.time.LocalDateTime; +import java.util.UUID; + +/** + * SMS / 카카오 알림톡 Mock 구현. + *

+ * 실제 NHN Toast/알리고/카카오 비즈메시지 SDK 가 들어오기 전까지 사용. 항상 성공 응답. + * phoneNumber 는 뒷 4자리 외 마스킹. + */ +@Slf4j +@Component +public class MockMessageAdapter implements MessageAdapter { + + @Override + public MessageResponse send(MessageRequest request) { + String masked = maskPhone(request.getPhoneNumber()); + String msgId = "MOCK-MSG-" + UUID.randomUUID(); + log.info("[MOCK MSG] send channel={} to={} title={} refType={} refId={} msgId={}", + request.getChannel(), masked, request.getTitle(), + request.getRefType(), request.getRefId(), msgId); + + return MessageResponse.builder() + .messageId(msgId) + .sent(true) + .resultCode("0000") + .resultMessage("MOCK sent") + .sentAt(LocalDateTime.now()) + .build(); + } + + private String maskPhone(String phone) { + if (phone == null) return null; + String digits = phone.replaceAll("[^0-9]", ""); + if (digits.length() <= 4) return "****"; + return "***-****-" + digits.substring(digits.length() - 4); + } +} diff --git a/settings.gradle b/settings.gradle index 8744f9d..da368d8 100644 --- a/settings.gradle +++ b/settings.gradle @@ -9,3 +9,4 @@ include 'ga-core' include 'ga-api' include 'ga-batch' include 'ga-admin' +include 'ga-external-adapter'