# Token Social Graph API — Design

> 관련 근거: `src/modules/social-recording/social-recording.types.ts`(`SocialObjectGraph`·`SocialObjectGroup`) ·
> `src/modules/social-graph/token/token-link.model.ts`(`link_depth`·`entry_url`) ·
> `guides/social-recording/decisions.md`(D-5·G-2·R-007·R-009) ·
> `.claude/skills/scanner-data`(T2·T6·T7·T8·T11·T14·T16)
> 실측 근거: §3 (전부 2026-08-27 · live prod `af-social-scanner`)
> 소비자용 레퍼런스: `guides/api/token-social-graph.md` (호출하는 쪽이 읽는 문서)
> **상태: 반환 스키마 확정.** 잔여 결정은 §9.
> 정정 2026-08-27: §8 이 «가드를 새로 만들어야 한다» 고 적었으나 `ApiKeyGuard` 가 이미 있다.

---

## 0. 한 줄 요약

토큰 주소 하나로 **그 토큰에 걸린 모든 소셜 정보를 URL 단위로 묶어** 돌려주는 첫 공개 계약.
한 URL 안의 인용·답글 사슬은 **깊이가 1단이든 9단이든 같은 모양**의 평탄 배열로 낸다.

---

## 1. 요구사항과 거기서 나온 결정

| # | 결정 | 근거 |
|---|---|---|
| D-1 | 소비자는 **외부 서비스**(다른 레포) | 계약 안정성이 표시 편의보다 우선. enum 고정, 결측도 `null` 로 명시 |
| D-2 | 묶음의 기준선은 **`tokens.social_urls[]` 전부** — 그래프가 없는 URL 도 한 줄 | 실패는 행을 만들지 않고 `status` 에만 남는다(T3). 그래프만 내면 «텔레그램이 없었다» 와 «못 가져왔다» 가 같은 응답이 된다 |
| D-3 | 노드 = **묶음 1개**(`{content, account, venue}`), 평탄 사슬 | 쓰기 계약 `SocialObjectGroup` 과 1:1 대칭. §3.1 이 «저장된 것과 반환하는 것이 갈릴 수 없다» 를 실측으로 보장 |
| D-4 | 중복 객체는 **전부 인라인**(비정규화) | 조인 0회. 토큰당 URL 최대 4개(§3.6)라 반복 비용이 구조적으로 작다 |
| D-5 | 공통축은 고정 계약, **`data{}` 는 무계약 패스쓰루** | `data` 는 «조회축이 되면 top-level 로 승격» 하는 **대기실**이다. 계약에 넣으면 승격이 곧 파괴적 변경이 된다 |
| D-6 | 지표는 **`metric_series` 최신 1점 + `observedAt`**. **`metrics_latest` 폴백은 두지 않는다** | `contents.metrics_latest` 는 행 생성 시각 기준이라, 행이 지표 없이 만들어졌으면 영영 빈다(`social-graph.writer.ts:449-456`). SoT 에서 읽고 as-of 를 함께 낸다(T10). 폴백을 뺀 근거는 §3.5 |
| D-7 | **단건만**. 없는 토큰은 404, 있는 토큰은 200 + `collection{}` 요약 | «수집된 적 없음» 과 «소셜이 없음» 이 갈려야 소비자가 재호출 여부를 판단한다 |
| D-8 | 경로는 `GET /api/v1/tokens/{address}/social-graph` | 식별자가 **토큰의 것**이라 리소스도 토큰이어야 이름과 식별자가 어긋나지 않는다. 그래프에는 자기 id 가 없다 — `token_links` 는 unique 제약 없는 append-only 로그다 |

### 하지 않는 것

- `/tokens` · `/tokens/{address}` 는 만들지 않는다. 하위 리소스만 서빙하는 것은 REST 상 정상이고,
  **이 서비스는 토큰 마스터의 주인이 아니다**(`tokens.symbol` 은 표시용 사본, SoT 는 tracker · T15).
- `internal/` 접두를 쓰지 않는다. 기존 컨트롤러 **6개가 전부** `internal/{모듈명}` 인데,
  그건 운영·디버깅 도구라서이지 규칙이어서가 아니다. 이건 첫 공개 계약이다.
- 페이지네이션·정렬 파라미터를 두지 않는다. 응답이 구조적으로 유계다(§3.6).

---

## 2. 반환 스키마 (확정)

