<!-- @format -->

# `token.social.recorded` — 소셜 기록 수렴 이벤트 (소비자용 계약)

> 이 서비스가 **발행하는 유일한 이벤트**다. 나머지 RabbitMQ 배선은 전부 소비 방향이다.
> 설계 이력: `docs/features/social-recorded-event/design.md`

---

## 1. 무엇을 알리는가

**한 토큰의 소셜 기록이 수렴했다** — 더 이상 열어볼 URL 이 없다는 뜻이다.

정확히는 `tokens.social_urls[].status` 에 **`pending` 과 `error` 가 하나도 없는** 상태다.
「재시도 여지가 전혀 없다」가 아니라 **「이 파이프라인이 지금 낼 수 있는 답은 다 냈다」** 로
읽어야 한다.

`unsupported` · `skipped_paid` 가 남아 있어도 발행된다. 그 둘은 외부를 부르지 않아 시도
횟수가 오르지 않고, 열리는 조건이 시간이 아니라 **배포**(담당 Generator 신설 / 유료 스위치)라
기다려도 바뀌지 않기 때문이다. 이 둘을 술어에 넣었다면 미지원 URL 하나를 가진 토큰
**1,896건(4.3%)** 이 나머지가 전부 `ok` 여도 영원히 이벤트를 못 받는다.

**토큰당 1회** 발행된다. 정확히는 그 토큰이 **미수렴 → 수렴으로 전이하는 순간**에만 나간다.

`/retry --reopen` 으로 사람이 흡수 상태를 다시 열어도 대부분 추가 발행은 없다 —
`exhausted` → `ok` 처럼 흡수 상태끼리 옮겨가면 전이가 아니기 때문이다. `exhausted` → `error`
로 **토큰이 실제로 미수렴이 된 경우에만** 이후 패스가 다시 닫으면서 한 번 더 나간다.

> ⚠️ 뒤집어 말하면 **`okCount`·`platforms` 가 늘어도 이벤트가 안 올 수 있다.** 값이 바뀌었는데
> 알림이 없는 경우다. 최신값이 필요하면 이벤트를 기다리지 말고 재조회할 것.

---

## 2. 계약

|             | 값                                                                       |
| ----------- | ------------------------------------------------------------------------ |
| Exchange    | `socialRecordExchange` (`topic` · `durable: true` · `autoDelete: false`) |
| Routing key | `token.social.recorded`                                                  |
| 봉투        | **없음.** `JSON.parse` 한 번이면 끝이다                                  |

### 페이로드

```jsonc
{
  "tokenAddress": "So1111...1112",
  "tokenSymbol": "WSOL",
  "recordedAt": "2026-08-26T04:05:06.789Z", // ISO 8601
  "urlCount": 3,
  "okCount": 2,
  "platforms": ["tg", "x"],
}
```

| 필드           | 뜻                                                                |
| -------------- | ----------------------------------------------------------------- |
| `tokenAddress` | 멱등키로 쓸 값                                                    |
| `tokenSymbol`  | 표시용                                                            |
| `recordedAt`   | **패스 시작 시각이지 발행 시각이 아니다**                         |
| `urlCount`     | `social_urls` 총 개수                                             |
| `okCount`      | 그중 `status: ok` 인 개수. `urlCount - okCount` 가 「못 건진 수」 |
| `platforms`    | `ok` 인 URL 의 플랫폼. 중복 제거 · 정렬                           |

`platforms` 값 어휘는 8종이다 — `x` · `tt` · `ig` · `yt` · `rd` · `gh` · `tg` · `web`.

> ⚠️ **`urlCount: 0` 은 정상값이다.** 소셜이 하나도 없는 토큰이 실측 **14.6%**(51,712 중
> 7,534)다. 오류로 읽고 버리면 그 집합이 통째로 사라진다. 「아직 안 끝난 것」과 「끝났는데
> 소셜이 없는 것」을 구분할 수 있어야 해서 일부러 발행한다.

> ⚠️ **`okCount: 0` 도 정상값이다.** URL 이 전부 `blocked`/`not_found` 로 수렴한 경우다.
> 완료는 완료이므로 발행하고, 거르는 것은 소비자 몫이다.

---

## 3. 붙는 법

### exchange 는 만들지 말 것

**exchange 는 이 서비스가 소유하고, 부팅 시 선언한다.** 소비자는 `checkExchange`(passive
declare)로 존재만 확인해야 한다.

`assertExchange` 를 쓰면 옵션이 우리 선언과 한 글자라도 어긋나는 순간 **소비자 부팅이 깨진다.**
이 레포가 상류 exchange 를 다룰 때 정확히 같은 규율을 지킨다 —
`AbstractEventManager.onApplicationBootstrap()` 의 주석이 그 근거다.

### 큐는 소비자가 만든다

우리는 큐를 만들지도, 이름을 정하지도 않는다. 권장 규칙만 둔다:

```
<consumer-service>.social-recorded[.<purpose>]
```

- prod 만 bare, 그 외는 `.{APP_ENV}` 접미사
- 접미사가 필요한 이유: 정적 durable + competing consumer 큐라, 접미사가 없으면 개발자
  로컬 인스턴스가 **prod 이벤트를 실제로 빼앗아 간다**
- `purpose` 세그먼트는 이벤트 이름이 아니라 **최종 소비 목적**을 적는다. 지금 로그만
  찍는다고 `log-only` 로 지으면 소비자가 붙는 날 이름이 거짓말이 된다

