# 텔레그램 정리 — 시스템 채널 신설 + 파트너 P2P 알림 보강

작성 2026-08-18 · repo `cryptoments` (core, scheduler)
근거: 텔레그램 전수 실측 (2026-08-18)

---

## 배경

**① 관리자 알림 채널이 존재하지 않는다.**

```java
public void sendSystemAlert(String alertType, String data) {
    log.warn("시스템 알림: type={}, data={}", alertType, data);   // 이게 전부다
}
```

호출부 13곳이 로그 한 줄로 끝난다. 그중 **이중지급 직전 방어선이 셋**이다. Vigil 은 `error_spike`
룰만 있고 이 로그들은 `warn` 레벨이라 걸리지도 않는다.

**② 파트너는 P2P 분쟁·주문 종결을 텔레그램으로 전혀 모른다.** 레그별 입금 확인만 N건 받고,
정작 "이 주문이 끝났는지"는 통보받지 못한다. 웹훅은 `P2P_ORDER_COMPLETE` 로 통합했는데
텔레그램에는 같은 정리가 적용되지 않았다.

---

# A. 시스템(관리자) 텔레그램 채널 신설

## A1. 설정은 `system_settings` 에 둔다 (DDL 불필요)

`system_settings` 는 key/value 테이블이다. **행 3개만 추가하면 되고 스키마 변경이 없다.**
행 삽입은 오케스트레이터가 한다 — **서브에이전트는 코드만 작성하고 DB 에 접속하지 마라.**

| setting_key | 예시값 | 의미 |
|---|---|---|
| `telegram.system.enabled` | `false` | 마스터 스위치. **기본 false** — chat_id 를 넣기 전엔 꺼져 있어야 한다 |
| `telegram.system.chat_id` | `-1001234567890` | 관리자 채널/그룹 chat_id |
| `telegram.system.min_severity` | `WARNING` | 이 등급 이상만 발송 (`CRITICAL` \| `WARNING`) |

읽기는 기존 `system_settings` 조회 방식을 그대로 따른다 — **먼저 코드에서 이 테이블을 어떻게
읽는지 확인하고 같은 패턴을 쓰라.** 새 조회 유틸을 만들지 마라.

## A2. `sendSystemAlert` 구현 교체

**시그니처를 바꾸지 마라.** 호출부 13곳을 건드리지 않는 것이 이 설계의 핵심이다.

```
public void sendSystemAlert(String alertType, String data)
```

내부 동작:

```
1  log.warn 은 그대로 남긴다 (Vigil·서버 로그 경로 유지)
2  telegram.system.enabled != true  → 여기서 종료
3  alertType 의 심각도가 min_severity 미만 → 종료
4  스로틀 판정(A4) 에 걸리면 → 종료
5  TelegramBotClient.sendMessage(null, chatId, message, "HTML") 로 발송
   botTokenRef=null → 기본 토큰(cryptoments.telegram.bot-token) 사용.
   이 토큰은 이미 전 서비스 env 에 있다
6  발송 실패해도 예외를 던지지 마라 — 알림 실패가 비즈니스 트랜잭션을 깨면 안 된다
```

⚠️ **`sendSystemAlert` 는 자금 트랜잭션 한복판에서 호출된다.** try/catch 로 전부 감싸고
어떤 경우에도 호출자에게 예외가 새지 않게 하라.

## A3. 심각도 분류

`alertType` → 심각도 매핑을 상수 맵으로 둔다. **맵에 없는 값은 `WARNING`** (안전한 기본).

| alertType | 심각도 | 이유 |
|---|---|---|
| `P2P_CONFIRM_OVERPAY_PARKED` | **CRITICAL** | 이중지급 방어선 |
| `P2P_CANCEL_PARKED_DISPUTE` | **CRITICAL** | 이중지급 방어선 |
| `P2P_CLOSED_ORDER_PARKED` | **CRITICAL** | 이중지급 방어선 (A5 에서 신설) |
| `P2P_SETTLEMENT_DONE_UNDER_DISPUTE` | **CRITICAL** | 분쟁 중 자금이 나갔다 |
| `P2P_INNER_SETTLEMENT_FAILED` | **CRITICAL** | 내부 정산 실패 |
| `P2P_LOCK_NEGATIVE_CLAMP` | **CRITICAL** | 잠금 회계가 음수로 갔다 |
| `BALANCE_LOW` | WARNING | 운영 신호 |
| `P2P_SETTLING_MATCH_STALE` | WARNING | 정체 감시 |
| `P2P_SETTLEMENT_FAILED_STALE` | WARNING | 정체 감시 |
| `P2P_MANUAL_CONFIRM_STALLED` | WARNING | 정체 감시 |
| `P2P_AUTO_DISPUTE` | WARNING | 자동 분쟁 발생 |
| `P2P_DISPUTE_HEADER_FAILED` | WARNING | 기록 유실 |
| `DISPUTE_EVENT_LOG_FAILED` | WARNING | 기록 유실 |

## A4. 스로틀 ⚠️ 없으면 채널이 죽는다

