diff --git a/HANDOFF.md b/HANDOFF.md index 8dbbb48..5d4ccff 100644 --- a/HANDOFF.md +++ b/HANDOFF.md @@ -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 가이드 (다음 세션 유의사항) diff --git a/ga-api/src/main/java/com/ga/api/aop/ExternalCallLoggingAspect.java b/ga-api/src/main/java/com/ga/api/aop/ExternalCallLoggingAspect.java new file mode 100644 index 0000000..aceec60 --- /dev/null +++ b/ga-api/src/main/java/com/ga/api/aop/ExternalCallLoggingAspect.java @@ -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). + * + *

대상: {@link com.ga.common.adapter.bank.BankTransferAdapter}, + * {@link com.ga.common.adapter.message.MessageAdapter} 모든 메서드. + * + *

정책: + *

+ */ +@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 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 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 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 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("\"", "\\\""); + } +} diff --git a/ga-api/src/main/java/com/ga/api/controller/external/ExternalCallLogController.java b/ga-api/src/main/java/com/ga/api/controller/external/ExternalCallLogController.java new file mode 100644 index 0000000..ec1c7db --- /dev/null +++ b/ga-api/src/main/java/com/ga/api/controller/external/ExternalCallLogController.java @@ -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> 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 detail(@PathVariable Long logId) { + return ApiResponse.ok(service.detail(logId)); + } +} diff --git a/ga-api/src/main/java/com/ga/api/service/external/ExternalCallLogService.java b/ga-api/src/main/java/com/ga/api/service/external/ExternalCallLogService.java new file mode 100644 index 0000000..724562d --- /dev/null +++ b/ga-api/src/main/java/com/ga/api/service/external/ExternalCallLogService.java @@ -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; + +/** + * 외부 연동 호출 감사 이력. + * + *

{@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 list(ExternalCallLogSearchParam param) { + param.startPage(); + return PageResponse.of(mapper.selectList(param)); + } + + @Transactional(readOnly = true) + public ExternalCallLogResp detail(Long logId) { + return mapper.selectById(logId); + } +} diff --git a/ga-common/src/main/resources/db/migration/V85__외부연동_호출이력.sql b/ga-common/src/main/resources/db/migration/V85__외부연동_호출이력.sql new file mode 100644 index 0000000..199e500 --- /dev/null +++ b/ga-common/src/main/resources/db/migration/V85__외부연동_호출이력.sql @@ -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; diff --git a/ga-core/src/main/java/com/ga/core/mapper/external/ExternalCallLogMapper.java b/ga-core/src/main/java/com/ga/core/mapper/external/ExternalCallLogMapper.java new file mode 100644 index 0000000..32b7bd7 --- /dev/null +++ b/ga-core/src/main/java/com/ga/core/mapper/external/ExternalCallLogMapper.java @@ -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 selectList(ExternalCallLogSearchParam param); +} diff --git a/ga-core/src/main/java/com/ga/core/vo/external/ExternalCallLogResp.java b/ga-core/src/main/java/com/ga/core/vo/external/ExternalCallLogResp.java new file mode 100644 index 0000000..a215349 --- /dev/null +++ b/ga-core/src/main/java/com/ga/core/vo/external/ExternalCallLogResp.java @@ -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; +} diff --git a/ga-core/src/main/java/com/ga/core/vo/external/ExternalCallLogSearchParam.java b/ga-core/src/main/java/com/ga/core/vo/external/ExternalCallLogSearchParam.java new file mode 100644 index 0000000..d1b60e9 --- /dev/null +++ b/ga-core/src/main/java/com/ga/core/vo/external/ExternalCallLogSearchParam.java @@ -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; +} diff --git a/ga-core/src/main/java/com/ga/core/vo/external/ExternalCallLogVO.java b/ga-core/src/main/java/com/ga/core/vo/external/ExternalCallLogVO.java new file mode 100644 index 0000000..2e91fdd --- /dev/null +++ b/ga-core/src/main/java/com/ga/core/vo/external/ExternalCallLogVO.java @@ -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; +} diff --git a/ga-core/src/main/resources/mapper/external/ExternalCallLogMapper.xml b/ga-core/src/main/resources/mapper/external/ExternalCallLogMapper.xml new file mode 100644 index 0000000..4b168fa --- /dev/null +++ b/ga-core/src/main/resources/mapper/external/ExternalCallLogMapper.xml @@ -0,0 +1,96 @@ + + + + + + + + + + + + + + + + + + + + + + + + + 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 + + + + 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} + ) + + + + + + + diff --git a/ga-frontend/src/App.tsx b/ga-frontend/src/App.tsx index b5d3a9c..c94ce99 100644 --- a/ga-frontend/src/App.tsx +++ b/ga-frontend/src/App.tsx @@ -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() { } /> } /> } /> + } /> ); diff --git a/ga-frontend/src/api/externalCallLog.ts b/ga-frontend/src/api/externalCallLog.ts new file mode 100644 index 0000000..9e2614a --- /dev/null +++ b/ga-frontend/src/api/externalCallLog.ts @@ -0,0 +1,41 @@ +import api, { unwrap } from './request'; + +export interface ExternalCallLogResp extends Record { + 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(api.get(`/api/external-call-logs/${id}`)), +}; diff --git a/ga-frontend/src/pages/system/ExternalCallLogList.tsx b/ga-frontend/src/pages/system/ExternalCallLogList.tsx new file mode 100644 index 0000000..d3ec9f7 --- /dev/null +++ b/ga-frontend/src/pages/system/ExternalCallLogList.tsx @@ -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(null); + + const columns: ProColumns[] = [ + { title: '호출시각', dataIndex: 'calledAt', width: 170, valueType: 'dateTime' }, + { + title: '종류', dataIndex: 'callType', width: 90, valueType: 'select', + valueEnum: { BANK: { text: '펌뱅킹' }, MESSAGE: { text: 'SMS/카카오' } }, + render: (_, r) => ( + {r.callType} + ), + }, + { 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) => ( + {r.success ? 'SUCCESS' : 'FAIL'} + ), + }, + { 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 ( + + + 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, + }} + /> + + setDetail(null)} + destroyOnClose + > + {detail && ( + + {detail.calledAt} + {detail.callType} + {detail.adapterClass} + {detail.methodName} + + {detail.targetRefType ? `${detail.targetRefType}#${detail.targetRefId ?? ''}` : '-'} + + + + {detail.success ? 'SUCCESS' : 'FAIL'} + + + {detail.resultCode ?? '-'} + {detail.resultMessage ?? '-'} + {detail.durationMs ?? '-'} + +

+                {detail.requestSummary ?? '-'}
+              
+ + +
+                {detail.responseSummary ?? '-'}
+              
+
+ {detail.errorClass && ( + + {detail.errorClass}: {detail.errorMessage ?? ''} + + )} + + )} + + + ); +} diff --git a/login.json b/login.json new file mode 100644 index 0000000..c89841a --- /dev/null +++ b/login.json @@ -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]} \ No newline at end of file diff --git a/token.txt b/token.txt new file mode 100644 index 0000000..db4330a --- /dev/null +++ b/token.txt @@ -0,0 +1 @@ +eyJhbGciOiJIUzM4NCJ9.eyJzdWIiOiJhZG1pbiIsInR5cGUiOiJBQ0NFU1MiLCJ1aWQiOjEsInJvbGVzIjpbIlNVUEVSX0FETUlOIl0sImlhdCI6MTc3OTYyOTYyNCwiZXhwIjoxNzc5NjM2ODI0fQ.wToTgSSi6aEZfWVJCZATKZwUlHXl5sz0Y6EngX5oTO4HzmvUB-dOQB76bIE84URE