# HQ 콘솔 Phase 4.5 — 파트너 관리 (쓰기) + 감사 로그 + 내보내기

> 설계 원본: `HQ_CONSOLE_DESIGN.md` §E · §6.1.1 · §7.4 · §8
> 선행: Phase 4 탐색기 — **완료**
> 대상: `partner-api` + `cryptoments-admin/hq-ui`
> ⚠️ **이 Phase 는 처음으로 DDL 변경과 쓰기(write) 를 포함한다.**

---

## 0. 이 Phase 의 3가지

| # | 내용 | 위험도 |
|---|---|---|
| A | **감사 로그 테이블 신설** (DDL) — 선행 필수 | DDL |
| B | **파트너 관리 쓰기** — 생성·요율·상태·임시비번 | 높음 (요율 오설정 = 수수료 유실) |
| C | **CSV 내보내기** — Phase 4 에서 미룬 것. A 가 있어야 한다 | 중간 (PII 반출) |

**순서는 A → B → C.** A 없이 B·C 를 하면 누가 무엇을 바꾸고 반출했는지 기록이 없다.

---

## 1. [A] 감사 로그 DDL

### 1-1. ☠️ 왜 `admin_audit_logs` 를 재사용하지 않는가

`admin_audit_logs.admin_id` 는 **`admins.id`** 다. HQ 행위자는 **`partners.id`** 라 의미가 다른 값을
같은 컬럼에 넣게 된다. 나중에 "이 감사 로그의 주체가 관리자인가 파트너인가"를 구분할 방법이 없다.

### 1-2. ☠️ 기존 결함을 복제하지 않는다 — `details` 는 `NOT NULL`

**운영 실측 (2026-08-25)** — `admin_audit_logs` 의 **파트너 대상 쓰기 액션 5종이 전부 `details=NULL`**:

| action | 건수 | `details` NULL |
|---|---|---|
| `RESET_PARTNER_PASSWORD` | 13 | **13 (전건)** |
| `CREATE_PARTNER` | 12 | **12 (전건)** |
| `UPDATE_PARTNER_FEES` | 8 | **8 (전건)** |
| `UPDATE_PARTNER` | 2 | **2 (전건)** |
| `REGENERATE_API_KEY` | 1 | **1 (전건)** |

행은 남았는데 **무엇이 무엇으로 바뀌었는지가 없다.** 요율 분쟁이 나면 쓸모가 없는 로그다.
(나중에 추가된 `UPDATE_PARTNER_KRW_FLAGS` 21건·`UPDATE_PARTNER_GROUP_SCOPE` 7건 등은 채워져 있다 —
**초기 구현군만 결함**이고 이후는 고쳐졌다.)

→ 신설 테이블은 `details JSON **NOT NULL**`. 비면 **저장이 실패**하게 만든다.
"로직 오류로 조용히 NULL 이 쌓이는 것"이 원래 결함의 모양이었다.

### 1-3. DDL (오너 승인 후 적용)

```sql
CREATE TABLE partner_audit_logs (
    id BIGINT AUTO_INCREMENT PRIMARY KEY
        COMMENT 'PK',

    actor_partner_id BIGINT NOT NULL
        COMMENT '행위자 파트너 id (partners.id) — HQ 콘솔 로그인 주체. ⚠️ admin_audit_logs.admin_id(admins.id)와 다른 네임스페이스라 테이블을 분리했다',
    actor_console VARCHAR(20) NOT NULL DEFAULT 'HQ'
        COMMENT '행위 콘솔 — HQ / PARTNER. 같은 계정이 두 콘솔을 쓰므로 어디서 한 행위인지 남긴다',

    action VARCHAR(60) NOT NULL
        COMMENT 'CREATE_PARTNER / UPDATE_PARTNER_FEES / CHANGE_PARTNER_STATUS / RESET_PARTNER_PASSWORD / EXPORT_DATASET',

    target_type VARCHAR(40)
        COMMENT 'PARTNER / DATASET',
    target_id BIGINT
        COMMENT '대상 엔티티 id',
    target_code VARCHAR(60)
        COMMENT '대상 식별 코드(파트너 코드 등) — id 만으로는 나중에 무엇이었는지 못 읽는 경우가 있어 함께 남긴다',

    details JSON NOT NULL
        COMMENT '☠️ NOT NULL — admin_audit_logs 의 파트너 대상 액션 5종이 전부 details=NULL 이라 요율 변경 이력 추적이 영구 불가능해진 결함(2026-08-25 실측 36건)을 복제하지 않는다. before/after/adjusted 를 담는다',

    ip_address VARCHAR(50)
        COMMENT '접속 IP (X-Forwarded-For)',
    user_agent VARCHAR(500)
        COMMENT '클라이언트 User-Agent',

    created_at DATETIME(6) DEFAULT CURRENT_TIMESTAMP(6)
        COMMENT '생성 시각',

    KEY idx_actor (actor_partner_id, created_at),
    KEY idx_action (action, created_at),
    KEY idx_target (target_type, target_id)
) COMMENT 'HQ/파트너 콘솔 행위 감사 로그 — details NOT NULL (admin_audit_logs 결함 미복제)';
```

