Files
shaguabijia-app-server/docs/superpowers/specs/2026-07-24-withdraw-allow-concurrent-design.md
T
guke e11f506e1e docs: 提现允许在途时继续提交申请 设计spec (#173)
## 摘要
取消「同一用户同时仅一笔在途提现」限制:已有 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
2026-07-24 16:40:57 +08:00

8.5 KiB
Raw Blame History

允许在途提现时继续提交新的提现申请 设计

  • 日期:2026-07-24
  • 状态:Draft — 待评审
  • 所属:app-server(app/),含一处 Alembic 迁移;另有一条 Android/客户端注意点(非本次后端工作)
  • 一句话:取消「同一用户同一时刻只能有一笔在途提现」的限制 —— 已有 reviewing(待审核)或 pending(打款在途)提现单时,允许再次发起新的提现申请。

1. 背景与目标

现状:用户发起提现后,在管理员审核通过并打款完成之前(reviewing / pending),无法再发起第二笔提现,会收到 409「已有提现申请正在审核或打款中,请处理完成后再申请」。人工审核有延迟时,用户被卡住、体验差。

目标:放开该限制,已有在途提现单时仍可继续提交新的提现申请。

非目标(本期不做)

  • 不改「先扣款」模型(提现建单即原子扣现金,天然防超提)。
  • 不改 coin_cash 的每日档位次数限制(仍是独立的限额闸)。
  • 不加任何新的并发/次数硬上限(见 §3 决策)。
  • 不改 admin 审核/打款/对账逻辑(其全部按单号维度操作,天然支持一人多单)。

2. 需求

# 需求 落地
R1 已有 reviewingpending 单时可再次发起提现 删除应用层在途单互斥检查 + 删除 DB 分区唯一索引(§4)
R2 不引入新的并发上限 仅靠既有约束:先扣款余额 + coin_cash 每日档位次数(§5)
R3 既有幂等/限额/退款/对账行为不回退 保留 out_bill_no 幂等、档位闸、解绑退款、对账(§5)

3. 决策记录(来自评审问答)

决策点 结论 理由
放行范围 reviewingpending 两种在途状态都放行,彻底取消「同时仅一单」 需求即"存在在途提现时可继续提交";人工审核延迟不应卡住用户
并发上限 不加硬上限 现金先扣款→每笔各需自己的余额,不会超提;coin_cash 每日档位次数已是天然上限;invite_cash 仅受余额约束,可接受
实现方式 彻底移除(删检查 + 删索引 + 清理死代码),非配置开关 决策明确且无回滚诉求,YAGNI;避免留半死代码与多余配置项
已失效异常/文案 删除 WithdrawTooFrequentError 及端点的 409 处理 全仓仅这 4 处引用(§4),移除后无残留

已知取舍(可接受)

  • 多笔 pending × 未开免确认:每笔 pending 会各自返回一个微信确认页 package_info。用户若未开启免确认,可能同时存在多笔待确认。属客户端交互问题(§6),后端行为正确;开启免确认后直接到账、无此问题。
  • 回滚风险:迁移 downgrade 会重建分区唯一索引;若届时某用户已有 ≥2 张在途单,重建会失败 —— 属预期的回滚代价,在迁移里注释说明。

4. 现状机制与改动点

「同一时刻仅一笔在途提现」由两道闸共同强制,均以 status IN ('reviewing','pending') 为口径:

  1. 应用层app/repositories/wallet.py:758-765 create_withdraw 内:查在途单 → raise WithdrawTooFrequentError → 端点转 409。
  2. 数据库层app/models/wallet.py:99-107分区唯一索引 ux_withdraw_order_user_active(user_id WHERE status IN ('reviewing','pending'))。这是硬约束,create_withdrawIntegrityError 兜底(:814-833)即捕获它。

提现单状态机:reviewing(待审核,建单即扣现金)→pending(审核通过、打款在途)→success/failed;或 reviewingrejected(已退款)。

改动清单(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_unbindfor 遍历该用户所有 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 / WithdrawTooFrequentError
  • app/models/wallet.py — 删分区唯一索引 ux_withdraw_order_user_active
  • app/api/v1/wallet.py — 删 WithdrawTooFrequentError 的 409 处理
  • tests/test_withdraw.py — 新增多笔在途放行 / 第二笔余额不足用例

新增

  • alembic/versions/<...>_drop_withdraw_active_unique_index.py — drop 分区唯一索引(downgrade 重建)