도메인: Quota (주 AI 분석 한도)
룰
| 항목 | 값 |
|---|---|
| 주 무료 quota | 10 |
| 짧은 공시 임계값 | 1000자 미만 무료 노출 |
| Unavailable 임계 retry_count | 90 (FAILED 상태에서 도달 시 영구 부재) |
주간 한도는 구독 티어로 결정 — 무료 10 / PRO 60 / MAX 200. WeeklyLimit 은 우회하는 게 아니라 weeklyLimit(activeTier) 매핑으로 상수만 바뀐다(used_count 는 티어 무관 기록). 티어/가격: domain-subscription. 한도는 종목당 ~1.3건/주 데이터로 책정(평상시 미도달, 피크·헤비유저 도달 → 상위 티어 유도).
상수는 internal/ai/policy.go 의 DefaultRevealPolicy() 에 캡처. 이전 버전은 WEEKLY_FREE_QUOTA / SHORT_DOC_THRESHOLD 환경변수로 매 호출마다 hot-reload 가능했으나 prod 미사용으로 폐기 (2026-05-12). 정책 변형 필요 시 RevealPolicyFromEnv() 추가하거나 DefaultRevealPolicy() 의 값 수정.
가입 보너스는 V4 (2026-05-06) 에서 폐기됨. 첫 경험 절벽 (10 → 5) 거부감이 더 컸음.
정책 모듈 구조
RevealPolicy = QuotaPolicy + AnalysisPolicy. Classify 메서드가 분석 + quota 룰의 단일 진입점.
| 객체 | 책임 | 호출자 |
|---|---|---|
QuotaPolicy {} | 주 경계 (CurrentWeekStart) + Remaining(Quota, weeklyLimit) — 한도는 들고 있지 않고 인자로 받음 | RevealService, QuotaRepository, admob.Handler |
AnalysisPolicy { ShortDocThreshold, UnavailableRetryThreshold } | IsShort(docLen), IsUnavailable(status, retryCount) | RevealService |
RevealPolicy { Quota, Analysis } | Classify(analysis, alreadyRevealed, quota, weeklyLimit) → Outcome | RevealService |
entitlement.Tier | 티어 → 주간 한도(WeeklyLimit() 10/60/200) + 슬롯(SlotCapacity()) 매핑, ActiveTier(userID) | RevealService, watchlist, ai.Handler |
weekly_limit은 정책 객체가 아니라 사용자 구독 티어로 결정 —RevealService가entitlement.ActiveTier로 resolve 해Classify/Remaining에 인자로 전달.
main.go 가 ai.DefaultRevealPolicy() 1회 생성 → 의존 모듈에 주입. admob 패키지는 revealPolicy.Quota 만 받아 SSV reward 시 quota_week_start 를 동일 룰로 set (이전엔 currentWeekStart 패키지 로컬 복제 — import cycle 회피용이었으나 deepening 후 ai → admob 단방향 import 로 해소).
비용 모델 (오해 방지)
LLM 호출은 공시당 1회 (스케줄러가 분석 시) — 사용자 reveal 은 analysis_cache 캐시 조회. 사용자 reveal 횟수는 LLM 비용과 무관. quota cap 의 목적은:
- 구독 IAP funnel hook (주 한도 → 광고 또는 PRO/MAX 상위 티어)
- 다중 계정 어뷰즈 방어 보조 (App Check + SSV 1차, cap 으로 ROI ↓)
- 인지 가치 — 무제한이면 "이 분석 가치 있나?" 신호 약화. 티어 한도(10/60/200)도 평상시 미도달이나 천장이 존재
상세 정책 결정 맥락: ../policies/monetization.md.
알고리즘
remaining = weekly_limit - used_count(weekly_limit = weeklyLimit(activeTier) — 무료 10 / PRO 60 / MAX 200. used_count 는 음수 허용 — 광고 시청 시 -1.)
별도 ad_bonus 컬럼 없음. 광고 보상은 used_count -= 1 로 표현. ad_reward_transactions 테이블은 SSV idempotency (중복 콜백 차단) 전용 — quota 계산에 참조 안 함.
차감 결정은 internal/ai/reveal.go 의 Classify() 가 단일 진실:
analysis == nil또는status != COMPLETED→ Pendingis_short(doc_length < SHORT_DOC_THRESHOLD) → FreeRevealalready_revealed(user_revealed_disclosures에 row 있음) → FreeRevealremaining <= 0→ QuotaExhausted (429 QUOTA_EXHAUSTED+can_watch_ad: true)- 정상 → PaidReveal (
used_count +1+ reveal 기록, 단일 트랜잭션)
자세한 race-safe 트랜잭션 시퀀스: docs/policies/lazy-reveal.md.
Edge case
- 주 경계:
quota_week_start != current_week_start(월요일 KST) 이면used_count0 으로 리셋 (음수였어도 0) - 광고 시청:
used_count -= 1, 무제한.used_count=0인 상태에서 광고 보면 -1 되어 "선납" 처럼 작동하나, 주 reset 시 0 으로 초기화 — 주말에 광고 미리 봐두는 식 이월은 불가 (의도된 동작) - SSV 중복 콜백:
ad_reward_transactions.transaction_idPK 로 idempotency. 같은 transaction_id 두 번째 호출은 INSERT 충돌 → 보상 미지급 - 동시 reveal:
user_revealed_disclosuresINSERT ON CONFLICT + quota 차감 트랜잭션으로 중복 차감 방지
관련 테이블
dartbrief.user_analysis_quota— user_id, used_count, quota_week_startdartbrief.ad_reward_transactions— transaction_id (PK), user_id (idempotency 전용)dartbrief.user_revealed_disclosures— user_id, rcept_no, revealed_at
소진 시 funnel
주 한도 소진 후 분석 경로는 광고 시청(+1) 또는 상위 티어 구독 2갈래. 0회 소진 시 인디케이터 하단에 "구독하고 더 보기" CTA 버튼(quota-indicator) 노출 → 탭하면 페이월 하단 시트(source: quota_soldout). 광고 +1 은 티어 무관 항상 허용(can_watch_ad: true) — 소진 시 광고로 충전하거나 상위 티어로 한도 상향(무료→PRO/MAX, PRO→MAX) 또는 다음 주 reset. 티어 한도: domain-subscription.
관련 API
GET /api/v1/quota→ quota.mdPOST /api/v1/ai/analyze/{rceptNo}/reveal→ ai-analyze-reveal.mdGET /api/v1/admob/ssv→ admob-ssv.md