도메인: 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 (FK → users(id) ON DELETE CASCADE) |
created_at | TIMESTAMPTZ | 처리 시각 |
인덱스: user_id
PK(transaction_id)가 멱등성 보장의 핵심 — 같은 트랜잭션 재처리 시 INSERT ON CONFLICT DO NOTHING으로 skip.
dartbrief.user_analysis_quota.used_count
| 컬럼 | 설명 |
|---|---|
used_count | 이번 주 분석 사용량. 광고 보상은 이 값을 1 감소(DecrementUsedTx) — 음수 허용 (다음 reveal 시 0 으로 복구). 주 경계(quota_week_start) 변경 시 reset |
V4 (2026-05-06):
ad_bonus컬럼 폐기. 별도 보너스 카운터 없이used_count -= 1로 capacity 를 직접 환급한다.
동작 플로우
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[DecrementUsed userID]
B -- 그 외 빈 문자열 포함 --> D[404 Not Found]
C --> G[used_count -= 1 → GetOrInit → quotaResponse 반환]룰 / 정책
| 항목 | 값 | 환경변수 |
|---|---|---|
| 보상 모델 | used_count -= 1 (음수 허용, 하드캡 없음) | — |
| 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) | — |
주 경계 연동
보상은 user_analysis_quota.used_count 를 직접 차감한다. DecrementUsedTx 가 현재 주(quota_week_start)면 used_count - 1, 다른 주면 reset 후 -1 로 처리 (quota_repository.go 의 단일 atomic UPDATE). 음수 used_count 도 주 reset 시 0 으로 초기화 — 이월 불가.
하드캡 없음
V4 에는 월 하드캡 (AD_BONUS_MONTHLY_CAP, adHardCap(), adBonusMonthlyHardCap()) 이 없다. SSV 가 Google 서버에서 실제 광고 시청을 검증하므로 클라이언트/서버 cap 이 불필요. 어뷰즈 방어선은 SSV ECDSA 서명 검증 + transaction_id 멱등성.
Edge case
| 케이스 | 동작 |
|---|---|
| Google SSV 재시도 (동일 transaction_id) | ad_reward_transactions PK 충돌 → INSERT ON CONFLICT DO NOTHING → RowsAffected==0 → tx rollback → 200 반환 (DecrementUsedTx skip) |
| DB 에러 시 SSV | tx 미commit 후 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