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,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>
);
}