```
GET /api/v1/tokens/{address}/social-graph
헤더: x-api-key   (ApiKeyGuard · §8)
```

```ts
// 200 — WrapperResWithMetaDto.from(data)  (common/dtos/api-response.dto.ts)
{ data: {
  address: string,
  symbol: string | null,            // 표시용 사본. SoT 는 tracker (T15)

  collection: {                     // "지금 보는 것이 완성본인가"
    total:   number,                // social_urls.length
    ok:      number,
    pending: number,                // RETRYABLE_FETCH_STATUSES — 다시 부르면 바뀔 수 있다
    failed:  number,                // 흡수 상태인데 ok 가 아닌 것
  },

  urls: UrlGroup[],                 // social_urls[] 전부. 평균 1.35 · 최대 4 (§3.6)
}}
```

```ts
interface UrlGroup {
  url: string;                      // 정규화 canonical URL (= token_links.entry_url)
  platform: SocialPlatform;         // 조회 시점 재분류. null 이 될 수 없다 — 아래 주의
  status: FetchStatus;              // 10개 어휘
  retryable: boolean;               // RETRYABLE_FETCH_STATUSES 소속 여부
  attemptedAt: string | null;       // ISO8601. 부르지 않았으면 null
  attempts: number;
  lastErrorCode: ExternalErrorCode | null;
  nodes: Node[];                    // depth 오름차순. status ≠ ok 면 [] 가 정상
}
```

> ⚠️ **`platform` 은 저장된 값이 아니라 조회 시점 재분류다.** `tokens.social_urls[]` 는 분류
> 결과를 담지 않는다(v5 T-M1) — 다섯 필드가 전부 `url` 의 순수 파생이라, 박제하면 분류 규칙의
> 버그를 고쳐도 옛 판정이 남는다. `SocialFetcherRouter.platformOf()` 를 응답 조립 시점에 부른다.
>
> ⚠️ **`platform: 'web'` 은 «웹사이트다» 라는 주장이 아니다.** 담당 소셜이 없는 호스트가 전부
> 여기로 떨어진다 — 웹은 *"아직 분류되지 않은 URL 을 담는 잠정 버킷"* 이라 `null` 이 나올 수 없다
> (`social-fetcher.router.ts:64`).

```ts
interface Node {                    // = 저장된 묶음 1개. (entry_url, link_depth) 로 복원
  depth: number;                    // 0 = 토큰이 직접 건 대상 · N = 인용/답글 N단 위. 실측 0..8
  parent: {
    relation: 'quote' | 'retweet' | 'reply' | 'fork';
    contentId: string | null;       // 부모 콘텐츠의 id. 응답 안 content.id 와 맞댄다 (§3.4)
    inChain: boolean;               // true = 바로 위 노드(depth+1)가 그 부모
  } | null;
  content: Content | null;          // 세 칸 각각 최대 1개 — §3.1 이 실측으로 보장
  account: Account | null;
  venue:   Venue   | null;
}
```

```ts
interface Content {
  id: string;
  platform: SocialPlatform;
  subtype: ContentSubtype;
  platformKey: string;              // 웹만 URL, 나머지는 id (플랫폼별 정의는 scanner-data §3)
  publishedAt: string | null;       // 세상의 성질. 저장 시각(created_at)과 다르다 (D-4)
  text: { primary: string | null; body: string | null; fromMedia: string | null };
  outboundUrls: string[];
  tags: string[];
  mediaUrls: string[];
  mentions: { platformKey: string; screenName: string | null }[];
  metrics: { observedAt: string; [key: string]: string | number } | null;
  data: Record<string, unknown>;    // ⚠️ 무계약 — 아래
}

interface Account {
  id: string;
  platform: SocialPlatform;
  platformKey: string;
  handle: string | null;            // handles[] 는 항상 1원소다 (T7)
  displayName: string | null;
  bio: string | null;
  avatarUrl: string | null;
  accountCreatedAt: string | null;  // ig 는 구조적 부재 (T9)
  outboundUrls: string[];
  metrics: { followers: number } | null;
  data: Record<string, unknown>;    // ⚠️ 무계약
}

interface Venue {
  id: string;
  platform: SocialPlatform;
  subtype: VenueSubtype;            // 상태이지 정체성이 아니다. subtype 없이 읽지 말 것 (T11)
  platformKey: string;
  name: string | null;
  description: string | null;
  venueCreatedAt: string | null;
  metrics: { members: number | null; moderatorCount: number | null } | null;
  data: Record<string, unknown>;    // ⚠️ 무계약
}
```

