提现档位后端权威化:tiers下发+每日限次/选一额度+下单档位闸(7-9) (#129)

## 提现档位后端权威化(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>
This commit was merged in pull request #129.
This commit is contained in:
2026-07-09 22:03:07 +08:00
committed by marco
parent 2ddea4159d
commit 3630fb7b3a
7 changed files with 352 additions and 7 deletions
+14 -2
View File
@@ -43,6 +43,7 @@ from app.schemas.welfare import (
WithdrawRequest, WithdrawRequest,
WithdrawResultOut, WithdrawResultOut,
WithdrawStatusOut, WithdrawStatusOut,
WithdrawTierOut,
) )
logger = logging.getLogger("shagua.wallet") logger = logging.getLogger("shagua.wallet")
@@ -173,8 +174,15 @@ def unbind_wechat(
return UnbindWechatResultOut(bound=False) return UnbindWechatResultOut(bound=False)
@router.get("/withdraw-info", response_model=WithdrawInfoOut, summary="提现额度/绑定状态/免确认开关") @router.get("/withdraw-info", response_model=WithdrawInfoOut, summary="提现额度/绑定状态/免确认开关/档位")
def withdraw_info(user: CurrentUser, db: DbSession) -> WithdrawInfoOut: def withdraw_info(
user: CurrentUser,
db: DbSession,
source: str = Query(
"coin_cash",
description="提现账户:coin_cash(福利页,下发 tiers 档位) / invite_cash(邀请页,tiers 为空走旧逻辑)",
),
) -> WithdrawInfoOut:
u = db.get(User, user.id) u = db.get(User, user.id)
# 顺带同步免确认授权状态(捕获首单确认后已生效的授权 pending→active),让开关展示实时 # 顺带同步免确认授权状态(捕获首单确认后已生效的授权 pending→active),让开关展示实时
auth = crud_wallet.sync_transfer_auth(db, user.id) auth = crud_wallet.sync_transfer_auth(db, user.id)
@@ -185,6 +193,7 @@ def withdraw_info(user: CurrentUser, db: DbSession) -> WithdrawInfoOut:
wechat_nickname=u.wechat_nickname if u else None, wechat_nickname=u.wechat_nickname if u else None,
wechat_avatar_url=u.wechat_avatar_url if u else None, wechat_avatar_url=u.wechat_avatar_url if u else None,
transfer_auth_enabled=bool(auth and auth.state == "active"), transfer_auth_enabled=bool(auth and auth.state == "active"),
tiers=[WithdrawTierOut(**t) for t in crud_wallet.withdraw_tier_states(db, user.id, source)],
) )
@@ -218,6 +227,9 @@ def withdraw(req: WithdrawRequest, user: CurrentUser, db: DbSession) -> Withdraw
status_code=status.HTTP_409_CONFLICT, status_code=status.HTTP_409_CONFLICT,
detail="已有提现申请正在审核或打款中,请处理完成后再申请", detail="已有提现申请正在审核或打款中,请处理完成后再申请",
) from e ) from e
except crud_wallet.WithdrawTierUnavailableError as e:
# 福利页档位闸(7-9):次数满/已选其他额度。正常客户端已按 tiers 预拦,此处兜底防绕过。
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="今日额度已达上限") from e
except crud_wallet.InsufficientCashError as e: except crud_wallet.InsufficientCashError as e:
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="现金余额不足") from e raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="现金余额不足") from e
+25
View File
@@ -6,6 +6,7 @@
from __future__ import annotations from __future__ import annotations
from datetime import date, datetime, timedelta, timezone from datetime import date, datetime, timedelta, timezone
from typing import NamedTuple
# 业务时区:签到的"今天"按北京时间算,不能用 UTC。 # 业务时区:签到的"今天"按北京时间算,不能用 UTC。
# 否则 UTC+8 的凌晨 0~8 点会被算成 UTC 的前一天,导致签到日期错位。 # 否则 UTC+8 的凌晨 0~8 点会被算成 UTC 的前一天,导致签到日期错位。
@@ -52,6 +53,30 @@ WITHDRAW_MIN_CENTS: int = 10
WITHDRAW_MAX_CENTS: int = 5_000_000 # 5 万元 WITHDRAW_MAX_CENTS: int = 5_000_000 # 5 万元
# ===== 提现档位(福利页 coin_cash;7-9 对齐原型 withdrawal.html)=====
# 后端是档位唯一真相源:withdraw-info 按此下发,create_withdraw 按此校验(防绕过客户端刷)。
# 规则(2026-07-09 拍板):
# - 新人档(is_newbie):账号历史一次性,"发起就算用过"(任意状态含被拒),用过即不再下发;
# 0.1 与 0.3 各自独立同天可各提一次,且不参与常规档"每日选一个额度"互斥。
# - 常规档:按北京日计次(0.5×3 / 10×1 / 20×1),三档每天只能选一个。
# invite_cash(邀请页)本轮无档位概念,不在此表。改档位=改这里发版。
class WithdrawTier(NamedTuple):
amount_cents: int
label: str # 客户端档位方块展示文案
badge: str | None # 角标文案;None=无角标
daily_limit: int # 每日次数上限(新人档的"历史一次性"另由 is_newbie 判定)
is_newbie: bool
WITHDRAW_TIERS_COIN_CASH: tuple[WithdrawTier, ...] = (
WithdrawTier(10, "0.1", "新人福利", 1, True),
WithdrawTier(30, "0.3", "新人福利", 1, True),
WithdrawTier(50, "0.5", None, 3, False),
WithdrawTier(1000, "10", None, 1, False),
WithdrawTier(2000, "20", None, 1, False),
)
# ===== 一次性任务(领一次,user_task 去重)===== # ===== 一次性任务(领一次,user_task 去重)=====
TASK_ENABLE_NOTIFICATION = "enable_notification" TASK_ENABLE_NOTIFICATION = "enable_notification"
+101 -1
View File
@@ -11,7 +11,7 @@ import unicodedata
import uuid import uuid
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
from sqlalchemy import select, update from sqlalchemy import func, select, update
from sqlalchemy.exc import IntegrityError from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
@@ -67,6 +67,10 @@ class WithdrawTooFrequentError(Exception):
"""提现申请过于频繁,或已有未完成提现单。""" """提现申请过于频繁,或已有未完成提现单。"""
class WithdrawTierUnavailableError(Exception):
"""该档位今日不可提:次数已满,或今天已选了其他额度(7-9 福利页档位规则)。"""
class WithdrawTransferError(Exception): class WithdrawTransferError(Exception):
"""调用微信转账失败(已退回余额)。""" """调用微信转账失败(已退回余额)。"""
@@ -605,6 +609,89 @@ def _settle_after_ambiguous(db: Session, order: WithdrawOrder, reason: str) -> N
db.commit() db.commit()
def _beijing_today_start_utc() -> datetime:
"""北京时今日 0 点(转 UTC)。WithdrawOrder.created_at 是 func.now()(UTC)存储,
比较时统一转 UTC,与 admin 看板 today_start 同口径(admin/repositories/queries.py)。"""
return (
datetime.now(rewards.CN_TZ)
.replace(hour=0, minute=0, second=0, microsecond=0)
.astimezone(timezone.utc)
)
def withdraw_tier_states(db: Session, user_id: int, source: str = "coin_cash") -> list[dict]:
"""福利页(coin_cash)提现档位的可提现状态。withdraw-info 下发与 create_withdraw 校验共用此口径。
规则(2026-07-09 拍板,7-9提现ui对齐):
- 新人档(0.1/0.3):账号历史一次性——只要发起过(**任意状态**,含被拒/失败,"发起就算")
即视为已用,直接**从返回列表消失**;两档各自独立互不影响,不参与"每日选一个额度"互斥。
- 常规档(0.5×3 / 10×1 / 20×1):按北京日计次,"发起就算占用"(当天创建的单不论最终状态
都计入,被拒/失败不退当天名额);三档每天只能选一个,选定后其余两档当天 other_tier_selected。
- invite_cash 本轮无档位概念 → 返回空列表(邀请页客户端仍用本地写死档位,行为不变)。
余额是否足够由客户端本地判断(余额随兑换实时变化,不在此快照)。
"""
if source != "coin_cash":
return []
tiers = rewards.WITHDRAW_TIERS_COIN_CASH
amounts = [t.amount_cents for t in tiers]
newbie_amounts = [t.amount_cents for t in tiers if t.is_newbie]
# 新人档历史是否用过:任意时间、任意状态("发起就算")
used_newbie: set[int] = set(
db.execute(
select(WithdrawOrder.amount_cents)
.distinct()
.where(
WithdrawOrder.user_id == user_id,
WithdrawOrder.source == "coin_cash",
WithdrawOrder.amount_cents.in_(newbie_amounts),
)
).scalars()
) if newbie_amounts else set()
# 今日(北京日)每档已发起次数(任意状态)
today_counts: dict[int, int] = {
int(amount): int(cnt)
for amount, cnt in db.execute(
select(WithdrawOrder.amount_cents, func.count(WithdrawOrder.id))
.where(
WithdrawOrder.user_id == user_id,
WithdrawOrder.source == "coin_cash",
WithdrawOrder.amount_cents.in_(amounts),
WithdrawOrder.created_at >= _beijing_today_start_utc(),
)
.group_by(WithdrawOrder.amount_cents)
)
}
# "每日选一个额度":今天发起过的常规档(新人档不算)
selected_regular = next(
(t.amount_cents for t in tiers if not t.is_newbie and today_counts.get(t.amount_cents, 0) > 0),
None,
)
out: list[dict] = []
for t in tiers:
if t.is_newbie:
if t.amount_cents in used_newbie:
continue # 用过即消失,不再下发
out.append({
"amount_cents": t.amount_cents, "label": t.label, "badge": t.badge,
"is_newbie": True, "available": True, "disabled_reason": None,
"remaining_today": 1,
})
continue
used = today_counts.get(t.amount_cents, 0)
if selected_regular is not None and selected_regular != t.amount_cents:
available, reason, remaining = False, "other_tier_selected", 0
elif used >= t.daily_limit:
available, reason, remaining = False, "quota_exhausted", 0
else:
available, reason, remaining = True, None, t.daily_limit - used
out.append({
"amount_cents": t.amount_cents, "label": t.label, "badge": t.badge,
"is_newbie": False, "available": available, "disabled_reason": reason,
"remaining_today": remaining,
})
return out
def create_withdraw( def create_withdraw(
db: Session, db: Session,
user_id: int, user_id: int,
@@ -660,6 +747,19 @@ def create_withdraw(
if active_order_id is not None: if active_order_id is not None:
raise WithdrawTooFrequentError raise WithdrawTooFrequentError
# 福利页档位闸(7-9):coin_cash 只能提预设档位,且该档今日可提(服务端权威口径,防绕过
# 客户端刷)。放在幂等返回/在途互斥之后:同号重试仍原样返回旧单,不被档位闸误杀。
# allow_sub_min(0.01 调试直发)保持原样放行,不受档位约束;invite_cash 本轮无档位概念不校验。
if source == "coin_cash" and not allow_sub_min:
tier_state = next(
(t for t in withdraw_tier_states(db, user_id, source) if t["amount_cents"] == amount_cents),
None,
)
if tier_state is None: # 非预设档位金额,或新人档已用过(已从列表消失)
raise InvalidWithdrawAmountError
if not tier_state["available"]:
raise WithdrawTierUnavailableError
# 账户须存在(原子扣款的 UPDATE 不会建账户) # 账户须存在(原子扣款的 UPDATE 不会建账户)
get_or_create_account(db, user_id, commit=True) get_or_create_account(db, user_id, commit=True)
+19
View File
@@ -75,6 +75,21 @@ class ExchangeResultOut(BaseModel):
# ===== 提现(现金 → 微信零钱) ===== # ===== 提现(现金 → 微信零钱) =====
class WithdrawTierOut(BaseModel):
"""提现档位(福利页 coin_cash;7-9 对齐原型)。served by rewards.WITHDRAW_TIERS_COIN_CASH。"""
amount_cents: int = Field(..., description="档位金额(分)")
label: str = Field(..., description="档位方块展示文案,如 0.1 / 10")
badge: str | None = Field(None, description="角标文案(如 新人福利);无则空")
is_newbie: bool = Field(False, description="新人档:历史一次性,用过后不再下发;免广告直提")
available: bool = Field(True, description="当前是否可提(次数/选一额度口径;余额由客户端自判)")
disabled_reason: str | None = Field(
None,
description="不可提原因:quota_exhausted(今日次数满) / other_tier_selected(今日已选其他额度)",
)
remaining_today: int = Field(0, description="今日剩余可提次数")
class WithdrawInfoOut(BaseModel): class WithdrawInfoOut(BaseModel):
min_cents: int = Field(..., description="单次最低提现(分)") min_cents: int = Field(..., description="单次最低提现(分)")
max_cents: int = Field(..., description="单次最高提现(分)") max_cents: int = Field(..., description="单次最高提现(分)")
@@ -84,6 +99,10 @@ class WithdrawInfoOut(BaseModel):
transfer_auth_enabled: bool = Field( transfer_auth_enabled: bool = Field(
False, description="是否已开启免确认到账(开启后提现免跳微信确认,直接到账)" False, description="是否已开启免确认到账(开启后提现免跳微信确认,直接到账)"
) )
tiers: list[WithdrawTierOut] = Field(
default_factory=list,
description="提现档位(source=coin_cash 下发;invite_cash 为空,客户端走旧逻辑)",
)
# ===== 免确认收款授权(用户授权免确认模式)===== # ===== 免确认收款授权(用户授权免确认模式)=====
+5 -3
View File
@@ -143,14 +143,15 @@ def test_two_accounts_withdraw_independent(client, monkeypatch) -> None:
_reject(r1.json()["out_bill_no"]) # 退回 invite_cash + 结清活跃单 _reject(r1.json()["out_bill_no"]) # 退回 invite_cash + 结清活跃单
r2 = client.post( r2 = client.post(
"/api/v1/wallet/withdraw", "/api/v1/wallet/withdraw",
json={"amount_cents": 100, "source": "coin_cash"}, # 50 分 = 0.5 元档(7-9 起 coin_cash 只能提预设档位)
json={"amount_cents": 50, "source": "coin_cash"},
headers=_auth(token), headers=_auth(token),
) )
assert r2.json()["status"] == "reviewing" assert r2.json()["status"] == "reviewing"
cash, invite_cash = _balances(client, token) cash, invite_cash = _balances(client, token)
assert invite_cash == 500 # 已退回 assert invite_cash == 500 # 已退回
assert cash == 300 # 扣了 cash 100 assert cash == 350 # 扣了 cash 50
def test_invite_me_returns_reward_stats(client) -> None: def test_invite_me_returns_reward_stats(client) -> None:
@@ -177,7 +178,8 @@ def test_withdraw_orders_source_filter(client, monkeypatch) -> None:
_reject(r1.json()["out_bill_no"]) # 结清,才能提第二笔 _reject(r1.json()["out_bill_no"]) # 结清,才能提第二笔
client.post( client.post(
"/api/v1/wallet/withdraw", "/api/v1/wallet/withdraw",
json={"amount_cents": 100, "source": "coin_cash"}, # 50 分 = 0.5 元档(7-9 起 coin_cash 只能提预设档位)
json={"amount_cents": 50, "source": "coin_cash"},
headers=_auth(token), headers=_auth(token),
) )
+2 -1
View File
@@ -139,7 +139,8 @@ def test_coin_cash_withdraw_still_reconciled(client, monkeypatch) -> None:
before = _ledger() before = _ledger()
r = client.post( r = client.post(
"/api/v1/wallet/withdraw", "/api/v1/wallet/withdraw",
json={"amount_cents": 200, "source": "coin_cash"}, # 50 分 = 0.5 元档(7-9 起 coin_cash 只能提预设档位)
json={"amount_cents": 50, "source": "coin_cash"},
headers=_auth(token), headers=_auth(token),
) )
assert r.status_code == 200, r.text assert r.status_code == 200, r.text
+186
View File
@@ -0,0 +1,186 @@
"""福利页(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"