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>
152 lines
6.0 KiB
Python
152 lines
6.0 KiB
Python
"""提现现金账本校验(admin ledger-check)测试:两本物理隔离的账各自对账。
|
|
|
|
历史盲区:`withdraw_ledger_check` 曾拿全部提现单去和**普通现金流水**(cash_transaction)比对,
|
|
而 source=invite_cash 的提现单流水其实在 invite_cash_transaction 表,导致每笔邀请提现单都被
|
|
误报「缺扣款/缺退款流水」。这里用真实提现 API 造单 + before/after 差值断言锁定修复:
|
|
1) 邀请提现单不再污染普通现金账的缺流水计数;
|
|
2) 邀请账户已被纳入对账(能抓到它自己的缺流水);
|
|
3) 普通现金账的原有对账未被改坏。
|
|
|
|
conftest 的库是 session 级共享、测试间不清,故一律用 before/after 差值,只反映本用例造的数据。
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
from sqlalchemy import delete, select
|
|
|
|
from app.admin.repositories.queries import withdraw_ledger_check
|
|
from app.db.session import SessionLocal
|
|
from app.models.user import User
|
|
from app.models.wallet import CoinAccount, InviteCashTransaction
|
|
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 _patch_userinfo(monkeypatch, openid: str) -> None:
|
|
monkeypatch.setattr(
|
|
"app.integrations.wxpay.code_to_userinfo",
|
|
lambda code: {"openid": openid, "nickname": None, "avatar_url": None, "raw": {}},
|
|
)
|
|
|
|
|
|
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 _reject(bill: str, reason: str = "测试拒绝") -> None:
|
|
db = SessionLocal()
|
|
try:
|
|
crud_wallet.reject_withdraw(db, bill, reason)
|
|
finally:
|
|
db.close()
|
|
|
|
|
|
def _ledger() -> dict:
|
|
db = SessionLocal()
|
|
try:
|
|
return withdraw_ledger_check(db)
|
|
finally:
|
|
db.close()
|
|
|
|
|
|
def test_rejected_invite_withdraw_not_flagged_missing(client, monkeypatch) -> None:
|
|
"""核心回归:一笔被拒绝的 invite_cash 提现单,扣款/退款流水都在 invite_cash_transaction,
|
|
不应让普通现金账的缺扣款/缺退款计数增加(修复前每笔会各 +1)。"""
|
|
before = _ledger()
|
|
|
|
_patch_userinfo(monkeypatch, "openid_lc_1")
|
|
token = _login(client, "13800005001")
|
|
_seed_balances(client, token, "13800005001", cash=0, 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
|
|
_reject(r.json()["out_bill_no"]) # rejected + 退款流水落 invite_cash_transaction
|
|
|
|
after = _ledger()
|
|
|
|
# 普通现金账不该因这笔 invite 单产生缺流水(修复前会各 +1 → 就是页面上看到的误报)
|
|
assert after["missing_withdraw_txn_count"] == before["missing_withdraw_txn_count"]
|
|
assert after["missing_refund_txn_count"] == before["missing_refund_txn_count"]
|
|
# 邀请账扣款 + 退款流水齐全,邀请账自身也不该缺
|
|
assert after["invite_missing_withdraw_txn_count"] == before["invite_missing_withdraw_txn_count"]
|
|
assert after["invite_missing_refund_txn_count"] == before["invite_missing_refund_txn_count"]
|
|
|
|
|
|
def test_invite_ledger_detects_missing_withdraw_txn(client, monkeypatch) -> None:
|
|
"""删掉一笔 invite 提现单的扣款流水 → 邀请账缺扣款计数 +1、ok=False,
|
|
证明邀请账户已真正纳入对账(修复前邀请账完全不校验、永远报不出问题)。"""
|
|
_patch_userinfo(monkeypatch, "openid_lc_2")
|
|
token = _login(client, "13800005002")
|
|
_seed_balances(client, token, "13800005002", cash=0, 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),
|
|
)
|
|
bill = r.json()["out_bill_no"]
|
|
|
|
before = _ledger()
|
|
db = SessionLocal()
|
|
try:
|
|
db.execute(
|
|
delete(InviteCashTransaction).where(
|
|
InviteCashTransaction.ref_id == bill,
|
|
InviteCashTransaction.biz_type == "invite_withdraw",
|
|
)
|
|
)
|
|
db.commit()
|
|
finally:
|
|
db.close()
|
|
after = _ledger()
|
|
|
|
assert (
|
|
after["invite_missing_withdraw_txn_count"]
|
|
== before["invite_missing_withdraw_txn_count"] + 1
|
|
)
|
|
assert after["ok"] is False
|
|
|
|
|
|
def test_coin_cash_withdraw_still_reconciled(client, monkeypatch) -> None:
|
|
"""普通现金 coin_cash 提现单齐全时不新增缺流水(确保分账改造没弄坏原有普通现金对账)。"""
|
|
_patch_userinfo(monkeypatch, "openid_lc_3")
|
|
token = _login(client, "13800005003")
|
|
_seed_balances(client, token, "13800005003", cash=500, invite_cash=0)
|
|
client.post("/api/v1/wallet/bind-wechat", json={"code": "c"}, headers=_auth(token))
|
|
|
|
before = _ledger()
|
|
r = client.post(
|
|
"/api/v1/wallet/withdraw",
|
|
# 50 分 = 0.5 元档(7-9 起 coin_cash 只能提预设档位)
|
|
json={"amount_cents": 50, "source": "coin_cash"},
|
|
headers=_auth(token),
|
|
)
|
|
assert r.status_code == 200, r.text
|
|
after = _ledger()
|
|
|
|
# 普通现金提现扣款流水随单写入 cash_transaction,缺扣款计数不变;邀请账更不受影响
|
|
assert after["missing_withdraw_txn_count"] == before["missing_withdraw_txn_count"]
|
|
assert after["invite_missing_withdraw_txn_count"] == before["invite_missing_withdraw_txn_count"]
|