# partner_user_id 공백 정규화 + Axim connectId 권위값 전환 지침서

- 작성일: 2026-08-24
- 대상 모듈: `common`, `open-api`, `partner-api`
- 사유: 2026-08-24 파트너 36(피터) 회원 3명(kyum1996 / san105 / khc1193) Axim eKYC 조회 500 장애

---

## 1. 장애 요약 (수정 전 반드시 읽을 것)

파트너 36이 링크 생성 API에 `partner_user_id`를 **앞뒤 공백이 섞인 채로** 보냈다.
`matching_links` 원문 hex로 확정:

| 링크 | hex | 실제 값 |
|---|---|---|
| 8/23 mlnk_19c4e129… | `6B686331313933` | `khc1193` |
| 8/24 mlnk_649ece29… | `6B68633131393320` | `khc1193 ` ← 뒤 공백 |

Axim connectId는 `hex(aximPartnerId + ":" + partnerUserId)` 로 **바이트 정확**하게 만들어진다.
공백이 붙으면 `20:khc1193 ` 이 되어 Axim에 등록된 적 없는 connectId가 되고,
Axim은 이를 404가 아니라 **HTTP 500**으로 응답한다.

```
14:50:44 getEkycStatus 실패: connectId=32303a6b68633131393320("20:khc1193 "), error=500  (4회)
14:52:55 회원 해지 → 14:53:47 재연결 → 공백 버전으로 Axim 신규 등록
14:57:12 정상화
```

**증상이 혼란스러운 이유**: `external_wallets.partner_user_id` 컬레이션이 `utf8mb4_unicode_ci`(PAD SPACE)라
`'khc1193' = 'khc1193 '` 가 **참**이다. 그래서 우리 DB 조회·연결상태 API는 "연결됨"이라 답하는데
Axim에 던지는 connectId만 어긋난다. **DB는 관대, Axim은 엄격** — 이 비대칭이 근본 원인이다.

kyum1996은 방향이 반대로도 재현되어 원인이 확정된다 — 8/23엔 공백 링크로 Axim에 등록됐고,
8/24엔 파트너가 무공백 ID를 보내면서 다시 깨졌다. **공백 유무가 왕복하면 매번 재연결을 강요한다.**

### 운영 데이터 오염 현황 (2026-08-24 실측)

| 테이블 | 오염 행 |
|---|---|
| matching_links | 12 |
| deposits | 11 |
| p2p_deposit_orders | 9 |
| withdrawals | 9 |
| torq_trades | 9 |
| external_wallets | 6 |
| payment_links / deposit_reservations / wallet_addresses | 각 2 |
| axim_payments | 1 |

전부 **파트너 36 단독**. 다른 파트너는 0건.

### ⚠️ 이 지침의 핵심 제약

현재 **공백 connectId로 정상 연결되어 있는 회원이 3명** 있다:

| external_wallets.id | partner_user_id | connection_id |
|---|---|---|
| 172 | `qqq100 ` | `32303a71717131303020` |
| 347 | `san105 ` | `32303a73616e31303520` |
| 348 | `khc1193 ` | `32303a6b68633131393320` |

**단순히 trim만 넣으면 이 3명이 배포 직후 다시 깨진다.** trim된 ID로 connectId를 재생성하면
Axim에 등록된 공백 버전과 어긋나기 때문이다.

→ 그래서 이 지침은 **trim(§3) + connectId 권위값 전환(§4)을 반드시 한 세트로** 요구한다.
§4가 빠지면 이번 수정은 장애를 고치는 게 아니라 3명에게 새로 일으킨다.

---

## 2. 코딩 규칙

- Java 17. `@Getter @Setter @Builder(toBuilder = true) @NoArgsConstructor @AllArgsConstructor` 사용, `@Data` 금지
- 신규 유틸은 `final class` + `private` 생성자 + `static` 메서드
- DTO 멤버 변수에는 JavaDoc 또는 한 줄 주석 필수
- 예외는 `ErrorCodes` 상수 참조
- **신규 파일은 최소로.** 신설 허용은 §3-1, §3-2, §4-1 세 개뿐

---

## 3. P0 — 공백 정규화

### 3-1. [신규] `common/src/main/java/com/cryptoments/common/util/PartnerUserIds.java`

