3630fb7b3a
## 提现档位后端权威化(7-9,配套 android 同名分支 PR) ### 规则(2026-07-09 与产品逐条拍板) - 档位硬编码 `rewards.WITHDRAW_TIERS_COIN_CASH`:0.1/0.3(新人,历史一次性)+ 0.5(日3次)/10/20(日1次) - 计次口径「发起就算」:当天创建的单不论最终状态(含被拒/失败)都占名额 - 新人档:任意状态发起过即永久消失;两档独立同天可各提一次;不参与「每日选一个额度」互斥 - 常规三档每天只能选一个;invite_cash 无档位概念(tiers 空、下单不走档位闸,邀请页行为不变) - 「今天」= 北京时 cn_today();计次与 admin 看板同口径(Beijing 0点转 UTC 比较 created_at) ### 改动 - `GET /wallet/withdraw-info` 新增 `source` 参数 + 响应 `tiers[]`(amount/label/badge/available/disabled_reason/remaining_today) - `create_withdraw` 加档位闸:coin_cash 仅可提预设档位且该档可提,否则 400/409(防绕过客户端刷);放在幂等返回/在途互斥之后,不破坏同号重试 - 0.01 调试直发(allow_sub_min)不受档位约束,保持原样 ### 测试 - 新增 `tests/test_withdraw_tiers.py` 6 项全过(档位下发/新人独立+一次性/选一额度/次数耗尽/非档位金额拒绝/invite 不受影响) - 全量回归 305 过;5 项失败为 main 既有(coupon_proxy/invite_compare_reward,stash 验证与本 PR 无关) - 3 处旧测试的 coin_cash 金额从 100/200 调整为合法档位 50 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: no_gen_mu <liujianhishen@gmail.com> Reviewed-on: #129 Co-authored-by: liujiahui <liujiahui@wonderable.ai> Co-committed-by: liujiahui <liujiahui@wonderable.ai>
187 lines
7.6 KiB
Python
187 lines
7.6 KiB
Python
"""福利页(coin_cash)提现档位规则测试(7-9提现ui对齐)。
|
|
|
|
规则(2026-07-09 拍板):
|
|
- 档位 0.1/0.3(新人,历史一次性,免广告)+ 0.5(日3次)/10/20(日1次)。
|
|
- 计次口径"发起就算":当天创建的单不论最终状态(含被拒)都占名额;新人档任何状态都算用过。
|
|
- 常规三档每天只能选一个;新人档不参与该互斥,两个新人档同天可各提一次。
|
|
- 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
|
|
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 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_tiers_independent_and_once_forever(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 not 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))
|
|
|
|
r = _withdraw(client, token, 30) # 同天 0.3 照提
|
|
assert r.status_code == 200, r.text
|
|
_reject(r.json()["out_bill_no"])
|
|
|
|
# 0.1 已用过,重提 → 非法金额(档位已消失)
|
|
r = _withdraw(client, token, 10)
|
|
assert r.status_code == 400, 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"
|