6f2731ee2d
- 验签: 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>
272 lines
13 KiB
Python
272 lines
13 KiB
Python
"""提现(现金 → 微信零钱)测试。
|
|
|
|
wxpay 的网络调用(code 换 openid / 发起转账 / 查单)全部 monkeypatch,不真发微信。
|
|
现金余额用 SessionLocal 直接 seed(没有"加现金"接口,正常靠金币兑换)。
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
from sqlalchemy import select
|
|
|
|
from app.db.session import SessionLocal
|
|
from app.models.user import User
|
|
from app.models.wallet import CoinAccount, WithdrawOrder
|
|
|
|
|
|
def _login(client, phone: str) -> str:
|
|
client.post("/api/v1/auth/sms/send", json={"phone": phone})
|
|
r = client.post("/api/v1/auth/sms/login", json={"phone": phone, "code": "123456"})
|
|
assert r.status_code == 200, r.text
|
|
return r.json()["access_token"]
|
|
|
|
|
|
def _auth(token: str) -> dict[str, str]:
|
|
return {"Authorization": f"Bearer {token}"}
|
|
|
|
|
|
def _seed_cash(client, token: str, phone: str, cents: int) -> None:
|
|
"""先访问 /account 触发建账户,再用 DB 直接灌现金余额。"""
|
|
client.get("/api/v1/wallet/account", headers=_auth(token))
|
|
db = SessionLocal()
|
|
try:
|
|
user = db.execute(select(User).where(User.phone == phone)).scalar_one()
|
|
acc = db.get(CoinAccount, user.id)
|
|
acc.cash_balance_cents = cents
|
|
db.commit()
|
|
finally:
|
|
db.close()
|
|
|
|
|
|
def _patch_userinfo(monkeypatch, openid="openid_test_abc", nickname="测试昵称", avatar="https://x/y.png"):
|
|
monkeypatch.setattr(
|
|
"app.integrations.wxpay.code_to_userinfo",
|
|
lambda code: {"openid": openid, "nickname": nickname, "avatar_url": avatar, "raw": {}},
|
|
)
|
|
|
|
|
|
def test_bind_wechat(client, monkeypatch) -> None:
|
|
_patch_userinfo(monkeypatch)
|
|
token = _login(client, "13800002001")
|
|
|
|
r = client.get("/api/v1/wallet/withdraw-info", headers=_auth(token))
|
|
assert r.status_code == 200, r.text
|
|
assert r.json()["wechat_bound"] is False
|
|
|
|
r = client.post("/api/v1/wallet/bind-wechat", json={"code": "auth_code_x"}, headers=_auth(token))
|
|
assert r.status_code == 200, r.text
|
|
body = r.json()
|
|
assert body["bound"] is True
|
|
assert body["wechat_nickname"] == "测试昵称"
|
|
assert body["wechat_avatar_url"] == "https://x/y.png"
|
|
|
|
r = client.get("/api/v1/wallet/withdraw-info", headers=_auth(token))
|
|
j = r.json()
|
|
assert j["wechat_bound"] is True
|
|
assert j["wechat_nickname"] == "测试昵称"
|
|
|
|
|
|
def test_withdraw_success_flow(client, monkeypatch) -> None:
|
|
monkeypatch.setattr("app.integrations.wxpay.code_to_userinfo", lambda code: {"openid": "openid_ok", "nickname": None, "avatar_url": None, "raw": {}})
|
|
monkeypatch.setattr(
|
|
"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"},
|
|
},
|
|
)
|
|
token = _login(client, "13800002002")
|
|
_seed_cash(client, token, "13800002002", 100)
|
|
client.post("/api/v1/wallet/bind-wechat", json={"code": "c"}, headers=_auth(token))
|
|
|
|
# 发起提现 50 分
|
|
r = client.post("/api/v1/wallet/withdraw", json={"amount_cents": 50}, headers=_auth(token))
|
|
assert r.status_code == 200, r.text
|
|
body = r.json()
|
|
assert body["status"] == "pending"
|
|
assert body["package_info"] == "pkg123"
|
|
assert body["mch_id"] and body["app_id"]
|
|
assert body["cash_balance_cents"] == 50 # 已扣
|
|
bill = body["out_bill_no"]
|
|
|
|
# 现金流水有一条 -50 的 withdraw
|
|
r = client.get("/api/v1/wallet/cash-transactions", headers=_auth(token))
|
|
txns = r.json()["items"]
|
|
assert any(t["biz_type"] == "withdraw" and t["amount_cents"] == -50 for t in txns)
|
|
|
|
# 查单 → 微信返回 SUCCESS → 归一化 success
|
|
monkeypatch.setattr(
|
|
"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))
|
|
assert r.status_code == 200, r.text
|
|
assert r.json()["status"] == "success"
|
|
|
|
# 提现单列表有 1 条 success
|
|
r = client.get("/api/v1/wallet/withdraw-orders", headers=_auth(token))
|
|
items = r.json()["items"]
|
|
assert len(items) == 1 and items[0]["status"] == "success"
|
|
|
|
|
|
def test_withdraw_abandoned_confirm_cancels_and_refunds(client, monkeypatch) -> None:
|
|
"""用户进了确认页但没确认就回来:查单仍 WAIT_USER_CONFIRM → 撤销微信单 + 退款 + 单置 failed。"""
|
|
monkeypatch.setattr("app.integrations.wxpay.code_to_userinfo", lambda code: {"openid": "openid_wait", "nickname": None, "avatar_url": None, "raw": {}})
|
|
monkeypatch.setattr(
|
|
"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"},
|
|
},
|
|
)
|
|
token = _login(client, "13800002006")
|
|
_seed_cash(client, token, "13800002006", 100)
|
|
client.post("/api/v1/wallet/bind-wechat", json={"code": "c"}, headers=_auth(token))
|
|
|
|
r = client.post("/api/v1/wallet/withdraw", json={"amount_cents": 50}, headers=_auth(token))
|
|
bill = r.json()["out_bill_no"]
|
|
assert r.json()["cash_balance_cents"] == 50 # 已扣
|
|
|
|
# 回到 app 查单:微信仍是 WAIT_USER_CONFIRM(没确认) + 撤单成功
|
|
monkeypatch.setattr(
|
|
"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.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
|
|
assert r.json()["status"] == "failed"
|
|
assert cancel_called["bill"] == bill # 确实调了撤单
|
|
|
|
# 余额已退回 100
|
|
r = client.get("/api/v1/wallet/account", headers=_auth(token))
|
|
assert r.json()["cash_balance_cents"] == 100
|
|
# 有退款流水
|
|
r = client.get("/api/v1/wallet/cash-transactions", headers=_auth(token))
|
|
assert any(t["biz_type"] == "withdraw_refund" and t["amount_cents"] == 50 for t in r.json()["items"])
|
|
|
|
|
|
def test_withdraw_insufficient_cash(client, monkeypatch) -> None:
|
|
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))
|
|
|
|
r = client.post("/api/v1/wallet/withdraw", json={"amount_cents": 50}, headers=_auth(token))
|
|
assert r.status_code == 409, r.text
|
|
|
|
|
|
def test_withdraw_not_bound(client) -> None:
|
|
token = _login(client, "13800002004")
|
|
_seed_cash(client, token, "13800002004", 100)
|
|
r = client.post("/api/v1/wallet/withdraw", json={"amount_cents": 50}, headers=_auth(token))
|
|
assert r.status_code == 400, r.text
|
|
|
|
|
|
def test_withdraw_transfer_fail_refunds(client, monkeypatch) -> None:
|
|
monkeypatch.setattr("app.integrations.wxpay.code_to_userinfo", lambda code: {"openid": "openid_fail", "nickname": None, "avatar_url": None, "raw": {}})
|
|
monkeypatch.setattr(
|
|
"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"},
|
|
},
|
|
)
|
|
# #3:非200 也要先查单确认。这里 PARAM_ERROR=没创建,查单返回 NOT_FOUND → 退款安全
|
|
monkeypatch.setattr(
|
|
"app.integrations.wxpay.query_transfer",
|
|
lambda out_bill_no: {"status_code": 404, "data": {"code": "NOT_FOUND"}},
|
|
)
|
|
token = _login(client, "13800002005")
|
|
_seed_cash(client, token, "13800002005", 100)
|
|
client.post("/api/v1/wallet/bind-wechat", json={"code": "c"}, headers=_auth(token))
|
|
|
|
r = client.post("/api/v1/wallet/withdraw", json={"amount_cents": 50}, headers=_auth(token))
|
|
assert r.status_code == 502, r.text
|
|
|
|
# 余额已退回
|
|
r = client.get("/api/v1/wallet/account", headers=_auth(token))
|
|
assert r.json()["cash_balance_cents"] == 100
|
|
# 有退款流水
|
|
r = client.get("/api/v1/wallet/cash-transactions", headers=_auth(token))
|
|
txns = r.json()["items"]
|
|
assert any(t["biz_type"] == "withdraw_refund" and t["amount_cents"] == 50 for t in txns)
|
|
# 提现单 failed
|
|
r = client.get("/api/v1/wallet/withdraw-orders", headers=_auth(token))
|
|
assert r.json()["items"][0]["status"] == "failed"
|
|
|
|
|
|
def test_withdraw_idempotent_same_bill_no(client, monkeypatch) -> None:
|
|
"""#2 同 out_bill_no 重试:只转一次,第二次返回同一单,余额只扣一次。"""
|
|
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.integrations.wxpay.create_transfer", _create)
|
|
# 幂等查单时(pending)会查一次,返回仍在途
|
|
monkeypatch.setattr(
|
|
"app.integrations.wxpay.query_transfer",
|
|
lambda out_bill_no: {"status_code": 200, "data": {"state": "ACCEPTED"}},
|
|
)
|
|
token = _login(client, "13800002007")
|
|
_seed_cash(client, token, "13800002007", 100)
|
|
client.post("/api/v1/wallet/bind-wechat", json={"code": "c"}, headers=_auth(token))
|
|
|
|
bill = "idemkey1234567890"
|
|
r1 = client.post("/api/v1/wallet/withdraw", json={"amount_cents": 50, "out_bill_no": bill}, headers=_auth(token))
|
|
assert r1.status_code == 200, r1.text
|
|
r2 = client.post("/api/v1/wallet/withdraw", json={"amount_cents": 50, "out_bill_no": bill}, headers=_auth(token))
|
|
assert r2.status_code == 200, r2.text
|
|
|
|
assert calls["n"] == 1 # 只真发起一次转账
|
|
assert r1.json()["out_bill_no"] == bill
|
|
assert r2.json()["out_bill_no"] == bill
|
|
# 余额只扣一次(100-50=50)
|
|
r = client.get("/api/v1/wallet/account", headers=_auth(token))
|
|
assert r.json()["cash_balance_cents"] == 50
|
|
# 只有一条 withdraw 流水
|
|
r = client.get("/api/v1/wallet/cash-transactions", headers=_auth(token))
|
|
assert sum(1 for t in r.json()["items"] if t["biz_type"] == "withdraw") == 1
|
|
|
|
|
|
def test_withdraw_ambiguous_timeout_then_success_no_refund(client, monkeypatch) -> None:
|
|
"""#3 转账调用超时(异常),但查单确认已 SUCCESS → 不退款,单置 success。"""
|
|
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.integrations.wxpay.create_transfer", _boom)
|
|
monkeypatch.setattr(
|
|
"app.integrations.wxpay.query_transfer",
|
|
lambda out_bill_no: {"status_code": 200, "data": {"state": "SUCCESS", "transfer_bill_no": "tb_ok"}},
|
|
)
|
|
token = _login(client, "13800002008")
|
|
_seed_cash(client, token, "13800002008", 100)
|
|
client.post("/api/v1/wallet/bind-wechat", json={"code": "c"}, headers=_auth(token))
|
|
|
|
r = client.post("/api/v1/wallet/withdraw", json={"amount_cents": 50}, headers=_auth(token))
|
|
assert r.status_code == 200, r.text
|
|
assert r.json()["status"] == "success"
|
|
# 没退款:余额仍是扣后的 50,且无 withdraw_refund
|
|
r = client.get("/api/v1/wallet/account", headers=_auth(token))
|
|
assert r.json()["cash_balance_cents"] == 50
|
|
r = client.get("/api/v1/wallet/cash-transactions", headers=_auth(token))
|
|
assert not any(t["biz_type"] == "withdraw_refund" for t in r.json()["items"])
|
|
|
|
|
|
def test_bind_rejects_openid_already_bound(client, monkeypatch) -> None:
|
|
"""#5 一个微信只能绑一个账号:openid 已属于别的用户 → 409。"""
|
|
shared_openid = "openid_shared_xyz"
|
|
# 先让 A 绑定
|
|
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
|
|
# B 想绑同一个 openid → 409
|
|
token_b = _login(client, "13800002010")
|
|
r = client.post("/api/v1/wallet/bind-wechat", json={"code": "cb"}, headers=_auth(token_b))
|
|
assert r.status_code == 409, r.text
|