# Spring Boot admin-api — Audit Log 리팩토링 지침서

**작업일**: 2026-03-16
**대상 모듈**: `admin-api`, `common`
**빌드 검증**: `./gradlew :admin-api:compileJava` 성공 기준

---

## 문제

현재 audit log의 `action`, `targetType`이 전부 문자열 리터럴이고,
`details` JSON이 수동 문자열 결합으로 생성되어 있음:

```java
// ❌ 현재 — 오타 가능, JSON 특수문자 미이스케이프, 패턴 비일관
.action("APPROVE_WITHDRAWAL")
.targetType("WITHDRAWAL")
.details("{\"previousStatus\":\"" + previousStatus + "\",\"newStatus\":\"" + request.getStatus() + "\"}")
```

---

## 방향: B — AuditAction enum + `Map.of()` + ObjectMapper

```java
// ✅ 목표
auditLogService.log(adminId, AuditAction.APPROVE_WITHDRAWAL, targetId,
    Map.of("previousStatus", "PENDING_APPROVAL", "newStatus", "APPROVED"));
```

**장점**:
- `action` 오타 → 컴파일 에러로 잡힘
- `targetType` 자동 매핑 (enum에 내장)
- JSON 직렬화 안전 (ObjectMapper)
- details 구조를 강제하지 않아서 새 메타데이터 추가 자유

---

## 작업 항목

| # | 항목 | 위치 | 우선순위 |
|---|------|------|----------|
| A-1 | AuditAction enum 정의 | common/enums | 필수 (선행) |
| A-2 | AuditLogService 생성 | admin-api/service | 필수 (선행) |
| A-3 | 기존 호출부 마이그레이션 (17개 서비스) | admin-api/service | 필수 |
| A-4 | 빌드 검증 + 미사용 문자열 정리 | — | 필수 |

```
A-1 → A-2 → A-3 (서비스별 순차) → A-4
```

---

## A-1. AuditAction enum

**신규 파일**: `common/src/main/java/com/cryptoments/common/enums/AuditAction.java`

```java
package com.cryptoments.common.enums;

import lombok.Getter;
import lombok.RequiredArgsConstructor;

/**
 * 관리자 감사 로그 액션 정의
 * targetType을 내장하여 호출부에서 일관성 보장
 */
@Getter
@RequiredArgsConstructor
public enum AuditAction {

    // ━━━ 파트너 관리 ━━━
    CREATE_PARTNER("PARTNER"),
    UPDATE_PARTNER("PARTNER"),
    CHANGE_PARTNER_STATUS("PARTNER"),
    REGENERATE_API_KEY("PARTNER"),
    RESET_PARTNER_2FA("PARTNER"),
    UPDATE_PARTNER_FEES("PARTNER"),
    ADD_WHITELIST_ADDRESS("WITHDRAWAL_ADDRESS_WHITELIST"),
    DELETE_WHITELIST_ADDRESS("WITHDRAWAL_ADDRESS_WHITELIST"),
    UPDATE_CHAIN_CONFIGS("PARTNER_CHAIN_CONFIG"),
    UPDATE_WITHDRAWAL_POLICY("PARTNER_WITHDRAWAL_POLICY"),
    UPDATE_TELEGRAM_CONFIG("PARTNER_TELEGRAM_CONFIG"),
    UPDATE_TELEGRAM_SUBSCRIPTIONS("PARTNER_TELEGRAM_SUBSCRIPTION"),
    UPDATE_AXIM_SETTINGS("PARTNER_AXIM_SETTINGS"),

    // ━━━ 입금 ━━━
    MATCH_UNIDENTIFIED_DEPOSIT("DEPOSIT"),
    REFUND_UNIDENTIFIED_DEPOSIT("DEPOSIT"),

    // ━━━ 출금 ━━━
    APPROVE_WITHDRAWAL("WITHDRAWAL"),
    REJECT_WITHDRAWAL("WITHDRAWAL"),
    RETRY_WITHDRAWAL("WITHDRAWAL"),

    // ━━━ 집금 ━━━
    RETRY_COLLECTION("COLLECTION_QUEUE"),
    CANCEL_COLLECTION("COLLECTION_QUEUE"),

    // ━━━ 지갑 ━━━
    CREATE_ADMIN_WALLET("INFRA_WALLET"),
    CREATE_FEE_WALLET("INFRA_WALLET"),
    CREATE_SETTLEMENT_WALLET("INFRA_WALLET"),
    SYNC_BALANCES("WALLET_BALANCE"),
    SYNC_WALLET_BALANCE("WALLET_BALANCE"),
    RETRY_APPROVAL("WALLET_APPROVAL"),

    // ━━━ Relayer ━━━
    CREATE_RELAYER("RELAYER_WALLET"),
    DEACTIVATE_RELAYER("RELAYER_WALLET"),
    UPDATE_RELAYER_STATUS("RELAYER_WALLET"),

    // ━━━ 컨트랙트 ━━━
    REGISTER_CONTRACT("RELAYER_CONTRACT"),
    ADD_RELAYER_TO_CONTRACT("RELAYER_CONTRACT"),
    REMOVE_RELAYER_FROM_CONTRACT("RELAYER_CONTRACT"),
    PAUSE_CONTRACT("RELAYER_CONTRACT"),
    UNPAUSE_CONTRACT("RELAYER_CONTRACT"),

    // ━━━ 시스템 ━━━
    UPDATE_SYSTEM_SETTING("SYSTEM_SETTING"),
    CREATE_BLOCKCHAIN_NETWORK("BLOCKCHAIN_NETWORK"),
    UPDATE_BLOCKCHAIN_NETWORK("BLOCKCHAIN_NETWORK"),
    CREATE_CURRENCY("CURRENCY"),
    UPDATE_CURRENCY("CURRENCY"),

    // ━━━ 논스 ━━━
    SYNC_NONCE("NONCE_TRACKER"),
    UNLOCK_NONCE("NONCE_TRACKER"),

    // ━━━ 정산/가스 ━━━
    SETTLEMENT_WITHDRAW("SETTLEMENT_BALANCE"),
    WAIVE_GAS_COST("GAS_COST_RECORD"),
    GENERATE_GAS_INVOICES("GAS_INVOICE"),
    CONFIRM_GAS_INVOICE_PAYMENT("GAS_INVOICE"),
    MARK_GAS_INVOICE_OVERDUE("GAS_INVOICE");

    /** admin_audit_logs.target_type에 저장될 값 */
    private final String targetType;
}
```