### `metrics` 의 키는 통일하지 않는다

플랫폼 원래 이름 그대로 낸다(T8) — x `viewCount`/`likeCount`, tt `playCount`/`diggCount`,
gh `stars`/`forks` … . **의도된 것이다**: TikTok 자동재생과 YouTube 30초 시청이 같은 칸에 들어가면
안 된다. `observedAt` 은 그 숫자의 as-of 앵커라 항상 함께 낸다.

### `data{}` 를 계약에서 뺀 것의 의미

`data` 는 «값이 조회·정렬의 축이 되는 순간 top-level 로 승격하는» **대기실**이다
(`content.model.ts` `ContentData` 헤더). 즉 **오늘 여기 있는 키가 내일 위로 올라가는 것이 정상**이고,
계약에 넣으면 그 승격이 전부 파괴적 변경이 된다. 그래서 값이 있는 키만 그대로 실어 보내되
**«예고 없이 바뀐다»** 를 스펙에 못 박는다. 여기에 의존하는 소비자는 자기 책임으로 의존한다.

### 의도적으로 뺀 필드

| 필드 | 이유 |
|---|---|
| `accounts.knownWallets[]` | **채우는 코드가 없다.** 항상 `[]` — 상수를 계약에 실으면 «지갑이 없다» 는 신호로 오독된다(T4) |
| `accounts.unavailable` | 정지 계정은 `platform_key` 를 못 만들어 **행 자체가 안 생긴다.** 정지 신호는 `status='suspended'` 에만 있다(T4) |
| `contents.venueId` | **44,706행 전부 미채움**(§3.2). 항상 `null` 인 칸이다 |

---

## 3. 스키마가 서 있는 실측

전부 2026-08-27 · live prod. **파이프라인이 도는 중이라 총계는 쿼리 간 수십 건 흔들린다**
(content 보유 노드가 77,011 / 77,021 / 77,040 으로 관측됐다). 비율 판단에는 영향이 없다.

### 3.1 노드는 `(token, entry_url, link_depth)` 로 유일하게 복원된다 ← 스키마의 토대

쓰기 쪽 계약은 *"한 URL 이 만드는 것은 가지도 고아도 없는 **사슬 하나**"* 이고, 어기면 Writer 가
던진다(`SocialObjectGraph.groups` 주석). **그 계약이 읽기 쪽에도 살아 있는지**를 먼저 확인했다.

```js
db.token_links.aggregate([
  { $group: { _id: { t:"$token_address", u:"$entry_url", d:"$link_depth", o:"$object" },
              objs: { $addToSet: "$object_id" } } },
  { $project: { n: { $size: "$objs" } } },
  { $group: { _id: { o:"$_id.o", n:"$n" }, groups: { $sum: 1 } } },
])
```

| object | 그룹 수 | distinct object_id 가 2 이상인 그룹 |
|---|---|---|
| `contents` | 77,007 | **0** |
| `accounts` | 70,874 | **0** |
| `venues` | 3,722 | **0** |

**151,603 그룹 전부 대상이 정확히 하나다.** 그래서 노드의 세 칸이 각각 단수인 것은 «지금 그렇더라»
가 아니라 저장 구조가 보장하는 성질이다.

### 3.2 노드의 세 칸이 실제로 차는 조합 — 다섯 가지뿐

| 조합 | 노드 | 의미 |
|---|---|---|
| `content` + `account` | 61,124 (d0 42,785 / d≥1 18,339) | 글 + 글쓴이 |
| `content` 만 | 15,887 (d0 13,684 / d≥1 2,203) | web·reddit 글, 검색어 — 작성자 개념이 없다 |
| `account` 만 | 8,496 (전부 d0) | 프로필 URL. **IG 스토리도 여기로 온다**(T11) |
| `venue` 만 | 2,464 (전부 d0) | 텔레그램 채널·포털 |
| `venue` + `account` | 1,258 (전부 d0) | X 커뮤니티 + 개설자 |
| `content` + `venue` | **0** | `contents.venue_id` 가 44,706행 전부 미채움 |

