# P2P 출금회원 텔레그램 알림 — 구현 지침서

> 작성: 2026-07-10 (Cowork) · 상태: 설계 확정, 구현 대기
> 대상: IntelliJ(Spring Boot), VS Code(Node.js `telegram-bot`)
> 관련: `CRYPTOMENTS_PARTNER_NOTIFICATION.md`, `P2P_MATCHING_ARCHITECTURE.md`

## 1. 목표 / 범위

P2P **출금회원 본인**(end-user)이 자신의 출금 주문 진행 상황을 개인 텔레그램으로
직접 받도록 한다. 기존 텔레그램은 **파트너 운영자 단위**(`partner_telegram_configs`)만
지원하며, 회원 개인 채널은 존재하지 않는다.

**이번 범위(확정)**

| 구분 | 내용 |
|------|------|
| 알림 대상 | 출금회원 본인 (`p2p_members`) |
| 알림 이벤트 | ① 매칭 완료 ② 입금 확인(부분/전체) ③ 취소 · 분쟁(발생/해결) |
| 결과물 | 본 지침서 + DDL 변경 |
| 봇 | **P2P 전용 봇 신설** (파트너 default 봇과 분리) — 토큰·polling 프로세스·발송 경로 모두 별도 |

> 정산 완료(USDT 전송) 알림은 이번 범위에서 제외됐으나, §7에 템플릿을 옵션으로 포함.
> 나중에 이벤트 지점만 1줄 추가하면 활성화 가능.

## 2. 현재 구조 (조사 결과)

```
[인바운드 — 연결/명령]  node-service/telegram-bot (Long Polling)
    /start, /stop ...  →  telegramConfigRepo.upsert (partner_telegram_configs)
                          ※ 회원용 핸들러 없음

[아웃바운드 — 발송]     Spring core/notification/TelegramBotClient
    sendMessage(botToken, chatId, text, "HTML") → api.telegram.org
    NotificationService.sendTelegram()는 partnerId → PartnerTelegramConfig 만 조회
                          ※ 회원 chatId 발송 경로 없음
```

핵심 사실:
- `TelegramBotClient`는 현재 `botTokenRef`를 무시하고 **항상 default(파트너) 봇 토큰**으로 발송한다.
  → **P2P 전용 봇 분리를 위해 `resolveBotToken`이 `"p2p"` ref → P2P 봇 토큰을 반환하도록 확장**해야 한다(§6-1).
- 발송은 Spring, 연결(chatId 수집)은 Node 봇이 담당하는 **분리 구조**.
  → P2P 봇은 파트너 봇과 **다른 토큰 = 다른 polling 프로세스**가 필요하다.
    회원 연결 플로우는 이 P2P 봇 프로세스의 `/start`에서 chatId를 받아 DB에 저장한다.
- 상태 전환 콜사이트(§6)에는 알림 훅이 **전혀 없다** → 신규 삽입 필요.
- 모든 전환은 `@Transactional` 서비스 내부 → **발송은 커밋 이후 + try/catch 격리** 필수
  (기존 `P2pSettlementService`의 try/catch 패턴, 그리고 로그↔크레딧 격리 원칙과 동일).

## 3. 아키텍처 개요

```
① 연결(Connect)  — P2P 전용 봇 프로세스가 담당
  회원 위젯(로그인 상태) → GET /p2p/page/telegram/connect-token
     → 서명된 단기 토큰(memberId, exp) 발급 → 딥링크 https://t.me/<P2P봇>?start=m_<token>
  회원이 딥링크 탭 → P2P 봇에 /start m_<token> 전송
     → telegram-bot-p2p: verifyMemberConnectToken → memberId 확인
     → p2p_members.telegram_chat_id 저장 + 확인 메시지 회신

② 발송(Outbound)  — Spring이 P2P 봇 토큰으로 발송
  상태 전환(매칭/입금확인/취소/분쟁) → afterCommit 훅
     → P2pMemberNotifier.notify(order, event)
     → member = findByPartnerIdAndPartnerUserId(order.partnerId, order.partnerUserId)
     → chatId·notifyEnabled 확인 → TelegramBotClient.sendMessage("p2p", chatId, msg, "HTML")
     → 실패는 로그만 (MVP: 재시도 없음)
```

## 4. DDL 변경

`p2p_members`에 텔레그램 연결 컬럼 3개 추가. (thread_id는 1:1 개인 채팅이라 불필요.)

### 4-1. 운영 반영용 ALTER (canonical: `CRYPTOMENTS_V2_DDL.sql`에도 반영 완료)

```sql
ALTER TABLE p2p_members
    ADD COLUMN telegram_chat_id      VARCHAR(32)  NULL      COMMENT '연결된 텔레그램 chat_id' AFTER status,
    ADD COLUMN telegram_notify_enabled TINYINT(1) NOT NULL DEFAULT 1 COMMENT '텔레그램 알림 수신 여부' AFTER telegram_chat_id,
    ADD COLUMN telegram_connected_at DATETIME(6)  NULL      COMMENT '텔레그램 연결 시각' AFTER telegram_notify_enabled,
    ADD UNIQUE KEY uk_p2p_member_tg_chat (telegram_chat_id);
```

