# Guide #52 — 파트너 지갑 관리 기능 구현 지침서

> **작성일**: 2026-03-25
> **범위**: partner-api 백엔드 8개 EP + partner-ui 프론트엔드 3개 화면
> **선행 조건**: Guide #50 백엔드 적용 완료, Guide #51 DB 수정 완료
> **예상 소요**: 백엔드 2~3시간, 프론트엔드 2~3시간

---

## 1. 개요

### 현재 상태
- PartnerWalletController: `POST /hot`, `POST /master` 2개만 존재
- UI: 지갑 관련 화면 0개, 사이드바에 메뉴 없음
- BalancesView: 통화별 합산 잔액만 표시 (어떤 지갑인지 모름)

### 목표
- **지갑 목록** — 내 지갑 전체 조회 (HOT/MASTER/POOL), 네트워크/타입 필터
- **지갑 생성** — HOT, MASTER, POOL 지갑 생성
- **지갑 상세** — 기본정보 + 통화별 잔액 + 입출금 내역 + Approve 현황 + 인라인 출금 요청

### 결정 사항
| 항목 | 결정 |
|------|------|
| POOL 지갑 생성 | 파트너도 생성 가능 |
| 입출금 내역 범위 | TO 주소 매칭(입금) + FROM 주소 매칭(출금) |
| 출금 요청 | 지갑 상세에서 바로 가능 (출금 정책 적용) |
| 사이드바 위치 | 별도 "지갑 관리" 그룹 |

---

## 2. 백엔드 API 설계

### 2-1. 신규/수정 엔드포인트 (8개)

| # | Method | Path | 설명 | 신규/수정 |
|---|--------|------|------|----------|
| 1 | GET | `/api/partner/wallets` | 내 지갑 목록 (페이지네이션) | **신규** |
| 2 | GET | `/api/partner/wallets/{id}` | 지갑 상세 정보 | **신규** |
| 3 | GET | `/api/partner/wallets/{id}/balances` | 지갑별 통화 잔액 | **신규** |
| 4 | GET | `/api/partner/wallets/{id}/deposits` | 지갑 관련 입금 내역 | **신규** |
| 5 | GET | `/api/partner/wallets/{id}/withdrawals` | 지갑 관련 출금 내역 | **신규** |
| 6 | GET | `/api/partner/wallets/{id}/approvals` | Approve 현황 | **신규** |
| 7 | POST | `/api/partner/wallets/pool` | POOL 지갑 생성 | **신규** |
| 8 | POST | `/api/partner/wallets/hot` | HOT 지갑 생성 | 기존 유지 |
| 9 | POST | `/api/partner/wallets/master` | MASTER 지갑 생성 | 기존 유지 |

---

### 2-2. DTO 설계

#### PartnerWalletResponse (지갑 목록/상세 공용)

```java
package com.cryptoments.partnerapi.dto.response;

/**
 * 파트너 지갑 정보 응답
 */
@Getter @Setter @Builder
@NoArgsConstructor @AllArgsConstructor
public class PartnerWalletResponse {
    /** 지갑 ID */
    private Long id;
    /** 블록체인 주소 */
    private String address;
    /** 네트워크 ID */
    private Long networkId;
    /** 네트워크 이름 (JOIN) — 예: "BSC" */
    private String networkName;
    /** 네트워크 체인 심볼 (JOIN) — 예: "BSC" */
    private String chainSymbol;
    /** 지갑 타입 — HOT, MASTER, POOL */
    private String walletType;
    /** 파생 경로 — 예: "m/44'/60'/1'/0/1" */
    private String derivationPath;
    /** 파생 인덱스 */
    private Long derivationIndex;
    /** 파트너 유저 ID (HOT 전용) */
    private String partnerUserId;
    /** 지갑 상태 — ACTIVE, INACTIVE */
    private String status;
    /** 모니터 등록 여부 */
    private Boolean monitorRegistered;
    /** 생성일 */
    private LocalDateTime createdAt;
}
```

#### PartnerWalletBalanceResponse (통화별 잔액)

```java
/**
 * 지갑 통화별 잔액 응답
 */
@Getter @Setter @Builder
@NoArgsConstructor @AllArgsConstructor
public class PartnerWalletBalanceResponse {
    /** wallet_balances.id */
    private Long id;
    /** 통화 ID */
    private Long currencyId;
    /** 통화 코드 (JOIN) — 예: "USDT" */
    private String currencyCode;
    /** 토큰 심볼 (JOIN) — 예: "USDT" */
    private String currencySymbol;
    /** 네이티브 여부 */
    private Boolean isNative;
    /** 잔액 */
    private BigDecimal balance;
}
```

#### PartnerWalletDepositResponse (지갑 입금 내역)

```java
/**
 * 지갑 관련 입금 내역 응답
 */
@Getter @Setter @Builder
@NoArgsConstructor @AllArgsConstructor
public class PartnerWalletDepositResponse {
    /** 입금 ID */
    private Long id;
    /** 입금 코드 */
    private String depositCode;
    /** 통화 코드 (JOIN) */
    private String currencyCode;
    /** 네트워크 이름 (JOIN) */
    private String networkName;
    /** 입금 금액 */
    private BigDecimal amount;
    /** 수수료 */
    private BigDecimal feeAmount;
    /** 순 입금액 */
    private BigDecimal netAmount;
    /** TX 해시 */
    private String txHash;
    /** 상태 */
    private String status;
    /** FROM 주소 */
    private String fromAddress;
    /** 확인일 */
    private LocalDateTime confirmedAt;
    /** 생성일 */
    private LocalDateTime createdAt;
}
```