⚠️ **DDL 을 코드보다 먼저 적용한다.** Axim `IXRepository` 는 엔티티 필드로 SELECT 를 만들기 때문에
테이블이 없으면 그 레포지토리가 통째로 죽는다(2026-08-11 실장애).
⚠️ 적용 후 **v2-docs/CRYPTOMENTS_V2_DDL.sql 헤더와 본문에 폴드인**한다 — DDL 파일이 소스 오브 트루스다.

`details` 예시:
```json
{ "before": { "depositFeeRate": "1.0", "maxFeeCap": "3.0" },
  "after":  { "depositFeeRate": "1.2", "maxFeeCap": "3.0" },
  "adjusted": { "depositFeeRate": "하한 0.4 로 보정 적용" } }
```
`adjusted` — §2-3 자동 보정이 개입했다면 그 사실도 남긴다. 사용자가 입력한 값과 저장된 값이 다른데
로그에 저장값만 있으면 나중에 "내가 그렇게 넣은 적 없다"가 된다.

---

## 2. [B] 파트너 관리 쓰기

### 2-1. ☠️ 현재 쓰기 경로는 **직속 하위만** 허용한다 (설계 §E-2)

`PartnerSubMgmtService` 실측:

| 경로 | 스코프 검사 | 범위 |
|---|---|---|
| `getSubPartnerDetail` (조회) | `getDescendantPartner` (`depth < 10`) | 서브트리 |
| `updateSubPartner` / `changeSubPartnerStatus` / `resetPassword` / `deleteSubPartner` | `getSubPartner` → `distributorId.equals(sub.getParentPartnerId())` | **직속만** |

HQ 가 손자 이하를 바꾸려면 막힌다.

**☠️ `distributorId` 에 HQ id 를 넣어 재사용하면 안 된다.** 요율 밴드 검증이 그 값을 기준으로 도는데,
기준은 **HQ 가 아니라 대상 파트너의 실제 부모**여야 한다. HQ 기준으로 검증하면 중간 총판의 상한을
건너뛰고 밴드가 무너진다.

```
HQ 쓰기 = 2단 검사
  ① 권한 : 대상.root_partner_id == 세션 partnerId   (서브트리 전체 허용)
  ② 검증 : 요율 밴드는 대상.parent_partner_id 기준   (실제 부모로 검증)
```

**`PartnerSubMgmtService` 에 actor/parent 를 분리한 오버로드를 추가**한다.
기존 partner-ui 경로는 `actor == parent` 인 특수 케이스가 되어 **동작이 바뀌지 않는다.**

⚠️ **설계 리뷰 R7 정정**: 분리가 실제로 필요한 곳은 `updateSubPartner` 가 아니라
**`createSubPartnerInternal`** 이다 — `distributor` 기준 min/cap(`:171-173`),
`p2pFeeResolver.validateP2pRates(distributorId, …)`(`:193`), `rootPartnerId` 복사(`:224`) 세 곳이
전부 actor 를 부모로 가정한다. `updateSubPartner` 는 min/cap 을 **subPartner 자기 값**으로 쓰고
(`:321-324`) distributor 는 상한 비교(`:307`)에만 쓴다.

### 2-2. ☠️ `parent_fee_rate` 는 변경 불가 — 유지 (설계 §E-3, 오너 확정)

`updateSubPartner` 가 바꿀 수 있는 건 `max_fee_cap` / `deposit_fee_rate` / P2P 요율 3종 /
`partnerName` / `loginEmail` 뿐이다. **`parent_fee_rate` 가 빠진 것은 버그가 아니라 안전장치다.**