`content`+`venue` 가 0 이라는 사실이 스키마를 단순하게 만든다 — 쓰기 계약이 `GeneratedVenue.creator`
를 **중첩**으로 둔 이유(*"채널 개설자와 글쓴이는 다른 사람일 수 있다"*)가 저장된 데이터에는
아직 실현되지 않았다. 텔레그램은 `accounts` 행을 아예 만들지 않고, X 커뮤니티는 콘텐츠와 같은
노드에 오지 않는다. **그래서 노드당 계정은 언제나 최대 1개**이고(§3.1), 중첩 없이 평탄한 세 칸으로 충분하다.

### 3.3 `account` 슬롯: 링크 우선 · `content.creator_id` 폴백

두 근거가 있어 규칙이 필요하다. content 를 가진 노드 77,021건을 대조했다.

| 판정 | 노드 | 뜻 |
|---|---|---|
| 일치 | 61,124 | 두 근거가 같은 계정 |
| 둘 다 없음 | 13,670 | web·rd·검색어 — 작성자 개념이 없다 |
| **`creator_id` 만 있음** | **2,218** | 링크가 없다. **전부 X tweet, 99.3%(2,203)가 depth≥1** |
| 링크만 있음 | 6 | |
| **불일치** | **3** | 아래 |

**규칙: 노드의 account 링크를 먼저 쓰고, 없으면 `content.creator_id` 로 폴백한다.**
근거는 이 API 의 스코프다 — 응답 전체가 *"이 **토큰**이 무엇에 걸렸나"* 이므로 토큰 스코프 사실
(링크)이 전역 사실(`creator_id`)보다 우선한다. 폴백이 없으면 **인용 원본의 작성자를 depth≥1 노드의
10.7% 에서 놓친다.**

불일치 3건은 전부 GitHub 이고 **같은 repo id `1317028114`** 를 서로 다른 소유자 경로
(`ayanchurevich/Ava-Fontaine` · `JasonBudd/Ava-Fontaine`)가 가리킨다. `contents` 는
`(platform, platform_key)` unique 라 한 행인데:

- `content.creator_id` = **우리가 이 repo 를 처음 봤을 때의 소유자** (재관측이 덮지 않는다 · T7)
- 노드의 account 링크 = **이 토큰이 건 URL 의 소유자**

둘 다 맞고 서로 다른 질문의 답이다. 그리고 이 어긋남 자체가 audit 이 actionable 로 판정한
**포크 위장** 신호와 같은 자리다(`ContentData.fork`: *"남의 repo 를 포크해 자기 프로젝트로 위장"*).
링크 우선 규칙은 «이 토큰이 내세운 소유자» 를 내고, 첫 관측 소유자는 `content.data.ownerLogin` 에
그대로 남아 있어 **소비자가 응답만으로 두 값을 맞댈 수 있다.** 값을 잃지 않는다.

### 3.4 `parent.inChain` 이 필요한 이유 — 관계는 전역, 사슬은 (토큰, URL)별

content 노드 77,040건에서 «`parent_relation` 이 가리키는 부모가 depth+1 노드인가» 를 확인했다.

| 판정 | 노드 | |
|---|---|---|
| 관계 없음 · 위 노드 없음 (사슬 꼭대기) | 55,319 | 정상 |
| **관계 → depth+1 노드 일치** | **20,537** | 정상. **불일치 0건** |
| **관계는 있는데 위 노드가 없다** | **1,172 (1.5%)** | ← `inChain: false` |
| 관계는 없는데 위 노드가 있다 | 12 | |

**불일치가 0건**이라 «depth+1 이 곧 부모» 는 안전하게 계약이 된다. 문제는 1,172건인데,
원인은 구조적이다 — `contents.parent_relation` 은 **콘텐츠의 전역 속성**이고 사슬은
**(토큰, URL)별**이다. 인용 트윗 C 를 토큰 Y 가 다른 URL 로 depth 0 에 걸면, C 는 여전히
`parent_relation='quote'` 를 들고 있지만 그 URL 의 사슬에는 부모가 없다.

`parent.relation` 만 내면 소비자는 **배열에 없는 부모를 찾게 된다.** `inChain` 을 서버가 계산해
주면 «부모가 응답 밖에 있다» 가 한 필드로 끝난다. 답글 문맥 결손(T6)과는 다른 축이다 —
그쪽은 `parent_content_id` 자체가 비는 경우이고, 이쪽은 있는데 이 URL 로는 안 딸려온 경우다.

#### `inChain: false` 의 내역 — 그리고 `contentId` 가 필요한 이유

