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",
|
"/withdraw",
|
||||||
response_model=WithdrawResultOut,
|
response_model=WithdrawResultOut,
|
||||||
summary="发起提现(扣款建单,待人工审核;审核通过后才打款)",
|
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:
|
def withdraw(req: WithdrawRequest, user: CurrentUser, db: DbSession) -> WithdrawResultOut:
|
||||||
# 提现发起本身不调微信(打款在审核通过后),但仍要求微信支付已配置——否则审核通过也打不了款,提前拦
|
# 提现发起本身不调微信(打款在审核通过后),但仍要求微信支付已配置——否则审核通过也打不了款,提前拦
|
||||||
@@ -222,11 +222,6 @@ def withdraw(req: WithdrawRequest, user: CurrentUser, db: DbSession) -> Withdraw
|
|||||||
) from e
|
) from e
|
||||||
except crud_wallet.WechatNotBoundError as e:
|
except crud_wallet.WechatNotBoundError as e:
|
||||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="请先绑定微信") from 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:
|
except crud_wallet.WithdrawTierUnavailableError as e:
|
||||||
# 福利页档位闸(7-9):次数满/已选其他额度。正常客户端已按 tiers 预拦,此处兜底防绕过。
|
# 福利页档位闸(7-9):次数满/已选其他额度。正常客户端已按 tiers 预拦,此处兜底防绕过。
|
||||||
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="今日额度已达上限") from e
|
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="今日额度已达上限") from e
|
||||||
|
|||||||
@@ -96,16 +96,6 @@ class WithdrawOrder(Base):
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
__tablename__ = "withdraw_order"
|
__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)
|
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||||
user_id: Mapped[int] = mapped_column(
|
user_id: Mapped[int] = mapped_column(
|
||||||
Integer, ForeignKey("user.id"), index=True, nullable=False
|
Integer, ForeignKey("user.id"), index=True, nullable=False
|
||||||
|
|||||||
@@ -33,7 +33,6 @@ from app.models.wallet import (
|
|||||||
_WX_STATE_SUCCESS = "SUCCESS"
|
_WX_STATE_SUCCESS = "SUCCESS"
|
||||||
_WX_STATE_FAILED = {"FAIL", "CANCELLED", "CLOSED"}
|
_WX_STATE_FAILED = {"FAIL", "CANCELLED", "CLOSED"}
|
||||||
_WX_STATE_WAIT_CONFIRM = "WAIT_USER_CONFIRM" # 用户还没在微信确认页确认
|
_WX_STATE_WAIT_CONFIRM = "WAIT_USER_CONFIRM" # 用户还没在微信确认页确认
|
||||||
_WITHDRAW_ACTIVE_STATUSES = {"reviewing", "pending"}
|
|
||||||
# 免确认收款授权状态
|
# 免确认收款授权状态
|
||||||
_WX_AUTH_ACTIVE = "TAKING_EFFECT" # 已生效,可免确认转账
|
_WX_AUTH_ACTIVE = "TAKING_EFFECT" # 已生效,可免确认转账
|
||||||
_WX_AUTH_CLOSED = "CLOSED" # 已关闭(用户/商户/风控),需重新开启
|
_WX_AUTH_CLOSED = "CLOSED" # 已关闭(用户/商户/风控),需重新开启
|
||||||
@@ -63,10 +62,6 @@ class InsufficientCashError(Exception):
|
|||||||
"""现金余额不足。"""
|
"""现金余额不足。"""
|
||||||
|
|
||||||
|
|
||||||
class WithdrawTooFrequentError(Exception):
|
|
||||||
"""提现申请过于频繁,或已有未完成提现单。"""
|
|
||||||
|
|
||||||
|
|
||||||
class WithdrawTierUnavailableError(Exception):
|
class WithdrawTierUnavailableError(Exception):
|
||||||
"""该档位今日不可提:次数已满,或今天已选了其他额度(7-9 福利页档位规则)。"""
|
"""该档位今日不可提:次数已满,或今天已选了其他额度(7-9 福利页档位规则)。"""
|
||||||
|
|
||||||
@@ -738,15 +733,6 @@ def create_withdraw(
|
|||||||
else:
|
else:
|
||||||
out_bill_no = uuid.uuid4().hex
|
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 只能提预设档位,且该档今日可提(服务端权威口径,防绕过
|
# 福利页档位闸(7-9):coin_cash 只能提预设档位,且该档今日可提(服务端权威口径,防绕过
|
||||||
# 客户端刷)。放在幂等返回/在途互斥之后:同号重试仍原样返回旧单,不被档位闸误杀。
|
# 客户端刷)。放在幂等返回/在途互斥之后:同号重试仍原样返回旧单,不被档位闸误杀。
|
||||||
# allow_sub_min(0.01 调试直发)保持原样放行,不受档位约束;invite_cash 本轮无档位概念不校验。
|
# allow_sub_min(0.01 调试直发)保持原样放行,不受档位约束;invite_cash 本轮无档位概念不校验。
|
||||||
@@ -805,14 +791,6 @@ def create_withdraw(
|
|||||||
).scalar_one_or_none()
|
).scalar_one_or_none()
|
||||||
if existing is not None:
|
if existing is not None:
|
||||||
return existing
|
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
|
raise
|
||||||
db.refresh(order)
|
db.refresh(order)
|
||||||
return order # 待管理员审核;**不在此处打款**
|
return order # 待管理员审核;**不在此处打款**
|
||||||
|
|||||||
@@ -45,9 +45,9 @@ reviewing ──admin 审核拒绝──▶ rejected(已退款)
|
|||||||
|
|
||||||
## 索引与约束
|
## 索引与约束
|
||||||
- PK `id`;UNIQUE+index `out_bill_no`;index `user_id`、`created_at`。
|
- 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` 后台调。
|
- `WITHDRAW_MIN_CENTS=10`(0.1 元,微信商家转账地板价),可经 `app_config.withdraw_min_cents` 后台调。
|
||||||
- "待审核期间钱已扣减",防用户拿同一笔余额重复发起多笔提现。
|
- "待审核期间钱已扣减",防用户拿同一笔余额重复发起多笔提现。
|
||||||
|
|||||||
@@ -134,7 +134,7 @@ App 用户主表。两种登录(极光一键 / 短信验证码)都映射到
|
|||||||
|
|
||||||
## `withdraw_order` — 提现单
|
## `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 / withdraw_refund / exchange_in)+ 账户余额,
|
||||||
让顶部「账本校验」保持绿色、详情抽屉的现金余额/流水也真实。
|
让顶部「账本校验」保持绿色、详情抽屉的现金余额/流水也真实。
|
||||||
|
|
||||||
约束:withdraw_order 有部分唯一索引(同一 user 在 reviewing/pending 最多 1 单),
|
约束:同一 user 可有多笔 reviewing/pending 单,每笔 out_bill_no 唯一且独立对账,
|
||||||
本脚本每个 mock 用户至多 1 个活动单,满足约束。
|
本脚本每个 mock 用户至多 1 个活动单,满足约束。
|
||||||
|
|
||||||
幂等:每次运行先按固定 mock 手机号清掉旧 mock 再重建。仅清理用 --clean-only。
|
幂等:每次运行先按固定 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:
|
def test_two_accounts_withdraw_independent(client, monkeypatch) -> None:
|
||||||
"""两账户各提各的不串:先提 invite_cash(拒绝结清),再提 cash,各扣各账户。
|
"""两账户各提各的不串:invite_cash 与 coin_cash 可同时保留 reviewing 单。"""
|
||||||
注:一个用户同一时间只能一个活跃提现单(跨账户),故第二笔需先结清第一笔。"""
|
|
||||||
_patch_userinfo(monkeypatch, "openid_ic_3")
|
_patch_userinfo(monkeypatch, "openid_ic_3")
|
||||||
token = _login(client, "13800004003")
|
token = _login(client, "13800004003")
|
||||||
_seed_balances(client, token, "13800004003", cash=400, invite_cash=500)
|
_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"},
|
json={"amount_cents": 200, "source": "invite_cash"},
|
||||||
headers=_auth(token),
|
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(
|
r2 = client.post(
|
||||||
"/api/v1/wallet/withdraw",
|
"/api/v1/wallet/withdraw",
|
||||||
# 50 分 = 0.5 元档(7-9 起 coin_cash 只能提预设档位)
|
# 50 分 = 0.5 元档(7-9 起 coin_cash 只能提预设档位)
|
||||||
json={"amount_cents": 50, "source": "coin_cash"},
|
json={"amount_cents": 50, "source": "coin_cash"},
|
||||||
headers=_auth(token),
|
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)
|
cash, invite_cash = _balances(client, token)
|
||||||
assert invite_cash == 500 # 已退回
|
assert invite_cash == 300 # invite_cash 单仍待审核,已扣 200
|
||||||
assert cash == 350 # 扣了 cash 50
|
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
|
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:
|
def test_withdraw_ambiguous_timeout_then_success_no_refund(client, monkeypatch) -> None:
|
||||||
"""#3 转账调用超时(异常),但查单确认已 SUCCESS → 不退款,单置 success。"""
|
"""#3 转账调用超时(异常),但查单确认已 SUCCESS → 不退款,单置 success。"""
|
||||||
monkeypatch.setattr("app.integrations.wxpay.code_to_userinfo", lambda code: {"openid": "openid_amb", "nickname": None, "avatar_url": None, "raw": {}})
|
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