```java
package com.cryptoments.common.util;

/**
 * partner_user_id 정규화 유틸.
 *
 * <p>파트너가 앞뒤 공백이 섞인 회원 ID를 보내면 Axim connectId(바이트 정확 hex)가 어긋나
 * 연결 조회가 실패한다. DB 컬레이션(utf8mb4_unicode_ci, PAD SPACE)은 후행 공백을 무시하므로
 * 조회는 성공하는데 외부 연동만 깨지는 비대칭이 생긴다. 진입점에서 일괄 정규화한다.
 */
public final class PartnerUserIds {

    private PartnerUserIds() {
    }

    /**
     * 앞뒤 공백 제거. null 또는 공백뿐이면 null 반환.
     *
     * @param partnerUserId 원본 파트너 회원 ID
     * @return 정규화된 ID (없으면 null)
     */
    public static String normalize(String partnerUserId) {
        if (partnerUserId == null) {
            return null;
        }
        String trimmed = partnerUserId.trim();
        return trimmed.isEmpty() ? null : trimmed;
    }
}
```

> `trim()`은 U+0020 이하만 제거한다. 실측 오염은 전부 U+0020이므로 충분하다. `strip()`으로 바꾸지 말 것 —
> 전각 공백까지 제거하면 정상 ID를 변형시킬 위험이 생긴다.

### 3-2. [신규] `common/src/main/java/com/cryptoments/common/util/AximConnectIds.java`

`generateConnectId`가 open-api에 **9곳 복붙**되어 있다. 전부 제거하고 이 한 곳으로 모은다.
복붙이 남아 있으면 열 번째 복붙에서 같은 버그가 재발한다.

```java
package com.cryptoments.common.util;

import java.nio.charset.StandardCharsets;

/**
 * Axim connectId 생성 유틸.
 *
 * <p>형식: hex(aximPartnerId + ":" + partnerUserId) — Axim 측과 바이트 단위로 일치해야 하므로
 * partnerUserId는 반드시 정규화된 값이어야 한다.
 */
public final class AximConnectIds {

    private AximConnectIds() {
    }

    /**
     * connectId 생성. aximPartnerId 또는 partnerUserId가 없으면 null 반환.
     *
     * @param aximPartnerId Axim 측 파트너 ID
     * @param partnerUserId 파트너 회원 ID (내부에서 정규화)
     * @return hex 인코딩된 connectId (생성 불가 시 null)
     */
    public static String generate(Long aximPartnerId, String partnerUserId) {
        String normalized = PartnerUserIds.normalize(partnerUserId);
        if (aximPartnerId == null || normalized == null) {
            return null;
        }
        byte[] bytes = (aximPartnerId + ":" + normalized).getBytes(StandardCharsets.UTF_8);
        StringBuilder hex = new StringBuilder(bytes.length * 2);
        for (byte b : bytes) {
            hex.append(String.format("%02x", b));
        }
        return hex.toString();
    }
}
```

**제거 대상 `private String generateConnectId(...)` 9곳** (전부 삭제하고 §4-1 resolver 또는 `AximConnectIds.generate` 호출로 교체):

```
open-api/.../controller/widget/AximController.java:140
open-api/.../controller/widget/PaymentLinkController.java:378
open-api/.../controller/widget/PhonepayWidgetController.java:236
open-api/.../controller/widget/TorqWidgetController.java:417
open-api/.../controller/widget/MatchingLinkWidgetController.java:281
open-api/.../controller/widget/PhonepayLinkWidgetController.java:227
open-api/.../controller/widget/TorqLinkWidgetController.java:250
open-api/.../controller/widget/P2pDepositLinkWidgetController.java:204
open-api/.../service/P2pEkycGuard.java:131
```

### 3-3. 세션 객체에서 정규화 — 위젯 6개 경로를 한 번에 방어

`WidgetSessionData`는 6곳에서 생성된다(위젯 토큰 발급 1 + 링크 경유 5). 생성자·setter에서 정규화하면
6곳을 개별 수정하지 않고 전부 막힌다.

- `open-api/src/main/java/com/cryptoments/openapi/session/WidgetSessionData.java`
  - 생성자(`:23-31`)에서 `this.partnerUserId = PartnerUserIds.normalize(partnerUserId);`
  - setter(`:40`)도 동일
- `open-api/src/main/java/com/cryptoments/openapi/session/P2pPageSessionData.java`
  - 생성자(`:22-28`) / setter(`:37`) 동일

