07c0b7502c
提现人工审核(提现不再即时打款): - models/wallet.py: WithdrawOrder 状态机改为 reviewing →(审核通过)→ pending → success/failed;reviewing →(驳回)→ rejected(已退款), 默认态 pending → reviewing(扣现金建单但不打款);新增 user_name 列(实名, 微信达额转账要求) - admin/routers/withdraw.py + admin/schemas/wallet.py: 运营后台审核接口(通过触发微信转账 / 驳回退款) - api/v1/wallet.py + repositories/wallet.py: 发起提现进 reviewing 态, 驳回退回现金并写 withdraw_refund - schemas/welfare.py: 提现出参增 reviewing/rejected 状态 + fail_reason 看广告每日时长限流(防刷主闸 + 次数兜底): - 新增 models/ad_watch_log.py: 按 (user_id, watch_date) 聚合当日观看秒数 - 新增 repositories/ad_watch.py: watched_seconds_today / add_watch_seconds(夹 [0, MAX_SINGLE_WATCH_SECONDS]) - api/v1/ad.py: 新增 POST /ad/watch-report(JWT 取 user, 落时长返当日累计); /ad/reward-status 增 watched_seconds_today / limit / remaining - schemas/ad.py: WatchReportIn/Out - core/rewards.py: 新增 DAILY_AD_WATCH_SECONDS_LIMIT=50 分钟(主闸)+ MAX_SINGLE_WATCH_SECONDS=120; DAILY_AD_REWARD_LIMIT 20→200 改作次数兜底(防前端少报时长绕过时长闸, 又不误伤看短广告的正常用户) - repositories/ad_reward.py + models/__init__.py: 接入新表与时长闸 alembic: withdraw_review_and_ad_watch 迁移(withdraw_order.user_name 列 + ad_watch_log 表) tests: 补 test_ad_reward / test_withdraw Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: pure <pure@192.168.0.104> Reviewed-on: #15
201 lines
8.4 KiB
Python
201 lines
8.4 KiB
Python
"""看激励视频发奖 endpoint。
|
|
|
|
路由前缀 `/api/v1/ad`:
|
|
GET /pangle-callback 穿山甲 S2S 发奖回调(**无 JWT,靠验签**),穿山甲服务器调
|
|
GET /reward-status 客户端查今日看广告发奖进度(Bearer)
|
|
|
|
发奖走服务端:激励视频播完穿山甲回调本接口,验签通过后幂等发金币。客户端只负责
|
|
看完后刷新余额,不参与发奖,被破解也刷不到钱。
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
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_reward as crud_ad
|
|
from app.repositories import ad_watch as crud_watch
|
|
from app.schemas.ad import (
|
|
AdRewardStatusOut,
|
|
EcpmReportIn,
|
|
EcpmReportOut,
|
|
PangleCallbackOut,
|
|
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 不存在(可能伪造)
|
|
|
|
|
|
@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 / reward_amount / sign 等 query 参数。
|
|
|
|
流程:开关/密钥就绪 → 验签(SHA256(m-key:trans_id)) → 解析 user_id/reward_amount → 幂等发金币。
|
|
验签失败 403(留给真请求重试);参数缺/坏或 user 不存在 → is_verify=false + reason(不发,不重试)。
|
|
granted / capped → is_verify=true + reason=0。
|
|
"""
|
|
if not settings.pangle_callback_configured:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail="pangle callback not configured"
|
|
)
|
|
|
|
params = dict(request.query_params)
|
|
|
|
if not pangle.verify_callback_sign(params, settings.PANGLE_REWARD_SECRET):
|
|
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)
|
|
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],
|
|
)
|
|
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
|
|
)
|
|
# 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="上报激励视频观看时长(每日 50 分钟防刷计时)",
|
|
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]。
|
|
当日累计达 DAILY_AD_WATCH_SECONDS_LIMIT(50 分钟)后:① 本接口 / reward-status 返回 remaining=0,
|
|
客户端不再展示广告;② 后端发奖(S2S 回调)也会因时长闸记 capped 不发金币(双闸,前端绕不过)。
|
|
"""
|
|
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,
|
|
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,
|
|
)
|
|
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) -> 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")
|
|
|
|
# 每次新 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
|
|
|
|
(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)
|
|
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,
|
|
)
|