- `telegram_chat_id` UNIQUE → 한 텔레그램 계정이 여러 회원에 붙는 것 방지 + Node `/stop`의 chatId 역조회 지원. (NULL 다수 허용 — MySQL UNIQUE는 NULL 중복 허용)
- `telegram_notify_enabled` 기본 ON. 회원이 위젯 토글로 OFF 가능.

> DDL은 CI/CD에 포함되지 않으므로 운영 DB 직접 적용 필요(적용 전 승인). 적용 명령:
> ```bash
> ssh cryptoments-bastion "ssh db-01 'mysql -u cryptoments -p\"Crypt0m3nts!2026\" cryptoments_db -e \"ALTER TABLE p2p_members ...\"'"
> ```

## 5. 연결(Connect) 플로우 상세

### 5-1. Spring — 엔티티/레포 변경

`common/entity/P2pMember.java` 필드 추가:
```java
/** 연결된 텔레그램 chat_id (미연결 시 null) */
@XColumn("telegram_chat_id")
private String telegramChatId;

/** 텔레그램 알림 수신 여부 */
@XColumn("telegram_notify_enabled")
private Boolean telegramNotifyEnabled;

/** 텔레그램 연결 시각 */
@XColumn("telegram_connected_at")
private LocalDateTime telegramConnectedAt;
```

`common/repository/P2pMemberRepository.java` 추가:
```java
/** 텔레그램 chat_id로 조회 (연결 해제/중복 확인용) */
P2pMember findByTelegramChatId(String telegramChatId);
```

### 5-2. Spring — 회원용 connect token 유틸 (신규)

파트너의 `partner-api/util/TelegramConnectToken.java`를 그대로 미러링하되,
**payload에 memberId + type="M"**를 넣는다. 서명키는 파트너와 동일한
`axim.rest.session.secret-key`(= Node `config.session.secretKey`) 사용.

`open-api/.../util/MemberTelegramConnectToken.java` (신규):
```java
// generate(memberId, secretKey, ttlSeconds) → base64url(HMAC-SHA256)
//   payload = memberId + "." + exp + "." + "M"
//   반환 문자열은 딥링크에 'm_' 접두사와 함께 사용: "m_" + token
// verify(token, secretKey) → memberId (만료/서명/type!=M 이면 null)
```
- TTL 권장 300초(5분). 딥링크 노출 최소화.
- 딥링크 start 파라미터 제약(최대 64자, `[A-Za-z0-9_-]`)을 넘지 않도록 base64url + 짧은 payload 유지.

### 5-3. Spring — 위젯 엔드포인트 (P2pWithdrawPageController, 세션 인증)

`P2pWithdrawPageController`(`extends XSessionController<P2pPageSessionData>`)에 추가.
세션의 `memberId`로만 동작 → 소유권 보장.

| 메서드 | 경로 | 동작 |
|--------|------|------|
| GET | `/p2p/page/telegram` | 연결 상태 조회 (`connected`, `notifyEnabled`) |
| GET | `/p2p/page/telegram/connect-token` | 딥링크 발급 `{ deepLink, expiresIn }` |
| PUT | `/p2p/page/telegram/notify` | 알림 수신 on/off 토글 |
| DELETE | `/p2p/page/telegram` | 연결 해제 (chat_id null 처리) |

connect-token 응답 예:
```json
{ "deepLink": "https://t.me/cryptoments_p2p_bot?start=m_<token>", "expiresIn": 300 }
```
봇 username은 **P2P 전용 봇** username(`cryptoments.telegram.p2p.bot-username`, 신규 프로퍼티)으로 조립.

### 5-4. Node — P2P 전용 봇 프로세스 신설 (`telegram-bot-p2p`)

파트너 봇(`telegram-bot`)과 **다른 토큰**이므로 별도 polling 프로세스가 필요하다.
기존 `packages/telegram-bot`을 미러링해 **신규 패키지 `packages/telegram-bot-p2p`**를 만든다
(구조 최소화 — 회원 전용 명령만).

```
node-service/packages/telegram-bot-p2p/
├── src/app.ts            # config.telegram.p2pBotToken 로 봇 생성 (telegram-bot/app.ts 복제)
└── src/handlers/index.ts # 회원 전용 3개 핸들러
```

명령: `/start m_<token>`(연결), `/stop`(해제), `/help`.
파트너 봇의 `/balance /recent /status`는 회원 봇에 두지 않는다.

