# 파트너 MASTER 지갑 생성 기능 복원 지침서

> **작성일**: 2026-03-29
> **우선순위**: P0 (파트너 온보딩 필수 기능)
> **관련 문서**: `NODEJS_WALLET_INDEX_FIX_GUIDE.md` (Node.js 인덱스 버그 수정 — 선행 작업)

---

## 1. 개요

파트너 상세 페이지에서 MASTER/HOT 지갑을 조회하고, 지갑이 없는 경우 수동으로 생성할 수 있는 기능을 추가한다.

현재 상태:
- 지갑 생성은 `createPartner()` 내부에서만 호출됨 (실패 시 재시도 불가)
- 파트너 상세 페이지에 지갑 관련 UI가 전혀 없음
- 기존 3개 테스트 파트너 모두 지갑 없는 상태 (Node.js 인덱스 버그로 실패)

목표:
- admin-api에 파트너 지갑 조회/생성 엔드포인트 추가
- admin-ui 파트너 상세 페이지에 지갑 탭 추가

---

## 2. 작업 범위

| 영역 | 파일 | 작업 |
|------|------|------|
| Spring (admin-api) | Controller 1, Service 1, DTO 1 | 엔드포인트 2개 추가 |
| Admin UI | service 1, type 1, tab 1, view 1 | 지갑 탭 + API 연동 |
| DB | — | 테스트 데이터 초기화 SQL |

---

## 3. Spring (admin-api) 작업

### 3.1 PartnerManagementController — 엔드포인트 2개 추가

**파일**: `admin-api/src/main/java/com/cryptoments/adminapi/controller/PartnerManagementController.java`

기존 엔드포인트 뒤에 추가:

```java
// ── 파트너 지갑 관리 ──

/**
 * 파트너 지갑 목록 조회.
 * 해당 파트너에 할당된 MASTER/HOT 지갑 주소를 네트워크별로 반환한다.
 *
 * @param id 파트너 ID
 * @return 네트워크별 지갑 목록
 * @response 200 성공
 * @group 파트너 지갑
 * @auth true
 */
@GetMapping(name = "파트너 지갑 목록 조회", value = "/{id}/wallets")
public List<PartnerWalletInfo> getPartnerWallets(@PathVariable Long id) throws NotFoundException {
    return partnerManagementService.getPartnerWallets(id);
}

/**
 * 파트너 지갑 생성 (MASTER + HOT).
 * 파트너의 활성 네트워크별 MASTER/HOT 지갑을 일괄 생성한다.
 * 이미 존재하는 네트워크는 건너뛴다 (멱등성 보장).
 *
 * @param id 파트너 ID
 * @return 네트워크별 생성 결과 (성공/실패)
 * @response 200 성공 (부분 실패 포함 가능)
 * @group 파트너 지갑
 * @auth true
 */
@PostMapping(name = "파트너 지갑 생성", value = "/{id}/wallets")
public List<MasterWalletInfo> createPartnerWallets(@PathVariable Long id) throws NotFoundException {
    return partnerManagementService.createPartnerWallets(id, getSession().getAdminId());
}
```

### 3.2 PartnerWalletInfo DTO 신규 생성

**파일**: `admin-api/src/main/java/com/cryptoments/adminapi/dto/response/PartnerWalletInfo.java`

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

import lombok.*;

/**
 * 파트너 지갑 정보 (조회용).
 */
@Getter
@Setter
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class PartnerWalletInfo {

    /** 블록체인 네트워크 ID */
    private Long networkId;

    /** 체인 심볼 (BSC, POLYGON, TRON) */
    private String chainSymbol;

    /** MASTER 지갑 주소 */
    private String masterAddress;

    /** MASTER wallet_addresses.id */
    private Long masterWalletId;

    /** HOT 지갑 주소 */
    private String hotAddress;

    /** HOT wallet_addresses.id */
    private Long hotWalletId;
}
```

### 3.3 PartnerManagementService — 메서드 2개 추가

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

```java
// ========================================
// 파트너 지갑 관리
// ========================================

/**
 * 파트너 지갑 목록 조회.
 */
public List<PartnerWalletInfo> getPartnerWallets(Long partnerId) {
    Partner partner = getPartner(partnerId);

    List<BlockchainNetwork> activeNetworks = blockchainNetworkRepository.findByIsActive(true);
    List<PartnerWalletInfo> wallets = new ArrayList<>();

    for (BlockchainNetwork network : activeNetworks) {
        Long networkId = network.getId();

        WalletAddress master = walletAddressRepository
                .findByPartnerIdAndNetworkIdAndWalletType(partnerId, networkId, WalletType.MASTER);
        WalletAddress hot = walletAddressRepository
                .findByPartnerIdAndNetworkIdAndWalletType(partnerId, networkId, WalletType.HOT);

        if (master != null || hot != null) {
            wallets.add(PartnerWalletInfo.builder()
                    .networkId(networkId)
                    .chainSymbol(network.getChainSymbol())
                    .masterAddress(master != null ? master.getAddress() : null)
                    .masterWalletId(master != null ? master.getId() : null)
                    .hotAddress(hot != null ? hot.getAddress() : null)
                    .hotWalletId(hot != null ? hot.getId() : null)
                    .build());
        }
    }

    return wallets;
}

