Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 2d74693c21 |
@@ -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')"),
|
||||
)
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 # 待管理员审核;**不在此处打款**
|
||||
|
||||
@@ -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 / failed;reviewing →(拒绝)→ rejected(已退款)。同一用户同时只能有一笔进行中。
|
||||
现金 → 微信零钱。状态机:reviewing(待审核)→ pending(打款在途)→ success / failed;reviewing →(拒绝)→ rejected(已退款)。同一用户可同时存在多笔进行中的提现单,每次提交通过唯一 `out_bill_no` 保证幂等。
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|---|---|---|
|
||||
|
||||
@@ -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。
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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": {}})
|
||||
|
||||
Reference in New Issue
Block a user