```ts
// telegram-bot-p2p/src/handlers/index.ts (요지)
export function registerAllHandlers(bot: TelegramBot): void {
  bot.onText(/\/start (.+)/, async (msg, match) => {
    const chatId = msg.chat.id;
    const input = match?.[1]?.trim() ?? '';
    const token = input.startsWith('m_') ? input.slice(2) : input;
    const memberId = verifyMemberConnectToken(token, config.session.secretKey);
    if (!memberId) {
      return bot.sendMessage(chatId, '유효하지 않거나 만료된 연결 링크입니다. 출금 페이지에서 다시 발급해주세요.');
    }
    // 중복 chat_id → 기존 회원 연결 해제 후 재바인딩(UNIQUE)
    await p2pMemberRepo.bindTelegram(memberId, chatId.toString());
    await bot.sendMessage(chatId, '✅ 텔레그램 알림이 연결되었습니다.\n출금 진행 상황을 이 채팅으로 보내드립니다.\n해제하려면 /stop 를 입력하세요.');
  });

  bot.onText(/\/stop/, async (msg) => {
    const chatId = msg.chat.id;
    const cleared = await p2pMemberRepo.clearTelegramByChatId(chatId.toString());
    await bot.sendMessage(chatId, cleared ? '텔레그램 알림 연결이 해제되었습니다.' : '연결된 알림이 없습니다.');
  });

  bot.onText(/\/help/, async (msg) => {
    await bot.sendMessage(msg.chat.id, 'Cryptoments P2P 출금 알림 봇\n\n/start — 출금 페이지에서 발급한 링크로 연결\n/stop — 알림 해제\n/help — 도움말');
  });
}
```

전용 봇이므로 `/start`의 파트너 partner_code 폴백은 두지 않는다(회원 토큰만 허용 → 오용 차단).

> **대안(경량)**: 별도 패키지 대신 기존 `telegram-bot` 프로세스 안에서 P2P 토큰으로
> `new TelegramBot(p2pToken)` 인스턴스를 하나 더 띄우는 방법도 가능. 단, "봇 별도" 요구와
> 배포·로그 격리를 고려하면 **별도 패키지/PM2 프로세스를 권장**.

### 5-5. Node common — p2pMemberRepo (신규, 두 봇 패키지에서 공용)

`node-service/packages/common/src/db/`에 회원 레포 추가 후 `index.ts`에서 export:
```ts
p2pMemberRepo.bindTelegram(memberId, chatId)        // UNIQUE 충돌 시 기존 행 chat_id=null 후 세팅
p2pMemberRepo.clearTelegramByChatId(chatId)         // → boolean (해제된 행 존재 여부)
```
`verifyMemberConnectToken`은 `common/src/crypto/`에 추가(기존 `verifyConnectToken` 옆),
payload의 type=="M" 및 exp 검증. `index.ts` export 추가.
`config.telegram.p2pBotToken`(신규)도 common `config`에 추가한다.

## 6. 발송(Outbound) 구현

### 6-1. 신규 서비스 `P2pMemberNotifier` (core/notification)

```java
@Service
public class P2pMemberNotifier {
    // 의존: P2pMemberRepository, TelegramBotClient
    // afterCommit에서 호출됨. 절대 예외를 전파하지 않는다.
    public void notify(P2pWithdrawOrder order, P2pMemberEvent event, /* 이벤트별 파라미터 */ ...) {
        try {
            P2pMember m = memberRepo.findByPartnerIdAndPartnerUserId(order.getPartnerId(), order.getPartnerUserId());
            if (m == null || m.getTelegramChatId() == null
                    || !Boolean.TRUE.equals(m.getTelegramNotifyEnabled())) return;
            String msg = TelegramMessageFormatter.p2pMember...(...);   // §7
            telegramBotClient.sendMessage("p2p", m.getTelegramChatId(), msg, "HTML");  // P2P 전용 봇 토큰
        } catch (Exception e) {
            log.warn("P2P 회원 텔레그램 발송 실패(무시): order={}, event={}, err={}",
                     order.getOrderCode(), event, e.getMessage());
        }
    }
}
```

**`TelegramBotClient` 확장(P2P 봇 토큰 지원)** — 현재 `resolveBotToken`은 ref를 무시하고 default만 반환.
`"p2p"` ref를 P2P 봇 토큰으로 매핑하도록 추가:
```java
@Value("${cryptoments.telegram.p2p.bot-token:}")
private String p2pBotToken;

private String resolveBotToken(String botTokenRef) {
    if ("p2p".equalsIgnoreCase(botTokenRef)) return p2pBotToken;   // ← 신규
    if (botTokenRef == null || "default".equalsIgnoreCase(botTokenRef)) return defaultBotToken;
    return defaultBotToken;
}
```
> 파트너 알림(§없음)은 기존대로 default 봇, 회원 알림만 `"p2p"` ref로 분리 발송.

`P2pMemberEvent` enum: `MATCHED_FULL`, `MATCHED_PARTIAL`, `DEPOSIT_CONFIRMED_PARTIAL`,
`DEPOSIT_CONFIRMED_FULL`, `CANCELLED`, `DISPUTE_OPENED`, `DISPUTE_RESOLVED`
(옵션: `SETTLED`) · **수동 전용: `DEPOSIT_CONFIRM_REQUEST`(C1, 인라인 버튼), `DEPOSIT_CONFIRM_REMINDER`(C2)**.
→ 전체 알림 종류(taxonomy)와 수동 버튼 처리 흐름은 **§7B** 참조.