`inChain: false` 1,199건이 어디에 있는지 갈랐다(2026-08-27).

| 부모의 위치 | 건수 | |
|---|---|---|
| 같은 URL, 다른 depth | **0** | `depth + 1` 이 유일한 사슬 자리라는 것이 실측으로 확정 |
| **다른 URL 묶음** | **203 (16.9%)** | 객체는 응답에 **있는데** 이을 수단이 없었다 |
| 응답 밖 | 996 (83.1%) | 정말로 없다 |

가운데 203건이 **요구사항 위반**이었다 — 「토큰에 관련된 모든 객체가 포함되고 **연결**된다」
에서 포함은 됐는데 연결이 끊긴 상태다. 토큰이 인용 트윗과 그 원본을 **각각 다른 URL 로**
걸면 둘 다 응답에 실리는데, `depth` 는 한 URL 안의 *순서*만 표현해 URL 경계를 못 넘는다.

그래서 `parent.contentId` 를 낸다. 소비자가 응답 안의 `content.id` 와 맞대면 원본이 잡힌다.
**`depth` 와 역할이 겹치지 않는다** — `depth` 는 순서, `contentId` 는 동일성이다.

> 실증(live, 2026-08-27): 토큰 `4Dqn…E9pump` 의 `Kirodev7` URL depth 1 노드가
> `inChain: false` 인데 `parent.contentId` 가 **다른 URL 묶음(`goodalexander`)의 콘텐츠**로
> 해소된다. 수정 전에는 따라갈 수 없던 간선이다.

---

### 3.5 `metrics_latest` 폴백은 도달할 수 없다 — 그래서 넣지 않는다

설계 대화에서는 «시계열이 비면 `metrics_latest` 로 폴백» 으로 정했는데, 구현 직전에 그 분기가
실제로 도달 가능한지 확인했다.

```js
db.contents.aggregate([
  { $lookup: { from:"metric_series", localField:"_id", foreignField:"content_id", as:"s" } },
  { $group: { _id: { latest: {$cond:[{$ifNull:["$metrics_latest",false]},"has","none"]},
                     series: {$cond:[{$gt:[{$size:"$s"},0]},"has","none"]} }, n:{$sum:1} } },
])
```

| `metrics_latest` | 시계열 | 콘텐츠 |
|---|---|---|
| 있음 | 있음 | 37,321 |
| 없음 | 없음 | 8,138 |
| **없음** | **있음** | **3** ← D-6 이 노린 경우 |
| **있음** | **없음** | **0** ← 폴백이 필요한 경우 |

**폴백 방향이 0건이다.** 두 값은 같은 순간에 같은 입력으로 쓰이므로(`Content.of()` 와
`recordFirstMetrics` 가 한 트랜잭션 없는 한 흐름에 있다) `metrics_latest` 만 있는 상태가
만들어지지 않고, 반대로 «기존 행 + 지표를 실은 재관측» 이 시계열만 채우는 3건이 나온다.

도달할 수 없는 분기를 두면 «언젠가 여기로 온다» 는 거짓 전제를 모든 후속 독자에게 심는다.
이 레포에서 가장 강한 금지 사항이 그것이라 **빼고 근거를 남긴다.**

---

### 3.6 응답 크기는 구조적으로 유계다 — 페이지네이션 불필요

| | 값 |
|---|---|
| 토큰 수 | 60,017 |
| `social_urls` 평균 | 1.35개 |
| **`social_urls` 최대** | **4개** |
| `social_urls` 가 0개인 토큰 | 8,900 (14.8%) |
| URL 당 노드 최대 | 9 (depth 0..8) |

최악의 경우도 4 URL × 9 노드 = 36 노드다. 상한을 걸 필요가 없고, 걸면 «잘렸는가» 를 소비자가
또 판정해야 한다.

### 3.7 depth 는 실제로 8까지 간다 — X 전용

| depth | contents 링크 | accounts 링크 |
|---|---|---|
| 0 | 56,465 | 52,535 |
| 1 | 16,423 | 15,591 |
| 2 | 3,651 | 2,613 |
| 3 | 232 | 98 |
| 4 | 115 | 25 |
| 5 | 54 | 10 |
| 6 | 33 | 2 |
| 7 | 18 | 0 |
| 8 | 16 | 0 |

