e11f506e1e
## 摘要 取消「同一用户同时仅一笔在途提现」限制:已有 reviewing/pending 提现单时可继续发起新申请。 - 删应用层在途单检查(WithdrawTooFrequentError)+ 删 DB 分区唯一索引 ux_withdraw_order_user_active(含迁移) - 清理失效死代码;IntegrityError 兜底瘦身为仅处理 out_bill_no 幂等 - 既有约束不变:建单先扣款(防超提)、coin_cash 每日档位次数、out_bill_no 幂等、解绑退款、admin 审核/对账均按单号维度 ## 测试 - 新增:多笔在途并存放行(coin_cash & invite_cash)、第二笔仅受余额约束(409 现金余额不足) - 迁移 upgrade→downgrade→upgrade 回环验证 - 提现域全绿(test_withdraw / test_invite_cash_withdraw / test_withdraw_ledger_check) ## 注意 - 客户端:每次提交需生成新的 out_bill_no;未开免确认时多笔 pending 各返回一个微信确认页,App 需能处理多笔待确认 - 无并发硬上限(产品拍板):coin_cash 由每日档位次数天然封顶,invite_cash 仅受余额约束 --------- Co-authored-by: guke <guke@autohome.com.cn> Reviewed-on: #173
8.5 KiB
8.5 KiB
允许在途提现时继续提交新的提现申请 设计
- 日期:2026-07-24
- 状态:Draft — 待评审
- 所属:app-server(
app/),含一处 Alembic 迁移;另有一条 Android/客户端注意点(非本次后端工作) - 一句话:取消「同一用户同一时刻只能有一笔在途提现」的限制 —— 已有
reviewing(待审核)或pending(打款在途)提现单时,允许再次发起新的提现申请。
1. 背景与目标
现状:用户发起提现后,在管理员审核通过并打款完成之前(reviewing / pending),无法再发起第二笔提现,会收到 409「已有提现申请正在审核或打款中,请处理完成后再申请」。人工审核有延迟时,用户被卡住、体验差。
目标:放开该限制,已有在途提现单时仍可继续提交新的提现申请。
非目标(本期不做)
- 不改「先扣款」模型(提现建单即原子扣现金,天然防超提)。
- 不改
coin_cash的每日档位次数限制(仍是独立的限额闸)。 - 不加任何新的并发/次数硬上限(见 §3 决策)。
- 不改 admin 审核/打款/对账逻辑(其全部按单号维度操作,天然支持一人多单)。
2. 需求
| # | 需求 | 落地 |
|---|---|---|
| R1 | 已有 reviewing 或 pending 单时可再次发起提现 |
删除应用层在途单互斥检查 + 删除 DB 分区唯一索引(§4) |
| R2 | 不引入新的并发上限 | 仅靠既有约束:先扣款余额 + coin_cash 每日档位次数(§5) |
| R3 | 既有幂等/限额/退款/对账行为不回退 | 保留 out_bill_no 幂等、档位闸、解绑退款、对账(§5) |
3. 决策记录(来自评审问答)
| 决策点 | 结论 | 理由 |
|---|---|---|
| 放行范围 | reviewing 与 pending 两种在途状态都放行,彻底取消「同时仅一单」 |
需求即"存在在途提现时可继续提交";人工审核延迟不应卡住用户 |
| 并发上限 | 不加硬上限 | 现金先扣款→每笔各需自己的余额,不会超提;coin_cash 每日档位次数已是天然上限;invite_cash 仅受余额约束,可接受 |
| 实现方式 | 彻底移除(删检查 + 删索引 + 清理死代码),非配置开关 | 决策明确且无回滚诉求,YAGNI;避免留半死代码与多余配置项 |
| 已失效异常/文案 | 删除 WithdrawTooFrequentError 及端点的 409 处理 |
全仓仅这 4 处引用(§4),移除后无残留 |
已知取舍(可接受)
- 多笔
pending× 未开免确认:每笔pending会各自返回一个微信确认页package_info。用户若未开启免确认,可能同时存在多笔待确认。属客户端交互问题(§6),后端行为正确;开启免确认后直接到账、无此问题。 - 回滚风险:迁移
downgrade会重建分区唯一索引;若届时某用户已有 ≥2 张在途单,重建会失败 —— 属预期的回滚代价,在迁移里注释说明。
4. 现状机制与改动点
「同一时刻仅一笔在途提现」由两道闸共同强制,均以 status IN ('reviewing','pending') 为口径:
- 应用层 —
app/repositories/wallet.py:758-765create_withdraw内:查在途单 →raise WithdrawTooFrequentError→ 端点转 409。 - 数据库层 —
app/models/wallet.py:99-107的分区唯一索引ux_withdraw_order_user_active(user_id WHERE status IN ('reviewing','pending'))。这是硬约束,create_withdraw的IntegrityError兜底(:814-833)即捕获它。
提现单状态机:
reviewing(待审核,建单即扣现金)→pending(审核通过、打款在途)→success/failed;或reviewing→rejected(已退款)。
改动清单(4 处代码 + 1 个迁移)
① app/repositories/wallet.py · create_withdraw
- 删除在途单互斥检查(
:758-765):select WithdrawOrder.id WHERE status IN (_WITHDRAW_ACTIVE_STATUSES)→raise WithdrawTooFrequentError。 IntegrityError兜底(:814-833):保留out_bill_no幂等重试分支(:816-824);删除其中的在途单二次检查分支(:825-832)(索引移除后不会再因在途单触发IntegrityError);末尾其余情况原样raise(仅剩out_bill_no唯一冲突等,正常不该出现)。- 删除模块常量
_WITHDRAW_ACTIVE_STATUSES(:38)与异常类WithdrawTooFrequentError(:72-73)。_NEWBIE_TIER_HELD_STATUSES(档位资格判定,含success)与本改动无关,保留。
② app/models/wallet.py · WithdrawOrder
- 删除
__table_args__中的分区唯一索引ux_withdraw_order_user_active(:99-107)。该表__table_args__仅此一项,整块移除。
③ app/api/v1/wallet.py · withdraw 端点
- 删除
except crud_wallet.WithdrawTooFrequentError(:225-229)这段已失效的 409 处理。
④ 新增 Alembic 迁移 alembic/versions/<...>_drop_withdraw_active_unique_index.py
down_revision= 当前 head(实现时确定)。upgrade:op.drop_index("ux_withdraw_order_user_active", table_name="withdraw_order")。downgrade:op.create_index("ux_withdraw_order_user_active", "withdraw_order", ["user_id"], unique=True, sqlite_where=text("status IN ('reviewing','pending')"), postgresql_where=text("status IN ('reviewing','pending')")),并注释"若已有用户存在多张在途单则重建失败,属预期回滚代价"。- 索引的 drop/create 为具名操作,SQLite/PG 均无需
render_as_batch重建表。
5. 天然保持不变(无需改动)的约束
| 约束 | 为何仍成立 |
|---|---|
| 不会超提 | 建单即原子扣现金(_try_deduct_cash,余额不足影响 0 行 → InsufficientCashError);每笔并行单各需自己的余额 |
coin_cash 每日档位次数 |
withdraw_tier_states 的档位闸独立于在途单检查:常规档按当天发起即计入(任意状态,含被拒/失败,按 created_at 北京日)、每档每日限次(0.5×3 / 10×1 / 20×1)且当天只选一档;新人档(0.1/0.3)按 reviewing/pending/success 一次性占用。故即便并行,coin_cash 单日在途仍被档位天然封顶(至多 3 笔 0.5) |
out_bill_no 幂等 |
幂等分支在被删检查之前,同号重试仍原样返回旧单、不重复扣款 |
| 解绑退款 | refund_reviewing_withdraws_on_unbind 已 for 遍历该用户所有 reviewing 单,天然支持多单 |
| admin 审核 / 打款 / 对账 | approve_withdraw/reject_withdraw/refresh_withdraw_status/对账全按 out_bill_no 单号维度,不假设一人一单 |
6. 客户端/App 团队注意点(跨仓,非本次后端工作)
- 允许多笔并行后,每次提交都要生成新的
out_bill_no(复用旧号会命中幂等、返回旧单)。- 用户未开启免确认时,多笔
pending会各自返回一个微信确认页package_info,App 需能处理/串行多笔待确认。开启免确认后直接到账、无此问题。
7. 测试计划(tests/test_withdraw.py,沿用现有 helper)
- 新增 · 多笔在途放行:同一用户 seed ≥1 元现金,用两个不同
out_bill_no各提 0.5 元(在 0.5 元档 3 次/天限额内)→ 两次均 200 且status=reviewing;/withdraw-orders返回 2 条;余额正确扣两次(1 元 → 0)。 - 新增 · 第二笔余额不足:seed 0.5 元,连提两笔 0.5 元 → 第一笔 200、第二笔 409(
InsufficientCashError),验证每笔各需自己的余额。 - 回归 · 幂等:
test_withdraw_idempotent_same_bill_no(同out_bill_no→ 同一单、只扣一次)仍绿。 - 回归 · 档位限额:
tests/test_withdraw_tiers.py(0.5 元日 3 次、第 4 次 409;跨档互斥)不受影响仍绿。 - 沿用
tests/conftest.py(临时 SQLite、RATE_LIMIT_ENABLED=false);wxpay 网络调用全部 monkeypatch。
附:涉及文件清单
改动
app/repositories/wallet.py— 删在途单检查 +IntegrityError兜底瘦身 + 删_WITHDRAW_ACTIVE_STATUSES/WithdrawTooFrequentErrorapp/models/wallet.py— 删分区唯一索引ux_withdraw_order_user_activeapp/api/v1/wallet.py— 删WithdrawTooFrequentError的 409 处理tests/test_withdraw.py— 新增多笔在途放行 / 第二笔余额不足用例
新增
alembic/versions/<...>_drop_withdraw_active_unique_index.py— drop 分区唯一索引(downgrade 重建)