b85177e3d3
## 问题 激励视频发奖走穿山甲 S2S 回调 /api/v1/ad/pangle-callback,发币时只认回调里的裸数字 user_id(grant_ad_reward 仅校验「本库存在该 id」),无任何环境归属校验。 同一 user_id 在测试库(本地 localhost)与正式库(线上)是两个不同的人;穿山甲 S2S 回调 地址只配一个(指向生产),测试包(dev)看广告的回调也会打到生产 → 生产把奖发给「本库里 同 user_id 的另一个真人」,造成跨库串号:测试看一次广告,正式库同号用户也被发币(线上 广告收益页因此多出一条本不该有的发奖记录)。 ## 修复(服务端,按环境隔离) pangle_callback 解析激励视频 mediaExtra 透传的 srv_env(dev/prod),与本服 settings.APP_ENV 不符则受理但不发币、不写记录(is_verify=true 防穿山甲重试)。不带 srv_env 的旧客户端按本 环境放行(兼容,不误伤存量正式用户)。 - 新增回归测试 test_callback_foreign_env_skipped(srv_env=prod 打到 APP_ENV=dev 的服务 → 不发) - sim_pangle_callback 增加 --srv-env 便于本地手验 客户端配套(mediaExtra 写入 srv_env)见 app-android 对应 PR。 ## 生效前提(三者缺一仍漏) 1. 生产 app-server 部署本代码并重启 2. 生产 .env 为 APP_ENV=prod 3. app 重新构建(旧包不带 srv_env 仍走兼容放行) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
466 lines
21 KiB
Python
466 lines
21 KiB
Python
"""看激励视频发奖 endpoint。
|
|
|
|
路由前缀 `/api/v1/ad`:
|
|
GET /pangle-callback 穿山甲 S2S 发奖回调(**无 JWT,靠验签**),穿山甲服务器调
|
|
GET /reward-status 客户端查今日看广告发奖进度(Bearer)
|
|
|
|
发奖走服务端:激励视频播完穿山甲回调本接口,验签通过后幂等发金币。客户端只负责
|
|
看完后刷新余额,不参与发奖,被破解也刷不到钱。
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
import json
|
|
import uuid
|
|
|
|
from fastapi import APIRouter, Depends, HTTPException, Request, status
|
|
|
|
from app.api.deps import CurrentUser, DbSession
|
|
from app.core import rewards
|
|
from app.core.config import settings
|
|
from app.integrations import pangle
|
|
from app.core.ratelimit import rate_limit
|
|
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 app_config
|
|
from app.repositories import signin as crud_signin
|
|
from app.schemas.ad import (
|
|
AdRewardStatusOut,
|
|
EcpmReportIn,
|
|
EcpmReportOut,
|
|
FeedRewardIn,
|
|
FeedRewardOut,
|
|
FeedRewardUnitsOut,
|
|
PangleCallbackOut,
|
|
RewardNoShowIn,
|
|
RewardNoShowOut,
|
|
TestGrantIn,
|
|
TestGrantOut,
|
|
WatchReportIn,
|
|
WatchReportOut,
|
|
)
|
|
|
|
logger = logging.getLogger("shagua.ad")
|
|
|
|
router = APIRouter(prefix="/api/v1/ad", tags=["ad"])
|
|
|
|
# GroMore 回调 is_verify=false 时透传给客户端 SDK 的错误码(自定义,仅用于排查/客户端提示)
|
|
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"
|
|
# 提现看视频:看完才能提现的「硬门槛」广告,**不发金币**,只记一条幂等记录(收益由 eCPM 上报口径
|
|
# ad_type="withdrawal_video" 单独统计)。故意不放进 SUPPORTED_REWARD_SCENES——它不走发币分支。
|
|
REWARD_SCENE_WITHDRAWAL_AD = "withdrawal_ad"
|
|
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",
|
|
response_model=PangleCallbackOut,
|
|
summary="穿山甲激励视频发奖回调(S2S,验签)",
|
|
dependencies=[Depends(rate_limit(300, 60, "pangle-callback"))],
|
|
)
|
|
def pangle_callback(request: Request, db: DbSession) -> PangleCallbackOut:
|
|
"""穿山甲 GroMore 在激励视频播完后回调,带 user_id / trans_id / ecpm / sign 等 query 参数。
|
|
|
|
流程:开关/密钥就绪 → 验签(SHA256(m-key:trans_id)) → 解析 user_id/场景/eCPM → 幂等发金币。
|
|
验签失败 403(留给真请求重试);参数缺/坏或 user 不存在 → is_verify=false + reason(不发,不重试)。
|
|
granted / capped → is_verify=true + reason=0。
|
|
"""
|
|
if not settings.PANGLE_CALLBACK_ENABLED:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail="pangle callback not configured"
|
|
)
|
|
|
|
params = dict(request.query_params)
|
|
|
|
# 验签密钥:admin 后台配的 reward_mkey 优先,.env 的 PANGLE_REWARD_SECRET* 兜底(兼容/过渡)。
|
|
# 换激励位时后台同步换 mkey 即可,无需改 .env。两者都空才视为未配置。
|
|
ad_mkey = app_config.get_ad_config(db).get("reward_mkey") or ""
|
|
secrets = ([ad_mkey] if ad_mkey else []) + settings.pangle_reward_secrets
|
|
if not secrets:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail="pangle callback not configured"
|
|
)
|
|
if not pangle.verify_callback_sign_any(params, secrets):
|
|
logger.warning("pangle callback bad sign trans_id=%s", params.get("trans_id"))
|
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="bad sign")
|
|
|
|
trans_id = params.get("trans_id") or ""
|
|
raw_user_id = params.get("user_id") or ""
|
|
if not trans_id or not raw_user_id.isdigit():
|
|
# 验签过了但参数缺/坏:不发奖(is_verify=false),带错误码
|
|
logger.warning("pangle callback bad params: %s", params)
|
|
return PangleCallbackOut(is_verify=False, reason=REASON_BAD_PARAMS)
|
|
|
|
user_id = int(raw_user_id)
|
|
raw = "&".join(f"{k}={v}" for k, v in sorted(params.items()) if k != "sign")
|
|
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")
|
|
|
|
# 环境隔离:激励视频 mediaExtra 里带「这次观看属于哪个后端环境」(srv_env=dev/prod,客户端按
|
|
# BuildConfig.DEBUG 决定),穿山甲 S2S 回调原样带回。回调 URL 在穿山甲后台只配一个(指向生产),
|
|
# 所以测试包(dev)看广告的 S2S 也会打到生产——若不拦,生产会把奖发给「本库里同 user_id 的另一个
|
|
# 真人」(user_id 是跨库不隔离的裸数字 → 跨库串号)。这里只处理「属于本服环境」的回调:环境不符
|
|
# 直接受理但不发币、不写任何记录,保证各环境后台广告收益页只含本环境用户。is_verify=true 让穿山甲
|
|
# 不再重试(测试用户的币由 localhost 的 test-grant 单独发,不依赖这条 S2S)。
|
|
# 兼容:旧客户端不带 srv_env(取不到)→ 视为本环境,照常处理,不误伤存量正式用户。
|
|
callback_env = extra.get("srv_env")
|
|
if callback_env and callback_env != settings.APP_ENV:
|
|
logger.info(
|
|
"pangle callback foreign env skip: callback_env=%s self_env=%s user_id=%d trans_id=%s",
|
|
callback_env, settings.APP_ENV, user_id, trans_id,
|
|
)
|
|
return PangleCallbackOut(is_verify=True, reason=REASON_OK)
|
|
|
|
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 == REWARD_SCENE_WITHDRAWAL_AD:
|
|
# 提现看视频:已知合法场景,只记一条幂等记录、**不动钱包**(发不发币由 reward_scene 决定)。
|
|
# is_verify=true 让穿山甲不重试(区别于 unknown_scene 的 false)。
|
|
crud_ad.record_external_reward(
|
|
db, user_id, trans_id, coin=0, reward_scene=REWARD_SCENE_WITHDRAWAL_AD,
|
|
ad_session_id=ad_session_id, ecpm=ecpm,
|
|
reward_name=params.get("reward_name"), raw=raw[:1024],
|
|
status="withdrawal_ad",
|
|
)
|
|
logger.info(
|
|
"pangle callback withdrawal_ad user_id=%d trans_id=%s", user_id, trans_id,
|
|
)
|
|
return PangleCallbackOut(is_verify=True, reason=REASON_OK)
|
|
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 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)
|
|
|
|
|
|
@router.get("/reward-status", response_model=AdRewardStatusOut, summary="今日看广告发奖进度")
|
|
def reward_status(user: CurrentUser, db: DbSession) -> AdRewardStatusOut:
|
|
(used, limit, coin_per, round_count, cooldown_until,
|
|
watched_sec, watch_limit) = crud_ad.today_status(db, user.id)
|
|
return AdRewardStatusOut(
|
|
used_today=used,
|
|
daily_limit=limit,
|
|
remaining=max(0, limit - used),
|
|
coin_per_ad=coin_per,
|
|
round_count=round_count,
|
|
cooldown_until=cooldown_until,
|
|
watched_seconds_today=watched_sec,
|
|
watch_seconds_limit=watch_limit,
|
|
watch_seconds_remaining=max(0, watch_limit - watched_sec),
|
|
)
|
|
|
|
|
|
@router.post(
|
|
"/watch-report",
|
|
response_model=WatchReportOut,
|
|
summary="上报激励视频观看时长(旧客户端兼容字段)",
|
|
dependencies=[Depends(rate_limit(120, 60, "ad-watch-report"))],
|
|
)
|
|
def watch_report(payload: WatchReportIn, user: CurrentUser, db: DbSession) -> WatchReportOut:
|
|
"""客户端在激励视频关闭(onAdClose)后上报本次实际观看秒数,服务端累计到当日总时长。
|
|
|
|
Bearer 鉴权,user_id 取自 JWT(不信 body)。seconds 服务端夹 [0, MAX_SINGLE_WATCH_SECONDS]。
|
|
当前产品只保留每日 500 次上限,DAILY_AD_WATCH_SECONDS_LIMIT=0 表示时长闸不启用;该接口
|
|
仍保留用于旧客户端兼容和排查观看时长。
|
|
"""
|
|
total = crud_watch.add_watch_seconds(db, user.id, payload.seconds)
|
|
limit = rewards.DAILY_AD_WATCH_SECONDS_LIMIT
|
|
logger.info(
|
|
"ad watch report user_id=%d +%ds total=%d/%d",
|
|
user.id, payload.seconds, total, limit,
|
|
)
|
|
return WatchReportOut(
|
|
watched_seconds_today=total,
|
|
watch_seconds_limit=limit,
|
|
watch_seconds_remaining=max(0, limit - total),
|
|
)
|
|
|
|
|
|
@router.post(
|
|
"/ecpm-report",
|
|
response_model=EcpmReportOut,
|
|
summary="上报本次广告展示的 eCPM(内部收益统计)",
|
|
dependencies=[Depends(rate_limit(120, 60, "ad-ecpm-report"))],
|
|
)
|
|
def ecpm_report(payload: EcpmReportIn, user: CurrentUser, db: DbSession) -> EcpmReportOut:
|
|
"""客户端在广告展示后(onAdShow 读 getShowEcpm)上报 eCPM,落库做内部收益统计/对账。
|
|
|
|
Bearer 鉴权,user_id 取自 JWT(不信 body)。best-effort:落库即 ok,客户端 fire-and-forget,
|
|
丢一两条不影响业务(穿山甲后台报表是结算权威)。eCPM 与发奖(S2S)是两条独立流,不逐条关联。
|
|
"""
|
|
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,
|
|
app_env=payload.app_env, our_code_id=payload.our_code_id,
|
|
)
|
|
logger.info(
|
|
"ad ecpm report user_id=%d type=%s session=%s ecpm=%s adn=%s slot=%s app=%s code=%s",
|
|
user.id, payload.ad_type, payload.ad_session_id, payload.ecpm, payload.adn, payload.slot_id,
|
|
payload.app_env, payload.our_code_id,
|
|
)
|
|
return EcpmReportOut(ok=True)
|
|
|
|
|
|
@router.post(
|
|
"/test-grant",
|
|
response_model=TestGrantOut,
|
|
summary="[仅本地联调]模拟穿山甲回调发奖",
|
|
dependencies=[Depends(rate_limit(60, 60, "ad-test-grant"))],
|
|
)
|
|
def test_grant(user: CurrentUser, db: DbSession, payload: TestGrantIn | None = None) -> TestGrantOut:
|
|
"""⚠️ 仅本地联调用:没部署公网、穿山甲 S2S 回调打不到本地时,客户端(debug 包)看完广告后
|
|
调这个接口,直接走与回调相同的发奖逻辑(幂等 + 每日上限),验证"看广告→金币到账"全链路。
|
|
|
|
必须 settings.AD_REWARD_TEST_GRANT_ENABLED=true 才开放(默认 False),否则 404 当不存在。
|
|
它让已登录客户端能自助发奖 = 绕过反作弊,**生产必须关闭**。
|
|
"""
|
|
if not settings.AD_REWARD_TEST_GRANT_ENABLED:
|
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="not found")
|
|
|
|
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}"
|
|
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:
|
|
# 优先用客户端按 ad_session_id 上报的真实 eCPM(走与正式发奖相同的公式);
|
|
# 取不到或 eCPM≤0(测试应用常返 0/假值)时兜底 200,保证本地联调仍能验出非零金币。
|
|
ad_session_id = payload.ad_session_id if payload is not None else None
|
|
ecpm_val = "200"
|
|
if ad_session_id:
|
|
ecpm_rec = crud_ecpm.find_by_session(db, user_id=user.id, ad_session_id=ad_session_id)
|
|
if ecpm_rec is not None and rewards.parse_ecpm_fen(ecpm_rec.ecpm_raw) > 0:
|
|
ecpm_val = ecpm_rec.ecpm_raw
|
|
try:
|
|
rec = crud_ad.grant_ad_reward(
|
|
db, user.id, trans_id, ecpm=ecpm_val, ad_session_id=ad_session_id,
|
|
reward_name="测试发奖", raw=f"client debug test-grant ecpm={ecpm_val}",
|
|
)
|
|
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 scene=%s status=%s coin=%d",
|
|
user.id, reward_scene, rec.status, rec.coin,
|
|
)
|
|
return TestGrantOut(
|
|
granted=(rec.status == "granted"),
|
|
status=rec.status,
|
|
coin=rec.coin,
|
|
used_today=used,
|
|
daily_limit=limit,
|
|
remaining=max(0, limit - used),
|
|
coin_per_ad=coin_per,
|
|
round_count=round_count,
|
|
cooldown_until=cooldown_until,
|
|
)
|
|
|
|
|
|
@router.post(
|
|
"/feed-reward",
|
|
response_model=FeedRewardOut,
|
|
summary="信息流广告完成后结算金币",
|
|
dependencies=[Depends(rate_limit(120, 60, "ad-feed-reward"))],
|
|
)
|
|
def feed_reward(payload: FeedRewardIn, user: CurrentUser, db: DbSession) -> FeedRewardOut:
|
|
"""点位 2:信息流广告每展示满 10 秒累计一份奖励,视频完成后一次性入账。
|
|
|
|
当前一期由客户端完成回调携带 eCPM / 展示秒数上报;client_event_id 做幂等键,避免重试重复发。
|
|
"""
|
|
rec = crud_feed.grant_feed_reward(
|
|
db,
|
|
user.id,
|
|
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,
|
|
feed_scene=payload.feed_scene,
|
|
trace_id=payload.trace_id,
|
|
app_env=payload.app_env,
|
|
our_code_id=payload.our_code_id,
|
|
aborted=payload.aborted,
|
|
)
|
|
logger.info(
|
|
"feed ad reward user_id=%d event=%s status=%s units=%d coin=%d",
|
|
user.id, rec.client_event_id, rec.status, rec.unit_count, rec.coin,
|
|
)
|
|
return FeedRewardOut(
|
|
granted=(rec.status == "granted"),
|
|
status=rec.status,
|
|
coin=rec.coin,
|
|
unit_count=rec.unit_count,
|
|
daily_limit=rewards.get_ad_daily_limit(db),
|
|
)
|
|
|
|
|
|
@router.get(
|
|
"/feed-reward/units",
|
|
response_model=FeedRewardUnitsOut,
|
|
summary="查账号累计信息流发奖份数(前端算因子2 LT、做实时金币进度条用)",
|
|
dependencies=[Depends(rate_limit(120, 60, "ad-feed-units"))],
|
|
)
|
|
def feed_reward_units(user: CurrentUser, db: DbSession) -> FeedRewardUnitsOut:
|
|
"""返回账号累计已发奖份数(因子2 LT 基线)。
|
|
|
|
信息流金币进度条:前端 show 时拉一次,之后每满 10 秒一份、逐份用 (granted_units + offset)
|
|
查 LT 因子,精确复刻发奖公式 → 进度条数值 ≈ 实际到账。
|
|
"""
|
|
return FeedRewardUnitsOut(granted_units=crud_feed.granted_unit_total(db, user.id))
|
|
|
|
|
|
@router.post(
|
|
"/reward-noshow",
|
|
response_model=RewardNoShowOut,
|
|
summary="激励视频提前关闭/未发奖留痕",
|
|
dependencies=[Depends(rate_limit(120, 60, "ad-reward-noshow"))],
|
|
)
|
|
def reward_noshow(payload: RewardNoShowIn, user: CurrentUser, db: DbSession) -> RewardNoShowOut:
|
|
"""激励视频展示了但用户提前关/跳过、未触发 S2S 发奖时,客户端 best-effort 上报一条留痕,
|
|
让广告收益报表能呈现「有展示、没发金币」的原因。不发金币;同一 session 已发奖则跳过。
|
|
"""
|
|
rec = crud_ad.record_reward_noshow(
|
|
db,
|
|
user.id,
|
|
ad_session_id=payload.ad_session_id,
|
|
ecpm=payload.ecpm,
|
|
adn=payload.adn,
|
|
slot_id=payload.slot_id,
|
|
app_env=payload.app_env,
|
|
our_code_id=payload.our_code_id,
|
|
)
|
|
logger.info(
|
|
"ad reward noshow user_id=%d session=%s watched=%ds -> status=%s",
|
|
user.id, payload.ad_session_id, payload.watched_seconds, rec.status,
|
|
)
|
|
return RewardNoShowOut(ok=True, status=rec.status)
|