Files
shaguabijia-app-server/tests/test_withdraw.py
T

615 lines
27 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""提现(现金 → 微信零钱)测试。
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, WechatTransferAuthorization, WithdrawOrder
from app.repositories import wallet as crud_wallet
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 _approve(bill: str) -> None:
"""模拟管理员审核通过(走 repo,等价 admin 端点 approve → execute_withdraw_transfer 发起转账)。"""
db = SessionLocal()
try:
crud_wallet.approve_withdraw(db, bill)
finally:
db.close()
def _reject(bill: str, reason: str = "不符合提现规则") -> None:
"""模拟管理员审核拒绝(走 repo,等价 admin 端点 reject → 退款 + rejected)。"""
db = SessionLocal()
try:
crud_wallet.reject_withdraw(db, bill, reason)
finally:
db.close()
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_submit_then_approve_success_flow(client, monkeypatch) -> None:
"""提现先进 reviewing(扣款不打款) → 管理员审核通过才发起转账 → 查单 SUCCESS → success。"""
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 分 → 进入待审核(reviewing),已扣款但未打款,无 package_info
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"] == "reviewing"
assert body["package_info"] is None
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)
# 管理员审核通过 → 发起微信转账(mock WAIT_USER_CONFIRM,单进 pending)
_approve(bill)
# 用户确认到账后查单 → 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()["status"] == "reviewing"
assert r.json()["cash_balance_cents"] == 50 # 已扣
# 管理员审核通过 → 发起转账(mock WAIT_USER_CONFIRM,单进 pending)
_approve(bill)
# 回到 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_approve_transfer_fail_refunds(client, monkeypatch) -> None:
"""审核通过发起转账失败(非200) + 查单 NOT_FOUND(未创建) → 自动退款 + 单 failed。"""
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))
# 发起提现 → reviewing(已扣款),此刻不打款不会失败
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"] == "reviewing"
bill = r.json()["out_bill_no"]
# 管理员审核通过 → 转账失败 → 自动退款 + 单 failed
_approve(bill)
# 余额已退回
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_approved_withdraw_query_fail_exposes_wechat_reason(client, monkeypatch) -> None:
"""审核通过后微信终态失败时,保存官方失败码对应原因并退回余额。"""
monkeypatch.setattr(
"app.integrations.wxpay.code_to_userinfo",
lambda code: {
"openid": "openid_query_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": 200,
"data": {
"state": "WAIT_USER_CONFIRM",
"package_info": "pkg_query_fail",
"transfer_bill_no": "tb_query_fail",
},
},
)
token = _login(client, "13800002025")
_seed_cash(client, token, "13800002025", 100)
client.post("/api/v1/wallet/bind-wechat", json={"code": "c"}, headers=_auth(token))
response = client.post(
"/api/v1/wallet/withdraw",
json={"amount_cents": 50},
headers=_auth(token),
)
bill = response.json()["out_bill_no"]
_approve(bill)
monkeypatch.setattr(
"app.integrations.wxpay.query_transfer",
lambda out_bill_no: {
"status_code": 200,
"data": {
"state": "FAIL",
"fail_reason": "PAYEE_ACCOUNT_ABNORMAL",
"transfer_bill_no": "tb_query_fail",
},
},
)
response = client.get(
"/api/v1/wallet/withdraw/status",
params={"out_bill_no": bill},
headers=_auth(token),
)
assert response.status_code == 200, response.text
assert response.json()["status"] == "failed"
assert response.json()["wechat_state"] == "FAIL"
assert response.json()["fail_reason"] == (
"微信转账失败:用户微信账户收款异常(PAYEE_ACCOUNT_ABNORMAL"
)
with SessionLocal() as db:
order = db.execute(
select(WithdrawOrder).where(WithdrawOrder.out_bill_no == bill)
).scalar_one()
assert order.transfer_bill_no == "tb_query_fail"
account = client.get("/api/v1/wallet/account", headers=_auth(token))
assert account.json()["cash_balance_cents"] == 100
def test_unknown_wechat_fail_reason_code_is_preserved() -> None:
assert crud_wallet._wechat_terminal_failure_reason(
{"fail_reason": "NEW_WECHAT_REASON"}, "FAIL"
) == "微信转账失败:NEW_WECHAT_REASON"
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
# 提现阶段不打款 → create_transfer 一次都不会被调;两次都返回同一张待审核单
assert calls["n"] == 0
assert r1.json()["status"] == "reviewing" and r2.json()["status"] == "reviewing"
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_multiple_in_flight_allowed(client, monkeypatch) -> None:
"""取消「同时仅一单」:已有在途(reviewing)时,不同 out_bill_no 可继续提交,两单并存。"""
_patch_userinfo(monkeypatch, "openid_multi_inflight")
token = _login(client, "13800002020")
_seed_cash(client, token, "13800002020", 100) # 够两笔 0.5
client.post("/api/v1/wallet/bind-wechat", json={"code": "c"}, headers=_auth(token))
r1 = client.post(
"/api/v1/wallet/withdraw",
json={"amount_cents": 50, "out_bill_no": "billmulti00000001"},
headers=_auth(token),
)
assert r1.status_code == 200, r1.text
assert r1.json()["status"] == "reviewing"
r2 = client.post(
"/api/v1/wallet/withdraw",
json={"amount_cents": 50, "out_bill_no": "billmulti00000002"},
headers=_auth(token),
)
assert r2.status_code == 200, r2.text
assert r2.json()["status"] == "reviewing"
# 两张在途单并存
r = client.get("/api/v1/wallet/withdraw-orders", headers=_auth(token))
reviewing = [o for o in r.json()["items"] if o["status"] == "reviewing"]
assert len(reviewing) == 2, r.text
# 余额扣两次:100-50-50=0
r = client.get("/api/v1/wallet/account", headers=_auth(token))
assert r.json()["cash_balance_cents"] == 0
def test_withdraw_second_blocked_only_by_insufficient_cash(client, monkeypatch) -> None:
"""并行放开后第二笔仅受余额约束:余额不足返 409「现金余额不足」,而非旧的「已有提现」拦截。"""
_patch_userinfo(monkeypatch, "openid_multi_insuff")
token = _login(client, "13800002021")
_seed_cash(client, token, "13800002021", 50) # 仅够一笔 0.5
client.post("/api/v1/wallet/bind-wechat", json={"code": "c"}, headers=_auth(token))
r1 = client.post(
"/api/v1/wallet/withdraw",
json={"amount_cents": 50, "out_bill_no": "billinsuff0000001"},
headers=_auth(token),
)
assert r1.status_code == 200, r1.text
r2 = client.post(
"/api/v1/wallet/withdraw",
json={"amount_cents": 50, "out_bill_no": "billinsuff0000002"},
headers=_auth(token),
)
assert r2.status_code == 409, r2.text
assert "现金余额不足" in r2.json()["detail"]
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"] == "reviewing"
bill = r.json()["out_bill_no"]
# 审核通过 → 转账调用超时(异常) → 查单确认 SUCCESS → 不退款,单 success
_approve(bill)
r = client.get("/api/v1/wallet/withdraw-orders", headers=_auth(token))
assert r.json()["items"][0]["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
# ===== §10 绑微信时展示身份回填规则 =====
def test_bind_replaces_default_nickname_and_null_avatar(client, monkeypatch) -> None:
"""§10-A: 全默认(昵称=系统默认,头像=null) → 绑微信后两个展示字段都替换为微信值。"""
monkeypatch.setattr(
"app.integrations.wxpay.code_to_userinfo",
lambda code: {"openid": "openid_s10_a", "nickname": "微信昵称A", "avatar_url": "https://x/a.png", "raw": {}},
)
token = _login(client, "13800003001")
r = client.post("/api/v1/wallet/bind-wechat", json={"code": "c"}, headers=_auth(token))
assert r.status_code == 200, r.text
r = client.get("/api/v1/auth/me", headers=_auth(token))
assert r.status_code == 200, r.text
body = r.json()
assert body["nickname"] == "微信昵称A"
assert body["avatar_url"] == "https://x/a.png"
def test_bind_keeps_customized_nickname(client, monkeypatch) -> None:
"""§10-B: 用户已改过昵称 → 绑微信后昵称保留,但 null 头像仍替换为微信头像。"""
monkeypatch.setattr(
"app.integrations.wxpay.code_to_userinfo",
lambda code: {"openid": "openid_s10_b", "nickname": "微信昵称B", "avatar_url": "https://x/b.png", "raw": {}},
)
token = _login(client, "13800003002")
# 先把昵称改成非默认值
r = client.patch(
"/api/v1/user/profile", json={"nickname": "我的名字"}, headers=_auth(token)
)
assert r.status_code == 200, r.text
r = client.post("/api/v1/wallet/bind-wechat", json={"code": "c"}, headers=_auth(token))
assert r.status_code == 200, r.text
r = client.get("/api/v1/auth/me", headers=_auth(token))
body = r.json()
assert body["nickname"] == "我的名字" # 保留自定义昵称
assert body["avatar_url"] == "https://x/b.png" # null 头像被微信头像替换
def test_bind_keeps_customized_avatar(client, monkeypatch) -> None:
"""§10-C: 已有自定义头像 → 绑微信后头像保留,但默认昵称替换为微信昵称。"""
monkeypatch.setattr(
"app.integrations.wxpay.code_to_userinfo",
lambda code: {"openid": "openid_s10_c", "nickname": "微信昵称C", "avatar_url": "https://x/c.png", "raw": {}},
)
token = _login(client, "13800003003")
phone = "13800003003"
# 直接用 DB 给用户写入自定义头像(保持默认昵称)
db = SessionLocal()
try:
user = db.execute(select(User).where(User.phone == phone)).scalar_one()
user.avatar_url = "https://custom/av.png"
db.commit()
finally:
db.close()
r = client.post("/api/v1/wallet/bind-wechat", json={"code": "c"}, headers=_auth(token))
assert r.status_code == 200, r.text
r = client.get("/api/v1/auth/me", headers=_auth(token))
body = r.json()
assert body["nickname"] == "微信昵称C" # 默认昵称被替换
assert body["avatar_url"] == "https://custom/av.png" # 自定义头像保留
def test_bind_wechat_empty_keeps_default(client, monkeypatch) -> None:
"""§10-D: 微信侧 nickname/avatar 均为 None → 不覆盖,展示字段维持原默认值。"""
monkeypatch.setattr(
"app.integrations.wxpay.code_to_userinfo",
lambda code: {"openid": "openid_s10_d", "nickname": None, "avatar_url": None, "raw": {}},
)
token = _login(client, "13800003004")
r = client.get("/api/v1/auth/me", headers=_auth(token))
original_nickname = r.json()["nickname"] # 系统分配的默认昵称
assert original_nickname.startswith("用户")
r = client.post("/api/v1/wallet/bind-wechat", json={"code": "c"}, headers=_auth(token))
assert r.status_code == 200, r.text
r = client.get("/api/v1/auth/me", headers=_auth(token))
body = r.json()
assert body["nickname"] == original_nickname # 默认昵称不变
assert body["avatar_url"] is None # 头像仍为 null
def test_withdraw_reject_refunds(client, monkeypatch) -> None:
"""管理员审核拒绝 → 退回现金 + 单 rejected + 理由写入 fail_reason(用户可见)。"""
monkeypatch.setattr("app.integrations.wxpay.code_to_userinfo", lambda code: {"openid": "openid_reject", "nickname": None, "avatar_url": None, "raw": {}})
token = _login(client, "13800002011")
_seed_cash(client, token, "13800002011", 100)
client.post("/api/v1/wallet/bind-wechat", json={"code": "c"}, headers=_auth(token))
# 发起提现 → reviewing(已扣款,余额 50)
r = client.post("/api/v1/wallet/withdraw", json={"amount_cents": 50}, headers=_auth(token))
assert r.json()["status"] == "reviewing"
assert r.json()["cash_balance_cents"] == 50
bill = r.json()["out_bill_no"]
# 管理员拒绝 → 退款 + rejected(不调微信,无需 mock 转账)
_reject(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"])
# 单 rejected + 理由透出
r = client.get("/api/v1/wallet/withdraw/status", params={"out_bill_no": bill}, headers=_auth(token))
assert r.json()["status"] == "rejected"
assert r.json()["fail_reason"] == "测试拒绝"
# ===== §fix 免确认授权 enabled 判定收严:active+authorization_id 非空才算已授权 =====
def _seed_transfer_auth(phone: str, state: str, authorization_id: str | None) -> None:
"""直接在库里写/改该用户的免确认授权记录(绕过微信,用于判定测试)。"""
db = SessionLocal()
try:
user = db.execute(select(User).where(User.phone == phone)).scalar_one()
auth = db.get(WechatTransferAuthorization, user.id)
if auth is None:
auth = WechatTransferAuthorization(
user_id=user.id, openid=user.wechat_openid or "openid_test_abc",
out_authorization_no=f"oan_{user.id}",
)
db.add(auth)
auth.state = state
auth.authorization_id = authorization_id
db.commit()
finally:
db.close()
def test_withdraw_info_auth_enabled_requires_authorization_id(client, monkeypatch) -> None:
_patch_userinfo(monkeypatch)
token = _login(client, "13800002051")
# 触发建号 + 绑定微信(withdraw-info 读 openid)
client.get("/api/v1/wallet/withdraw-info", headers=_auth(token))
client.post("/api/v1/wallet/bind-wechat", json={"code": "c1"}, headers=_auth(token))
# active 但无 authorization_id → 视为未授权
_seed_transfer_auth("13800002051", "active", None)
r = client.get("/api/v1/wallet/withdraw-info", headers=_auth(token))
assert r.status_code == 200, r.text
assert r.json()["transfer_auth_enabled"] is False
# active 且有 authorization_id → 已授权
_seed_transfer_auth("13800002051", "active", "wx_auth_123")
r = client.get("/api/v1/wallet/withdraw-info", headers=_auth(token))
assert r.status_code == 200, r.text
assert r.json()["transfer_auth_enabled"] is True
def test_transfer_auth_status_requires_authorization_id(client, monkeypatch) -> None:
_patch_userinfo(monkeypatch)
token = _login(client, "13800002052")
client.get("/api/v1/wallet/withdraw-info", headers=_auth(token))
client.post("/api/v1/wallet/bind-wechat", json={"code": "c2"}, headers=_auth(token))
_seed_transfer_auth("13800002052", "active", None)
r = client.get("/api/v1/wallet/transfer-auth/status", headers=_auth(token))
assert r.status_code == 200, r.text
assert r.json()["state"] == "active"
assert r.json()["enabled"] is False
_seed_transfer_auth("13800002052", "active", "wx_auth_456")
r = client.get("/api/v1/wallet/transfer-auth/status", headers=_auth(token))
assert r.status_code == 200, r.text
assert r.json()["enabled"] is True