diff --git a/docs/superpowers/plans/2026-07-24-withdraw-allow-concurrent.md b/docs/superpowers/plans/2026-07-24-withdraw-allow-concurrent.md new file mode 100644 index 0000000..6b1498c --- /dev/null +++ b/docs/superpowers/plans/2026-07-24-withdraw-allow-concurrent.md @@ -0,0 +1,323 @@ +# 允许在途提现时继续提交新申请 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](../specs/2026-07-24-withdraw-allow-concurrent-design.md) + +--- + +## 关键事实(实现依据) + +- 当前 Alembic head:`d8dd2106e438`(新迁移的 `down_revision`)。 +- 索引出处:[`alembic/versions/withdraw_safety_indexes.py`](../../../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`): + +```python +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: +```python + 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: +```python + # 福利页档位闸(7-9):coin_cash 只能提预设档位,且该档今日可提(服务端权威口径,防绕过 +``` + +- [ ] **Step 4: 应用层给 IntegrityError 兜底瘦身**(同函数末尾 commit 处) + +索引移除后不会再因在途单触发唯一冲突,只保留 `out_bill_no` 幂等重试分支: + +old: +```python + 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: +```python + 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: +```python +_WITHDRAW_ACTIVE_STATUSES = {"reviewing", "pending"} +# 占用新人档「一次性」资格的提现状态:进行中(reviewing/pending)或成功打款(success)。 +``` +new: +```python +# 占用新人档「一次性」资格的提现状态:进行中(reviewing/pending)或成功打款(success)。 +``` + +- [ ] **Step 6: 删死异常类 `WithdrawTooFrequentError`**(`app/repositories/wallet.py`) + +old: +```python +class WithdrawTooFrequentError(Exception): + """提现申请过于频繁,或已有未完成提现单。""" + + +class WithdrawTierUnavailableError(Exception): +``` +new: +```python +class WithdrawTierUnavailableError(Exception): +``` + +- [ ] **Step 7: 删端点内失效的 409 处理**(`app/api/v1/wallet.py` · `withdraw`) + +old: +```python + 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: +```python + 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: +```python + __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: +```python + __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`: + +```python +"""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: 提交** + +```bash +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 " +``` + +--- + +## 自审(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` 在模型/迁移中拼写一致。✓