### 6-2. 트랜잭션 커밋 이후 발송 (필수 패턴)

각 콜사이트에서 직접 발송하지 말고, 커밋 성공 후에만 실행:
```java
TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
    @Override public void afterCommit() {
        memberNotifier.notify(order, P2pMemberEvent.MATCHED_FULL, ...);
    }
});
```
> 이유: 텔레그램 HTTP 호출이 트랜잭션을 물고 늘어지는 것 방지 + 롤백 시 유령 알림 방지.
> `notify()` 자체도 try/catch로 감싸 발송 실패가 이후 로직에 영향 없게 한다(이중 안전장치).

### 6-3. 이벤트별 삽입 위치

| 이벤트 | 파일 : 메서드 | 삽입 지점 | 이벤트 |
|--------|--------------|-----------|--------|
| 매칭 완료 | `P2pMatchingService.createMatch` (≈L666-674) | `withdrawRepo.modify(wo)` 직후, status에 따라 분기 | FULLY_MATCHED→`MATCHED_FULL`, PARTIALLY_MATCHED→`MATCHED_PARTIAL`(옵션) |
| 입금 확인 | `P2pMatchingService.confirmBankTransfer` (≈L726-735) | `wo.setConfirmedAmount(...)` 갱신 직후 | confirmed≥krw→`DEPOSIT_CONFIRMED_FULL`, 아니면 `DEPOSIT_CONFIRMED_PARTIAL` |
| 취소 | `P2pWithdrawService.cancelOrder` (≈L203-206) | `withdrawRepo.modify(order)` 직후 | `CANCELLED` |
| 분쟁 발생 | `P2pMatchingService.submitDispute` (≈L1044~) | 재잠금 처리 후 | `DISPUTE_OPENED` |
| 분쟁 발생(자동) | `P2pMatchingService.autoDisputeMatch` (≈L1135~) | 기존 `sendSystemAlert` 옆 | `DISPUTE_OPENED` |
| 분쟁 해결 | `P2pMatchingService.forceCancelDisputedLeg` (≈L1167~) | wo status 복원/CANCELLED 후 | `DISPUTE_RESOLVED` |
| **입금확인 요청(수동)** | `P2pMatchingService.submitTransferDone` (구매자 이체완료→BANK_PENDING) | 회원 `deposit_method=MANUAL`일 때만 | `DEPOSIT_CONFIRM_REQUEST` (§7B) |

> 라인번호는 조사 시점 기준 근사값 — 구현 시 메서드명으로 정확히 위치 확인.
> 분쟁은 매칭(P2pMatch) 단위이므로, 해당 match의 `withdrawOrderId`로 order를 조회해 회원을 특정한다.

## 7. 메시지 템플릿 (`TelegramMessageFormatter` 추가)

parse_mode="HTML", 기존 스타일 준수. **회원 대상이므로 온체인 주소/txHash 등
내부 정보는 노출하지 않고** 주문코드·금액·상태·시각만 표기.

```java
/** 매칭 완료 (전체) */
public static String p2pMemberMatchedFull(String orderCode, long krwAmount) {
    return """
            🤝 <b>매칭 완료</b>

            주문번호: <code>%s</code>
            금액: <b>%s원</b>
            상태: 전체 매칭 완료 — 입금 확인 대기 중
            시각: %s

            ℹ️ 구매자의 입금이 확인되면 다시 알려드립니다."""
            .formatted(orderCode, formatKrw(krwAmount), now());
}

/** 매칭 완료 (부분, 옵션) */
public static String p2pMemberMatchedPartial(String orderCode, long matchedKrw, long totalKrw) { ... "일부 매칭 (%s / %s원)" ... }

/** 입금 확인 (부분) */
public static String p2pMemberDepositConfirmedPartial(String orderCode, long confirmedKrw, long totalKrw) {
    return """
            💰 <b>입금 확인</b>

            주문번호: <code>%s</code>
            확인 금액: <b>%s / %s원</b>
            상태: 일부 입금 확인 — 잔여분 처리 중
            시각: %s""" ... ;
}

/** 입금 확인 (전체) → 정산 진행 */
public static String p2pMemberDepositConfirmedFull(String orderCode, long krwAmount) {
    return """
            ✅ <b>입금 확인 완료</b>

            주문번호: <code>%s</code>
            금액: <b>%s원</b>
            상태: 전체 입금 확인 — USDT 정산이 진행됩니다.
            시각: %s""" ... ;
}

/** 취소 */
public static String p2pMemberCancelled(String orderCode, long krwAmount, String reason) {
    return """
            ⏹️ <b>출금 주문 취소</b>

            주문번호: <code>%s</code>
            금액: <b>%s원</b>
            사유: %s
            시각: %s""" ... ;
}

/** 분쟁 발생 */
public static String p2pMemberDisputeOpened(String orderCode) {
    return """
            ⚠️ <b>분쟁 접수</b>

            주문번호: <code>%s</code>
            상태: 입금 확인에 이의가 제기되어 검토 중입니다.
            시각: %s

            ℹ️ 처리 결과를 다시 알려드립니다.""" ... ;
}

/** 분쟁 해결 */
public static String p2pMemberDisputeResolved(String orderCode, String result) {
    return """
            ✅ <b>분쟁 처리 완료</b>

            주문번호: <code>%s</code>
            결과: %s
            시각: %s""" ... ;
}

/** (옵션) 정산 완료 */
public static String p2pMemberSettled(String orderCode, java.math.BigDecimal usdtAmount) { ... "USDT 정산 완료" ... }
```
`formatKrw(long)`은 `#,###` 콤마 포맷 헬퍼 신규(기존 `formatAmount`는 BigDecimal용).

