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:
+115
-15
@@ -191,20 +191,115 @@
|
||||
|
||||
운영 DB 최신 상태: ga schema V73 적용 완료.
|
||||
|
||||
## 4. 잔여 작업 (P6)
|
||||
## 3-11. P6 PoC — 외부 연동 어댑터 모듈 (V81~V84, 2026-05-24 라이브 적용)
|
||||
|
||||
P5-W2 / P5-W3(메뉴·권한 보강 V74 + 본론 V75~V78) 모두 완료. 잔여는 P6뿐.
|
||||
P6 본 작업의 PoC가 완료되어 어댑터 추상화 + Mock 구현 + SDK 분기 + 운영 잔여 기능까지 들어감.
|
||||
실제 KFTC/Toast SDK 통합 시 어댑터 구현체만 바꾸면 ga-api/ga-common 무변경.
|
||||
|
||||
### P6 — 외부 연동 (P5 이후)
|
||||
| 항목 | 설명 |
|
||||
|---|---|
|
||||
| 펌뱅킹 실연동 | withdraw_request 결재 완료 → 은행 API 직접 호출 |
|
||||
| SMS/카카오 발송 | user_notification 채널 실연동 |
|
||||
| 모바일 셀프 조회 | 설계사 전용 모바일 앱 or PWA |
|
||||
| 항목 | 마이그레이션 | 상태 |
|
||||
|---|---|---|
|
||||
| 분급 비율 회차(월) 단위 등록 지원 | V81 | 완료 |
|
||||
| withdraw_request 펌뱅킹 추적 컬럼 (transfer_tx_id/transfer_status/transfer_at) | V82 | 완료 |
|
||||
| WITHDRAW_REQUESTS EXECUTE 권한 보강 (오기 — 0건 적용) | V83 | 완료(no-op) |
|
||||
| WITHDRAW 메뉴 EXECUTE 권한 (V83 보정본) | V84 | 완료 |
|
||||
|
||||
핵심 산출물:
|
||||
- **신규 모듈 `ga-external-adapter`** — 외부 SDK 의존성 격리, settings.gradle 등록
|
||||
- ga-common.adapter.bank: `BankTransferAdapter` 인터페이스 + Request/Response/Status
|
||||
- ga-common.adapter.message: `MessageAdapter` 인터페이스 + Channel(SMS/KAKAO)/Request/Response
|
||||
- `MockBankTransferAdapter` / `MockMessageAdapter` (로그 + 계좌·전화 마스킹, 기본 활성)
|
||||
- `KftcBankTransferAdapter` / `ToastMessageAdapter` 스켈레톤 (UnsupportedOperationException, SDK 통합 시 구현)
|
||||
- `@ConditionalOnProperty` 프로파일 분기: `app.adapter.bank=mock|kftc`, `app.adapter.message=mock|toast`
|
||||
- `ApprovalService.handleApprovalCallback(WITHDRAW)` — 최종 승인 시 펌뱅킹 어댑터 호출 → 성공 시 APPROVED→SENT 전이 + transfer_tx_id 영속화, 실패 시 APPROVED 유지 + fail_reason 기록
|
||||
- `ApprovalService.sendStepNotification` — DB 알림 외 SMS 어댑터 호출 (UserMapper.selectFirstActiveContactByRoleCode 로 실제 결재자 phone 매핑, phone 미확보 시 SMS skip)
|
||||
- `WithdrawBankRetryService` + `BankRetryController` (`POST /api/internal/bank-retry`) — 실패 transfer 재시도 배치 + `@EnableScheduling` + cron 옵션(기본 비활성) + `app.bank-retry.limit`
|
||||
- `WithdrawRequestMapper.selectFailedTransfers` / `updateTransferResult` 추가
|
||||
- `WithdrawRequestVO/Resp` + `WithdrawRequestMapper.xml` + 화면 `WithdrawRequest.tsx` 에 transfer_tx_id/transfer_status/transfer_at 노출
|
||||
|
||||
설정값(application.yml):
|
||||
```yaml
|
||||
app:
|
||||
adapter:
|
||||
bank: ${ADAPTER_BANK:mock} # mock | kftc
|
||||
message: ${ADAPTER_MESSAGE:mock} # mock | toast
|
||||
bank-retry:
|
||||
limit: ${BANK_RETRY_LIMIT:50}
|
||||
cron: ${BANK_RETRY_CRON:0 0 0 1 1 ?} # 사실상 비활성. 운영 활성화 예 "0 */10 * * * *"
|
||||
```
|
||||
|
||||
통합 검증 결과 (2026-05-24):
|
||||
- 5모듈 컴파일 BUILD SUCCESSFUL (ga-common/ga-core/ga-external-adapter/ga-api/ga-batch)
|
||||
- 운영 DB v82~v84 적용 완료, ga-api 8082 가동
|
||||
- POST /api/auth/login → 200 OK, accessToken 발급
|
||||
- GET /api/withdraw-requests → 200 OK, 2건 모두 status=SENT / transfer_status=ACCEPTED / transferTxId="MOCK-…" 영속화
|
||||
- POST /api/internal/bank-retry → 200 OK, data=0 (실패 건 없음 — 정상)
|
||||
- GET /api/approvals/requests/mine → 200 OK, WITHDRAW APPROVED 이력 정상
|
||||
- GET /api/forecast-kpi → 200 OK
|
||||
- GET /api/year-end-statements → 200 OK
|
||||
|
||||
보정 사항:
|
||||
- V83 작성 시 menu_code 오기로 0건 적용 → V84 로 정정 보강
|
||||
|
||||
## 4. 잔여 작업 (P6 실연동)
|
||||
|
||||
P5 전체 + P6 PoC(어댑터 모듈 + Mock 구현 + SDK 분기 + 재시도 배치 + DB 추적 컬럼) 완료.
|
||||
잔여는 **외부 스펙 확정 후 적용** 가능한 항목 + 모바일.
|
||||
|
||||
### P6 실연동
|
||||
| 항목 | 설명 | 선행 조건 |
|
||||
|---|---|---|
|
||||
| KFTC 펌뱅킹 실 SDK 통합 | `KftcBankTransferAdapter` 본체 구현 + 인증·엔드포인트 주입 | KFTC API 키·계약·테스트망 |
|
||||
| Toast(NHN) SMS 실 SDK 통합 | `ToastMessageAdapter` 본체 구현 | 계정·App Key·발신번호·템플릿 코드 |
|
||||
| 카카오 알림톡 실 SDK 통합 | 동일 (채널만 KAKAO) | 비즈 채널·템플릿 승인 |
|
||||
| 펌뱅킹 SETTLED 동기화 | ACCEPTED→SETTLED 폴링 스케줄 (queryStatus 사용) | 실 SDK 통합 이후 |
|
||||
| 펌뱅킹/메시지 호출 감사 이력 | `external_call_log` 테이블 + 영속화 + 조회 화면 | 독립 진행 가능 |
|
||||
| 모바일 셀프 조회 PWA | 설계사 전용. 정산 명세서/출금 신청/공지 등 | 화면 우선순위 협의 |
|
||||
|
||||
## 3-12. P6 PoC — 외부연동 호출 감사 이력 (V85, 2026-05-24 라이브 적용)
|
||||
|
||||
P6 PoC 의 운영성 보강. 펌뱅킹/SMS·카카오 어댑터 모든 호출이 `external_call_log` 테이블에 AOP 로 자동 영속화. 외부 SDK 통합 후에도 그대로 재사용.
|
||||
|
||||
| 항목 | 위치 | 상태 |
|
||||
|---|---|---|
|
||||
| external_call_log 테이블 + 인덱스 + SYSTEM_EXTERNAL_CALL 메뉴/권한 | V85 | 완료 |
|
||||
| ExternalCallLogVO / Resp / SearchParam | ga-core | 완료 |
|
||||
| ExternalCallLogMapper(.java/.xml) — insertOne + selectList + selectById | ga-core | 완료 |
|
||||
| ExternalCallLogService — recordCall @Transactional(REQUIRES_NEW) | ga-api | 완료 |
|
||||
| ExternalCallLogController — GET /api/external-call-logs[/{id}] | ga-api | 완료 |
|
||||
| ExternalCallLoggingAspect — BankTransferAdapter+/MessageAdapter+ 모든 메서드 @Around | ga-api/aop | 완료 |
|
||||
| ExternalCallLogList.tsx + api/externalCallLog.ts + App.tsx 라우트 | ga-frontend | 완료 |
|
||||
|
||||
핵심 동작:
|
||||
- 어댑터 호출 전/후·예외 모두 `external_call_log` INSERT (호출자 트랜잭션과 분리 — REQUIRES_NEW)
|
||||
- request/response 는 PII 마스킹된 JSON 한 줄로 요약 (계좌·전화 뒤 4자리, 이름 첫글자만)
|
||||
- 영속화 자체가 실패해도 비즈니스 흐름은 절대 깨지 않음 (warn 로그만)
|
||||
- `target_ref_type/id` 자동 추출: WITHDRAW(BankTransferRequest.withdrawRequestId), MessageRequest.refType/refId
|
||||
|
||||
통합 검증 결과 (2026-05-24):
|
||||
- Flyway: V85 자동 적용 → 운영 DB schema **v85**
|
||||
- 5모듈 컴파일 BUILD SUCCESSFUL + ga-frontend TS 타입 검증 통과
|
||||
- 라이브 시나리오: 신규 withdraw#3 → 결재 1단계 + 2단계 APPROVE → 최종 승인 → 펌뱅킹 호출
|
||||
- withdraw#3 status=SENT, transferTxId=MOCK-…, transferStatus=ACCEPTED
|
||||
- GET /api/external-call-logs → total=1, log#1: BANK/MockBankTransferAdapter/requestTransfer/success=true/durMs=3/target=WITHDRAW#3
|
||||
- 상세: requestSummary 의 accountNoMasked="****0123" — PII 마스킹 정상 (평문 비노출)
|
||||
|
||||
## 4. 잔여 작업 (P6 실연동)
|
||||
|
||||
P5 전체 + P6 PoC(어댑터 모듈 + Mock 구현 + SDK 분기 + 재시도 배치 + DB 추적 컬럼 + **호출 감사 이력**) 완료.
|
||||
잔여는 **외부 스펙 확정 후 적용** 가능한 항목 + 모바일.
|
||||
|
||||
### P6 실연동
|
||||
| 항목 | 설명 | 선행 조건 |
|
||||
|---|---|---|
|
||||
| KFTC 펌뱅킹 실 SDK 통합 | `KftcBankTransferAdapter` 본체 구현 + 인증·엔드포인트 주입 | KFTC API 키·계약·테스트망 |
|
||||
| Toast(NHN) SMS 실 SDK 통합 | `ToastMessageAdapter` 본체 구현 | 계정·App Key·발신번호·템플릿 코드 |
|
||||
| 카카오 알림톡 실 SDK 통합 | 동일 (채널만 KAKAO) | 비즈 채널·템플릿 승인 |
|
||||
| 펌뱅킹 SETTLED 동기화 | ACCEPTED→SETTLED 폴링 스케줄 (queryStatus 사용) | 실 SDK 통합 이후 |
|
||||
| 모바일 셀프 조회 PWA | 설계사 전용. 정산 명세서/출금 신청/공지 등 | 화면 우선순위 협의 |
|
||||
|
||||
### 운영 DB 마이그레이션 상태
|
||||
운영 DB `192.168.0.60:55432/trading_ai/ga` 현재 **V78 적용 완료** (2026-05-21).
|
||||
V74~V78 Flyway 자동 적용 확인. ga-api 8082 가동 중.
|
||||
운영 DB `192.168.0.60:55432/trading_ai/ga` 현재 **V85 적용 완료** (2026-05-24).
|
||||
V79~V85 Flyway 자동 적용 확인. ga-api 8082 가동 중.
|
||||
|
||||
단위/통합 테스트는 SqlSessionFactory 환경 설정 문제로 아직 미작성 상태입니다.
|
||||
|
||||
@@ -252,16 +347,21 @@ V74~V78 Flyway 자동 적용 확인. ga-api 8082 가동 중.
|
||||
- 스모크: 신규 4 + 회귀 4 = **GET 8/8 PASS**, 쓰기(regenerate/detect/draft) 4/4 PASS
|
||||
- 보정: YearEndStatementMapper.xml 의 `a.agent_code` 미존재 컬럼 참조 제거 (agent 테이블에 agent_code 없음)
|
||||
|
||||
## 5. 재개 절차 (다음 세션 — P6 라운드)
|
||||
## 5. 재개 절차 (다음 세션 — P6 실연동 라운드)
|
||||
|
||||
1. **Claude Code 시작 시 cwd 확인**: `C:\Users\kyu\Desktop\ga_pro` (현재 세션과 동일)
|
||||
2. **Agent Teams 활성화 확인**: `.claude/settings.json` 커밋되어 있음
|
||||
3. **HANDOFF.md 읽기**: 본 문서 + 최근 git log로 컨텍스트 확보
|
||||
4. **운영 DB 상태**: **V78 적용 완료**. ga-api 8082 가동 중 (필요 시 `python verify_v74.py` / `smoke_v78.py` 재실행)
|
||||
5. **권장 시작 지점**: P6 외부 연동 — 펌뱅킹 실연동 / SMS·카카오 발송 / 모바일 셀프 조회
|
||||
6. **사용자 결정 필요 항목**:
|
||||
4. **운영 DB 상태**: **V85 적용 완료**. ga-api 8082 가동 중 (필요 시 `python verify_v74.py` / `smoke_v78.py` 재실행)
|
||||
5. **권장 시작 지점 (외부 스펙 미정 시)**:
|
||||
- 모바일 셀프 조회 PWA 스캐폴드 (화면 우선순위 협의)
|
||||
- 펌뱅킹 SETTLED 동기화 폴러 (Mock 검증 → 실 SDK 통합 시 가치)
|
||||
- 알림 채널 SMS phone 매핑 보강 (현재 user.phone 미설정 시 SMS skip — phone 등록 화면)
|
||||
6. **사용자 결정 필요 항목 (P6 실연동 차단 사유)**:
|
||||
- KFTC 펌뱅킹 API 계약·인증 정보 (실 SDK 통합 차단)
|
||||
- NHN Toast / 카카오 비즈채널 계정 + 템플릿 코드 (실 메시지 통합 차단)
|
||||
- 모바일 PWA 화면 우선순위 (잔여 27 빈 화면 중 어떤 것부터)
|
||||
- P2-1 마감 도메인의 마감 후 수정 차단 AOP 구현 방식 (전역 AOP vs Service-level 가드)
|
||||
- P6 펌뱅킹/메시징 외부 연동처 실제 스펙 (현재 미정 → 착수 전 확인 필요)
|
||||
|
||||
## 6. PL 가이드 (다음 세션 유의사항)
|
||||
|
||||
|
||||
@@ -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("\"", "\\\"");
|
||||
}
|
||||
}
|
||||
+31
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
-- V85: 외부 연동(펌뱅킹/SMS·카카오) 호출 감사 이력
|
||||
--
|
||||
-- 목적: BankTransferAdapter / MessageAdapter 모든 호출을 자동 영속화.
|
||||
-- 운영 감사·장애 추적·외부 SDK 통합 검증에 사용. AOP(@Around)가 INSERT.
|
||||
-- PII 는 요약 필드에 어댑터에서 이미 마스킹된 값만 들어간다 (계좌·전화 뒤 4자리만).
|
||||
|
||||
CREATE TABLE IF NOT EXISTS external_call_log (
|
||||
log_id BIGSERIAL PRIMARY KEY,
|
||||
call_type VARCHAR(20) NOT NULL, -- BANK / MESSAGE
|
||||
adapter_class VARCHAR(200) NOT NULL, -- 실제 호출된 구현체 (Mock/Kftc/Toast)
|
||||
method_name VARCHAR(50) NOT NULL, -- requestTransfer / queryStatus / send
|
||||
target_ref_type VARCHAR(30), -- WITHDRAW / APPROVAL 등 (가능 시)
|
||||
target_ref_id BIGINT, -- withdrawRequestId / refId 등
|
||||
request_summary TEXT, -- 요청 요약 (마스킹된 JSON 한 줄)
|
||||
response_summary TEXT, -- 응답 요약
|
||||
result_code VARCHAR(20), -- 외부 응답 코드 (정상 시)
|
||||
result_message TEXT,
|
||||
success BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
duration_ms INTEGER, -- 호출 소요 시간 (ms)
|
||||
error_class VARCHAR(200), -- 예외 발생 시 클래스명
|
||||
error_message TEXT, -- 예외 메시지
|
||||
called_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
created_by BIGINT -- 시도자 (가능 시)
|
||||
);
|
||||
|
||||
COMMENT ON TABLE external_call_log IS '외부 연동 호출 감사 이력 (AOP 자동 영속화)';
|
||||
COMMENT ON COLUMN external_call_log.call_type IS 'BANK / MESSAGE';
|
||||
COMMENT ON COLUMN external_call_log.adapter_class IS '실제 호출된 어댑터 클래스 (Mock/Kftc/Toast)';
|
||||
COMMENT ON COLUMN external_call_log.method_name IS 'requestTransfer / queryStatus / send';
|
||||
COMMENT ON COLUMN external_call_log.success IS 'true=정상응답, false=예외/실패';
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_ecl_called_at_desc
|
||||
ON external_call_log(called_at DESC);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_ecl_type_called_at
|
||||
ON external_call_log(call_type, called_at DESC);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_ecl_target
|
||||
ON external_call_log(target_ref_type, target_ref_id)
|
||||
WHERE target_ref_type IS NOT NULL;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_ecl_failures
|
||||
ON external_call_log(called_at DESC)
|
||||
WHERE success = FALSE;
|
||||
|
||||
-- ============================================================
|
||||
-- 메뉴 등록 (시스템 그룹 하위)
|
||||
-- ============================================================
|
||||
INSERT INTO menu (menu_code, menu_name, menu_type, menu_path, component_path, menu_icon,
|
||||
menu_level, sort_order, is_visible, is_active, parent_menu_id, description)
|
||||
SELECT
|
||||
'SYSTEM_EXTERNAL_CALL',
|
||||
'외부연동 호출이력',
|
||||
'PAGE',
|
||||
'/system/external-call-log',
|
||||
'system/ExternalCallLogList',
|
||||
'api',
|
||||
2,
|
||||
20,
|
||||
'Y',
|
||||
'Y',
|
||||
(SELECT menu_id FROM menu WHERE menu_code = 'GRP_SYSTEM'),
|
||||
'펌뱅킹/SMS·카카오 어댑터 호출 감사 이력 (AOP 자동 영속화)'
|
||||
WHERE EXISTS (SELECT 1 FROM menu WHERE menu_code = 'GRP_SYSTEM')
|
||||
ON CONFLICT (menu_code) DO NOTHING;
|
||||
|
||||
-- ============================================================
|
||||
-- 권한 정의 (READ 만 — 감사 로그는 수동 등록 없음)
|
||||
-- ============================================================
|
||||
INSERT INTO menu_permission (menu_id, perm_code, perm_name, sort_order)
|
||||
SELECT menu_id, 'READ', '조회', 1 FROM menu WHERE menu_code = 'SYSTEM_EXTERNAL_CALL'
|
||||
ON CONFLICT (menu_id, perm_code) DO NOTHING;
|
||||
|
||||
-- ============================================================
|
||||
-- 역할 부여 (SUPER_ADMIN / ADMIN)
|
||||
-- ============================================================
|
||||
INSERT INTO role_menu_permission (role_id, menu_id, perm_code, is_granted)
|
||||
SELECT r.role_id, m.menu_id, p.perm_code, 'Y'
|
||||
FROM role r
|
||||
CROSS JOIN menu m
|
||||
JOIN menu_permission p ON p.menu_id = m.menu_id
|
||||
WHERE r.role_code IN ('SUPER_ADMIN', 'ADMIN')
|
||||
AND m.menu_code = 'SYSTEM_EXTERNAL_CALL'
|
||||
AND p.perm_code = 'READ'
|
||||
ON CONFLICT (role_id, menu_id, perm_code) DO NOTHING;
|
||||
@@ -0,0 +1,20 @@
|
||||
package com.ga.core.mapper.external;
|
||||
|
||||
import com.ga.core.vo.external.ExternalCallLogResp;
|
||||
import com.ga.core.vo.external.ExternalCallLogSearchParam;
|
||||
import com.ga.core.vo.external.ExternalCallLogVO;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@Mapper
|
||||
public interface ExternalCallLogMapper {
|
||||
|
||||
/** AOP @Around 영속화. selectKey 로 logId 채움. */
|
||||
int insertOne(ExternalCallLogVO vo);
|
||||
|
||||
ExternalCallLogResp selectById(@Param("logId") Long logId);
|
||||
|
||||
List<ExternalCallLogResp> selectList(ExternalCallLogSearchParam param);
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package com.ga.core.vo.external;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
@Data
|
||||
public class ExternalCallLogResp {
|
||||
private Long logId;
|
||||
private String callType;
|
||||
private String adapterClass;
|
||||
private String methodName;
|
||||
private String targetRefType;
|
||||
private Long targetRefId;
|
||||
private String requestSummary;
|
||||
private String responseSummary;
|
||||
private String resultCode;
|
||||
private String resultMessage;
|
||||
private Boolean success;
|
||||
private Integer durationMs;
|
||||
private String errorClass;
|
||||
private String errorMessage;
|
||||
private LocalDateTime calledAt;
|
||||
private Long createdBy;
|
||||
}
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
package com.ga.core.vo.external;
|
||||
|
||||
import com.ga.common.model.SearchParam;
|
||||
import lombok.Data;
|
||||
import lombok.EqualsAndHashCode;
|
||||
|
||||
@Data
|
||||
@EqualsAndHashCode(callSuper = true)
|
||||
public class ExternalCallLogSearchParam extends SearchParam {
|
||||
/** BANK / MESSAGE */
|
||||
private String callType;
|
||||
private String methodName;
|
||||
/** true 만 / false 만 (실패 조회) — null 이면 전체 */
|
||||
private Boolean success;
|
||||
private String targetRefType;
|
||||
private Long targetRefId;
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package com.ga.core.vo.external;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* external_call_log — 외부 연동(펌뱅킹/SMS·카카오) 호출 감사 이력 (V85).
|
||||
* AOP @Around 가 INSERT. 운영 감사·장애 추적용. PII 는 이미 어댑터에서 마스킹된 값만 들어간다.
|
||||
*/
|
||||
@Data
|
||||
public class ExternalCallLogVO {
|
||||
private Long logId;
|
||||
/** BANK / MESSAGE */
|
||||
private String callType;
|
||||
/** 실제 호출된 구현체 (Mock/Kftc/Toast) */
|
||||
private String adapterClass;
|
||||
/** requestTransfer / queryStatus / send */
|
||||
private String methodName;
|
||||
/** WITHDRAW / APPROVAL 등 (가능 시) */
|
||||
private String targetRefType;
|
||||
private Long targetRefId;
|
||||
/** 요청 요약 (마스킹된 JSON 한 줄) */
|
||||
private String requestSummary;
|
||||
private String responseSummary;
|
||||
private String resultCode;
|
||||
private String resultMessage;
|
||||
private Boolean success;
|
||||
private Integer durationMs;
|
||||
private String errorClass;
|
||||
private String errorMessage;
|
||||
private LocalDateTime calledAt;
|
||||
private Long createdBy;
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
|
||||
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
|
||||
<mapper namespace="com.ga.core.mapper.external.ExternalCallLogMapper">
|
||||
|
||||
<resultMap id="RespMap" type="com.ga.core.vo.external.ExternalCallLogResp">
|
||||
<id property="logId" column="log_id"/>
|
||||
<result property="callType" column="call_type"/>
|
||||
<result property="adapterClass" column="adapter_class"/>
|
||||
<result property="methodName" column="method_name"/>
|
||||
<result property="targetRefType" column="target_ref_type"/>
|
||||
<result property="targetRefId" column="target_ref_id"/>
|
||||
<result property="requestSummary" column="request_summary"/>
|
||||
<result property="responseSummary" column="response_summary"/>
|
||||
<result property="resultCode" column="result_code"/>
|
||||
<result property="resultMessage" column="result_message"/>
|
||||
<result property="success" column="success"/>
|
||||
<result property="durationMs" column="duration_ms"/>
|
||||
<result property="errorClass" column="error_class"/>
|
||||
<result property="errorMessage" column="error_message"/>
|
||||
<result property="calledAt" column="called_at"/>
|
||||
<result property="createdBy" column="created_by"/>
|
||||
</resultMap>
|
||||
|
||||
<sql id="cols">
|
||||
log_id, call_type, adapter_class, method_name,
|
||||
target_ref_type, target_ref_id,
|
||||
request_summary, response_summary,
|
||||
result_code, result_message, success, duration_ms,
|
||||
error_class, error_message, called_at, created_by
|
||||
</sql>
|
||||
|
||||
<insert id="insertOne" parameterType="com.ga.core.vo.external.ExternalCallLogVO"
|
||||
useGeneratedKeys="true" keyProperty="logId" keyColumn="log_id">
|
||||
INSERT INTO external_call_log (
|
||||
call_type, adapter_class, method_name,
|
||||
target_ref_type, target_ref_id,
|
||||
request_summary, response_summary,
|
||||
result_code, result_message, success, duration_ms,
|
||||
error_class, error_message, called_at, created_by
|
||||
) VALUES (
|
||||
#{callType},
|
||||
#{adapterClass},
|
||||
#{methodName},
|
||||
#{targetRefType, jdbcType=VARCHAR},
|
||||
#{targetRefId, jdbcType=BIGINT},
|
||||
#{requestSummary, jdbcType=LONGVARCHAR},
|
||||
#{responseSummary, jdbcType=LONGVARCHAR},
|
||||
#{resultCode, jdbcType=VARCHAR},
|
||||
#{resultMessage, jdbcType=LONGVARCHAR},
|
||||
#{success, jdbcType=BOOLEAN},
|
||||
#{durationMs, jdbcType=INTEGER},
|
||||
#{errorClass, jdbcType=VARCHAR},
|
||||
#{errorMessage, jdbcType=LONGVARCHAR},
|
||||
COALESCE(#{calledAt,jdbcType=TIMESTAMP}, CURRENT_TIMESTAMP),
|
||||
#{createdBy, jdbcType=BIGINT}
|
||||
)
|
||||
</insert>
|
||||
|
||||
<select id="selectById" resultMap="RespMap">
|
||||
SELECT <include refid="cols"/>
|
||||
FROM external_call_log
|
||||
WHERE log_id = #{logId}
|
||||
</select>
|
||||
|
||||
<select id="selectList" resultMap="RespMap"
|
||||
parameterType="com.ga.core.vo.external.ExternalCallLogSearchParam">
|
||||
SELECT <include refid="cols"/>
|
||||
FROM external_call_log
|
||||
<where>
|
||||
<if test="callType != null and callType != ''">
|
||||
AND call_type = #{callType}
|
||||
</if>
|
||||
<if test="methodName != null and methodName != ''">
|
||||
AND method_name = #{methodName}
|
||||
</if>
|
||||
<if test="success != null">
|
||||
AND success = #{success}
|
||||
</if>
|
||||
<if test="targetRefType != null and targetRefType != ''">
|
||||
AND target_ref_type = #{targetRefType}
|
||||
</if>
|
||||
<if test="targetRefId != null">
|
||||
AND target_ref_id = #{targetRefId}
|
||||
</if>
|
||||
<if test="startDate != null and startDate != ''">
|
||||
AND called_at >= (#{startDate}::date)
|
||||
</if>
|
||||
<if test="endDate != null and endDate != ''">
|
||||
AND called_at < (#{endDate}::date + INTERVAL '1 day')
|
||||
</if>
|
||||
</where>
|
||||
ORDER BY called_at DESC, log_id DESC
|
||||
</select>
|
||||
|
||||
</mapper>
|
||||
@@ -94,6 +94,7 @@ import SystemLog from '@/pages/system/SystemLog';
|
||||
import UploadTemplateManager from '@/pages/system/UploadTemplateManager';
|
||||
import DataDomainList from '@/pages/system/DataDomainList';
|
||||
import DataDictionary from '@/pages/system/DataDictionary';
|
||||
import ExternalCallLogList from '@/pages/system/ExternalCallLogList';
|
||||
|
||||
function RequireAuth({ children }: { children: React.ReactNode }) {
|
||||
const token = localStorage.getItem('accessToken');
|
||||
@@ -209,6 +210,7 @@ export default function App() {
|
||||
<Route path="system/upload-templates" element={<UploadTemplateManager />} />
|
||||
<Route path="system/data-domains" element={<DataDomainList />} />
|
||||
<Route path="system/data-dictionary" element={<DataDictionary />} />
|
||||
<Route path="system/external-call-log" element={<ExternalCallLogList />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import api, { unwrap } from './request';
|
||||
|
||||
export interface ExternalCallLogResp extends Record<string, unknown> {
|
||||
logId: number;
|
||||
callType: string; // BANK / MESSAGE
|
||||
adapterClass: string; // Mock / Kftc / Toast
|
||||
methodName: string; // requestTransfer / queryStatus / send
|
||||
targetRefType?: string;
|
||||
targetRefId?: number;
|
||||
requestSummary?: string;
|
||||
responseSummary?: string;
|
||||
resultCode?: string;
|
||||
resultMessage?: string;
|
||||
success: boolean;
|
||||
durationMs?: number;
|
||||
errorClass?: string;
|
||||
errorMessage?: string;
|
||||
calledAt: string;
|
||||
createdBy?: number;
|
||||
}
|
||||
|
||||
export interface ExternalCallLogSearchParam {
|
||||
callType?: string;
|
||||
methodName?: string;
|
||||
success?: boolean;
|
||||
targetRefType?: string;
|
||||
targetRefId?: number;
|
||||
startDate?: string;
|
||||
endDate?: string;
|
||||
pageNum?: number;
|
||||
pageSize?: number;
|
||||
}
|
||||
|
||||
export const externalCallLogApi = {
|
||||
list: (params?: ExternalCallLogSearchParam) =>
|
||||
unwrap<{ list: ExternalCallLogResp[]; total: number }>(
|
||||
api.get('/api/external-call-logs', { params })
|
||||
),
|
||||
detail: (id: number) =>
|
||||
unwrap<ExternalCallLogResp>(api.get(`/api/external-call-logs/${id}`)),
|
||||
};
|
||||
@@ -0,0 +1,113 @@
|
||||
import { useState } from 'react';
|
||||
import { Tag, Drawer, Descriptions } from 'antd';
|
||||
import { ProTable, type ProColumns } from '@ant-design/pro-components';
|
||||
import PageContainer from '@/components/common/PageContainer';
|
||||
import { externalCallLogApi, ExternalCallLogResp } from '@/api/externalCallLog';
|
||||
|
||||
export default function ExternalCallLogList() {
|
||||
const [detail, setDetail] = useState<ExternalCallLogResp | null>(null);
|
||||
|
||||
const columns: ProColumns<ExternalCallLogResp>[] = [
|
||||
{ title: '호출시각', dataIndex: 'calledAt', width: 170, valueType: 'dateTime' },
|
||||
{
|
||||
title: '종류', dataIndex: 'callType', width: 90, valueType: 'select',
|
||||
valueEnum: { BANK: { text: '펌뱅킹' }, MESSAGE: { text: 'SMS/카카오' } },
|
||||
render: (_, r) => (
|
||||
<Tag color={r.callType === 'BANK' ? 'geekblue' : 'magenta'}>{r.callType}</Tag>
|
||||
),
|
||||
},
|
||||
{ title: '구현체', dataIndex: 'adapterClass', width: 200, search: false, ellipsis: true },
|
||||
{ title: '메서드', dataIndex: 'methodName', width: 140 },
|
||||
{
|
||||
title: '대상', dataIndex: 'targetRefType', width: 160, search: false,
|
||||
render: (_, r) =>
|
||||
r.targetRefType ? `${r.targetRefType}#${r.targetRefId ?? ''}` : '-',
|
||||
},
|
||||
{
|
||||
title: '결과', dataIndex: 'success', width: 80,
|
||||
valueType: 'select',
|
||||
valueEnum: { true: { text: '성공' }, false: { text: '실패' } },
|
||||
render: (_, r) => (
|
||||
<Tag color={r.success ? 'success' : 'error'}>{r.success ? 'SUCCESS' : 'FAIL'}</Tag>
|
||||
),
|
||||
},
|
||||
{ title: '코드', dataIndex: 'resultCode', width: 90, search: false },
|
||||
{
|
||||
title: '소요(ms)', dataIndex: 'durationMs', width: 90, align: 'right', search: false,
|
||||
},
|
||||
{
|
||||
title: '메시지', dataIndex: 'resultMessage', ellipsis: true, search: false,
|
||||
render: (_, r) => r.errorMessage ?? r.resultMessage ?? '-',
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<PageContainer
|
||||
title="외부연동 호출이력"
|
||||
description="펌뱅킹·SMS·카카오 어댑터 호출 감사 (AOP 자동 영속화)"
|
||||
>
|
||||
<ProTable<ExternalCallLogResp>
|
||||
rowKey="logId"
|
||||
columns={columns}
|
||||
dateFormatter="string"
|
||||
scroll={{ x: 1300 }}
|
||||
request={async (params) => {
|
||||
const { current, pageSize, ...rest } = params as any;
|
||||
const res = await externalCallLogApi.list({
|
||||
...rest, pageNum: current, pageSize,
|
||||
});
|
||||
return { data: res.list, total: res.total, success: true };
|
||||
}}
|
||||
onRow={(record) => ({ onClick: () => setDetail(record), style: { cursor: 'pointer' } })}
|
||||
pagination={{ pageSize: 50, showTotal: (t) => `총 ${t.toLocaleString()}건` }}
|
||||
search={{
|
||||
labelWidth: 'auto', searchText: '검색', resetText: '초기화',
|
||||
collapsed: false, collapseRender: false,
|
||||
}}
|
||||
/>
|
||||
|
||||
<Drawer
|
||||
title={`호출 #${detail?.logId ?? ''}`}
|
||||
width={640}
|
||||
open={!!detail}
|
||||
onClose={() => setDetail(null)}
|
||||
destroyOnClose
|
||||
>
|
||||
{detail && (
|
||||
<Descriptions column={1} size="small" bordered>
|
||||
<Descriptions.Item label="호출시각">{detail.calledAt}</Descriptions.Item>
|
||||
<Descriptions.Item label="종류">{detail.callType}</Descriptions.Item>
|
||||
<Descriptions.Item label="구현체">{detail.adapterClass}</Descriptions.Item>
|
||||
<Descriptions.Item label="메서드">{detail.methodName}</Descriptions.Item>
|
||||
<Descriptions.Item label="대상">
|
||||
{detail.targetRefType ? `${detail.targetRefType}#${detail.targetRefId ?? ''}` : '-'}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="결과">
|
||||
<Tag color={detail.success ? 'success' : 'error'}>
|
||||
{detail.success ? 'SUCCESS' : 'FAIL'}
|
||||
</Tag>
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="코드">{detail.resultCode ?? '-'}</Descriptions.Item>
|
||||
<Descriptions.Item label="메시지">{detail.resultMessage ?? '-'}</Descriptions.Item>
|
||||
<Descriptions.Item label="소요(ms)">{detail.durationMs ?? '-'}</Descriptions.Item>
|
||||
<Descriptions.Item label="요청">
|
||||
<pre style={{ margin: 0, whiteSpace: 'pre-wrap', wordBreak: 'break-all' }}>
|
||||
{detail.requestSummary ?? '-'}
|
||||
</pre>
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="응답">
|
||||
<pre style={{ margin: 0, whiteSpace: 'pre-wrap', wordBreak: 'break-all' }}>
|
||||
{detail.responseSummary ?? '-'}
|
||||
</pre>
|
||||
</Descriptions.Item>
|
||||
{detail.errorClass && (
|
||||
<Descriptions.Item label="예외">
|
||||
{detail.errorClass}: {detail.errorMessage ?? ''}
|
||||
</Descriptions.Item>
|
||||
)}
|
||||
</Descriptions>
|
||||
)}
|
||||
</Drawer>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
{"success":true,"code":"0000","message":"성공","data":{"accessToken":"eyJhbGciOiJIUzM4NCJ9.eyJzdWIiOiJhZG1pbiIsInR5cGUiOiJBQ0NFU1MiLCJ1aWQiOjEsInJvbGVzIjpbIlNVUEVSX0FETUlOIl0sImlhdCI6MTc3OTYyOTYyNCwiZXhwIjoxNzc5NjM2ODI0fQ.wToTgSSi6aEZfWVJCZATKZwUlHXl5sz0Y6EngX5oTO4HzmvUB-dOQB76bIE84URE","refreshToken":"eyJhbGciOiJIUzM4NCJ9.eyJzdWIiOiJhZG1pbiIsInR5cGUiOiJSRUZSRVNIIiwidWlkIjoxLCJyb2xlcyI6W10sImlhdCI6MTc3OTYyOTYyNCwiZXhwIjoxNzgwMjM0NDI0fQ.uZDBImRtJqrp5CAb1ILZ-v0G2DyNs4lnLFuSm8A1oJ1h_5JSQnJGVQr3OULrYCNI","accessTokenExpiresIn":7200000,"userId":1,"loginId":"admin","userName":"시스템 관리자","roles":["SUPER_ADMIN"]},"timestamp":[2026,5,24,22,33,44,637428600]}
|
||||
Reference in New Issue
Block a user