> 세션은 HMAC 서명 토큰으로 직렬화/역직렬화된다. **필드 추가·삭제·타입 변경 금지** — 값 정규화만 할 것.
> 기존 발급 토큰은 그대로 역직렬화되어야 한다.

### 3-4. Webhook 역변환 방어

- `open-api/src/main/java/com/cryptoments/webhook/controller/AximWebhookController.java:339-358`
  `extractPartnerUserId()` 의 **모든 return 값**(v2 hex 디코딩 `:349`, v1 폴백 `:357`, 원문 폴백)에
  `PartnerUserIds.normalize(...)` 적용.

이미 발급된 공백 connectId로 들어오는 웹훅이 신규 오염 행(`external_wallets.partner_user_id`)을
만드는 것을 차단한다.

### 3-5. 링크 생성 요청 DTO 게터 정규화

링크가 세션과 connectId의 소스이므로 **저장 시점에 차단**한다.
이 저장소의 관용구는 `CreateWithdrawalRequest.java:57`(orderId)의 **게터 오버라이드 정규화**다. 동일하게 적용:

```java
/** 파트너 회원 ID (앞뒤 공백 제거) */
public String getPartnerUserId() {
    return PartnerUserIds.normalize(partnerUserId);
}
```

| 파일 | 라인 |
|---|---|
| `partner-api/.../dto/p2p/CreateMatchingLinkRequest.java` | 13 |
| `partner-api/.../dto/p2p/CreateP2pDepositLinkRequest.java` | 15 |
| `partner-api/.../dto/request/CreatePaymentLinkRequest.java` | 22 |
| `partner-api/.../dto/request/CreateTorqLinkRequest.java` | 18 |
| `partner-api/.../dto/request/CreatePhonepayLinkRequest.java` | 18 |

> Lombok `@Getter`가 붙어 있어도 **명시 게터가 우선**한다. 필드는 그대로 두고 게터만 추가할 것.
> `@NotBlank`가 붙은 DTO는 검증이 정규화 **전** 원본 필드에 적용되므로 동작이 바뀌지 않는다.

---

## 4. P0 — Axim connectId 권위값 전환 (§3과 한 세트, 생략 불가)

### 원칙

**이미 연결된 회원의 connectId는 `external_wallets.connection_id` 가 권위값이다.**
계산으로 만들지 않는다. 계산은 "아직 연결이 없어 새로 연결시켜야 할 때"만 쓴다.

이 전환으로 얻는 것 두 가지:
1. 공백으로 연결된 기존 3명(172/347/348)이 배포 후에도 **무중단**으로 동작한다
2. 파트너가 앞으로 공백 유무를 왕복시켜도 **연결이 다시 깨지지 않는다** (§3만으로는 못 막는 부분)

### 4-1. [신규] `open-api/src/main/java/com/cryptoments/openapi/service/AximConnectIdResolver.java`

```java
@Service
@RequiredArgsConstructor
public class AximConnectIdResolver {

    private final ExternalWalletRepository externalWalletRepository;

    /**
     * 파트너 회원의 활성 AXIM 연결 지갑 조회.
     * 여러 건이면 최근 연결 우선(connectedAt desc, id desc) — 정렬 미지정 시
     * 오래된 행이 선택되어 만료 토큰을 쓰게 되는 것을 막는다.
     */
    public ExternalWallet findConnectedWallet(Long partnerId, String partnerUserId) { ... }

    /**
     * Axim 조회/연동에 사용할 connectId 결정.
     * 활성 연결이 있으면 저장된 connection_id(권위값), 없으면 정규화된 ID로 신규 생성.
     */
    public String resolveConnectId(Long partnerId, Long aximPartnerId, String partnerUserId) {
        ExternalWallet wallet = findConnectedWallet(partnerId, partnerUserId);
        if (wallet != null && wallet.getConnectionId() != null) {
            return wallet.getConnectionId();
        }
        return AximConnectIds.generate(aximPartnerId, partnerUserId);
    }
}
```

구현 요구사항:
- `findConnectedWallet`: `externalWalletRepository.findByPartnerIdAndPartnerUserId(partnerId, PartnerUserIds.normalize(partnerUserId))`
  → `connectionType == AXIM` && `status == CONNECTED` 필터
  → `connectedAt` desc, `id` desc 정렬 (**`connectedAt` null 안전**하게)
  → 첫 건 반환, 없으면 null