#### PartnerWalletWithdrawalResponse (지갑 출금 내역)

```java
/**
 * 지갑 관련 출금 내역 응답
 */
@Getter @Setter @Builder
@NoArgsConstructor @AllArgsConstructor
public class PartnerWalletWithdrawalResponse {
    /** 출금 ID */
    private Long id;
    /** 출금 코드 */
    private String withdrawalCode;
    /** 통화 코드 (JOIN) */
    private String currencyCode;
    /** 네트워크 이름 (JOIN) */
    private String networkName;
    /** 출금 금액 */
    private BigDecimal amount;
    /** TX 해시 */
    private String txHash;
    /** 상태 */
    private String status;
    /** 수신 주소 */
    private String toAddress;
    /** 출금 타입 */
    private String withdrawalType;
    /** 생성일 */
    private LocalDateTime createdAt;
}
```

#### PartnerWalletApprovalResponse (Approve 현황)

```java
/**
 * 지갑 Approve 현황 응답
 */
@Getter @Setter @Builder
@NoArgsConstructor @AllArgsConstructor
public class PartnerWalletApprovalResponse {
    /** Approval ID */
    private Long id;
    /** 통화 코드 (JOIN) */
    private String currencyCode;
    /** Spender 주소 (Relayer) */
    private String spenderAddress;
    /** 상태 — PENDING, APPROVED, FAILED 등 */
    private String status;
    /** Approve TX 해시 */
    private String approveTxHash;
    /** 승인일 */
    private LocalDateTime approvedAt;
}
```

#### PoolWalletCreateRequest (POOL 생성 요청)

```java
/**
 * POOL 지갑 생성 요청
 */
@Getter @Setter
public class PoolWalletCreateRequest {
    /** 네트워크 ID (필수) */
    @NotNull(message = "네트워크 ID는 필수입니다.")
    private Long networkId;
}
```

---

### 2-3. Mapper 설계

#### PartnerWalletMapper.java (신규)

```java
package com.cryptoments.partnerapi.mapper;

@Mapper
public interface PartnerWalletMapper {

    /**
     * 파트너 지갑 목록 (JOIN blockchain_networks)
     * - walletType 필터: HOT, MASTER, POOL 만 허용
     * - networkId 필터: 선택적
     */
    @Select("""
        <script>
        SELECT wa.id, wa.address, wa.network_id, bn.name AS network_name,
               bn.chain_symbol, wa.wallet_type, wa.derivation_path,
               wa.derivation_index, wa.partner_user_id, wa.status,
               wa.monitor_registered, wa.created_at
        FROM wallet_addresses wa
        INNER JOIN blockchain_networks bn ON wa.network_id = bn.id
        <where>
            wa.partner_id = #{partnerId}
            AND wa.wallet_type IN ('HOT', 'MASTER', 'POOL')
            <if test="walletType != null"> AND wa.wallet_type = #{walletType}</if>
            <if test="networkId != null"> AND wa.network_id = #{networkId}</if>
            <if test="search != null and search != ''">
                AND (wa.address LIKE CONCAT('%', #{search}, '%')
                     OR wa.partner_user_id LIKE CONCAT('%', #{search}, '%'))
            </if>
        </where>
        </script>
        """)
    XPage<PartnerWalletResponse> searchWallets(
            XPagination pagination,
            @Param("partnerId") Long partnerId,
            @Param("walletType") String walletType,
            @Param("networkId") Long networkId,
            @Param("search") String search,
            Class<?> cls);

    /**
     * 지갑 상세 (단건, 소유권 검증 포함)
     */
    @Select("""
        SELECT wa.id, wa.address, wa.network_id, bn.name AS network_name,
               bn.chain_symbol, wa.wallet_type, wa.derivation_path,
               wa.derivation_index, wa.partner_user_id, wa.status,
               wa.monitor_registered, wa.created_at
        FROM wallet_addresses wa
        INNER JOIN blockchain_networks bn ON wa.network_id = bn.id
        WHERE wa.id = #{walletId} AND wa.partner_id = #{partnerId}
        """)
    PartnerWalletResponse findWalletDetail(
            @Param("walletId") Long walletId,
            @Param("partnerId") Long partnerId);

    /**
     * 지갑 통화별 잔액 (JOIN currencies)
     */
    @Select("""
        SELECT wb.id, wb.currency_id, c.currency_code, c.currency_symbol,
               c.is_native, wb.balance
        FROM wallet_balances wb
        INNER JOIN currencies c ON wb.currency_id = c.id
        WHERE wb.wallet_address_id = #{walletAddressId}
        ORDER BY c.is_native ASC, c.currency_code ASC
        """)
    List<PartnerWalletBalanceResponse> findWalletBalances(
            @Param("walletAddressId") Long walletAddressId);

    /**
     * 지갑 관련 입금 내역 (TO 주소 매칭)
     * wallet_address_id 매칭 사용 (wallet_addresses.address = deposits.wallet_address_id를 통해)
     */
    @Select("""
        <script>
        SELECT d.id, d.deposit_code, c.currency_code, bn.name AS network_name,
               d.amount, d.fee_amount, d.net_amount, d.tx_hash,
               d.status, d.from_address, d.confirmed_at, d.created_at
        FROM deposits d
        INNER JOIN currencies c ON d.currency_id = c.id
        INNER JOIN blockchain_networks bn ON d.network_id = bn.id
        WHERE d.wallet_address_id = #{walletAddressId}
          AND d.partner_id = #{partnerId}
        </script>
        """)
    XPage<PartnerWalletDepositResponse> findWalletDeposits(
            XPagination pagination,
            @Param("walletAddressId") Long walletAddressId,
            @Param("partnerId") Long partnerId,
            Class<?> cls);

    /**
     * 지갑 관련 출금 내역 (같은 네트워크의 파트너 출금)
     * ⚠️ withdrawals에 source_wallet_address_id 없음 → network_id 간접 매칭
     * MASTER 지갑에서만 호출 (HOT/POOL은 출금 주체가 아님)
     */
    @Select("""
        <script>
        SELECT w.id, w.withdrawal_code, c.currency_code, bn.name AS network_name,
               w.amount, w.tx_hash, w.status, w.to_address,
               w.withdrawal_type, w.created_at
        FROM withdrawals w
        INNER JOIN currencies c ON w.currency_id = c.id
        INNER JOIN blockchain_networks bn ON w.network_id = bn.id
        WHERE w.partner_id = #{partnerId}
          AND w.network_id = #{networkId}
        </script>
        """)
    XPage<PartnerWalletWithdrawalResponse> findWalletWithdrawals(
            XPagination pagination,
            @Param("walletAddressId") Long walletAddressId,
            @Param("partnerId") Long partnerId,
            Class<?> cls);

    /**
     * Approve 현황 (JOIN currencies)
     */
    @Select("""
        SELECT wap.id, c.currency_code, wap.spender_address,
               wap.status, wap.approve_tx_hash, wap.approved_at
        FROM wallet_approvals wap
        INNER JOIN currencies c ON wap.currency_id = c.id
        WHERE wap.wallet_address_id = #{walletAddressId}
        ORDER BY c.currency_code ASC
        """)
    List<PartnerWalletApprovalResponse> findWalletApprovals(
            @Param("walletAddressId") Long walletAddressId);
}
```

