From 6f2731ee2d73c6a80645b642ffbaf6a28e4af07a Mon Sep 17 00:00:00 2001 From: OuYingJun1024 <1034284404@qq.com> Date: Wed, 27 May 2026 20:44:08 +0800 Subject: [PATCH] =?UTF-8?q?refactor(integrations):=20=E7=A9=BF=E5=B1=B1?= =?UTF-8?q?=E7=94=B2=E9=AA=8C=E7=AD=BE=E6=94=B9=20GroMore=20SHA256=20+=20p?= =?UTF-8?q?angle/wxpay=20=E4=BB=8E=20core=20=E7=A7=BB=E5=85=A5=20integrati?= =?UTF-8?q?ons?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 验签: 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 --- app/api/v1/ad.py | 29 ++++++++++++------- app/core/pangle.py | 42 --------------------------- app/core/rewards.py | 21 ++++++++++++++ app/crud/ad_reward.py | 7 +++-- app/crud/wallet.py | 2 +- app/integrations/pangle.py | 44 +++++++++++++++++++++++++++++ app/{core => integrations}/wxpay.py | 0 app/schemas/ad.py | 12 ++++++-- scripts/sim_pangle_callback.py | 7 +++-- tests/test_ad_reward.py | 39 ++++++++++++++++++------- tests/test_withdraw.py | 38 ++++++++++++------------- 11 files changed, 151 insertions(+), 90 deletions(-) delete mode 100644 app/core/pangle.py create mode 100644 app/integrations/pangle.py rename app/{core => integrations}/wxpay.py (100%) diff --git a/app/api/v1/ad.py b/app/api/v1/ad.py index ba38632..45026a4 100644 --- a/app/api/v1/ad.py +++ b/app/api/v1/ad.py @@ -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="今日看广告发奖进度") diff --git a/app/core/pangle.py b/app/core/pangle.py deleted file mode 100644 index 10dcc90..0000000 --- a/app/core/pangle.py +++ /dev/null @@ -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) diff --git a/app/core/rewards.py b/app/core/rewards.py index 1651383..ff98e65 100644 --- a/app/core/rewards.py +++ b/app/core/rewards.py @@ -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) diff --git a/app/crud/ad_reward.py b/app/crud/ad_reward.py index 4f31f8e..2422bd0 100644 --- a/app/crud/ad_reward.py +++ b/app/crud/ad_reward.py @@ -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) diff --git a/app/crud/wallet.py b/app/crud/wallet.py index 25796a6..97299b9 100644 --- a/app/crud/wallet.py +++ b/app/crud/wallet.py @@ -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 diff --git a/app/integrations/pangle.py b/app/integrations/pangle.py new file mode 100644 index 0000000..ee5edad --- /dev/null +++ b/app/integrations/pangle.py @@ -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) diff --git a/app/core/wxpay.py b/app/integrations/wxpay.py similarity index 100% rename from app/core/wxpay.py rename to app/integrations/wxpay.py diff --git a/app/schemas/ad.py b/app/schemas/ad.py index ecf8315..83d960b 100644 --- a/app/schemas/ad.py +++ b/app/schemas/ad.py @@ -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): diff --git a/scripts/sim_pangle_callback.py b/scripts/sim_pangle_callback.py index b6ec1c7..8b03a49 100644 --- a/scripts/sim_pangle_callback.py +++ b/scripts/sim_pangle_callback.py @@ -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) diff --git a/tests/test_ad_reward.py b/tests/test_ad_reward.py index 9ee3f20..ff1d00c 100644 --- a/tests/test_ad_reward.py +++ b/tests/test_ad_reward.py @@ -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: diff --git a/tests/test_withdraw.py b/tests/test_withdraw.py index c77ca1a..5eb8f8c 100644 --- a/tests/test_withdraw.py +++ b/tests/test_withdraw.py @@ -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