**depth ≥ 1 은 전부 `x` 다.** 나머지 7개 플랫폼은 duet·remix·crosspost 개념이 있어도 fetcher 가
관계를 돌려주지 않아 사슬 길이가 항상 1이다. 그리고 **`scanner-data` 스킬의 «0/1/2 = 51/14/2»
는 낡은 샘플이다** — 스킬 문서에 반영이 필요하다(§9).

---

## 4. 조립 규칙

### 4.1 절차

1. `tokens.findByAddress(address)` — 없으면 **404**.
2. `token_links.find({ token_address })` — 세 `object` 전부. `link_depth` 오름차순 정렬.
3. `object_id` 를 종류별로 모아 `contents` · `accounts` · `venues` 를 `_id: {$in}` 으로 한 번씩.
4. content 의 `creator_id` 중 2단계에 없던 것을 accounts 조회에 **합쳐서** 넣는다(§3.3 폴백).
5. `metric_series.find({ content_id: {$in} })` — 각 시리즈의 `points` 마지막 원소가 최신이다.
6. `social_urls[]` 를 순회하며 `UrlGroup` 을 만들고, 그 `url` 과 `entry_url` 이 같은 링크를
   `link_depth` 로 묶어 `Node[]` 를 세운다.

**`social_urls[]` 가 기준선이다.** 링크에서 URL 목록을 뽑지 않는다 — 그러면 그래프가 없는 URL 이
통째로 사라진다(D-2).

### 4.2 노드 하나를 세우는 규칙

```
node.content = 그 depth 의 contents 링크 대상 (없으면 null)
node.venue   = 그 depth 의 venues 링크 대상   (없으면 null)
node.account = 그 depth 의 accounts 링크 대상
               ?? node.content?.creator_id 의 대상          // §3.3
               ?? null
node.parent  = node.content?.parent_relation
               ? { relation, inChain: (depth+1 노드가 존재하고 그 content._id === parent_content_id) }
               : null
```

### 4.3 `collection{}` 집계

`FetchStatus` 10개를 소비자가 해석하게 두지 않는다. `RETRYABLE_FETCH_STATUSES` 를 **유일한
근거**로 삼아 `pending` 을 세고(`$in` 용 집합이다 — 부정으로 뒤집지 말 것), 나머지 흡수 상태 중
`ok` 가 아닌 것이 `failed` 다. `UrlGroup.retryable` 도 같은 상수에서 나온다 — 두 곳에서 세면
언젠가 갈린다.

---

## 5. 조회 계획

**질의 6회로 고정이다.** 그래프 크기와 무관하다.

| # | 컬렉션 | 술어 | 쓰는 인덱스 |
|---|---|---|---|
| 1 | `tokens` | `address` | `uniq_tokens_address` |
| 2 | `token_links` | `token_address` | `idx_links_token_linked` 접두 |
| 3 | `contents` | `_id: {$in}` | `_id_` |
| 4 | `accounts` | `_id: {$in}` | `_id_` |
| 5 | `venues` | `_id: {$in}` | `_id_` |
| 6 | `metric_series` | `content_id: {$in}` | `uniq_series_content` |

**컬렉션 스캔이 없다.** 인덱스는 실제로 도는 질의만큼만 남아 있어(T14), 새 축으로 조회하려면
인덱스를 함께 추가해야 한다 — 이 설계는 **인덱스를 하나도 추가하지 않는다.**

3~5 는 서로 독립이라 `Promise.all` 로 묶는다. 4는 2와 3 둘 다에 의존하므로(§4.1 4단계)
3 이 끝난 뒤에 나간다 — 즉 실제 왕복은 **1 → 2 → (3,5 병렬) → (4) → 조립** 이다.

---

## 6. 구성요소

| 파일 | 역할 |
|---|---|
| `src/apis/v1/token-social-graph/token-social-graph.controller.ts` | `@Controller('tokens')` + `@Get(':address/social-graph')` |
| `src/apis/v1/token-social-graph/token-social-graph.module.ts` | `TokenSocialGraphApiModule` |
| `src/modules/social-graph/dtos/token-social-graph.dto.ts` | 응답 DTO. `static from()` |
| `src/modules/social-graph/token-social-graph.reader.ts` | 조회 6회 + 노드 조립. Service 계층 |
| — | 가드는 **새로 만들지 않는다**. `src/common/guards/api-key.guard.ts` 재사용 — §8 |

**구현 완료 2026-08-27.** 실제로 만든 것 —