/**
 * 파트너 지갑 생성 (MASTER + HOT).
 * core WalletService.createAllWalletsForPartner()로 위임.
 * 이미 존재하는 네트워크는 WalletService 내부에서 skip.
 */
@Transactional
public List<MasterWalletInfo> createPartnerWallets(Long partnerId, Long adminId) {
    Partner partner = getPartner(partnerId);

    List<WalletCreationResult> walletResults = walletService.createAllWalletsForPartner(partnerId);

    // 결과 변환
    List<MasterWalletInfo> result = walletResults.stream()
            .filter(r -> r.getError() == null)
            .map(r -> MasterWalletInfo.builder()
                    .networkId(r.getNetworkId())
                    .chainSymbol(r.getChainSymbol())
                    .walletAddressId(r.getMasterWalletId())
                    .address(r.getMasterAddress())
                    .build())
            .toList();

    auditLogService.log(adminId, AuditAction.CREATE_PARTNER_WALLET,
            "PARTNER", partnerId,
            "파트너 지갑 생성: " + result.size() + "개 네트워크");

    return result;
}
```

### 3.4 필요한 import 추가

`PartnerManagementService.java`에 추가 필요:
```java
import com.cryptoments.adminapi.dto.response.PartnerWalletInfo;
import com.cryptoments.common.enums.WalletType;
```

### 3.5 AuditAction enum 확인

`AuditAction`에 `CREATE_PARTNER_WALLET`이 없으면 추가 필요.

**파일**: `common/src/main/java/com/cryptoments/common/enums/AuditAction.java` (또는 해당 위치)

```java
CREATE_PARTNER_WALLET,  // 파트너 지갑 생성
```

### 3.6 WalletAddressRepository 메서드 확인

`findByPartnerIdAndNetworkIdAndWalletType` 메서드가 Repository에 있는지 확인.
없으면 추가:

**파일**: `common/src/main/java/com/cryptoments/common/repository/WalletAddressRepository.java`

```java
WalletAddress findByPartnerIdAndNetworkIdAndWalletType(Long partnerId, Long networkId, WalletType walletType);
```

---

## 4. Admin UI 작업

### 4.1 partner.service.ts — API 메서드 추가

**파일**: `admin-ui/src/api/services/partner.service.ts`

기존 메서드 뒤에 추가:

```typescript
// ── 파트너 지갑 (Wallet Tab) ──
getWallets(id: number) {
  return apiClient.get<PartnerWalletInfo[]>(`${BASE}/${id}/wallets`)
},

createWallets(id: number) {
  return apiClient.post<MasterWalletInfo[]>(`${BASE}/${id}/wallets`)
},
```

### 4.2 타입 정의 추가

**파일**: `admin-ui/src/api/types/partner-detail.ts` (또는 적절한 위치)

```typescript
export interface PartnerWalletInfo {
  /** 블록체인 네트워크 ID */
  networkId: number
  /** 체인 심볼 (BSC, POLYGON, TRON) */
  chainSymbol: string
  /** MASTER 지갑 주소 */
  masterAddress: string | null
  /** MASTER wallet_addresses.id */
  masterWalletId: number | null
  /** HOT 지갑 주소 */
  hotAddress: string | null
  /** HOT wallet_addresses.id */
  hotWalletId: number | null
}

export interface MasterWalletInfo {
  /** 블록체인 네트워크 ID */
  networkId: number
  /** 체인 심볼 */
  chainSymbol: string
  /** wallet_addresses.id */
  walletAddressId: number
  /** 지갑 주소 */
  address: string
}
```

### 4.3 PartnerWalletTab.vue 신규 생성

**파일**: `admin-ui/src/views/partners/tabs/PartnerWalletTab.vue`

```vue
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { toast } from 'vue-sonner'
import { partnerService } from '@/api/services/partner.service'
import type { PartnerWalletInfo } from '@/api/types/partner-detail'
import { useConfirm } from '@/composables/useConfirm'
import { useClipboard } from '@/composables/useClipboard'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { Plus, Copy, RefreshCw } from 'lucide-vue-next'

const props = defineProps<{ partnerId: number }>()

const { confirm } = useConfirm()
const { copy } = useClipboard()

const wallets = ref<PartnerWalletInfo[]>([])
const loading = ref(true)
const creating = ref(false)

