# HQ 콘솔 Phase 4 — 원시 데이터 탐색기 (백엔드 + UI)

> 설계 원본: `HQ_CONSOLE_DESIGN.md` §4-D · §7.4 · 부록 B R13
> 선행: Phase 3 통계 — **완료**
> 대상: `partner-api` + `cryptoments-admin/hq-ui`

---

## 0. ☠️ 순서 변경 — CSV 내보내기는 이 Phase 에서 하지 않는다

설계 §9 는 Phase 4 에 "마스킹 + **내보내기**", Phase 4.5 에 "감사 로그 신설"을 뒀다. **순서를 바꾼다.**

> 내보내기가 감사보다 먼저면 **누가 언제 무엇을 반출했는지 기록이 없다.**
> 그룹 전체 원시 데이터를 CSV 로 뽑는 기능이라 되돌릴 수 없다.

**Phase 4 = 조회 전용.** 내보내기는 Phase 4.5 에서 감사 로그와 **한 세트로** 만든다.
화면에 "내보내기" 버튼을 두지 마라 — 비활성 버튼도 두지 마라(곧 된다는 신호가 된다).

---

## 1. PII 지형 (2026-08-25 `information_schema` 실측)

### 1-1. 마스킹 대상 — 서버 DTO 에서 변환

| 테이블 | 컬럼 | 마스킹 |
|---|---|---|
| `p2p_deposit_orders` | `buyer_name`, `buyer_account_holder` | `홍*동` |
| | `buyer_account_number` | `****1234` (뒤 4) |
| | `buyer_phone` | `****5678` (뒤 4) |
| `torq_trades` | `buyer_name`, `buyer_account_holder`, `seller_name` | `홍*동` |
| | `seller_account` | 뒤 4 |
| | `buyer_phone`, `recipient_phone` | 뒤 4 |
| `p2p_matches` | `confirmed_depositor_name` | `홍*동` |
| `bank_accounts` | `account_holder` / `account_number` | `홍*동` / 뒤 4 |

`bank_code` · `bank_name` 은 마스킹하지 않는다(개인 식별 정보가 아니다).
온체인 주소 · `tx_hash` 도 마스킹하지 않는다(공개 원장).

### 1-2. ☠️ 마스킹으로 막을 수 없는 컬럼 — **응답에서 제외한다** (설계 R13)

| 테이블 | 컬럼 | 이유 |
|---|---|---|
| `dispute_events` | `evidence_url`, `payload_json` | URL 은 이름 패턴에 안 걸린다. **증빙 파일은 접근 제어가 없어 URL 을 아는 사람이면 누구나 연다**(2026-08-18 기록). `payload_json` 에는 관리자 판정 메모가 실린다 |
| `p2p_disputes` | `evidence_url` | 동상 |
| `p2p_matches` | `dispute_evidence_url`, **`resolve_memo`** | `resolve_memo` 는 **관리자 판정 메모**다 |
| `torq_trades` | `dispute_evidence_url`, `dispute_statement` | 자유 텍스트에 이름·계좌가 그대로 적힌다 |
| `p2p_members` | `memo` | 자유 텍스트 |
| `p2p_withdraw_orders` | `refund_memo` | 자유 텍스트 |
| `p2p_matches` | `bank_transfer_ref` | 이체 참조에 이름이 들어간다 |
| `withdrawals` | `partner_metadata` | 파트너가 임의로 채우는 JSON — 무엇이 들어 있는지 서버가 통제하지 못한다 |
| `withdrawals`, `torq_trades` | **`partner_reference`** | ☠️ **마스킹한 `partner_user_id` 를 평문으로 되돌린다.** 운영 실측: `partner_user_id=Jessica` 인 행의 `partner_reference` 가 `W-260807153941-SG98A-Jessica` (id 695/699/701). 포맷이 파트너 재량이라 어디를 가릴지 서버가 알 수 없어 마스킹이 불가능하다. 검색은 `withdrawal_code`·`ref_code` 접두 일치로 충분 (2026-08-25 적대적 리뷰 P1) |

**대신 `hasEvidence` boolean 만 내려준다.** 자유 텍스트는 존재 여부도 알릴 필요가 없으면 아예 뺀다.

