From b395648b7cc645eff7a721d48486ec35be88834a Mon Sep 17 00:00:00 2001 From: liujiahui Date: Fri, 17 Jul 2026 16:34:55 +0800 Subject: [PATCH] =?UTF-8?q?fix(withdraw):=20=E6=96=B0=E4=BA=BA=E6=A1=A3?= =?UTF-8?q?=E6=8F=90=E7=8E=B0=E6=B2=A1=E6=88=90=E5=8A=9F=E4=B9=9F=E8=A2=AB?= =?UTF-8?q?=E5=88=A4=E5=AE=9A=E5=B7=B2=E7=94=A8(=E8=A7=A3=E7=BB=91?= =?UTF-8?q?=E5=BE=AE=E4=BF=A1=E9=80=80=E5=9B=9E=E5=90=8E=200.1=20=E6=B0=B8?= =?UTF-8?q?=E4=B9=85=E9=94=81=E6=AD=BB)=20(#142)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 现象 新人提现 0.1 元,提现审核中时解绑微信 —— 现金正常退回,但「新人领取的 0.1 元」被判定为已使用:该档位从提现页永久消失,重提报 400。 ## 根因 新人档(0.1/0.3)「一次性」的判定口径只看**有没有发起过**,完全不看提现单的最终状态。`withdraw_tier_states()` 里的 `used_newbie` 查询没有任何 `status` 过滤: ```python select(WithdrawOrder.amount_cents).distinct().where( WithdrawOrder.user_id == user_id, WithdrawOrder.source == "coin_cash", WithdrawOrder.amount_cents.in_(newbie_amounts), # ← 没有 status 条件:只要「存在这条单」就算用过 ) ``` 而解绑微信时,`refund_reviewing_withdraws_on_unbind` → `_refund_withdraw` 把待审核单**退回现金 + 置 `failed`**,但记录仍在库里。两者相撞:那条 `failed` 的单照样落进 `used_newbie` → **钱退了,资格没退**。 ## 改法 判定口径从「发起过没有」改为「钱是不是真到手 / 正在路上」——只认 `reviewing`/`pending`/`success`: | status | 含义 | 钱在哪 | 新人档 | |---|---|---|---| | `reviewing` / `pending` | 待审核 / 打款在途 | 已扣,挂在单上 | 占用(防重复发起) | | `success` | 打款成功 | **进了用户微信** | 用掉,永久锁死 | | `failed` | 转账失败 / **解绑退回** | 已退回余额 | **恢复可提** | | `rejected` | 审核拒绝 | 已退回余额 | **恢复可提** | 核心即查询加一行 `WithdrawOrder.status.in_(_NEWBIE_TIER_HELD_STATUSES)`。 - 下发(`withdraw-info`)与下单校验(`create_withdraw`)共用 `withdraw_tier_states`,**改一处两处同时生效**,不会出现「显示能提、点了 400」。 - 复用现有 `status` 语义,**无新字段、无 alembic 迁移**。 - **存量受害用户上线后自动恢复**(他们那条单是 `failed`,新查询天然排除它),无需数据修复脚本;已成功提现过的(`success`)仍正确锁死,不会误恢复。 ## ⚠️ 行为变更 7-9 定的「新人档发起就算(含被拒/失败)」按本次拍板**放宽为「没成功打款就恢复」**。 常规档(0.5 / 10 / 20)的「当天名额发起就算、被拒不退」规则**一行未动**。 ## 🔴 测试按要求不进本 PR —— 但既有测试会失败,合入前需同步处理 本 PR 只含源码改动。**既有测试 `tests/test_withdraw_tiers.py::test_newbie_tiers_independent_and_once_forever` 锁死的正是本次推翻的旧口径**(它断言「被拒后 0.1 消失、重提 400」)。 已实测(main 上的原版测试 + 本 PR 的新代码): ``` FAILED tests/test_withdraw_tiers.py::test_newbie_tiers_independent_and_once_forever 1 failed, 5 passed ``` **本 PR 合入前需同步调整该测试,否则 main 会红。** 本地已备好可用的测试改动(反转该断言,另加「解绑退回后恢复」回归 + 「成功打款后永久锁死」),需要即可单独提供。 ## 待办 - 上述既有测试需同步调整(否则合入后 main 红) - 纯后端逻辑改动,**未真机验证** 🤖 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/142 Co-authored-by: liujiahui Co-committed-by: liujiahui --- app/repositories/wallet.py | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/app/repositories/wallet.py b/app/repositories/wallet.py index fe1e23e..29b7a2c 100644 --- a/app/repositories/wallet.py +++ b/app/repositories/wallet.py @@ -34,6 +34,10 @@ _WX_STATE_SUCCESS = "SUCCESS" _WX_STATE_FAILED = {"FAIL", "CANCELLED", "CLOSED"} _WX_STATE_WAIT_CONFIRM = "WAIT_USER_CONFIRM" # 用户还没在微信确认页确认 _WITHDRAW_ACTIVE_STATUSES = {"reviewing", "pending"} +# 占用新人档「一次性」资格的提现状态:进行中(reviewing/pending)或成功打款(success)。 +# 被拒/转账失败/解绑退回(rejected/failed,均已退款、钱没到手)不在此列 → 新人档恢复可提 +# (2026-07-16 修正:此前判定不看状态,解绑微信退回后 0.1 被误判已用、资格永久锁死)。 +_NEWBIE_TIER_HELD_STATUSES = {"reviewing", "pending", "success"} # 免确认收款授权状态 _WX_AUTH_ACTIVE = "TAKING_EFFECT" # 已生效,可免确认转账 _WX_AUTH_CLOSED = "CLOSED" # 已关闭(用户/商户/风控),需重新开启 @@ -622,9 +626,10 @@ def _beijing_today_start_utc() -> datetime: 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):账号历史一次性——只要发起过(**任意状态**,含被拒/失败,"发起就算") - 即视为已用,直接**从返回列表消失**;两档各自独立互不影响,不参与"每日选一个额度"互斥。 + 规则(2026-07-09 拍板,7-9提现ui对齐;新人档判定 2026-07-16 修正): + - 新人档(0.1/0.3):账号历史一次性——进行中(reviewing/pending)或成功打款(success)即视为 + 已用,直接**从返回列表消失**;被拒/转账失败/解绑退回(均已退款、钱没到手)则恢复可提,不永久 + 占用资格。两档各自独立互不影响,不参与"每日选一个额度"互斥。 - 常规档(0.5×3 / 10×1 / 20×1):按北京日计次,"发起就算占用"(当天创建的单不论最终状态 都计入,被拒/失败不退当天名额);三档每天只能选一个,选定后其余两档当天 other_tier_selected。 - invite_cash 本轮无档位概念 → 返回空列表(邀请页客户端仍用本地写死档位,行为不变)。 @@ -635,7 +640,7 @@ def withdraw_tier_states(db: Session, user_id: int, source: str = "coin_cash") - 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) @@ -644,6 +649,7 @@ def withdraw_tier_states(db: Session, user_id: int, source: str = "coin_cash") - WithdrawOrder.user_id == user_id, WithdrawOrder.source == "coin_cash", WithdrawOrder.amount_cents.in_(newbie_amounts), + WithdrawOrder.status.in_(_NEWBIE_TIER_HELD_STATUSES), ) ).scalars() ) if newbie_amounts else set()