async function fetchWallets() {
  loading.value = true
  try {
    const res = await partnerService.getWallets(props.partnerId)
    wallets.value = res.data
  } finally {
    loading.value = false
  }
}

async function handleCreateWallets() {
  const confirmed = await confirm({
    title: 'MASTER 지갑 생성',
    description: '활성 네트워크별 MASTER/HOT 지갑을 생성합니다. 이미 존재하는 네트워크는 건너뜁니다.',
    confirmText: '생성',
  })
  if (!confirmed) return

  creating.value = true
  try {
    const res = await partnerService.createWallets(props.partnerId)
    toast.success(`${res.data.length}개 네트워크 지갑이 생성되었습니다.`)
    await fetchWallets()
  } catch (error: any) {
    toast.error(error?.response?.data?.message ?? '지갑 생성에 실패했습니다.')
  } finally {
    creating.value = false
  }
}

/** 주소 축약 표시 */
function shortenAddress(address: string | null): string {
  if (!address) return '-'
  if (address.length <= 16) return address
  return `${address.slice(0, 8)}...${address.slice(-6)}`
}

onMounted(fetchWallets)
</script>

<template>
  <div class="space-y-4">
    <!-- 헤더 -->
    <div class="flex items-center justify-between">
      <h3 class="text-lg font-medium">지갑 목록</h3>
      <div class="flex gap-2">
        <Button variant="outline" size="sm" :disabled="loading" @click="fetchWallets">
          <RefreshCw class="mr-1 h-4 w-4" />
          새로고침
        </Button>
        <Button size="sm" :disabled="creating" @click="handleCreateWallets">
          <Plus class="mr-1 h-4 w-4" />
          {{ creating ? '생성 중...' : '지갑 생성' }}
        </Button>
      </div>
    </div>

    <!-- 지갑 없음 -->
    <Card v-if="!loading && wallets.length === 0">
      <CardContent class="py-8 text-center text-muted-foreground">
        <p>생성된 지갑이 없습니다.</p>
        <p class="mt-1 text-sm">
          "지갑 생성" 버튼을 클릭하여 활성 네트워크별 MASTER/HOT 지갑을 생성하세요.
        </p>
      </CardContent>
    </Card>

    <!-- 네트워크별 지갑 카드 -->
    <Card v-for="wallet in wallets" :key="wallet.networkId">
      <CardHeader class="pb-3">
        <CardTitle class="flex items-center gap-2 text-base">
          <Badge variant="outline">{{ wallet.chainSymbol }}</Badge>
          Network #{{ wallet.networkId }}
        </CardTitle>
      </CardHeader>
      <CardContent>
        <div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
          <!-- MASTER -->
          <div class="space-y-1">
            <p class="text-sm text-muted-foreground">MASTER 지갑</p>
            <div v-if="wallet.masterAddress" class="flex items-center gap-1">
              <code class="text-sm font-mono" :title="wallet.masterAddress">
                {{ shortenAddress(wallet.masterAddress) }}
              </code>
              <Button variant="ghost" size="icon" class="h-6 w-6" @click="copy(wallet.masterAddress!)">
                <Copy class="h-3 w-3" />
              </Button>
            </div>
            <p v-else class="text-sm text-muted-foreground">-</p>
          </div>

          <!-- HOT -->
          <div class="space-y-1">
            <p class="text-sm text-muted-foreground">HOT 지갑</p>
            <div v-if="wallet.hotAddress" class="flex items-center gap-1">
              <code class="text-sm font-mono" :title="wallet.hotAddress">
                {{ shortenAddress(wallet.hotAddress) }}
              </code>
              <Button variant="ghost" size="icon" class="h-6 w-6" @click="copy(wallet.hotAddress!)">
                <Copy class="h-3 w-3" />
              </Button>
            </div>
            <p v-else class="text-sm text-muted-foreground">-</p>
          </div>
        </div>
      </CardContent>
    </Card>
  </div>
</template>
```

### 4.4 PartnerDetailView.vue — 지갑 탭 추가

**파일**: `admin-ui/src/views/partners/PartnerDetailView.vue`

#### 4.4.1 import 추가

```typescript
import PartnerWalletTab from '@/views/partners/tabs/PartnerWalletTab.vue'
```

#### 4.4.2 TabsList에 탭 추가

기존 탭 순서에 "지갑" 탭 삽입 (`체인/통화 설정` 다음):

```html
<TabsList>
  <TabsTrigger value="basic">기본 정보</TabsTrigger>
  <TabsTrigger value="wallets">지갑</TabsTrigger>          <!-- ← 추가 -->
  <TabsTrigger value="chain">체인/통화 설정</TabsTrigger>
  <TabsTrigger value="withdrawal">출금 정책</TabsTrigger>
  <TabsTrigger value="integration">연동 설정</TabsTrigger>
  <TabsTrigger v-if="isDistributor" value="children">하위 파트너</TabsTrigger>
  <TabsTrigger value="logs">활동 로그</TabsTrigger>
