# Open API 정합성 수정 지침서

> **작성일**: 2026-03-31
> **기반**: curl 통합 테스트 (24 PASS / 1 FAIL) + openapi.json 스펙 감사
> **우선순위**: P0 → P4 (P0이 가장 긴급)

---

## P0. [BUG] Deposit Wallet NPE (500 에러)

### 증상

```
POST /api/v1/users/deposit-wallet
→ 500: NullPointerException: Cannot invoke "WalletAddressStatus.name()"
   because the return value of "WalletAddress.getStatus()" is null
```

### 원인

`WalletService.createHotWallet()` (line 145~152)에서 `WalletAddress`를 Builder로 직접 생성하면서 `.status()` 를 설정하지 않음:

```java
// core/wallet/WalletService.java:145
WalletAddress hotWallet = WalletAddress.builder()
        .id(derived.getWalletAddressId())
        .networkId(networkId)
        .address(derived.getAddress())
        .walletType(WalletType.HOT)
        .partnerId(partnerId)
        .partnerUserId(partnerUserId)
        .build();  // ← status 없음!
```

blockchain-api가 DB에 INSERT할 때 DDL DEFAULT `'ACTIVE'`가 적용되지만, Java 객체에는 `null`이 남음. 컨트롤러에서 `wallet.getStatus().name()` 호출 시 NPE.

### 수정 (파일 2개)

**수정 1**: `WalletService.createHotWallet()` — status 필드 추가

```java
// core/src/main/java/com/cryptoments/core/wallet/WalletService.java
// 기존 (line 145~152)
WalletAddress hotWallet = WalletAddress.builder()
        .id(derived.getWalletAddressId())
        .networkId(networkId)
        .address(derived.getAddress())
        .walletType(WalletType.HOT)
        .partnerId(partnerId)
        .partnerUserId(partnerUserId)
        .build();

// 변경 → status 추가
WalletAddress hotWallet = WalletAddress.builder()
        .id(derived.getWalletAddressId())
        .networkId(networkId)
        .address(derived.getAddress())
        .walletType(WalletType.HOT)
        .status(WalletAddressStatus.ACTIVE)      // ← 추가
        .partnerId(partnerId)
        .partnerUserId(partnerUserId)
        .build();
```

**수정 2 (방어적)**: `UserController.createDepositWallet()` — null-safe 처리

```java
// open-api/src/main/java/com/.../controller/v1/UserController.java:85
// 기존
.status(wallet.getStatus().name())

// 변경
.status(wallet.getStatus() != null ? wallet.getStatus().name() : "ACTIVE")
```

### 검증

```bash
curl -s http://localhost:8081/api/v1/users/deposit-wallet \
  -X POST -H "Content-Type: application/json" \
  -H "X-API-KEY: $API_KEY" -H "X-TIMESTAMP: $TS" -H "X-ACCESS-TOKEN: $SIG" \
  -d '{"partnerUserId":"test-new-user","chainType":"BSC","currencyType":"USDT"}'
# 기대: HTTP 200 + DepositWalletResponse JSON
```

---

## P1. [BUG] openapi.json SessionData 필드 노출 (28 endpoints)

### 증상

openapi.json에서 28개 엔드포인트가 내부 SessionData 필드를 query parameter로 노출:

```json
{
  "name": "DEFAULT_TOKEN_EXPIRE_DAYS",
  "in": "query",
  "required": true,    ← 실제로는 내부 상수!
  "schema": { "type": "integer" }
}
```

노출되는 7개 필드: `DEFAULT_TOKEN_EXPIRE_DAYS`, `partnerCode`, `partnerName`, `FORMAT`, `partnerId`, `sessionId`, `createDate`

### 원인

`gradle-restdoc-generator` v2.1.3가 컨트롤러 메서드의 세션 파라미터(`WidgetSessionData`, `OpenApiSessionData`)를 일반 query DTO로 인식하여 부모 `SessionData`의 모든 필드를 확장.

### 수정

**Option A (권장)**: 세션 클래스에 `@XApiIgnore` 추가

