diff --git a/ga-api/src/main/java/com/ga/api/GaApiApplication.java b/ga-api/src/main/java/com/ga/api/GaApiApplication.java index 4490a7c..c50e490 100644 --- a/ga-api/src/main/java/com/ga/api/GaApiApplication.java +++ b/ga-api/src/main/java/com/ga/api/GaApiApplication.java @@ -3,6 +3,7 @@ package com.ga.api; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.cache.annotation.EnableCaching; +import org.springframework.scheduling.annotation.EnableScheduling; /** * GA 수수료 정산 API 서버. @@ -10,6 +11,7 @@ import org.springframework.cache.annotation.EnableCaching; */ @SpringBootApplication(scanBasePackages = {"com.ga"}) @EnableCaching +@EnableScheduling public class GaApiApplication { public static void main(String[] args) { SpringApplication.run(GaApiApplication.class, args); diff --git a/ga-api/src/main/java/com/ga/api/controller/internal/BankRetryController.java b/ga-api/src/main/java/com/ga/api/controller/internal/BankRetryController.java new file mode 100644 index 0000000..98284ee --- /dev/null +++ b/ga-api/src/main/java/com/ga/api/controller/internal/BankRetryController.java @@ -0,0 +1,29 @@ +package com.ga.api.controller.internal; + +import com.ga.api.service.withdraw.WithdrawBankRetryService; +import com.ga.common.annotation.RequirePermission; +import com.ga.common.model.ApiResponse; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +/** + * 운영용 내부 트리거. 펌뱅킹 재시도를 수동으로 실행한다. + * cron 활성화 환경에서는 자동 실행되지만 운영 중 즉시 회수가 필요할 때 사용. + */ +@Tag(name = "내부 트리거") +@RestController +@RequestMapping("/api/internal/bank-retry") +@RequiredArgsConstructor +public class BankRetryController { + + private final WithdrawBankRetryService bankRetryService; + + @PostMapping + @RequirePermission(menu = "WITHDRAW", perm = "EXECUTE") + public ApiResponse retry() { + return ApiResponse.ok(bankRetryService.retryOnce()); + } +} 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 a102b7e..813acc9 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 @@ -13,6 +13,7 @@ import com.ga.common.model.PageResponse; import com.ga.common.util.SecurityUtil; import com.ga.core.mapper.approval.*; import com.ga.core.mapper.notice.UserNotificationMapper; +import com.ga.core.mapper.user.UserMapper; import com.ga.core.mapper.settle.CommissionDisputeMapper; import com.ga.core.mapper.settle.IncentivePaymentMapper; import com.ga.core.mapper.settle.SettleMasterMapper; @@ -23,6 +24,7 @@ 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; +import com.ga.core.vo.user.UserContactBrief; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; @@ -42,6 +44,7 @@ public class ApprovalService { private final ApprovalRequestMapper requestMapper; private final ApprovalHistoryMapper historyMapper; private final UserNotificationMapper notificationMapper; + private final UserMapper userMapper; // 콜백 대상 도메인 Mapper private final WithdrawRequestMapper withdrawMapper; @@ -265,14 +268,21 @@ public class ApprovalService { } } - /** 다음 결재 단계 담당자에게 알림 발송 (DB + 외부채널) */ + /** 다음 결재 단계 담당자에게 알림 발송 (DB + 외부채널). 담당자 user는 step.roleCode 매핑. */ private void sendStepNotification(Long requestId, Long lineId, int stepNo, String targetType) { try { ApprovalLineStepVO step = stepMapper.selectByLineAndStep(lineId, stepNo); if (step == null) return; - // roleCode 기반 userId 매핑은 P5에서 고도화 — 현재는 메타만 저장 + + // step.roleCode → user/phone 1차 매핑. 없으면 userId=0L 으로 fallback (히스토리 보존). + UserContactBrief contact = step.getRoleCode() != null + ? userMapper.selectFirstActiveContactByRoleCode(step.getRoleCode()) + : null; + Long approverUserId = contact != null ? contact.getUserId() : 0L; + String approverPhone = contact != null ? contact.getPhone() : null; + UserNotificationVO noti = new UserNotificationVO(); - noti.setUserId(0L); // 실제 담당자 ID 조회 로직은 P5 + noti.setUserId(approverUserId); String title = "[결재요청] " + (targetType != null ? targetType : "") + " 결재 " + stepNo + "단계"; String content = "결재 요청 ID " + requestId + " 에 대한 " + stepNo + "단계 결재가 필요합니다."; noti.setTitle(title); @@ -282,8 +292,13 @@ public class ApprovalService { noti.setIsRead(false); notificationMapper.insert(noti); - // SMS/카카오 외부채널 — Mock 단계라 항상 시도. 실패해도 DB 알림은 살아있다. - sendExternalMessage(MessageChannel.SMS, title, content, requestId); + // SMS 외부채널 — phone 미확보 시 skip (mock이라도 일관성 유지) + if (approverPhone != null && !approverPhone.isBlank()) { + sendExternalMessage(MessageChannel.SMS, approverPhone, title, content, requestId); + } else { + log.debug("[MSG] 결재 단계 알림 phone 미확보 → SMS skip: stepRoleCode={}, requestId={}", + step.getRoleCode(), requestId); + } } catch (Exception e) { log.warn("결재 알림 발송 실패 (non-critical): requestId={}", requestId, e); } @@ -332,12 +347,13 @@ public class ApprovalService { } } - /** SMS/카카오 어댑터로 메시지 전송 — Mock 단계라 PoC 용 phone "010-0000-0000" 고정. */ - private void sendExternalMessage(MessageChannel channel, String title, String content, Long refId) { + /** SMS/카카오 어댑터로 메시지 전송. phone 은 호출자가 user 마스터에서 조회해 전달. */ + private void sendExternalMessage(MessageChannel channel, String phone, + String title, String content, Long refId) { try { MessageRequest req = MessageRequest.builder() .channel(channel) - .phoneNumber("010-0000-0000") // P6 보강: user.phone 조회로 교체 + .phoneNumber(phone) .title(title) .content(content) .refType(NotificationRefType.APPROVAL.name()) diff --git a/ga-api/src/main/java/com/ga/api/service/withdraw/WithdrawBankRetryService.java b/ga-api/src/main/java/com/ga/api/service/withdraw/WithdrawBankRetryService.java new file mode 100644 index 0000000..06c559c --- /dev/null +++ b/ga-api/src/main/java/com/ga/api/service/withdraw/WithdrawBankRetryService.java @@ -0,0 +1,92 @@ +package com.ga.api.service.withdraw; + +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.core.mapper.withdraw.WithdrawRequestMapper; +import com.ga.core.vo.withdraw.WithdrawRequestVO; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +/** + * 펌뱅킹 재시도 서비스 — transfer_status='FAILED' 이고 status='APPROVED' 인 출금을 어댑터로 재호출. + *

+ * 운영: 매뉴얼 trigger(`POST /api/internal/bank-retry`) + cron 옵션(기본 비활성). + * cron 활성화 시 yml 에 `app.bank-retry.cron=0 */10 * * * *` 형식으로 지정. + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class WithdrawBankRetryService { + + private static final int DEFAULT_BATCH_LIMIT = 50; + + private final WithdrawRequestMapper mapper; + private final BankTransferAdapter bankTransferAdapter; + + @Value("${app.bank-retry.limit:50}") + private int batchLimit; + + /** 재시도 1회 — 처리한 건수 반환. */ + @Transactional + public int retryOnce() { + int limit = batchLimit > 0 ? batchLimit : DEFAULT_BATCH_LIMIT; + List failed = mapper.selectFailedTransfers(limit); + if (failed.isEmpty()) return 0; + + log.info("[BANK-RETRY] 대상 {}건 재시도 시작", failed.size()); + int processed = 0; + for (WithdrawRequestVO vo : failed) { + try { + BankTransferRequest req = BankTransferRequest.builder() + .withdrawRequestId(vo.getRequestId()) + .bankCode(vo.getBankCode()) + .accountNo(vo.getAccountNo()) + .amount(vo.getAmount()) + .memo("GA수수료 출금 #" + vo.getRequestId() + " (재시도)") + .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"); + mapper.updateTransferResult( + vo.getRequestId(), + resp != null ? resp.getTxId() : null, + resp != null && resp.getStatus() != null ? resp.getStatus().name() : null, + nextStatus, + failReason + ); + processed++; + } catch (Exception e) { + log.error("[BANK-RETRY] 재시도 중 어댑터 예외: requestId={}", vo.getRequestId(), e); + mapper.updateTransferResult( + vo.getRequestId(), null, BankTransferStatus.FAILED.name(), + "APPROVED", e.getMessage() + ); + } + } + log.info("[BANK-RETRY] 완료 — 처리 {}/{}건", processed, failed.size()); + return processed; + } + + /** + * cron 기반 자동 재시도. + * yml 에 `app.bank-retry.cron` 미설정 시 매년 1월 1일 0시(=사실상 비활성)로 작동. + */ + @Scheduled(cron = "${app.bank-retry.cron:0 0 0 1 1 ?}") + public void scheduled() { + try { + retryOnce(); + } catch (Exception e) { + log.error("[BANK-RETRY] 스케줄러 실패", e); + } + } +} diff --git a/ga-api/src/main/resources/application.yml b/ga-api/src/main/resources/application.yml index 09dfc6c..6466180 100644 --- a/ga-api/src/main/resources/application.yml +++ b/ga-api/src/main/resources/application.yml @@ -55,6 +55,17 @@ app: path: ${ATTACHMENT_PATH:./uploads} withdraw: approval-line-id: ${WITHDRAW_APPROVAL_LINE_ID:1} + # 외부연동 어댑터 선택 (ga-external-adapter 모듈의 구현체 분기) + # bank: mock | kftc — 실제 KFTC SDK 통합 전까지 mock 유지 + # message: mock | toast — 실제 NHN Toast/카카오 통합 전까지 mock 유지 + adapter: + bank: ${ADAPTER_BANK:mock} + message: ${ADAPTER_MESSAGE:mock} + # 펌뱅킹 재시도 배치 + bank-retry: + limit: ${BANK_RETRY_LIMIT:50} + # 미설정 시 사실상 비활성 (매년 1/1 0시). 운영 활성화 예: "0 */10 * * * *" + cron: ${BANK_RETRY_CRON:0 0 0 1 1 ?} logging: level: diff --git a/ga-common/src/main/resources/db/migration/V83__WITHDRAW_REQUESTS_EXECUTE_권한_보강.sql b/ga-common/src/main/resources/db/migration/V83__WITHDRAW_REQUESTS_EXECUTE_권한_보강.sql new file mode 100644 index 0000000..4c96ca6 --- /dev/null +++ b/ga-common/src/main/resources/db/migration/V83__WITHDRAW_REQUESTS_EXECUTE_권한_보강.sql @@ -0,0 +1,29 @@ +-- V83: WITHDRAW_REQUESTS 메뉴에 EXECUTE 권한 추가 +-- P6 펌뱅킹 재시도 endpoint(POST /api/internal/bank-retry) 가 EXECUTE 권한을 요구. +-- 동일 패턴 V72/V73 참조. + +-- 1. menu_permission에 EXECUTE 추가 (이미 있으면 skip) +INSERT INTO menu_permission (menu_id, perm_code, perm_name, sort_order) +SELECT m.menu_id, 'EXECUTE', '실행', 70 + FROM menu m + WHERE m.menu_code = 'WITHDRAW_REQUESTS' + AND NOT EXISTS ( + SELECT 1 FROM menu_permission mp2 + WHERE mp2.menu_id = m.menu_id AND mp2.perm_code = 'EXECUTE' + ); + +-- 2. SUPER_ADMIN / ADMIN 에 EXECUTE 권한 부여 +INSERT INTO role_menu_permission (role_id, menu_id, perm_code, is_granted) +SELECT r.role_id, mp.menu_id, mp.perm_code, 'Y' + FROM role r + CROSS JOIN menu_permission mp + JOIN menu m ON m.menu_id = mp.menu_id + WHERE r.role_code IN ('SUPER_ADMIN', 'ADMIN') + AND mp.perm_code = 'EXECUTE' + AND m.menu_code = 'WITHDRAW_REQUESTS' + AND NOT EXISTS ( + SELECT 1 FROM role_menu_permission rmp2 + WHERE rmp2.role_id = r.role_id + AND rmp2.menu_id = mp.menu_id + AND rmp2.perm_code = 'EXECUTE' + ); diff --git a/ga-common/src/main/resources/db/migration/V84__WITHDRAW_EXECUTE_권한.sql b/ga-common/src/main/resources/db/migration/V84__WITHDRAW_EXECUTE_권한.sql new file mode 100644 index 0000000..bf47e5d --- /dev/null +++ b/ga-common/src/main/resources/db/migration/V84__WITHDRAW_EXECUTE_권한.sql @@ -0,0 +1,26 @@ +-- V84: WITHDRAW 메뉴에 EXECUTE 권한 추가 (V83 은 잘못된 menu_code='WITHDRAW_REQUESTS' 로 0건 적용됨) +-- P6 펌뱅킹 재시도 endpoint POST /api/internal/bank-retry 가 EXECUTE 권한을 요구. + +INSERT INTO menu_permission (menu_id, perm_code, perm_name, sort_order) +SELECT m.menu_id, 'EXECUTE', '실행', 70 + FROM menu m + WHERE m.menu_code = 'WITHDRAW' + AND NOT EXISTS ( + SELECT 1 FROM menu_permission mp2 + WHERE mp2.menu_id = m.menu_id AND mp2.perm_code = 'EXECUTE' + ); + +INSERT INTO role_menu_permission (role_id, menu_id, perm_code, is_granted) +SELECT r.role_id, mp.menu_id, mp.perm_code, 'Y' + FROM role r + CROSS JOIN menu_permission mp + JOIN menu m ON m.menu_id = mp.menu_id + WHERE r.role_code IN ('SUPER_ADMIN', 'ADMIN') + AND mp.perm_code = 'EXECUTE' + AND m.menu_code = 'WITHDRAW' + AND NOT EXISTS ( + SELECT 1 FROM role_menu_permission rmp2 + WHERE rmp2.role_id = r.role_id + AND rmp2.menu_id = mp.menu_id + AND rmp2.perm_code = 'EXECUTE' + ); diff --git a/ga-core/src/main/java/com/ga/core/mapper/user/UserMapper.java b/ga-core/src/main/java/com/ga/core/mapper/user/UserMapper.java index 3d6a555..ff28ad2 100644 --- a/ga-core/src/main/java/com/ga/core/mapper/user/UserMapper.java +++ b/ga-core/src/main/java/com/ga/core/mapper/user/UserMapper.java @@ -1,5 +1,6 @@ package com.ga.core.mapper.user; +import com.ga.core.vo.user.UserContactBrief; import com.ga.core.vo.user.UserResp; import com.ga.core.vo.user.UserSearchParam; import com.ga.core.vo.user.UserVO; @@ -30,4 +31,10 @@ public interface UserMapper { int insertUserRole(@Param("userId") Long userId, @Param("roleId") Long roleId); int deleteUserRoles(@Param("userId") Long userId); + + /** + * 특정 role_code 보유 + ACTIVE 상태 사용자 중 최선임(또는 가장 최근 가입) 1명의 연락처 요약. + * approval 단계 알림 발송 시 phone 획득용. 없으면 null 반환. + */ + UserContactBrief selectFirstActiveContactByRoleCode(@Param("roleCode") String roleCode); } 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 bb900e5..f3d6654 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 @@ -27,4 +27,10 @@ public interface WithdrawRequestMapper { @Param("transferStatus") String transferStatus, @Param("nextStatus") String nextStatus, @Param("failReason") String failReason); + + /** + * 펌뱅킹 재시도 대상 조회 — transfer_status='FAILED' 이고 status='APPROVED' 상태. + * account_no 는 복호화되어 반환. + */ + List selectFailedTransfers(@Param("limit") int limit); } diff --git a/ga-core/src/main/java/com/ga/core/vo/user/UserContactBrief.java b/ga-core/src/main/java/com/ga/core/vo/user/UserContactBrief.java new file mode 100644 index 0000000..ba4ae2a --- /dev/null +++ b/ga-core/src/main/java/com/ga/core/vo/user/UserContactBrief.java @@ -0,0 +1,13 @@ +package com.ga.core.vo.user; + +import lombok.Data; + +/** + * 알림/메시지 발송 등 외부 채널용 사용자 연락처 요약. + */ +@Data +public class UserContactBrief { + private Long userId; + private String userName; + private String phone; +} diff --git a/ga-core/src/main/java/com/ga/core/vo/withdraw/WithdrawRequestResp.java b/ga-core/src/main/java/com/ga/core/vo/withdraw/WithdrawRequestResp.java index 812ea7a..afcd1fe 100644 --- a/ga-core/src/main/java/com/ga/core/vo/withdraw/WithdrawRequestResp.java +++ b/ga-core/src/main/java/com/ga/core/vo/withdraw/WithdrawRequestResp.java @@ -24,4 +24,11 @@ public class WithdrawRequestResp { private LocalDateTime sentAt; private LocalDateTime completedAt; private String failReason; + + /** 펌뱅킹 외부 거래 ID (멱등키) */ + private String transferTxId; + /** ACCEPTED/SETTLED/FAILED — BankTransferStatus 코드 */ + private String transferStatus; + /** 펌뱅킹 호출/응답 시각 */ + private LocalDateTime transferAt; } diff --git a/ga-core/src/main/java/com/ga/core/vo/withdraw/WithdrawRequestVO.java b/ga-core/src/main/java/com/ga/core/vo/withdraw/WithdrawRequestVO.java index 2a2f14f..f2dd9e3 100644 --- a/ga-core/src/main/java/com/ga/core/vo/withdraw/WithdrawRequestVO.java +++ b/ga-core/src/main/java/com/ga/core/vo/withdraw/WithdrawRequestVO.java @@ -26,6 +26,9 @@ public class WithdrawRequestVO { private LocalDateTime sentAt; private LocalDateTime completedAt; private String failReason; + private String transferTxId; + private String transferStatus; + private LocalDateTime transferAt; private LocalDateTime createdAt; private LocalDateTime updatedAt; } diff --git a/ga-core/src/main/resources/mapper/user/UserMapper.xml b/ga-core/src/main/resources/mapper/user/UserMapper.xml index c34d299..a74c867 100644 --- a/ga-core/src/main/resources/mapper/user/UserMapper.xml +++ b/ga-core/src/main/resources/mapper/user/UserMapper.xml @@ -6,6 +6,12 @@ + + + + + + u.user_id, u.login_id, u.password, u.user_name, u.email, u.phone, u.agent_id, u.status, u.fail_count, u.last_login_at, u.pwd_changed_at, @@ -119,4 +125,20 @@ DELETE FROM user_role WHERE user_id = #{userId} + + + diff --git a/ga-core/src/main/resources/mapper/withdraw/WithdrawRequestMapper.xml b/ga-core/src/main/resources/mapper/withdraw/WithdrawRequestMapper.xml index 8a7380a..9e35210 100644 --- a/ga-core/src/main/resources/mapper/withdraw/WithdrawRequestMapper.xml +++ b/ga-core/src/main/resources/mapper/withdraw/WithdrawRequestMapper.xml @@ -22,6 +22,9 @@ + + + @@ -46,6 +49,9 @@ + + + @@ -57,7 +63,8 @@ wr.status, (SELECT cc.code_name FROM common_code cc WHERE cc.group_code = 'WITHDRAW_STATUS' AND cc.code = wr.status) AS status_name, - wr.sent_at, wr.completed_at, wr.fail_reason + wr.sent_at, wr.completed_at, wr.fail_reason, + wr.transfer_tx_id, wr.transfer_status, wr.transfer_at + SELECT wr.request_id, wr.settle_master_id, wr.agent_id, wr.amount, + wr.requested_by, wr.requested_at, wr.approval_request_id, + wr.bank_code, wr.account_no, wr.status, + wr.sent_at, wr.completed_at, wr.fail_reason, + wr.transfer_tx_id, wr.transfer_status, wr.transfer_at, + wr.created_at, wr.updated_at + FROM withdraw_request wr + WHERE wr.transfer_status = 'FAILED' + AND wr.status = 'APPROVED' + ORDER BY wr.transfer_at NULLS FIRST, wr.request_id + LIMIT #{limit} + + UPDATE withdraw_request 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 new file mode 100644 index 0000000..91b1251 --- /dev/null +++ b/ga-external-adapter/src/main/java/com/ga/external/bank/KftcBankTransferAdapter.java @@ -0,0 +1,33 @@ +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 lombok.extern.slf4j.Slf4j; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.stereotype.Component; + +/** + * KFTC(금융결제원) 오픈뱅킹 펌뱅킹 어댑터 — 스켈레톤. + *

+ * 활성 조건: app.adapter.bank=kftc. 실제 SDK / 클라이언트 의존성은 아직 미추가. + * 외부 스펙(인증/엔드포인트/응답 코드)이 확정되면 본 클래스의 메서드를 구현한다. + * 그 동안은 활성화 시 명시적 예외를 던져 잘못된 운영 적용을 빠르게 감지한다. + */ +@Slf4j +@Component +@ConditionalOnProperty(prefix = "app.adapter", name = "bank", havingValue = "kftc") +public class KftcBankTransferAdapter implements BankTransferAdapter { + + @Override + public BankTransferResponse requestTransfer(BankTransferRequest request) { + throw new UnsupportedOperationException( + "KFTC 펌뱅킹 SDK 통합 미구현 — application.yml 의 app.adapter.bank=mock 으로 되돌리거나 본 어댑터를 완성하세요."); + } + + @Override + public BankTransferResponse queryStatus(String txId) { + throw new UnsupportedOperationException( + "KFTC 펌뱅킹 SDK 통합 미구현 — txId=" + txId); + } +} 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 index 0462c72..ac1966b 100644 --- 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 @@ -5,6 +5,7 @@ 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.stereotype.Component; import java.time.LocalDateTime; @@ -13,11 +14,12 @@ import java.util.UUID; /** * 펌뱅킹 Mock 구현. *

- * 실제 KFTC/은행 SDK 가 들어오기 전까지 사용. 결과는 항상 ACCEPTED 반환 + 로그. - * 계좌번호는 뒷 4자리 외 마스킹해 로그에 남긴다. + * 활성 조건: app.adapter.bank=mock (기본). 실제 SDK 통합 시 yml 값을 'kftc' 등으로 바꾸면 본 빈은 비활성. + * 결과는 항상 ACCEPTED 반환. 계좌번호는 뒷 4자리 외 마스킹해 로그에 남긴다. */ @Slf4j @Component +@ConditionalOnProperty(prefix = "app.adapter", name = "bank", havingValue = "mock", matchIfMissing = true) public class MockBankTransferAdapter implements BankTransferAdapter { @Override 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 index 835c512..c40b78a 100644 --- 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 @@ -4,6 +4,7 @@ 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.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.stereotype.Component; import java.time.LocalDateTime; @@ -12,11 +13,12 @@ import java.util.UUID; /** * SMS / 카카오 알림톡 Mock 구현. *

- * 실제 NHN Toast/알리고/카카오 비즈메시지 SDK 가 들어오기 전까지 사용. 항상 성공 응답. - * phoneNumber 는 뒷 4자리 외 마스킹. + * 활성 조건: app.adapter.message=mock (기본). 실제 SDK 통합 시 yml 값을 'toast' 등으로 바꾸면 본 빈은 비활성. + * 항상 성공 응답. phoneNumber 는 뒷 4자리 외 마스킹. */ @Slf4j @Component +@ConditionalOnProperty(prefix = "app.adapter", name = "message", havingValue = "mock", matchIfMissing = true) public class MockMessageAdapter implements MessageAdapter { @Override 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 new file mode 100644 index 0000000..a9a5d70 --- /dev/null +++ b/ga-external-adapter/src/main/java/com/ga/external/message/ToastMessageAdapter.java @@ -0,0 +1,26 @@ +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.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.stereotype.Component; + +/** + * NHN Toast SMS / 카카오 알림톡 어댑터 — 스켈레톤. + *

+ * 활성 조건: app.adapter.message=toast. 실제 SDK / API 키 등은 미주입 상태. + * 외부 스펙(인증/엔드포인트/템플릿 코드)이 확정되면 본 클래스의 send 를 구현한다. + */ +@Slf4j +@Component +@ConditionalOnProperty(prefix = "app.adapter", name = "message", havingValue = "toast") +public class ToastMessageAdapter implements MessageAdapter { + + @Override + public MessageResponse send(MessageRequest request) { + throw new UnsupportedOperationException( + "Toast / 카카오 비즈메시지 SDK 통합 미구현 — application.yml 의 app.adapter.message=mock 으로 되돌리거나 본 어댑터를 완성하세요."); + } +} diff --git a/ga-frontend/src/api/withdraw.ts b/ga-frontend/src/api/withdraw.ts index 1c86ef6..541da5e 100644 --- a/ga-frontend/src/api/withdraw.ts +++ b/ga-frontend/src/api/withdraw.ts @@ -15,6 +15,9 @@ export interface WithdrawRequestRow extends Record { requestedAt: string; completedAt?: string; failReason?: string; + transferTxId?: string; + transferStatus?: string; // ACCEPTED / SETTLED / FAILED + transferAt?: string; } export interface WithdrawRequestSaveReq { diff --git a/ga-frontend/src/pages/payments/WithdrawRequest.tsx b/ga-frontend/src/pages/payments/WithdrawRequest.tsx index 15baee0..094912a 100644 --- a/ga-frontend/src/pages/payments/WithdrawRequest.tsx +++ b/ga-frontend/src/pages/payments/WithdrawRequest.tsx @@ -33,6 +33,9 @@ const COLUMNS: ColDef[] = [ }, { field: 'requestedAt', headerName: '신청일', width: 120 }, { field: 'completedAt', headerName: '완료일', width: 120 }, + { field: 'transferTxId', headerName: '펌뱅킹 거래ID', width: 200 }, + { field: 'transferStatus', headerName: '펌뱅킹 상태', width: 110 }, + { field: 'transferAt', headerName: '펌뱅킹 시각', width: 150 }, ]; export default function WithdrawRequest() {