---

### 2-4. Service 설계

#### PartnerWalletService.java (확장)

기존 `createHotWallet()`, `createMasterWallet()`에 추가:

```java
/**
 * 파트너 지갑 관리 서비스
 */
@Service
@RequiredArgsConstructor
public class PartnerWalletService {

    private final WalletService walletService;           // core
    private final WalletAddressRepository walletAddressRepository;
    private final PartnerWalletMapper partnerWalletMapper;

    // === 기존 메서드 유지 ===
    public WalletCreateResponse createHotWallet(Long partnerId, WalletCreateRequest request) { ... }
    public WalletCreateResponse createMasterWallet(Long partnerId, WalletCreateRequest request) { ... }

    // === 신규 메서드 ===

    /**
     * 지갑 목록 조회 (페이지네이션)
     */
    public XPage<PartnerWalletResponse> getWallets(
            Long partnerId, XPagination pagination,
            String walletType, Long networkId, String search) {
        return partnerWalletMapper.searchWallets(
                pagination, partnerId, walletType, networkId, search,
                PartnerWalletResponse.class);
    }

    /**
     * 지갑 상세 조회 (소유권 검증)
     */
    public PartnerWalletResponse getWalletDetail(Long partnerId, Long walletId) {
        PartnerWalletResponse wallet = partnerWalletMapper.findWalletDetail(walletId, partnerId);
        if (wallet == null) {
            throw new NotFoundException(ErrorCodes.WALLET_NOT_FOUND.code(), "지갑을 찾을 수 없습니다.");
        }
        return wallet;
    }

    /**
     * 지갑 통화별 잔액
     */
    public List<PartnerWalletBalanceResponse> getWalletBalances(Long partnerId, Long walletId) {
        // 소유권 검증
        getWalletDetail(partnerId, walletId);
        return partnerWalletMapper.findWalletBalances(walletId);
    }

    /**
     * 지갑 입금 내역
     */
    public XPage<PartnerWalletDepositResponse> getWalletDeposits(
            Long partnerId, Long walletId, XPagination pagination) {
        getWalletDetail(partnerId, walletId);
        return partnerWalletMapper.findWalletDeposits(
                pagination, walletId, partnerId, PartnerWalletDepositResponse.class);
    }

    /**
     * 지갑 출금 내역 (MASTER 지갑만 — 같은 네트워크의 파트너 출금)
     * HOT/POOL 지갑은 출금 주체가 아니므로 빈 결과 반환
     */
    public XPage<PartnerWalletWithdrawalResponse> getWalletWithdrawals(
            Long partnerId, Long walletId, XPagination pagination) {
        PartnerWalletResponse wallet = getWalletDetail(partnerId, walletId);

        // MASTER 지갑이 아니면 빈 페이지 반환
        if (!"MASTER".equals(wallet.getWalletType())) {
            return new XPage<>();  // empty
        }

        return partnerWalletMapper.findWalletWithdrawals(
                pagination, partnerId, wallet.getNetworkId(),
                PartnerWalletWithdrawalResponse.class);
    }

    /**
     * Approve 현황
     */
    public List<PartnerWalletApprovalResponse> getWalletApprovals(Long partnerId, Long walletId) {
        getWalletDetail(partnerId, walletId);
        return partnerWalletMapper.findWalletApprovals(walletId);
    }

    /**
     * POOL 지갑 생성
     * - core WalletService에 createPoolWallet() 메서드 필요 (아래 §2-5 참조)
     */
    public WalletCreateResponse createPoolWallet(Long partnerId, PoolWalletCreateRequest request) {
        WalletAddress wallet = walletService.createPoolWallet(partnerId, request.getNetworkId());
        return WalletCreateResponse.builder()
                .walletAddressId(wallet.getId())
                .address(wallet.getAddress())
                .networkId(wallet.getNetworkId())
                .walletType(wallet.getWalletType().name())
                .derivationPath(wallet.getDerivationPath())
                .build();
    }
}
```

