feat: P6 외부연동 호출 감사 이력 (V85)

펌뱅킹/SMS·카카오 어댑터 모든 호출을 external_call_log 테이블에 AOP 자동 영속화.
외부 SDK 통합 후에도 그대로 재사용. 운영 감사·장애 추적·SDK 검증 용도.

- V85: external_call_log 테이블 + 인덱스 4종 (called_at desc / type / target / failures)
       + SYSTEM_EXTERNAL_CALL 메뉴 + READ 권한 (SUPER_ADMIN/ADMIN)
- ga-core: ExternalCallLogVO/Resp/SearchParam + Mapper (insertOne / selectList / selectById)
- ga-api/aop: ExternalCallLoggingAspect — BankTransferAdapter+/MessageAdapter+
  모든 메서드 @Around 영속화. 호출자 트랜잭션과 분리(REQUIRES_NEW),
  request/response 는 PII 마스킹된 JSON 한 줄 요약(계좌·전화 뒤 4자리, 이름 첫글자만),
  영속화 자체가 실패해도 비즈니스 흐름 비차단(warn 로그만), 예외는 그대로 propagate.
- ga-api: ExternalCallLogService + Controller GET /api/external-call-logs[/{id}]
- ga-frontend: ExternalCallLogList.tsx + api/externalCallLog.ts + App.tsx 라우트
  /system/external-call-log (ProTable + Drawer 상세)

