## 摘要 取消「同一用户同时仅一笔在途提现」限制:已有 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
14 KiB
允许在途提现时继续提交新申请 Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: 取消「同一用户同一时刻仅一笔在途提现」限制,已有 reviewing/pending 单时允许继续提交新的提现申请。
Architecture: 该限制由两道闸共同强制——应用层 create_withdraw 的在途单检查(WithdrawTooFrequentError)与数据库分区唯一索引 ux_withdraw_order_user_active。彻底移除两者 + 清理随之失效的死代码;既有约束(建单先扣款、coin_cash 每日档位次数、out_bill_no 幂等)天然保留,无需改动。测试库由 Base.metadata.create_all() 依模型建表,故删模型内索引定义即让测试反映新 schema;另配一条 Alembic 迁移让真实库(dev/prod)落地同一变更。
Tech Stack: FastAPI · SQLAlchemy 2.0 · Alembic · pytest · ruff
规格来源:docs/superpowers/specs/2026-07-24-withdraw-allow-concurrent-design.md
关键事实(实现依据)
- 当前 Alembic head:
d8dd2106e438(新迁移的down_revision)。 - 索引出处:
alembic/versions/withdraw_safety_indexes.py同时建了两个索引——本次只删ux_withdraw_order_user_active,保留姊妹索引ux_cash_transaction_withdraw_refund_ref(退款幂等)。 - 档位:
WithdrawTier(50, "0.5", None, 3, False)——50 分(0.5 元)是常规档,每日 3 次,非新人档;测试用它来造多笔在途。 - 测试库走
create_all(见tests/conftest.py),不跑 Alembic;故迁移的正确性由本计划单独的 upgrade/downgrade 回环验证,不由 pytest 覆盖。 app/models/wallet.py的Index/text仍被其它表(行 54/186/224)使用,删本表__table_args__后无需清理 import。
Task 1: 允许多笔在途提现并存(TDD:模型 + 应用层 + 端点 + 迁移,单次提交)
本变更是一次原子的 schema+行为改动:模型内索引、应用层检查、Alembic 迁移相互依赖,任一缺失都会让"多笔在途"在测试库或真实库其一不成立。故作为一个任务、一次提交完成,内部按 TDD 分步。
Files:
- Test:
tests/test_withdraw.py(新增 2 个用例) - Modify:
app/repositories/wallet.py(删在途单检查 + IntegrityError 兜底瘦身 + 删死常量/异常) - Modify:
app/api/v1/wallet.py:225-229(删失效的 409 处理) - Modify:
app/models/wallet.py:99-107(删分区唯一索引) - Create:
alembic/versions/drop_withdraw_active_unique_index.py(真实库删索引)
- Step 1: 写两个失败测试
在 tests/test_withdraw.py 末尾追加(复用文件内既有 helper _login/_auth/_seed_cash/_patch_userinfo):
def test_withdraw_multiple_in_flight_allowed(client, monkeypatch) -> None:
"""取消「同时仅一单」:已有在途(reviewing)时,不同 out_bill_no 可继续提交,两单并存。"""
_patch_userinfo(monkeypatch, "openid_multi_inflight")
token = _login(client, "13800002020")
_seed_cash(client, token, "13800002020", 100) # 够两笔 0.5
client.post("/api/v1/wallet/bind-wechat", json={"code": "c"}, headers=_auth(token))
r1 = client.post(
"/api/v1/wallet/withdraw",
json={"amount_cents": 50, "out_bill_no": "billmulti00000001"},
headers=_auth(token),
)
assert r1.status_code == 200, r1.text
assert r1.json()["status"] == "reviewing"
r2 = client.post(
"/api/v1/wallet/withdraw",
json={"amount_cents": 50, "out_bill_no": "billmulti00000002"},
headers=_auth(token),
)
assert r2.status_code == 200, r2.text
assert r2.json()["status"] == "reviewing"
# 两张在途单并存
r = client.get("/api/v1/wallet/withdraw-orders", headers=_auth(token))
reviewing = [o for o in r.json()["items"] if o["status"] == "reviewing"]
assert len(reviewing) == 2, r.text
# 余额扣两次:100-50-50=0
r = client.get("/api/v1/wallet/account", headers=_auth(token))
assert r.json()["cash_balance_cents"] == 0
def test_withdraw_second_blocked_only_by_insufficient_cash(client, monkeypatch) -> None:
"""并行放开后第二笔仅受余额约束:余额不足返 409「现金余额不足」,而非旧的「已有提现」拦截。"""
_patch_userinfo(monkeypatch, "openid_multi_insuff")
token = _login(client, "13800002021")
_seed_cash(client, token, "13800002021", 50) # 仅够一笔 0.5
client.post("/api/v1/wallet/bind-wechat", json={"code": "c"}, headers=_auth(token))
r1 = client.post(
"/api/v1/wallet/withdraw",
json={"amount_cents": 50, "out_bill_no": "billinsuff0000001"},
headers=_auth(token),
)
assert r1.status_code == 200, r1.text
r2 = client.post(
"/api/v1/wallet/withdraw",
json={"amount_cents": 50, "out_bill_no": "billinsuff0000002"},
headers=_auth(token),
)
assert r2.status_code == 409, r2.text
assert "现金余额不足" in r2.json()["detail"]
- Step 2: 运行新测试,确认失败
Run: pytest tests/test_withdraw.py::test_withdraw_multiple_in_flight_allowed tests/test_withdraw.py::test_withdraw_second_blocked_only_by_insufficient_cash -q
Expected: 两条 FAIL —— multiple_in_flight 因第二笔被拦返回 409(期望 200);second_blocked 因返回的 409 detail 是「已有提现申请正在审核或打款中」而非「现金余额不足」。
- Step 3: 应用层删在途单互斥检查(
app/repositories/wallet.py·create_withdraw)
删掉在途单预检查(它紧邻档位闸注释之前):
old:
active_order_id = db.execute(
select(WithdrawOrder.id).where(
WithdrawOrder.user_id == user_id,
WithdrawOrder.status.in_(_WITHDRAW_ACTIVE_STATUSES),
).limit(1)
).scalar_one_or_none()
if active_order_id is not None:
raise WithdrawTooFrequentError
# 福利页档位闸(7-9):coin_cash 只能提预设档位,且该档今日可提(服务端权威口径,防绕过
new:
# 福利页档位闸(7-9):coin_cash 只能提预设档位,且该档今日可提(服务端权威口径,防绕过
- Step 4: 应用层给 IntegrityError 兜底瘦身(同函数末尾 commit 处)
索引移除后不会再因在途单触发唯一冲突,只保留 out_bill_no 幂等重试分支:
old:
except IntegrityError:
db.rollback()
existing = db.execute(
select(WithdrawOrder).where(
WithdrawOrder.out_bill_no == out_bill_no, WithdrawOrder.user_id == user_id
)
).scalar_one_or_none()
if existing is not None:
return existing
active_order_id = db.execute(
select(WithdrawOrder.id).where(
WithdrawOrder.user_id == user_id,
WithdrawOrder.status.in_(_WITHDRAW_ACTIVE_STATUSES),
).limit(1)
).scalar_one_or_none()
if active_order_id is not None:
raise WithdrawTooFrequentError from None
raise
new:
except IntegrityError:
db.rollback()
# 唯一冲突只可能来自 out_bill_no 幂等键并发重试:原样返回既有单;否则未知冲突,上抛。
existing = db.execute(
select(WithdrawOrder).where(
WithdrawOrder.out_bill_no == out_bill_no, WithdrawOrder.user_id == user_id
)
).scalar_one_or_none()
if existing is not None:
return existing
raise
- Step 5: 删死常量
_WITHDRAW_ACTIVE_STATUSES(app/repositories/wallet.py模块顶部)
只删常量行,保留其后属于 _NEWBIE_TIER_HELD_STATUSES 的注释:
old:
_WITHDRAW_ACTIVE_STATUSES = {"reviewing", "pending"}
# 占用新人档「一次性」资格的提现状态:进行中(reviewing/pending)或成功打款(success)。
new:
# 占用新人档「一次性」资格的提现状态:进行中(reviewing/pending)或成功打款(success)。
- Step 6: 删死异常类
WithdrawTooFrequentError(app/repositories/wallet.py)
old:
class WithdrawTooFrequentError(Exception):
"""提现申请过于频繁,或已有未完成提现单。"""
class WithdrawTierUnavailableError(Exception):
new:
class WithdrawTierUnavailableError(Exception):
- Step 7: 删端点内失效的 409 处理(
app/api/v1/wallet.py·withdraw)
old:
except crud_wallet.WechatNotBoundError as e:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="请先绑定微信") from e
except crud_wallet.WithdrawTooFrequentError as e:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="已有提现申请正在审核或打款中,请处理完成后再申请",
) from e
except crud_wallet.WithdrawTierUnavailableError as e:
new:
except crud_wallet.WechatNotBoundError as e:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="请先绑定微信") from e
except crud_wallet.WithdrawTierUnavailableError as e:
- Step 8: 删模型内分区唯一索引(
app/models/wallet.py·WithdrawOrder)
old:
__tablename__ = "withdraw_order"
__table_args__ = (
Index(
"ux_withdraw_order_user_active",
"user_id",
unique=True,
sqlite_where=text("status IN ('reviewing', 'pending')"),
postgresql_where=text("status IN ('reviewing', 'pending')"),
),
)
new:
__tablename__ = "withdraw_order"
- Step 9: 运行新测试,确认通过
Run: pytest tests/test_withdraw.py::test_withdraw_multiple_in_flight_allowed tests/test_withdraw.py::test_withdraw_second_blocked_only_by_insufficient_cash -q
Expected: 2 passed。
- Step 10: 建 Alembic 迁移(真实库删索引)
创建 alembic/versions/drop_withdraw_active_unique_index.py:
"""drop withdraw active-order partial unique index (allow multiple in-flight withdrawals)
Revision ID: drop_withdraw_active_unique_index
Revises: d8dd2106e438
Create Date: 2026-07-24 00:00:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = "drop_withdraw_active_unique_index"
down_revision: Union[str, Sequence[str], None] = "d8dd2106e438"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# 取消「同一用户同一时刻仅一笔在途提现」:允许 reviewing/pending 并存。
# 仅删本索引;姊妹索引 ux_cash_transaction_withdraw_refund_ref(退款幂等)保持不动。
op.drop_index("ux_withdraw_order_user_active", table_name="withdraw_order")
def downgrade() -> None:
# 回滚重建分区唯一索引。注意:若届时某用户已有 ≥2 张在途单,重建会因唯一冲突失败——
# 属预期的回滚代价(取消限制后本就允许多单),需先人工收敛在途单再回滚。
op.create_index(
"ux_withdraw_order_user_active",
"withdraw_order",
["user_id"],
unique=True,
sqlite_where=sa.text("status IN ('reviewing', 'pending')"),
postgresql_where=sa.text("status IN ('reviewing', 'pending')"),
)
- Step 11: 验证迁移 upgrade + 回环 downgrade/upgrade
Run: alembic upgrade head
Expected: 输出应用 drop_withdraw_active_unique_index,无报错。
Run: alembic downgrade -1 && alembic upgrade head
Expected: downgrade 重建索引、upgrade 再次删除,均成功(dev 库每用户在途单 ≤1,不会触发唯一冲突)。
- Step 12: 全量测试 + Lint
Run: pytest -q
Expected: 全绿(既有 test_withdraw_idempotent_same_bill_no、tests/test_withdraw_tiers.py、tests/test_invite_cash_withdraw.py 均不受影响)。
Run: ruff check .
Expected: 无新增告警(死常量/异常已连同引用一并删除)。
- Step 13: 提交
git add tests/test_withdraw.py app/repositories/wallet.py app/api/v1/wallet.py app/models/wallet.py alembic/versions/drop_withdraw_active_unique_index.py
git commit -m "feat(withdraw): 允许在途提现时继续提交新申请
取消「同一用户同时仅一笔在途提现」限制:删应用层在途单检查 +
删 DB 分区唯一索引 ux_withdraw_order_user_active + 清理死代码
(_WITHDRAW_ACTIVE_STATUSES / WithdrawTooFrequentError)。
先扣款、coin_cash 每日档位次数、out_bill_no 幂等等既有约束不变。
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"
自审(spec 覆盖核对)
- R1 已有 reviewing/pending 可再提 → Step 3/4(应用层)+ Step 8/10(模型 + 迁移);
test_withdraw_multiple_in_flight_allowed证明。✓ - R2 不加硬上限 → 无新增限制;
coin_cash天然封顶由既有档位次数(tests/test_withdraw_tiers.py)保障,不改。✓ - R3 幂等/限额/退款/对账不回退 →
test_withdraw_idempotent_same_bill_no与档位/邀请现金测试留绿(Step 12);解绑退款、对账按单号维度,未触碰。✓ - spec §4 四处改动 → Step 3/4/5/6(repo)、Step 7(api)、Step 8(model)、Step 10(迁移)一一对应。✓
- spec §6 客户端注意点 → 属跨仓 App 事项,文档已记,本计划无对应代码任务(有意为之)。✓
- 占位符扫描:迁移
revision/down_revision(d8dd2106e438)均为具体值,无 TBD。✓ - 命名一致性:
WithdrawTooFrequentError/_WITHDRAW_ACTIVE_STATUSES的删除点与引用点全覆盖(全仓仅这 4 处);ux_withdraw_order_user_active在模型/迁移中拼写一致。✓