`min_fee_rate` 는 비정규화 값(부모의 `min_fee_rate` + `parent_fee_rate`)이고,
`aggregateDailyFees` 는 저장값과 재계산값이 **0.000001 이상 어긋나면 그 파트너를 통째로 건너뛴다.**
건너뛴 수수료는 이미 징수됐으므로 **MASTER 에 주인 없는 USDT 로 남는다.**

`parent_fee_rate` 를 바꾸면 **그 아래 서브트리 전원의 `min_fee_rate` 가 동시에 틀어진다.**
→ **이 Phase 에서 열지 않는다.** UI 에서 읽기 전용으로 표시하고 사유를 툴팁에 적는다.

### 2-3. ☠️ 요율 밴드 — 자동 보정을 침묵으로 처리하지 마라 (설계 §E-4)

`min_fee_rate > 0` 인데 `deposit_fee_rate = 0` 이면 집계에서 스킵되어 **시스템 몫까지 유실**된다.
현재 서비스는 하한 미만 입력을 거부하지 않고 **`min_fee_rate` 로 자동 보정**한다(2026-08-08).

**HQ 폼 규칙**
- 입력값이 하한 미만이면 저장 전에 **"하한 X% 로 보정됩니다"** 를 표시하고 확인받는다
- `min_fee_rate` / `max_fee_cap` 을 **폼에 항상 노출**한다 — 이 필드를 총판에게 숨긴 것이
  과거 밴드 위반의 근본 원인이었다
- 보정이 일어났으면 감사 로그 `details.adjusted` 에 남긴다 (§1-3)

⚠️ **저장 전 `computeMinFeeRate` 재계산 검증**을 넣는다(설계 리뷰 R3).
`PartnerSubMgmtService:173` 은 MERCHANT 에도 `parent.min + parentFeeRate` 를 넣는데
`computeMinFeeRate` 는 MERCHANT 시작 노드의 자기 `parent_fee_rate` 를 제외한다 →
stored ≠ computed → **집계 스킵(G1) 생성기**가 된다. 불일치면 저장을 거부하라.

### 2-4. 엔드포인트

`HqPartnerMgmtController` (`/api/hq/group`).

| Method · Path | 설명 |
|---|---|
| `GET /partners` | 트리 (`root_partner_id` 기준, depth 무제한) |
| `GET /partners/{id}` | 상세 — `assertInMyGroup` |
| `POST /partners` | 생성. **부모 노드 지정 가능**(그룹 내). 밴드 검증은 **지정한 부모** 기준 |
| `PATCH /partners/{id}` | 요율·정보 수정. §2-1 2단 검사. `parent_fee_rate` **불가** |
| `PATCH /partners/{id}/status` | 상태 변경. **사유 필수** |
| `POST /partners/{id}/reset-password` | 임시 비밀번호 |

☠️ **`@RequiresOtp` 규약** — 기존 `PartnerSubPartnerController` 의 등록(`:117`)·삭제(`:407`)에 붙어 있다.
얇은 래퍼가 이를 빠뜨리면 **2FA 사용자의 서브트리 쓰기가 OTP 없이 통과**한다(D7 로 2FA 가 선택이라
비밀번호 단일 방어선). 붙이면 프론트가 `withOtp()` 패턴 없이는 막다른 에러를 낸다 — **양쪽을 맞춰라.**

☠️ **삭제(`DELETE`)는 이 Phase 에서 만들지 않는다.** 되돌릴 수 없고, 하위가 있는 노드를 지우면
`root_partner_id` 사슬이 끊긴다.

### 2-5. 서비스 공유 (설계 §6.1)

HQ 컨트롤러는 `PartnerSubMgmtService` 를 호출하는 **얇은 래퍼**로만 둔다.
쓰기 화면이 partner-ui 와 hq-ui 두 곳에 생기므로 **검증·보정·감사 로직이 갈라지면
요율 밴드가 콘솔마다 달라진다.**

---

## 3. [C] CSV 내보내기

Phase 4 에서 감사 로그가 없어 미뤘던 것. **이제 만든다.**

| 규칙 | 내용 |
|---|---|
| 대상 | Phase 4 의 8개 데이터셋 |
| ☠️ 마스킹 | **조회와 완전히 동일한 DTO 경로를 탄다.** 별도 쿼리·별도 변환을 만들면 마스킹이 우회된다 |
| ☠️ 감사 | 내보내기 1회 = `partner_audit_logs` 1행. `action='EXPORT_DATASET'`, `details` 에 데이터셋·필터·건수 |
| 건수 상한 | 서버에서 강제(예: 10,000행). 초과 시 기간 축소 안내 |
| 동기 응답 | 상한 내에서만. 비동기 잡은 이번 범위 밖 |