## 7B. 알림 종류(taxonomy) + 수동 입금확인 버튼 처리

### 7B-1. 알림 종류 (전체)

| 그룹 | 코드 | 알림 | 트리거 | 모드 |
|------|------|------|--------|------|
| A 연결·모드 | A1 | 텔레그램 연결 완료 | `/start` 연결 성공 | 공통 |
|  | A2 | 수동모드 전환 안내 | deposit_method AUTO→MANUAL | 공통 |
| B 거래진행(정보성) | B1 | 매칭 완료 | `matched_at` | 공통 |
|  | B2 | 입금 확인 완료 | `BANK_CONFIRMED` | 공통 |
|  | B3 | 정산 완료(옵션) | `SETTLED` | 공통 |
|  | B4 | 주문 취소 | `CANCELLED` | 공통 |
|  | B5 | 분쟁 발생 | `DISPUTED` | 공통 |
|  | B6 | 분쟁 해결 | resolved | 공통 |
| **C 수동전용(액션)** | **C1** | **입금 확인 요청** | 구매자 transfer-done & 회원=MANUAL | **수동** |
|  | C2 | 미확인 리마인더 | C1 후 미확인 N분 | 수동 |
|  | C3 | 미확인 에스컬레이션 | 이체기한 `expires_at` 임박/경과 | 수동 |

> B 정보성 알림은 자동/수동 모두 발송(회원이 notify_enabled로 on/off). C는 수동 모드에서만.

### 7B-2. 수동(MANUAL) 입금확인 흐름

```
매칭 생성 → 구매자 계좌 확인 → [구매자] 이체완료 신고(transfer-done) → 매칭 BANK_PENDING
   회원 AUTO  → P2pScrapingVerifyJob(CODEF)가 자동 확인 → BANK_CONFIRMED → 정산
   회원 MANUAL→ ① ScrapingVerifyJob 자동확인 SKIP (MANUAL 게이트)
              ② C1 "입금 확인 요청" 발송 — 버튼 2개:
                   [✅ 입금 확인]        = 인라인 콜백(원탭)
                   [⚠️ 입금이 안 왔어요]  = URL(위젯 페이지, PIN 인증)
              ③-a 확인: [✅] 탭 → 봇 콜백 → confirmBankTransfer → BANK_CONFIRMED → 정산
                        → 봇이 메시지를 "✅ 확인 완료"로 수정(editMessageText)
              ③-b 문제/미확인: [⚠️] 탭 → 위젯 페이지 오픈 → PIN 인증 → 상세 확인 후
                        재확인 / 문제 신고(분쟁·문의) (§7B-7)
   미확인 시  → C2 리마인더(주기 반복) → C3 에스컬레이션
```

> **수동 확인 = 2채널(확정 2026-07-14)**: 회원은 **① 회원 페이지(거래상세 [입금 확인])** 와
> **② 텔레그램 [✅ 입금 확인] 버튼** 둘 다로 확인할 수 있다. 두 채널 모두 동일 core(`confirmBankTransfer`)를
> 호출하고 **멱등**하며, **어느 채널로 처리하든 결과가 텔레그램 메시지로 반드시 발송**된다(§7B-8).
> ("안 됨/문제"(negative)만 텔레그램 원탭이 아니라 **URL→위젯 페이지 PIN 인증**으로 분리 — §7B-7.)