```java
// open-api/src/main/java/com/cryptoments/openapi/session/WidgetSessionData.java
import one.axim.gradle.annotation.XApiIgnore;

@XApiIgnore    // ← 추가
public class WidgetSessionData extends SessionData {
    // ...
}

// open-api/src/main/java/com/cryptoments/openapi/session/OpenApiSessionData.java
import one.axim.gradle.annotation.XApiIgnore;

@XApiIgnore    // ← 추가
public class OpenApiSessionData extends SessionData {
    // ...
}
```

**Option B (대안)**: `open-api/build.gradle`에 excludeClasses 설정

```gradle
restMetaGenerator {
    // 기존 설정 유지 ...
    excludeClasses = [
        'com.cryptoments.openapi.session.WidgetSessionData',
        'com.cryptoments.openapi.session.OpenApiSessionData'
    ]
}
```

### 검증

```bash
./gradlew :open-api:generateRestMeta
# build/docs/openapi.json 에서 DEFAULT_TOKEN_EXPIRE_DAYS 검색 → 0건이어야 함
grep -c "DEFAULT_TOKEN_EXPIRE_DAYS" open-api/build/docs/openapi.json
# 기대: 0
```

---

## P2. [SPEC] WidgetTokenRequest.permissions 타입 불일치

### 증상

| 항목 | openapi.json 스펙 | Java DTO (실제) |
|------|-------------------|----------------|
| `permissions` | `type: "string"` | `Set<String>` (JSON 배열) |

스펙을 보고 `"DEPOSIT,WITHDRAWAL,BALANCE"` (CSV 문자열)로 보내면 Jackson 400 에러:
```
Cannot construct instance of java.util.HashSet: no String-argument constructor
```

### 원인

`gradle-restdoc-generator`가 `Set<String>`을 단일 `string`으로 잘못 해석.

### 수정 (2가지 중 택 1)

**Option A (스펙 정합성 — 권장)**: 스펙이 재생성되면 자동으로 해결됨 (P1의 Generator 수정 후).
Generator가 `Set<String>`을 올바르게 `array` + `items: string`으로 생성해야 함.
→ Generator 버전 업그레이드 또는 수동 스펙 수정 필요.

**Option B (하위 호환)**: DTO에서 CSV 문자열도 수용하도록 커스텀 역직렬화

```java
// open-api/src/main/java/com/cryptoments/openapi/dto/request/WidgetTokenRequest.java
import com.fasterxml.jackson.databind.annotation.JsonDeserialize;

@Getter @Setter @Builder
@NoArgsConstructor @AllArgsConstructor
public class WidgetTokenRequest {
    private String partnerUserId;

    @JsonDeserialize(using = PermissionsDeserializer.class)
    private Set<String> permissions;
}

// PermissionsDeserializer.java (신규)
public class PermissionsDeserializer extends JsonDeserializer<Set<String>> {
    @Override
    public Set<String> deserialize(JsonParser p, DeserializationContext ctxt) throws IOException {
        if (p.currentToken() == JsonToken.START_ARRAY) {
            return ctxt.readValue(p, ctxt.getTypeFactory()
                    .constructCollectionType(HashSet.class, String.class));
        }
        // CSV 문자열 지원: "DEPOSIT,WITHDRAWAL" → Set
        String csv = p.getValueAsString();
        return Arrays.stream(csv.split(","))
                .map(String::trim)
                .collect(Collectors.toSet());
    }
}
```

### 검증

```bash
# 배열 형태 (정상)
curl -s http://localhost:8081/widgets/auth/token -X POST \
  -H "Content-Type: application/json" \
  -H "X-API-KEY: $KEY" -H "X-TIMESTAMP: $TS" -H "X-SIGNATURE: $SIG" \
  -d '{"partnerUserId":"u1","permissions":["DEPOSIT","WITHDRAWAL"]}'

# CSV 문자열 형태 (Option B 적용 시 정상)
curl -s http://localhost:8081/widgets/auth/token -X POST \
  -H "Content-Type: application/json" \
  -H "X-API-KEY: $KEY" -H "X-TIMESTAMP: $TS" -H "X-SIGNATURE: $SIG" \
  -d '{"partnerUserId":"u1","permissions":"DEPOSIT,WITHDRAWAL"}'
```