---

### 2-5. Core WalletService 확장 — createPoolWallet()

```java
/**
 * POOL 지갑 생성 (소수점 매칭용)
 * - HOT과 동일한 HD 파생, walletType = POOL
 * - Approve 불필요 (POOL → 직접 전송 아님, Relayer가 POOL에서 집금)
 *   → 단, 집금 대상이면 approve 필요 → registerTokenApprovals() 호출
 */
public WalletAddress createPoolWallet(Long partnerId, Long networkId) {
    // 1. HD 파생
    WalletDeriveResponse derived = deriveWallet(networkId, "POOL", partnerId, null);

    // 2. Entity 저장
    WalletAddress wallet = WalletAddress.builder()
            .address(derived.getAddress())
            .networkId(networkId)
            .hdWalletId(derived.getHdWalletId())
            .derivationIndex(derived.getIndex())
            .derivationPath(derived.getDerivationPath())
            .walletType(WalletType.POOL)
            .partnerId(partnerId)
            .status(WalletAddressStatus.ACTIVE)
            .monitorRegistered(false)
            .build();
    walletAddressRepository.save(wallet);

    // 3. 잔액 캐시 초기화
    initWalletBalances(wallet.getId(), networkId);

    // 4. Approve 등록 (Relayer가 POOL에서 MASTER로 집금)
    registerTokenApprovals(wallet.getId(), networkId);

    return wallet;
}
```

**⚠️ WalletType enum에 POOL이 이미 있는지 확인 필요.** 없으면 추가:

```java
// common/src/main/java/com/cryptoments/common/enums/WalletType.java
public enum WalletType {
    HOT, MASTER, GAS, SETTLEMENT, POOL, ADMIN, RELAYER
}
```

---

### 2-6. Controller 확장

#### PartnerWalletController.java (수정)

```java
@RestController
@RequestMapping("/api/partner/wallets")
public class PartnerWalletController extends XSessionController<PartnerSessionData> {

    private final PartnerWalletService partnerWalletService;

    // === 기존 ===

    @PostMapping(name = "HOT 지갑 생성", value = "/hot")
    public WalletCreateResponse createHotWallet(@RequestBody WalletCreateRequest request) { ... }

    @PostMapping(name = "MASTER 지갑 생성", value = "/master")
    public WalletCreateResponse createMasterWallet(@RequestBody WalletCreateRequest request) { ... }

    // === 신규 ===

    /**
     * 내 지갑 목록 조회.
     * @param walletType HOT, MASTER, POOL (선택)
     * @param networkId 네트워크 ID (선택)
     * @param search 주소/유저ID 검색 (선택)
     */
    @GetMapping(name = "지갑 목록 조회")
    public XPage<PartnerWalletResponse> getWallets(
            @XPaginationDefault(column = "created_at", direction = "DESC") XPagination pagination,
            @RequestParam(required = false) String walletType,
            @RequestParam(required = false) Long networkId,
            @RequestParam(required = false) String search) {
        return partnerWalletService.getWallets(
                getSession().getPartnerId(), pagination, walletType, networkId, search);
    }

    /**
     * 지갑 상세 정보.
     */
    @GetMapping(name = "지갑 상세 조회", value = "/{id}")
    public PartnerWalletResponse getWalletDetail(@PathVariable Long id) {
        return partnerWalletService.getWalletDetail(getSession().getPartnerId(), id);
    }

    /**
     * 지갑 통화별 잔액.
     */
    @GetMapping(name = "지갑 잔액 조회", value = "/{id}/balances")
    public List<PartnerWalletBalanceResponse> getWalletBalances(@PathVariable Long id) {
        return partnerWalletService.getWalletBalances(getSession().getPartnerId(), id);
    }

    /**
     * 지갑 관련 입금 내역 (TO 주소 매칭).
     */
    @GetMapping(name = "지갑 입금 내역", value = "/{id}/deposits")
    public XPage<PartnerWalletDepositResponse> getWalletDeposits(
            @PathVariable Long id,
            @XPaginationDefault(column = "created_at", direction = "DESC") XPagination pagination) {
        return partnerWalletService.getWalletDeposits(getSession().getPartnerId(), id, pagination);
    }

    /**
     * 지갑 관련 출금 내역 (FROM 주소 매칭).
     */
    @GetMapping(name = "지갑 출금 내역", value = "/{id}/withdrawals")
    public XPage<PartnerWalletWithdrawalResponse> getWalletWithdrawals(
            @PathVariable Long id,
            @XPaginationDefault(column = "created_at", direction = "DESC") XPagination pagination) {
        return partnerWalletService.getWalletWithdrawals(getSession().getPartnerId(), id, pagination);
    }

    /**
     * 지갑 Approve 현황.
     */
    @GetMapping(name = "지갑 Approve 현황", value = "/{id}/approvals")
    public List<PartnerWalletApprovalResponse> getWalletApprovals(@PathVariable Long id) {
        return partnerWalletService.getWalletApprovals(getSession().getPartnerId(), id);
    }

    /**
     * POOL 지갑 생성 (소수점 매칭용).
     */
    @PostMapping(name = "POOL 지갑 생성", value = "/pool")
    public WalletCreateResponse createPoolWallet(@Valid @RequestBody PoolWalletCreateRequest request) {
        return partnerWalletService.createPoolWallet(getSession().getPartnerId(), request);
    }
}
```

