fix(withdraw): allow multiple active applications

This commit is contained in:
no_gen_mu
2026-07-12 19:27:43 +08:00
parent 824045dd19
commit 2d74693c21
9 changed files with 69 additions and 47 deletions
@@ -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')"),
)
+1 -6
View File
@@ -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
-10
View File
@@ -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
-22
View File
@@ -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 # 待管理员审核;**不在此处打款**
+2 -2
View File
@@ -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` 后台调。
- "待审核期间钱已扣减",防用户拿同一笔余额重复发起多笔提现。
@@ -134,7 +134,7 @@ App 用户主表。两种登录(极光一键 / 短信验证码)都映射到
## `withdraw_order` — 提现单
现金 → 微信零钱。状态机:reviewing(待审核)→ pending(打款在途)→ success / failedreviewing →(拒绝)→ rejected(已退款)。同一用户同时只能有一笔进行中
现金 → 微信零钱。状态机:reviewing(待审核)→ pending(打款在途)→ success / failedreviewing →(拒绝)→ rejected(已退款)。同一用户同时存在多笔进行中的提现单,每次提交通过唯一 `out_bill_no` 保证幂等
| 字段 | 类型 | 说明 |
|---|---|---|
+1 -1
View File
@@ -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
+4 -5
View File
@@ -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
+28
View File
@@ -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": {}})