| 파일 | |
|---|---|
| `src/modules/social-graph/dtos/token-social-graph.dto.ts` | 응답 DTO 6종 + `isRetryable()` |
| `src/modules/social-graph/token-social-graph.reader.ts` | 조회 6회 + 노드 조립 |
| `src/apis/v1/token-social-graph/{controller,module}.ts` | 라우트 · 가드 배선 |
| `token-link.repository.ts` · `metric-series.repository.ts` | 위 두 메서드 추가 |
| `social-graph.service.module.ts` · `app.module.ts` | 리더 export · 상시 등록 |
| `test/unit/token-social-graph.reader.spec.ts` | 조립 14건 |
| `test/unit/token-social-graph/wiring-contract.spec.ts` | 배선 8건 |
| `test/e2e/token-social-graph.e2e-spec.ts` | HTTP 계약 6건 |

배선 테스트가 따로 있는 이유 — 앞의 둘은 컨트롤러와 리더를 **손으로 엮어서** 만들기 때문에
Nest 컨테이너가 그것들을 찾을 수 있는지는 어느 쪽도 보지 않는다. 특히
**`app.module.ts` 등록이 조건부로 바뀌는 회귀**는 dev 에서 라우트가 멀쩡히 떠서 테스트가
전부 통과하는데 **프로덕션에만 없다** — 그 하나를 소스 문자열로 못박는다.

폴더 이름(`token-social-graph`)과 라우트 접두(`tokens`)가 다르다. 이 레포에서 둘이 같았던 것은
규칙이 아니라 지금까지 전부 `internal/{모듈명}` 이었기 때문이다.

### 새로 필요한 리포지토리 메서드 2개 ← 구현 전 유일한 코드 선행조건

| 메서드 | 왜 필요한가 |
|---|---|
| `TokenLinkRepository.findAllByTokenAddress(address)` | 기존 `findByTokenAddress` 는 `object` 를 **필수**로 받아 세 갈래를 세 번 물어야 한다. 세 번이어도 같은 인덱스(`idx_links_token_linked` 접두)를 타지만 왕복이 3배다 |
| `MetricSeriesRepository.findLatestPointsByContentIds(ids)` | **배치 메서드가 없다.** 지금은 `findByContent`·`findLatestPoint` 둘 다 `contentId` 단건이라, 그대로 쓰면 콘텐츠 수만큼 N+1 이 난다 |

둘 다 순수 추가이고 인덱스 변경이 없다. §5 의 «질의 6회» 는 이 둘을 전제한 수치다 —
없이 구현하면 6회가 아니라 `8 + 콘텐츠 수` 회가 된다.

**응답 DTO 가 `src/modules/` 에 있는 것은 컨벤션이다** — 요청 DTO 만 `src/apis/v1/` 에 산다.
도메인은 API 를 import 하지 않는다.

### 인스펙터와의 관계

`token-inspector` 의 `groupByEntryUrl()` 이 같은 묶음을 이미 만들고 있지만 **재사용하지 않는다.**
그쪽은 `⚠️ TEMPORARY` 로 못 박힌 디버깅 전용이고(파일 헤더), contents 링크만 담아 계정·베뉴
전용 링크를 별도 목록으로 흘리며, `ContentItem` 은 화면용 요약 타입이다. 인스펙터를 걷어낼 때
함께 사라지는 코드에 공개 계약을 얹을 수 없다.

---

## 7. 경계·실패 케이스

| 상황 | 응답 | 근거 |
|---|---|---|
| 토큰 문서 없음 | **404** | «수집된 적 없음». 재호출해도 소용없다는 신호 |
| `social_urls` 0개 | 200 · `urls: []` · `collection.total: 0` | 실측 8,900 토큰(14.8%). 정상 상태다 |
| 전부 `pending` | 200 · `nodes: []` · `collection.pending > 0` | 나중에 다시 부르면 채워진다 |
| 링크는 있는데 대상 행이 없음 | 그 칸을 `null` 로 두고 노드는 낸다 | **트랜잭션이 없다**(T16). «객체 없는 링크» 는 알려진 상태이지 데이터 오류가 아니다 |
| 대상 행은 있는데 링크가 없음 | 응답에 안 나온다 | 같은 근거의 반대편. `token_links` 가 유일한 진입 경로다 |
| `metric_series` 없음 | `metrics: null` | 지표를 실은 관측이 온 적 없다는 뜻. `0` 이 아니다 |