---

## 3. 프론트엔드 UI 설계

### 3-1. 사이드바 메뉴 추가

**파일**: `src/utils/constants.ts`

"잔액/원장" 아래, "정산" 위에 추가:

```typescript
import { Wallet2 } from 'lucide-vue-next'  // 새 아이콘

// MENU_ITEMS 배열에 "잔액/원장" 다음에 추가:
{
  label: '지갑 관리', icon: Wallet2,
  children: [
    { label: '지갑 목록', path: '/partner/wallets' },
    { label: 'HOT 지갑 생성', path: '/partner/wallets/new/hot' },
    { label: 'MASTER 지갑 생성', path: '/partner/wallets/new/master' },
    { label: 'POOL 지갑 생성', path: '/partner/wallets/new/pool' },
  ],
},
```

### 3-2. 라우터 추가

**파일**: `src/router/index.ts`

children 배열에 추가:

```typescript
// 지갑 관리
{ path: 'wallets', name: 'partner-wallets', component: () => import('@/views/partner/wallets/WalletListView.vue'), meta: { title: '지갑 목록' } },
{ path: 'wallets/new/:type', name: 'partner-wallet-new', component: () => import('@/views/partner/wallets/WalletNewView.vue'), meta: { title: '지갑 생성' } },
{ path: 'wallets/:id', name: 'partner-wallet-detail', component: () => import('@/views/partner/wallets/WalletDetailView.vue'), meta: { title: '지갑 상세' } },
```

### 3-3. API 타입 정의

**파일**: `src/api/types/wallet.ts` (신규)

```typescript
/** 지갑 정보 */
export interface PartnerWallet {
  id: number
  address: string
  networkId: number
  /** JOIN된 네트워크 이름 */
  networkName: string
  /** 네트워크 체인 심볼 */
  chainSymbol: string
  walletType: 'HOT' | 'MASTER' | 'POOL'
  derivationPath?: string
  derivationIndex?: number
  partnerUserId?: string
  status: 'ACTIVE' | 'INACTIVE'
  monitorRegistered: boolean
  createdAt: string
}

/** 지갑 통화별 잔액 */
export interface WalletBalance {
  id: number
  currencyId: number
  currencyCode: string
  currencySymbol: string
  isNative: boolean
  balance: number
}

/** 지갑 입금 내역 */
export interface WalletDeposit {
  id: number
  depositCode: string
  currencyCode: string
  networkName: string
  amount: number
  feeAmount: number
  netAmount: number
  txHash: string
  status: string
  fromAddress: string
  confirmedAt?: string
  createdAt: string
}

/** 지갑 출금 내역 */
export interface WalletWithdrawal {
  id: number
  withdrawalCode: string
  currencyCode: string
  networkName: string
  amount: number
  txHash?: string
  status: string
  toAddress: string
  withdrawalType: string
  createdAt: string
}

/** 지갑 Approve 현황 */
export interface WalletApproval {
  id: number
  currencyCode: string
  spenderAddress: string
  status: string
  approveTxHash?: string
  approvedAt?: string
}

/** 지갑 생성 요청 */
export interface WalletCreateRequest {
  networkId: number
  partnerUserId?: string  // HOT 전용
}

/** 지갑 생성 응답 */
export interface WalletCreateResponse {
  walletAddressId: number
  address: string
  networkId: number
  walletType: string
  derivationPath: string
}
```

### 3-4. API 서비스

**파일**: `src/api/services/wallet.service.ts` (신규)

