diff --git a/app/admin/repositories/queries.py b/app/admin/repositories/queries.py index ac2be15..41a30da 100644 --- a/app/admin/repositories/queries.py +++ b/app/admin/repositories/queries.py @@ -23,7 +23,13 @@ from app.models.feedback import Feedback from app.models.onboarding import OnboardingCompletion from app.models.price_report import PriceReport 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") @@ -734,26 +740,19 @@ def withdraw_risk_flags( return flags, score -def withdraw_ledger_check(db: Session) -> dict: - 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() - ) +def _check_withdraw_ledger_side( + orders: list[WithdrawOrder], txns: list, *, withdraw_biz: str, refund_biz: str +) -> dict: + """对某一本账(普通现金 / 邀请奖励金)做提现单 ↔ 流水的交叉校验。 - orders = list(db.execute(select(WithdrawOrder)).scalars().all()) - cash_txns = list( - db.execute( - select(CashTransaction).where( - CashTransaction.biz_type.in_(("withdraw", "withdraw_refund")) - ) - ).scalars().all() - ) - withdraw_refs = {txn.ref_id for txn in cash_txns if txn.biz_type == "withdraw"} + orders 已按 source 过滤到本账;txns 是本账流水表里 withdraw_biz/refund_biz 两类流水。 + 规则:每单发起应有一条扣款流水(ref_id=out_bill_no);失败/拒绝单应有且仅一条退款流水; + 非退款终态不应出现退款流水。四个计数全为 0 即本账自洽。 + """ + withdraw_refs = {txn.ref_id for txn in txns if txn.biz_type == withdraw_biz} refund_counts: dict[str, int] = {} - for txn in cash_txns: - if txn.biz_type == "withdraw_refund" and txn.ref_id: + for txn in txns: + if txn.biz_type == refund_biz and txn.ref_id: refund_counts[txn.ref_id] = refund_counts.get(txn.ref_id, 0) + 1 missing_withdraw = 0 @@ -768,24 +767,95 @@ def withdraw_ledger_check(db: Session) -> dict: if has_refund and order.status not in {"failed", "rejected"}: refund_on_non_terminal += 1 - duplicate_refund = sum(1 for count in refund_counts.values() if count > 1) - diff = cash_balance_total - cash_txn_total + return { + "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 = ( - diff == 0 - and missing_withdraw == 0 - and missing_refund == 0 - and duplicate_refund == 0 - and refund_on_non_terminal == 0 + cash_diff == 0 + and invite_diff == 0 + and all(v == 0 for v in coin.values()) + and all(v == 0 for v in invite.values()) ) return { "ok": ok, + # 普通现金账(coin_cash:金币兑换的现金) "cash_balance_total_cents": cash_balance_total, "cash_transaction_total_cents": cash_txn_total, - "balance_diff_cents": diff, - "missing_withdraw_txn_count": missing_withdraw, - "missing_refund_txn_count": missing_refund, - "duplicate_refund_txn_count": duplicate_refund, - "refund_txn_on_non_terminal_count": refund_on_non_terminal, + "balance_diff_cents": cash_diff, + "missing_withdraw_txn_count": coin["missing_withdraw"], + "missing_refund_txn_count": coin["missing_refund"], + "duplicate_refund_txn_count": coin["duplicate_refund"], + "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"], } diff --git a/app/admin/schemas/wallet.py b/app/admin/schemas/wallet.py index 06b4c7e..7f61d7b 100644 --- a/app/admin/schemas/wallet.py +++ b/app/admin/schemas/wallet.py @@ -130,6 +130,7 @@ class WithdrawBulkResult(BaseModel): class WithdrawLedgerCheckOut(BaseModel): ok: bool + # 普通现金账(coin_cash:金币兑换的现金) cash_balance_total_cents: int cash_transaction_total_cents: int balance_diff_cents: int @@ -137,6 +138,14 @@ class WithdrawLedgerCheckOut(BaseModel): missing_refund_txn_count: int duplicate_refund_txn_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): diff --git a/tests/test_withdraw_ledger_check.py b/tests/test_withdraw_ledger_check.py new file mode 100644 index 0000000..f74206a --- /dev/null +++ b/tests/test_withdraw_ledger_check.py @@ -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"]