- Repository에 **새 메서드를 추가하지 말 것.** 기존 `findByPartnerIdAndPartnerUserId(Long, String)`(List 반환)을 그대로 쓴다
- `partnerUserId`가 null이면 `findConnectedWallet`은 null 반환 (DB 조회 스킵)

### 4-2. connectId 사용처를 resolver로 교체

아래 지점은 **기존 연결에 대한 조회**이므로 반드시 `resolveConnectId` 사용:

| 파일:라인 | 용도 |
|---|---|
| `open-api/.../service/P2pEkycGuard.java:74` | eKYC 게이트 (**이번 장애 지점**) |
| `open-api/.../controller/widget/AximController.java:109, 128, 492, 543` | init-info / eKYC status / eKYC info |
| `open-api/.../controller/widget/PaymentLinkController.java:198` | 위젯 진입 |
| `open-api/.../controller/widget/PhonepayWidgetController.java:130` | 위젯 진입 |
| `open-api/.../controller/widget/TorqWidgetController.java:162` | 위젯 진입 |
| `open-api/.../controller/widget/MatchingLinkWidgetController.java:147` | 위젯 진입 |
| `open-api/.../controller/widget/PhonepayLinkWidgetController.java:147` | 위젯 진입 |
| `open-api/.../controller/widget/TorqLinkWidgetController.java:153` | 위젯 진입 |
| `open-api/.../controller/widget/P2pDepositLinkWidgetController.java:168` | 위젯 진입 |

> 미연결 회원은 `findConnectedWallet`이 null → `AximConnectIds.generate` 폴백이 동작하므로
> **신규 연결 딥링크 발급 동작은 그대로**다.

### 4-3. 활성 지갑 조회 10곳을 `findConnectedWallet`으로 통일

현재 10곳이 `findByPartnerIdAndPartnerUserId(...).stream().filter(CONNECTED).findFirst()` 를
**정렬 없이** 복붙하고 있다. `@XRepository` 파생 쿼리에는 `ORDER BY`가 없어 CONNECTED가 2건 이상일 때
오래된 행이 선택될 수 있고, 그 행의 `connect_wallet_token`은 만료되어 Axim push가 실패한다.

교체 대상:

```
open-api/.../controller/widget/AximController.java:172-178, 207-212, 260-265
open-api/.../controller/widget/PaymentLinkController.java:207-212
open-api/.../controller/widget/TorqLinkWidgetController.java:162-166
open-api/.../controller/widget/MatchingLinkWidgetController.java:154-157
open-api/.../controller/widget/P2pDepositLinkWidgetController.java:177-181
open-api/.../controller/widget/PhonepayLinkWidgetController.java:156-160
```

**주의 — 동작을 바꾸지 말 것:**
- `AximController.java:172-178`(connection-status)에는 `.filter(aximService::verifyConnectionActive)`가
  추가로 걸려 있다. 이 필터는 **자가치유 부작용이 있는 의도된 동작**이므로 반드시 유지한다.
  → `findConnectedWallet` 결과에 대해 호출부에서 `verifyConnectionActive` 검사를 이어서 수행할 것
- `AximController.java:260-265`(결제 생성)는 못 찾으면 `NotFoundException(ErrorCodes.WALLET_NOT_FOUND)`,
  나머지는 `null` 반환이다. **각 호출부의 기존 null/예외 semantics를 그대로 유지**할 것
- `AximController.java:478-481`, `P2pEkycGuard.java:82-85`는 `anyMatch(CONNECTED)` boolean이다.
  `findConnectedWallet(...) != null` 로 바꿔도 동치이므로 교체 가능
- `AximService.java:154-160`(IDOR 소유권 검증)은 **status 무관 전체 목록**이 의도다. **교체 금지**
- `PartnerUserContextController.java:148`(콘솔 목록)도 전체 목록 반환이 의도다. **교체 금지**

---

## 5. P1 — 일관성

### 5-1. 나머지 요청 DTO 게터 정규화 (§3-5와 동일 패턴)

```
partner-api: CreateWithdrawalRequest:40, CreateP2pWithdrawalRequest:39,
             RequestAximPaymentRequest:20, CreateP2pDepositRequest:19,
             CreateP2pMemberRequest:16, WalletCreateRequest:11
open-api:    WidgetTokenRequest:17, AximPaymentRequest:17, WithdrawalRequest:16,
             UserWithdrawalRequest:21, DepositAddressRequest:15,
             CreateDepositWalletRequest:18, DepositReservationRequest:44(getPartnerUserId)
admin-api:   DepositMatchRequest:21
```

