# Spring Boot TODO 우선순위 지침서

**대상**: IntelliJ (admin-api, open-api, partner-api, core)
**날짜**: 2026-03-19

---

## 우선순위 분류

| 우선순위 | 기준 | 건수 |
|---------|------|------|
| 🔴 **P0** | Phase 0 E2E 테스트 블로커 | 2건 |
| 🟡 **P1** | Phase 1~2 테스트에 필요 | 3건 |
| 🟢 **P2** | 운영/부가 기능 (나중) | 8건 |

---

## 🔴 P0 — Phase 0 E2E 테스트 블로커 (즉시 구현)

### P0-1. WebhookProcessingService — 비즈니스 분기 처리

**파일**: `open-api/src/main/java/com/cryptoments/webhook/service/WebhookProcessingService.java`

현재 상태: `BusinessEventClassifier`가 분류까지는 정상 동작하지만, 분류 결과에 따른 **비즈니스 처리가 주석**으로 남아있음.

Phase 0 E2E 테스트(입금→집금→출금)를 진행하려면 최소한 아래 분기가 동작해야 함.

#### 변경 내용

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

import com.cryptoments.common.entity.BlockchainNetwork;
import com.cryptoments.common.entity.CollectionQueue;
import com.cryptoments.common.entity.Deposit;
import com.cryptoments.common.entity.Withdrawal;
import com.cryptoments.common.enums.CollectionStatus;
import com.cryptoments.common.enums.DepositStatus;
import com.cryptoments.common.enums.WithdrawalStatus;
import com.cryptoments.common.repository.BlockchainNetworkRepository;
import com.cryptoments.common.repository.CollectionQueueRepository;
import com.cryptoments.common.repository.DepositRepository;
import com.cryptoments.common.repository.WithdrawalRepository;
import com.cryptoments.core.webhook.WebhookBalanceSyncService;
import com.cryptoments.webhook.dto.TransactionWebhookDTO;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.List;

/**
 * Webhook 처리 파이프라인.
 *
 * <pre>
 * [1단계] 비즈니스 이벤트 분류 (BusinessEventClassifier)
 * [2단계] 비즈니스 처리 (Deposit/Withdrawal/Collection)
 * [3단계] 온체인 잔액 동기화 (from/to 지갑)
 * </pre>
 */
@Service
public class WebhookProcessingService {

    private static final Logger log = LoggerFactory.getLogger(WebhookProcessingService.class);

    private final BlockchainNetworkRepository blockchainNetworkRepository;
    private final BusinessEventClassifier businessEventClassifier;
    private final WebhookBalanceSyncService webhookBalanceSyncService;
    private final DepositRepository depositRepository;
    private final WithdrawalRepository withdrawalRepository;
    private final CollectionQueueRepository collectionQueueRepository;

    public WebhookProcessingService(BlockchainNetworkRepository blockchainNetworkRepository,
                                     BusinessEventClassifier businessEventClassifier,
                                     WebhookBalanceSyncService webhookBalanceSyncService,
                                     DepositRepository depositRepository,
                                     WithdrawalRepository withdrawalRepository,
                                     CollectionQueueRepository collectionQueueRepository) {
        this.blockchainNetworkRepository = blockchainNetworkRepository;
        this.businessEventClassifier = businessEventClassifier;
        this.webhookBalanceSyncService = webhookBalanceSyncService;
        this.depositRepository = depositRepository;
        this.withdrawalRepository = withdrawalRepository;
        this.collectionQueueRepository = collectionQueueRepository;
    }

    @Transactional
    public void process(TransactionWebhookDTO dto) {
        // ── 네트워크 매핑 ──
        BlockchainNetwork network = blockchainNetworkRepository.findByChainId(dto.getChainId());
        if (network == null) {
            log.error("미등록 네트워크: chainId={}", dto.getChainId());
            return;
        }

        // ── [1단계] 비즈니스 이벤트 분류 ──
        String businessType = businessEventClassifier.classify(dto, network.getId());
        log.info("Webhook 분류 완료: txHash={}, businessType={}", dto.getTxHash(), businessType);

        // ── [2단계] 비즈니스 처리 ──
        switch (businessType) {
            case "DEPOSIT" -> processDeposit(dto, network);
            case "WITHDRAWAL_CONFIRM" -> confirmWithdrawal(dto);
            case "COLLECTION_CONFIRM" -> confirmCollection(dto);
            case "TX_FAILED" -> handleFailedTx(dto);
            case "INTERNAL_TRANSFER" -> log.info("내부 이체 감지: txHash={}", dto.getTxHash());
            default -> log.warn("미처리 비즈니스 타입: {} (txHash={})", businessType, dto.getTxHash());
        }

        // ── [3단계] 온체인 잔액 동기화 ──
        webhookBalanceSyncService.syncAffectedWallets(
                dto.getFrom(), dto.getTo(), dto.getChainId(), dto.getTokenSymbol());

        log.info("Webhook 처리 완료: txHash={}, businessType={}", dto.getTxHash(), businessType);
    }

