# Widget 입금 예약 상태 폴링 + 만료 카운트다운 구현 지침

> **작성**: 2026-04-02
> **대상 파일**: widget-ui/src/views/deposit.vue, widget-ui/src/api/widgetApis.js
> **Backend**: open-api DepositReservationController (변경 없음 — GET API 이미 충분)

---

## 1. 현재 상태 분석

### Backend (이미 구현 완료 — 변경 불필요)

| 항목 | 상태 | 설명 |
|------|------|------|
| POST `/widgets/api/deposit-reservations` | ✅ | 예약 생성. `expiresAt = now + 60분` |
| GET `/widgets/api/deposit-reservations` | ✅ | PENDING 예약 조회. `expiresAt`, `status`, `actualAmountCrypto`, `txHash` 등 모두 반환 |
| `DepositReservationMatcher` | ✅ | 입금 확정 시 자동 매칭 → status=COMPLETED, actualAmount/txHash 채움 |
| `DepositReservationResponse` | ✅ | `expiresAt`, `status`, `depositCompletedAt`, `transactionHash` 포함 |

**GET API 응답에 이미 `expiresAt` 포함.** Frontend 폴링만 추가하면 됨.

### Frontend (수정 필요)

| 항목 | 현재 | 수정 |
|------|------|------|
| 예약 생성 | ✅ `depositReservations()` (line 717) | 응답에서 `expiresAt` 저장 |
| 예약 상태 폴링 | ❌ 없음 | **추가 필요** |
| 만료 카운트다운 | ❌ 없음 | **추가 필요** |
| 입금 완료 감지 | ❌ 없음 | 폴링으로 `status=COMPLETED` 감지 |

---

## 2. 수정 대상 파일

### 2-1. `widget-ui/src/api/widgetApis.js` — API 함수 추가

```javascript
// 기존 depositReservationsApi 아래에 추가

export async function getDepositReservationApi(partnerUserId) {
  const response = await Rest.axios('get', `/api/deposit-reservations?partnerUserId=${partnerUserId}`)
  return response
}
```

### 2-2. `widget-ui/src/views/deposit.vue` — 3가지 추가

---

## 3. deposit.vue 수정 상세

### 3-1. import 추가

```javascript
// line 584 — import 목록에 getDepositReservationApi 추가
import {
  getExchangeRatesApi,
  getConnectionStatusApi,
  depositReservationsApi,
  getDepositReservationApi,  // ← 추가
  getLoadWithdrawalLimits,
  addPaymentApi,
  getBestChainsApi,
  createDepositAddressApi,
  getChainsApi,
  getPaymentApi,
  deletePaymentApi
} from '@/api/widgetApis'
```

### 3-2. 상태 변수 추가 (ref 선언부, ~line 602 근처)

```javascript
const isQrView = ref(false)

// ── 입금 예약 폴링 + 카운트다운 ──
const reservationData = ref(null)           // 예약 응답 데이터
const reservationCountdown = ref('')        // "12:34" 형식 카운트다운 텍스트
const reservationExpired = ref(false)       // 만료 여부
let reservationCheckInterval = null         // 폴링 interval ID
let countdownInterval = null                // 카운트다운 interval ID
```

### 3-3. 예약 생성 함수 수정 — `expiresAt` 저장 + 폴링 시작

**기존 `depositReservations()` (line 717~740) 교체:**

```javascript
const depositReservations = async () => {
  try {
    store.setIsLoading(true)
    let payload = {
      userId: params.value.partnerUserId,
      currencyType: transferData.value.currency,
      chainType: transferData.value.chainType,
      amountKrw: Number(transferData.value.krwAmount),
      amountCrypto: Number(transferData.value.usdAmount),
      exchangeRate: rates.value.usdKrw
    }
    if (params.value.linkId) {
      payload.linkId = params.value.linkId
    }
    const response = await depositReservationsApi(payload)

    // ── 응답에서 예약 데이터 저장 ──
    if (response && response.data) {
      reservationData.value = response.data
      reservationExpired.value = false
      startReservationPolling()    // 폴링 시작
      startCountdown()              // 카운트다운 시작
    }

  } catch (error) {
    sendMessageToParent('WIDGET_ERROR', {
      message: error.message
    })
  } finally {
    store.setIsLoading(false)
  }
}
```