⚠️ `withdrawals.error_message` 는 **시스템 생성 문자열이 아니다.** 운영에 `누군지 모름` ·
`처리됨` 같은 사람이 친 값이 실재한다(실명·계좌는 스캔에서 발견되지 않아 노출은 아니지만,
**자유 텍스트로 취급**할 것 — "시스템 생성이라 안전" 을 근거로 다른 컬럼을 끼워 넣지 마라).

### 1-2b. ☠️ 개인정보가 아닌데 가려야 하는 것 — 그룹 밖 상대의 **상업 조건**

크로스그룹 `p2p_matches` 에서 상대 파트너의 id·이름은 SQL 이 지운다. 그런데 **요율은 그대로
나갔다** — 익명이라도 남의 수수료 구조가 매칭 한 건마다 읽힌다. 상대가 그룹 밖이면 **그 쪽
값을 `null` 로 접는다**.

| 상대 | 접는 필드 |
|---|---|
| 판매측 external | `withdraw_fee_rate`, `withdraw_bonus_rate`, `rebate_rate`(출금 파트너가 받는 몫 — DDL v2.8), `withdraw_order_id` |
| 구매측 external | `fee_rate`(입금자 수수료 = 구매측 체인 합), `deposit_order_id` |

내부 PK 를 함께 접는 이유는 연속값이라 **열거하면 그룹 밖 주문 규모가 드러나기** 때문이다.
판정은 `Boolean.TRUE.equals(external)` — 플래그가 `null` 인 행(TORQ 레그)을 "외부" 로 읽으면
**내 그룹 데이터를 스스로 지운다.**

☠️ **DTO 에 필드를 만들어놓고 프론트에서 안 그리는 방식은 금지.** API 를 직접 호출하면 그대로 나온다.
**엔티티 → DTO 변환 시점에 값 자체를 만들지 않는다.**

### 1-3. 마스킹 위치 — 서버, 예외 없음

```
Entity → [서버 DTO 변환 시 마스킹] → JSON → 프론트는 받은 값을 그대로 표시
```

프론트 마스킹은 **API 직접 호출로 뚫린다.** 마스킹 유틸을 프론트에 만들지 마라 —
만들면 다음 사람이 "여기서 마스킹하니까 서버는 원본으로" 라고 오해한다.

---

## 2. 데이터셋 (7종)

`GET /api/hq/explorer/{dataset}` — `XPage`. `XPagination` 1-based.

| dataset | 테이블 | 그룹 스코프 | 마스킹 |
|---|---|---|---|
| `deposits` | `deposits` | `p.root_partner_id` | `partner_user_id` 부분 |
| `withdrawals` | `withdrawals` | 동상 | 주소·해시 노출(공개 원장) |
| `ledger` | `ledger_entries` | 동상 | 없음 |
| `p2p-orders` | `p2p_deposit_orders` + `p2p_withdraw_orders` | 동상 | §1-1 |
| `p2p-matches` | `p2p_matches` | 구매측·판매측 **양쪽 파트너 중 하나라도 내 그룹** | §1-1, `hasEvidence` |
| `lp-trades` | `torq_trades` | `p.root_partner_id` | §1-1, `hasEvidence` |
| `daily-fees` | `settlement_daily_fees` | `source_partner_id` 기준 | 없음 |
| `realizations` | `settlement_realizations` | `participant_partner_id` 기준 | 그룹 외 발생 파트너는 이름 `null` |

⚠️ `p2p-matches` 는 **크로스그룹 매칭에서 상대 파트너가 내 그룹 밖일 수 있다.**
그 경우 상대 파트너 id·이름을 내려보내지 마라 — Phase 2 의 `EXTERNAL` 처리와 같은 원칙.

⚠️ `daily-fees` 는 **`participant_partner_id` 가 그룹 밖인 행**(크로스그룹 REBATE)이 나온다.
Phase 2 §3 과 동일하게 참여자 식별정보를 제거하고 "외부"로만 표기한다.

---

## 3. 공통 규칙 (Phase 2·3 과 동일 — 재확인)

