ea22a11ef9
## 问题 激励视频发奖走穿山甲 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> --------- Co-authored-by: zzhyyyyy <2685922758@qq.com> Reviewed-on: #77 Co-authored-by: zhuzihao <zhuzihao@wonderable.ai> Co-committed-by: zhuzihao <zhuzihao@wonderable.ai>
100 lines
4.0 KiB
Python
100 lines
4.0 KiB
Python
"""模拟穿山甲激励视频 S2S 发奖回调,本地验证「验签 → 幂等发币 → 余额到账」闭环。
|
|
|
|
真穿山甲回调需要后端公网可达 + 穿山甲后台配置"奖励回调 URL",本地内网拿不到。本脚本用
|
|
FastAPI TestClient 在进程内打**真实** endpoint(`/api/v1/ad/pangle-callback`)、写**真实** DB
|
|
(./data/app.db),绕开公网依赖,证明后端这半条链路 OK——发完进 App 收益明细/重进福利页即可看到余额。
|
|
|
|
用法(在 shaguabijia-app-server 目录下,用本项目 python 跑):
|
|
python scripts/sim_pangle_callback.py # 给最近注册用户发一次(随机 trans_id)
|
|
python scripts/sim_pangle_callback.py 2 # 指定 user_id=2
|
|
python scripts/sim_pangle_callback.py 2 --trans t1 # 指定交易号;同号再跑应 Δ0(验幂等)
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import json
|
|
import os
|
|
import sys
|
|
import uuid
|
|
|
|
if hasattr(sys.stdout, "reconfigure"):
|
|
sys.stdout.reconfigure(encoding="utf-8")
|
|
|
|
# 必须在 import app.* 之前开 PANGLE 开关(settings 在 import 时构造并缓存);
|
|
# 这里 setdefault 不覆盖 .env / 真实环境已有的值。
|
|
os.environ.setdefault("PANGLE_CALLBACK_ENABLED", "true")
|
|
os.environ.setdefault("PANGLE_REWARD_SECRET", "dev-local-pangle-secret")
|
|
|
|
from fastapi.testclient import TestClient # noqa: E402
|
|
from sqlalchemy import select # noqa: E402
|
|
|
|
from app.integrations import pangle # noqa: E402
|
|
from app.core.config import settings # noqa: E402
|
|
from app.core.rewards import AD_REWARD_COIN # noqa: E402
|
|
from app.repositories import wallet as crud_wallet # noqa: E402
|
|
from app.db.session import SessionLocal # noqa: E402
|
|
from app.main import app # noqa: E402
|
|
from app.models.user import User # noqa: E402
|
|
|
|
|
|
def _latest_user_id() -> int | None:
|
|
db = SessionLocal()
|
|
try:
|
|
return db.execute(select(User.id).order_by(User.id.desc())).scalars().first()
|
|
finally:
|
|
db.close()
|
|
|
|
|
|
def _coin_balance(user_id: int) -> int:
|
|
db = SessionLocal()
|
|
try:
|
|
return crud_wallet.get_or_create_account(db, user_id).coin_balance
|
|
finally:
|
|
db.close()
|
|
|
|
|
|
def main() -> None:
|
|
p = argparse.ArgumentParser(description="模拟穿山甲发奖回调")
|
|
p.add_argument("user_id", nargs="?", type=int, default=None, help="目标 user_id(默认最近注册)")
|
|
p.add_argument("--trans", default=None, help="交易号(默认随机);同号重跑验幂等")
|
|
p.add_argument(
|
|
"--srv-env", default=None,
|
|
help="模拟客户端 mediaExtra 里的后端环境标记(dev/prod)。与本服 APP_ENV 不符时"
|
|
"应被环境隔离拦下(Δ0、不串号);留空=不带该字段(旧客户端,照常发)",
|
|
)
|
|
args = p.parse_args()
|
|
|
|
if not settings.pangle_callback_configured:
|
|
raise SystemExit("PANGLE 未配置(PANGLE_CALLBACK_ENABLED / PANGLE_REWARD_SECRET)")
|
|
|
|
uid = args.user_id or _latest_user_id()
|
|
if uid is None:
|
|
raise SystemExit("DB 里没有用户,先在 App 登录一次再跑")
|
|
|
|
trans_id = args.trans or f"sim_{uuid.uuid4().hex[:12]}"
|
|
before = _coin_balance(uid)
|
|
|
|
params = {
|
|
"user_id": str(uid),
|
|
"trans_id": trans_id,
|
|
"reward_name": "金币",
|
|
# 镜像生产:后台代码位"奖励数量"应配成 AD_REWARD_COIN,回调据此发金币
|
|
"reward_amount": str(AD_REWARD_COIN),
|
|
}
|
|
# 镜像穿山甲透传 mediaExtra:把后端环境标记放进 gromoreExtra(服务端据此做环境隔离)
|
|
if args.srv_env:
|
|
params["gromoreExtra"] = json.dumps({"srv_env": args.srv_env})
|
|
params["sign"] = pangle.build_sign(trans_id, settings.PANGLE_REWARD_SECRET)
|
|
|
|
resp = TestClient(app).get("/api/v1/ad/pangle-callback", params=params)
|
|
after = _coin_balance(uid)
|
|
|
|
print(f"user_id={uid} trans_id={trans_id}")
|
|
print(f"HTTP {resp.status_code} {resp.json()}")
|
|
print(f"金币: {before} -> {after} (Δ{after - before}, 单次应发 {AD_REWARD_COIN})")
|
|
print("同 trans_id 再跑应 Δ0(幂等);进 App 收益明细/重进福利页刷新即可看到余额。")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|