feat(coin): 金币数值体系二期——看广告按 eCPM 发奖 + ad_session_id 幂等 + 签到膨胀固定金币 (#28)

- rewards: 激励视频实发改按 calculate_ad_reward_coin(eCPM, 当日第N次) 公式;AD_REWARD_COIN/MAX_AD_REWARD_COIN 降为历史兼容口径;新增 SIGNIN_BOOST_COIN=2000

- 签到膨胀: Day1-13 看完激励视频额外发固定金币、Day14 不允许 (signin.py / signin-boost)

- ad_session_id 幂等: ad_ecpm/ad_reward/ad_feed_reward 记录加 ad_session_id 列 + 唯一索引(新迁移 coin_reward_phase2 / ad_feed_reward_session)

- ad.py + schemas + repositories: ecpm-report / feed-reward / reward-status / test-grant 改造;config_schema 增 signin_boost_coin;admin stats overview 补充

- 同步 docs/api + docs/database + docs/integrations/pangle 及 tests(test_ad_reward / test_welfare)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: OuYingJun1024 <1034284404@qq.com>
Reviewed-on: #28
Co-authored-by: ouzhou <ouzhou@wonderable.ai>
Co-committed-by: ouzhou <ouzhou@wonderable.ai>
This commit was merged in pull request #28.
This commit is contained in:
ouzhou
2026-06-09 01:56:47 +08:00
committed by marco
parent 6432497af1
commit d7c29c0883
33 changed files with 650 additions and 154 deletions
+148 -19
View File
@@ -10,6 +10,7 @@
from __future__ import annotations
import logging
import json
import uuid
from fastapi import APIRouter, Depends, HTTPException, Request, status
@@ -23,6 +24,7 @@ from app.repositories import ad_ecpm as crud_ecpm
from app.repositories import ad_feed_reward as crud_feed
from app.repositories import ad_reward as crud_ad
from app.repositories import ad_watch as crud_watch
from app.repositories import signin as crud_signin
from app.schemas.ad import (
AdRewardStatusOut,
EcpmReportIn,
@@ -30,6 +32,7 @@ from app.schemas.ad import (
FeedRewardIn,
FeedRewardOut,
PangleCallbackOut,
TestGrantIn,
TestGrantOut,
WatchReportIn,
WatchReportOut,
@@ -44,6 +47,23 @@ REASON_OK = 0
REASON_BAD_PARAMS = 1 # 验签过但缺 trans_id / user_id 非数字
REASON_UNKNOWN_USER = 2 # user_id 不存在(可能伪造)
REWARD_SCENE_REWARD_VIDEO = "reward_video"
REWARD_SCENE_SIGNIN_BOOST = "signin_boost"
SUPPORTED_REWARD_SCENES = {REWARD_SCENE_REWARD_VIDEO, REWARD_SCENE_SIGNIN_BOOST}
def _parse_extra(raw_extra: str | None) -> dict[str, str]:
"""解析客户端 setMediaExtra 透传的 JSON;旧格式/异常返回空 dict。"""
if not raw_extra:
return {}
try:
data = json.loads(raw_extra)
except (TypeError, ValueError):
return {}
if not isinstance(data, dict):
return {}
return {str(k): str(v) for k, v in data.items() if v is not None}
@router.get(
"/pangle-callback",
@@ -52,9 +72,9 @@ REASON_UNKNOWN_USER = 2 # user_id 不存在(可能伪造)
dependencies=[Depends(rate_limit(300, 60, "pangle-callback"))],
)
def pangle_callback(request: Request, db: DbSession) -> PangleCallbackOut:
"""穿山甲 GroMore 在激励视频播完后回调,带 user_id / trans_id / reward_amount / sign 等 query 参数。
"""穿山甲 GroMore 在激励视频播完后回调,带 user_id / trans_id / ecpm / sign 等 query 参数。
流程:开关/密钥就绪 → 验签(SHA256(m-key:trans_id)) → 解析 user_id/reward_amount → 幂等发金币。
流程:开关/密钥就绪 → 验签(SHA256(m-key:trans_id)) → 解析 user_id/场景/eCPM → 幂等发金币。
验签失败 403(留给真请求重试);参数缺/坏或 user 不存在 → is_verify=false + reason(不发,不重试)。
granted / capped → is_verify=true + reason=0。
"""
@@ -77,19 +97,86 @@ def pangle_callback(request: Request, db: DbSession) -> PangleCallbackOut:
return PangleCallbackOut(is_verify=False, reason=REASON_BAD_PARAMS)
user_id = int(raw_user_id)
coin = rewards.resolve_ad_reward_coin(db, params.get("reward_amount"))
raw = "&".join(f"{k}={v}" for k, v in sorted(params.items()) if k != "sign")
try:
rec = crud_ad.grant_ad_reward(
db, user_id, trans_id, coin=coin,
reward_name=params.get("reward_name"), raw=raw[:1024],
extra = {}
for extra_key in ("extra", "gromoreExtra", "gromore_extra"):
extra.update(_parse_extra(params.get(extra_key)))
reward_scene = extra.get("reward_scene") or REWARD_SCENE_REWARD_VIDEO
ad_session_id = extra.get("ad_session_id")
ecpm = params.get("ecpm")
existing = crud_ad.find_by_trans(db, trans_id)
if existing is not None:
logger.info(
"pangle callback idempotent user_id=%d trans_id=%s status=%s scene=%s",
user_id, trans_id, existing.status, existing.reward_scene,
)
return PangleCallbackOut(is_verify=True, reason=REASON_OK)
try:
if reward_scene not in SUPPORTED_REWARD_SCENES:
rec = crud_ad.record_external_reward(
db, user_id, trans_id, coin=0, reward_scene=reward_scene[:32],
ad_session_id=ad_session_id, ecpm=ecpm,
reward_name=params.get("reward_name"), raw=raw[:1024],
status="unknown_scene",
)
logger.warning(
"pangle callback unknown scene user_id=%d trans_id=%s scene=%s",
user_id, trans_id, reward_scene,
)
return PangleCallbackOut(is_verify=False, reason=REASON_BAD_PARAMS)
if reward_scene == REWARD_SCENE_SIGNIN_BOOST:
try:
boost, _balance = crud_signin.boost_today_signin(
db, user_id, ad_ref_id=trans_id, commit=False
)
except crud_signin.NotSignedTodayError:
db.rollback()
rec = crud_ad.record_external_reward(
db, user_id, trans_id, coin=0, reward_scene=reward_scene,
ad_session_id=ad_session_id, ecpm=ecpm,
reward_name=params.get("reward_name"), raw=raw[:1024],
status="not_signed",
)
except crud_signin.AlreadyBoostedError:
db.rollback()
rec = crud_ad.record_external_reward(
db, user_id, trans_id, coin=0, reward_scene=reward_scene,
ad_session_id=ad_session_id, ecpm=ecpm,
reward_name=params.get("reward_name"), raw=raw[:1024],
status="already_boosted",
)
except crud_signin.LastCycleDayBoostBlockedError:
db.rollback()
rec = crud_ad.record_external_reward(
db, user_id, trans_id, coin=0, reward_scene=reward_scene,
ad_session_id=ad_session_id, ecpm=ecpm,
reward_name=params.get("reward_name"), raw=raw[:1024],
status="last_day",
)
else:
rec = crud_ad.record_external_reward(
db, user_id, trans_id, coin=boost.coin_awarded,
reward_scene=reward_scene, ad_session_id=ad_session_id, ecpm=ecpm,
reward_name=params.get("reward_name"), raw=raw[:1024],
commit=False,
)
db.commit()
db.refresh(rec)
else:
rec = crud_ad.grant_ad_reward(
db, user_id, trans_id, ecpm=ecpm, ad_session_id=ad_session_id,
reward_scene=REWARD_SCENE_REWARD_VIDEO,
reward_name=params.get("reward_name"), raw=raw[:1024],
)
except crud_ad.UnknownUserError:
logger.warning("pangle callback unknown user_id=%d trans_id=%s", user_id, trans_id)
return PangleCallbackOut(is_verify=False, reason=REASON_UNKNOWN_USER)
logger.info(
"ad reward user_id=%d trans_id=%s status=%s coin=%d", user_id, trans_id, rec.status, rec.coin
"ad reward user_id=%d trans_id=%s scene=%s status=%s coin=%d",
user_id, trans_id, rec.reward_scene, rec.status, rec.coin,
)
# granted / capped 均算"已处理":is_verify=true 不让穿山甲重试(capped 只是没加币)
return PangleCallbackOut(is_verify=True, reason=REASON_OK)
@@ -153,11 +240,12 @@ def ecpm_report(payload: EcpmReportIn, user: CurrentUser, db: DbSession) -> Ecpm
crud_ecpm.create_ecpm_record(
db, user.id,
ad_type=payload.ad_type, ecpm_raw=payload.ecpm,
ad_session_id=payload.ad_session_id,
adn=payload.adn, slot_id=payload.slot_id,
)
logger.info(
"ad ecpm report user_id=%d type=%s ecpm=%s adn=%s slot=%s",
user.id, payload.ad_type, payload.ecpm, payload.adn, payload.slot_id,
"ad ecpm report user_id=%d type=%s session=%s ecpm=%s adn=%s slot=%s",
user.id, payload.ad_type, payload.ad_session_id, payload.ecpm, payload.adn, payload.slot_id,
)
return EcpmReportOut(ok=True)
@@ -168,7 +256,7 @@ def ecpm_report(payload: EcpmReportIn, user: CurrentUser, db: DbSession) -> Ecpm
summary="[仅本地联调]模拟穿山甲回调发奖",
dependencies=[Depends(rate_limit(60, 60, "ad-test-grant"))],
)
def test_grant(user: CurrentUser, db: DbSession) -> TestGrantOut:
def test_grant(user: CurrentUser, db: DbSession, payload: TestGrantIn | None = None) -> TestGrantOut:
"""⚠️ 仅本地联调用:没部署公网、穿山甲 S2S 回调打不到本地时,客户端(debug 包)看完广告后
调这个接口,直接走与回调相同的发奖逻辑(幂等 + 每日上限),验证"看广告→金币到账"全链路。
@@ -178,18 +266,58 @@ def test_grant(user: CurrentUser, db: DbSession) -> TestGrantOut:
if not settings.AD_REWARD_TEST_GRANT_ENABLED:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="not found")
# 每次新 trans_id,模拟一次独立的穿山甲发奖回调(幂等键各不相同 → 每次都发,直到当日上限)
reward_scene = (payload.reward_scene if payload is not None else REWARD_SCENE_REWARD_VIDEO)
if reward_scene not in SUPPORTED_REWARD_SCENES:
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="bad reward_scene")
# 每次新 trans_id,模拟一次独立的穿山甲发奖回调(幂等键各不相同 → 每次都发,直到当日上限/今日膨胀一次)
trans_id = f"test-{user.id}-{uuid.uuid4().hex}"
try:
rec = crud_ad.grant_ad_reward(
db, user.id, trans_id, reward_name="测试发奖", raw="client debug test-grant"
)
except crud_ad.UnknownUserError as e:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="user not found") from e
if reward_scene == REWARD_SCENE_SIGNIN_BOOST:
try:
boost, _balance = crud_signin.boost_today_signin(
db, user.id, ad_ref_id=trans_id, commit=False
)
except crud_signin.NotSignedTodayError:
db.rollback()
rec = crud_ad.record_external_reward(
db, user.id, trans_id, coin=0, reward_scene=reward_scene,
raw="client debug test-grant signin_boost", status="not_signed",
)
except crud_signin.AlreadyBoostedError:
db.rollback()
rec = crud_ad.record_external_reward(
db, user.id, trans_id, coin=0, reward_scene=reward_scene,
raw="client debug test-grant signin_boost", status="already_boosted",
)
except crud_signin.LastCycleDayBoostBlockedError:
db.rollback()
rec = crud_ad.record_external_reward(
db, user.id, trans_id, coin=0, reward_scene=reward_scene,
raw="client debug test-grant signin_boost", status="last_day",
)
else:
rec = crud_ad.record_external_reward(
db, user.id, trans_id, coin=boost.coin_awarded,
reward_scene=reward_scene, reward_name="测试签到膨胀",
raw="client debug test-grant signin_boost", commit=False,
)
db.commit()
db.refresh(rec)
else:
try:
rec = crud_ad.grant_ad_reward(
db, user.id, trans_id, ecpm="200", reward_name="测试发奖",
raw="client debug test-grant ecpm=200",
)
except crud_ad.UnknownUserError as e:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="user not found") from e
(used, limit, coin_per, round_count, cooldown_until,
_watched, _watch_limit) = crud_ad.today_status(db, user.id)
logger.info("ad TEST grant user_id=%d status=%s coin=%d", user.id, rec.status, rec.coin)
logger.info(
"ad TEST grant user_id=%d scene=%s status=%s coin=%d",
user.id, reward_scene, rec.status, rec.coin,
)
return TestGrantOut(
granted=(rec.status == "granted"),
status=rec.status,
@@ -220,6 +348,7 @@ def feed_reward(payload: FeedRewardIn, user: CurrentUser, db: DbSession) -> Feed
client_event_id=payload.client_event_id,
ecpm=payload.ecpm,
duration_seconds=payload.duration_seconds,
ad_session_id=payload.ad_session_id,
adn=payload.adn,
slot_id=payload.slot_id,
)
+17 -8
View File
@@ -12,7 +12,9 @@ import logging
from fastapi import APIRouter, HTTPException, status
from app.api.deps import CurrentUser, DbSession
from app.repositories import ad_reward as crud_ad
from app.repositories import signin as crud_signin
from app.repositories import wallet as crud_wallet
from app.schemas.welfare import (
SigninBoostRequest,
SigninBoostResultOut,
@@ -54,14 +56,21 @@ def do_signin(user: CurrentUser, db: DbSession) -> SigninResultOut:
def boost_signin(
payload: SigninBoostRequest, user: CurrentUser, db: DbSession
) -> SigninBoostResultOut:
try:
record, balance = crud_signin.boost_today_signin(
db, user.id, ad_ref_id=payload.ad_ref_id
)
except crud_signin.NotSignedTodayError as e:
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="not signed today") from e
except crud_signin.AlreadyBoostedError as e:
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="already boosted today") from e
if not payload.ad_ref_id:
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="ad reward required")
ad_rec = crud_ad.find_by_trans(db, payload.ad_ref_id)
if (
ad_rec is None
or ad_rec.user_id != user.id
or ad_rec.reward_scene != "signin_boost"
or ad_rec.status != "granted"
):
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="ad reward not verified")
record = crud_signin.boost_by_ad_ref(db, user.id, payload.ad_ref_id)
if record is None:
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="signin boost not granted")
acc = crud_wallet.get_or_create_account(db, user.id)
balance = acc.coin_balance
logger.info(
"signin boost ok user_id=%d date=%s coin=%d",