반복 발화하는 잡이 있다.

```
BalanceLowCheckJob   30분 주기 · 잔액이 회복될 때까지 매번 발화 · 중복 억제 없음
StaleTxMonitorJob    10분 주기 · 같은 정체 건을 계속 잡는다
```

이대로 켜면 하루 48건 + 144건이 같은 내용으로 쏟아지고, 운영자가 채널을 음소거한다.
**그러면 CRITICAL 도 안 보인다.**

**설계**: `(alertType + data 의 해시)` 를 키로 마지막 발송 시각을 기억하고, 타입별 TTL 안에는 재발송하지 않는다.

| alertType | TTL |
|---|---|
| `BALANCE_LOW` | 6시간 |
| `P2P_SETTLING_MATCH_STALE` | 1시간 |
| `P2P_SETTLEMENT_FAILED_STALE` | 1시간 |
| `P2P_MANUAL_CONFIRM_STALLED` | 1시간 |
| **그 외 전부** | **0 (스로틀 없음)** |

- 저장은 **프로세스 메모리**(`ConcurrentHashMap`)로 충분하다. Redis 를 도입하지 마라 —
  이 코드베이스는 Java 에서 Redis 를 쓰지 않는다. 반복 발화 잡은 scheduler 단일 프로세스에서만 돈다.
  재기동 시 중복 1건은 무해하다.
- 맵이 무한히 자라지 않게 하라 (TTL 지난 항목 정리 또는 크기 상한).
- **CRITICAL 은 절대 스로틀하지 마라.**

## A5. 알림 호출이 아예 없는 방어선 1곳 추가

`core/p2p/P2pMatchingService.java` 의 **종결 주문에 입금 확인이 도착한 경로**(약 :1521-1547,
`log.error` 만 있고 알림 호출이 없다. 형제 분기 :1572 에는 `sendSystemAlert` 가 있다).

여기에 `sendSystemAlert("P2P_CLOSED_ORDER_PARKED", ...)` 를 추가한다. **가장 위험한 이중지급
방어선인데 통지가 없다.** 데이터에는 매칭 코드·주문 코드·금액을 담아라.

> 기존 `log.error` 와 파킹 로직은 그대로 둔다. 알림만 얹는다.

## A6. 메시지 포맷

`TelegramMessageFormatter` 에 시스템 알림 포맷을 추가한다. 기존 포맷터의 톤·HTML 태그 사용법을 따를 것.

```
🚨 [CRITICAL] P2P_CONFIRM_OVERPAY_PARKED
<내용>
2026-08-18 05:12:33
```

- CRITICAL `🚨` / WARNING `⚠️`
- **`data` 문자열을 HTML escape 하라.** 사유 텍스트에 `<`/`&` 가 섞이면 Telegram 이 400 을 준다
- 길이 상한(예: 3,500자)을 두고 넘으면 잘라라 — Telegram 메시지 상한은 4,096자다

---

# B. 파트너 P2P 알림 보강

## B1. 지금 상태

파트너가 **받는** P2P 텔레그램:

```
P2P 입금 확인 (USDT 레그)     P2pSettlementService 약 :1531   레그마다 1건
P2P TORQ 입금 확인            약 :759                          레그마다 1건
P2P 원화입금 확인 (PARTNER)   약 :477                          레그마다 1건
P2P 출금 종결 (자연 완료)     약 :921
출금 P2P 전환                 WithdrawalService 약 :1218
```

파트너가 **못 받는** P2P:

```
주문 종결        웹훅 P2P_ORDER_COMPLETE 은 나가는데 텔레그램은 없다
                 → 운영자가 "이 주문 끝났나"를 텔레그램으로 알 수 없다
분쟁 발생·종결   전 경로에서 0건
```

## B2. 주문 종결 알림 신설 ★

웹훅 `P2P_ORDER_COMPLETE` 를 발행하는 지점(`P2pSettlementService` 의 `notifyP2pOrderComplete`
부근)에서 **같은 주문에 대해 텔레그램도 1건** 보낸다.

메시지에 담을 것 — **웹훅 페이로드에서 이미 계산된 값을 재사용하라. 다시 계산하지 마라.**

```
결과        FULL(전액 확정) | PARTIAL(부분 확정)
주문 코드
확정 USDT   settledAmount (수수료 차감 후)
확정 원화   settledAmountKrw
주문 액면   orderAmount
미확정 원화 unsettledAmountKrw   (PARTIAL 일 때만)
레그        settledLegCount / legCount
종결 사유   closeReason
```

**PARTIAL 은 시각적으로 구분되게 하라** — 파트너가 "부분만 들어왔다"를 놓치면 안 된다.

⚠️ **웹훅이 안 나가는 경우엔 텔레그램도 보내지 마라.** 정산 레그 0건(입금이 전혀 없는 만료·전부취소)은
양쪽 다 침묵이 정상이다. 웹훅 발행 조건과 같은 게이트를 태워라.

## B3. 분쟁 알림 신설 ★

파트너 채널로 **2건**만 보낸다. 과하게 만들지 마라 — 파트너가 할 수 있는 조치가 제한적이다.

