GET /api/v1/admob/ssv
의도
AdMob 리워드 광고 완료 후 Google 서버가 직접 호출하는 SSV(Server-Side Verification) 콜백. ECDSA-SHA256 서명을 검증한 뒤 user_analysis_quota.used_count 를 1 감소(DecrementUsedTx)시켜 분석 capacity 를 +1 지급한다 (V4 — ad_bonus 컬럼 폐기).
클라이언트가 직접 호출하는 엔드포인트가 아님 — Google AdMob 서버만 호출.
인증
- JWT 불필요 (Google이 직접 호출, 일반 rate limit 제외)
- ECDSA-SHA256 서명 검증 (
signature,key_id파라미터 사용) - 서명 공개키:
https://www.gstatic.com/admob/reward/verifier-keys.json(24h TTL 캐시)
입력
Google AdMob이 전달하는 쿼리 파라미터:
| 파라미터 | 설명 |
|---|---|
transaction_id | 광고 시청 고유 ID (멱등성 키) |
user_id | ServerSideVerificationOptions.userId로 앱이 설정한 값 (= dartbrief user ID) |
reward_amount | 보상 수량 (참고용, 현재 항상 1) |
reward_item | 보상 이름 (참고용) |
timestamp | Unix ms |
signature | base64url ECDSA 서명 |
key_id | 서명 검증에 쓸 공개키 ID |
응답
200 OK — 정상 지급 / 중복 모두 200
Google은 non-200만 재시도하므로, 비즈니스 로직 분기는 모두 200으로 반환.
400 Bad Request
transaction_id, key_id, signature 누락 시.
401 Unauthorized
서명 검증 실패 시. Google은 재시도하지 않으므로 실제 invalid signature에만 발생해야 함.
500 Internal Server Error
DB 오류 시 → Google이 제한 횟수 재시도. transaction_id가 아직 기록되지 않으므로 멱등 재시도 안전.
비즈니스 로직
V4: 단일 CTE 가 아니라 2-step 트랜잭션 (BeginTx → INSERT idempotent → DecrementUsedTx → Commit). ad_reward_transactions (admob 도메인) 와 user_analysis_quota (quota 도메인) 를 같은 tx 로 묶어 atomicity 유지 — 부분 실패 시 reward 기록과 quota 변화가 함께 롤백돼 Google 재시도가 안전.
tx := pool.BeginTx(ctx)
defer tx.Rollback(ctx) // commit 후 no-op
// 1) 멱등 gate: 중복 transaction_id 면 RowsAffected==0
tag := tx.Exec(`
INSERT INTO dartbrief.ad_reward_transactions (transaction_id, user_id, created_at)
VALUES ($1, $2, NOW())
ON CONFLICT (transaction_id) DO NOTHING`)
if tag.RowsAffected() == 0 {
return // 중복 — defer rollback, quota 미변경, 200 반환
}
// 2) 신규면 quota 차감 후 commit
quotaRepo.DecrementUsedTx(ctx, tx, userID) // used_count -= 1 (음수 허용)
tx.Commit(ctx)INSERT ... ON CONFLICT DO NOTHING이 gate — 중복 transaction_id 면RowsAffected()==0→ rollback + 200 (quota 미변경)DecrementUsedTx: 주 경계 reset 후used_count -= 1(음수 허용 — 다음 reveal 시 0 으로 복구).quota_repository.go의 단일 atomic UPDATE 로 동시 요청 race-safe- 월 하드캡 없음 — SSV가 Google 서버에서 실제 광고 시청을 검증하므로 클라이언트 cap 불필요
Flutter 앱 연동
// prod: ServerSideVerificationOptions.userId에 dartbrief user ID 설정
await ad.setServerSideOptions(
ServerSideVerificationOptions(userId: userId.toString()),
);
// → SSV 콜백 user_id 파라미터로 전달됨
// → 광고 완료 후 클라이언트 별도 claim 불필요dev flavor는 test ad unit 사용 → SSV 콜백 발생 안 함 → legacy /quota/ad-reward 경로 유지.
Rate limit
일반 IP rate limit 제외 — Google 공유 egress IP에서 발송되므로 버킷 공유 시 429 발생. SSV 엔드포인트는 ECDSA 서명 검증으로 보호.
운영 체크리스트
- AdMob 콘솔 → 각 광고 단위(
dartbrief-aos,dartbrief-ios) → Server-side verification → Callback URL 등록:https://api.dartbrief.com/api/v1/admob/ssv
관련 테이블
dartbrief.ad_reward_transactions— transaction_id PK (멱등성 키), user_id FK → users(id) ON DELETE CASCADEdartbrief.user_analysis_quota— used_count (보상 시 -1, 음수 허용), quota_week_start