**MANUAL 게이트(핵심)** — `P2pScrapingVerifyJob`가 매칭을 자동확인하기 전에, **실효 수동 여부**를
확인하여 수동이면 확인/자동분쟁을 **skip**하고 그 매칭을 회원 확인 대기로 남긴다.
실효 수동 = **회원 `deposit_method==MANUAL` OR 은행 자동확인 불가(`fast_inquiry_pattern IS NULL`)**
(→ 인터넷은행 등 CODEF 미지원 계좌는 회원 토글과 무관하게 항상 수동, 설정 지침서 §6B).
```java
// P2pScrapingVerifyJob.verify(...) 진입부 근처
P2pWithdrawOrder wo = withdrawRepo.findOne(match.getWithdrawOrderId());
if (wo != null && isEffectiveManual(wo)) {     // member.MANUAL || bank.fast_inquiry_pattern == null
    return VerifyOutcome.SKIPPED_MANUAL;       // 자동 confirm/dispute 안 함 → C1 발송
}
```
> 오탭 방지: [✅ 입금 확인]은 **이중 확인**(누르면 "정말 확인?" [예][아니오]로 editReplyMarkup) 권장.
> `confirmBankTransfer`는 이미 멱등(상태가 BANK_PENDING/CREATED/DISPUTED가 아니면 no-op)이라 중복 탭 안전.

### 7B-3. 인라인 버튼 발송 (Spring)

`TelegramBotClient.sendMessage`가 `reply_markup`(inline_keyboard)을 실을 수 있게 확장.
현재 record는 `{chat_id, text, parse_mode}`만 → optional `reply_markup` 추가.

```java
// 신규 오버로드
public boolean sendMessage(String botTokenRef, String chatId, String text,
                           String parseMode, Object replyMarkup) { ... }

// C1 발송 (P2pMemberNotifier)
var kb = Map.of("inline_keyboard", List.of(List.of(
    Map.of("text", "✅ 입금 확인",       "callback_data", "cfm:" + matchId),   // 원탭 콜백
    Map.of("text", "⚠️ 입금이 안 왔어요", "url", matchPageDeepLink)             // 위젯 페이지(PIN)
)));
telegramBotClient.sendMessage("p2p", chatId,
    TelegramMessageFormatter.p2pMemberConfirmRequest(...), "HTML", kb);
```
- `callback_data` 64바이트 제한 → 확인만 `cfm:<matchId>`. 문제 버튼은 **url**(콜백 없음).
- `matchPageDeepLink` = 회원 위젯의 해당 주문/매칭 상세로 가는 딥링크
  (예: `https://<p2p-ui>/m/{memberToken}#/orders/{orderCode}?match={matchId}`). **PIN 인증 필수**(토큰만으론 접근 불가).

### 7B-4. 콜백 처리 (Node `telegram-bot-p2p`)

봇이 `callback_query`(확인 버튼 = `cfm`만) 수신 → chatId로 회원 식별 → 소유권 확인 후 백엔드 내부 API 호출.
문제 버튼은 url이라 콜백이 오지 않는다(페이지에서 처리).
```ts
bot.on('callback_query', async (q) => {
  const chatId = q.message.chat.id.toString();
  const [action, matchIdStr] = (q.data ?? '').split(':');   // 'cfm' 만
  if (action !== 'cfm') return bot.answerCallbackQuery(q.id);
  try {
    const res = await callBackendTelegramConfirm({ chatId, matchId: Number(matchIdStr) });
    // 결과코드별 토스트 + 원본 메시지 수정 (§7B-8 매트릭스)
    await bot.answerCallbackQuery(q.id, { text: res.toast });        // 예: "확인됨" / "이미 처리됨" / "만료됨"
    await bot.editMessageText(res.resultText, {                      // 원본 C1을 최종 상태로 치환(버튼 제거)
      chat_id: q.message.chat.id, message_id: q.message.message_id, parse_mode: 'HTML',
    });
  } catch (e) {
    await bot.answerCallbackQuery(q.id, { text: '오류가 발생했습니다. 잠시 후 다시 시도해 주세요', show_alert: true });
  }
});
```
> 회원 식별은 **chat 바인딩**(p2p_members.telegram_chat_id). callback_data엔 matchId만 담고,
> "이 chat이 이 match의 주인인가"는 **백엔드에서 검증**(위·변조 방지).
> **중복 탭·스테일 버튼**(이미 확인/만료/취소)도 결과코드로 안전 처리 → 원본 메시지를 항상 최종 상태로 치환해
> 버튼을 없앤다(재탭 방지). 이중확인(예/아니오)은 §7B-6.

### 7B-5. 봇→백엔드 내부 확인 엔드포인트 (신규)