    // ── 비즈니스 처리 메서드 ──

    /**
     * 입금 처리: DETECTED 상태의 deposit 조회 또는 신규 생성.
     */
    private void processDeposit(TransactionWebhookDTO dto, BlockchainNetwork network) {
        // txHash로 기존 deposit 확인 (멱등성)
        Deposit existing = depositRepository.findByTxHash(dto.getTxHash());
        if (existing != null) {
            log.info("입금 중복 감지 (멱등): depositId={}, txHash={}", existing.getId(), dto.getTxHash());
            // 상태 업데이트 (DETECTED → CONFIRMED)
            if (existing.getStatus() == DepositStatus.DETECTED) {
                Deposit updated = existing.toBuilder()
                        .status(DepositStatus.CONFIRMED)
                        .confirmedAt(LocalDateTime.now())
                        .build();
                depositRepository.modify(updated);
                log.info("입금 확인: depositId={}, amount={}", existing.getId(), existing.getAmount());
            }
            return;
        }

        // 신규 입금 생성 (CONFIRMED 상태로 바로 생성 — webhook은 confirmed TX만 수신)
        Deposit deposit = Deposit.builder()
                .networkId(network.getId())
                .txHash(dto.getTxHash())
                .fromAddress(dto.getFrom())
                .toAddress(dto.getTo())
                .amount(new BigDecimal(dto.getAmount()))
                .tokenSymbol(dto.getTokenSymbol())
                .tokenContract(dto.getTokenContract())
                .blockNumber(dto.getBlockNumber())
                .status(DepositStatus.CONFIRMED)
                .confirmedAt(LocalDateTime.now())
                .build();
        depositRepository.save(deposit);
        log.info("신규 입금 생성: txHash={}, to={}, amount={}", dto.getTxHash(), dto.getTo(), dto.getAmount());
    }

    /**
     * 출금 확인: BROADCASTING 상태 withdrawal을 CONFIRMED로 업데이트.
     */
    private void confirmWithdrawal(TransactionWebhookDTO dto) {
        List<Withdrawal> withdrawals = withdrawalRepository.findByTxHash(dto.getTxHash());
        if (withdrawals.isEmpty()) {
            log.warn("출금 매칭 실패: txHash={}", dto.getTxHash());
            return;
        }

        for (Withdrawal w : withdrawals) {
            if (w.getStatus() == WithdrawalStatus.BROADCASTING
                    || w.getStatus() == WithdrawalStatus.PROCESSING) {
                Withdrawal updated = w.toBuilder()
                        .status(WithdrawalStatus.CONFIRMED)
                        .confirmedAt(LocalDateTime.now())
                        .build();
                withdrawalRepository.modify(updated);
                log.info("출금 확인: withdrawalId={}, txHash={}", w.getId(), dto.getTxHash());
            }
        }
    }

    /**
     * 집금 확인: collection_queue 상태 업데이트.
     */
    private void confirmCollection(TransactionWebhookDTO dto) {
        CollectionQueue queue = collectionQueueRepository.findByTxHash(dto.getTxHash());
        if (queue == null) {
            log.warn("집금 매칭 실패: txHash={}", dto.getTxHash());
            return;
        }

        if (queue.getStatus() == CollectionStatus.BROADCASTING
                || queue.getStatus() == CollectionStatus.PROCESSING) {
            CollectionQueue updated = queue.toBuilder()
                    .status(CollectionStatus.CONFIRMED)
                    .completedAt(LocalDateTime.now())
                    .build();
            collectionQueueRepository.modify(updated);
            log.info("집금 확인: queueId={}, txHash={}", queue.getId(), dto.getTxHash());
        }
    }