**`parseCanonical()` 에 try/catch 를 두지 않는다.** `platformOf()` 가 내부에서 이것을 부르고
비정규 URL 에 던지는데(R-17), `social_urls[].url` 은 **정규화에 성공한 값만 들어온다**(v5 규칙 ②).
즉 여기서 던지면 그것은 입력 문제가 아니라 우리 버그이고, 삼키면 그 버그가 조용히 묻힌다.
이 레포에서 가장 강한 금지 사항이 «호출자가 이미 보장한 것을 다시 검사하지 말 것» 이다.

---

## 8. 인증 — 기존 `ApiKeyGuard` 재사용 (새로 만들 것 없음)

`src/common/guards/api-key.guard.ts` 가 이미 있고 **세 모듈이 쓰고 있다**
(`SocialReconcileApiModule` · `ProductionRecordingAuditApiModule` · `NamesiloCohortApiModule`).

| | |
|---|---|
| 헤더 | `x-api-key` (`API_KEY_HEADER` 상수. Swagger `@ApiSecurity` 와 같은 문자열) |
| 대조 대상 | `app.secretKey` (= `APP_SECRET_KEY`. dev·test·prod 세 env 파일에 전부 있다) |
| 실패 | **401 `UnauthorizedException`** — 403 이 아니다 |
| 키 미설정 | 기대값이 비어도 **통과시키지 않는다.** «헤더도 비고 기대값도 비어서 일치» 로 뚫리는 것을 막는 이중 방어가 가드 안에 있다 |

배선은 `NamesiloCohortApiModule` 을 그대로 따른다 — 모듈 `providers: [ApiKeyGuard]` +
컨트롤러 `@UseGuards(ApiKeyGuard)`.

### `app.module.ts` 등록은 조건부가 아니다

기존 API 모듈은 두 갈래다 — 유료 호출·수집 부수효과가 있는 둘은
`APP_ENV !== 'prod'` 조건부, 프로덕션 기능인 나머지는 상시 등록 + `ApiKeyGuard`.
이건 **공개 계약이므로 후자**다. 상시 등록하고 경계는 가드가 맡는다.

> 📌 `token-inspector` 만 예외로 **prod 에 가드 없이** 열려 있다(2026-08-13 사용자 결정).
> 이 API 는 그 선례를 따르지 않는다 — 그쪽 주석 스스로 *"가드를 붙이는 게 다음 개선 후보"*
> 라고 적어 두었다.

---

## 9. 잔여 결정 · 후속

| # | 항목 | 상태 |
|---|---|---|
| Q-1 | 다건 조회(`POST /tokens/social-graphs`) | **이번 스코프 밖.** 소비자가 N번 호출하는 부담이 실제로 문제가 되면 그때 붙인다. 스키마는 `urls[]` 를 address 별로 감싸기만 하면 되도록 이미 열려 있다 |
| ~~Q-2~~ | ~~`venues.metrics` 의 필드 목록~~ | **닫힘** — `VenueMetrics` 는 `members`·`moderatorCount` 둘뿐이다(`venue.model.ts:18`). X 커뮤니티 전용이고 **tg 는 의도적으로 빈다** — 구독자 수는 `data.subscriber_count` 에 있다(T5) |
| Q-3 | 레이트 리밋 | 미결. 가드와 같은 자리라 함께 정하는 것이 자연스럽다 |
| ~~Q-6~~ | ~~`parent.contentId` 노출 여부~~ | **닫힘 — 노출한다.** 없으면 203건이 «포함됐는데 연결 안 됨» 으로 남아 요구사항을 못 채운다. §3.4 |
| Q-4 | `scanner-data` 스킬의 depth 분포 갱신 | «0/1/2 = 51/14/2» 가 낡았다. 실제는 §3.7 이고 상한도 8까지 실재한다 |
| Q-5 | `metric_series` 갱신 프로세스가 생기면 | 지금은 콘텐츠당 1점뿐이라 `metrics` 단수로 충분하다. 시계열이 실제로 쌓이기 시작하면 `?withSeries=true` 를 **추가**한다 — 기본 응답 모양은 바뀌지 않는다 |

**다음 단계는 `signatures/` 다.** 이 레포는 반환 타입 계약이 코드보다 먼저 승인된다 —
§2 를 `signatures/task-001-response.html` 로 옮겨 승인받은 뒤 `tasks/` 를 연다.
