# 어워드 투표 설계 — 구현 스펙

> [04-fanjai-정의서.md](04-fanjai-정의서.md)의 **원칙 ②(다리는 지위로)**를 실제 코드로 옮기는 문서.
> 대상 코드베이스: `app/` (fanjai.com을 서빙하는 라이브 앱). `fanjai/` 모노레포 아님.

---

## 결론부터 — 새로 만들 게 거의 없습니다

투표 시스템은 **이미 구현돼 있습니다.** 실제 코드를 확인한 결과입니다.

| 기능 | 상태 | 위치 |
|---|---|---|
| 투표 API | ✅ 완성 | `app/src/index.ts` `/api/vote` |
| 중복 방지 | ✅ 완성 | `Idempotency-Key` 헤더 + `vote_events.request_id` UNIQUE |
| 레이트 리밋 | ✅ 완성 | 분당 30회 (`rateLimit(..., "vote", 30, 60000)`) |
| 표 차감 | ✅ 완성 | `INSERT … SELECT … WHERE free_remaining > 0 OR paid_votes > 0` (원자적) |
| **매일 무료표 5장** | ✅ **완성** | `refillUserIfNeeded()` — 24시간마다 `free_remaining = 5` |
| 컨테스트 기간 검증 | ✅ 완성 | DB 트리거 (`contest_not_open`) |
| 순위 집계 | ✅ 완성 | `getTally()` → `votePayload()` |
| 참가자 | ✅ 12명 등록됨 | `participants` |

**즉 "매일 무료 투표권"은 이미 돌고 있습니다.** 문제는 아무도 로그인을 안 해서 쓰이지 않을 뿐입니다.

### 실제 스키마

```sql
users        (id, display_name, line_id, free_remaining, paid_votes, last_refill)
contests     (id, title, status, starts_at, ends_at, created_at, updated_at)
participants (id, name, name_en, no, region, age, tagline, c1, c2, video_key)
vote_events  (id, request_id, contest_id, user_id, participant_id, source, created_at)
posts        (id, category, title, body, author, likes, created_at)
                                          ^^^^^^ TEXT — 계정 FK 없음 ⚠️
```

---

## 새로 만들 것은 둘뿐

### ① `posts.author_id` — 선행 조건 (병목)

**지금은 글을 써도 시스템이 누가 썼는지 모릅니다.** `author`가 자유 텍스트라서요.

활동량을 셀 수 없으면 **투표 가중치가 성립하지 않습니다.** 이게 먼저입니다.

```sql
-- migrations/0007_post_author_link.sql
ALTER TABLE posts ADD COLUMN author_id TEXT REFERENCES users(id);
ALTER TABLE comments ADD COLUMN author_id TEXT REFERENCES users(id);
CREATE INDEX idx_posts_author ON posts(author_id, created_at);
CREATE INDEX idx_comments_author ON comments(author_id, created_at);
```

**기존 19건 처리**: 운영자 글 18건(`author = 'FanJai Editorial'`)은 운영자 계정 하나로 귀속.
나머지 1건은 `author`가 이미 `u_…` 형식이라 그대로 매핑 가능.

> ⚠️ `author` 컬럼은 **지우지 않습니다.** 화면이 그걸 읽고 있어서 함께 고쳐야 하고,
> 표시명과 계정을 분리해두면 나중에 닉네임 변경이 과거 글에 자동 반영됩니다.

### ② 투표 가중치

**핵심 설계: 표를 더 주는 게 아니라, 같은 한 표의 무게를 다르게 합니다.**

```
활동 점수 = (내 글 수 × 3) + (내 댓글 수 × 1) + (내 글이 받은 좋아요 × 0.5)
            ※ 최근 30일 기준
```

| 활동 점수 | 등급 | 가중치 |
|---|---|---|
| 0 | 신규 | **×0** (투표 불가) |
| 1 ~ 4 | 저활동 | ×0.5 |
| 5 ~ 19 | 정상 | ×1.0 |
| 20 이상 | 활발 | ×1.5 |

**`×0`이 핵심입니다.** 가입만 하고 투표하는 계정은 순위에 영향을 못 줍니다.
계정 무한 생성으로 표를 만드는 경로가 구조적으로 막힙니다.

**저장 위치**: `vote_events`에 컬럼 추가. 투표 시점의 가중치를 굳혀둡니다.

```sql
ALTER TABLE vote_events ADD COLUMN weight REAL NOT NULL DEFAULT 1.0;
```

> 나중에 다시 계산하지 않고 투표 시점 값을 박는 이유:
> 활동량은 계속 변하는데, 과거 표의 무게가 소급해서 바뀌면 순위가 이유 없이 흔들립니다.

**집계 변경**: `getTally()`가 `COUNT(*)` 대신 `SUM(weight)`를 씁니다.

---

## 화면 — 공간 분리

정의서 원칙 ①대로, **커뮤니티에는 보상을 노출하지 않습니다.**

| 화면 | 보이는 것 | 안 보이는 것 |
|---|---|---|
| **어워드 존** (`โหวต` 탭) | 순위표, 남은 표, 내 가중치 등급 | — |
| **커뮤니티 존** (`คุย`·`팬픽`) | 글, 댓글, 좋아요 | **포인트·리워드 일절 없음** |

**단 하나의 연결**: 커뮤니티 화면에 *"활동하면 투표 힘이 세집니다"* 안내만 둡니다.
포인트 숫자를 커뮤니티에 띄우지 않습니다 — 숫자가 보이는 순간 그걸 노린 글이 생깁니다.

**어워드 존에는 내 등급을 보여줍니다:**

```
내 투표 힘   ×1.0  (정상)
             ━━━━━━━━━━  활동 점수 12 / 20
             글 5개 더 쓰면 ×1.5
```

---

## 순서

```
0. 태국어 버전 프로덕션 승격        개발 0   ← 결정만 필요
1. 라인 로그인 env 3개 설정         개발 0   ← 설정만
─────────────────────────────────────────────
2. posts.author_id 마이그레이션     ⚠️ 병목
3. 활동 점수 계산 + weight 컬럼
4. getTally를 SUM(weight)로 변경
5. 어워드 존 UI (등급 표시)
6. 시즌 운영 (참가자 채우기)
```

**0·1을 먼저 하는 이유**: 지금 fanjai.com은 한국어이고 로그인이 안 됩니다.
투표 가중치를 아무리 잘 만들어도 **로그인이 없으면 활동량을 셀 사람 자체가 없습니다.**

---

## 검증

| # | 확인할 것 | 통과 기준 |
|---|---|---|
| 1 | 신규 계정 투표 | 가중치 ×0 → 순위 변동 없음 |
| 2 | 활동 후 재투표 | 등급 상승이 순위에 반영 |
| 3 | 같은 요청 2회 | `Idempotency-Key` 중복 → 409, 표 차감 1회 |
| 4 | 무료표 소진 | 5표 후 `no_votes` |
| 5 | 24시간 후 | `free_remaining`이 5로 리필 |
| 6 | 커뮤니티 화면 | **포인트·리워드 문구 0건** (grep으로 확인) |
| 7 | 기존 글 19건 | 마이그레이션 후에도 화면에 그대로 보임 |
| 8 | 가중치 소급 | 활동량이 변해도 **과거 표의 weight는 안 바뀜** |

---

## 손대지 않는 것

- `/api/vote`의 멱등성·레이트리밋·원자적 차감 로직 — **잘 만들어져 있습니다.** 가중치만 얹습니다
- `refillUserIfNeeded()` — 이미 매일 5표를 줍니다
- `fanjai/` 모노레포 — 이번 작업과 무관