    /**
     * TX 실패 처리: 관련 deposit/withdrawal/collection 상태 FAILED 업데이트.
     */
    private void handleFailedTx(TransactionWebhookDTO dto) {
        log.error("TX 실패 감지: txHash={}, from={}, to={}", dto.getTxHash(), dto.getFrom(), dto.getTo());

        // 출금 실패
        List<Withdrawal> withdrawals = withdrawalRepository.findByTxHash(dto.getTxHash());
        for (Withdrawal w : withdrawals) {
            Withdrawal updated = w.toBuilder()
                    .status(WithdrawalStatus.FAILED)
                    .build();
            withdrawalRepository.modify(updated);
            log.warn("출금 실패 처리: withdrawalId={}", w.getId());
        }

        // 집금 실패
        CollectionQueue queue = collectionQueueRepository.findByTxHash(dto.getTxHash());
        if (queue != null) {
            CollectionQueue updated = queue.toBuilder()
                    .status(CollectionStatus.FAILED)
                    .build();
            collectionQueueRepository.modify(updated);
            log.warn("집금 실패 처리: queueId={}", queue.getId());
        }
    }
}
```

#### 주의사항

- `Deposit`, `Withdrawal`, `CollectionQueue` Entity에 필요한 필드 (`txHash`, `status`, `confirmedAt` 등)가 있는지 확인
- `DepositRepository.findByTxHash()`, `CollectionQueueRepository.findByTxHash()` 메서드 존재 여부 확인
- `DepositStatus`, `WithdrawalStatus`, `CollectionStatus` enum에 `CONFIRMED`, `FAILED` 등 값 존재 확인
- `Deposit.builder()`에 필요한 필드가 `TransactionWebhookDTO`에서 매핑 가능한지 확인
- **Entity에 없는 필드가 있으면 TODO 주석으로 남기고**, 분기 로직 자체는 동작하도록 구현

---

## 🟡 P1 — Phase 1~2 테스트에 필요

### P1-1. NonceTrackerService — 온체인 논스 조회

**파일**: `admin-api/src/main/java/com/cryptoments/adminapi/service/NonceTrackerService.java:45,53`

현재: `lastConfirmedNonce + 1`로 단순 계산. 실제 온체인 논스와 불일치 시 TX 실패.

**구현**: `blockchainApiClient.getNativeBalance()` 대신 논스 조회 API 필요.
또는 `blockchain-api`에 `GET /api/wallet/nonce/{address}?networkId=N` 엔드포인트 추가.

**Phase 0 영향**: 없음 (논스 동기화는 admin 관리 기능).
**Phase 2 영향**: Relayer TX 실패 시 논스 복구에 필요.

### P1-2. WithdrawalController — 출금 수수료 설정

**파일**: `open-api/src/main/java/com/cryptoments/openapi/controller/WithdrawalController.java:90`

현재: 기본값 `fee=1.0`, `minAmount=10.0` 하드코딩.

**⚠️ 지침서 오류 정정**: 이전 가이드에서 `policy.getWithdrawalFee()`를 안내했으나,
`partner_withdrawal_policies` 테이블에는 수수료 필드가 **존재하지 않음** (DDL 확인).

출금 수수료는 **`partners` 테이블**의 `withdrawal_fee_fixed` 컬럼에 있음:
```sql
-- partners 테이블
withdrawal_fee_fixed DECIMAL(36,18) DEFAULT 0 COMMENT '출금 고정 수수료'
```

**구현**: `partners` 테이블에서 파트너별 고정 수수료 조회.
```java
Partner partner = partnerRepository.findOne(partnerId);
String fee = partner != null && partner.getWithdrawalFeeFixed() != null
        ? partner.getWithdrawalFeeFixed().toPlainString() : "1.0";
```

### P1-3. WithdrawalController — 일일 출금 사용량

**파일**: `open-api/src/main/java/com/cryptoments/openapi/controller/WithdrawalController.java:120`

현재: `dailyUsed = BigDecimal.ZERO` 하드코딩.

**구현**: WithdrawalMapper에 오늘 출금 합계 쿼리 추가.
```java
// WithdrawalMapper (또는 WithdrawalRepository에 커스텀 쿼리)
@Select("SELECT COALESCE(SUM(amount), 0) FROM withdrawals WHERE partner_id = #{partnerId} AND DATE(created_at) = CURDATE() AND status NOT IN ('CANCELLED', 'FAILED')")
BigDecimal sumTodayWithdrawalAmount(@Param("partnerId") Long partnerId);
```

---

## 🟢 P2 — 운영/부가 기능 (Phase 0 테스트 무관)

| # | 파일 | TODO | 설명 |
|---|------|------|------|
| 1 | `GasInvoiceService.java:63` | 파트너별 가스비 집계 → 인보이스 생성 | 정산 기능 |
| 2 | `AximController.java:156` | AximPayment 조회 | Axim Pay 연동 |
| 3 | `AximController.java:171` | AximPayment 취소 | Axim Pay 연동 |
| 4 | `PartnerSubMgmtService.java:54` | 파트너 코드 자동 생성 + API 키 발급 | 파트너 관리 |
| 5 | `PartnerSettlementController.java:115` | 정산 잔액 확인 + 출금 생성 | 정산 기능 |
| 6 | `PartnerDepositController.java:197` | PaymentLink 생성 | 결제 링크 기능 |
| 7 | `PartnerDepositController.java:323` | Axim Pay 외부 API 연동 | Axim Pay 연동 |
| 8 | `PartnerIntegrationController.java:154` | Telegram Bot 메시지 전송 | 알림 기능 |

이 항목들은 Phase 0 E2E 테스트에 영향 없음. 각 기능 구현 시 개별 지침서 생성.

---

## 구현 순서

```
Phase 0-3 작업 적용 (GAS/Relayer API) ← 지금 진행 중
  ↓
P0-1. WebhookProcessingService 비즈니스 분기 ← Phase 0 E2E 전 필수
  ↓
Phase 0-3~0-6 테스트 진행
  ↓
P1-1~P1-3. 논스/수수료/한도 ← Phase 1~2 테스트 전
  ↓
P2. 운영 기능 ← Phase 4 이후
```