- `CreateP2pWithdrawRequest:22`는 컨트롤러가 폐기(예외 throw) 상태 → **건드리지 말 것**
- `ExternalWalletSearchRequest:12`는 검색용 → **건드리지 말 것**

### 5-2. 완전일치 조회 파라미터 정규화

`findByPartnerIdAndPartnerUserId` 완전일치 조회라 앞 공백이 들어오면 무조건 빈 결과가 된다.

```
partner-api/.../controller/PartnerUserContextController.java:57, 75, 93, 111, 127, 146  (@PathVariable 6개)
open-api/.../controller/widget/DepositReservationController.java:138, 215                (@RequestParam 2개)
```

각 메서드 진입 직후 `partnerUserId = PartnerUserIds.normalize(partnerUserId);` 로 로컬 정규화.

> **전역 `StringTrimmerEditor`(`@ControllerAdvice` + `WebDataBinder`) 등록 금지.**
> 모든 문자열 파라미터에 영향이 가서 부수효과 범위를 통제할 수 없다.

### 5-3. 저장 직전 최종 방어

```
core/.../p2p/P2pMemberService.java:66     ← getOrCreate. 공백 유입 시 중복 회원 레코드 생성 위험. 우선순위 상위
core/.../axim/AximService.java:182        ← AximPayment 저장
core/.../axim/AximService.java:337        ← ExternalWallet 저장 (웹훅 디코딩 값)
core/.../p2p/MatchingLinkService.java:95
core/.../p2p/P2pDepositLinkService.java:99
```

---

## 6. 범위 밖 — 손대지 말 것

| 대상 | 이유 |
|---|---|
| `core/.../wallet/WalletService.java:155, 319` (HD 파생) | `wallet_addresses`에 공백 기준으로 이미 파생된 주소 2건(id 557 `[ top100]`, 561 `[qqq100 ]`)이 있다. 정규화하면 **다른 주소가 파생**되어 기존 입금 주소가 바뀐다. 별도 검토 필요 |
| `common/.../client/dto/WalletDeriveRequest.java:32` | 위와 동일 이유 |
| 전역 `StringTrimmerEditor` | §5-2 참조 |
| 검색용 `@RequestParam`(LIKE 조회) | 저장/완전일치가 아니라 실익 없음 |
| 기존 오염 데이터 DML(`UPDATE ... SET partner_user_id = TRIM(...)`) | 오너 승인 필요. 이번 작업 범위 아님 |
| `git push` | **금지.** 커밋까지만. 다른 작업과 묶어 배포 예정 |

---

## 7. 완료 기준

1. `./gradlew :common:compileJava :core:compileJava :open-api:compileJava :partner-api:compileJava :admin-api:compileJava` 전부 성공
2. `private String generateConnectId` 문자열이 저장소에 **0건** (`grep -rn "generateConnectId" --include=*.java`)
3. §4-2의 9개 지점이 모두 `resolveConnectId` 사용 — 계산으로 connectId를 만드는 조회 경로가 없을 것
4. §4-3 교체 후 각 호출부의 null/예외 semantics가 **변경 전과 동일**
5. `WidgetSessionData` / `P2pPageSessionData`의 **필드 구성 무변경** (직렬화 호환)
6. Entity에 `@XIgnoreColumn` 추가 없음, DDL 변경 없음, XML 매퍼 신설 없음
7. 신규 파일은 §3-1 / §3-2 / §4-1 세 개뿐

## 8. 배포 후 검증 포인트

1. **회원 172/347/348 무중단** — qqq100 / san105 / khc1193 위젯 진입 시 Axim eKYC 조회 성공
   (`grep "getEkycStatus 실패" open-api/logs/default.log` 신규 0건)
2. **공백 링크 차단** — 파트너 36이 다시 공백 ID를 보내도
   `SELECT ... FROM matching_links WHERE partner_user_id RLIKE '^[[:space:]]|[[:space:]]$'` 신규 행 증가 없음
3. **미연결 회원 신규 연결 정상** — 연결 이력 없는 회원의 딥링크 발급·`connection.succeeded` 웹훅 수신 정상
