feat(wallet): 钱包系统替代配额系统,新增支付与退款机制

- 新增钱包余额管理、充值、扣费、退款完整流程
- 新增支付网关抽象层(支持手动/支付宝/微信)
- 用量计费从配额系统迁移到钱包余额扣费
- 新增管理员钱包管理与支付订单管理页面
- 新增用户钱包中心页面
- 移除独立 Key 锁定机制,统一由钱包余额控制
- 新增相关 API 路由、序列化器与数据库迁移
- 新增钱包、支付、退款相关测试
This commit is contained in:
LewisPen
2026-03-08 00:05:48 +08:00
committed by fawney19
parent 9cdcce1b5f
commit 783f654953
108 changed files with 13152 additions and 3372 deletions

View File

@@ -0,0 +1,328 @@
from __future__ import annotations
from datetime import datetime, timedelta, timezone
from decimal import Decimal
from types import SimpleNamespace
from unittest.mock import MagicMock
import pytest
from src.models.database import PaymentOrder, Wallet
from src.services.payment.gateway import get_payment_gateway
from src.services.payment import PaymentService
CALLBACK_SECRET = "test-callback-secret"
def _sign_payload(payload: dict[str, object]) -> str:
gateway = get_payment_gateway("alipay")
signature = gateway.build_callback_signature(payload=payload, callback_secret=CALLBACK_SECRET)
assert signature is not None
return signature
def test_create_recharge_order_creates_pending_order(monkeypatch: pytest.MonkeyPatch) -> None:
db = MagicMock()
user = SimpleNamespace(id="u1")
wallet = SimpleNamespace(id="w1", status="active")
monkeypatch.setattr(
"src.services.payment.service.WalletService.get_or_create_wallet",
lambda _db, user: wallet,
)
order = PaymentService.create_recharge_order(
db,
user=user,
amount_usd="12.5",
payment_method="alipay",
pay_amount="88.00",
pay_currency="CNY",
exchange_rate="7.04",
)
db.add.assert_called_once()
db.flush.assert_called_once()
assert order.wallet_id == "w1"
assert order.user_id == "u1"
assert order.status == "pending"
assert order.payment_method == "alipay"
assert order.gateway_order_id == f"ali_{order.order_no}"
assert isinstance(order.gateway_response, dict)
assert order.gateway_response["gateway"] == "alipay"
assert Decimal(order.amount_usd) == Decimal("12.50000000")
assert Decimal(order.refundable_amount_usd) == Decimal("0")
def test_refresh_order_status_marks_expired_pending_order() -> None:
order = PaymentOrder(
id="po-expired",
order_no="order-expired",
wallet_id="w1",
user_id="u1",
amount_usd=Decimal("2.00000000"),
refunded_amount_usd=Decimal("0"),
refundable_amount_usd=Decimal("2.00000000"),
payment_method="wechat",
status="pending",
)
order.expires_at = datetime(2000, 1, 1, tzinfo=timezone.utc)
changed = PaymentService.refresh_order_status(order)
assert changed is True
assert order.status == "expired"
def test_handle_callback_is_idempotent_for_processed_callback(
monkeypatch: pytest.MonkeyPatch,
) -> None:
db = MagicMock()
callback = SimpleNamespace(status="processed", payment_order_id="po1")
monkeypatch.setattr(
"src.services.payment.service.PaymentService.log_callback",
lambda *args, **kwargs: (callback, False),
)
result = PaymentService.handle_callback(
db,
payment_method="alipay",
callback_key="cb1",
payload={"foo": "bar"},
callback_signature=_sign_payload({"foo": "bar"}),
callback_secret=CALLBACK_SECRET,
order_no="order-1",
)
assert result["ok"] is True
assert result["duplicate"] is True
assert result["credited"] is False
assert result["order_id"] == "po1"
def test_handle_callback_fails_on_amount_mismatch(monkeypatch: pytest.MonkeyPatch) -> None:
db = MagicMock()
callback = SimpleNamespace(
status="received",
payment_order_id=None,
order_no=None,
gateway_order_id=None,
error_message=None,
processed_at=None,
)
order = SimpleNamespace(
id="po1",
order_no="order-1",
gateway_order_id=None,
amount_usd=Decimal("10.00000000"),
)
monkeypatch.setattr(
"src.services.payment.service.PaymentService.log_callback",
lambda *args, **kwargs: (callback, True),
)
monkeypatch.setattr(
"src.services.payment.service.PaymentService.get_order",
lambda *args, **kwargs: order,
)
result = PaymentService.handle_callback(
db,
payment_method="wechat",
callback_key="cb2",
payload={"foo": "bar"},
callback_signature=_sign_payload({"foo": "bar"}),
callback_secret=CALLBACK_SECRET,
order_no="order-1",
amount_usd="9.99",
)
assert result["ok"] is False
assert "mismatch" in result["error"]
assert callback.status == "failed"
def test_handle_callback_rejects_invalid_signature() -> None:
db = MagicMock()
result = PaymentService.handle_callback(
db,
payment_method="alipay",
callback_key="cb-invalid-signature",
payload={"foo": "bar"},
callback_signature="invalid-signature",
callback_secret=CALLBACK_SECRET,
order_no="order-1",
amount_usd="9.99",
)
assert result["ok"] is False
assert "signature" in result["error"]
def test_credit_order_applies_wallet_recharge_once(monkeypatch: pytest.MonkeyPatch) -> None:
order = PaymentOrder(
id="po1",
order_no="order-1",
wallet_id="w1",
user_id="u1",
amount_usd=Decimal("5.00000000"),
refunded_amount_usd=Decimal("0"),
refundable_amount_usd=Decimal("0"),
payment_method="alipay",
status="pending",
)
wallet = Wallet(
id="w1",
user_id="u1",
balance=Decimal("1.00000000"),
status="active",
total_recharged=Decimal("1.00000000"),
total_consumed=Decimal("0"),
total_refunded=Decimal("0"),
total_adjusted=Decimal("0"),
currency="USD",
)
class DummyOrderQuery:
def filter(self, *args: object, **kwargs: object) -> "DummyOrderQuery":
return self
def with_for_update(self) -> "DummyOrderQuery":
return self
def one_or_none(self) -> PaymentOrder:
return order
class DummyWalletQuery:
def filter(self, *args: object, **kwargs: object) -> "DummyWalletQuery":
return self
def first(self) -> Wallet:
return wallet
db = MagicMock()
db.query.side_effect = [DummyOrderQuery(), DummyWalletQuery()]
create_tx = MagicMock()
monkeypatch.setattr(
"src.services.payment.service.WalletService.create_wallet_transaction",
create_tx,
)
updated, credited = PaymentService.credit_order(
db,
order=order,
gateway_order_id="gw-1",
pay_amount="36.00",
pay_currency="CNY",
exchange_rate="7.20",
)
assert credited is True
assert updated.status == "credited"
assert updated.gateway_order_id == "gw-1"
assert updated.paid_at is not None
assert updated.credited_at is not None
assert Decimal(updated.refundable_amount_usd) == Decimal("5.00000000")
create_tx.assert_called_once()
def test_expire_order_marks_pending_order_expired() -> None:
order = PaymentOrder(
id="po2",
order_no="order-2",
wallet_id="w1",
user_id="u1",
amount_usd=Decimal("3.00000000"),
refunded_amount_usd=Decimal("0"),
refundable_amount_usd=Decimal("3.00000000"),
payment_method="wechat",
status="pending",
)
order.expires_at = datetime.now(timezone.utc) + timedelta(minutes=30)
class DummyOrderQuery:
def filter(self, *args: object, **kwargs: object) -> "DummyOrderQuery":
return self
def with_for_update(self) -> "DummyOrderQuery":
return self
def one_or_none(self) -> PaymentOrder:
return order
db = MagicMock()
db.query.return_value = DummyOrderQuery()
updated, changed = PaymentService.expire_order(db, order=order, reason="ops_close")
assert changed is True
assert updated.status == "expired"
assert updated.gateway_response["expire_reason"] == "ops_close"
assert "expired_at" in updated.gateway_response
def test_expire_order_is_idempotent_for_existing_expired_order() -> None:
order = PaymentOrder(
id="po3",
order_no="order-3",
wallet_id="w1",
user_id="u1",
amount_usd=Decimal("1.00000000"),
refunded_amount_usd=Decimal("0"),
refundable_amount_usd=Decimal("1.00000000"),
payment_method="alipay",
status="expired",
)
class DummyOrderQuery:
def filter(self, *args: object, **kwargs: object) -> "DummyOrderQuery":
return self
def with_for_update(self) -> "DummyOrderQuery":
return self
def one_or_none(self) -> PaymentOrder:
return order
db = MagicMock()
db.query.return_value = DummyOrderQuery()
updated, changed = PaymentService.expire_order(db, order=order)
assert changed is False
assert updated.status == "expired"
def test_list_orders_expires_overdue_pending_before_filtered_count() -> None:
expiry_query = MagicMock()
expiry_query.filter.return_value = expiry_query
expiry_query.update.return_value = 2
list_query = MagicMock()
list_query.filter.return_value = list_query
list_query.count.return_value = 1
ordered_query = MagicMock()
list_query.order_by.return_value = ordered_query
ordered_query.offset.return_value = ordered_query
ordered_query.limit.return_value = ordered_query
ordered_query.all.return_value = ["order-1"]
db = MagicMock()
db.query.side_effect = [expiry_query, list_query]
items, total, changed = PaymentService.list_orders(
db,
status="pending",
payment_method="alipay",
limit=5,
offset=10,
)
assert items == ["order-1"]
assert total == 1
assert changed is True
expiry_query.update.assert_called_once()
ordered_query.offset.assert_called_once_with(10)
ordered_query.limit.assert_called_once_with(5)