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

Merged
marco merged 1 commits from 79ljh into main 2026-07-09 22:03:08 +08:00
7 changed files with 352 additions and 7 deletions
+14 -2
View File
@@ -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
+25
View File
@@ -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"
+101 -1
View File
@@ -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)
+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):
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 为空,客户端走旧逻辑)",
)
# ===== 免确认收款授权(用户授权免确认模式)=====
+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 + 结清活跃单
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),
)
+2 -1
View File
@@ -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
+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"