Node 봇은 세션이 없으므로, 봇 전용 내부 엔드포인트를 만들고 **공유 시크릿**으로 인증한다.
```
POST /internal/p2p/telegram/confirm       (open-api, 신규 InternalP2pTelegramController)
Header: X-Bot-Secret: ${P2P_BOT_INTERNAL_SECRET}
Body:   { "chatId": "...", "matchId": 123 }     // 확인(cfm) 전용

처리:
  1) X-Bot-Secret 검증 (env, 상수시간 비교)
  2) member = p2pMembers.findByTelegramChatId(chatId)   (없으면 404)
  3) match = matchRepo.findOne(matchId)
     wo = withdrawRepo.findOne(match.withdrawOrderId)
     verify wo.partnerId==member.partnerId && wo.partnerUserId==member.partnerUserId  (아니면 403)
  4) result = matchingService.confirmBankTransferResult(matchId, "TG_MEMBER_CONFIRM")  // 결과코드 반환판
  5) { code, resultText } 반환   // code ∈ CONFIRMED / ALREADY_DONE / NOT_ACTIONABLE / IN_REVIEW (§7B-8)
```
- **확인(cfm)만** 봇→백엔드로 처리. 분쟁/문제 신고는 봇이 아니라 **위젯 페이지(PIN)** 에서 → §7B-7.
- 시크릿: 신규 env `P2P_BOT_INTERNAL_SECRET`(Spring + Node 공유). 봇 토큰과 별개.
- **멱등/예외**: 기존 `confirmBankTransfer`는 void·조용히 no-op → 봇이 결과를 알 수 있도록
  **결과코드 반환판 `confirmBankTransferResult`** 신규(전이 여부·현재 상태 판별). 코드별 메시지는 §7B-8.

### 7B-6. 보안 정리 (확정)
- 인증 경계 = **회원이 직접 연결한 개인 chat**(telegram_chat_id UNIQUE). 버튼은 그 chat에만 노출.
- **확인(positive)** = 이미 받은 돈 인정 = 저위험 → 텔레그램 **원탭 콜백 + 이중확인(예/아니오)** 로 오탭 방지.
- **문제/분쟁(negative)** = 결과가 무겁고 입력 필요 → **URL 버튼 → 위젯 페이지 PIN 인증** 후 처리(§7B-7).
- 이 분리로 "실수 탭에 의한 잘못된 분쟁/자금 사고"를 최소화한다.

### 7B-7. "입금이 안 왔어요/문제" — 페이지 처리 (신규)

문제 버튼(url)은 회원 위젯의 해당 주문/매칭 상세로 딥링크한다. 페이지에서 **PIN 인증 후**:
- **재확인**: 방금 입금이 확인되면 그 자리에서 `POST /p2p/page/orders/{code}/confirm-deposit/{matchId}`(기존).
- **문제 신고(신규)**: "입금이 안 왔어요 / 금액이 달라요" → **회원(출금자) 분쟁 제기**.
  - 신규 엔드포인트: `POST /p2p/page/orders/{code}/matches/{matchId}/report`
    Body `{ reason, evidenceUrl? }` → `dispute_submitted_by=WITHDRAWER`, `dispute_source=WITHDRAWER`로 분쟁 생성
    (구매자 분쟁 `submitDispute`와 대칭. 매칭 status→`DISPUTED`, 이후 관리자 판정).
  - 증빙 첨부(선택)·사유 입력은 페이지 폼에서.
- 페이지 미인증/이탈해도 매칭은 그대로 `BANK_PENDING` 유지(자동확인 skip 상태) → C2 리마인더 지속.

> 프론트: 이 딥링크 진입점 + "문제 신고" 폼(사유/증빙)은 UI 핸드오프에 반영 필요(신규 화면).

### 7B-8. 결과 메시지 + 멱등/예외 (확정 2026-07-14)

**원칙**: 확인이 **어느 채널(페이지·텔레그램)** 로 처리되든, **처리 결과는 회원 텔레그램 메시지로 반드시 발송**한다.

**결과코드** — `confirmBankTransferResult(matchId, ref)` 반환:

| 코드 | 조건(현재 상태) | 처리 |
|------|----------------|------|
| `CONFIRMED` | `BANK_PENDING`(정상) | 이번에 확인 → `BANK_CONFIRMED` → 정산 |
| `ALREADY_DONE` | `BANK_CONFIRMED`/`SETTLING`/`SETTLED` | 이미 처리됨(no-op) |
| `IN_REVIEW` | `DISPUTED` | 검토 중 — 텔레그램 확인 불가(페이지/관리자) |
| `NOT_ACTIONABLE` | `CANCELLED`/`FAILED`/`EXPIRED` | 처리 불가(종료됨) |

**텔레그램 버튼 채널** — 결과코드별 봇 응답(answerCallbackQuery `toast` + 원본 메시지 editMessageText로 치환·버튼 제거):

| 코드 | toast | 원본 메시지 치환 |
|------|-------|------------------|
| CONFIRMED | "확인되었습니다" | "✅ 입금 확인 완료 · 정산이 진행됩니다" |
| ALREADY_DONE | "이미 확인된 거래예요" | "✅ 이미 확인 처리된 거래입니다" |
| IN_REVIEW | "검토 중인 거래예요" | "⚠️ 문제 신고로 검토 중입니다 · 페이지에서 확인" |
| NOT_ACTIONABLE | "처리할 수 없어요" | "⏹️ 만료/취소되어 처리할 수 없는 거래입니다" |

- **중복 탭(레이스)**: 첫 탭 CONFIRMED, 두 번째 탭은 ALREADY_DONE → 안전. 원본 메시지를 매 처리마다 최종
  상태로 치환하므로 버튼이 사라져 재탭 자체를 줄인다.