바인딩은 `token.social.recorded` 로 **정확히** 좁힌다. 나중에 형제 이벤트가 생겨도 받고
싶다면 `token.social.#` 을 쓴다.

### exchange 이름에 env 접미사가 없다

큐와 달리 exchange 는 dev/prod 이름이 같다. 브로커가 물리적으로 분리돼 있어서
(dev `localhost:5672/dev` · prod `192.168.50.9:30002/dev`) 가로채기가 성립하지 않는다.

---

## 4. 배달 보장

- **at-least-once.** 브로커가 보장하는 것은 여기까지다
- **멱등키는 `tokenAddress`** — 중복은 사실상 없지만(수렴이 단조라 술어가 두 번 참이 되지
  않는다) 접을 수단은 이 값이다
- **순서 미보장.** 순서에 의존한다면 도착 순서가 아니라 `recordedAt` 으로 정렬할 것

---

## 5. 한계 — 반드시 알고 붙을 것

### 5.1 이미 수렴해 있던 토큰은 이벤트를 받지 못한다

이 이벤트는 **수렴하는 순간을 포착**해 발행한다(전이 판정). 발행 여부를 DB 에 적어 두지
않기 때문에, 기능이 배포된 시점에 **이미 수렴이 끝나 있던 토큰 43,106건은 그 순간이 지나
영원히 발행되지 않는다.**

과거 토큰이 필요하면 이벤트가 아니라 **DB/API 를 직접 조회**해야 한다. 그것이 가능하다는
전제 위에서 「발행하고 잊는다」를 골랐다.

### 5.2 페이로드에 필드를 추가해도 소급되지 않는다

같은 뿌리다. 필드가 늘어나면 **그 이후 발행분부터** 담긴다.

### 5.3 발행이 실패하면 그 이벤트는 사라진다

발행 시점에 DB 기록은 이미 커밋돼 있고, 재발행 경로가 없다. 실패는 로그로만 남는다
(`tokenAddress` · `routingKey` 포함). 기록은 정상이므로 **DB 를 보면 값은 거기 있다.**

### 5.4 셋 다 같은 방법으로 풀린다

`tokens` 에 영속 마커(예: `social_recorded_at`)를 두면 세 한계가 한 번에 사라진다 —
백필도, 소급도, 유실 복구도 가능해진다. 넣지 않은 이유는 소비자가 재조회할 수 있어서다.

**재검토 시점은 소비자가 「지난 토큰도 전부 달라」를 요구할 때다.** 미루면 손실이 커지는
종류의 결정이라는 것도 함께 기록해 둔다 — 마커를 나중에 넣으면 그 시점까지 수렴한 토큰이
또 한 번 사각지대가 된다.

---

## 6. 언제 발행되나 — 네 경로 전부

| 경로           | 트리거                                          |
| -------------- | ----------------------------------------------- |
| 실시간         | `token.flow` 이벤트                             |
| reconcile 백필 | `POST /v1/social-reconcile/reconcile`           |
| 재시도         | `POST /v1/social-reconcile/retry` · 재시도 크론 |
| 테스트 API     | `social-record-test` (prod 미등록)              |

⚠️ **reconcile 백필은 대량 발행이 가능하다.** `limit` 을 생략하면 후보가 마를 때까지 돌므로,
소비자가 붙은 뒤 대규모 백필을 돌릴 때는 그쪽 처리량을 먼저 확인할 것.

---

## 7. 현재 소비자

| 서비스              | 큐                                                          | 목적                                                       | 설계 문서 |
| -------------------- | ------------------------------------------------------------ | ------------------------------------------------------------ | --------- |
| `af-social-scanner`(이 레포 자체) | `af-social-scanner.social-recorded.namesilo-alert[.{APP_ENV}]` | NameSilo 코호트로 판정된 토큰을 슬랙 웹훅으로 알린다 | `docs/features/namesilo-cohort-slack-alert/design.md` |

**이 레포 자신이 첫 소비자다** — `token.social.recorded` 를 되받아 판정하는 감사 경로로,
발행 측 `SocialRecordEventManager` 가 그대로 구독도 겸한다(`social-record-event-manager.ts`).
다른 레포의 소비자가 붙으면 이 표에 행을 추가할 것.

### 페이로드만으로는 못 하는 것

`namesilo-cohort-alert` 가 실제로 필요로 하는 값(`contents.data.registrar` ·
`contents.data.domainCreatedDate` · `contents.data.pattern`)은 **이 이벤트의 페이로드에
없다.** 이유는 §2 페이로드가 명시적으로 6필드로 얼려 있어서가 아니라, 그 값들이 애초에
**토큰의 속성이 아니라 토큰에 걸린 web content 하나의 속성**이기 때문이다 — 토큰에 웹
URL 이 여러 개면 페이로드가 어느 것의 값을 넣을지 답할 수 없다.

**이 레포 안의 소비자는 로컬 쿼리 2회로 재조회한다**(`TokenRepository.findByAddress` →
`ContentRepository.findByIds`, `namesilo-cohort-slack-alert/design.md` §2.2). **원격
소비자라면 이 재조회 경로가 없다** — `tokenAddress` 를 받아도 그 값을 얻을 API 가 이
서비스에 아직 없다. 이런 값이 필요한 원격 소비자가 생기면 이벤트를 부풀리기 전에
조회 API 를 먼저 여는 편이 낫다(§2 페이로드를 「일반 계약」으로 유지하려는 이유가
`social-recorded-event/design.md` §2.1 에 있다).
