refactor(integrations): 穿山甲验签改 GroMore SHA256 + pangle/wxpay 从 core 移入 integrations

- 验签: pangle.build_sign/verify_callback_sign 改为 GroMore 官方
  sign=SHA256("{m-key}:{trans_id}")(普通 SHA256,只签 trans_id);
  回调响应改成 GroMore 要求的 {is_verify, reason}
- 发奖: 按回调 reward_amount 发金币(rewards 带回退/夹紧),ad_reward 幂等 + 每日上限
- 分层: app/core/{pangle,wxpay}.py → app/integrations/(对齐 jiguang/meituan/sms);
  更新 ad.py / crud/wallet.py / scripts/sim_pangle_callback.py / 测试的 import
  (含 test_withdraw 的 monkeypatch 路径 app.core.wxpay→app.integrations.wxpay)
- 测试: test_ad_reward + test_withdraw 共 17 项通过

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
OuYingJun1024
2026-05-27 20:44:08 +08:00
parent 18ab7eab92
commit 6f2731ee2d
11 changed files with 151 additions and 90 deletions
+19 -10
View File
@@ -15,8 +15,9 @@ 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 import rewards
from app.core.config import settings
from app.integrations import pangle
from app.core.ratelimit import rate_limit
from app.crud import ad_reward as crud_ad
from app.schemas.ad import AdRewardStatusOut, PangleCallbackOut, TestGrantOut
@@ -25,6 +26,11 @@ 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",
@@ -33,11 +39,11 @@ router = APIRouter(prefix="/api/v1/ad", tags=["ad"])
dependencies=[Depends(rate_limit(300, 60, "pangle-callback"))],
)
def pangle_callback(request: Request, db: DbSession) -> PangleCallbackOut:
"""穿山甲服务器在激励视频播完后回调,带 user_id / trans_id / sign 等 query 参数。
"""穿山甲 GroMore 在激励视频播完后回调,带 user_id / trans_id / reward_amount / sign 等 query 参数。
流程:开关/密钥就绪 → 验签 → 解析 user_id/trans_id → 幂等发金币。
验签失败 403(穿山甲会重试,留给真请求);user_id 非法/不存在则受理但不发(is_valid 仍 True,
避免穿山甲无意义重试)
流程:开关/密钥就绪 → 验签(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(
@@ -53,24 +59,27 @@ def pangle_callback(request: Request, db: DbSession) -> PangleCallbackOut:
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_valid=False)
return PangleCallbackOut(is_verify=False, reason=REASON_BAD_PARAMS)
user_id = int(raw_user_id)
coin = rewards.resolve_ad_reward_coin(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, reward_name=params.get("reward_name"), raw=raw[:1024]
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_valid=False)
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
)
return PangleCallbackOut(is_valid=True)
# granted / capped 均算"已处理":is_verify=true 不让穿山甲重试(capped 只是没加币)
return PangleCallbackOut(is_verify=True, reason=REASON_OK)
@router.get("/reward-status", response_model=AdRewardStatusOut, summary="今日看广告发奖进度")