---

## P3. [SPEC] WalletResponseV1.balances 타입 불일치

### 증상

| 항목 | openapi.json 스펙 | Java DTO | 실제 JSON 출력 |
|------|-------------------|----------|---------------|
| `balances` | `type: "string"` | `Map<String, Object>` | `{"BNB": 0.039, "USDT": 13.29}` |

실제 응답:
```json
{
  "id": 1,
  "address": "0x84f2...",
  "balances": {
    "BNB": 0.0396088677,
    "USDT": 13.292244193287774,
    "USDC": 0.0
  }
}
```

### 문제점

1. 스펙에는 `string`으로 되어 있어 Swagger/codegen이 잘못된 클라이언트 코드 생성
2. `balances` 내부의 숫자가 JSON Number → **정밀도 손실** (JS 클라이언트: `13.292244193287774` → 15자리 이후 오류)

### 수정

**스펙 수정**: P1 해결 후 재생성하면 `Map<String, Object>`가 올바르게 반영될 수 있음.
그러나 Generator 동작에 따라 수동 수정 필요할 수 있음.

**정밀도 수정 (권장)**: balances Map의 value를 String으로 변환

```java
// open-api/.../controller/v1/PartnerInfoController.java
// partner/balances 응답 생성 시
Map<String, String> balanceMap = new LinkedHashMap<>();
for (var entry : rawBalances.entrySet()) {
    balanceMap.put(entry.getKey(),
        entry.getValue() instanceof BigDecimal bd
            ? bd.toPlainString()
            : String.valueOf(entry.getValue()));
}
```

또는 `WalletResponseV1.balances` 타입을 `Map<String, String>`으로 변경.

---

## P4. [SPEC] V1 API BigDecimal → JSON Number 정밀도 손실

### 증상

Widget API는 금액을 String으로 반환 (정확):
```json
{ "balance": "145.000000000000000000" }
```

V1 API는 BigDecimal을 JSON Number로 반환:
```json
{ "amount": 13.71297486, "priceKrw": 1406.0 }
```

### 영향받는 DTO (8개, 23 fields)

| DTO | 필드 |
|-----|------|
| `TransactionHistoryResponse` | amount, priceKrw, priceUsd |
| `CurrencyPriceResponseV1` | priceKrw, priceUsd |
| `CurrencyConversionResponse` | fromAmount, toAmount, exchangeRate |
| `WithdrawalResponseV1` | tokenAmount, krwAmount, tokenKrwPrice |
| `DepositReservationResponse` | amountKrw, amountCrypto, exchangeRate, actualAmountCrypto, actualAmountKrw |
| `ExchangeRateResponse.RateInfo` | usdKrw, krwUsd |
| `AximPaymentResponse` | amount, priceKrw |
| `PaymentLinkInfoResponse` | amountCrypto, priceKrw, calculatedKrwAmount |

### 수정 (필드별 `@JsonSerialize` 추가)

각 DTO의 BigDecimal 필드에 다음 어노테이션 추가:

```java
import com.fasterxml.jackson.databind.annotation.JsonSerialize;
import com.fasterxml.jackson.databind.ser.std.ToStringSerializer;

// 예: TransactionHistoryResponse
@JsonSerialize(using = ToStringSerializer.class)
private BigDecimal amount;

@JsonSerialize(using = ToStringSerializer.class)
private BigDecimal priceKrw;

@JsonSerialize(using = ToStringSerializer.class)
private BigDecimal priceUsd;
```

**적용 대상 전체 목록**:

