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,232 @@
from __future__ import annotations
from decimal import Decimal
from types import SimpleNamespace
from unittest.mock import MagicMock
import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
from src.api.admin.payments.routes import AdminPaymentOrderCreditAdapter
from src.api.payment.routes import router as payment_router
from src.config import config
from src.database import get_db
from src.models.database import PaymentOrder
from src.services.payment.gateway import get_payment_gateway
CALLBACK_SECRET = "test-callback-secret"
def _build_payment_app(db: MagicMock) -> TestClient:
app = FastAPI()
app.include_router(payment_router)
app.dependency_overrides[get_db] = lambda: db
return TestClient(app)
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_specific_wechat_callback_route_is_not_shadowed(
monkeypatch: pytest.MonkeyPatch,
) -> None:
db = MagicMock()
client = _build_payment_app(db)
monkeypatch.setattr(config, "payment_callback_secret", CALLBACK_SECRET)
captured_kwargs: dict[str, object] = {}
def _fake_handle_callback(*args: object, **kwargs: object) -> dict[str, object]:
captured_kwargs.update(kwargs)
return {
"ok": True,
"credited": True,
"duplicate": False,
"payment_method_seen": kwargs["payment_method"],
}
monkeypatch.setattr("src.api.payment.routes.PaymentService.handle_callback", _fake_handle_callback)
callback_payload = {"callback_key": "cb-wechat", "amount_usd": 1.0}
response = client.post(
"/api/payment/callback/wechat",
json=callback_payload,
headers={
"x-payment-callback-token": CALLBACK_SECRET,
"x-payment-callback-signature": _sign_payload(callback_payload),
},
)
assert response.status_code == 200
payload = response.json()
assert payload["payment_method"] == "wechat"
assert payload["payment_method_seen"] == "wechat"
assert payload["request_path"] == "/api/payment/callback/wechat"
assert captured_kwargs["callback_signature"] == _sign_payload(callback_payload)
assert captured_kwargs["callback_secret"] == CALLBACK_SECRET
assert "signature_valid" not in captured_kwargs
db.commit.assert_called_once()
def test_generic_payment_callback_route_still_handles_custom_methods(
monkeypatch: pytest.MonkeyPatch,
) -> None:
db = MagicMock()
client = _build_payment_app(db)
monkeypatch.setattr(config, "payment_callback_secret", CALLBACK_SECRET)
captured_kwargs: dict[str, object] = {}
def _fake_handle_callback(*args: object, **kwargs: object) -> dict[str, object]:
captured_kwargs.update(kwargs)
return {
"ok": True,
"credited": False,
"duplicate": False,
"payment_method_seen": kwargs["payment_method"],
}
monkeypatch.setattr("src.api.payment.routes.PaymentService.handle_callback", _fake_handle_callback)
callback_payload = {"callback_key": "cb-generic", "amount_usd": 1.0}
response = client.post(
"/api/payment/callback/mockpay",
json=callback_payload,
headers={
"x-payment-callback-token": CALLBACK_SECRET,
"x-payment-callback-signature": _sign_payload(callback_payload),
},
)
assert response.status_code == 200
payload = response.json()
assert payload["payment_method"] == "mockpay"
assert payload["payment_method_seen"] == "mockpay"
assert captured_kwargs["callback_signature"] == _sign_payload(callback_payload)
assert captured_kwargs["callback_secret"] == CALLBACK_SECRET
assert "signature_valid" not in captured_kwargs
def test_callback_requires_shared_token(monkeypatch: pytest.MonkeyPatch) -> None:
db = MagicMock()
client = _build_payment_app(db)
monkeypatch.setattr(config, "payment_callback_secret", CALLBACK_SECRET)
response = client.post(
"/api/payment/callback/alipay",
json={"callback_key": "cb-missing-token", "amount_usd": 1.0},
)
assert response.status_code == 401
db.commit.assert_not_called()
def test_callback_rejects_invalid_shared_token(monkeypatch: pytest.MonkeyPatch) -> None:
db = MagicMock()
client = _build_payment_app(db)
monkeypatch.setattr(config, "payment_callback_secret", CALLBACK_SECRET)
callback_payload = {"callback_key": "cb-invalid-token", "amount_usd": 1.0}
response = client.post(
"/api/payment/callback/alipay",
json=callback_payload,
headers={
"x-payment-callback-token": "wrong-token",
"x-payment-callback-signature": _sign_payload(callback_payload),
},
)
assert response.status_code == 401
db.commit.assert_not_called()
def test_callback_rejects_missing_signature(monkeypatch: pytest.MonkeyPatch) -> None:
db = MagicMock()
client = _build_payment_app(db)
monkeypatch.setattr(config, "payment_callback_secret", CALLBACK_SECRET)
response = client.post(
"/api/payment/callback/alipay",
json={"callback_key": "cb-missing-signature", "amount_usd": 1.0},
headers={"x-payment-callback-token": CALLBACK_SECRET},
)
assert response.status_code == 401
db.commit.assert_not_called()
def test_callback_disabled_when_secret_not_configured(monkeypatch: pytest.MonkeyPatch) -> None:
db = MagicMock()
client = _build_payment_app(db)
monkeypatch.setattr(config, "payment_callback_secret", "")
response = client.post(
"/api/payment/callback/alipay",
json={"callback_key": "cb-secret-missing", "amount_usd": 1.0},
)
assert response.status_code == 503
db.commit.assert_not_called()
@pytest.mark.asyncio
async def test_admin_payment_credit_adapter_marks_manual_credit(
monkeypatch: pytest.MonkeyPatch,
) -> None:
db = MagicMock()
order = PaymentOrder(
id="po-credit",
order_no="order-credit",
wallet_id="w1",
user_id="u1",
amount_usd=Decimal("8.00000000"),
refunded_amount_usd=Decimal("0"),
refundable_amount_usd=Decimal("8.00000000"),
payment_method="alipay",
status="pending",
gateway_response={"existing": True},
)
adapter = AdminPaymentOrderCreditAdapter(order_id=order.id)
context = SimpleNamespace(
db=db,
raw_body=b"{}",
ensure_json_body=lambda: {
"pay_amount": 58.0,
"pay_currency": "CNY",
"exchange_rate": 7.25,
},
user=SimpleNamespace(id="admin-1"),
)
monkeypatch.setattr(
"src.api.admin.payments.routes.PaymentService.get_order",
lambda _db, order_id: order if order_id == "po-credit" else None,
)
captured: dict[str, object] = {}
def _fake_credit_order(_db: MagicMock, **kwargs: object) -> tuple[PaymentOrder, bool]:
captured.update(kwargs)
return order, True
monkeypatch.setattr(
"src.api.admin.payments.routes.PaymentService.credit_order",
_fake_credit_order,
)
result = await adapter.handle(context)
assert result["credited"] is True
assert result["order"]["id"] == "po-credit"
gateway_response = captured["gateway_response"]
assert isinstance(gateway_response, dict)
assert gateway_response["existing"] is True
assert gateway_response["manual_credit"] is True
assert gateway_response["credited_by"] == "admin-1"
db.commit.assert_called_once()