Phase 4 에서 제외한 컬럼(증빙 URL·자유 텍스트 메모·`partner_reference`·`partner_metadata`)은
**CSV 에도 없다** — 같은 DTO 를 쓰므로 자동으로 보장된다. 그것이 DTO 경로를 공유하는 이유다.

---

## 4. 공통 규칙 (Phase 2~4 와 동일)

그룹 스코프 `root_partner_id = #{hqId}`(세션에서만) · `PlainBigDecimalSerializer` ·
`<script>` 내 `<` 금지 · MyBatis 3규칙 · 프론트 `== null` 비교 · DTO JavaDoc · `@Data` 금지 ·
XML 매퍼 금지 · 목업 금지.

---

## 5. 하지 말 것

- `admin_audit_logs` 재사용 (`admin_id` 네임스페이스 충돌)
- `details` nullable 로 두기 (기존 결함 복제)
- `parent_fee_rate` 변경 허용
- `distributorId` 에 HQ id 를 넣어 기존 서비스 재사용 (밴드 붕괴)
- 파트너 삭제(`DELETE`)
- 자동 보정을 침묵으로 처리
- CSV 를 별도 쿼리·별도 변환으로 만들기 (마스킹 우회)
- 감사 로그 없이 내보내기
- `@RequiresOtp` 누락
- 신규 테이블 **`partner_audit_logs` 외** 추가
- **커밋·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
```

**단위 테스트 필수**
- actor/parent 분리: HQ 가 손자를 수정할 때 밴드 검증이 **손자의 실제 부모** 기준인지
- `computeMinFeeRate` 불일치 시 저장 거부
- 하한 미만 입력 → 보정 + `details.adjusted` 기록
- `details` 가 비면 저장 실패
- 그룹 밖 파트너 수정 시도 → 403

## 6-A. 적대적 리뷰 반영 (2026-08-25, P1 5건 + P2)

| # | 내용 | 처리 |
|---|---|---|
| P1-1 | admin-api 가 옛 산식(`parentMin + parentFeeRate`) 보유 | **산식을 `core` 의 `MinFeeRateCalculator` 한 곳으로 통일.** `SettlementService.computeMinFeeRate` 는 위임만 한다. admin-api `createPartnerInternal` · `updateFees` · `cascadeMinFeeRate` 모두 계산기 호출로 교체 |
| P1-2 | MERCHANT 의 `parent_fee_rate > 0` 이 조용히 저장됨 | **400 거부**(`5007 MERCHANT_PARENT_FEE_NOT_ALLOWED`) + hq-ui 입력 잠금. 아래 "왜 거부인가" 참조 |
| P1-3 | `min_fee_rate` 불일치 노드의 복구 경로 부재 | **(a) 채택** — 요율을 건드리는 요청만 차단, 이름·이메일 수정은 통과 + 경고 로그 + 감사 `details.minFeeRateMismatch` + 화면 표시 유지 |
| P1-4 | `createPartner` 감사 실패 시 "생성됐는데 500" | `recordHq` 를 try/catch 로 감싸 `log.error` 후 201 반환. 수정·상태·비번은 `@Transactional` 이라 함께 롤백 — **그대로 둔다** |
| P1-5 | create 경로 테스트 0건 | `HqPartnerMgmtServiceTest` 에 생성 테스트 8건 추가(밴드 기준·MERCHANT/DISTRIBUTOR 산식·MERCHANT 부모·보정 기록·감사 실패·그룹 밖 부모) + `HqExplorerExportAuditTest`(내보내기 1회 = 감사 1행) |

**P1-2 를 "강제 0 + adjusted" 가 아니라 400 으로 고른 이유**
1. `parent_fee_rate` 는 **생성 시점에만** 정할 수 있다(수정 경로 없음, §2-2). 조용히 0 으로 바꿔
   저장하면 나중에 어느 콘솔에서도 되돌릴 수 없고 DB 직접 수정만 남는다.
2. 저장된 값이 **무해하지도 않다.** 배분 루프가 무시하는 것은 그 MERCHANT 가 *소스*일 때뿐이라,
   그 아래에 하위가 생기는 순간 그 값이 하위의 하한(체인 합)에 살아난다.
3. 화면이 "상위 몫 X% 로 생성합니다" 라고 확인까지 받는 값이다. 확인받고 무시하는 것보다
   입력을 잠그고 거부하는 쪽이 정직하다.

**☠️ 부수 발견 — 부모 저장값에 더하는 방식 자체가 틀렸다**
`parentMin + parentFeeRate` 는 **부모가 MERCHANT 면 항상 틀린다.** MERCHANT 의 저장된 하한은
자기 몫이 빠진 값인데, 그 아래 자식의 체인 합에는 그 몫이 **포함**된다(예외는 시작 노드에만
적용). 그래서 계산기는 부모 값을 재료로 쓰지 않고 **트리를 직접 거슬러 올라간다**
(`MinFeeRateCalculator.computeForNew`). 세 콘솔(어드민/총판/HQ)이 전부 이 경로로 통일됐다.

**P2 처리**
- `MIN_FEE_RATE_MISMATCH(1233)` 를 실제로 던진다 — 밴드 위반(`5001`)과 불일치를 프론트가 구분한다
- 생성에서 `depositFeeRate` **미지정**도 보정으로 본다 → `details.adjusted` 에 "미지정 → 하한 적용"
- `HqCsvWriter` 수식 방어에 `-` · 선행 `TAB/CR/LF/공백` 추가. ☠️ **문자열 셀 한정**으로 좁혔다 —
  전 셀에 걸면 음수 금액(`-5.25`)이 `'-5.25` 로 훼손된다(테스트로 고정)