```
open-api/src/main/java/com/cryptoments/openapi/dto/response/
├── TransactionHistoryResponse.java    → amount, priceKrw, priceUsd (3)
├── CurrencyPriceResponseV1.java       → priceKrw, priceUsd (2)
├── CurrencyConversionResponse.java    → fromAmount, toAmount, exchangeRate (3)
├── WithdrawalResponseV1.java          → tokenAmount, krwAmount, tokenKrwPrice (3)
├── DepositReservationResponse.java    → amountKrw, amountCrypto, exchangeRate,
│                                         actualAmountCrypto, actualAmountKrw (5)
├── ExchangeRateResponse.java          → RateInfo.usdKrw, RateInfo.krwUsd (2)
├── AximPaymentResponse.java           → amount, priceKrw (2)
└── PaymentLinkInfoResponse.java       → amountCrypto, priceKrw, calculatedKrwAmount (3)
```

### 검증

```bash
# 수정 전: "amount": 13.71297486 (숫자)
# 수정 후: "amount": "13.71297486" (문자열)
curl -s http://localhost:8081/api/v1/transactions/deposits/unconfirmed \
  -H "X-API-KEY: $KEY" -H "X-TIMESTAMP: $TS" -H "X-ACCESS-TOKEN: $SIG" \
  | python3 -c "import sys,json; d=json.load(sys.stdin); print(type(d[0]['amount']), d[0]['amount'])"
# 기대: <class 'str'> 13.71297486
```

### ⚠️ 하위 호환성 주의

이 변경은 V1 API 소비자의 JSON 파싱에 영향을 줄 수 있음:
- 기존: `"amount": 13.71` (number) → 클라이언트가 number로 파싱
- 변경: `"amount": "13.71"` (string) → 클라이언트가 string→number 변환 필요

**판단 기준**: 현재 V1 API 소비자가 있는지 확인 후 적용. 소비자가 없으면 즉시 적용. 있으면 신규 V2 API에만 적용하고 V1은 유지.

---

## P5. [SECURITY] ExchangeRateResponse에서 partnerId 노출

### 증상

`GET /widgets/api/exchange-rates` 응답에 내부 DB PK 노출:

```json
{
  "partnerId": 1,       ← 보안 우려
  "baseCurrency": "USD",
  "rates": { "usdKrw": 1436.0, "krwUsd": 0.0006963788 },
  "lastUpdated": 1774955263
}
```

### 수정

```java
// open-api/src/main/java/com/cryptoments/openapi/dto/response/ExchangeRateResponse.java
import com.fasterxml.jackson.annotation.JsonIgnore;

@JsonIgnore        // ← 추가
private Long partnerId;
```

또는 빌더에서 아예 `partnerId` 세팅을 제거.

---

## P6. [STYLE] DateTime 포맷 일관성

### 현황

현재 모든 V1/Widget 응답이 `yyyy-MM-dd HH:mm:ss` 포맷 사용 중 (ISO 8601 아님).
openapi.json 스펙은 `"format": "date-time"` (ISO 8601)로 명시.

### 판단

**현재 상태 유지 권장** — V1 호환성 우선.
단, openapi.json 스펙의 `format` 설명을 수정하거나, 스펙에 `example: "2025-08-03 11:06:55"` 추가.

향후 V2 전용 API 신설 시 ISO 8601 (`yyyy-MM-dd'T'HH:mm:ss'Z'`) 채택 검토.

---

## P7. [SPEC] 누락 스키마 3개

openapi.json에 다음 DTO가 누락:

| Java DTO | 사용 컨트롤러 | 원인 추정 |
|----------|-------------|----------|
| `WidgetConfigResponse` | WidgetConfigController | `basePackage` 범위 밖 또는 컨트롤러 스캔 누락 |
| `AuthResponse` | WidgetAuthController | 내부 인증 응답 → 스캔 제외됨 |
| `DepositCompleteResponse` | DepositReservationController | 최근 추가 → 재생성 필요 |

### 수정

```bash
# openapi.json 재생성
./gradlew :open-api:generateRestMeta

# 누락 스키마 확인
grep -c "WidgetConfigResponse\|AuthResponse\|DepositCompleteResponse" \
  open-api/build/docs/openapi.json
# 기대: 3건 이상
```

