fix(withdraw): 提现账本校验按 source 分账,邀请奖励金提现纳入对账 (#121)
线上后台「提现审核」页现金账本报红(缺扣款/缺退款流水),根因是 withdraw_ledger_check 用全部提现单去比对普通现金流水(cash_transaction), 而 source=invite_cash 的提现,其扣款/退款流水写在独立的 invite_cash_transaction 表,于是每笔邀请提现单都在普通现金流水里找不到、被误报为「缺流水」。 改动: - 抽出 _check_withdraw_ledger_side 复用单账核对逻辑;withdraw_ledger_check 改为按 order.source 分两本账各自对账: coin_cash ↔ cash_transaction(withdraw/withdraw_refund), invite_cash ↔ invite_cash_transaction(invite_withdraw/invite_withdraw_refund)。 - 邀请奖励金账户的余额差额也纳入校验(此前完全未对账)。 - 纯只读校验,不改任何资金/流水写入;不掩盖真实缺流水 (coin_cash 单若真缺流水仍照报)。 - WithdrawLedgerCheckOut 新增 7 个 invite_* 字段(默认 0,向后兼容)。 - 补 tests/test_withdraw_ledger_check.py(此前无 ledger-check 测试): 覆盖邀请提现不再误报、邀请账缺流水能被抓、普通现金账未回归。 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: zzhyyyyy <2685922758@qq.com> Reviewed-on: #121 Co-authored-by: zhuzihao <zhuzihao@wonderable.ai> Co-committed-by: zhuzihao <zhuzihao@wonderable.ai>
This commit was merged in pull request #121.
This commit is contained in:
@@ -23,7 +23,13 @@ from app.models.feedback import Feedback
|
|||||||
from app.models.onboarding import OnboardingCompletion
|
from app.models.onboarding import OnboardingCompletion
|
||||||
from app.models.price_report import PriceReport
|
from app.models.price_report import PriceReport
|
||||||
from app.models.user import User
|
from app.models.user import User
|
||||||
from app.models.wallet import CashTransaction, CoinAccount, CoinTransaction, WithdrawOrder
|
from app.models.wallet import (
|
||||||
|
CashTransaction,
|
||||||
|
CoinAccount,
|
||||||
|
CoinTransaction,
|
||||||
|
InviteCashTransaction,
|
||||||
|
WithdrawOrder,
|
||||||
|
)
|
||||||
|
|
||||||
# 折算成可提现现金时,非广告金币来源的排除集(广告单独统计、人工调整不算"赚取")
|
# 折算成可提现现金时,非广告金币来源的排除集(广告单独统计、人工调整不算"赚取")
|
||||||
_NON_TASK_BIZ_TYPES = ("reward_video", "feed_ad_reward", "admin_grant", "admin_deduct")
|
_NON_TASK_BIZ_TYPES = ("reward_video", "feed_ad_reward", "admin_grant", "admin_deduct")
|
||||||
@@ -734,26 +740,19 @@ def withdraw_risk_flags(
|
|||||||
return flags, score
|
return flags, score
|
||||||
|
|
||||||
|
|
||||||
def withdraw_ledger_check(db: Session) -> dict:
|
def _check_withdraw_ledger_side(
|
||||||
cash_balance_total = int(
|
orders: list[WithdrawOrder], txns: list, *, withdraw_biz: str, refund_biz: str
|
||||||
db.execute(select(func.coalesce(func.sum(CoinAccount.cash_balance_cents), 0))).scalar_one()
|
) -> dict:
|
||||||
)
|
"""对某一本账(普通现金 / 邀请奖励金)做提现单 ↔ 流水的交叉校验。
|
||||||
cash_txn_total = int(
|
|
||||||
db.execute(select(func.coalesce(func.sum(CashTransaction.amount_cents), 0))).scalar_one()
|
|
||||||
)
|
|
||||||
|
|
||||||
orders = list(db.execute(select(WithdrawOrder)).scalars().all())
|
orders 已按 source 过滤到本账;txns 是本账流水表里 withdraw_biz/refund_biz 两类流水。
|
||||||
cash_txns = list(
|
规则:每单发起应有一条扣款流水(ref_id=out_bill_no);失败/拒绝单应有且仅一条退款流水;
|
||||||
db.execute(
|
非退款终态不应出现退款流水。四个计数全为 0 即本账自洽。
|
||||||
select(CashTransaction).where(
|
"""
|
||||||
CashTransaction.biz_type.in_(("withdraw", "withdraw_refund"))
|
withdraw_refs = {txn.ref_id for txn in txns if txn.biz_type == withdraw_biz}
|
||||||
)
|
|
||||||
).scalars().all()
|
|
||||||
)
|
|
||||||
withdraw_refs = {txn.ref_id for txn in cash_txns if txn.biz_type == "withdraw"}
|
|
||||||
refund_counts: dict[str, int] = {}
|
refund_counts: dict[str, int] = {}
|
||||||
for txn in cash_txns:
|
for txn in txns:
|
||||||
if txn.biz_type == "withdraw_refund" and txn.ref_id:
|
if txn.biz_type == refund_biz and txn.ref_id:
|
||||||
refund_counts[txn.ref_id] = refund_counts.get(txn.ref_id, 0) + 1
|
refund_counts[txn.ref_id] = refund_counts.get(txn.ref_id, 0) + 1
|
||||||
|
|
||||||
missing_withdraw = 0
|
missing_withdraw = 0
|
||||||
@@ -768,24 +767,95 @@ def withdraw_ledger_check(db: Session) -> dict:
|
|||||||
if has_refund and order.status not in {"failed", "rejected"}:
|
if has_refund and order.status not in {"failed", "rejected"}:
|
||||||
refund_on_non_terminal += 1
|
refund_on_non_terminal += 1
|
||||||
|
|
||||||
duplicate_refund = sum(1 for count in refund_counts.values() if count > 1)
|
return {
|
||||||
diff = cash_balance_total - cash_txn_total
|
"missing_withdraw": missing_withdraw,
|
||||||
|
"missing_refund": missing_refund,
|
||||||
|
"duplicate_refund": sum(1 for count in refund_counts.values() if count > 1),
|
||||||
|
"refund_on_non_terminal": refund_on_non_terminal,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def withdraw_ledger_check(db: Session) -> dict:
|
||||||
|
"""现金账本校验:两本物理隔离的账各自对账(产品红线:coin_cash / invite_cash 不串)。
|
||||||
|
|
||||||
|
普通现金:CoinAccount.cash_balance_cents ↔ cash_transaction(withdraw/withdraw_refund);
|
||||||
|
邀请奖励金:CoinAccount.invite_cash_balance_cents ↔ invite_cash_transaction
|
||||||
|
(invite_withdraw/invite_withdraw_refund)。
|
||||||
|
提现单按 source 分流到对应账核对——邀请提现的流水写在 invite_cash_transaction 表,
|
||||||
|
绝不能拿去和普通现金流水比(否则每笔邀请提现单都会被误报「缺扣款/缺退款流水」)。
|
||||||
|
分流口径与 create_withdraw 一致:仅 source==invite_cash 走邀请账,其余(含历史空值)归普通现金。
|
||||||
|
"""
|
||||||
|
orders = list(db.execute(select(WithdrawOrder)).scalars().all())
|
||||||
|
coin_orders = [o for o in orders if o.source != "invite_cash"]
|
||||||
|
invite_orders = [o for o in orders if o.source == "invite_cash"]
|
||||||
|
|
||||||
|
# —— 普通现金账(coin_cash) ——
|
||||||
|
cash_balance_total = int(
|
||||||
|
db.execute(select(func.coalesce(func.sum(CoinAccount.cash_balance_cents), 0))).scalar_one()
|
||||||
|
)
|
||||||
|
cash_txn_total = int(
|
||||||
|
db.execute(select(func.coalesce(func.sum(CashTransaction.amount_cents), 0))).scalar_one()
|
||||||
|
)
|
||||||
|
cash_txns = list(
|
||||||
|
db.execute(
|
||||||
|
select(CashTransaction).where(
|
||||||
|
CashTransaction.biz_type.in_(("withdraw", "withdraw_refund"))
|
||||||
|
)
|
||||||
|
).scalars().all()
|
||||||
|
)
|
||||||
|
coin = _check_withdraw_ledger_side(
|
||||||
|
coin_orders, cash_txns, withdraw_biz="withdraw", refund_biz="withdraw_refund"
|
||||||
|
)
|
||||||
|
cash_diff = cash_balance_total - cash_txn_total
|
||||||
|
|
||||||
|
# —— 邀请奖励金账(invite_cash,独立账户 + 独立流水表) ——
|
||||||
|
invite_balance_total = int(
|
||||||
|
db.execute(
|
||||||
|
select(func.coalesce(func.sum(CoinAccount.invite_cash_balance_cents), 0))
|
||||||
|
).scalar_one()
|
||||||
|
)
|
||||||
|
invite_txn_total = int(
|
||||||
|
db.execute(
|
||||||
|
select(func.coalesce(func.sum(InviteCashTransaction.amount_cents), 0))
|
||||||
|
).scalar_one()
|
||||||
|
)
|
||||||
|
invite_txns = list(
|
||||||
|
db.execute(
|
||||||
|
select(InviteCashTransaction).where(
|
||||||
|
InviteCashTransaction.biz_type.in_(("invite_withdraw", "invite_withdraw_refund"))
|
||||||
|
)
|
||||||
|
).scalars().all()
|
||||||
|
)
|
||||||
|
invite = _check_withdraw_ledger_side(
|
||||||
|
invite_orders, invite_txns,
|
||||||
|
withdraw_biz="invite_withdraw", refund_biz="invite_withdraw_refund",
|
||||||
|
)
|
||||||
|
invite_diff = invite_balance_total - invite_txn_total
|
||||||
|
|
||||||
ok = (
|
ok = (
|
||||||
diff == 0
|
cash_diff == 0
|
||||||
and missing_withdraw == 0
|
and invite_diff == 0
|
||||||
and missing_refund == 0
|
and all(v == 0 for v in coin.values())
|
||||||
and duplicate_refund == 0
|
and all(v == 0 for v in invite.values())
|
||||||
and refund_on_non_terminal == 0
|
|
||||||
)
|
)
|
||||||
return {
|
return {
|
||||||
"ok": ok,
|
"ok": ok,
|
||||||
|
# 普通现金账(coin_cash:金币兑换的现金)
|
||||||
"cash_balance_total_cents": cash_balance_total,
|
"cash_balance_total_cents": cash_balance_total,
|
||||||
"cash_transaction_total_cents": cash_txn_total,
|
"cash_transaction_total_cents": cash_txn_total,
|
||||||
"balance_diff_cents": diff,
|
"balance_diff_cents": cash_diff,
|
||||||
"missing_withdraw_txn_count": missing_withdraw,
|
"missing_withdraw_txn_count": coin["missing_withdraw"],
|
||||||
"missing_refund_txn_count": missing_refund,
|
"missing_refund_txn_count": coin["missing_refund"],
|
||||||
"duplicate_refund_txn_count": duplicate_refund,
|
"duplicate_refund_txn_count": coin["duplicate_refund"],
|
||||||
"refund_txn_on_non_terminal_count": refund_on_non_terminal,
|
"refund_txn_on_non_terminal_count": coin["refund_on_non_terminal"],
|
||||||
|
# 邀请奖励金账(invite_cash:与普通现金物理隔离,各自对账)
|
||||||
|
"invite_cash_balance_total_cents": invite_balance_total,
|
||||||
|
"invite_cash_transaction_total_cents": invite_txn_total,
|
||||||
|
"invite_balance_diff_cents": invite_diff,
|
||||||
|
"invite_missing_withdraw_txn_count": invite["missing_withdraw"],
|
||||||
|
"invite_missing_refund_txn_count": invite["missing_refund"],
|
||||||
|
"invite_duplicate_refund_txn_count": invite["duplicate_refund"],
|
||||||
|
"invite_refund_txn_on_non_terminal_count": invite["refund_on_non_terminal"],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -130,6 +130,7 @@ class WithdrawBulkResult(BaseModel):
|
|||||||
|
|
||||||
class WithdrawLedgerCheckOut(BaseModel):
|
class WithdrawLedgerCheckOut(BaseModel):
|
||||||
ok: bool
|
ok: bool
|
||||||
|
# 普通现金账(coin_cash:金币兑换的现金)
|
||||||
cash_balance_total_cents: int
|
cash_balance_total_cents: int
|
||||||
cash_transaction_total_cents: int
|
cash_transaction_total_cents: int
|
||||||
balance_diff_cents: int
|
balance_diff_cents: int
|
||||||
@@ -137,6 +138,14 @@ class WithdrawLedgerCheckOut(BaseModel):
|
|||||||
missing_refund_txn_count: int
|
missing_refund_txn_count: int
|
||||||
duplicate_refund_txn_count: int
|
duplicate_refund_txn_count: int
|
||||||
refund_txn_on_non_terminal_count: int
|
refund_txn_on_non_terminal_count: int
|
||||||
|
# 邀请奖励金账(invite_cash:与普通现金物理隔离,各自对账)。默认 0 向后兼容。
|
||||||
|
invite_cash_balance_total_cents: int = 0
|
||||||
|
invite_cash_transaction_total_cents: int = 0
|
||||||
|
invite_balance_diff_cents: int = 0
|
||||||
|
invite_missing_withdraw_txn_count: int = 0
|
||||||
|
invite_missing_refund_txn_count: int = 0
|
||||||
|
invite_duplicate_refund_txn_count: int = 0
|
||||||
|
invite_refund_txn_on_non_terminal_count: int = 0
|
||||||
|
|
||||||
|
|
||||||
class WxpayHealthCheckOut(BaseModel):
|
class WxpayHealthCheckOut(BaseModel):
|
||||||
|
|||||||
@@ -0,0 +1,150 @@
|
|||||||
|
"""提现现金账本校验(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",
|
||||||
|
json={"amount_cents": 200, "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"]
|
||||||
Reference in New Issue
Block a user