> **44개 액션** — 현재 코드베이스에서 수집한 전체 목록.
> 새 기능 추가 시 여기에 enum 값만 추가하면 됨.

---

## A-2. AuditLogService

**신규 파일**: `admin-api/src/main/java/com/cryptoments/adminapi/service/AuditLogService.java`

```java
package com.cryptoments.adminapi.service;

import com.cryptoments.common.entity.AdminAuditLog;
import com.cryptoments.common.enums.AuditAction;
import com.cryptoments.common.repository.AdminAuditLogRepository;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;

import java.util.Map;

/**
 * 감사 로그 기록 서비스
 * - AuditAction enum으로 action/targetType 자동 매핑
 * - ObjectMapper로 details JSON 안전 직렬화
 */
@Slf4j
@Service
@RequiredArgsConstructor
public class AuditLogService {

    private final AdminAuditLogRepository adminAuditLogRepository;
    private final ObjectMapper objectMapper;

    /**
     * 감사 로그 기록 (details 있음)
     *
     * @param adminId  관리자 ID
     * @param action   액션 (enum — targetType 자동 매핑)
     * @param targetId 대상 엔티티 ID (nullable)
     * @param details  메타데이터 (Map → JSON 직렬화)
     */
    public void log(Long adminId, AuditAction action, Long targetId, Map<String, Object> details) {
        AdminAuditLog auditLog = AdminAuditLog.builder()
                .adminId(adminId)
                .action(action.name())
                .targetType(action.getTargetType())
                .targetId(targetId)
                .details(toJson(details))
                .build();

        adminAuditLogRepository.save(auditLog);
    }

    /**
     * 감사 로그 기록 (details 없음)
     */
    public void log(Long adminId, AuditAction action, Long targetId) {
        log(adminId, action, targetId, null);
    }

    /**
     * Map → JSON 변환 (안전)
     * - null → null (details 컬럼 nullable)
     * - 빈 Map → null
     * - 직렬화 실패 시 fallback 문자열
     */
    private String toJson(Map<String, Object> details) {
        if (details == null || details.isEmpty()) {
            return null;
        }
        try {
            return objectMapper.writeValueAsString(details);
        } catch (JsonProcessingException e) {
            log.error("감사 로그 details 직렬화 실패: {}", details, e);
            return "{\"_serializationError\":true}";
        }
    }
}
```

### ObjectMapper Bean 확인

Spring Boot 자동 구성으로 `ObjectMapper`가 이미 등록되어 있을 가능성 높음.
없으면 config에 추가:

```java
// admin-api config 패키지
@Configuration
public class JacksonConfig {
    @Bean
    @ConditionalOnMissingBean
    public ObjectMapper objectMapper() {
        return new ObjectMapper()
                .registerModule(new JavaTimeModule())
                .disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS);
    }
}
```