### 3-4. 예약 상태 폴링 함수 추가

```javascript
// ── 입금 예약 상태 폴링 (3초 간격) ──
const checkReservationStatus = async () => {
  try {
    const response = await getDepositReservationApi(params.value.partnerUserId)

    if (!response || !response.data) {
      // 예약이 없음 (삭제되었거나 만료)
      stopReservationPolling()
      reservationExpired.value = true
      return
    }

    reservationData.value = response.data

    if (response.data.status === 'COMPLETED') {
      // 입금 매칭 완료
      stopReservationPolling()
      stopCountdown()

      sendMessageToParent('DEPOSIT_COMPLETED', {
        reservationId: response.data.id,
        actualAmount: response.data.actualAmountCrypto,
        actualAmountKrw: response.data.actualAmountKrw,
        transactionHash: response.data.transactionHash,
        completedAt: response.data.depositCompletedAt
      })

      // 완료 화면으로 이동 (기존 step에 맞게 조정)
      goToStep('payment-success')
      return
    }

    // 만료 시간 체크
    if (response.data.expiresAt) {
      const expiresAt = new Date(response.data.expiresAt)
      if (expiresAt <= new Date()) {
        stopReservationPolling()
        stopCountdown()
        reservationExpired.value = true
        toast.error('입금 예약이 만료되었습니다. 다시 시도해주세요.')
      }
    }

  } catch (error) {
    console.error('예약 상태 확인 실패:', error)
  }
}

const startReservationPolling = () => {
  stopReservationPolling()  // 기존 폴링 정리

  // 3초마다 체크
  reservationCheckInterval = setInterval(() => {
    checkReservationStatus()
  }, 3000)
}

const stopReservationPolling = () => {
  if (reservationCheckInterval) {
    clearInterval(reservationCheckInterval)
    reservationCheckInterval = null
  }
}
```

### 3-5. 만료 카운트다운 함수 추가

```javascript
// ── 만료 카운트다운 (1초 간격) ──
const startCountdown = () => {
  stopCountdown()  // 기존 타이머 정리
  updateCountdown()  // 즉시 한 번 실행

  countdownInterval = setInterval(() => {
    updateCountdown()
  }, 1000)
}

const updateCountdown = () => {
  if (!reservationData.value || !reservationData.value.expiresAt) {
    reservationCountdown.value = ''
    return
  }

  const expiresAt = new Date(reservationData.value.expiresAt)
  const now = new Date()
  const diffMs = expiresAt - now

  if (diffMs <= 0) {
    reservationCountdown.value = '00:00'
    reservationExpired.value = true
    stopCountdown()
    stopReservationPolling()
    toast.error('입금 예약이 만료되었습니다. 다시 시도해주세요.')
    return
  }

  const totalSeconds = Math.floor(diffMs / 1000)
  const minutes = Math.floor(totalSeconds / 60)
  const seconds = totalSeconds % 60
  reservationCountdown.value = `${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}`
}

const stopCountdown = () => {
  if (countdownInterval) {
    clearInterval(countdownInterval)
    countdownInterval = null
  }
}
```

### 3-6. 정리 — onUnmounted에 폴링/카운트다운 정리 추가

기존 `onUnmounted` 블록을 찾아서 추가:

```javascript
onUnmounted(() => {
  stopConnectionCheck()
  stopPaymentCheck()
  stopReservationPolling()   // ← 추가
  stopCountdown()            // ← 추가
})
```

### 3-7. 템플릿 — QR 뷰에 카운트다운 표시

**기존 `<template v-if="isQrView">` 블록 (line 523~553) 안에 카운트다운 추가:**

QR 코드 아래, address_box 아래에 추가:

```html
<!-- 기존 address_box 닫힌 후 (line 552 </div> 뒤) -->

<!-- 입금 예약 카운트다운 -->
<div v-if="reservationData && !reservationExpired" class="reservation_timer">
  <div class="timer_info">
    <span class="timer_label">입금 대기 중</span>
    <span class="timer_countdown" :class="{ 'timer_warning': isCountdownWarning }">
      {{ reservationCountdown }}
    </span>
  </div>
  <div class="timer_amount">
    <span>예약 금액: {{ reservationData.amountCrypto }} {{ transferData.currency }}</span>
    <span v-if="reservationData.amountKrw">
      ({{ common.comma(reservationData.amountKrw) }}원)
    </span>
  </div>
</div>

<!-- 만료 안내 -->
<div v-if="reservationExpired" class="reservation_expired">
  <svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
    <path d="M10 2C5.58 2 2 5.58 2 10s3.58 8 8 8 8-3.58 8-8-3.58-8-8-8zm1 11H9v-2h2v2zm0-4H9V5h2v4z" fill="#EF4444"/>
  </svg>
  <span>입금 예약이 만료되었습니다. 다시 시도해주세요.</span>
</div>
```

### 3-8. computed — 5분 이하 경고 표시

```javascript
// 카운트다운 5분 이하일 때 경고 색상
const isCountdownWarning = computed(() => {
  if (!reservationData.value || !reservationData.value.expiresAt) return false
  const expiresAt = new Date(reservationData.value.expiresAt)
  const diffMs = expiresAt - new Date()
  return diffMs > 0 && diffMs <= 5 * 60 * 1000  // 5분 이하
})
```

### 3-9. CSS — 카운트다운 스타일 추가

`deposit.vue`의 `<style>` 섹션에 추가:

```css
.reservation_timer {
  margin-top: 16px;
  padding: 12px 16px;
  background: #F0F9FF;
  border: 1px solid #BAE6FD;
  border-radius: 8px;
  text-align: center;
}

.timer_info {
  display: flex;
  align-items: center;
  justify-content: center;
  gap: 8px;
  margin-bottom: 4px;
}

.timer_label {
  font-size: 13px;
  color: #64748B;
}

.timer_countdown {
  font-size: 20px;
  font-weight: 700;
  color: #0284C7;
  font-variant-numeric: tabular-nums;
}

.timer_countdown.timer_warning {
  color: #EF4444;
  animation: pulse 1s infinite;
}

@keyframes pulse {
  0%, 100% { opacity: 1; }
  50% { opacity: 0.6; }
}

.timer_amount {
  font-size: 12px;
  color: #94A3B8;
}

.reservation_expired {
  margin-top: 16px;
  padding: 12px 16px;
  background: #FEF2F2;
  border: 1px solid #FECACA;
  border-radius: 8px;
  display: flex;
  align-items: center;
  gap: 8px;
  font-size: 13px;
  color: #DC2626;
}
```

---

## 4. 네트워크/토큰 변경 시 폴링 정리

기존 `selectCurrency`, `selectNetwork` 함수에서 폴링 정리 추가:

```javascript
const selectCurrency = async (value) => {
  isQrView.value = false
  stopReservationPolling()   // ← 추가
  stopCountdown()            // ← 추가
  reservationData.value = null  // ← 추가
  transferData.value.currency = value;
  updateChainItems(value)
}

const selectNetwork = async (option) => {
  isQrView.value = false
  stopReservationPolling()   // ← 추가
  stopCountdown()            // ← 추가
  reservationData.value = null  // ← 추가
  transferData.value.chainType = option.value;
  updateCurrencyItems(option.value)
}
```

---

## 5. Backend GET API 동작 확인

GET `/widgets/api/deposit-reservations` 응답 예시:

```json
// PENDING 상태 (입금 대기 중)
{
  "id": 123,
  "partnerId": 7,
  "userId": "user-001",
  "currencyType": "USDT",
  "chainType": "BSC",
  "amountKrw": 50000,
  "amountCrypto": 35.50,
  "exchangeRate": 1408.45,
  "status": "PENDING",
  "expiresAt": "2026-04-02T15:30:00",
  "createdAt": "2026-04-02T14:30:00",
  "actualAmountCrypto": null,
  "actualAmountKrw": null,
  "transactionHash": null,
  "depositCompletedAt": null
}

// COMPLETED 상태 (입금 매칭 완료)
{
  "id": 123,
  "status": "COMPLETED",              // ← 변경됨
  "actualAmountCrypto": 35.50,        // ← 채워짐
  "actualAmountKrw": 50000,           // ← 채워짐
  "transactionHash": "0xabc...",      // ← 채워짐
  "depositCompletedAt": "2026-04-02T14:35:00",  // ← 채워짐
  // ... 나머지 필드 동일
}
```

**`DepositReservationMatcher`가 입금 확정 시 자동으로 PENDING → COMPLETED 전환.**
폴링으로 GET을 반복 호출하면 `status`가 `"COMPLETED"`로 바뀌는 시점을 감지할 수 있음.

---

## 6. 주의사항

### Backend 잠재 개선 (선택)

현재 GET API는 PENDING 예약만 조회함 (`findByPartnerIdAndPartnerUserIdAndStatus(..., PENDING)`).
**COMPLETED 직후 폴링하면 null이 반환될 수 있음** — COMPLETED 상태도 조회 가능하게 수정하면 더 안정적:

```java
// DepositReservationController.getReservation() 수정 (선택적)
// 현재: PENDING만 조회
// 개선: PENDING 없으면 최근 COMPLETED도 반환

List<DepositReservation> pendings = reservationRepository
    .findByPartnerIdAndPartnerUserIdAndStatus(
        session.getPartnerId(), userId, ReservationStatus.PENDING);

if (pendings.isEmpty()) {
    // COMPLETED도 조회 (최근 5분 이내)
    List<DepositReservation> completeds = reservationRepository
        .findByPartnerIdAndPartnerUserIdAndStatus(
            session.getPartnerId(), userId, ReservationStatus.COMPLETED);
    if (!completeds.isEmpty()) {
        DepositReservation r = completeds.get(0);
        // 5분 이내 완료된 것만 반환
        if (r.getCompletedAt() != null
            && r.getCompletedAt().isAfter(LocalDateTime.now().minusMinutes(5))) {
            return buildResponse(r, userId);  // 응답 빌드
        }
    }
    return null;
}
```

**이 개선을 하지 않을 경우**: 프론트엔드에서 폴링 응답이 null이면 COMPLETED로 간주하는 로직이 필요 (현재 `checkReservationStatus`에서 처리됨).

### 타임존

`expiresAt`은 서버 시간(UTC+9) 기준. Widget에서 `new Date(response.data.expiresAt)`로 변환 시, 서버 응답이 ISO 형식이면 브라우저가 자동 처리함. 만약 `"2026-04-02T15:30:00"` 형태로 오면 로컬 시간으로 해석되므로, 서버와 클라이언트 타임존이 다르면 주의.

---

## 7. 전체 흐름 요약

```
1. 사용자: 금액 입력 → 체인/토큰 선택 → "입금 주소 보기" 클릭
2. showDepositAddress() → 주소 생성 + depositReservations() 호출
3. 예약 생성 응답 → reservationData에 저장 (expiresAt 포함)
4. startReservationPolling() — 3초마다 GET 호출
5. startCountdown() — 1초마다 남은 시간 계산 → "45:23" 표시

[입금 대기 중]
6. QR 화면에 카운트다운 + 예약 금액 표시
7. 5분 이하 → 빨간색 경고 + 깜박임

[입금 완료 시]
8. DepositReservationMatcher가 PENDING → COMPLETED 전환
9. 폴링이 COMPLETED 감지 → 폴링/타이머 중단 → 완료 화면 이동

[만료 시]
10. 카운트다운 00:00 → "만료되었습니다" 안내
11. 폴링 중단, 재시도 유도
```
