From 3630fb7b3a872b0117b762ebfc69cba6006c8980 Mon Sep 17 00:00:00 2001 From: liujiahui Date: Thu, 9 Jul 2026 22:03:07 +0800 Subject: [PATCH] =?UTF-8?q?=E6=8F=90=E7=8E=B0=E6=A1=A3=E4=BD=8D=E5=90=8E?= =?UTF-8?q?=E7=AB=AF=E6=9D=83=E5=A8=81=E5=8C=96:tiers=E4=B8=8B=E5=8F=91+?= =?UTF-8?q?=E6=AF=8F=E6=97=A5=E9=99=90=E6=AC=A1/=E9=80=89=E4=B8=80?= =?UTF-8?q?=E9=A2=9D=E5=BA=A6+=E4=B8=8B=E5=8D=95=E6=A1=A3=E4=BD=8D?= =?UTF-8?q?=E9=97=B8(7-9)=20(#129)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 提现档位后端权威化(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 Reviewed-on: https://gitea.shaguabijia.com/WonderableAI/shaguabijia-app-server/pulls/129 Co-authored-by: liujiahui Co-committed-by: liujiahui --- app/api/v1/wallet.py | 16 ++- app/core/rewards.py | 25 ++++ app/repositories/wallet.py | 102 ++++++++++++++- app/schemas/welfare.py | 19 +++ tests/test_invite_cash_withdraw.py | 8 +- tests/test_withdraw_ledger_check.py | 3 +- tests/test_withdraw_tiers.py | 186 ++++++++++++++++++++++++++++ 7 files changed, 352 insertions(+), 7 deletions(-) create mode 100644 tests/test_withdraw_tiers.py diff --git a/app/api/v1/wallet.py b/app/api/v1/wallet.py index 29a0407..454ff7c 100644 --- a/app/api/v1/wallet.py +++ b/app/api/v1/wallet.py @@ -43,6 +43,7 @@ from app.schemas.welfare import ( WithdrawRequest, WithdrawResultOut, WithdrawStatusOut, + WithdrawTierOut, ) logger = logging.getLogger("shagua.wallet") @@ -173,8 +174,15 @@ def unbind_wechat( return UnbindWechatResultOut(bound=False) -@router.get("/withdraw-info", response_model=WithdrawInfoOut, summary="提现额度/绑定状态/免确认开关") -def withdraw_info(user: CurrentUser, db: DbSession) -> WithdrawInfoOut: +@router.get("/withdraw-info", response_model=WithdrawInfoOut, summary="提现额度/绑定状态/免确认开关/档位") +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) # 顺带同步免确认授权状态(捕获首单确认后已生效的授权 pending→active),让开关展示实时 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_avatar_url=u.wechat_avatar_url if u else None, 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, detail="已有提现申请正在审核或打款中,请处理完成后再申请", ) 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: raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="现金余额不足") from e diff --git a/app/core/rewards.py b/app/core/rewards.py index d931db4..eccdc25 100644 --- a/app/core/rewards.py +++ b/app/core/rewards.py @@ -6,6 +6,7 @@ from __future__ import annotations from datetime import date, datetime, timedelta, timezone +from typing import NamedTuple # 业务时区:签到的"今天"按北京时间算,不能用 UTC。 # 否则 UTC+8 的凌晨 0~8 点会被算成 UTC 的前一天,导致签到日期错位。 @@ -52,6 +53,30 @@ WITHDRAW_MIN_CENTS: int = 10 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 去重)===== TASK_ENABLE_NOTIFICATION = "enable_notification" diff --git a/app/repositories/wallet.py b/app/repositories/wallet.py index 345cda7..fe1e23e 100644 --- a/app/repositories/wallet.py +++ b/app/repositories/wallet.py @@ -11,7 +11,7 @@ import unicodedata import uuid from datetime import datetime, timedelta, timezone -from sqlalchemy import select, update +from sqlalchemy import func, select, update from sqlalchemy.exc import IntegrityError from sqlalchemy.orm import Session @@ -67,6 +67,10 @@ class WithdrawTooFrequentError(Exception): """提现申请过于频繁,或已有未完成提现单。""" +class WithdrawTierUnavailableError(Exception): + """该档位今日不可提:次数已满,或今天已选了其他额度(7-9 福利页档位规则)。""" + + class WithdrawTransferError(Exception): """调用微信转账失败(已退回余额)。""" @@ -605,6 +609,89 @@ def _settle_after_ambiguous(db: Session, order: WithdrawOrder, reason: str) -> N 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( db: Session, user_id: int, @@ -660,6 +747,19 @@ def create_withdraw( if active_order_id is not None: 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 不会建账户) get_or_create_account(db, user_id, commit=True) diff --git a/app/schemas/welfare.py b/app/schemas/welfare.py index df13be3..d6ba63a 100644 --- a/app/schemas/welfare.py +++ b/app/schemas/welfare.py @@ -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): min_cents: int = Field(..., description="单次最低提现(分)") max_cents: int = Field(..., description="单次最高提现(分)") @@ -84,6 +99,10 @@ class WithdrawInfoOut(BaseModel): transfer_auth_enabled: bool = Field( False, description="是否已开启免确认到账(开启后提现免跳微信确认,直接到账)" ) + tiers: list[WithdrawTierOut] = Field( + default_factory=list, + description="提现档位(source=coin_cash 下发;invite_cash 为空,客户端走旧逻辑)", + ) # ===== 免确认收款授权(用户授权免确认模式)===== diff --git a/tests/test_invite_cash_withdraw.py b/tests/test_invite_cash_withdraw.py index 26a2d7d..5b83110 100644 --- a/tests/test_invite_cash_withdraw.py +++ b/tests/test_invite_cash_withdraw.py @@ -143,14 +143,15 @@ def test_two_accounts_withdraw_independent(client, monkeypatch) -> None: _reject(r1.json()["out_bill_no"]) # 退回 invite_cash + 结清活跃单 r2 = client.post( "/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), ) assert r2.json()["status"] == "reviewing" cash, invite_cash = _balances(client, token) assert invite_cash == 500 # 已退回 - assert cash == 300 # 扣了 cash 100 + assert cash == 350 # 扣了 cash 50 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"]) # 结清,才能提第二笔 client.post( "/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), ) diff --git a/tests/test_withdraw_ledger_check.py b/tests/test_withdraw_ledger_check.py index f74206a..a9054d9 100644 --- a/tests/test_withdraw_ledger_check.py +++ b/tests/test_withdraw_ledger_check.py @@ -139,7 +139,8 @@ def test_coin_cash_withdraw_still_reconciled(client, monkeypatch) -> None: before = _ledger() r = client.post( "/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), ) assert r.status_code == 200, r.text diff --git a/tests/test_withdraw_tiers.py b/tests/test_withdraw_tiers.py new file mode 100644 index 0000000..73e8de9 --- /dev/null +++ b/tests/test_withdraw_tiers.py @@ -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"