---

## A-3. 기존 호출부 마이그레이션

### 패턴별 변환 가이드

#### 패턴 1: 상태 변경

```java
// ❌ Before
adminAuditLogRepository.save(AdminAuditLog.builder()
    .adminId(adminId)
    .action("APPROVE_WITHDRAWAL")
    .targetType("WITHDRAWAL")
    .targetId(withdrawalId)
    .details("{\"previousStatus\":\"" + previousStatus
        + "\",\"newStatus\":\"" + request.getStatus() + "\"}")
    .build());

// ✅ After
auditLogService.log(adminId, AuditAction.APPROVE_WITHDRAWAL, withdrawalId,
    Map.of("previousStatus", previousStatus, "newStatus", request.getStatus()));
```

#### 패턴 2: reject (사유 포함)

```java
// ❌ Before
.details("{\"newStatus\":\"REJECTED\",\"reason\":\"" + request.getReason() + "\"}")

// ✅ After
auditLogService.log(adminId, AuditAction.REJECT_WITHDRAWAL, withdrawalId,
    Map.of("newStatus", "REJECTED", "reason", request.getReason()));
```

#### 패턴 3: 생성 (단일 필드)

```java
// ❌ Before
.action("CREATE_CURRENCY")
.targetType("CURRENCY")
.details("{\"symbol\":\"" + request.getSymbol()
    + "\",\"networkId\":" + request.getNetworkId() + "}")

// ✅ After
auditLogService.log(adminId, AuditAction.CREATE_CURRENCY, currencyId,
    Map.of("symbol", request.getSymbol(), "networkId", request.getNetworkId()));
```

#### 패턴 4: 복합 정보

```java
// ❌ Before
.details("{\"networkId\":" + request.getNetworkId()
    + ",\"contractAddress\":\"" + request.getContractAddress()
    + "\",\"status\":\"" + response.getStatus() + "\"}")

// ✅ After
auditLogService.log(adminId, AuditAction.REGISTER_CONTRACT, contractId,
    Map.of("networkId", request.getNetworkId(),
           "contractAddress", request.getContractAddress(),
           "status", response.getStatus()));
```

#### 패턴 5: 시스템 설정 변경

```java
// ❌ Before
.details("{\"settingKey\":\"" + key
    + "\",\"previousValue\":\"" + previousValue
    + "\",\"newValue\":\"" + request.getSettingValue() + "\"}")

// ✅ After
auditLogService.log(adminId, AuditAction.UPDATE_SYSTEM_SETTING, settingId,
    Map.of("settingKey", key,
           "previousValue", previousValue,
           "newValue", request.getSettingValue()));
```

#### 패턴 6: 연동 결과 (카운트)

```java
// ❌ Before
.details("{\"type\":\"BATCH_SYNC\",\"syncedCount\":" + syncedCount + "}")

// ✅ After
auditLogService.log(adminId, AuditAction.SYNC_BALANCES, null,
    Map.of("type", "BATCH_SYNC", "syncedCount", syncedCount));
```

#### 패턴 7: details 없음

```java
// ❌ Before
adminAuditLogRepository.save(AdminAuditLog.builder()
    .adminId(adminId)
    .action("CANCEL_COLLECTION")
    .targetType("COLLECTION_QUEUE")
    .targetId(collectionId)
    .build());

// ✅ After
auditLogService.log(adminId, AuditAction.CANCEL_COLLECTION, collectionId);
```

#### 패턴 8: PartnerManagementService의 recordAudit 헬퍼

```java
// ❌ Before (private helper)
private void recordAudit(Long adminId, String action, String targetType,
                         Long targetId, String details) { ... }
recordAudit(adminId, "CHANGE_PARTNER_STATUS", "PARTNER", partnerId,
    "{\"from\":\"" + partner.getStatus() + "\",\"to\":\"" + request.getStatus() + "\"}");

// ✅ After (recordAudit 헬퍼 삭제, AuditLogService 직접 사용)
auditLogService.log(adminId, AuditAction.CHANGE_PARTNER_STATUS, partnerId,
    Map.of("from", partner.getStatus().name(), "to", request.getStatus()));
```

---

### 서비스별 마이그레이션 체크리스트

각 서비스에서 수행할 작업:
1. `AdminAuditLogRepository` 필드 → `AuditLogService` 필드로 교체
2. `adminAuditLogRepository.save(AdminAuditLog.builder()...build())` → `auditLogService.log(...)` 교체
3. 문자열 결합 details → `Map.of(...)` 교체