- `HqExplorerService.export` 는 **1행 프로브로 count 선행** 후 본조회 — 거부되는 요청이 가장 무거운
  쿼리를 돌리지 않는다
- `GET /api/hq/group/partners` 에 `LIMIT 2000` + 상한 도달 시 서버 `log.warn` + 화면 경고 배너
- `PartnerSubMgmtService` 의 `SettlementService` 전체 주입 → **`MinFeeRateCalculator`** (repository 만
  의존하는 leaf 빈)로 축소. `SettlementService` 생성자 시그니처는 건드리지 않았다(기존 단위 테스트
  18-arg 생성자 보호) — 내부에서 계산기를 만들어 위임한다

**어드민만 다르게 둔 것 (의도)**
- admin-api 는 불일치를 **거부하지 않고 `log.error` 경고**만 한다. 이미 어긋난 트리를 고치는
  최후의 수단이 그 화면이라, 거기까지 잠그면 복구 경로가 DB 직접 수정뿐이 된다.
- admin-api 의 MERCHANT `parent_fee_rate > 0` 도 같은 이유로 경고만 한다(콘솔 쪽은 400).

## 6-B. 후속 백로그 (이번 범위 밖 — 구현하지 않음)

1. **`partner_audit_logs` 읽기 경로가 없다.** 지금은 write-only 라 "누가 무엇을 바꿨나"를 DB 로만
   볼 수 있다 — 기록의 목적을 절반만 달성한 상태다. **다음 Phase 필수**: HQ 콘솔 감사 로그 조회
   (행위자·액션·대상·기간 필터 + `details` diff 표시).
2. **관리자 전용 `recompute-min-fee-rate` 액션.** 불일치 노드의 저장값을 재계산값으로 되돌리는
   단발 조치. 서브트리 cascade + 실행 전후 감사 기록이 함께 필요하다. (P1-3 (a) 로 "이름은 고칠 수
   있다"까지는 열렸지만, 하한 자체를 바로잡는 수단은 여전히 DB 직접 수정뿐이다.)
3. `GET /api/hq/group/partners` 의 진짜 해법 — 상한이 아니라 **부분 로딩(노드 펼치기)**.

## 7. 참고

| 목적 | 경로 |
|---|---|
| 기존 쓰기 서비스 | `partner-api/.../service/PartnerSubMgmtService.java` |
| 기존 컨트롤러(OTP 규약) | `partner-api/.../controller/PartnerSubPartnerController.java` |
| 집계 스킵 로직 | `core/.../settlement/SettlementService.java` `computeMinFeeRate`, `aggregateDailyFees` |
| Phase 4 탐색기(내보내기 재사용 대상) | `partner-api/.../service/HqExplorerService.java` |
| 설계 | `v2-docs/HQ_CONSOLE_DESIGN.md` §E |
