0722d7b0d5
- 福利钱包: 金币/现金余额、流水、兑换 (api/v1/wallet.py, crud/wallet.py, models/wallet.py, schemas/welfare.py) - 每日签到: 连续天数 + 档位奖励 (api/v1/signin.py, crud/signin.py, models/signin.py) - 任务系统: "打开消息提醒"等任务领奖 (api/v1/tasks.py, crud/task.py, models/task.py) - 省钱战绩: 省钱汇总/战绩/店铺菜品 (api/v1/savings.py, crud/savings.py, models/savings.py) - 激励广告发奖: 穿山甲服务端回调 + 发奖规则 + 限流 (api/v1/ad.py, core/pangle.py, core/rewards.py, core/ratelimit.py, crud/ad_reward.py, schemas/ad.py, docs/ad_reward_golive_checklist.md) - 微信提现: 商家转账到零钱 V3 (core/wxpay.py); user 表加微信 openid/nickname/avatar - DB 迁移: 8 个 alembic (welfare 表/cash_transaction/openid 唯一约束/savings 店铺菜品/savings_record/ad_reward/withdraw_order+openid/user 微信字段) - 运维脚本: reconcile_withdraws(对账) + reset_signin/reset_welfare + sim_pangle_callback(模拟穿山甲回调) - 测试: test_welfare / test_withdraw / test_ad_reward - 配置: .env.example + config.py 新增福利/广告/微信支付项; main.py 挂 5 个新路由 Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
123 lines
5.0 KiB
Python
123 lines
5.0 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 pangle
|
|
from app.core.config import settings
|
|
from app.core.ratelimit import rate_limit
|
|
from app.crud import ad_reward as crud_ad
|
|
from app.schemas.ad import AdRewardStatusOut, PangleCallbackOut, TestGrantOut
|
|
|
|
logger = logging.getLogger("shagua.ad")
|
|
|
|
router = APIRouter(prefix="/api/v1/ad", tags=["ad"])
|
|
|
|
|
|
@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:
|
|
"""穿山甲服务器在激励视频播完后回调,带 user_id / trans_id / sign 等 query 参数。
|
|
|
|
流程:开关/密钥就绪 → 验签 → 解析 user_id/trans_id → 幂等发金币。
|
|
验签失败 403(穿山甲会重试,留给真请求);user_id 非法/不存在则受理但不发(is_valid 仍 True,
|
|
避免穿山甲无意义重试)。
|
|
"""
|
|
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():
|
|
# 验签过了但参数缺/坏:受理不发奖,不让穿山甲重试
|
|
logger.warning("pangle callback bad params: %s", params)
|
|
return PangleCallbackOut(is_valid=False)
|
|
|
|
user_id = int(raw_user_id)
|
|
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, 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_valid=False)
|
|
|
|
logger.info(
|
|
"ad reward user_id=%d trans_id=%s status=%s coin=%d", user_id, trans_id, rec.status, rec.coin
|
|
)
|
|
return PangleCallbackOut(is_valid=True)
|
|
|
|
|
|
@router.get("/reward-status", response_model=AdRewardStatusOut, summary="今日看广告发奖进度")
|
|
def reward_status(user: CurrentUser, db: DbSession) -> AdRewardStatusOut:
|
|
used, limit, coin_per = 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,
|
|
)
|
|
|
|
|
|
@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 = 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,
|
|
)
|