```typescript
import { api } from '@/api/client'
import type { XPage } from '@/api/types/common'
import type {
  PartnerWallet, WalletBalance, WalletDeposit, WalletWithdrawal,
  WalletApproval, WalletCreateRequest, WalletCreateResponse
} from '@/api/types/wallet'

const BASE = '/api/partner/wallets'

export const walletService = {
  /** 지갑 목록 */
  getWallets(params?: {
    page?: number; size?: number;
    walletType?: string; networkId?: number; search?: string
  }) {
    return api.get<XPage<PartnerWallet>>(BASE, { params })
  },

  /** 지갑 상세 */
  getWalletDetail(id: number) {
    return api.get<PartnerWallet>(`${BASE}/${id}`)
  },

  /** 지갑 잔액 */
  getWalletBalances(id: number) {
    return api.get<WalletBalance[]>(`${BASE}/${id}/balances`)
  },

  /** 지갑 입금 내역 */
  getWalletDeposits(id: number, params?: { page?: number; size?: number }) {
    return api.get<XPage<WalletDeposit>>(`${BASE}/${id}/deposits`, { params })
  },

  /** 지갑 출금 내역 */
  getWalletWithdrawals(id: number, params?: { page?: number; size?: number }) {
    return api.get<XPage<WalletWithdrawal>>(`${BASE}/${id}/withdrawals`, { params })
  },

  /** Approve 현황 */
  getWalletApprovals(id: number) {
    return api.get<WalletApproval[]>(`${BASE}/${id}/approvals`)
  },

  /** HOT 지갑 생성 */
  createHotWallet(data: WalletCreateRequest) {
    return api.post<WalletCreateResponse>(`${BASE}/hot`, data)
  },

  /** MASTER 지갑 생성 */
  createMasterWallet(data: { networkId: number }) {
    return api.post<WalletCreateResponse>(`${BASE}/master`, data)
  },

  /** POOL 지갑 생성 */
  createPoolWallet(data: { networkId: number }) {
    return api.post<WalletCreateResponse>(`${BASE}/pool`, data)
  },
}
```

### 3-5. 화면 설계 (3개)

---

#### 화면 1: WalletListView.vue — 지갑 목록

**레이아웃**:
```
┌─────────────────────────────────────────────────────┐
│  PageHeader: 지갑 관리                               │
│  ┌──────────┬──────────┬────────────────┬─────────┐ │
│  │ 타입 필터 │ 네트워크  │  주소/유저 검색   │  조회  │ │
│  │ [전체 ▼] │ [전체 ▼] │  [____________] │  [🔍]  │ │
│  └──────────┴──────────┴────────────────┴─────────┘ │
│                                                      │
│  ┌─────┬──────┬──────────────────┬───────┬────────┐ │
│  │ 타입 │ 네트워크│ 주소              │ 상태  │ 생성일  │ │
│  ├─────┼──────┼──────────────────┼───────┼────────┤ │
│  │ HOT │ BSC  │ 0xSAMPLE...001 → │ACTIVE │ 03-22  │ │
│  │ HOT │ PLG  │ 0xSAMPLE...001 → │ACTIVE │ 03-22  │ │
│  │ MASTER│ BSC │ 0xSAMPLE...001 → │ACTIVE │ 03-22  │ │
│  │ POOL │ TRON│ TSAMPLE...001  → │ACTIVE │ 03-23  │ │
│  └─────┴──────┴──────────────────┴───────┴────────┘ │
│  [◀ 1 2 3 ▶]                                        │
└─────────────────────────────────────────────────────┘
```

**동작**:
- 필터: walletType 셀렉트 (전체/HOT/MASTER/POOL), networkId 셀렉트
- 검색: 주소 또는 partnerUserId 부분 매칭
- 행 클릭 → `/partner/wallets/:id` 상세 페이지로 이동
- 타입 컬럼: StatusBadge (HOT=파랑, MASTER=초록, POOL=보라)
- 주소 컬럼: AddressDisplay (말줄임 + 복사 버튼)

---

#### 화면 2: WalletNewView.vue — 지갑 생성 (통합)

**라우트**: `/partner/wallets/new/:type` (type = hot | master | pool)

**레이아웃**:
```
┌─────────────────────────────────────────────────────┐
│  PageHeader: HOT 지갑 생성 / MASTER 지갑 생성 / POOL 생성│
│                                                      │
│  ┌─────────────────────────────────────────────────┐ │
│  │  네트워크 선택                                    │ │
│  │  ┌──────┐ ┌──────┐ ┌──────┐                     │ │
│  │  │ BSC  │ │Polygon│ │ TRON │  ← 라디오 또는 셀렉트│ │
│  │  └──────┘ └──────┘ └──────┘                     │ │
│  │                                                  │ │
│  │  (HOT만) 유저 ID: [________________] (선택)      │ │
│  │                                                  │ │
│  │  [생성하기]                                       │ │
│  └─────────────────────────────────────────────────┘ │
│                                                      │
│  ┌─ 생성 결과 (성공 시) ──────────────────────────┐  │
│  │  ✅ 지갑이 생성되었습니다                        │  │
│  │  주소: 0xABC...DEF  [복사]                      │  │
│  │  네트워크: BSC                                   │  │
│  │  파생 경로: m/44'/60'/1'/0/3                     │  │
│  │  [지갑 상세 보기] [목록으로]                      │  │
│  └───────────────────────────────────────────────┘  │
└─────────────────────────────────────────────────────┘
```

**동작**:
- URL의 `:type` 파라미터로 지갑 타입 결정
- 네트워크 목록은 `masterService.getNetworks()`에서 가져옴
- HOT 타입일 때만 유저 ID 입력 필드 표시
- 생성 성공 시 결과 카드 표시 + "지갑 상세 보기" 링크

