From 2d74693c217ce8294376facd8a15f33d43a8b683 Mon Sep 17 00:00:00 2001 From: no_gen_mu Date: Sun, 12 Jul 2026 19:27:43 +0800 Subject: [PATCH] fix(withdraw): allow multiple active applications --- .../withdraw_allow_multiple_active.py | 32 +++++++++++++++++++ app/api/v1/wallet.py | 7 +--- app/models/wallet.py | 10 ------ app/repositories/wallet.py | 22 ------------- docs/database/withdraw_order.md | 4 +-- docs/database/数据表字典-产品参考.md | 2 +- scripts/seed_mock_withdraws.py | 2 +- tests/test_invite_cash_withdraw.py | 9 +++--- tests/test_withdraw.py | 28 ++++++++++++++++ 9 files changed, 69 insertions(+), 47 deletions(-) create mode 100644 alembic/versions/withdraw_allow_multiple_active.py diff --git a/alembic/versions/withdraw_allow_multiple_active.py b/alembic/versions/withdraw_allow_multiple_active.py new file mode 100644 index 0000000..609c67c --- /dev/null +++ b/alembic/versions/withdraw_allow_multiple_active.py @@ -0,0 +1,32 @@ +"""allow multiple active withdraw orders per user + +Revision ID: withdraw_allow_multiple_active +Revises: ad_ecpm_trace_id +Create Date: 2026-07-12 18:00:00.000000 + +""" +from collections.abc import Sequence + +import sqlalchemy as sa + +from alembic import op + +revision: str = "withdraw_allow_multiple_active" +down_revision: str | Sequence[str] | None = "ad_ecpm_trace_id" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + op.drop_index("ux_withdraw_order_user_active", table_name="withdraw_order") + + +def downgrade() -> None: + 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')"), + ) diff --git a/app/api/v1/wallet.py b/app/api/v1/wallet.py index 454ff7c..1e96108 100644 --- a/app/api/v1/wallet.py +++ b/app/api/v1/wallet.py @@ -201,7 +201,7 @@ def withdraw_info( "/withdraw", response_model=WithdrawResultOut, summary="发起提现(扣款建单,待人工审核;审核通过后才打款)", - dependencies=[Depends(rate_limit(5, 60, "withdraw"))], # IP 级粗限流;用户级未完成单限制在仓库层 + dependencies=[Depends(rate_limit(5, 60, "withdraw"))], # IP 级粗限流;余额/档位/幂等限制在仓库层 ) def withdraw(req: WithdrawRequest, user: CurrentUser, db: DbSession) -> WithdrawResultOut: # 提现发起本身不调微信(打款在审核通过后),但仍要求微信支付已配置——否则审核通过也打不了款,提前拦 @@ -222,11 +222,6 @@ def withdraw(req: WithdrawRequest, user: CurrentUser, db: DbSession) -> Withdraw ) from e 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: # 福利页档位闸(7-9):次数满/已选其他额度。正常客户端已按 tiers 预拦,此处兜底防绕过。 raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="今日额度已达上限") from e diff --git a/app/models/wallet.py b/app/models/wallet.py index cb815d8..0f784b5 100644 --- a/app/models/wallet.py +++ b/app/models/wallet.py @@ -96,16 +96,6 @@ class WithdrawOrder(Base): """ __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')"), - ), - ) - id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) user_id: Mapped[int] = mapped_column( Integer, ForeignKey("user.id"), index=True, nullable=False diff --git a/app/repositories/wallet.py b/app/repositories/wallet.py index fe1e23e..bde6406 100644 --- a/app/repositories/wallet.py +++ b/app/repositories/wallet.py @@ -33,7 +33,6 @@ from app.models.wallet import ( _WX_STATE_SUCCESS = "SUCCESS" _WX_STATE_FAILED = {"FAIL", "CANCELLED", "CLOSED"} _WX_STATE_WAIT_CONFIRM = "WAIT_USER_CONFIRM" # 用户还没在微信确认页确认 -_WITHDRAW_ACTIVE_STATUSES = {"reviewing", "pending"} # 免确认收款授权状态 _WX_AUTH_ACTIVE = "TAKING_EFFECT" # 已生效,可免确认转账 _WX_AUTH_CLOSED = "CLOSED" # 已关闭(用户/商户/风控),需重新开启 @@ -63,10 +62,6 @@ class InsufficientCashError(Exception): """现金余额不足。""" -class WithdrawTooFrequentError(Exception): - """提现申请过于频繁,或已有未完成提现单。""" - - class WithdrawTierUnavailableError(Exception): """该档位今日不可提:次数已满,或今天已选了其他额度(7-9 福利页档位规则)。""" @@ -738,15 +733,6 @@ def create_withdraw( else: out_bill_no = uuid.uuid4().hex - 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 只能提预设档位,且该档今日可提(服务端权威口径,防绕过 # 客户端刷)。放在幂等返回/在途互斥之后:同号重试仍原样返回旧单,不被档位闸误杀。 # allow_sub_min(0.01 调试直发)保持原样放行,不受档位约束;invite_cash 本轮无档位概念不校验。 @@ -805,14 +791,6 @@ def create_withdraw( ).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 db.refresh(order) return order # 待管理员审核;**不在此处打款** diff --git a/docs/database/withdraw_order.md b/docs/database/withdraw_order.md index e06c0e0..d73efae 100644 --- a/docs/database/withdraw_order.md +++ b/docs/database/withdraw_order.md @@ -45,9 +45,9 @@ reviewing ──admin 审核拒绝──▶ rejected(已退款) ## 索引与约束 - PK `id`;UNIQUE+index `out_bill_no`;index `user_id`、`created_at`。 -- 部分唯一索引 `ux_withdraw_order_user_active`(`user_id`),条件 `status IN ('reviewing', 'pending')`:每个用户同时只能有一笔在途(待审核 / 打款中)提现单,DB 层挡并发重复提现。 +- 同一用户允许同时存在多笔 `reviewing` / `pending` 提现单;每笔以唯一 `out_bill_no` 独立审核、打款和对账。 ## 注意 -- **资金安全**:原子扣款(`WHERE cash_balance_cents >= amount`)+ `out_bill_no` 幂等 + 结果不明时**先查单再决定,绝不盲目退款**(防退款后又到账)+ 孤儿 pending 单 `reconcile_pending_withdraws` 对账兜底。 +- **资金安全**:允许多笔在途不等于重复扣款。仍由原子扣款(`WHERE cash_balance_cents >= amount`)+ `out_bill_no` 幂等 + 结果不明时**先查单再决定,绝不盲目退款**(防退款后又到账)+ 孤儿 pending 单 `reconcile_pending_withdraws` 对账兜底。 - `WITHDRAW_MIN_CENTS=10`(0.1 元,微信商家转账地板价),可经 `app_config.withdraw_min_cents` 后台调。 - "待审核期间钱已扣减",防用户拿同一笔余额重复发起多笔提现。 diff --git a/docs/database/数据表字典-产品参考.md b/docs/database/数据表字典-产品参考.md index fa59348..9530198 100644 --- a/docs/database/数据表字典-产品参考.md +++ b/docs/database/数据表字典-产品参考.md @@ -134,7 +134,7 @@ App 用户主表。两种登录(极光一键 / 短信验证码)都映射到 ## `withdraw_order` — 提现单 -现金 → 微信零钱。状态机:reviewing(待审核)→ pending(打款在途)→ success / failed;reviewing →(拒绝)→ rejected(已退款)。同一用户同时只能有一笔进行中。 +现金 → 微信零钱。状态机:reviewing(待审核)→ pending(打款在途)→ success / failed;reviewing →(拒绝)→ rejected(已退款)。同一用户可同时存在多笔进行中的提现单,每次提交通过唯一 `out_bill_no` 保证幂等。 | 字段 | 类型 | 说明 | |---|---|---| diff --git a/scripts/seed_mock_withdraws.py b/scripts/seed_mock_withdraws.py index ad45f29..37e2c79 100644 --- a/scripts/seed_mock_withdraws.py +++ b/scripts/seed_mock_withdraws.py @@ -7,7 +7,7 @@ - 配套双分录现金流水(withdraw / withdraw_refund / exchange_in)+ 账户余额, 让顶部「账本校验」保持绿色、详情抽屉的现金余额/流水也真实。 -约束:withdraw_order 有部分唯一索引(同一 user 在 reviewing/pending 最多 1 单), +约束:同一 user 可有多笔 reviewing/pending 单,每笔 out_bill_no 唯一且独立对账, 本脚本每个 mock 用户至多 1 个活动单,满足约束。 幂等:每次运行先按固定 mock 手机号清掉旧 mock 再重建。仅清理用 --clean-only。 diff --git a/tests/test_invite_cash_withdraw.py b/tests/test_invite_cash_withdraw.py index 5b83110..bad104b 100644 --- a/tests/test_invite_cash_withdraw.py +++ b/tests/test_invite_cash_withdraw.py @@ -128,8 +128,7 @@ def test_invite_cash_reject_refunds_invite_account(client, monkeypatch) -> None: def test_two_accounts_withdraw_independent(client, monkeypatch) -> None: - """两账户各提各的不串:先提 invite_cash(拒绝结清),再提 cash,各扣各账户。 - 注:一个用户同一时间只能一个活跃提现单(跨账户),故第二笔需先结清第一笔。""" + """两账户各提各的不串:invite_cash 与 coin_cash 可同时保留 reviewing 单。""" _patch_userinfo(monkeypatch, "openid_ic_3") token = _login(client, "13800004003") _seed_balances(client, token, "13800004003", cash=400, invite_cash=500) @@ -140,17 +139,17 @@ def test_two_accounts_withdraw_independent(client, monkeypatch) -> None: json={"amount_cents": 200, "source": "invite_cash"}, headers=_auth(token), ) - _reject(r1.json()["out_bill_no"]) # 退回 invite_cash + 结清活跃单 + assert r1.status_code == 200 and r1.json()["status"] == "reviewing", r1.text r2 = client.post( "/api/v1/wallet/withdraw", # 50 分 = 0.5 元档(7-9 起 coin_cash 只能提预设档位) json={"amount_cents": 50, "source": "coin_cash"}, headers=_auth(token), ) - assert r2.json()["status"] == "reviewing" + assert r2.status_code == 200 and r2.json()["status"] == "reviewing", r2.text cash, invite_cash = _balances(client, token) - assert invite_cash == 500 # 已退回 + assert invite_cash == 300 # invite_cash 单仍待审核,已扣 200 assert cash == 350 # 扣了 cash 50 diff --git a/tests/test_withdraw.py b/tests/test_withdraw.py index 55e6a2f..9c11583 100644 --- a/tests/test_withdraw.py +++ b/tests/test_withdraw.py @@ -268,6 +268,34 @@ def test_withdraw_idempotent_same_bill_no(client, monkeypatch) -> None: assert sum(1 for t in r.json()["items"] if t["biz_type"] == "withdraw") == 1 +def test_withdraw_allows_multiple_active_orders_with_distinct_bill_no(client, monkeypatch) -> None: + """不同 out_bill_no 是不同提现意图,允许同时处于 reviewing;每笔分别扣款建单。""" + _patch_userinfo(monkeypatch, "openid_multi_active") + token = _login(client, "13800002017") + _seed_cash(client, token, "13800002017", 200) + 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": "multi_active_bill_1"}, + headers=_auth(token), + ) + r2 = client.post( + "/api/v1/wallet/withdraw", + json={"amount_cents": 50, "out_bill_no": "multi_active_bill_2"}, + headers=_auth(token), + ) + + assert r1.status_code == 200 and r1.json()["status"] == "reviewing", r1.text + assert r2.status_code == 200 and r2.json()["status"] == "reviewing", r2.text + assert r1.json()["out_bill_no"] != r2.json()["out_bill_no"] + + account = client.get("/api/v1/wallet/account", headers=_auth(token)).json() + assert account["cash_balance_cents"] == 100 + txns = client.get("/api/v1/wallet/cash-transactions", headers=_auth(token)).json()["items"] + assert sum(1 for t in txns if t["biz_type"] == "withdraw") == 2 + + def test_withdraw_ambiguous_timeout_then_success_no_refund(client, monkeypatch) -> None: """#3 转账调用超时(异常),但查单确认已 SUCCESS → 不退款,单置 success。""" monkeypatch.setattr("app.integrations.wxpay.code_to_userinfo", lambda code: {"openid": "openid_amb", "nickname": None, "avatar_url": None, "raw": {}})