"""提现(现金 → 微信零钱)测试。 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 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_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_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 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"] == "测试拒绝"