---

#### 화면 3: WalletDetailView.vue — 지갑 상세

**레이아웃**:
```
┌─────────────────────────────────────────────────────┐
│  PageHeader: HOT 지갑 상세 — BSC                     │
│  [← 목록으로]                                        │
│                                                      │
│  ┌─ 기본 정보 카드 ─────────────────────────────────┐│
│  │ 주소      0xSAMPLE...001  [복사] [Explorer ↗]   ││
│  │ 네트워크  BSC                                    ││
│  │ 타입      HOT                                    ││
│  │ 상태      🟢 ACTIVE                              ││
│  │ 파생 경로 m/44'/60'/1'/0/1                       ││
│  │ 유저 ID   user_001 (HOT만)                       ││
│  │ 모니터    ✅ 등록됨                               ││
│  │ 생성일    2026-03-22 10:30                       ││
│  └──────────────────────────────────────────────────┘│
│                                                      │
│  ┌─ 통화별 잔액 ────────────────────────────────────┐│
│  │ ┌──────────┬──────────────────┐                  ││
│  │ │ 통화      │ 잔액              │                  ││
│  │ ├──────────┼──────────────────┤                  ││
│  │ │ USDT     │ 500.000000       │                  ││
│  │ │ USDC     │ 200.000000       │                  ││
│  │ │ BNB (Native)│ 0.050000      │                  ││
│  │ └──────────┴──────────────────┘                  ││
│  └──────────────────────────────────────────────────┘│
│                                                      │
│  ┌─ Approve 현황 ───────────────────────────────────┐│
│  │ ┌──────┬────────────┬──────────┬─────────┐      ││
│  │ │ 통화  │ Spender     │ 상태     │ TX Hash │      ││
│  │ ├──────┼────────────┼──────────┼─────────┤      ││
│  │ │ USDT │ 0xRelay... │ APPROVED │ 0xabc.. │      ││
│  │ │ USDC │ 0xRelay... │ PENDING  │ —       │      ││
│  │ └──────┴────────────┴──────────┴─────────┘      ││
│  └──────────────────────────────────────────────────┘│
│                                                      │
│  ┌─ 탭: [입금 내역] [출금 내역] ────────────────────┐│
│  │                                                  ││
│  │  (입금 탭 선택 시)                                ││
│  │  ┌─────┬──────┬────────┬────────┬──────┬─────┐  ││
│  │  │ 코드 │ 통화  │ 금액    │ 수수료  │ 상태 │날짜 │  ││
│  │  ├─────┼──────┼────────┼────────┼──────┼─────┤  ││
│  │  │ DEP..│ USDT │ 100.00 │ 5.00   │ ✅  │03-22│  ││
│  │  └─────┴──────┴────────┴────────┴──────┴─────┘  ││
│  │  [◀ 1 2 ▶]                                      ││
│  │                                                  ││
│  │  (출금 탭 선택 시)                                ││
│  │  ┌─────┬──────┬────────┬──────────┬──────┬───┐  ││
│  │  │ 코드 │ 통화  │ 금액    │ 수신주소   │ 상태 │날짜│  ││
│  │  └─────┴──────┴────────┴──────────┴──────┴───┘  ││
│  │  [◀ 1 ▶]                                        ││
│  │                                                  ││
│  └──────────────────────────────────────────────────┘│
│                                                      │
│  ┌─ 출금 요청 (인라인) ─────────────────────────────┐│
│  │  통화: [USDT ▼]  금액: [________]                ││
│  │  수신 주소: [____________________________]        ││
│  │  또는 화이트리스트: [Binance BSC Hot ▼]           ││
│  │  [출금 요청]                                      ││
│  │  ※ 출금 정책 적용: 자동승인 한도 100 USDT        ││
│  └──────────────────────────────────────────────────┘│
└─────────────────────────────────────────────────────┘
```

**동작**:
- 페이지 진입 시 `getWalletDetail()` + `getWalletBalances()` + `getWalletApprovals()` 병렬 호출
- 탭 전환: 입금 내역 (`getWalletDeposits()`) / 출금 내역 (`getWalletWithdrawals()`) — 탭 선택 시 lazy 로딩
- 출금 요청: 기존 WithdrawalNewView 로직 재사용하되, 네트워크가 이 지갑의 networkId로 고정
- 화이트리스트 주소 셀렉트: 해당 네트워크의 화이트리스트만 필터
- 출금 정책 표시: `partner_withdrawal_policies` 정보 (기존 출금 요청 API 활용)

---

## 4. DDL 확인 결과 (✅ 실제 DB 확인 완료)

### deposits 테이블 — ✅ wallet_address_id 있음
- `deposits.wallet_address_id` (BIGINT, nullable, indexed) → 입금 TO 지갑 직접 매칭 가능

### withdrawals 테이블 — ⚠️ source_wallet_address_id 없음
- `from_address` 컬럼 **없음**, `source_wallet_address_id`도 **없음**
- 있는 컬럼: `to_address` (수신 주소), `relayer_address_id`
- **출금의 출처는 MASTER 지갑** → partner_id + network_id로 간접 매칭

