feat: P6 어댑터 PoC 보강 — Resp 노출 / phone 매핑 / 재시도 배치 / SDK 분기
1. WithdrawRequestResp / VO / Mapper.xml / 화면에 transfer_tx_id/transfer_status/transfer_at 노출 2. UserMapper.selectFirstActiveContactByRoleCode — step.roleCode → user.phone 1차 매핑 - ApprovalService.sendStepNotification 이 실제 결재자 user_id/phone 사용 (기존 0L 하드코딩 해소) - phone 미확보 시 SMS skip (DB 알림은 유지) 3. 펌뱅킹 재시도 — WithdrawBankRetryService + POST /api/internal/bank-retry - WithdrawRequestMapper.selectFailedTransfers 추가 - @EnableScheduling + cron 옵션(기본 비활성), app.bank-retry.limit 4. 어댑터 프로파일 분기 — @ConditionalOnProperty - app.adapter.bank: mock(기본) | kftc → KftcBankTransferAdapter 스켈레톤 - app.adapter.message: mock(기본) | toast → ToastMessageAdapter 스켈레톤 - 활성화 시 UnsupportedOperationException 명시 5. V83(menu_code 오기로 0건 적용) + V84(실제 WITHDRAW 메뉴에 EXECUTE 권한 보강)
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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<Integer> retry() {
|
||||
return ApiResponse.ok(bankRetryService.retryOnce());
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
|
||||
@@ -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' 인 출금을 어댑터로 재호출.
|
||||
* <p>
|
||||
* 운영: 매뉴얼 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<WithdrawRequestVO> 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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:
|
||||
|
||||
@@ -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'
|
||||
);
|
||||
@@ -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'
|
||||
);
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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<WithdrawRequestVO> selectFailedTransfers(@Param("limit") int limit);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -6,6 +6,12 @@
|
||||
<resultMap id="UserVOMap" type="com.ga.core.vo.user.UserVO"/>
|
||||
<resultMap id="UserRespMap" type="com.ga.core.vo.user.UserResp"/>
|
||||
|
||||
<resultMap id="UserContactBriefMap" type="com.ga.core.vo.user.UserContactBrief">
|
||||
<id property="userId" column="user_id"/>
|
||||
<result property="userName" column="user_name"/>
|
||||
<result property="phone" column="phone"/>
|
||||
</resultMap>
|
||||
|
||||
<sql id="UserCols">
|
||||
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}
|
||||
</delete>
|
||||
|
||||
<!--
|
||||
role_code 보유 + ACTIVE 사용자 1명의 연락처 요약.
|
||||
결재 단계 알림 시 단계 담당자의 phone 획득용.
|
||||
여러 명일 경우: user_id 오름차순 (가장 먼저 등록된 담당자 우선)
|
||||
-->
|
||||
<select id="selectFirstActiveContactByRoleCode" resultMap="UserContactBriefMap">
|
||||
SELECT u.user_id, u.user_name, u.phone
|
||||
FROM users u
|
||||
JOIN user_role ur ON ur.user_id = u.user_id
|
||||
JOIN role r ON r.role_id = ur.role_id
|
||||
WHERE r.role_code = #{roleCode}
|
||||
AND u.status = 'ACTIVE'
|
||||
ORDER BY u.user_id
|
||||
LIMIT 1
|
||||
</select>
|
||||
|
||||
</mapper>
|
||||
|
||||
@@ -22,6 +22,9 @@
|
||||
<result property="sentAt" column="sent_at"/>
|
||||
<result property="completedAt" column="completed_at"/>
|
||||
<result property="failReason" column="fail_reason"/>
|
||||
<result property="transferTxId" column="transfer_tx_id"/>
|
||||
<result property="transferStatus" column="transfer_status"/>
|
||||
<result property="transferAt" column="transfer_at"/>
|
||||
<result property="createdAt" column="created_at"/>
|
||||
<result property="updatedAt" column="updated_at"/>
|
||||
</resultMap>
|
||||
@@ -46,6 +49,9 @@
|
||||
<result property="sentAt" column="sent_at"/>
|
||||
<result property="completedAt" column="completed_at"/>
|
||||
<result property="failReason" column="fail_reason"/>
|
||||
<result property="transferTxId" column="transfer_tx_id"/>
|
||||
<result property="transferStatus" column="transfer_status"/>
|
||||
<result property="transferAt" column="transfer_at"/>
|
||||
</resultMap>
|
||||
|
||||
<sql id="joinCols">
|
||||
@@ -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
|
||||
</sql>
|
||||
|
||||
<select id="selectById" resultMap="RespMap">
|
||||
@@ -74,6 +81,7 @@
|
||||
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.request_id = #{requestId}
|
||||
@@ -140,6 +148,21 @@
|
||||
AND status IN ('REQUESTED', 'APPROVED')
|
||||
</update>
|
||||
|
||||
<!-- 펌뱅킹 재시도 대상 — 어댑터 실패로 transfer_status='FAILED' 상태인 출금 -->
|
||||
<select id="selectFailedTransfers" resultMap="VOMap">
|
||||
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}
|
||||
</select>
|
||||
|
||||
<!-- 펌뱅킹 어댑터 응답 저장 -->
|
||||
<update id="updateTransferResult">
|
||||
UPDATE withdraw_request
|
||||
|
||||
+33
@@ -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(금융결제원) 오픈뱅킹 펌뱅킹 어댑터 — <b>스켈레톤</b>.
|
||||
* <p>
|
||||
* 활성 조건: <code>app.adapter.bank=kftc</code>. 실제 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);
|
||||
}
|
||||
}
|
||||
+4
-2
@@ -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 구현.
|
||||
* <p>
|
||||
* 실제 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
|
||||
|
||||
+4
-2
@@ -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 구현.
|
||||
* <p>
|
||||
* 실제 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
|
||||
|
||||
+26
@@ -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 / 카카오 알림톡 어댑터 — <b>스켈레톤</b>.
|
||||
* <p>
|
||||
* 활성 조건: <code>app.adapter.message=toast</code>. 실제 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 으로 되돌리거나 본 어댑터를 완성하세요.");
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,9 @@ export interface WithdrawRequestRow extends Record<string, unknown> {
|
||||
requestedAt: string;
|
||||
completedAt?: string;
|
||||
failReason?: string;
|
||||
transferTxId?: string;
|
||||
transferStatus?: string; // ACCEPTED / SETTLED / FAILED
|
||||
transferAt?: string;
|
||||
}
|
||||
|
||||
export interface WithdrawRequestSaveReq {
|
||||
|
||||
@@ -33,6 +33,9 @@ const COLUMNS: ColDef<WithdrawRequestRow>[] = [
|
||||
},
|
||||
{ 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() {
|
||||
|
||||
Reference in New Issue
Block a user