| 규칙 | 비고 |
|---|---|
| 그룹 스코프 `root_partner_id = #{hqId}` | 예외 없이. `hqId` 는 세션에서만 |
| 금액 `PlainBigDecimalSerializer` | ☠️ `ToStringSerializer` 는 1e-6 미만에서 지수표기 → 프론트가 못 읽는다 |
| KRW(`currency_id=99`) 는 `Long` | USDT 문자열과 타입 분리 |
| `<script>` 내 `<`/`<=`/`<>` 금지 | 위반 시 **기동 시 전 서비스 다운** |
| MyBatis 3규칙 | `XPagination` 첫 / `Class<?>` 마지막 / `XPage<T>` 반환. ORDER BY·LIMIT·COUNT 직접 작성 금지 |
| Jackson `non_null` | ☠️ 프론트에서 `=== null` 비교 금지 — 필드가 **사라진다**. `== null` 사용 |
| DTO 멤버 JavaDoc / `@Data` 금지 / XML 매퍼 금지 | |

**필터 공통**: 기간(`from`/`to`) · 파트너(`partnerId`, 그룹 외면 400) · 통화·네트워크 ·
상태 · 코드·해시 검색(`keyword`).

⚠️ 검색은 `LIKE CONCAT('%', #{keyword}, '%')` 가 인덱스를 못 탄다. 코드·해시는
**접두 일치(`CONCAT(#{keyword}, '%')`)를 기본**으로 하고, 전체 일치 검색이 필요하면 별도 파라미터로.

---

## 4. UI

라우트 `/hq/explorer/:dataset`. 사이드바에 "원시 데이터" 섹션.

- 데이터셋 탭 7개
- 필터 바(기간·파트너·통화·상태·검색) — Phase 3 의 `PartnerSelect`, `StatsMetaBar` 재사용
- 표: 컬럼은 데이터셋마다 다름. **마스킹된 값은 그대로 표시**(프론트가 추가 가공하지 않는다)
- 페이지네이션: 1-based, 페이지 크기 20/50/100
- ☠️ **내보내기 버튼 없음** (§0)
- 마스킹 컬럼 헤더에 🔒 아이콘 + "개인정보는 마스킹되어 표시됩니다" 안내 한 줄

각 행에서 Phase 2·3 화면으로 역이동(파트너 클릭 → 파트너 통계) 링크를 둔다.

---

## 5. 하지 말 것

- **CSV/XLSX 내보내기** (§0 — 감사 로그와 함께 Phase 4.5)
- 증빙 URL · `payload_json` · 자유 텍스트 메모를 DTO 에 담기 (§1-2)
- 프론트에서 마스킹 (서버에서 해야 뚫리지 않는다)
- 크로스그룹 상대 파트너 식별정보 노출
- `ToStringSerializer`
- `<script>` 내 raw `<`
- 프론트 `parseFloat`/`Number` 금액 파싱, `=== null` 비교
- 신규 테이블·DDL
- **커밋·push**

## 6. 검증

```
cd /Users/dudgh/work/Cryptoments/cryptoments && ./gradlew :partner-api:compileJava :partner-api:test --rerun-tasks
cd /Users/dudgh/work/Cryptoments/cryptoments-admin/hq-ui && NODE_ENV=development npm run build
```

**qndk11 기준 기대 건수**: `deposits` 16 · `withdrawals` 6 · `p2p-orders` 11(입금7+출금4) ·
`lp-trades` 5 · `daily-fees` 12 · `realizations` 0

**마스킹 단위 테스트 필수** — 이름/계좌/전화 각각 + null + 빈문자 + 1글자 + 2글자
(`홍*동` 규칙이 2글자 이름에서 깨지지 않는지).

## 7. 참고

| 목적 | 경로 |
|---|---|
| 원형 (백엔드) | `partner-api/.../controller/HqStatsController.java`, `service/HqStatsService.java`, `mapper/HqStatsMapper.java` |
| 금액 직렬화 | `partner-api/.../config/PlainBigDecimalSerializer.java` |
| XPage 원형 | `partner-api/.../controller/HqRevenueController.java` `getRealizations` |
| 원형 (프론트) | `cryptoments-admin/hq-ui/src/views/hq/StatsPartnersView.vue` |
| 설계 | `v2-docs/HQ_CONSOLE_DESIGN.md` §4-D · §7.4 |
