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="今日看广告发奖进度")
-42
View File
@@ -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)
+21
View File
@@ -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)
+5 -2
View File
@@ -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
View File
@@ -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
+44
View File
@@ -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
View File
@@ -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):
+4 -3
View File
@@ -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
View File
@@ -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
View File
@@ -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