feat: add recharge create and status APIs

This commit is contained in:
QiuSW
2026-07-03 09:34:07 +08:00
parent c15a07a5c2
commit fa87359a2c
18 changed files with 808 additions and 22 deletions
+21
View File
@@ -1,5 +1,9 @@
from decimal import Decimal
from rest_framework import serializers
from apps.billing.models import RechargeOrder
class GenerateTitleRequestSerializer(serializers.Serializer):
prompt = serializers.CharField(trim_whitespace=True, allow_blank=False)
@@ -43,3 +47,20 @@ class GenerateImageRequestSerializer(serializers.Serializer):
default="1:1",
)
parameters = serializers.DictField(required=False, default=dict)
class RechargeCreateRequestSerializer(serializers.Serializer):
amount = serializers.DecimalField(
max_digits=12,
decimal_places=2,
min_value=Decimal("0.01"),
)
pay_method = serializers.ChoiceField(choices=RechargeOrder.PayMethod.values)
class RechargeStatusRequestSerializer(serializers.Serializer):
order_no = serializers.CharField(
trim_whitespace=True,
allow_blank=False,
max_length=64,
)
+171 -1
View File
@@ -10,6 +10,7 @@ from cryptography.fernet import Fernet
from django.contrib.auth import get_user_model
from django.test import TestCase, override_settings
from django.urls import path
from django.utils import timezone
from rest_framework.response import Response
from rest_framework.test import APIClient
@@ -22,11 +23,18 @@ from apps.ai.providers import (
ImageGenerationResult,
TextGenerationResult,
)
from apps.billing.models import CallRecord, PointsLedger, PricingRule, RechargeOrder
from apps.billing.models import (
CallRecord,
ExchangeRate,
PointsLedger,
PricingRule,
RechargeOrder,
)
from apps.billing.payment_gateways import (
build_mock_alipay_signature,
build_mock_body_signature,
)
from apps.billing.services import RechargePayment
from apps.users.models import ApiKey
from apps.users.models import UserWallet
@@ -375,6 +383,168 @@ class RechargeCallbackApiTests(TestCase):
self.assertFalse(PointsLedger.objects.filter(ref_order_id=order.id).exists())
@override_settings(
PAYMENT_CALLBACK_MODE="mock",
PAYMENT_MOCK_CALLBACK_SECRET="test-payment-callback-secret",
PAYMENT_QR_EXPIRES_MINUTES=15,
)
class RechargeCreateStatusApiTests(TestCase):
create_url = "/api/v1/recharge/create"
status_url = "/api/v1/recharge/status"
def setUp(self):
suffix = uuid.uuid4().hex[:8]
self.user = get_user_model().objects.create_user(
username=f"recharge-create-{suffix}",
email=f"recharge-create-{suffix}@example.com",
password="password",
)
self.other_user = get_user_model().objects.create_user(
username=f"recharge-other-{suffix}",
email=f"recharge-other-{suffix}@example.com",
password="password",
)
self.wallet = UserWallet.objects.create(user=self.user, points_balance=100)
self.api_key, self.raw_key = ApiKey.create_for_user(self.user, name="recharge")
self.exchange_rate = ExchangeRate.objects.create(
currency="CNY",
points_per_unit=Decimal("10.0000"),
effective_from=timezone.now(),
)
self.client = APIClient()
def create_order(
self,
*,
user=None,
amount="20.00",
points_granted=200,
pay_method=RechargeOrder.PayMethod.WEIXIN,
):
return RechargeOrder.objects.create(
user=user or self.user,
order_no=f"R{uuid.uuid4().hex[:12]}",
amount_money=Decimal(amount),
pay_method=pay_method,
exchange_rate=Decimal("10.0000"),
points_granted=points_granted,
code_url=f"mockpay://{pay_method}/existing",
)
def test_recharge_create_requires_web_session_not_api_key(self):
response = self.client.post(
self.create_url,
{"amount": "20.00", "pay_method": "weixin"},
format="json",
HTTP_AUTHORIZATION=f"Bearer {self.raw_key}",
)
self.assertEqual(response.status_code, 403)
self.assertFalse(RechargeOrder.objects.filter(user=self.user).exists())
def test_recharge_create_is_session_authenticated_and_locks_quote(self):
self.client.force_login(self.user)
response = self.client.post(
self.create_url,
{"amount": "20.00", "pay_method": "weixin"},
format="json",
)
self.assertEqual(response.status_code, 201)
self.assertEqual(response.data["amount"], "20.00")
self.assertEqual(response.data["exchange_rate"], "10.0000")
self.assertEqual(response.data["points_granted"], 200)
self.assertEqual(response.data["pay_method"], RechargeOrder.PayMethod.WEIXIN)
self.assertEqual(response.data["status"], RechargeOrder.Status.PENDING)
self.assertTrue(response.data["code_url"].startswith("weixin://wxpay/cmhub-mock"))
self.assertIsNotNone(response.data["expires_at"])
order = RechargeOrder.objects.get(order_no=response.data["order_no"])
self.assertEqual(order.user, self.user)
self.assertEqual(order.exchange_rate, Decimal("10.0000"))
self.assertEqual(order.points_granted, 200)
self.wallet.refresh_from_db()
self.assertEqual(self.wallet.points_balance, 100)
self.assertFalse(PointsLedger.objects.filter(ref_order_id=order.id).exists())
def test_recharge_create_supports_alipay_mock_qr_code(self):
self.client.force_login(self.user)
response = self.client.post(
self.create_url,
{"amount": "30.00", "pay_method": "alipay"},
format="json",
)
self.assertEqual(response.status_code, 201)
self.assertEqual(response.data["pay_method"], RechargeOrder.PayMethod.ALIPAY)
self.assertTrue(response.data["code_url"].startswith("https://qr.alipay.com/cmhub-mock"))
def test_recharge_create_enforces_csrf_for_real_session_clients(self):
csrf_client = APIClient(enforce_csrf_checks=True)
csrf_client.force_login(self.user)
response = csrf_client.post(
self.create_url,
{"amount": "20.00", "pay_method": "weixin"},
format="json",
)
self.assertEqual(response.status_code, 403)
self.assertFalse(RechargeOrder.objects.filter(user=self.user).exists())
def test_recharge_status_returns_pending_order_for_owner_only(self):
order = self.create_order()
self.client.force_login(self.user)
response = self.client.get(self.status_url, {"order_no": order.order_no})
self.assertEqual(response.status_code, 200)
self.assertEqual(response.data["order_no"], order.order_no)
self.assertEqual(response.data["status"], RechargeOrder.Status.PENDING)
self.client.force_login(self.other_user)
denied = self.client.get(self.status_url, {"order_no": order.order_no})
self.assertEqual(denied.status_code, 404)
self.assertEqual(denied.data["error"]["code"], "order_not_found")
def test_recharge_status_active_query_can_apply_paid_order_once(self):
order = self.create_order(amount="20.00", points_granted=200)
self.client.force_login(self.user)
def fake_query(queried_order):
return RechargePayment(
order_no=queried_order.order_no,
pay_method=queried_order.pay_method,
amount=queried_order.amount_money,
transaction_id="queried-txn-001",
paid_at=timezone.now(),
)
with patch("apps.api.views.query_payment_order", side_effect=fake_query) as query:
first = self.client.get(self.status_url, {"order_no": order.order_no})
second = self.client.get(self.status_url, {"order_no": order.order_no})
self.assertEqual(first.status_code, 200)
self.assertEqual(second.status_code, 200)
self.assertEqual(first.data["status"], RechargeOrder.Status.PAID)
self.assertEqual(second.data["status"], RechargeOrder.Status.PAID)
self.assertEqual(query.call_count, 1)
self.wallet.refresh_from_db()
order.refresh_from_db()
self.assertEqual(self.wallet.points_balance, 300)
self.assertEqual(order.payment_txn_no, "queried-txn-001")
self.assertEqual(
PointsLedger.objects.filter(
ref_order_id=order.id,
change_type=PointsLedger.ChangeType.RECHARGE,
).count(),
1,
)
class FakeGenerationProvider:
def __init__(self, *, capabilities=None):
self._capabilities = set(capabilities or {"text", "image", "vision"})
+4
View File
@@ -5,6 +5,8 @@ from .views import (
BalanceView,
GenerateImageView,
GenerateTitleView,
RechargeCreateView,
RechargeStatusView,
WechatRechargeCallbackView,
)
@@ -12,6 +14,8 @@ urlpatterns = [
path("v1/balance", BalanceView.as_view(), name="api-balance"),
path("v1/generate/title", GenerateTitleView.as_view(), name="api-generate-title"),
path("v1/generate/image", GenerateImageView.as_view(), name="api-generate-image"),
path("v1/recharge/create", RechargeCreateView.as_view(), name="api-recharge-create"),
path("v1/recharge/status", RechargeStatusView.as_view(), name="api-recharge-status"),
path(
"v1/recharge/callback/wechat",
WechatRechargeCallbackView.as_view(),
+112
View File
@@ -1,8 +1,10 @@
import logging
from django.http import HttpResponse
from django.utils import timezone
from django.utils.decorators import method_decorator
from django.views.decorators.csrf import csrf_exempt
from rest_framework.authentication import SessionAuthentication
from rest_framework.exceptions import AuthenticationFailed
from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
@@ -19,20 +21,30 @@ from apps.api.generation import (
from apps.api.serializers import (
GenerateImageRequestSerializer,
GenerateTitleRequestSerializer,
RechargeCreateRequestSerializer,
RechargeStatusRequestSerializer,
)
from apps.billing.models import RechargeOrder
from apps.billing.payment_gateways import (
PaymentOrderCreateError,
PaymentQueryUnavailableError,
PaymentVerificationError,
query_payment_order,
verify_alipay_callback,
verify_wechat_callback,
)
from apps.billing.pricing import NoExchangeRateError
from apps.billing.services import (
InvalidRechargeOrderStateError,
RechargeAmountMismatchError,
RechargeCallbackError,
RechargeOrderCreateError,
RechargeOrderNotFoundError,
RechargePayMethodMismatchError,
apply_recharge_payment,
create_recharge_order,
get_balance_snapshot,
query_and_apply_recharge_payment,
)
logger = logging.getLogger(__name__)
@@ -99,6 +111,106 @@ class BalanceView(ExternalApiView):
)
class PortalSessionApiView(APIView):
authentication_classes = (SessionAuthentication,)
permission_classes = (IsAuthenticated,)
def _recharge_order_response(order: RechargeOrder) -> dict:
is_expired = bool(
order.status == RechargeOrder.Status.PENDING
and order.expires_at is not None
and order.expires_at <= timezone.now()
)
return {
"order_no": order.order_no,
"amount": f"{order.amount_money:.2f}",
"currency": order.currency,
"exchange_rate": f"{order.exchange_rate:.4f}",
"points_granted": order.points_granted,
"pay_method": order.pay_method,
"status": order.status,
"code_url": order.code_url,
"expires_at": order.expires_at.isoformat() if order.expires_at else None,
"paid_at": order.paid_at.isoformat() if order.paid_at else None,
"is_expired": is_expired,
}
class RechargeCreateView(PortalSessionApiView):
def post(self, request):
serializer = RechargeCreateRequestSerializer(data=request.data)
if not serializer.is_valid():
return Response(
api_error("bad_request", "参数错误"),
status=status.HTTP_400_BAD_REQUEST,
)
try:
order = create_recharge_order(
user=request.user,
amount=serializer.validated_data["amount"],
pay_method=serializer.validated_data["pay_method"],
)
except NoExchangeRateError:
return Response(
api_error("no_exchange_rate", "未配置当前币种汇率"),
status=status.HTTP_400_BAD_REQUEST,
)
except RechargeOrderCreateError:
return Response(
api_error("bad_request", "充值下单参数错误"),
status=status.HTTP_400_BAD_REQUEST,
)
except PaymentOrderCreateError:
return Response(
api_error("payment_order_create_failed", "支付下单失败"),
status=status.HTTP_502_BAD_GATEWAY,
)
return Response(_recharge_order_response(order), status=status.HTTP_201_CREATED)
class RechargeStatusView(PortalSessionApiView):
def get(self, request):
serializer = RechargeStatusRequestSerializer(data=request.query_params)
if not serializer.is_valid():
return Response(
api_error("bad_request", "参数错误"),
status=status.HTTP_400_BAD_REQUEST,
)
order_no = serializer.validated_data["order_no"]
order = RechargeOrder.objects.filter(
user=request.user,
order_no=order_no,
).first()
if order is None:
return Response(
api_error("order_not_found", "充值订单不存在"),
status=status.HTTP_404_NOT_FOUND,
)
if order.status == RechargeOrder.Status.PENDING:
try:
result = query_and_apply_recharge_payment(order.order_no, query_payment_order)
order = result.order
except (PaymentQueryUnavailableError, PaymentOrderCreateError):
order.refresh_from_db()
except RechargeCallbackError as exc:
logger.warning(
"Rejected active recharge query result for %s: %s",
order.order_no,
exc.__class__.__name__,
)
return Response(
api_error(exc.code, "支付查单结果与本地订单不一致"),
status=status.HTTP_400_BAD_REQUEST,
)
return Response(_recharge_order_response(order), status=status.HTTP_200_OK)
class RechargeCallbackView(APIView):
authentication_classes = ()
permission_classes = ()