라이브 검증:
- Flyway V85 자동 적용 → 운영 DB schema v85
- 5모듈 컴파일 BUILD SUCCESSFUL + ga-frontend tsc --noEmit 통과
- 시나리오: withdraw#3 신규 → 결재 advance ×2 → 펌뱅킹 호출
  external_call_log #1: BANK/MockBankTransferAdapter/requestTransfer/
  success=true/durMs=3/target=WITHDRAW#3/accountNoMasked="****0123"

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
GA Pro
2026-05-24 22:56:27 +09:00
parent 0e8c563a9a
commit 5ee8321ef2
15 changed files with 803 additions and 15 deletions
@@ -0,0 +1,179 @@
package com.ga.api.aop;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.ga.api.service.external.ExternalCallLogService;
import com.ga.common.adapter.bank.BankTransferRequest;
import com.ga.common.adapter.bank.BankTransferResponse;
import com.ga.common.adapter.message.MessageRequest;
import com.ga.common.adapter.message.MessageResponse;
import com.ga.common.util.SecurityUtil;
import com.ga.core.vo.external.ExternalCallLogVO;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.springframework.stereotype.Component;
import java.time.LocalDateTime;
import java.util.LinkedHashMap;
import java.util.Map;
/**
* 외부 연동 어댑터 호출 감사 이력 자동 영속화 (V85 external_call_log).
*
* <p>대상: {@link com.ga.common.adapter.bank.BankTransferAdapter},
* {@link com.ga.common.adapter.message.MessageAdapter} 모든 메서드.
*
* <p>정책:
* <ul>
* <li>호출 전/후 (정상·예외) 모두 영속화. 예외는 그대로 propagate.
* <li>request/response 는 마스킹된 JSON 요약만 저장 (계좌·전화 뒤 4자리만).
* <li>영속화는 별도 트랜잭션({@link com.ga.api.service.external.ExternalCallLogService}#recordCall = REQUIRES_NEW).
* <li>영속화 자체가 실패해도 비즈니스 흐름을 깨지 않는다 (warn 로그만).
* </ul>
*/
@Slf4j
@Aspect
@Component
@RequiredArgsConstructor
public class ExternalCallLoggingAspect {
private final ExternalCallLogService logService;
private final ObjectMapper om;
@Around("execution(* com.ga.common.adapter.bank.BankTransferAdapter+.*(..))")
public Object aroundBank(ProceedingJoinPoint jp) throws Throwable {
return around(jp, "BANK");
}
@Around("execution(* com.ga.common.adapter.message.MessageAdapter+.*(..))")
public Object aroundMessage(ProceedingJoinPoint jp) throws Throwable {
return around(jp, "MESSAGE");
}
private Object around(ProceedingJoinPoint jp, String callType) throws Throwable {
long start = System.currentTimeMillis();
ExternalCallLogVO vo = new ExternalCallLogVO();
vo.setCallType(callType);
vo.setAdapterClass(simpleName(jp.getTarget()));
vo.setMethodName(jp.getSignature().getName());
vo.setCalledAt(LocalDateTime.now());
try {
vo.setCreatedBy(SecurityUtil.getCurrentUserId());
} catch (Exception ignore) {
// 스케줄러/배치 컨텍스트에서는 인증 정보 없을 수 있음
}
applyRequest(vo, jp.getArgs());
try {
Object result = jp.proceed();
vo.setSuccess(true);
applyResponse(vo, result);
return result;
} catch (Throwable t) {
vo.setSuccess(false);
vo.setErrorClass(t.getClass().getName());
vo.setErrorMessage(truncate(t.getMessage(), 4000));
throw t;
} finally {
vo.setDurationMs((int) (System.currentTimeMillis() - start));
try {
logService.recordCall(vo);
} catch (Exception persistEx) {
log.warn("[external_call_log] persist failed: {}", persistEx.getMessage());
}
}
}
private void applyRequest(ExternalCallLogVO vo, Object[] args) {
if (args == null || args.length == 0) return;
Object a0 = args[0];
if (a0 instanceof BankTransferRequest btr) {
vo.setTargetRefType("WITHDRAW");
vo.setTargetRefId(btr.getWithdrawRequestId());
Map<String, Object> m = new LinkedHashMap<>();
m.put("withdrawRequestId", btr.getWithdrawRequestId());
m.put("bankCode", btr.getBankCode());
m.put("accountNoMasked", maskAccount(btr.getAccountNo()));
m.put("holder", maskName(btr.getAccountHolderName()));
m.put("amount", btr.getAmount());
m.put("memo", btr.getMemo());
vo.setRequestSummary(toJson(m));
} else if (a0 instanceof MessageRequest mr) {
vo.setTargetRefType(mr.getRefType());
vo.setTargetRefId(mr.getRefId());
Map<String, Object> m = new LinkedHashMap<>();
m.put("channel", mr.getChannel());
m.put("phoneMasked", maskPhone(mr.getPhoneNumber()));
m.put("title", mr.getTitle());
m.put("templateCode", mr.getTemplateCode());
m.put("refUserId", mr.getRefUserId());
m.put("contentPreview", truncate(mr.getContent(), 80));
vo.setRequestSummary(toJson(m));
} else if (a0 instanceof String s) {
// BankTransferAdapter.queryStatus(txId)
vo.setRequestSummary("{\"arg\":\"" + safeJsonString(s) + "\"}");
}
}
private void applyResponse(ExternalCallLogVO vo, Object result) {
if (result instanceof BankTransferResponse btr) {
vo.setResultCode(btr.getResultCode());
vo.setResultMessage(truncate(btr.getResultMessage(), 1000));
Map<String, Object> m = new LinkedHashMap<>();
m.put("txId", btr.getTxId());
m.put("status", btr.getStatus());
m.put("processedAt", btr.getProcessedAt());
vo.setResponseSummary(toJson(m));
} else if (result instanceof MessageResponse mr) {
vo.setResultCode(mr.getResultCode());
vo.setResultMessage(truncate(mr.getResultMessage(), 1000));
Map<String, Object> m = new LinkedHashMap<>();
m.put("messageId", mr.getMessageId());
m.put("sent", mr.isSent());
m.put("sentAt", mr.getSentAt());
vo.setResponseSummary(toJson(m));
}
}
private String simpleName(Object o) {
return o == null ? null : o.getClass().getSimpleName();
}
private String toJson(Object o) {
try {
return om.writeValueAsString(o);
} catch (Exception e) {
return "{}";
}
}
private String maskAccount(String accountNo) {
if (accountNo == null) return null;
String d = accountNo.replaceAll("[^0-9]", "");
if (d.length() <= 4) return "****";
return "****" + d.substring(d.length() - 4);
}
private String maskPhone(String phone) {
if (phone == null) return null;
String d = phone.replaceAll("[^0-9]", "");
if (d.length() <= 4) return "****";
return "***-****-" + d.substring(d.length() - 4);
}
private String maskName(String name) {
if (name == null || name.isEmpty()) return null;
return name.charAt(0) + "*".repeat(Math.max(0, name.length() - 1));
}
private String truncate(String s, int max) {
if (s == null) return null;
return s.length() <= max ? s : s.substring(0, max);
}
private String safeJsonString(String s) {
return s == null ? "" : s.replace("\\", "\\\\").replace("\"", "\\\"");
}
}
@@ -0,0 +1,31 @@
package com.ga.api.controller.external;
import com.ga.api.service.external.ExternalCallLogService;
import com.ga.common.annotation.RequirePermission;
import com.ga.common.model.ApiResponse;
import com.ga.common.model.PageResponse;
import com.ga.core.vo.external.ExternalCallLogResp;
import com.ga.core.vo.external.ExternalCallLogSearchParam;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
@Tag(name = "외부연동 호출이력")
@RestController
@RequiredArgsConstructor
public class ExternalCallLogController {
private final ExternalCallLogService service;
@GetMapping("/api/external-call-logs")
@RequirePermission(menu = "SYSTEM_EXTERNAL_CALL", perm = "READ")
public ApiResponse<PageResponse<ExternalCallLogResp>> list(@ModelAttribute ExternalCallLogSearchParam param) {
return ApiResponse.ok(service.list(param));
}
@GetMapping("/api/external-call-logs/{logId}")
@RequirePermission(menu = "SYSTEM_EXTERNAL_CALL", perm = "READ")
public ApiResponse<ExternalCallLogResp> detail(@PathVariable Long logId) {
return ApiResponse.ok(service.detail(logId));
}
}
@@ -0,0 +1,43 @@
package com.ga.api.service.external;
import com.ga.common.model.PageResponse;
import com.ga.core.mapper.external.ExternalCallLogMapper;
import com.ga.core.vo.external.ExternalCallLogResp;
import com.ga.core.vo.external.ExternalCallLogSearchParam;
import com.ga.core.vo.external.ExternalCallLogVO;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;
/**
* 외부 연동 호출 감사 이력.
*
* <p>{@link #recordCall} 은 AOP({@link com.ga.api.aop.ExternalCallLoggingAspect})에서 호출된다.
* 호출자 트랜잭션 롤백과 무관하게 영속화돼야 하므로 {@link Propagation#REQUIRES_NEW}.
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class ExternalCallLogService {
private final ExternalCallLogMapper mapper;
/** AOP에서 호출. 호출자 트랜잭션과 분리(REQUIRES_NEW). */
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void recordCall(ExternalCallLogVO vo) {
mapper.insertOne(vo);
}
@Transactional(readOnly = true)
public PageResponse<ExternalCallLogResp> list(ExternalCallLogSearchParam param) {
param.startPage();
return PageResponse.of(mapper.selectList(param));
}
@Transactional(readOnly = true)
public ExternalCallLogResp detail(Long logId) {
return mapper.selectById(logId);
}
}