**B3-1. 분쟁 발생** — 분쟁 헤더가 새로 열릴 때 1회

- 발송 지점: 분쟁 이벤트 `RAISED` 가 적재되는 공통 경로. **`P2pDisputeService.openOrGet` 이
  헤더를 새로 만든 경우에만** 보낸다(재사용 시 재발송 금지)
- 수신: 해당 매칭의 **입금측 파트너**와 **출금측 파트너** 각각. 두 파트너가 같으면 1건만
- 담을 것: 매칭 코드, 주문 코드, 금액(KRW), 분쟁 출처(구매자/판매자/TORQ/시스템/관리자), 발생 시각
- **담지 말 것**: 상대 파트너명, 회원 실명, 증빙 URL

**B3-2. 분쟁 종결** — 판정이 내려질 때 1회

- 담을 것: 매칭 코드, 결과(확인/취소), 판정 시각
- 판정 메모(`resolveMemo`)는 **내부 문구다. 넣지 마라**

> 증빙 제출·재요청은 파트너에게 보내지 않는다. 당사자와 관리자 사이의 일이다.

## B4. 레그별 알림 — 유지하되 진행 표시 추가

레그별 입금 확인은 **실시간성 가치가 있으므로 없애지 않는다.** 다만 주문 하나가 여러 건으로
쪼개져 오는 이유를 알 수 있게, 메시지에 **`n/m 레그`** 진행 표시를 넣어라.
(값은 이미 `settledLegCount`/`legCount` 로 구할 수 있다.)

## B5. txHash 전문 노출 수정

`P2pSettlementService` 의 P2P 입금 확인 텔레그램(약 :1531-1535)이 **포맷터를 거치지 않고
문자열 연결로 txHash 전문을 노출**한다. 다른 모든 메시지는 `0x1234...abcd` 로 축약한다.
기존 축약 헬퍼를 써서 맞춰라.

---

## 코딩 규칙

- Java 17 · Lombok(`@Data` 금지) · 기존 파일 스타일 준수
- **`sendSystemAlert` 시그니처 변경 금지** (호출부 13곳 무변경)
- 알림 발송이 **비즈니스 트랜잭션을 깨면 안 된다** — 전부 try/catch, 예외 전파 금지
- 트랜잭션 커밋 후 발송이 필요한 곳은 **기존 `afterCommit` 패턴을 따르라** (P2pMatchingService 에 선례가 있다)
- **DDL 을 만들지 마라. DB 에 접속하지 마라.** `system_settings` 행 삽입은 오케스트레이터가 한다
- Redis 를 도입하지 마라
- 메시지에 **계좌번호·개인키·회원 실명·상대 파트너 정보**를 넣지 마라
- HTML parse mode 를 쓰므로 **동적 문자열은 escape** 하라

## 완료 기준

```
1  ./gradlew :core:compileJava :scheduler:compileJava :open-api:compileJava 통과
2  telegram.system.enabled 가 없거나 false 면 발송 0 (기존 동작과 동일 — 안전한 기본)
3  sendSystemAlert 호출부 13곳이 무변경
4  CRITICAL 은 스로틀되지 않는다
5  BALANCE_LOW 는 같은 내용이 6시간 내 1회만 나간다
6  P2P_CLOSED_ORDER_PARKED 알림이 추가됐다
7  주문 종결 텔레그램이 웹훅과 같은 게이트를 탄다 (레그 0건이면 둘 다 침묵)
8  분쟁 발생 알림이 헤더 신규 생성 시에만 나간다 (재사용 시 미발송)
9  P2P 입금 확인 메시지의 txHash 가 축약된다
```

## 보고 형식

- 작업별 수정 파일:라인 + 한 줄
- **A3 심각도 맵과 A4 TTL 맵의 최종 내용**
- 스로틀 저장 구조와 메모리 상한 처리 방식
- B3 에서 수신 파트너를 어떻게 특정했는지 (양측 파트너 조회 경로)
- 지침이 실제 코드와 어긋난 지점 — **고치지 말고 먼저 보고**
- 빌드 결과

## 착수 전 필수 확인

```
core/.../notification/NotificationService.java        (sendTelegram·sendSystemAlert 현재 구현)
core/.../notification/TelegramBotClient.java          (sendMessage 시그니처·토큰 해석)
core/.../notification/TelegramMessageFormatter.java   (포맷 톤·축약 헬퍼)
system_settings 를 읽는 기존 코드                      (조회 패턴 — 새로 만들지 마라)
core/.../p2p/P2pSettlementService.java                (notifyP2pOrderComplete 부근, 레그별 sendTelegram 3곳)
core/.../p2p/P2pDisputeService.java                   (openOrGet — 헤더 신규/재사용 판별)
core/.../p2p/P2pMatchingService.java 약 :1521-1547     (알림 없는 파킹 분기)
scheduler/.../job/BalanceLowCheckJob.java             (반복 발화 실태)
```

지침과 다르면 **멈추고 보고하라.** 지침을 쓴 사람이 틀렸을 수 있다.