| # | 서비스 | 호출 수 | 비고 |
|---|--------|---------|------|
| 1 | PartnerManagementService | 14 | `recordAudit` 헬퍼 삭제, AuditLogService로 전환 |
| 2 | WithdrawalManagementService | 3 | APPROVE, REJECT, RETRY |
| 3 | ContractManagementService | 5 | REGISTER, ADD/REMOVE_RELAYER, PAUSE, UNPAUSE |
| 4 | RelayerManagementService | 3 | CREATE, DEACTIVATE, UPDATE_STATUS |
| 5 | InfraWalletService | 3 | CREATE_ADMIN/FEE/SETTLEMENT_WALLET |
| 6 | WalletManagementService | 2 | SYNC_BALANCES, SYNC_WALLET_BALANCE |
| 7 | CsToolService | 2 | MATCH/REFUND_UNIDENTIFIED_DEPOSIT |
| 8 | CollectionManagementService | 2 | RETRY/CANCEL_COLLECTION |
| 9 | GasInvoiceService | 3 | GENERATE/CONFIRM/MARK_OVERDUE |
| 10 | NonceTrackerService | 2 | SYNC/UNLOCK_NONCE |
| 11 | SystemManagementService | 1 | UPDATE_SYSTEM_SETTING |
| 12 | BlockchainNetworkManagementService | 2 | CREATE/UPDATE_BLOCKCHAIN_NETWORK |
| 13 | CurrencyManagementService | 2 | CREATE/UPDATE_CURRENCY |
| 14 | GasCostManagementService | 1 | WAIVE_GAS_COST |
| 15 | WalletApprovalService | 1 | RETRY_APPROVAL |
| 16 | SettlementService | 1 | SETTLEMENT_WITHDRAW |
| 17 | MaintenanceService | 1+ | 기존 패턴 확인 후 전환 |
| **합계** | | **~48** | |

---

## A-4. 빌드 검증 체크리스트

- [ ] `AuditAction` enum — 44개 값 + targetType 매핑
- [ ] `AuditLogService` — `log(adminId, action, targetId, details)` 2개 오버로드
- [ ] ObjectMapper Bean 존재 확인
- [ ] 17개 서비스 전부 `auditLogService.log(...)` 전환 완료
- [ ] `recordAudit` 헬퍼 메서드 삭제 (PartnerManagementService)
- [ ] 문자열 결합 JSON 패턴 0건 확인: `"{\"` 검색 → 0 match
- [ ] `./gradlew :admin-api:compileJava` 성공
- [ ] `adminAuditLogRepository.save()` 직접 호출 0건 확인 (AuditLogService 외)

---

## 변경 파일 목록

| 유형 | 파일 | 내용 |
|------|------|------|
| **신규** | `common/enums/AuditAction.java` | 44개 액션 enum (targetType 내장) |
| **신규** | `admin-api/service/AuditLogService.java` | 중앙 감사 로그 서비스 |
| 수정 | `admin-api/service/PartnerManagementService.java` | 14개소 전환 + recordAudit 삭제 |
| 수정 | `admin-api/service/WithdrawalManagementService.java` | 3개소 전환 |
| 수정 | `admin-api/service/ContractManagementService.java` | 5개소 전환 |
| 수정 | `admin-api/service/RelayerManagementService.java` | 3개소 전환 |
| 수정 | `admin-api/service/InfraWalletService.java` | 3개소 전환 |
| 수정 | `admin-api/service/WalletManagementService.java` | 2개소 전환 |
| 수정 | `admin-api/service/CsToolService.java` | 2개소 전환 |
| 수정 | `admin-api/service/CollectionManagementService.java` | 2개소 전환 |
| 수정 | `admin-api/service/GasInvoiceService.java` | 3개소 전환 |
| 수정 | `admin-api/service/NonceTrackerService.java` | 2개소 전환 |
| 수정 | `admin-api/service/SystemManagementService.java` | 1개소 전환 |
| 수정 | `admin-api/service/BlockchainNetworkManagementService.java` | 2개소 전환 |
| 수정 | `admin-api/service/CurrencyManagementService.java` | 2개소 전환 |
| 수정 | `admin-api/service/GasCostManagementService.java` | 1개소 전환 |
| 수정 | `admin-api/service/WalletApprovalService.java` | 1개소 전환 |
| 수정 | `admin-api/service/SettlementService.java` | 1개소 전환 |
| 수정 | `admin-api/service/MaintenanceService.java` | 기존 패턴 확인 후 전환 |

---

## 변경 이력

| 버전 | 날짜 | 변경 내용 |
|------|------|----------|
| v1.0 | 2026-03-16 | 초안 — AuditAction enum + AuditLogService + 마이그레이션 가이드 |
