# Guide #59 — 파트너 기능 완성 + 잔여 정비

**작성일**: 2026-03-25
**대상**: partner-api (Spring Boot) + open-api + partner-ui + admin-ui (Vue 3)
**선행 조건**: Guide #58 완료 (총판 기능 정비)
**DDL 변경**: 없음 (기존 `partner_exchange_rate_policies` 테이블 활용)

---

## 목차

| Part | 내용 | 작업량 |
|------|------|--------|
| **A** | 외부 지갑 탭 (지갑 관리) | Backend 3 EP + UI 1 화면 |
| **B** | 사용자 상세 페이지 | Backend 4 EP + UI 1 페이지 |
| **C** | 사용자 액션 4종 | Backend 2 EP(신규) + UI 4 모달 |
| **D** | 환율 정책 | Backend 2 EP + UI 1 탭 + 환율 조회 로직 |
| **E** | 총판 수수료 설정 | UI 1 탭 (기존 API 활용) |
| **F** | OTP 적용 범위 | Backend 인터셉터 + UI 모달 |
| **G** | 대시보드 — 총판 하위 파트너 요약 | Backend 1 EP + UI 위젯 |
| **H** | 잔여 정비 (#56/#58 미완료 통합) | UI 정리 + TODO 구현 + 파일 삭제 |

---

## Part A: 외부 지갑 탭

### A-1. 개요

지갑 관리 메뉴에 "외부 지갑" 탭을 추가한다. 외부 지갑은 사용자가 결제 위젯에서 Axim/MetaMask로 연결한 지갑이며, 등록은 위젯 플로우에서 수행된다. partner-ui에서는 **조회 + 해제(Revoke)** 관리만 제공한다.

### A-2. 기존 코드 현황

```
Entity:      ExternalWallet.java (partnerId, partnerUserId, connectionType, connectionId, walletAddresses(JSON), status, connectedAt, revokedAt)
Repository:  ExternalWalletRepository.java
  - findByConnectionId(String)
  - findByPartnerIdAndPartnerUserId(Long, String)
  - findByPartnerIdAndStatus(Long, ExternalWalletStatus)
Enum:        ExternalWalletStatus (CONNECTED, REVOKED)
             ExternalWalletConnectionType (AXIM, METAMASK)
```

### A-3. Backend API (partner-api)

**컨트롤러**: `PartnerWalletController.java` (기존) 또는 신규 `PartnerExternalWalletController.java`

#### EP-1: 외부 지갑 목록 조회
```
GET /api/partner/external-wallets?page=1&size=20&partnerUserId=&address=&status=
```

**Response DTO** — `ExternalWalletListResponse`:
```java
public class ExternalWalletListResponse {
    /** PK */
    private Long id;
    /** 사용자 ID */
    private String partnerUserId;
    /** 연결 타입 (AXIM/METAMASK) */
    private String connectionType;
    /** 연결 ID */
    private String connectionId;
    /** 지갑 주소 목록 (JSON 파싱 결과) */
    private List<WalletAddressInfo> walletAddresses;
    /** 상태 */
    private String status;
    /** 연결 시각 */
    private LocalDateTime connectedAt;
    /** 해제 시각 */
    private LocalDateTime revokedAt;
}

public class WalletAddressInfo {
    /** 네트워크 (BSC, TRON, POLYGON) */
    private String network;
    /** 지갑 주소 */
    private String address;
}
```

**Mapper**: `ExternalWalletMapper.java`
```java
@Mapper
public interface ExternalWalletMapper {
    @Select("<script>" +
        "SELECT * FROM external_wallets " +
        "<where>" +
        "  partner_id = #{partnerId} " +
        "  <if test='search.partnerUserId != null'> AND partner_user_id LIKE CONCAT('%', #{search.partnerUserId}, '%') </if>" +
        "  <if test='search.address != null'> AND wallet_addresses LIKE CONCAT('%', #{search.address}, '%') </if>" +
        "  <if test='search.status != null'> AND status = #{search.status} </if>" +
        "</where>" +
        "</script>")
    XPage<ExternalWallet> searchExternalWallets(XPagination pagination,
                                                 @Param("partnerId") Long partnerId,
                                                 @Param("search") ExternalWalletSearchRequest search,
                                                 Class<?> cls);
}
```

#### EP-2: 외부 지갑 상세 조회
```
GET /api/partner/external-wallets/{id}
```

#### EP-3: 외부 지갑 연결 해제
```
DELETE /api/partner/external-wallets/{id}
```

**로직**: status = REVOKED, revokedAt = now(). 실제 삭제하지 않고 소프트 해제.

**서비스 로직**:
```java
public void revokeExternalWallet(Long partnerId, Long walletId) {
    ExternalWallet wallet = externalWalletRepository.findById(walletId);
    if (wallet == null || !wallet.getPartnerId().equals(partnerId)) {
        throw new NotFoundException(ErrorCodes.EXTERNAL_WALLET_NOT_FOUND);
    }
    if (wallet.getStatus() == ExternalWalletStatus.REVOKED) {
        throw new BadRequestException(ErrorCodes.EXTERNAL_WALLET_ALREADY_REVOKED);
    }
    wallet.setStatus(ExternalWalletStatus.REVOKED);
    wallet.setRevokedAt(LocalDateTime.now());
    externalWalletRepository.save(wallet);
}
```

### A-4. Frontend — 외부 지갑 탭

**파일**: `partner-ui/src/views/partner/wallets/ExternalWalletsView.vue`

지갑 관리 페이지에 탭 추가 (기존 "내 지갑" + 신규 "외부 지갑"):

```
┌─────────────────────────────────────────────┐
│ 지갑 관리                                    │
│ [내 지갑]  [외부 지갑]                        │
├─────────────────────────────────────────────┤
│ 검색: [사용자 ID ____] [지갑 주소 ____] [상태 v] │
├─────────────────────────────────────────────┤
│ 사용자ID  | 연결타입 | 주소      | 상태  | 연결일 | Action │
│ user_001 | AXIM    | 0xABC... | 연결됨 | 3/25  | [해제]  │
│ user_002 | META    | 0xDEF... | 해제됨 | 3/20  | -      │
└─────────────────────────────────────────────┘
```

- **사용자 ID 클릭** → 사용자 상세 페이지 이동 (Part B)
- **[해제] 버튼** → 확인 모달 → `DELETE /external-wallets/{id}` → OTP 검증 불필요 (해제는 입금 차단 목적)
- **walletAddresses JSON 파싱**: `[{"network":"BSC","address":"0x..."},...]` → 네트워크별 주소 표시

### A-5. 라우터 설정

```typescript
// router/index.ts
{ path: '/wallets/external', name: 'external-wallets', component: () => import('@/views/partner/wallets/ExternalWalletsView.vue') }
```

### A-6. 사이드바 메뉴 변경

`constants.ts` 지갑 관리 그룹에 "외부 지갑" 항목 추가:
```typescript
{ path: '/wallets/external', label: '외부 지갑', icon: 'LinkIcon' }
```

---

## Part B: 사용자 상세 페이지

### B-1. 개요

`partnerUserId`를 클릭하면 이동하는 별도 페이지. 해당 사용자의 입금/출금/연결 지갑을 한 곳에서 확인할 수 있는 허브 역할.

### B-2. Backend API (partner-api)

**컨트롤러**: 신규 `PartnerUserController.java`

#### EP-1: 사용자 요약 정보
```
GET /api/partner/users/{partnerUserId}/summary
```

**Response DTO** — `UserSummaryResponse`:
```java
public class UserSummaryResponse {
    /** 사용자 ID */
    private String partnerUserId;
    /** 총 입금 건수 */
    private int totalDepositCount;
    /** 총 입금 금액 (USD) */
    private BigDecimal totalDepositAmount;
    /** 총 출금 건수 */
    private int totalWithdrawalCount;
    /** 총 출금 금액 (USD) */
    private BigDecimal totalWithdrawalAmount;
    /** 연결 지갑 수 */
    private int connectedWalletCount;
    /** 첫 활동 시각 */
    private LocalDateTime firstActivityAt;
    /** 마지막 활동 시각 */
    private LocalDateTime lastActivityAt;
}
```

**Mapper**: `PartnerUserMapper.java`
```java
@Mapper
public interface PartnerUserMapper {
    @Select("SELECT " +
        "  (SELECT COUNT(*) FROM deposit_sessions WHERE partner_id = #{partnerId} AND partner_user_id = #{userId}) as totalDepositCount, " +
        "  (SELECT COALESCE(SUM(d.amount), 0) FROM deposits d JOIN deposit_sessions ds ON d.deposit_session_id = ds.id " +
        "   WHERE ds.partner_id = #{partnerId} AND ds.partner_user_id = #{userId} AND d.status = 'CONFIRMED') as totalDepositAmount, " +
        "  (SELECT COUNT(*) FROM withdrawals WHERE partner_id = #{partnerId} AND partner_user_id = #{userId}) as totalWithdrawalCount, " +
        "  (SELECT COALESCE(SUM(amount), 0) FROM withdrawals WHERE partner_id = #{partnerId} AND partner_user_id = #{userId} AND status = 'CONFIRMED') as totalWithdrawalAmount, " +
        "  (SELECT COUNT(*) FROM external_wallets WHERE partner_id = #{partnerId} AND partner_user_id = #{userId} AND status = 'CONNECTED') as connectedWalletCount")
    UserSummaryResponse getUserSummary(@Param("partnerId") Long partnerId, @Param("userId") String userId);
}
```

#### EP-2: 사용자 입금 내역
```
GET /api/partner/users/{partnerUserId}/deposits?page=1&size=20
```
기존 `PartnerDepositController`의 입금 목록 API에 `partnerUserId` 필터를 추가하거나, 별도 엔드포인트 제공.

**Response**: 기존 `DepositListResponse` 재사용. deposits JOIN deposit_sessions WHERE partner_user_id = #{userId}

#### EP-3: 사용자 출금 내역
```
GET /api/partner/users/{partnerUserId}/withdrawals?page=1&size=20
```
기존 `PartnerWithdrawalController`의 출금 목록 API에 `partnerUserId` 필터를 추가하거나, 별도 엔드포인트 제공.

**Response**: 기존 `WithdrawalListResponse` 재사용.

#### EP-4: 사용자 연결 지갑
```
GET /api/partner/users/{partnerUserId}/wallets
```
`ExternalWalletRepository.findByPartnerIdAndPartnerUserId(partnerId, userId)` 활용. 페이지네이션 불필요 (사용자당 지갑 수 적음).

### B-3. Frontend — 사용자 상세 페이지

**파일**: `partner-ui/src/views/partner/users/UserDetailView.vue`
**라우트**: `/users/:partnerUserId`

```
┌─────────────────────────────────────────────────────┐
│ ← 돌아가기                                           │
│                                                     │
│ 사용자: user_001                                     │
│ ┌────────┬────────┬────────┬────────┐              │
│ │ 입금   │ 출금   │ 연결지갑│ 첫활동  │              │
│ │ 15건   │ 3건   │ 2개   │ 3/10   │              │
│ │ 500 USDT│ 100 USDT│      │        │              │
│ └────────┴────────┴────────┴────────┘              │
│                                                     │
│ [액심 결제 요청] [결제 링크 생성] [입금 지갑 생성] [사용자 출금] │
│                                                     │
│ [입금 내역]  [출금 내역]  [연결 지갑]                   │
├─────────────────────────────────────────────────────┤
│ (선택된 탭 내용 — 테이블)                              │
└─────────────────────────────────────────────────────┘
```

**컴포넌트 구조**:
```
UserDetailView.vue
├── UserSummaryCard.vue       — 상단 요약 (건수/금액/지갑수)
├── UserActionButtons.vue     — 4개 액션 버튼
├── UserDepositsTab.vue       — 입금 내역 테이블
├── UserWithdrawalsTab.vue    — 출금 내역 테이블
└── UserWalletsTab.vue        — 연결 지갑 목록 + Revoke
```

### B-4. 공통 링크 — partnerUserId 클릭 시 이동

모든 화면에서 `partnerUserId`가 표시되는 곳에 `<router-link>` 적용:

```typescript
// 공통 유틸리티
const navigateToUser = (partnerUserId: string) => {
    router.push({ name: 'user-detail', params: { partnerUserId } })
}
```

적용 위치:
- 외부 지갑 목록 (Part A)
- 입금 내역 목록
- 출금 내역 목록
- 거래 현황
- 입금 세션 목록

---

## Part C: 사용자 액션 4종

### C-1. 액심 결제 요청

**기존 코드**: `PartnerDepositController.requestAximPayment()` — 스텁 존재
**기존 DTO**: `RequestAximPaymentRequest` — partnerUserId, amount, currencyId, networkId, partnerReference

**UI 모달**: `AximPaymentRequestModal.vue`

```
┌──────────────────────────────┐
│ 액심 결제 요청                 │
│                              │
│ 사용자 ID (필수)              │
│ [user_001          ] (읽기전용) │
│                              │
│ 네트워크 (필수)               │
│ [BSC               v]       │
│                              │
│ 토큰 수량 (필수)              │
│ [수량 입력        ] USDT     │
│ ┌──────────────────────┐    │
│ │ 1 USDT = 1,550 원     │    │
│ └──────────────────────┘    │
│ [금액 입력        ] 원       │
│                              │
│       [취소]  [요청]         │
└──────────────────────────────┘
```

**환율 연동**: Part D에서 구현하는 파트너 환율 조회 API 사용.
- 토큰 수량 입력 → 원화 자동 계산 (`amount * exchangeRate`)
- 원화 입력 → 토큰 수량 자동 계산 (`krwAmount / exchangeRate`)

**API 호출**:
```
POST /api/partner/axim-payments/request
Body: { partnerUserId, amount, currencyId, networkId }
```

**핵심**: 이 API는 기존 스텁(`requestAximPayment()`)을 구현해야 함.
- AximService.requestPayment() → AximPayClient로 Axim API 호출
- 결과를 axim_payments 테이블에 저장
- Axim 앱으로 결제 알림 발송

### C-2. 결제 링크 생성

**기존 코드**: `PartnerDepositController.createPaymentLink()` — 스텁 존재

**UI 모달**: `CreatePaymentLinkModal.vue`

```
┌──────────────────────────────┐
│ 결제 링크 생성                 │
│                              │
│ 사용자 ID (필수)              │
│ [user_001          ] (읽기전용) │
│                              │
│ 네트워크 (필수)               │
│ [BSC               v]       │
│                              │
│ 금액 (필수)                   │
│ [수량 입력        ] USDT     │
│                              │
│ 메모 (선택)                   │
│ [___________________]        │
│                              │
│       [취소]  [생성]         │
└──────────────────────────────┘
```

**API 호출**:
```
POST /api/partner/payment-links
Body: { partnerUserId, amount, currencyId, networkId, memo }
```

**핵심**: 이 API도 기존 스텁(`createPaymentLink()`)을 구현해야 함.
- DepositService.createPaymentLink() → deposit_sessions 생성 (type=PAYMENT_LINK)
- 결제 URL 생성 후 반환

### C-3. 입금 지갑 생성

**기존 코드**: `PartnerDepositController.createDepositAddress()` — 이미 구현됨

사용자 상세에서 호출할 때 partnerUserId를 자동으로 채워주는 모달.

**UI 모달**: `CreateDepositAddressModal.vue`

```
┌──────────────────────────────┐
│ 입금 지갑 생성                 │
│                              │
│ 사용자 ID (필수)              │
│ [user_001          ] (읽기전용) │
│                              │
│ 네트워크 (필수)               │
│ [BSC               v]       │
│                              │
│       [취소]  [생성]         │
└──────────────────────────────┘
```

**API 호출** (기존):
```
POST /api/partner/deposits/address
Body: { partnerUserId, networkId }
```

### C-4. 사용자 출금

**기존 코드**: `PartnerWithdrawalController.requestWithdrawal()` — 이미 구현됨

사용자 상세에서 호출할 때 partnerUserId를 자동으로 채워주는 모달.

**UI 모달**: `UserWithdrawalModal.vue`

```
┌──────────────────────────────┐
│ 사용자 출금                    │
│                              │
│ 사용자 ID (필수)              │
│ [user_001          ] (읽기전용) │
│                              │
│ 네트워크 (필수)               │
│ [BSC               v]       │
│                              │
│ 수량 (필수)                   │
│ [수량 입력        ] USDT     │
│                              │
│ 출금 주소 (필수)              │
│ [0x...             ]         │
│                              │
│       [취소]  [출금 요청]     │
└──────────────────────────────┘
```

**API 호출** (기존):
```
POST /api/partner/withdrawals
Body: { partnerUserId, amount, currencyId, networkId, toAddress }
```

**⚠️ OTP 필수**: 출금 요청 시 OTP 검증 (Part F 참조)

---

## Part D: 환율 정책

### D-1. 개요

파트너별로 환율을 고정하거나 시스템 시세를 사용하도록 설정한다.
- **INHERIT**: PriceService의 실시간 시세 사용 (기본값)
- **FIXED**: 파트너가 지정한 고정 환율 사용

### D-2. 기존 코드 현황

```
DDL:         partner_exchange_rate_policies (id, partner_id, rate_type, fixed_rate, created_at, updated_at)
Entity:      PartnerExchangeRatePolicy.java (partnerId, rateType, fixedRate)
Repository:  PartnerExchangeRatePolicyRepository.java
  - findByPartnerId(Long)
```

### D-3. Backend API (partner-api)

**컨트롤러**: `PartnerIntegrationController.java` (기존 정책 관리)에 환율 정책 추가

#### EP-1: 환율 정책 조회
```
GET /api/partner/settings/exchange-rate
```

**Response DTO** — `ExchangeRatePolicyResponse`:
```java
public class ExchangeRatePolicyResponse {
    /** 정책 유형 (INHERIT/FIXED) */
    private String rateType;
    /** 고정 환율값 (FIXED일 때) */
    private BigDecimal fixedRate;
    /** 현재 시스템 환율 (참고 표시용) */
    private BigDecimal currentSystemRate;
}
```

#### EP-2: 환율 정책 수정
```
PUT /api/partner/settings/exchange-rate
```

**Request DTO** — `UpdateExchangeRateRequest`:
```java
public class UpdateExchangeRateRequest {
    /** 정책 유형 (INHERIT/FIXED) */
    private String rateType;
    /** 고정 환율값 (rateType=FIXED 필수, INHERIT이면 무시) */
    private BigDecimal fixedRate;
}
```

**서비스 로직**:
```java
public void updateExchangeRatePolicy(Long partnerId, UpdateExchangeRateRequest request) {
    // FIXED인 경우 fixedRate 필수 + 범위 검증 (예: 500 ~ 5000)
    if ("FIXED".equals(request.getRateType())) {
        if (request.getFixedRate() == null || request.getFixedRate().compareTo(BigDecimal.ZERO) <= 0) {
            throw new BadRequestException(ErrorCodes.INVALID_EXCHANGE_RATE);
        }
    }

    PartnerExchangeRatePolicy policy = exchangeRatePolicyRepository.findByPartnerId(partnerId);
    if (policy == null) {
        // 최초 설정 — INSERT
        policy = PartnerExchangeRatePolicy.builder()
            .partnerId(partnerId)
            .rateType(request.getRateType())
            .fixedRate("FIXED".equals(request.getRateType()) ? request.getFixedRate() : null)
            .build();
    } else {
        // 기존 설정 — UPDATE
        policy.setRateType(request.getRateType());
        policy.setFixedRate("FIXED".equals(request.getRateType()) ? request.getFixedRate() : null);
    }
    exchangeRatePolicyRepository.save(policy);
}
```

### D-4. 환율 조회 공통 서비스

**core 모듈**: `PriceService.java`에 메서드 추가 (또는 별도 `ExchangeRateService`)

```java
/**
 * 파트너에 적용할 USD/KRW 환율을 반환한다.
 * FIXED 정책이면 고정값, INHERIT이면 시스템 실시간 시세.
 */
public BigDecimal getExchangeRate(Long partnerId) {
    PartnerExchangeRatePolicy policy = exchangeRatePolicyRepository.findByPartnerId(partnerId);
    if (policy != null && "FIXED".equals(policy.getRateType()) && policy.getFixedRate() != null) {
        return policy.getFixedRate();
    }
    // 시스템 실시간 시세 (빗썸 API)
    return getSystemExchangeRate();
}
```

### D-5. 환율을 사용하는 모든 곳에 적용

환율이 사용되는 위치와 변경 방법:

| 위치 | 현재 | 변경 |
|------|------|------|
| 입금 세션 생성 (`deposit_sessions.exchange_rate`) | PriceService 시세 직접 사용 | `getExchangeRate(partnerId)` 호출 |
| Axim 결제 요청 모달 (원화 환산) | PriceService 시세 직접 사용 | `getExchangeRate(partnerId)` 호출 |
| 결제 링크 생성 (원화 표시) | PriceService 시세 직접 사용 | `getExchangeRate(partnerId)` 호출 |
| open-api 위젯 결제 화면 | PriceService 시세 직접 사용 | `getExchangeRate(partnerId)` 호출 |

**핵심**: `PriceService.getUsdKrwRate()` 또는 유사한 직접 호출을 모두 `getExchangeRate(partnerId)`로 교체.

### D-6. Frontend — 정책 관리 탭

**위치**: 정책 관리 페이지에 "환율 정책" 탭 추가

```
┌─────────────────────────────────────────────┐
│ 정책 관리                                    │
│ [환율 정책]  [출금 정책]  [콜백 URL]  [텔레그램] │
├─────────────────────────────────────────────┤
│                                             │
│ 환율 정책 선택 (필수)                         │
│ ┌──────────────┐ ┌──────────────┐          │
│ │ 상위 시스템 승계 │ │  환율 고정    │ ← 선택됨  │
│ └──────────────┘ └──────────────┘          │
│                                             │
│ 환율 고정값 (필수)  ← FIXED 선택 시에만 표시    │
│ [1550          ]                            │
│                                             │
│ 현재 시스템 환율: 1,548 원  ← 참고 표시        │
│                                             │
│              [저장]                          │
└─────────────────────────────────────────────┘
```

**OTP**: 환율 정책 변경 시 OTP 검증 필요 (Part F 참조)

### D-7. 환율 조회 API (프론트용)

Axim 결제 요청 모달 등에서 현재 적용 환율을 표시하기 위한 API:

```
GET /api/partner/settings/exchange-rate/current
```

**Response**:
```java
public class CurrentExchangeRateResponse {
    /** 적용 환율 (FIXED면 고정값, INHERIT이면 시세) */
    private BigDecimal rate;
    /** 환율 출처 */
    private String source;  // "FIXED" 또는 "SYSTEM"
}
```

---

## Part E: 총판 수수료 설정

### E-1. 개요

총판(DISTRIBUTOR)이 본인의 `deposit_fee_rate`를 설정할 수 있는 UI. 정책 관리 페이지에 "수수료 설정" 탭을 추가한다.

### E-2. Backend API

**기존 코드 활용**: 파트너 정보 수정 API에서 `deposit_fee_rate` 업데이트 가능.

별도 전용 API가 더 명확:

```
GET /api/partner/settings/fee-rate
PUT /api/partner/settings/fee-rate
```

**Response DTO** — `FeeRateSettingsResponse`:
```java
public class FeeRateSettingsResponse {
    /** 상위가 설정한 나의 parent_fee_rate (읽기 전용) */
    private BigDecimal parentFeeRate;
    /** 누적 최소 수수료율 (읽기 전용) */
    private BigDecimal minFeeRate;
    /** 최대 허용 수수료율 (읽기 전용) */
    private BigDecimal maxFeeCap;
    /** 현재 내 입금 수수료율 (수정 가능) */
    private BigDecimal depositFeeRate;
    /** 파트너 유형 */
    private String partnerType;
}
```

**Request DTO** — `UpdateFeeRateRequest`:
```java
public class UpdateFeeRateRequest {
    /** 입금 수수료율 (0이면 직접 입금 비활성화) */
    private BigDecimal depositFeeRate;
}
```

**서비스 로직**:
```java
public void updateFeeRate(Long partnerId, UpdateFeeRateRequest request) {
    Partner partner = partnerRepository.findById(partnerId);
    BigDecimal newRate = request.getDepositFeeRate();

    // 0은 허용 (직접 입금 비활성화)
    if (newRate.compareTo(BigDecimal.ZERO) > 0) {
        // min_fee_rate ≤ deposit_fee_rate ≤ max_fee_cap
        if (newRate.compareTo(partner.getMinFeeRate()) < 0) {
            throw new BadRequestException(ErrorCodes.FEE_RATE_BELOW_MINIMUM);
        }
        if (newRate.compareTo(partner.getMaxFeeCap()) > 0) {
            throw new BadRequestException(ErrorCodes.FEE_RATE_ABOVE_MAXIMUM);
        }
    }

    partner.setDepositFeeRate(newRate);
    partnerRepository.save(partner);
}
```

### E-3. Frontend — 수수료 설정 탭

**위치**: 정책 관리 페이지에 "수수료 설정" 탭 추가 (총판에게만 표시)

```
┌─────────────────────────────────────────────────┐
│ 정책 관리                                        │
│ [환율 정책]  [수수료 설정]  [출금 정책]  [콜백 URL]  │
├─────────────────────────────────────────────────┤
│                                                 │
│ 내 수수료 구조                                    │
│ ┌─────────────────────────────────────┐        │
│ │ 상위 설정 수수료율: 0.5%  (읽기 전용)  │        │
│ │ 누적 최소 수수료율: 1.0%  (읽기 전용)  │        │
│ │ 최대 허용 수수료율: 5.0%  (읽기 전용)  │        │
│ └─────────────────────────────────────┘        │
│                                                 │
│ 내 입금 수수료율                                  │
│ [  2.0  ] %                                     │
│ ⓘ 0으로 설정하면 직접 입금을 받지 않습니다.         │
│ ⓘ 최소 1.0% ~ 최대 5.0% 범위 내에서 설정 가능합니다. │
│                                                 │
│              [저장]                              │
└─────────────────────────────────────────────────┘
```

**주의**: 수수료율은 DB에 소수(0.01 = 1%) 저장. UI에서는 ×100으로 %로 표시. Guide #58의 `formatFeeRate()` 유틸 사용.

---

## Part F: OTP 적용 범위

### F-1. 개요

보안 민감한 액션에 OTP(2FA) 검증을 추가한다. 파트너가 2FA를 활성화한 경우, 지정된 액션 실행 시 OTP 코드 입력이 필수.

### F-2. 기존 2FA 코드

```
PartnerAuthController:
  - POST /auth/2fa/verify   — 로그인 시 OTP 검증
PartnerAccountController:
  - GET  /account/2fa/setup  — QR 코드 생성
  - POST /account/2fa/enable — 2FA 활성화
  - DELETE /account/2fa      — 2FA 비활성화
Entity:
  - Partner.twoFactorEnabled (boolean)
  - Partner.twoFactorSecret  (암호화 저장)
```

### F-3. OTP 검증이 필요한 액션 목록

#### 필수 (높은 위험)
| 액션 | API | 비고 |
|------|-----|------|
| 출금 요청 | `POST /withdrawals` | 자금 유출 |
| 출금 승인 | `POST /sub-partners/{id}/withdrawals/{wid}/approve` | 하위 파트너 출금 승인 |
| API 키 재발급 | `POST /settings/api-key/regenerate` | 인증 수단 변경 |
| 비밀번호 변경 | `PUT /account/password` | 계정 보안 |

#### 권장 (중간 위험)
| 액션 | API | 비고 |
|------|-----|------|
| 하위 파트너 생성 | `POST /sub-partners` | 조직 변경 |
| 하위 파트너 삭제 | `DELETE /sub-partners/{id}` | 조직 변경 |
| 콜백 URL 변경 | `PUT /settings/webhook` | 알림 경로 변경 |
| 출금 정책 변경 | `PUT /settings/withdrawal-policy` | 한도 변경 |
| 환율 정책 변경 | `PUT /settings/exchange-rate` | 환율 변경 |

### F-4. Backend 구현 — OTP 인터셉터

**방법**: 커스텀 어노테이션 + 인터셉터 (AOP)

#### 어노테이션 정의
```java
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface RequiresOtp {
    String description() default "";
}
```

#### 컨트롤러 적용
```java
@RequiresOtp(description = "출금 요청")
@PostMapping("/withdrawals")
public WithdrawalResponse requestWithdrawal(@RequestBody WithdrawalRequest request) {
    // ...
}
```

#### 인터셉터 로직
```java
@Component
public class OtpVerificationInterceptor implements HandlerInterceptor {

    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response,
                             Object handler) throws Exception {
        if (handler instanceof HandlerMethod) {
            HandlerMethod method = (HandlerMethod) handler;
            RequiresOtp annotation = method.getMethodAnnotation(RequiresOtp.class);
            if (annotation != null) {
                // 세션에서 파트너 정보 확인
                Partner partner = getPartnerFromSession(request);

                // 2FA 미활성화 → OTP 검증 스킵 (정상 진행)
                if (!partner.getTwoFactorEnabled()) {
                    return true;
                }

                // 2FA 활성화 → OTP 헤더 확인
                String otpCode = request.getHeader("X-OTP-Code");
                if (otpCode == null || otpCode.isEmpty()) {
                    throw new UnauthorizedException(ErrorCodes.OTP_REQUIRED);
                }

                // OTP 검증
                boolean valid = totpService.verifyCode(partner.getTwoFactorSecret(), otpCode);
                if (!valid) {
                    throw new UnauthorizedException(ErrorCodes.OTP_INVALID);
                }
            }
        }
        return true;
    }
}
```

#### ErrorCodes 추가
```java
public static final ErrorCode OTP_REQUIRED = new ErrorCode("401-OTP", "OTP 인증이 필요합니다.");
public static final ErrorCode OTP_INVALID = new ErrorCode("401-OTP-INVALID", "OTP 코드가 올바르지 않습니다.");
```

### F-5. Frontend 구현 — OTP 확인 모달

**공통 컴포넌트**: `OtpVerificationModal.vue`

```
┌──────────────────────────────┐
│ OTP 인증                      │
│                              │
│ 보안을 위해 OTP 코드를          │
│ 입력해주세요.                   │
│                              │
│ [  _ _ _ _ _ _  ]            │
│                              │
│       [취소]  [확인]          │
└──────────────────────────────┘
```

**사용 패턴** — composable:

```typescript
// composables/useOtpVerification.ts
export function useOtpVerification() {
    const showOtpModal = ref(false)
    const pendingAction = ref<(() => Promise<any>) | null>(null)

    /**
     * OTP 검증이 필요한 API 호출을 래핑한다.
     * 2FA 미활성화 시 바로 실행, 활성화 시 OTP 모달 표시.
     */
    async function withOtp(apiCall: (otpCode?: string) => Promise<any>) {
        try {
            // 먼저 OTP 없이 시도
            return await apiCall()
        } catch (error: any) {
            if (error.response?.data?.code === '401-OTP') {
                // OTP 필요 → 모달 표시
                return new Promise((resolve, reject) => {
                    pendingAction.value = async (otpCode: string) => {
                        try {
                            const result = await apiCall(otpCode)
                            resolve(result)
                        } catch (e) {
                            reject(e)
                        }
                    }
                    showOtpModal.value = true
                })
            }
            throw error
        }
    }

    async function onOtpSubmit(otpCode: string) {
        if (pendingAction.value) {
            await pendingAction.value(otpCode)
            showOtpModal.value = false
            pendingAction.value = null
        }
    }

    return { showOtpModal, withOtp, onOtpSubmit }
}
```

**API 호출 시 OTP 헤더 추가**:
```typescript
// api/client.ts — axios 인터셉터
function callApi(url: string, options: any, otpCode?: string) {
    const headers = { ...options.headers }
    if (otpCode) {
        headers['X-OTP-Code'] = otpCode
    }
    return axios({ url, ...options, headers })
}
```

### F-6. 2FA 미설정 시 동작

- 2FA 미활성화 파트너 → OTP 검증 스킵 (인터셉터에서 `twoFactorEnabled=false`면 통과)
- 권장: 출금/파트너 관리 등 민감 액션 진입 시 "2FA를 설정하면 보안이 강화됩니다" 안내 배너 표시

---

## Part G: 대시보드 — 총판 하위 파트너 요약

### G-1. 개요

총판(DISTRIBUTOR) 로그인 시 대시보드에 하위 파트너 요약 위젯을 추가한다.

### G-2. Backend API

#### EP-1: 하위 파트너 요약 정보
```
GET /api/partner/dashboard/sub-partners-summary
```

**Response DTO** — `SubPartnersSummaryResponse`:
```java
public class SubPartnersSummaryResponse {
    /** 직속 하위 파트너 수 */
    private int directChildCount;
    /** 전체 산하 파트너 수 (자손 전체) */
    private int totalDescendantCount;
    /** 활성 파트너 수 */
    private int activeCount;
    /** 정지 파트너 수 */
    private int suspendedCount;
    /** 산하 전체 오늘 입금 건수 */
    private int todayDepositCount;
    /** 산하 전체 오늘 입금 금액 (USD) */
    private BigDecimal todayDepositAmount;
    /** 산하 전체 오늘 출금 건수 */
    private int todayWithdrawalCount;
    /** 산하 전체 오늘 출금 금액 (USD) */
    private BigDecimal todayWithdrawalAmount;
    /** 상위 5개 활성 파트너 (입금 기준) */
    private List<TopPartnerInfo> topPartners;
}

public class TopPartnerInfo {
    /** 파트너 ID */
    private Long partnerId;
    /** 파트너명 */
    private String partnerName;
    /** 오늘 입금 금액 */
    private BigDecimal todayAmount;
}
```

**서비스 로직**: 기존 `PartnerSubMgmtService.getDescendantIds(partnerId)` 활용하여 산하 파트너 ID 목록 조회 후 집계.

### G-3. Frontend — 대시보드 위젯

**위치**: `DashboardView.vue`에 총판 전용 위젯 추가 (distributorOnly)

```
┌─────────────────────────────────────────────────┐
│ 대시보드                                         │
│                                                 │
│ ┌── 내 현황 (기존) ──────────────────────────┐  │
│ │ 오늘 입금: 5건 / 500 USDT                   │  │
│ │ 오늘 출금: 2건 / 100 USDT                   │  │
│ └───────────────────────────────────────────┘  │
│                                                 │
│ ┌── 산하 파트너 현황 (총판 전용) ──────────────┐  │
│ │ 직속 파트너: 5개  |  전체 산하: 12개           │  │
│ │ 활성: 10개  |  정지: 2개                     │  │
│ │                                             │  │
│ │ 산하 오늘 입금: 32건 / 2,500 USDT            │  │
│ │ 산하 오늘 출금: 8건 / 800 USDT               │  │
│ │                                             │  │
│ │ 활성 TOP 5                                  │  │
│ │ 1. 파트너A — 500 USDT                       │  │
│ │ 2. 파트너B — 350 USDT                       │  │
│ │ 3. ...                                      │  │
│ └───────────────────────────────────────────┘  │
│                                                 │
│ ┌── 7일 추이 차트 (기존) ────────────────────┐  │
│ │ (차트)                                      │  │
│ └───────────────────────────────────────────┘  │
└─────────────────────────────────────────────────┘
```

**조건부 렌더링**:
```vue
<SubPartnersSummaryWidget v-if="partner.partnerType === 'DISTRIBUTOR'" />
```

---

## 작업 순서

### Phase 1: Backend (D1~D10)

| # | 작업 | 예상 |
|---|------|------|
| D1 | `PartnerExternalWalletController` 3 EP + Service + Mapper | 1h |
| D2 | `PartnerUserController` 4 EP + Service + Mapper | 1.5h |
| D3 | 환율 정책 API 3 EP (조회/수정/현재환율) | 0.5h |
| D4 | `PriceService.getExchangeRate(partnerId)` 공통 메서드 | 0.5h |
| D5 | 수수료 설정 API 2 EP (조회/수정) | 0.5h |
| D6 | OTP 어노테이션 + 인터셉터 + ErrorCodes | 1h |
| D7 | `@RequiresOtp` 어노테이션 적용 (9개 메서드) | 0.5h |
| D8 | 대시보드 하위 파트너 요약 API 1 EP | 0.5h |
| D9 | Axim 결제 요청 스텁 구현 (`requestAximPayment()`) | 1h |
| D10 | 결제 링크 스텁 구현 (`createPaymentLink()`) | 1h |

### Phase 2: Frontend (E1~E8)

| # | 작업 | 예상 |
|---|------|------|
| E1 | 외부 지갑 탭 (ExternalWalletsView.vue) | 1h |
| E2 | 사용자 상세 페이지 (UserDetailView.vue + 하위 컴포넌트 5개) | 2h |
| E3 | 사용자 액션 모달 4종 (Axim결제/결제링크/입금지갑/출금) | 2h |
| E4 | 환율 정책 탭 (정책 관리에 추가) | 0.5h |
| E5 | 수수료 설정 탭 (정책 관리에 추가, distributorOnly) | 0.5h |
| E6 | OTP 확인 모달 + useOtpVerification composable | 1h |
| E7 | OTP 적용 — 9개 액션에 withOtp() 래핑 | 1h |
| E8 | 대시보드 하위 파트너 요약 위젯 (distributorOnly) | 1h |

### Phase 3: 통합 검증 (F1~F4)

| # | 작업 | 예상 |
|---|------|------|
| F1 | 외부 지갑 목록/검색/해제 동작 확인 | 0.5h |
| F2 | 사용자 상세 페이지 진입 + 탭 전환 + 액션 4종 | 0.5h |
| F3 | 환율 정책 FIXED 설정 → Axim 결제 요청 모달에서 고정 환율 표시 확인 | 0.5h |
| F4 | OTP 2FA 활성화 → 출금 요청 시 OTP 모달 → 입력 → 성공 | 0.5h |

---

## 사이드바 메뉴 최종 구조 (Guide #58 + #59 적용 후)

```typescript
const MENU_ITEMS = [
  { path: '/dashboard', label: '대시보드', icon: 'LayoutDashboardIcon' },
  {
    label: '입금 관리',
    children: [
      { path: '/deposits', label: '입금 내역' },
      { path: '/deposit-sessions', label: '입금 세션' },
      { path: '/payment-links', label: '결제 링크' },
      { path: '/axim-payments', label: 'Axim Pay' },
    ]
  },
  {
    label: '출금 관리',
    children: [
      { path: '/withdrawals', label: '출금 내역' },
      { path: '/withdrawal-whitelist', label: '출금 화이트리스트' },
    ]
  },
  {
    label: '지갑 관리',
    children: [
      { path: '/wallets', label: '내 지갑' },
      { path: '/wallets/external', label: '외부 지갑' },     // ← 신규
    ]
  },
  {
    label: '총판',
    distributorOnly: true,
    children: [
      { path: '/sub-partners', label: '파트너 관리' },
      { path: '/sub-partners/transactions', label: '거래 현황' },
      { path: '/sub-partners/settlements', label: '정산 현황' },
    ]
  },
  {
    label: '정산',
    children: [
      { path: '/settlements', label: '정산 현황' },
    ]
  },
  {
    label: '설정',
    children: [
      { path: '/settings/exchange-rate', label: '환율 정책' },    // ← 신규
      { path: '/settings/fee-rate', label: '수수료 설정', distributorOnly: true }, // ← 신규 (총판만)
      { path: '/settings/withdrawal-policy', label: '출금 정책' },
      { path: '/settings/api-keys', label: 'API 키' },
      { path: '/settings/webhook', label: 'Webhook' },
      { path: '/settings/telegram', label: '텔레그램' },
      { path: '/settings/axim', label: 'Axim Pay' },
    ]
  },
  {
    label: '내 정보',
    children: [
      { path: '/account/profile', label: '프로필' },
      { path: '/account/security', label: '보안 설정 (2FA)' },
    ]
  },
]
```

> **사용자 상세 페이지** (`/users/:partnerUserId`)는 사이드바 메뉴에 넣지 않음. partnerUserId 클릭으로만 진입하는 상세 페이지.

---

## Part H: 잔여 정비 (#56/#58 미완료 통합)

Guide #56, #58 검증 결과 발견된 미완료 항목을 통합 정리한다.

### H-1. Contact 필드 UI 제거

**배경**: `contact_email`, `contact_phone` 컬럼은 **DB에서 이미 DROP 완료**. DDL 파일에서도 제거됨. 백엔드 Entity/DTO 정리 완료. **UI만 잔존**.

#### partner-ui (2개 파일)

**파일 1**: `partner-ui/src/views/partner/subpartners/SubPartnerNewView.vue`
- **Line 18~20**: `contactName`, `contactEmail`, `contactPhone` ref 변수 3개 **삭제**
- **Line 67**: 담당자명 `<Input>` 필드 **삭제**
- **Line 70**: 담당자 이메일 `<Input>` 필드 **삭제**
- **Line 71**: 담당자 전화 `<Input>` 필드 **삭제**
- 참고: submit 로직(Line 29~35)에는 이미 포함되지 않음 → ref 삭제만으로 충분

**파일 2**: `partner-ui/src/views/partner/account/ProfileView.vue`
- **Line 17~19**: `contactName`, `contactEmail`, `contactPhone` ref 변수 3개 **삭제**
- **Line 25~27**: fetch에서 contact 필드 할당 **삭제**
- **Line 36~40**: updateProfile() 요청에서 contact 필드 **삭제**
- 관련 `<Input>` 필드 **삭제**

**파일 3**: `partner-ui/src/api/types/account.ts`
- `PartnerProfile` interface에서 `contactName?`, `contactEmail?`, `contactPhone?` 3개 필드 **삭제**

#### admin-ui (4개 파일)

**⚠️ 중요**: DB에 컬럼이 없으므로 admin-ui에서도 **반드시 제거**해야 한다. 현재 폼에서 전송해도 백엔드에서 무시되지만, 깨끗하게 정리.

**파일 4**: `admin-ui/src/api/types/partner.ts`
- `PartnerListItem`: `contactEmail` 필드 **삭제**
- `PartnerDetail`: `contactEmail`, `contactPhone` 필드 **삭제**
- `PartnerCreateRequest`: `contactEmail`, `contactPhone` 필드 **삭제**
- `PartnerUpdateRequest`: `contactEmail`, `contactPhone` 필드 **삭제**

**파일 5**: `admin-ui/src/views/partners/PartnerListView.vue`
- **Line 37**: 컬럼 정의 `{ key: 'contactEmail', label: '이메일' }` **삭제**
- **Line 84**: 검색 `<Input>` (이메일) **삭제**

**파일 6**: `admin-ui/src/views/partners/PartnerCreateView.vue`
- **Line 138~139**: `contactEmail`, `contactPhone` defineField **삭제**
- **Line 47~52**: validation schema에서 contactEmail, contactPhone **삭제**
- **Line 301~322**: 담당자 이메일/전화 `<Input>` 렌더링 **삭제**
- **Line 176~177**: submit payload에서 contactEmail, contactPhone **삭제**

**파일 7**: `admin-ui/src/views/partners/PartnerDetailView.vue`
- **Line 87~88**: `editContactEmail`, `editContactPhone` defineField **삭제**
- **Line 65~69**: validation schema에서 contact 필드 **삭제**
- **Line 345~368**: 뷰/편집 모드의 담당자 이메일/전화 렌더링 **삭제**
- **Line 131~132**: update payload에서 contact 필드 **삭제**

### H-2. 불필요한 파일 삭제

#### 리포트 뷰 3개 (사이드바에서 이미 제거, 라우터 리다이렉트 설정됨 → 파일만 삭제)

```
DELETE: partner-ui/src/views/partner/reports/RevenueView.vue
DELETE: partner-ui/src/views/partner/reports/CommissionView.vue
DELETE: partner-ui/src/views/partner/reports/PerformanceView.vue
```

#### 리포트 서비스 (더 이상 사용하는 컴포넌트 없음)

```
DELETE: partner-ui/src/api/services/report.service.ts
```

#### 리포트 타입 (report.service.ts 삭제 시 함께)

report.service.ts에서만 사용하는 타입이 별도 파일에 있다면 함께 삭제.

### H-3. 거래 현황 드릴다운 상세

Guide #58 Part D에서 설계한 거래 현황의 **드릴다운 상세**가 미구현.

**현재 상태**: `SubPartnerOverviewView.vue`에 개요 테이블은 존재하나, 파트너 행 클릭 시 상세(입금/출금/거래 탭) 화면이 없음.

#### Backend API (신규 3 EP)

```
GET /api/partner/sub-partners/{id}/deposits?page=1&size=20
GET /api/partner/sub-partners/{id}/withdrawals?page=1&size=20
GET /api/partner/sub-partners/{id}/transactions?page=1&size=20
```

**서비스 로직**:
- `isDescendantOf(partnerId, targetId)` — 산하 파트너 검증 (트리 상향 탐색, 최대 4회)
- 검증 통과 후 해당 파트너의 deposit_sessions/deposits, withdrawals, ledger_entries 조회

```java
/**
 * targetPartnerId가 myPartnerId 산하인지 검증.
 * 트리를 위로 올라가면서 parentPartnerId를 확인 (최대 maxDepth회).
 */
public boolean isDescendantOf(Long myPartnerId, Long targetPartnerId) {
    Long current = targetPartnerId;
    for (int i = 0; i < 4; i++) {
        Partner partner = partnerRepository.findById(current);
        if (partner == null) return false;
        if (myPartnerId.equals(partner.getParentPartnerId())) return true;
        current = partner.getParentPartnerId();
        if (current == null) return false;
    }
    return false;
}
```

#### Frontend — 드릴다운 상세 페이지

**라우트**: `/sub-partners/:id/detail`

**파일**: `partner-ui/src/views/partner/subpartners/SubPartnerTransactionDetailView.vue`

```
┌─────────────────────────────────────────────────┐
│ ← 거래 현황으로 돌아가기                            │
│                                                 │
│ 파트너: 가맹점A (partner_code: M001)              │
│ ┌────────┬────────┬────────┐                   │
│ │ 입금   │ 출금   │ 거래내역│                   │
│ │ 15건   │ 3건   │ 18건   │                   │
│ └────────┴────────┴────────┘                   │
│                                                 │
│ [입금 내역]  [출금 내역]  [거래 내역]              │
├─────────────────────────────────────────────────┤
│ (선택된 탭 — 페이지네이션 테이블)                   │
└─────────────────────────────────────────────────┘
```

#### 거래 현황 테이블에 내 수익 컬럼 추가

`SubPartnerOverviewView.vue`의 파트너 목록 테이블에 **내 수익 (USD)** 컬럼 추가:
- `settlement_daily_fees.share_amount` SUM으로 집계
- 선택한 기간(오늘/1주/1개월) 기준

### H-4. Axim TODO 스텁 구현

Guide #45의 미완료 TODO 2건.

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

#### getPaymentStatus (Line 156)

**현재 (TODO)**:
```java
@GetMapping("/payments/{paymentId}")
public AximPaymentStatusResponse getPaymentStatus(@PathVariable Long paymentId) {
    // TODO: AximPaymentRepository.findOne(paymentId)
    return AximPaymentStatusResponse.builder()
            .paymentId(paymentId)
            .status("PENDING")
            .build();
}
```

**구현**:
```java
@GetMapping("/payments/{paymentId}")
public AximPaymentStatusResponse getPaymentStatus(@PathVariable Long paymentId) {
    AximPayment payment = aximPaymentRepository.findById(paymentId);
    if (payment == null) {
        throw new NotFoundException(ErrorCodes.AXIM_PAYMENT_NOT_FOUND);
    }
    return AximPaymentStatusResponse.builder()
            .paymentId(payment.getId())
            .status(payment.getStatus().name())
            .amount(payment.getAmount())
            .currencyId(payment.getCurrencyId())
            .networkId(payment.getNetworkId())
            .createdAt(payment.getCreatedAt())
            .expiredAt(payment.getExpiredAt())
            .build();
}
```

#### cancelPayment (Line 171)

**현재 (TODO)**:
```java
@DeleteMapping("/payments/{paymentId}")
public AximPaymentStatusResponse cancelPayment(@PathVariable Long paymentId) {
    // TODO: AximService.cancelPayment(paymentId)
    return AximPaymentStatusResponse.builder()
            .paymentId(paymentId)
            .status("CANCELLED")
            .build();
}
```

**구현**:
```java
@DeleteMapping("/payments/{paymentId}")
public AximPaymentStatusResponse cancelPayment(@PathVariable Long paymentId) {
    AximPayment payment = aximService.cancelPayment(paymentId);
    return AximPaymentStatusResponse.builder()
            .paymentId(payment.getId())
            .status(payment.getStatus().name())
            .build();
}
```

**AximService.cancelPayment()** 추가:
```java
public AximPayment cancelPayment(Long paymentId) {
    AximPayment payment = aximPaymentRepository.findById(paymentId);
    if (payment == null) {
        throw new NotFoundException(ErrorCodes.AXIM_PAYMENT_NOT_FOUND);
    }
    if (payment.getStatus() != AximPaymentStatus.REQUESTED
        && payment.getStatus() != AximPaymentStatus.PENDING) {
        throw new BadRequestException(ErrorCodes.AXIM_PAYMENT_CANNOT_CANCEL);
    }
    payment.setStatus(AximPaymentStatus.CANCELLED);
    aximPaymentRepository.save(payment);
    return payment;
}
```

### H-5. 라우터 정리 확인

현재 라우터에 리다이렉트가 설정되어 있어 기능적으로는 문제 없지만, 파일 삭제 후에도 유지하면 됨 (하위 호환):

```typescript
// 이미 설정됨 — 유지
{ path: 'reports/revenue', redirect: { name: 'partner-sub-partners-overview' } }
{ path: 'reports/commission', redirect: { name: 'partner-sub-partners-overview' } }
{ path: 'reports/performance', redirect: { name: 'partner-sub-partners-overview' } }
{ path: 'deposit-addresses', redirect: { name: 'partner-wallets' } }
```

---

## 작업 순서 (Part A~H 통합)

### Phase 1: Backend (D1~D13)

| # | 작업 | Part | 예상 |
|---|------|------|------|
| D1 | `PartnerExternalWalletController` 3 EP + Service + Mapper | A | 1h |
| D2 | `PartnerUserController` 4 EP + Service + Mapper | B | 1.5h |
| D3 | 환율 정책 API 3 EP (조회/수정/현재환율) | D | 0.5h |
| D4 | `PriceService.getExchangeRate(partnerId)` 공통 메서드 | D | 0.5h |
| D5 | 수수료 설정 API 2 EP (조회/수정) | E | 0.5h |
| D6 | OTP 어노테이션 + 인터셉터 + ErrorCodes | F | 1h |
| D7 | `@RequiresOtp` 어노테이션 적용 (9개 메서드) | F | 0.5h |
| D8 | 대시보드 하위 파트너 요약 API 1 EP | G | 0.5h |
| D9 | Axim 결제 요청 스텁 구현 (`requestAximPayment()`) | C | 1h |
| D10 | 결제 링크 스텁 구현 (`createPaymentLink()`) | C | 1h |
| D11 | 거래 현황 드릴다운 API 3 EP + `isDescendantOf()` | H | 1h |
| D12 | Axim TODO 구현 (getPaymentStatus + cancelPayment) | H | 0.5h |
| D13 | 환율 사용처 `getExchangeRate(partnerId)` 교체 | D | 0.5h |

### Phase 2: Frontend (E1~E12)

| # | 작업 | Part | 예상 |
|---|------|------|------|
| E1 | 외부 지갑 탭 (ExternalWalletsView.vue) | A | 1h |
| E2 | 사용자 상세 페이지 (UserDetailView.vue + 하위 5개) | B | 2h |
| E3 | 사용자 액션 모달 4종 | C | 2h |
| E4 | 환율 정책 탭 (정책 관리에 추가) | D | 0.5h |
| E5 | 수수료 설정 탭 (distributorOnly) | E | 0.5h |
| E6 | OTP 확인 모달 + useOtpVerification composable | F | 1h |
| E7 | OTP 적용 — 9개 액션에 withOtp() 래핑 | F | 1h |
| E8 | 대시보드 하위 파트너 요약 위젯 | G | 1h |
| E9 | **Contact 필드 제거** — partner-ui 3파일 + admin-ui 4파일 | H | 0.5h |
| E10 | **파일 삭제** — 리포트 뷰 3개 + report.service.ts | H | 0.2h |
| E11 | **거래 현황 드릴다운** 상세 페이지 + 내 수익 컬럼 | H | 1.5h |
| E12 | partnerUserId 클릭 → 사용자 상세 링크 (전체 화면 적용) | B | 0.5h |

### Phase 3: 통합 검증 (F1~F6)

| # | 작업 | 예상 |
|---|------|------|
| F1 | 외부 지갑 목록/검색/해제 동작 확인 | 0.5h |
| F2 | 사용자 상세 페이지 진입 + 탭 전환 + 액션 4종 | 0.5h |
| F3 | 환율 정책 FIXED 설정 → Axim 결제 요청에서 고정 환율 표시 확인 | 0.5h |
| F4 | OTP 2FA 활성화 → 출금 요청 시 OTP 모달 → 성공 | 0.5h |
| F5 | **Contact 필드 완전 제거 확인** — partner-ui + admin-ui 빌드 | 0.3h |
| F6 | **거래 현황 드릴다운** — 파트너 행 클릭 → 입금/출금/거래 탭 확인 | 0.5h |

---

## 주의사항

1. **환율 적용 일관성**: `getExchangeRate(partnerId)`를 환율이 필요한 모든 곳에서 사용. PriceService 직접 호출 금지.
2. **OTP는 2FA 활성화 파트너만**: `twoFactorEnabled=false`면 OTP 검증 스킵. 강제하지 않음.
3. **수수료율 표시**: DB 소수값 → UI에서 ×100 → "1.0%". Guide #58의 `formatFeeRate()` 유틸 활용.
4. **walletAddresses JSON**: ExternalWallet의 walletAddresses 필드는 JSON 문자열. 프론트에서 파싱 필요.
5. **partnerUserId 링크**: 외부 지갑, 입금, 출금 등 모든 테이블에서 클릭 시 `/users/{partnerUserId}`로 이동.
6. **총판 전용 UI**: `distributorOnly` 플래그로 총판만 볼 수 있는 항목 제어 (수수료 설정, 대시보드 위젯 등).
7. **Contact 필드 DB 이미 DROP**: partners 테이블에 contact_email/contact_phone 없음. UI에서 전송해도 무시되지만 깨끗하게 제거.
8. **리포트 파일 삭제**: 라우터 리다이렉트는 유지 (하위 호환), 뷰 파일+서비스만 삭제.
