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:
GA Pro
2026-05-24 02:45:13 +09:00
parent b04fa79840
commit 0e8c563a9a
20 changed files with 368 additions and 13 deletions
@@ -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 *&#47;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);
}
}
}
+11
View File
@@ -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: