Files
shaguabijia-app-server/tests/test_withdraw_tiers.py
T
no_gen_mu faa7039e7d fix(withdraw): 新人档提现没成功也被判定已用(解绑微信退回后 0.1 永久锁死)
新人档(0.1/0.3)「一次性」判定原本只看「有没有发起过」、不看提现单最终状态:
withdraw_tier_states 的 used_newbie 查询没有任何 status 过滤。用户解绑微信时,
待审核单经 refund_reviewing_withdraws_on_unbind → _refund_withdraw 退回现金并
置 failed,但记录仍在库里,于是照样被算作「已用」→ 钱退了、资格没退,0.1 档从
档位列表永久消失且重提 400。

判定口径改为只认 reviewing/pending/success(进行中或真拿到钱);failed/rejected
(被拒、微信转账失败、解绑退回,钱都没到手)一律恢复可提。下发(withdraw-info)与
下单校验(create_withdraw)共用 withdraw_tier_states,改一处两处同时生效。复用现有
status 语义,无新字段、无 alembic 迁移;存量受害用户上线后自动恢复,已成功提现的
(success)仍正确锁死。

⚠️行为变更:7-9 定的「新人档发起就算(含被拒/失败)」按本次拍板放宽为「没成功打款
就恢复」,对应测试断言已反转。常规档(0.5/10/20)的「当天名额发起就算、被拒不退」
规则未动。

测试:新增 解绑退回后恢复(bug 回归) + 成功打款后永久锁死;档位套件 8 passed,
提现/邀请提现/删号/对账回归 23 passed。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 16:00:26 +08:00

249 lines
10 KiB
Python

"""福利页(coin_cash)提现档位规则测试(7-9提现ui对齐)。
规则(2026-07-09 拍板):
- 档位 0.1/0.3(新人,历史一次性,免广告)+ 0.5(日3次)/10/20(日1次)。
- 常规档计次"发起就算":当天创建的单不论最终状态(含被拒)都占当天名额。
- 新人档一次性:进行中/成功打款才算用过;被拒/失败/解绑退回(已退款)恢复可提(2026-07-16 修正)。
- 常规三档每天只能选一个;新人档不参与该互斥,两个新人档同天可各提一次。
- invite_cash 无档位概念:tiers 为空、下单不走档位闸(邀请页行为不变)。
wxpay 调用全部 monkeypatch;现金余额 DB 直灌(同 test_withdraw.py 套路)。
"""
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_balances(client, token: str, phone: str, cash: int = 0, invite_cash: int = 0) -> None:
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 = cash
acc.invite_cash_balance_cents = invite_cash
db.commit()
finally:
db.close()
def _patch_userinfo(monkeypatch, openid: str) -> None:
monkeypatch.setattr(
"app.integrations.wxpay.code_to_userinfo",
lambda code: {"openid": openid, "nickname": "昵称", "avatar_url": None, "raw": {}},
)
def _reject(bill: str) -> None:
"""管理员拒绝:退款结清活跃单(便于同用户继续发起下一笔;名额按'发起就算'仍占)。"""
db = SessionLocal()
try:
crud_wallet.reject_withdraw(db, bill, "test")
finally:
db.close()
def _withdraw(client, token: str, cents: int):
return client.post(
"/api/v1/wallet/withdraw",
json={"amount_cents": cents, "source": "coin_cash"},
headers=_auth(token),
)
def _tiers(client, token: str, source: str = "coin_cash") -> list[dict]:
r = client.get(
"/api/v1/wallet/withdraw-info", params={"source": source}, headers=_auth(token)
)
assert r.status_code == 200, r.text
return r.json()["tiers"]
def _mark_success(bill: str) -> None:
"""直接把提现单置 success(模拟审核通过并成功打款,不走 wxpay);测「真拿到钱才永久锁死」。"""
db = SessionLocal()
try:
order = db.execute(
select(WithdrawOrder).where(WithdrawOrder.out_bill_no == bill)
).scalar_one()
order.status = "success"
db.commit()
finally:
db.close()
def _cash_balance(phone: str) -> int:
db = SessionLocal()
try:
user = db.execute(select(User).where(User.phone == phone)).scalar_one()
return db.get(CoinAccount, user.id).cash_balance_cents
finally:
db.close()
def test_withdraw_info_tiers_full_and_invite_empty(client, monkeypatch) -> None:
"""新用户 coin_cash 下发 5 档(新人角标齐);invite_cash tiers 为空。"""
token = _login(client, "13800006001")
tiers = _tiers(client, token)
assert [t["amount_cents"] for t in tiers] == [10, 30, 50, 1000, 2000]
assert [t["label"] for t in tiers] == ["0.1", "0.3", "0.5", "10", "20"]
assert tiers[0]["badge"] == "新人福利" and tiers[0]["is_newbie"] is True
assert tiers[1]["badge"] == "新人福利" and tiers[1]["is_newbie"] is True
assert all(t["available"] for t in tiers)
assert tiers[2]["remaining_today"] == 3 # 0.5 日 3 次
assert _tiers(client, token, source="invite_cash") == []
def test_newbie_tier_restored_on_reject_and_independent(client, monkeypatch) -> None:
"""0.1 被拒(已退款)→ 恢复可再提;0.3 独立;新人档不锁常规档。"""
_patch_userinfo(monkeypatch, "openid_tier_2")
token = _login(client, "13800006002")
_seed_balances(client, token, "13800006002", cash=5000)
client.post("/api/v1/wallet/bind-wechat", json={"code": "c"}, headers=_auth(token))
r = _withdraw(client, token, 10) # 0.1 新人档
assert r.status_code == 200, r.text
_reject(r.json()["out_bill_no"]) # 被拒退款 → 钱没到手,不再算用过
tiers = _tiers(client, token)
amounts = [t["amount_cents"] for t in tiers]
assert 10 in amounts # 0.1 恢复(被拒已退款)
assert 30 in amounts # 0.3 独立仍在
# 新人档不参与"选一个额度":常规三档全部仍可提
regular = {t["amount_cents"]: t for t in tiers if not t["is_newbie"]}
assert all(regular[a]["available"] for a in (50, 1000, 2000))
# 0.1 恢复后可再次发起
r = _withdraw(client, token, 10)
assert r.status_code == 200, r.text
def test_newbie_tier_locked_forever_after_success(client, monkeypatch) -> None:
"""0.1 成功打款(success,真拿到钱)→ 永久消失,重提非法金额(一次性仍成立)。"""
_patch_userinfo(monkeypatch, "openid_tier_ok")
token = _login(client, "13800006011")
_seed_balances(client, token, "13800006011", cash=5000)
client.post("/api/v1/wallet/bind-wechat", json={"code": "c"}, headers=_auth(token))
r = _withdraw(client, token, 10)
assert r.status_code == 200, r.text
_mark_success(r.json()["out_bill_no"]) # 真拿到钱 → 一次性用掉
assert 10 not in [t["amount_cents"] for t in _tiers(client, token)] # 永久消失
r = _withdraw(client, token, 10)
assert r.status_code == 400, r.text # 档位已消失
def test_newbie_tier_restored_after_unbind_refund(client, monkeypatch) -> None:
"""审核中解绑微信→待审核 0.1 退回现金→新人档恢复可提(本次修复的核心场景)。"""
phone = "13800006012"
_patch_userinfo(monkeypatch, "openid_unbind")
token = _login(client, phone)
_seed_balances(client, token, phone, cash=5000)
client.post("/api/v1/wallet/bind-wechat", json={"code": "c"}, headers=_auth(token))
r = _withdraw(client, token, 10) # 0.1 → reviewing,现金扣至 4990
assert r.status_code == 200, r.text
assert _cash_balance(phone) == 4990
assert 10 not in [t["amount_cents"] for t in _tiers(client, token)] # 占用中,消失
# 审核中解绑(force):待审核单退回现金 + 置 failed
r = client.post("/api/v1/wallet/unbind-wechat", json={"force": True}, headers=_auth(token))
assert r.status_code == 200, r.text
assert r.json()["bound"] is False
assert _cash_balance(phone) == 5000 # 现金退回
assert 10 in [t["amount_cents"] for t in _tiers(client, token)] # 0.1 恢复
# 重新绑微信后可再次发起 0.1
client.post("/api/v1/wallet/bind-wechat", json={"code": "c2"}, headers=_auth(token))
r = _withdraw(client, token, 10)
assert r.status_code == 200, r.text
def test_regular_daily_select_one_tier(client, monkeypatch) -> None:
"""当天提过 0.5 → 10/20 置灰 other_tier_selected,下单 409;0.5 还能继续提(3 次内)。"""
_patch_userinfo(monkeypatch, "openid_tier_3")
token = _login(client, "13800006003")
_seed_balances(client, token, "13800006003", cash=10_000)
client.post("/api/v1/wallet/bind-wechat", json={"code": "c"}, headers=_auth(token))
r = _withdraw(client, token, 50)
assert r.status_code == 200, r.text
_reject(r.json()["out_bill_no"]) # 结清活跃单;当天名额仍占("发起就算")
tiers = {t["amount_cents"]: t for t in _tiers(client, token)}
assert tiers[50]["available"] and tiers[50]["remaining_today"] == 2
assert not tiers[1000]["available"] and tiers[1000]["disabled_reason"] == "other_tier_selected"
assert not tiers[2000]["available"] and tiers[2000]["disabled_reason"] == "other_tier_selected"
r = _withdraw(client, token, 1000) # 选一额度互斥 → 409
assert r.status_code == 409, r.text
assert "今日额度已达上限" in r.json()["detail"]
r = _withdraw(client, token, 50) # 0.5 第 2 次照常
assert r.status_code == 200, r.text
def test_regular_daily_quota_exhausted(client, monkeypatch) -> None:
"""0.5 日 3 次:第 4 次 409;tiers 显示 quota_exhausted。"""
_patch_userinfo(monkeypatch, "openid_tier_4")
token = _login(client, "13800006004")
_seed_balances(client, token, "13800006004", cash=10_000)
client.post("/api/v1/wallet/bind-wechat", json={"code": "c"}, headers=_auth(token))
for _ in range(3):
r = _withdraw(client, token, 50)
assert r.status_code == 200, r.text
_reject(r.json()["out_bill_no"])
tiers = {t["amount_cents"]: t for t in _tiers(client, token)}
assert not tiers[50]["available"]
assert tiers[50]["disabled_reason"] == "quota_exhausted"
assert tiers[50]["remaining_today"] == 0
r = _withdraw(client, token, 50)
assert r.status_code == 409, r.text
def test_non_tier_amount_rejected_for_coin_cash(client, monkeypatch) -> None:
"""coin_cash 只能提预设档位:任意其他金额 400(防绕过客户端刷)。"""
_patch_userinfo(monkeypatch, "openid_tier_5")
token = _login(client, "13800006005")
_seed_balances(client, token, "13800006005", cash=10_000)
client.post("/api/v1/wallet/bind-wechat", json={"code": "c"}, headers=_auth(token))
r = _withdraw(client, token, 123)
assert r.status_code == 400, r.text
def test_invite_cash_not_gated_by_tiers(client, monkeypatch) -> None:
"""invite_cash 不走档位闸:非档位金额(200=2元)照常下单——邀请页行为不变。"""
_patch_userinfo(monkeypatch, "openid_tier_6")
token = _login(client, "13800006006")
_seed_balances(client, token, "13800006006", invite_cash=500)
client.post("/api/v1/wallet/bind-wechat", json={"code": "c"}, headers=_auth(token))
r = client.post(
"/api/v1/wallet/withdraw",
json={"amount_cents": 200, "source": "invite_cash"},
headers=_auth(token),
)
assert r.status_code == 200, r.text
assert r.json()["status"] == "reviewing"