</TabsList>
```

#### 4.4.3 TabsContent 추가

`TabsContent value="basic"` 닫힌 직후에:

```html
<!-- Tab2: 지갑 -->
<TabsContent value="wallets">
  <PartnerWalletTab :partner-id="id" />
</TabsContent>
```

---

## 5. DB 테스트 데이터 초기화 SQL

테스트 데이터를 초기화하고 처음부터 다시 시작하는 SQL.
**주의**: 인프라 지갑(ADMIN/GAS/RELAYER)과 hd_wallets는 유지.

```sql
-- ═══════════════════════════════════════════════════
-- 테스트 파트너 데이터 초기화
-- 실행 순서: FK 종속성 역순 삭제
-- ═══════════════════════════════════════════════════

-- 1. 파트너 관련 데이터 삭제
DELETE FROM partner_chain_configs;
DELETE FROM partner_withdrawal_policies;
DELETE FROM partner_telegram_configs;
DELETE FROM partner_axim_settings;
DELETE FROM withdrawal_address_whitelists;

-- 2. 파트너 지갑 삭제 (partner_id IS NOT NULL)
DELETE FROM wallet_keys WHERE wallet_address_id IN (
    SELECT id FROM wallet_addresses WHERE partner_id IS NOT NULL
);
DELETE FROM wallet_balances WHERE wallet_address_id IN (
    SELECT id FROM wallet_addresses WHERE partner_id IS NOT NULL
);
DELETE FROM wallet_assignments WHERE wallet_address_id IN (
    SELECT id FROM wallet_addresses WHERE partner_id IS NOT NULL
);
DELETE FROM wallet_addresses WHERE partner_id IS NOT NULL;

-- 3. 지갑 인덱스 관리자 초기화 (잘못된 HOT 레코드 포함)
DELETE FROM wallet_index_manager;

-- 4. 파트너 관리자 계정 삭제
DELETE FROM admins WHERE partner_id IS NOT NULL;

-- 5. 파트너 삭제
DELETE FROM partners;

-- 6. 확인
SELECT 'wallet_addresses (infra)' AS item, COUNT(*) AS cnt FROM wallet_addresses
UNION ALL
SELECT 'wallet_index_manager', COUNT(*) FROM wallet_index_manager
UNION ALL
SELECT 'partners', COUNT(*) FROM partners
UNION ALL
SELECT 'partner_chain_configs', COUNT(*) FROM partner_chain_configs
UNION ALL
SELECT 'hd_wallets', COUNT(*) FROM hd_wallets;
```

**예상 결과**:
| item | cnt |
|------|-----|
| wallet_addresses (infra) | 9 (ADMIN 3 + GAS 3 + RELAYER 3) |
| wallet_index_manager | 0 |
| partners | 0 |
| partner_chain_configs | 0 |
| hd_wallets | 3 |

---

## 6. 전체 작업 순서

```
1. [Node.js] NODEJS_WALLET_INDEX_FIX_GUIDE.md 수정 사항 적용
   - WalletIndexManagerRepo.ts
   - WalletDerivationService.ts
   → 빌드 + 배포 + PM2 재시작

2. [DB] 테스트 데이터 초기화 SQL 실행 (§5)

3. [Spring] admin-api 엔드포인트 추가 (§3)
   - PartnerWalletInfo.java DTO 신규
   - PartnerManagementController.java 엔드포인트 2개
   - PartnerManagementService.java 메서드 2개
   - AuditAction enum 확인/추가
   - WalletAddressRepository 메서드 확인
   → 빌드 + 배포

4. [Admin UI] 파트너 상세 지갑 탭 추가 (§4)
   - partner.service.ts API 메서드 2개
   - partner-detail.ts 타입 추가
   - PartnerWalletTab.vue 신규
   - PartnerDetailView.vue 탭 추가
   → 빌드 + 배포

5. [검증] 파트너 생성 → 지갑 탭 → 지갑 생성 → 주소 확인
```

---

## 7. 검증 체크리스트

- [ ] 파트너 생성 시 MASTER/HOT 지갑 자동 생성 정상 (3개 네트워크)
- [ ] 파트너 상세 → 지갑 탭 → 목록 조회 정상
- [ ] 지갑 없는 파트너 → "지갑 생성" 버튼 → 3개 네트워크 생성 성공
- [ ] 이미 지갑 있는 파트너 → "지갑 생성" 재클릭 → 중복 없이 skip (멱등성)
- [ ] 주소 복사 버튼 정상 동작
- [ ] wallet_addresses에 partner_id, derivation_index 올바르게 기록
- [ ] wallet_index_manager에 MASTER/HOT 레코드 올바르게 생성
- [ ] audit_logs에 CREATE_PARTNER_WALLET 기록 확인
