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:
+19
-10
@@ -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="今日看广告发奖进度")
|
||||
|
||||
@@ -1,42 +0,0 @@
|
||||
"""穿山甲激励视频"服务端发奖回调"的验签。
|
||||
|
||||
发奖闭环:激励视频播完 → 穿山甲服务器 GET 回调本服务(带 user_id / trans_id / sign 等)
|
||||
→ 本服务**验签**确认回调确实来自穿山甲(而非伪造请求) → 幂等发金币。
|
||||
客户端不参与发奖,被破解也刷不到钱。
|
||||
|
||||
⚠️ 关于验签方案:穿山甲后台的"奖励校验"实际签名算法以控制台为准(不同接入方式可能是
|
||||
HMAC 共享密钥,也可能是穿山甲 RSA 公钥验签)。这里用 **HMAC-SHA256(排序参数, 密钥)** 作为
|
||||
默认实现并配套自验签测试。真实接入时**只需改本文件的 `verify_callback_sign` / `build_sign`
|
||||
这一处**——发奖、幂等、每日限额逻辑(crud/ad_reward.py)与验签方案完全无关,不受影响。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import hmac
|
||||
|
||||
# 验签时排除的参数(sign 自身不参与签名;空值参数也不计入)
|
||||
_SIGN_EXCLUDE = {"sign"}
|
||||
|
||||
|
||||
def _canonical(params: dict[str, str]) -> str:
|
||||
"""把参与签名的参数按 key 升序拼成 `k=v&k=v`。空值剔除,保证两端构造一致。"""
|
||||
items = sorted(
|
||||
(k, v) for k, v in params.items() if k not in _SIGN_EXCLUDE and v not in (None, "")
|
||||
)
|
||||
return "&".join(f"{k}={v}" for k, v in items)
|
||||
|
||||
|
||||
def build_sign(params: dict[str, str], secret: str) -> str:
|
||||
"""对参数计算 HMAC-SHA256 十六进制签名(测试 / 自验用)。"""
|
||||
msg = _canonical(params).encode("utf-8")
|
||||
return hmac.new(secret.encode("utf-8"), msg, hashlib.sha256).hexdigest()
|
||||
|
||||
|
||||
def verify_callback_sign(params: dict[str, str], secret: str) -> bool:
|
||||
"""校验回调签名。`params` 是回调全部 query 参数(含 sign)。密钥为空一律判失败。"""
|
||||
if not secret:
|
||||
return False
|
||||
got = params.get("sign") or ""
|
||||
expect = build_sign(params, secret)
|
||||
# 定长比较防时序侧信道
|
||||
return hmac.compare_digest(got, expect)
|
||||
@@ -60,7 +60,28 @@ TASK_REWARDS: dict[str, int] = {
|
||||
|
||||
# ===== 看激励视频发金币(穿山甲 S2S 服务端回调发奖)=====
|
||||
# 看完一个激励视频发的金币(≈¥0.01,汇率 10000 金币=1 元)。
|
||||
# 作用:① 回调缺/坏 reward_amount 时的回退值;② 客户端进度接口展示的"单次预告金币"。
|
||||
# 真实发放以穿山甲回调带回的 reward_amount 为准(见 resolve_ad_reward_coin),后台应把
|
||||
# 代码位"奖励数量"配成与本值一致(如 100),保证"广告内展示 / 进度预告 / 实际到账"三者一致。
|
||||
AD_REWARD_COIN: int = 100
|
||||
# 单次发奖金币上限:夹紧穿山甲回调里异常的 reward_amount(如后台多打一个 0),防刷爆余额。
|
||||
MAX_AD_REWARD_COIN: int = 1000
|
||||
# 每用户每日发奖次数上限,防刷 + 控成本。
|
||||
# ⚠️ 占位值:正式上线前按产品策略 + 广告 eCPM 实测收益重新核定,改这里即可。
|
||||
DAILY_AD_REWARD_LIMIT: int = 10
|
||||
|
||||
|
||||
def resolve_ad_reward_coin(reward_amount: str | int | None) -> int:
|
||||
"""把穿山甲回调的 reward_amount(后台代码位配的"奖励数量")解析成本次发放金币。
|
||||
|
||||
缺失 / 非数字 / ≤0 → 回退 AD_REWARD_COIN;超过 MAX_AD_REWARD_COIN → 夹紧。
|
||||
注:reward_amount 不参与穿山甲 sign(只签 trans_id),但伪造回调需先伪造合法 sign
|
||||
(要 SecurityKey),故能过验签的 reward_amount 即视为可信,这里只防后台配错。
|
||||
"""
|
||||
try:
|
||||
coin = int(reward_amount) # type: ignore[arg-type]
|
||||
except (TypeError, ValueError):
|
||||
return AD_REWARD_COIN
|
||||
if coin <= 0:
|
||||
return AD_REWARD_COIN
|
||||
return min(coin, MAX_AD_REWARD_COIN)
|
||||
|
||||
@@ -47,11 +47,14 @@ def grant_ad_reward(
|
||||
user_id: int,
|
||||
trans_id: str,
|
||||
*,
|
||||
coin: int = AD_REWARD_COIN,
|
||||
reward_name: str | None = None,
|
||||
raw: str | None = None,
|
||||
) -> AdRewardRecord:
|
||||
"""看广告发奖(幂等 + 每日限额)。返回发奖记录(status=granted/capped)。
|
||||
|
||||
coin 为本次发放金币(由调用方按穿山甲回调 reward_amount 解析,见
|
||||
rewards.resolve_ad_reward_coin);默认 AD_REWARD_COIN 供 test-grant / 缺省场景用。
|
||||
user_id 不存在抛 UnknownUserError(不建账户,防伪造 user_id 刷出脏数据)。
|
||||
"""
|
||||
# #2 幂等:同 trans_id 已处理过 → 原样返回,不重复发
|
||||
@@ -74,11 +77,11 @@ def grant_ad_reward(
|
||||
|
||||
# 发金币 + 记一笔,同事务
|
||||
crud_wallet.grant_coins(
|
||||
db, user_id, AD_REWARD_COIN,
|
||||
db, user_id, coin,
|
||||
biz_type="ad_reward", ref_id=trans_id, remark="看广告奖励",
|
||||
)
|
||||
rec = AdRewardRecord(
|
||||
trans_id=trans_id, user_id=user_id, coin=AD_REWARD_COIN, status="granted",
|
||||
trans_id=trans_id, user_id=user_id, coin=coin, status="granted",
|
||||
reward_date=today, reward_name=reward_name, raw=raw,
|
||||
)
|
||||
return _commit_record(db, rec, trans_id)
|
||||
|
||||
+1
-1
@@ -13,7 +13,6 @@ from datetime import datetime, timedelta, timezone
|
||||
from sqlalchemy import select, update
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core import wxpay
|
||||
from app.core.rewards import (
|
||||
COIN_PER_CENT,
|
||||
MIN_EXCHANGE_COIN,
|
||||
@@ -21,6 +20,7 @@ from app.core.rewards import (
|
||||
WITHDRAW_MIN_CENTS,
|
||||
coins_to_cents,
|
||||
)
|
||||
from app.integrations import wxpay
|
||||
from app.models.user import User
|
||||
from app.models.wallet import CashTransaction, CoinAccount, CoinTransaction, WithdrawOrder
|
||||
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
"""穿山甲激励视频"服务端发奖回调"的验签。
|
||||
|
||||
发奖闭环:激励视频播完 → 穿山甲服务器 GET 回调本服务(带 user_id / trans_id / sign 等)
|
||||
→ 本服务**验签**确认回调确实来自穿山甲(而非伪造请求) → 幂等发金币。
|
||||
客户端不参与发奖,被破解也刷不到钱。
|
||||
|
||||
验签方案(穿山甲 GroMore「广告位 → 服务端激励回调」官方规范,见 supportcenter/26240。
|
||||
我们客户端是 useMediation(true) 融合,回调走 GroMore 广告位层级,**不是**联盟代码位层级):
|
||||
**sign = SHA256("{m-key}:{trans_id}")** 的十六进制串。注意:
|
||||
- 是**普通 SHA256**,不是 HMAC,也不是 RSA;
|
||||
- **只对 `m-key:trans_id` 这一个字符串**签名,user_id / reward_amount 等其余参数**不参与**签名
|
||||
(它们的可信度由"能算出正确 sign = 知道 m-key"间接保证——伪造者没有 m-key 就连合法 sign
|
||||
都造不出,自然也无法注入假 user_id/reward_amount)。
|
||||
m-key(安全密钥)在穿山甲后台「GroMore 聚合管理 → 搜广告位ID → 编辑」处获取,配到
|
||||
`settings.PANGLE_REWARD_SECRET`。(联盟代码位层级用的是另一套 Security Key + isValid 响应,我们不走那条。)
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import hmac
|
||||
|
||||
|
||||
def build_sign(trans_id: str, secret: str) -> str:
|
||||
"""按穿山甲规范计算 sign = SHA256("{SecurityKey}:{trans_id}") 的十六进制串。
|
||||
|
||||
secret 即后台的 Security Key。自验签测试 / 模拟回调脚本也用这一个函数,保证两端一致。
|
||||
"""
|
||||
msg = f"{secret}:{trans_id}".encode("utf-8")
|
||||
return hashlib.sha256(msg).hexdigest()
|
||||
|
||||
|
||||
def verify_callback_sign(params: dict[str, str], secret: str) -> bool:
|
||||
"""校验回调签名。`params` 是回调全部 query 参数(含 sign / trans_id)。
|
||||
|
||||
密钥为空或缺 trans_id 一律判失败。比较用定长比较防时序侧信道。
|
||||
"""
|
||||
if not secret:
|
||||
return False
|
||||
trans_id = params.get("trans_id") or ""
|
||||
if not trans_id:
|
||||
return False
|
||||
got = params.get("sign") or ""
|
||||
expect = build_sign(trans_id, secret)
|
||||
return hmac.compare_digest(got, expect)
|
||||
+9
-3
@@ -8,10 +8,16 @@ from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class PangleCallbackOut(BaseModel):
|
||||
"""回给穿山甲的回调响应。is_valid=True 表示已受理(发奖或当日已达上限均算受理,
|
||||
穿山甲不必重试);验签失败由 API 层直接返 403,不走这里。"""
|
||||
"""回给穿山甲 GroMore 的回调响应。
|
||||
|
||||
is_valid: bool = True
|
||||
GroMore 服务端激励回调规范(supportcenter/26240)要求响应体为
|
||||
`{"is_verify": bool, "reason": int}`:
|
||||
- `is_verify=true` + `reason=0`:校验通过、发放奖励(发奖成功 或 当日已达上限均算"已处理")。
|
||||
- `is_verify=false`:不发放,`reason` 带错误码透传客户端 SDK(见 ad.py 的 REASON_*)。
|
||||
验签失败由 API 层直接返 403,不走这里。"""
|
||||
|
||||
is_verify: bool = True
|
||||
reason: int = 0
|
||||
|
||||
|
||||
class AdRewardStatusOut(BaseModel):
|
||||
|
||||
@@ -27,7 +27,7 @@ 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.core import pangle # 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.crud import wallet as crud_wallet # noqa: E402
|
||||
@@ -72,9 +72,10 @@ def main() -> None:
|
||||
"user_id": str(uid),
|
||||
"trans_id": trans_id,
|
||||
"reward_name": "金币",
|
||||
"reward_amount": "1",
|
||||
# 镜像生产:后台代码位"奖励数量"应配成 AD_REWARD_COIN,回调据此发金币
|
||||
"reward_amount": str(AD_REWARD_COIN),
|
||||
}
|
||||
params["sign"] = pangle.build_sign(params, settings.PANGLE_REWARD_SECRET)
|
||||
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)
|
||||
|
||||
+29
-10
@@ -7,9 +7,9 @@ from __future__ import annotations
|
||||
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.core import pangle
|
||||
from app.core.config import settings
|
||||
from app.core.rewards import AD_REWARD_COIN, DAILY_AD_REWARD_LIMIT
|
||||
from app.integrations import pangle
|
||||
from app.core.rewards import AD_REWARD_COIN, DAILY_AD_REWARD_LIMIT, MAX_AD_REWARD_COIN
|
||||
from app.db.session import SessionLocal
|
||||
from app.models.user import User
|
||||
|
||||
@@ -34,9 +34,9 @@ def _user_id(phone: str) -> int:
|
||||
|
||||
|
||||
def _signed(user_id: int, trans_id: str, **extra: str) -> dict[str, str]:
|
||||
"""构造带合法签名的回调参数(模拟穿山甲服务器)。"""
|
||||
"""构造带合法签名的回调参数(模拟穿山甲服务器)。sign 只对 trans_id 签(穿山甲规范)。"""
|
||||
params = {"user_id": str(user_id), "trans_id": trans_id, **extra}
|
||||
params["sign"] = pangle.build_sign(params, settings.PANGLE_REWARD_SECRET)
|
||||
params["sign"] = pangle.build_sign(trans_id, settings.PANGLE_REWARD_SECRET)
|
||||
return params
|
||||
|
||||
|
||||
@@ -49,14 +49,14 @@ def _coin_balance(client, token: str) -> int:
|
||||
|
||||
|
||||
def test_callback_grants_coins(client) -> None:
|
||||
"""验签通过的回调 → 发金币到账 + reward-status 计数 +1。"""
|
||||
"""验签通过的回调(无 reward_amount → 回退 AD_REWARD_COIN)→ 发金币到账 + 计数 +1。"""
|
||||
phone = "13800003001"
|
||||
token = _login(client, phone)
|
||||
uid = _user_id(phone)
|
||||
|
||||
r = _callback(client, _signed(uid, "trans_a1", reward_name="金币", reward_amount="1"))
|
||||
r = _callback(client, _signed(uid, "trans_a1", reward_name="金币"))
|
||||
assert r.status_code == 200, r.text
|
||||
assert r.json() == {"is_valid": True}
|
||||
assert r.json() == {"is_verify": True, "reason": 0}
|
||||
|
||||
assert _coin_balance(client, token) == AD_REWARD_COIN
|
||||
|
||||
@@ -67,6 +67,25 @@ def test_callback_grants_coins(client) -> None:
|
||||
assert st["coin_per_ad"] == AD_REWARD_COIN
|
||||
|
||||
|
||||
def test_callback_uses_reward_amount(client) -> None:
|
||||
"""带合法 reward_amount → 按它发金币(而非常量);异常值回退/夹紧。"""
|
||||
phone = "13800003011"
|
||||
token = _login(client, phone)
|
||||
uid = _user_id(phone)
|
||||
|
||||
# 合法值:发 250
|
||||
assert _callback(client, _signed(uid, "ra_ok", reward_amount="250")).status_code == 200
|
||||
assert _coin_balance(client, token) == 250
|
||||
|
||||
# ≤0 / 非数字 → 回退 AD_REWARD_COIN(累加 100)
|
||||
assert _callback(client, _signed(uid, "ra_zero", reward_amount="0")).status_code == 200
|
||||
assert _coin_balance(client, token) == 250 + AD_REWARD_COIN
|
||||
|
||||
# 超上限 → 夹紧到 MAX_AD_REWARD_COIN
|
||||
assert _callback(client, _signed(uid, "ra_big", reward_amount="999999")).status_code == 200
|
||||
assert _coin_balance(client, token) == 250 + AD_REWARD_COIN + MAX_AD_REWARD_COIN
|
||||
|
||||
|
||||
def test_callback_idempotent(client) -> None:
|
||||
"""同一 trans_id 二次回调 → 只发一次金币(穿山甲重试不重复发)。"""
|
||||
phone = "13800003002"
|
||||
@@ -107,7 +126,7 @@ def test_daily_cap(client) -> None:
|
||||
# 第 limit+1 次:capped,仍 is_valid(不让穿山甲重试),但不加币
|
||||
r = _callback(client, _signed(uid, "trans_cap_over"))
|
||||
assert r.status_code == 200
|
||||
assert r.json() == {"is_valid": True}
|
||||
assert r.json() == {"is_verify": True, "reason": 0}
|
||||
|
||||
assert _coin_balance(client, token) == DAILY_AD_REWARD_LIMIT * AD_REWARD_COIN
|
||||
st = client.get("/api/v1/ad/reward-status", headers=_auth(token)).json()
|
||||
@@ -116,11 +135,11 @@ def test_daily_cap(client) -> None:
|
||||
|
||||
|
||||
def test_callback_unknown_user(client) -> None:
|
||||
"""验签过但 user_id 不存在 → 受理不发奖(is_valid false),不崩。"""
|
||||
"""验签过但 user_id 不存在 → 不发奖(is_verify false + reason),不崩。"""
|
||||
params = _signed(999999, "trans_ghost")
|
||||
r = _callback(client, params)
|
||||
assert r.status_code == 200, r.text
|
||||
assert r.json() == {"is_valid": False}
|
||||
assert r.json() == {"is_verify": False, "reason": 2}
|
||||
|
||||
|
||||
def test_reward_status_requires_auth(client) -> None:
|
||||
|
||||
+19
-19
@@ -38,7 +38,7 @@ def _seed_cash(client, token: str, phone: str, cents: int) -> None:
|
||||
|
||||
def _patch_userinfo(monkeypatch, openid="openid_test_abc", nickname="测试昵称", avatar="https://x/y.png"):
|
||||
monkeypatch.setattr(
|
||||
"app.core.wxpay.code_to_userinfo",
|
||||
"app.integrations.wxpay.code_to_userinfo",
|
||||
lambda code: {"openid": openid, "nickname": nickname, "avatar_url": avatar, "raw": {}},
|
||||
)
|
||||
|
||||
@@ -65,9 +65,9 @@ def test_bind_wechat(client, monkeypatch) -> None:
|
||||
|
||||
|
||||
def test_withdraw_success_flow(client, monkeypatch) -> None:
|
||||
monkeypatch.setattr("app.core.wxpay.code_to_userinfo", lambda code: {"openid": "openid_ok", "nickname": None, "avatar_url": None, "raw": {}})
|
||||
monkeypatch.setattr("app.integrations.wxpay.code_to_userinfo", lambda code: {"openid": "openid_ok", "nickname": None, "avatar_url": None, "raw": {}})
|
||||
monkeypatch.setattr(
|
||||
"app.core.wxpay.create_transfer",
|
||||
"app.integrations.wxpay.create_transfer",
|
||||
lambda openid, amount_fen, out_bill_no, user_name=None: {
|
||||
"status_code": 200,
|
||||
"data": {"state": "WAIT_USER_CONFIRM", "package_info": "pkg123", "transfer_bill_no": "tb_1"},
|
||||
@@ -94,7 +94,7 @@ def test_withdraw_success_flow(client, monkeypatch) -> None:
|
||||
|
||||
# 查单 → 微信返回 SUCCESS → 归一化 success
|
||||
monkeypatch.setattr(
|
||||
"app.core.wxpay.query_transfer",
|
||||
"app.integrations.wxpay.query_transfer",
|
||||
lambda out_bill_no: {"status_code": 200, "data": {"state": "SUCCESS"}},
|
||||
)
|
||||
r = client.get("/api/v1/wallet/withdraw/status", params={"out_bill_no": bill}, headers=_auth(token))
|
||||
@@ -109,9 +109,9 @@ def test_withdraw_success_flow(client, monkeypatch) -> None:
|
||||
|
||||
def test_withdraw_abandoned_confirm_cancels_and_refunds(client, monkeypatch) -> None:
|
||||
"""用户进了确认页但没确认就回来:查单仍 WAIT_USER_CONFIRM → 撤销微信单 + 退款 + 单置 failed。"""
|
||||
monkeypatch.setattr("app.core.wxpay.code_to_userinfo", lambda code: {"openid": "openid_wait", "nickname": None, "avatar_url": None, "raw": {}})
|
||||
monkeypatch.setattr("app.integrations.wxpay.code_to_userinfo", lambda code: {"openid": "openid_wait", "nickname": None, "avatar_url": None, "raw": {}})
|
||||
monkeypatch.setattr(
|
||||
"app.core.wxpay.create_transfer",
|
||||
"app.integrations.wxpay.create_transfer",
|
||||
lambda openid, amount_fen, out_bill_no, user_name=None: {
|
||||
"status_code": 200,
|
||||
"data": {"state": "WAIT_USER_CONFIRM", "package_info": "pkg", "transfer_bill_no": "tb_w"},
|
||||
@@ -127,14 +127,14 @@ def test_withdraw_abandoned_confirm_cancels_and_refunds(client, monkeypatch) ->
|
||||
|
||||
# 回到 app 查单:微信仍是 WAIT_USER_CONFIRM(没确认) + 撤单成功
|
||||
monkeypatch.setattr(
|
||||
"app.core.wxpay.query_transfer",
|
||||
"app.integrations.wxpay.query_transfer",
|
||||
lambda out_bill_no: {"status_code": 200, "data": {"state": "WAIT_USER_CONFIRM"}},
|
||||
)
|
||||
cancel_called = {}
|
||||
def _fake_cancel(out_bill_no):
|
||||
cancel_called["bill"] = out_bill_no
|
||||
return {"status_code": 200, "data": {"state": "CANCELLING"}}
|
||||
monkeypatch.setattr("app.core.wxpay.cancel_transfer", _fake_cancel)
|
||||
monkeypatch.setattr("app.integrations.wxpay.cancel_transfer", _fake_cancel)
|
||||
|
||||
r = client.get("/api/v1/wallet/withdraw/status", params={"out_bill_no": bill}, headers=_auth(token))
|
||||
assert r.status_code == 200, r.text
|
||||
@@ -150,7 +150,7 @@ def test_withdraw_abandoned_confirm_cancels_and_refunds(client, monkeypatch) ->
|
||||
|
||||
|
||||
def test_withdraw_insufficient_cash(client, monkeypatch) -> None:
|
||||
monkeypatch.setattr("app.core.wxpay.code_to_userinfo", lambda code: {"openid": "openid_x", "nickname": None, "avatar_url": None, "raw": {}})
|
||||
monkeypatch.setattr("app.integrations.wxpay.code_to_userinfo", lambda code: {"openid": "openid_x", "nickname": None, "avatar_url": None, "raw": {}})
|
||||
token = _login(client, "13800002003")
|
||||
_seed_cash(client, token, "13800002003", 20)
|
||||
client.post("/api/v1/wallet/bind-wechat", json={"code": "c"}, headers=_auth(token))
|
||||
@@ -167,9 +167,9 @@ def test_withdraw_not_bound(client) -> None:
|
||||
|
||||
|
||||
def test_withdraw_transfer_fail_refunds(client, monkeypatch) -> None:
|
||||
monkeypatch.setattr("app.core.wxpay.code_to_userinfo", lambda code: {"openid": "openid_fail", "nickname": None, "avatar_url": None, "raw": {}})
|
||||
monkeypatch.setattr("app.integrations.wxpay.code_to_userinfo", lambda code: {"openid": "openid_fail", "nickname": None, "avatar_url": None, "raw": {}})
|
||||
monkeypatch.setattr(
|
||||
"app.core.wxpay.create_transfer",
|
||||
"app.integrations.wxpay.create_transfer",
|
||||
lambda openid, amount_fen, out_bill_no, user_name=None: {
|
||||
"status_code": 400,
|
||||
"data": {"code": "PARAM_ERROR", "message": "amount invalid"},
|
||||
@@ -177,7 +177,7 @@ def test_withdraw_transfer_fail_refunds(client, monkeypatch) -> None:
|
||||
)
|
||||
# #3:非200 也要先查单确认。这里 PARAM_ERROR=没创建,查单返回 NOT_FOUND → 退款安全
|
||||
monkeypatch.setattr(
|
||||
"app.core.wxpay.query_transfer",
|
||||
"app.integrations.wxpay.query_transfer",
|
||||
lambda out_bill_no: {"status_code": 404, "data": {"code": "NOT_FOUND"}},
|
||||
)
|
||||
token = _login(client, "13800002005")
|
||||
@@ -201,15 +201,15 @@ def test_withdraw_transfer_fail_refunds(client, monkeypatch) -> None:
|
||||
|
||||
def test_withdraw_idempotent_same_bill_no(client, monkeypatch) -> None:
|
||||
"""#2 同 out_bill_no 重试:只转一次,第二次返回同一单,余额只扣一次。"""
|
||||
monkeypatch.setattr("app.core.wxpay.code_to_userinfo", lambda code: {"openid": "openid_idem", "nickname": None, "avatar_url": None, "raw": {}})
|
||||
monkeypatch.setattr("app.integrations.wxpay.code_to_userinfo", lambda code: {"openid": "openid_idem", "nickname": None, "avatar_url": None, "raw": {}})
|
||||
calls = {"n": 0}
|
||||
def _create(openid, amount_fen, out_bill_no, user_name=None):
|
||||
calls["n"] += 1
|
||||
return {"status_code": 200, "data": {"state": "WAIT_USER_CONFIRM", "package_info": "pkg", "transfer_bill_no": "tb"}}
|
||||
monkeypatch.setattr("app.core.wxpay.create_transfer", _create)
|
||||
monkeypatch.setattr("app.integrations.wxpay.create_transfer", _create)
|
||||
# 幂等查单时(pending)会查一次,返回仍在途
|
||||
monkeypatch.setattr(
|
||||
"app.core.wxpay.query_transfer",
|
||||
"app.integrations.wxpay.query_transfer",
|
||||
lambda out_bill_no: {"status_code": 200, "data": {"state": "ACCEPTED"}},
|
||||
)
|
||||
token = _login(client, "13800002007")
|
||||
@@ -235,12 +235,12 @@ def test_withdraw_idempotent_same_bill_no(client, monkeypatch) -> None:
|
||||
|
||||
def test_withdraw_ambiguous_timeout_then_success_no_refund(client, monkeypatch) -> None:
|
||||
"""#3 转账调用超时(异常),但查单确认已 SUCCESS → 不退款,单置 success。"""
|
||||
monkeypatch.setattr("app.core.wxpay.code_to_userinfo", lambda code: {"openid": "openid_amb", "nickname": None, "avatar_url": None, "raw": {}})
|
||||
monkeypatch.setattr("app.integrations.wxpay.code_to_userinfo", lambda code: {"openid": "openid_amb", "nickname": None, "avatar_url": None, "raw": {}})
|
||||
def _boom(openid, amount_fen, out_bill_no, user_name=None):
|
||||
raise TimeoutError("read timeout")
|
||||
monkeypatch.setattr("app.core.wxpay.create_transfer", _boom)
|
||||
monkeypatch.setattr("app.integrations.wxpay.create_transfer", _boom)
|
||||
monkeypatch.setattr(
|
||||
"app.core.wxpay.query_transfer",
|
||||
"app.integrations.wxpay.query_transfer",
|
||||
lambda out_bill_no: {"status_code": 200, "data": {"state": "SUCCESS", "transfer_bill_no": "tb_ok"}},
|
||||
)
|
||||
token = _login(client, "13800002008")
|
||||
@@ -261,7 +261,7 @@ def test_bind_rejects_openid_already_bound(client, monkeypatch) -> None:
|
||||
"""#5 一个微信只能绑一个账号:openid 已属于别的用户 → 409。"""
|
||||
shared_openid = "openid_shared_xyz"
|
||||
# 先让 A 绑定
|
||||
monkeypatch.setattr("app.core.wxpay.code_to_userinfo", lambda code: {"openid": shared_openid, "nickname": "A", "avatar_url": None, "raw": {}})
|
||||
monkeypatch.setattr("app.integrations.wxpay.code_to_userinfo", lambda code: {"openid": shared_openid, "nickname": "A", "avatar_url": None, "raw": {}})
|
||||
token_a = _login(client, "13800002009")
|
||||
r = client.post("/api/v1/wallet/bind-wechat", json={"code": "ca"}, headers=_auth(token_a))
|
||||
assert r.status_code == 200, r.text
|
||||
|
||||
Reference in New Issue
Block a user