- **스테일 버튼**(오래된 C1의 [✅]): 상태가 이미 바뀌었으면 위 표대로 안내만.

**페이지 채널** — 거래상세 [입금 확인](`POST /orders/{code}/confirm-deposit/{matchId}`)로 확인 시:
- 동일하게 결과코드 반환(화면 토스트).
- **`CONFIRMED`이면 afterCommit으로 회원 텔레그램에 결과 메시지 발송**(B2 "입금 확인 완료").
  → 페이지로 처리해도 텔레그램에 기록이 남는다(요구사항).

**발송 지점(공통)**: `confirmBankTransfer` 성공 전이(→`BANK_CONFIRMED`) afterCommit 훅에서 회원 결과 메시지 1회.
텔레그램 버튼 채널은 여기에 더해 원본 C1 메시지를 즉시 editMessageText로 치환(중복 아님 — edit는 기존 메시지, 발송은 결과 통지).

## 8. 설정 / 보안

- **봇(전용 신설)**: @BotFather로 P2P 전용 봇 생성 후 토큰/username 발급. 신규 프로퍼티:
  ```yaml
  cryptoments:
    telegram:
      bot-token: ${TELEGRAM_BOT_TOKEN}            # 기존 파트너 봇
      p2p:
        bot-token: ${TELEGRAM_P2P_BOT_TOKEN}      # 신규 — Spring 발송 (open-api/scheduler)
        bot-username: cryptoments_p2p_bot         # 신규 — 딥링크 조립
  ```
  Node 쪽: `TELEGRAM_P2P_BOT_TOKEN` env → `config.telegram.p2pBotToken`, `telegram-bot-p2p` 프로세스 polling.
  → 파트너 봇 토큰과 **완전 분리**, 한쪽 장애가 다른 쪽에 영향 없음.
- **서명키**: 파트너 connect token과 동일한 세션 시크릿(`axim.rest.session.secret-key`) 재사용 →
  Node `config.session.secretKey`와 자동 일치.
- **토큰 만료 5분** + type=="M" 검증으로 파트너 토큰과 혼동/오용 차단.
- **chat_id UNIQUE**: 재바인딩 시 기존 연결 자동 해제(한 텔레그램=한 회원).
- 딥링크는 로그인 세션에서만 발급되므로, member_token 원문을 URL에 노출하지 않는다.

## 9. 구현 순서 (분담)

**0. 봇 생성** — ✅ 완료. P2P 전용 봇 `@cryptoments_p2p_bot`(id 8196181996) 발급됨.
   남은 일: 토큰을 운영 env `TELEGRAM_P2P_BOT_TOKEN`에 등록(문서/코드에 원문 커밋 금지).

**A. DDL (Cowork, 승인 후)** — `p2p_members` ALTER 운영 반영.

**B. Spring / IntelliJ**
1. `P2pMember` 필드 3개 + `P2pMemberRepository.findByTelegramChatId`
2. `TelegramBotClient` — `p2p.bot-token` 프로퍼티 + `resolveBotToken("p2p")` 분기
3. `MemberTelegramConnectToken` 유틸 (open-api)
4. `P2pWithdrawPageController` 텔레그램 4개 엔드포인트 (딥링크에 P2P 봇 username)
5. `P2pMemberEvent` enum + `P2pMemberNotifier` 서비스(`sendMessage("p2p", ...)`)
6. `TelegramMessageFormatter` 회원 템플릿 + `formatKrw`
7. §6-3 콜사이트 6곳에 afterCommit 훅 삽입

**C. Node / VS Code — 신규 패키지 `telegram-bot-p2p`**
1. common: `verifyMemberConnectToken`, `p2pMemberRepo`(bind/clear), `config.telegram.p2pBotToken` + export
2. `telegram-bot-p2p/src/app.ts`(P2P 토큰 polling) + `handlers/index.ts`(/start·/stop·/help)
3. `pnpm-workspace`/빌드 스크립트/PM2 프로세스 등록 (기존 `telegram-bot` 미러링)

**D. 위젯 UI** — 출금회원 페이지에 "텔레그램 알림 연결" 버튼(딥링크) + on/off 토글 + 해제.

## 10. 한계 / 오픈 이슈 (MVP)

- **재시도 없음**: 발송 실패는 로그만. 파트너 웹훅처럼 `webhook_delivery_logs` 재시도 인프라를
  회원 텔레그램에 붙이는 것은 후속(P1). 필요 시 outbox(`p2p_member_notifications`) 테이블 검토.
- **이벤트 세분 구독 없음**: `telegram_notify_enabled` 단일 on/off. 이벤트별 구독은 후속.
- **다국어**: 한국어 고정. 파트너 회원 로케일 확장은 후속.
- **결정 필요**: 부분 매칭(`MATCHED_PARTIAL`)·부분 입금확인 알림을 켤지(알림 빈도 ↑) 여부 —
  기본은 전체(FULL) 이벤트만 발송하고 부분은 옵션 플래그로 두기를 권장.
