Files
shaguabijia-app-server/app/api/v1/ad.py
T
linkeyu 15fb73791f 功能:统一限制策略与白名单管理 (#207)
## 需求背景
将比价、短信与登录、广告、引导与账号、风控免告警等限制统一配置,并支持按手机号或设备设置有有效期的临时白名单。

## 主要改动
- 新增统一限制策略注册表、全局 JSON 配置与白名单覆盖表
- 新增白名单管理、设备检索、批量追加与主体统一编辑接口
- 接入比价、短信登录、广告奖励、引导视频、账号换绑及风险告警调用链
- 保留旧配置接口兼容,并同步统一策略全局值
- 增加单主体唯一有效期、恢复全局、审计日志和风险事件自动处理
- 增加数据库迁移及完整回归测试

## 验证
- 白名单、权限、配置及风控测试 50 项通过
- 短信、登录、比价、广告关联测试 98 项通过
- Ruff 与 Python 编译检查通过
- Alembic 保持单一 head
- 已同步最新 main

---------

Co-authored-by: unknown <798648091@qq.com>
Reviewed-on: #207
Co-authored-by: linkeyu <linkeyu@wonderable.ai>
Co-committed-by: linkeyu <linkeyu@wonderable.ai>
2026-07-31 17:08:06 +08:00

476 lines
22 KiB
Python

"""看激励视频发奖 endpoint。
路由前缀 `/api/v1/ad`:
GET /pangle-callback 穿山甲 S2S 发奖回调(**无 JWT,靠验签**),穿山甲服务器调
GET /reward-status 客户端查今日看广告发奖进度(Bearer)
GET /reward-result/{ad_session_id}
客户端按会话查本次广告实发金币(Bearer,只读,弹窗金额用)
发奖走服务端:激励视频播完穿山甲回调本接口,验签通过后幂等发金币。客户端只负责
看完后刷新余额,不参与发奖,被破解也刷不到钱。
"""
from __future__ import annotations
import json
import logging
import uuid
from fastapi import APIRouter, Depends, HTTPException, Path, Request, status
from app.api.deps import CurrentUser, DbSession
from app.core import limit_policy, rewards
from app.core.config import settings
from app.core.ratelimit import rate_limit
from app.integrations import pangle
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.schemas.ad import (
AdRewardResultOut,
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"
# 提现看视频:看完才能提现的「硬门槛」广告,**不发金币**,只记一条幂等记录(收益由 eCPM 上报口径
# ad_type="withdrawal_video" 单独统计)。故意不放进 SUPPORTED_REWARD_SCENES——它不走发币分支。
REWARD_SCENE_WITHDRAWAL_AD = "withdrawal_ad"
# 2026-07 下线 signin_boost(签到膨胀):它按固定 3000 金币发,与广告实际收益脱钩,产品确认
# 从来不是设计内的口径。签到弹窗里的「看广告膨胀」现在与福利页看视频走同一条 reward_video
# 路径(按 eCPM 公式发),奖励只剩「签到」+「看视频」两种。历史发币流水(coin_transaction
# .biz_type='signin_boost')保留不动——钱是真发过的,账必须留。
SUPPORTED_REWARD_SCENES = {REWARD_SCENE_REWARD_VIDEO}
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")
# 「这条广告属于哪一轮膨胀」。纯标签:不参与发奖判定,只让 reward-result 能把同一轮求和成
# 弹窗要显示的累计值(见 crud_ad.round_coin_total)。老客户端不带 → NULL → 累计值返 null。
boost_round_id = (extra.get("boost_round_id") or None)
if boost_round_id is not None:
boost_round_id = boost_round_id[:64]
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)
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, boost_round_id=boost_round_id,
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.get(
"/reward-result/{ad_session_id}",
response_model=AdRewardResultOut,
summary="按 ad_session_id 查本次广告的权威发奖结果",
dependencies=[Depends(rate_limit(120, 60, "ad-reward-result"))],
)
def reward_result(
user: CurrentUser,
db: DbSession,
ad_session_id: str = Path(..., min_length=8, max_length=64, description="本次广告会话 id"),
) -> AdRewardResultOut:
"""客户端看完激励视频后轮询本接口拿**本次实发金币 + 本轮累计**用于弹窗,不再用余额差 /
coin_per_ad 估算(修「弹窗数值与真实金币对不上」)。
round_coin 是「恭喜累计获得奖励」弹窗真正显示的数:本轮(= 客户端的 boost_round_id)所有
granted 记录之和。由服务端求和而不是客户端自己累加——客户端进程被杀/重建后本地累计会丢,
发奖记录不会。取不到轮 id(pending / 老客户端 / extra 丢失)时为 null,客户端退回显示单条。
S2S 回调异步:查不到记录 = 回调还没到 → 返 200 + status='pending' 让客户端继续重试,
**不返 404**(404 只表示路由不存在)。纯只读:发奖仍只由验签过的 S2S 回调完成,
这里不写库、不产生任何奖励,被刷也只是查自己的记录。
"""
rec = crud_ad.find_by_session(db, user.id, ad_session_id)
if rec is None:
# 连记录都没有 → 不知道属于哪一轮,round_coin 一并为 null(不是 0,0 会被当成"本轮没赚到")
return AdRewardResultOut(
ad_session_id=ad_session_id, status="pending", coin=None, round_coin=None,
)
# 本条不是 granted 时**仍返本轮累计**(这条按 0 计):第 3 条撞每日上限那下,客户端的限额
# toast 要显示的是前两条已到账的总额,不是空。
round_coin = (
crud_ad.round_coin_total(db, user.id, rec.boost_round_id)
if rec.boost_round_id
else None
)
return AdRewardResultOut(
ad_session_id=ad_session_id, status=rec.status, coin=rec.coin, round_coin=round_coin,
)
@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)是两条独立流,不逐条关联。
"""
attributed_trace_id = crud_ecpm.attributable_trace_id(
db,
feed_scene=payload.feed_scene,
trace_id=payload.trace_id,
exposure_ms=payload.exposure_ms,
)
if payload.trace_id and attributed_trace_id is None:
logger.info(
"detach late coupon ad impression from failed trace user_id=%d trace=%s session=%s",
user.id, payload.trace_id, payload.ad_session_id,
)
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,
feed_scene=payload.feed_scene,
trace_id=attributed_trace_id,
app_env=payload.app_env, our_code_id=payload.our_code_id,
exposure_ms=payload.exposure_ms,
)
logger.info(
"ad ecpm report user_id=%d type=%s scene=%s session=%s ecpm=%s exposure_ms=%s adn=%s slot=%s app=%s code=%s",
user.id, payload.ad_type, payload.feed_scene, payload.ad_session_id, payload.ecpm,
payload.exposure_ms, 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}"
# 正式链路的轮次 id 走 S2S 的 mediaExtra;本接口不经 S2S,只能由 body 补,否则 debug 包
# 的 reward-result 恒返 round_coin=null,「弹窗 40 → 60」那套累计验收在本地跑不起来。
boost_round_id = (payload.boost_round_id if payload is not None else None) or None
# 优先用客户端按 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,
boost_round_id=boost_round_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_type=payload.ad_type,
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,
display_coin=payload.display_coin,
)
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,
)
feed_policy = limit_policy.resolve_for_user(db, "ad.feed.daily", user.id)
feed_limit = (
rewards.get_ad_daily_limit(db)
if feed_policy.override_id is None
and feed_policy.bucket_version == "default"
else feed_policy.limit
)
return FeedRewardOut(
granted=(rec.status == "granted"),
status=rec.status,
coin=rec.coin,
unit_count=rec.unit_count,
daily_limit=(
feed_limit
if feed_limit is not None
else limit_policy.get_rule("ad.feed.daily").max_value
),
)
@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)