Skip to content

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_idServerSideVerificationOptions.userId로 앱이 설정한 값 (= dartbrief user ID)
reward_amount보상 수량 (참고용, 현재 항상 1)
reward_item보상 이름 (참고용)
timestampUnix ms
signaturebase64url 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 재시도가 안전.

go
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 앱 연동

dart
// 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 CASCADE
  • dartbrief.user_analysis_quota — used_count (보상 시 -1, 음수 허용), quota_week_start