도메인: AdMob (광고 보상)
V4 (2026-05-06) 변경:
ad_bonus컬럼 폐기. 광고 보상은user_analysis_quota.used_count -= 1(음수 허용) 로 표현.ad_reward_transactions테이블은 SSV 중복 콜백 차단 (idempotency) 전용. 월 하드캡 등ad_bonus관련 항목 (이 문서 V3 시점) 은 모두 무효. 자세한 결정: docs/policies/quota-ad-v3.md §V4
개요
광고 시청 완료 후 AI 분석 capacity 를 +1 지급한다 (used_count -= 1). prod 환경에서는 Google AdMob Server-Side Verification(SSV) 콜백을 통해서만 보상이 이루어진다. legacy 클라이언트 claim 경로(POST /quota/ad-reward)는 local/dev 환경 전용이다.
데이터 모델 (핵심 테이블)
dartbrief.ad_reward_transactions
| 컬럼 | 타입 | 설명 |
|---|---|---|
transaction_id | TEXT PK | Google이 발급한 고유 트랜잭션 ID |
user_id | BIGINT | 보상 받은 사용자 ID |
created_at | TIMESTAMPTZ | 처리 시각 |
인덱스: user_id
PK(transaction_id)가 멱등성 보장의 핵심 — 같은 트랜잭션 재처리 시 INSERT ON CONFLICT DO NOTHING으로 skip.
dartbrief.user_analysis_quota.ad_bonus
| 컬럼 | 설명 |
|---|---|
ad_bonus | 이번 달 광고 보상 누적 횟수 (월 초 reset) |
동작 플로우
SSV 콜백 처리 (prod 경로)
sequenceDiagram
participant App as Flutter 앱
participant AdMob as Google AdMob
participant BE as 백엔드 /api/v1/admob/ssv
App->>AdMob: 광고 요청 (ServerSideVerificationOptions.userId = userID)
AdMob-->>App: 광고 노출
App->>AdMob: 광고 시청 완료
AdMob->>BE: GET /api/v1/admob/ssv?<br/>transaction_id=...&user_id=...&<br/>key_id=...&signature=...&...
Note over BE: Rate limit 제외 경로<br/>(Google 공유 egress IP)
BE->>BE: ECDSA-SHA256 서명 검증<br/>(signature, key_id 제거한 query string으로 signed content 구성)
alt 서명 검증 실패
BE-->>AdMob: 401 Unauthorized
else user_id 파싱 실패
BE-->>AdMob: 200 OK (Google 재시도 방지)
end
BE->>BE: tx 실행:<br/>1. INSERT ad_reward_transactions ON CONFLICT DO NOTHING<br/>2. RowsAffected==0 이면 rollback + 200 (중복)<br/>3. 신규면 ai.QuotaRepository.DecrementUsedTx (used_count -= 1)<br/>4. commit
alt DB 에러
BE-->>AdMob: 500 Internal Error (Google이 재시도)
else 정상
BE-->>AdMob: 200 OK
end서명 검증 상세
flowchart TD
A[rawQuery 수신] --> B[signature, key_id 파라미터 제거<br/>원래 순서 유지]
B --> C[SHA-256 해시]
C --> D[KeyCache.Get key_id<br/>Google 공개키 캐시 조회]
D --> E[ECDSA DER 서명 디코딩<br/>base64url 또는 base64url-nopadding]
E --> F{ecdsa.Verify?}
F -- true --> G[검증 성공]
F -- false --> H[401 반환]dev/local 경로 (legacy)
flowchart TD
A[POST /api/v1/quota/ad-reward] --> B{APP_ENV?}
B -- local 또는 dev --> C[AddAdBonusUpToCap userID, cap=200]
B -- 그 외 빈 문자열 포함 --> D[404 Not Found]
C --> E{ad_bonus >= 200?}
E -- yes --> F[429 AD_LIMIT_EXCEEDED]
E -- no --> G[ad_bonus +1 → quotaResponse 반환]룰 / 정책
| 항목 | 값 | 환경변수 |
|---|---|---|
| 월 ad_bonus 하드캡 | 200 | AD_BONUS_MONTHLY_CAP |
| prod 보상 경로 | SSV 콜백만 | — |
| dev/local 보상 경로 | POST /api/v1/quota/ad-reward | APP_ENV=local 또는 dev |
| SSV Rate Limit | 제외 (Google 공유 IP) | — |
| Google 공개키 캐시 | KeyCache (만료 정책은 keycache.go 참조) | — |
| 멱등성 키 | transaction_id (PK) | — |
월 reset 연동
ad_bonus 컬럼은 user_analysis_quota 테이블에 있다. SSV 처리의 원자 CTE에서 quota_month != current_month이면 ad_bonus = 1로 reset 후 적립 (월 변경 감지).
adHardCap 동기화
SSV 핸들러의 adHardCap()과 ai 핸들러의 adBonusMonthlyHardCap()이 동일하게 AD_BONUS_MONTHLY_CAP 환경변수를 읽는다. 두 경로가 같은 정책 적용.
Edge case
| 케이스 | 동작 |
|---|---|
| Google SSV 재시도 (동일 transaction_id) | ad_reward_transactions PK 충돌 → INSERT DO NOTHING → newly_recorded=0 → 200 반환 (보너스 지급 skip) |
| 월 하드캡 도달 | add_bonus WHERE 조건 ad_bonus < $4 불충족 → UPDATE 0 rows → bonus_added=0 → 200 반환 (지급 없이 성공) |
| DB 에러 시 SSV | 500 반환 → Google이 일정 횟수 재시도 → 복구 후 transaction_id로 멱등 처리 |
| user_id 파싱 불가 | 200 반환 (Google 재시도 방지) — 이미 서명 검증은 통과한 상태 |
| APP_ENV 미설정 | 빈 문자열 → local/dev 조건 미충족 → /quota/ad-reward 404 (fail-closed) |
| 동시 SSV 요청 (같은 user) | 두 요청이 다른 transaction_id 면 각자 tx 안에서 직렬 atomic 차감 — used_count = 누적 -2. 같은 transaction_id 면 한쪽만 INSERT 성공, 다른쪽 RowsAffected=0 → quota 미변경 |
관련 API
| 엔드포인트 | 설명 | 인증 |
|---|---|---|
GET /api/v1/admob/ssv | Google SSV 콜백 (public, ECDSA 검증) | 불필요 |
POST /api/v1/quota/ad-reward | legacy dev 경로 (local/dev only) | 필요 (JWT) |
GET /api/v1/quota | quota 현황 조회 | 필요 (JWT) |
관련 문서
- App Check (보완 방어선) →
domain-app-check.md - Quota 정책 전체 →
domain-quota.md