### WalletType — ⚠️ POOL 미존재
- 현재 DB: ADMIN, GAS, HOT, MASTER, RELAYER (5종)
- **POOL 추가 필요** (Java enum + 실제 사용)

### Mapper 매칭 전략 (확정)

| 관계 | 매칭 방법 |
|------|----------|
| 입금 → 지갑 | `deposits.wallet_address_id = #{walletAddressId}` (직접 FK) |
| 출금 → 지갑 | `withdrawals.partner_id = #{partnerId} AND withdrawals.network_id = wallet.network_id` (간접: 같은 네트워크의 파트너 출금) |

**⚠️ 출금 매칭 주의**: withdrawals에는 "어느 지갑에서 나갔는지" 컬럼이 없으므로, MASTER 지갑의 경우 해당 네트워크의 파트너 출금 전체를 보여줌. HOT/POOL 지갑에서는 출금 탭을 비활성화하거나 "이 지갑에서는 출금 내역이 없습니다" 표시.

---

## 5. 출금 탭 동작 규칙

지갑 타입별로 입출금 탭 표시가 달라져야 함:

| 지갑 타입 | 입금 탭 | 출금 탭 | 출금 요청 |
|----------|---------|---------|----------|
| HOT | ✅ (이 지갑으로 들어온 입금) | ❌ 비활성 | ❌ |
| MASTER | ❌ 비활성 | ✅ (이 네트워크의 파트너 출금 전체) | ✅ 가능 |
| POOL | ✅ (이 지갑으로 들어온 입금) | ❌ 비활성 | ❌ |

**UI 처리**:
- HOT/POOL 지갑 상세 → 출금 탭에 "HOT/POOL 지갑에서는 직접 출금되지 않습니다. 집금 후 MASTER 지갑에서 출금됩니다."
- MASTER 지갑 → 입금 탭에 "입금은 HOT 또는 POOL 지갑으로 수신됩니다."
- 출금 요청 인라인 폼은 MASTER 지갑에서만 표시

---

## 6. 체크리스트

### Phase A: 백엔드 (IntelliJ)

- [ ] A-1. `WalletType` enum에 `POOL` 존재 확인 (없으면 추가)
- [ ] A-2. `deposits` / `withdrawals` DDL에서 `wallet_address_id` / `source_wallet_address_id` 컬럼 확인
- [ ] A-3. DTO 6개 생성 (PartnerWalletResponse, PartnerWalletBalanceResponse, PartnerWalletDepositResponse, PartnerWalletWithdrawalResponse, PartnerWalletApprovalResponse, PoolWalletCreateRequest)
- [ ] A-4. `PartnerWalletMapper.java` 생성 (6개 @Select 메서드)
- [ ] A-5. Core `WalletService.createPoolWallet()` 추가
- [ ] A-6. `PartnerWalletService.java` 확장 (7개 메서드 추가)
- [ ] A-7. `PartnerWalletController.java` 확장 (7개 endpoint 추가)
- [ ] A-8. 컴파일 확인: `./gradlew :partner-api:compileJava`

### Phase B: 프론트엔드 (VS Code)

- [ ] B-1. `src/api/types/wallet.ts` 생성
- [ ] B-2. `src/api/services/wallet.service.ts` 생성
- [ ] B-3. `src/utils/constants.ts` — "지갑 관리" 메뉴 추가
- [ ] B-4. `src/router/index.ts` — 3개 라우트 추가
- [ ] B-5. `src/views/partner/wallets/WalletListView.vue` 생성
- [ ] B-6. `src/views/partner/wallets/WalletNewView.vue` 생성
- [ ] B-7. `src/views/partner/wallets/WalletDetailView.vue` 생성

### Phase C: 검증

- [ ] C-1. `GET /api/partner/wallets` — 목록 조회 + 필터 동작
- [ ] C-2. `GET /api/partner/wallets/100` — Partner 1의 HOT BSC 지갑 상세
- [ ] C-3. `GET /api/partner/wallets/100/balances` — 잔액 목록
- [ ] C-4. `GET /api/partner/wallets/100/deposits` — 입금 내역
- [ ] C-5. `GET /api/partner/wallets/100/withdrawals` — 출금 내역
- [ ] C-6. `GET /api/partner/wallets/100/approvals` — Approve 현황
- [ ] C-7. UI 지갑 목록 → 상세 → 탭 전환 → 출금 요청 흐름
- [ ] C-8. 소유권 검증: Partner 2 토큰으로 Partner 1 지갑 접근 시 404

---

## 7. 기존 코드 참고

| 참고 대상 | 파일 | 활용 |
|----------|------|------|
| admin-api InfraWalletSearchMapper | adminapi/mapper/InfraWalletSearchMapper.java | JOIN 패턴 참고 |
| admin-api WalletSearchMapper | adminapi/mapper/WalletSearchMapper.java | 검색 쿼리 참고 |
| PartnerWalletService | partnerapi/service/PartnerWalletService.java | 기존 코드에 메서드 추가 |
| PartnerWalletController | partnerapi/controller/PartnerWalletController.java | 기존 코드에 endpoint 추가 |
| Core WalletService | core/wallet/WalletService.java | createPoolWallet() 추가 |
| WithdrawalNewView.vue | views/partner/withdrawals/WithdrawalNewView.vue | 출금 요청 폼 로직 재사용 |
