도메인: Watchlist (관심종목)
개요
사용자가 관심 종목을 등록/삭제하고 알림 설정을 관리한다. 관심종목 상한(구독 티어별 — 무료 3 / PRO 30 / MAX 100) 내에서 자유롭게 추가/삭제 (cooldown / 일일 카운터 없음).
데이터 모델 (핵심 테이블)
dartbrief.watch_stocks
| 컬럼 | 타입 | 설명 |
|---|---|---|
id | BIGSERIAL PK | — |
user_id | BIGINT FK → users.id | ON DELETE CASCADE |
corp_code | VARCHAR(8) | DART 고유 회사 코드 |
corp_name | VARCHAR(255) | 회사명 (stocks 테이블에서 복사, 비정규화) |
stock_code | VARCHAR(6) | 종목코드 (nullable) |
notify_enabled | BOOLEAN | 알림 활성화 여부 (기본 true) |
created_at | TIMESTAMP(6) | 등록 시각 |
UNIQUE: (user_id, corp_code) 인덱스: user_id, corp_code
관심종목 상한 (전용 컬럼 없음)
상한은 구독 티어로부터 파생 — 무료 3 / PRO 30 / MAX 100 (entitlement.Tier.SlotCapacity()). 활성 티어는 users.premium_tier + premium_until 로 판정(domain-subscription). 폐기된 users.slot_capacity 컬럼(일회성 슬롯구매 모델 잔재, 전원 3 고정)은 migration 00018 에서 제거.
동작 플로우
종목 추가 (idempotent — 2026-05-08, PR #57)
flowchart TD
A[POST /api/v1/watchlist<br/>body: corp_code] --> B[Has user_id, corp_code]
B --> X{이미 보유?}
X -- yes --> Y[INSERT ... ON CONFLICT DO UPDATE<br/>RETURNING 기존 row]
Y --> H[201 Created + WatchStock<br/>idempotent no-op]
X -- no --> C[GetCapacityStatus]
C --> D{remaining <= 0?}
D -- yes --> E[409 Conflict<br/>슬롯 한도 초과]
D -- no --> F[INSERT watch_stocks<br/>SELECT from stocks WHERE corp_code]
F --> G{stocks에 corp_code 없음?}
G -- pgx.ErrNoRows --> I[400 Bad Request]
G -- 성공 --> H왜 capacity 검사 전에 Has 체크? lazy auth 게이트 통과 직후 자동 add 시 사용자가 이미 보유한 종목 (예: 게이트 직전 선택한 chip 이 그 사용자의 기존 watchlist 에 이미 존재) 을 재추가하는 경로에서 capacity 가 가득 차 있으면 409 → "선택했는데 추가 실패" 부조리. 이미 보유 = 결과 상태가 이미 사용자 의도와 일치 하므로 capacity 검사 건너뛰고 idempotent no-op 반환.
종목 삭제
flowchart TD
A[DELETE /api/v1/watchlist/:corpCode] --> B[DELETE watch_stocks<br/>WHERE user_id AND corp_code]
B --> C{RowsAffected = 0?}
C -- yes --> D[404 Not Found]
C -- no --> E[204 No Content]용량 조회
flowchart TD
A[GET /api/v1/watchlist/capacity] --> B[SELECT premium_tier, premium_until, COUNT watch_stocks<br/>FROM users WHERE id = userID]
B --> B2[capacity = slotCapacity activeTier]
B2 --> C[{ capacity, used, remaining }]룰 / 정책
슬롯 정책 (현재)
| 항목 | 값 |
|---|---|
| 한도 (티어별) | 무료 3 / PRO 30 / MAX 100 (entitlement.Tier.SlotCapacity()) |
| 한도 결정 | slotCapacity(activeTier) 단일 매핑 — 전용 컬럼 없음 (slot_capacity 폐기) |
| cooldown / 일일 카운터 | 없음 (폐기) |
| free/paid 구분 노출 | 티어 한도는 페이월/구독 화면에만, 인디케이터엔 익명 |
| 한도 상향 | PRO/MAX 구독 → activeTier 가 올라가 capacity 게이트 한도 상승 |
| 티어 하락 시 | 새 상한 초과분 자동 정리 (가장 오래된 것부터) — domain-subscription |
구독 티어 — capacity 상한 (3 / 30 / 100)
관심종목 상한은 구독 티어로 결정 — 무료 3 / PRO 30 / MAX 100. capacity 게이트의 한도가 티어로 바뀐다 — limit = slotCapacity(activeTier) (체크 로직 자체는 유지, 한도 값만 티어 파생).
무료/PRO 사용자가 자기 상한 초과 add 시도(409) 시 단순 에러 대신 페이월 하단 시트(source: watchlist_full) 를 띄워 상위 티어를 유도(무료→PRO/MAX, PRO→MAX). MAX(100) 초과는 이미 최상위라 토스트만. 티어 하락(만료/환불/MAX→PRO) 시 다운그레이드(첫 접속 alert → 7일 후 새 상한 초과분 자동 정리) 규칙은 domain-subscription 참조.
알림 설정
종목별로 notify_enabled 플래그로 관리. 기본값 true. PATCH /api/v1/watchlist/{corpCode}/notify로 토글.
종목 데이터 소스
추가 시 dartbrief.stocks 테이블에서 corp_code로 조회해 corp_name, stock_code를 복사 저장 (비정규화). stocks에 없는 corp_code는 등록 불가.
Edge case
| 케이스 | 동작 |
|---|---|
| 무료(3)·PRO(30) 상한 초과 추가 시도 | 409 Conflict + { message, capacity, used } → 단순 에러 대신 페이월 하단 시트(source: watchlist_full) 노출로 상위 티어 유도 |
| MAX(100) 초과 추가 시도 | 409 Conflict (최상위 — 페이월 X, "관심종목은 100개까지 등록할 수 있어요" 토스트) |
| 없는 corp_code 추가 | stocks INSERT SELECT → 0 rows → 400 Bad Request |
| 이미 등록된 종목 재등록 | idempotent 201 (기존 row 반환, capacity 가득 차도 OK) — PR #57 변경 |
| 없는 종목 삭제 | RowsAffected=0 → 404 Not Found |
| 사용자 탈퇴 | ON DELETE CASCADE → watch_stocks 자동 삭제 |
| remaining 음수 | GetCapacityStatus에서 max(0, cap-used) 처리 |
관련 API
| 엔드포인트 | 설명 |
|---|---|
GET /api/v1/watchlist | 관심종목 목록 (created_at DESC) |
GET /api/v1/watchlist/capacity | 슬롯 용량 현황 |
POST /api/v1/watchlist | 종목 추가 |
DELETE /api/v1/watchlist/{corpCode} | 종목 삭제 |
PATCH /api/v1/watchlist/{corpCode}/notify | 알림 활성화/비활성화 |