재생성 후에도 누락이면 `basePackage`에 해당 패키지 추가:
```gradle
// open-api/build.gradle
restMetaGenerator {
    basePackage = 'com.cryptoments.openapi,com.cryptoments.common.exception'
    // 필요 시 추가 패키지 포함
}
```

---

## 테스트 결과 요약 (2026-03-31)

| # | 테스트 | HTTP | 결과 | 비고 |
|---|--------|------|------|------|
| 1-1 | GET /ping | 200 | ✅ | |
| 1-2 | GET /health | 200 | ✅ | DB UP |
| 1-3 | GET /api/v1/chains | 200 | ✅ | 3개 체인 |
| 1-4 | GET /actuator/health | 500 | ⚠️ | XExceptionHandler 간섭 (기존 알려진 이슈) |
| 2-1 | v1 API no auth | 401 | ✅ | |
| 2-2 | Widget API no auth | 401 | ✅ | |
| 2-3 | Widget invalid key | 401 | ✅ | |
| 2-4 | Widget expired TS | 401 | ✅ | |
| 3-1 | Webhook normal | 200 | ✅ | |
| 3-2 | Webhook idempotent | 200 | ✅ | |
| 3-3 | Webhook malformed | 200 | ✅ | |
| 4-1 | Widget token | 200 | ✅ | |
| 4-2 | v1 partner/chains | 200 | ✅ | `activatedAt` 정상 |
| 4-3 | v1 partner/balances | 200 | ✅ | `balances` Map, BigDecimal→Number |
| 4-4 | v1 unconfirmed deposits | 200 | ✅ | 14건, `amount` Number |
| 4-5 | v1 currency-prices | 200 | ✅ | priceKrw Number |
| 4-6 | Widget chains | 200 | ✅ | |
| 4-7 | Widget tokens | 200 | ✅ | 3개 토큰 |
| 4-8 | Widget balance | 200 | ✅ | `balance` String ✅ |
| 4-9 | Widget exchange-rates | 200 | ✅ | `partnerId` 노출(P5), `rates` BigDecimal→Number(P4) |
| 4-10 | Widget transactions | 200 | ✅ | 72건, `amount` String ✅ |
| 4-11 | Widget widget-config | 200 | ✅ | 스키마 누락(P7) |
| 4-12 | **Deposit wallet** | **500** | **❌** | **NPE: wallet.getStatus() is null (P0)** |
| 4-13 | v1 user transactions | 200 | ✅ | |
| 4-14 | Callback test | 200 | ✅ | |

**최종: 24 PASS / 1 FAIL / 0 SKIP**

---

## 수정 체크리스트

| # | 우선순위 | 작업 | 파일 | 완료 |
|---|---------|------|------|------|
| 1 | **P0** | createHotWallet에 `.status(ACTIVE)` 추가 | `core/.../WalletService.java` | ☐ |
| 2 | **P0** | createDepositWallet null-safe 방어 | `open-api/.../UserController.java` | ☐ |
| 3 | **P1** | WidgetSessionData에 `@XApiIgnore` | `open-api/.../session/WidgetSessionData.java` | ☐ |
| 4 | **P1** | OpenApiSessionData에 `@XApiIgnore` | `open-api/.../session/OpenApiSessionData.java` | ☐ |
| 5 | **P1** | openapi.json 재생성 + 검증 | `./gradlew :open-api:generateRestMeta` | ☐ |
| 6 | P2 | permissions CSV 하위호환 역직렬화 (선택) | `open-api/.../WidgetTokenRequest.java` | ☐ |
| 7 | P3 | WalletResponseV1.balances BigDecimal→String | `open-api/.../PartnerInfoController.java` | ☐ |
| 8 | P4 | 23개 BigDecimal 필드 `@JsonSerialize` | 8개 Response DTO 파일 | ☐ |
| 9 | P5 | ExchangeRateResponse.partnerId `@JsonIgnore` | `open-api/.../ExchangeRateResponse.java` | ☐ |
| 10 | P7 | openapi.json 재생성 후 누락 스키마 확인 | `open-api/build.gradle` | ☐ |
