feat: add recharge callback processing
This commit is contained in:
+185
-1
@@ -1,6 +1,8 @@
|
||||
import uuid
|
||||
import base64
|
||||
import json
|
||||
import tempfile
|
||||
from decimal import Decimal
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
@@ -20,7 +22,11 @@ from apps.ai.providers import (
|
||||
ImageGenerationResult,
|
||||
TextGenerationResult,
|
||||
)
|
||||
from apps.billing.models import CallRecord, PointsLedger, PricingRule
|
||||
from apps.billing.models import CallRecord, PointsLedger, PricingRule, RechargeOrder
|
||||
from apps.billing.payment_gateways import (
|
||||
build_mock_alipay_signature,
|
||||
build_mock_body_signature,
|
||||
)
|
||||
from apps.users.models import ApiKey
|
||||
from apps.users.models import UserWallet
|
||||
|
||||
@@ -191,6 +197,184 @@ class BalanceApiTests(TestCase):
|
||||
self.assertEqual(response.data["error"]["code"], "unauthorized")
|
||||
|
||||
|
||||
@override_settings(
|
||||
PAYMENT_CALLBACK_MODE="mock",
|
||||
PAYMENT_MOCK_CALLBACK_SECRET="test-payment-callback-secret",
|
||||
)
|
||||
class RechargeCallbackApiTests(TestCase):
|
||||
wechat_url = "/api/v1/recharge/callback/wechat"
|
||||
alipay_url = "/api/v1/recharge/callback/alipay"
|
||||
|
||||
def setUp(self):
|
||||
suffix = uuid.uuid4().hex[:8]
|
||||
self.user = get_user_model().objects.create_user(
|
||||
username=f"recharge-user-{suffix}",
|
||||
email=f"recharge-user-{suffix}@example.com",
|
||||
password="password",
|
||||
)
|
||||
self.wallet = UserWallet.objects.create(user=self.user, points_balance=100)
|
||||
self.client = APIClient(enforce_csrf_checks=True)
|
||||
|
||||
def create_order(
|
||||
self,
|
||||
*,
|
||||
amount="20.00",
|
||||
points_granted=200,
|
||||
pay_method=RechargeOrder.PayMethod.WEIXIN,
|
||||
) -> RechargeOrder:
|
||||
return RechargeOrder.objects.create(
|
||||
user=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,
|
||||
)
|
||||
|
||||
def signed_wechat_body(self, order, *, total_cents=2000):
|
||||
payload = {
|
||||
"event_type": "TRANSACTION.SUCCESS",
|
||||
"resource": {
|
||||
"trade_state": "SUCCESS",
|
||||
"out_trade_no": order.order_no,
|
||||
"transaction_id": "wx-txn-001",
|
||||
"success_time": "2026-07-03T00:00:00+08:00",
|
||||
"amount": {"total": total_cents},
|
||||
},
|
||||
}
|
||||
body = json.dumps(payload, separators=(",", ":")).encode("utf-8")
|
||||
return body, build_mock_body_signature(body)
|
||||
|
||||
def signed_alipay_payload(self, order, *, total_amount="20.00"):
|
||||
payload = {
|
||||
"trade_status": "TRADE_SUCCESS",
|
||||
"out_trade_no": order.order_no,
|
||||
"trade_no": "ali-txn-001",
|
||||
"total_amount": total_amount,
|
||||
"gmt_payment": "2026-07-03 00:00:00",
|
||||
}
|
||||
payload["sign"] = build_mock_alipay_signature(payload)
|
||||
return payload
|
||||
|
||||
def test_wechat_callback_credits_once_and_is_csrf_exempt(self):
|
||||
order = self.create_order(amount="20.00", points_granted=200)
|
||||
body, signature = self.signed_wechat_body(order)
|
||||
|
||||
first = self.client.post(
|
||||
self.wechat_url,
|
||||
data=body,
|
||||
content_type="application/json",
|
||||
HTTP_WECHATPAY_SIGNATURE=signature,
|
||||
)
|
||||
second = self.client.post(
|
||||
self.wechat_url,
|
||||
data=body,
|
||||
content_type="application/json",
|
||||
HTTP_WECHATPAY_SIGNATURE=signature,
|
||||
)
|
||||
|
||||
self.assertEqual(first.status_code, 200)
|
||||
self.assertEqual(second.status_code, 200)
|
||||
self.assertEqual(first.data["code"], "SUCCESS")
|
||||
self.wallet.refresh_from_db()
|
||||
order.refresh_from_db()
|
||||
self.assertEqual(self.wallet.points_balance, 300)
|
||||
self.assertEqual(order.status, RechargeOrder.Status.PAID)
|
||||
self.assertEqual(order.payment_txn_no, "wx-txn-001")
|
||||
self.assertEqual(
|
||||
PointsLedger.objects.filter(
|
||||
user=self.user,
|
||||
ref_order_id=order.id,
|
||||
change_type=PointsLedger.ChangeType.RECHARGE,
|
||||
).count(),
|
||||
1,
|
||||
)
|
||||
|
||||
def test_wechat_callback_rejects_bad_signature_without_crediting(self):
|
||||
order = self.create_order(amount="20.00", points_granted=200)
|
||||
body, _signature = self.signed_wechat_body(order)
|
||||
|
||||
response = self.client.post(
|
||||
self.wechat_url,
|
||||
data=body,
|
||||
content_type="application/json",
|
||||
HTTP_WECHATPAY_SIGNATURE="bad-signature",
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 400)
|
||||
self.assertEqual(response.data["error"]["code"], "signature_invalid")
|
||||
self.wallet.refresh_from_db()
|
||||
order.refresh_from_db()
|
||||
self.assertEqual(self.wallet.points_balance, 100)
|
||||
self.assertEqual(order.status, RechargeOrder.Status.PENDING)
|
||||
self.assertFalse(PointsLedger.objects.filter(ref_order_id=order.id).exists())
|
||||
|
||||
def test_wechat_callback_rejects_amount_mismatch_without_crediting(self):
|
||||
order = self.create_order(amount="20.00", points_granted=200)
|
||||
body, signature = self.signed_wechat_body(order, total_cents=1999)
|
||||
|
||||
response = self.client.post(
|
||||
self.wechat_url,
|
||||
data=body,
|
||||
content_type="application/json",
|
||||
HTTP_WECHATPAY_SIGNATURE=signature,
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 400)
|
||||
self.assertEqual(response.data["error"]["code"], "amount_mismatch")
|
||||
self.wallet.refresh_from_db()
|
||||
order.refresh_from_db()
|
||||
self.assertEqual(self.wallet.points_balance, 100)
|
||||
self.assertEqual(order.status, RechargeOrder.Status.PENDING)
|
||||
self.assertFalse(PointsLedger.objects.filter(ref_order_id=order.id).exists())
|
||||
|
||||
def test_alipay_callback_credits_once_returns_success_and_is_csrf_exempt(self):
|
||||
order = self.create_order(
|
||||
amount="20.00",
|
||||
points_granted=200,
|
||||
pay_method=RechargeOrder.PayMethod.ALIPAY,
|
||||
)
|
||||
payload = self.signed_alipay_payload(order)
|
||||
|
||||
first = self.client.post(self.alipay_url, data=payload)
|
||||
second = self.client.post(self.alipay_url, data=payload)
|
||||
|
||||
self.assertEqual(first.status_code, 200)
|
||||
self.assertEqual(second.status_code, 200)
|
||||
self.assertEqual(first.content, b"success")
|
||||
self.wallet.refresh_from_db()
|
||||
order.refresh_from_db()
|
||||
self.assertEqual(self.wallet.points_balance, 300)
|
||||
self.assertEqual(order.status, RechargeOrder.Status.PAID)
|
||||
self.assertEqual(order.payment_txn_no, "ali-txn-001")
|
||||
self.assertEqual(
|
||||
PointsLedger.objects.filter(
|
||||
ref_order_id=order.id,
|
||||
change_type=PointsLedger.ChangeType.RECHARGE,
|
||||
).count(),
|
||||
1,
|
||||
)
|
||||
|
||||
def test_alipay_callback_rejects_bad_signature_without_crediting(self):
|
||||
order = self.create_order(
|
||||
amount="20.00",
|
||||
points_granted=200,
|
||||
pay_method=RechargeOrder.PayMethod.ALIPAY,
|
||||
)
|
||||
payload = self.signed_alipay_payload(order)
|
||||
payload["sign"] = "bad-signature"
|
||||
|
||||
response = self.client.post(self.alipay_url, data=payload)
|
||||
|
||||
self.assertEqual(response.status_code, 400)
|
||||
self.assertEqual(response.content, b"fail")
|
||||
self.wallet.refresh_from_db()
|
||||
order.refresh_from_db()
|
||||
self.assertEqual(self.wallet.points_balance, 100)
|
||||
self.assertEqual(order.status, RechargeOrder.Status.PENDING)
|
||||
self.assertFalse(PointsLedger.objects.filter(ref_order_id=order.id).exists())
|
||||
|
||||
|
||||
class FakeGenerationProvider:
|
||||
def __init__(self, *, capabilities=None):
|
||||
self._capabilities = set(capabilities or {"text", "image", "vision"})
|
||||
|
||||
+17
-1
@@ -1,9 +1,25 @@
|
||||
from django.urls import path
|
||||
|
||||
from .views import BalanceView, GenerateImageView, GenerateTitleView
|
||||
from .views import (
|
||||
AlipayRechargeCallbackView,
|
||||
BalanceView,
|
||||
GenerateImageView,
|
||||
GenerateTitleView,
|
||||
WechatRechargeCallbackView,
|
||||
)
|
||||
|
||||
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/callback/wechat",
|
||||
WechatRechargeCallbackView.as_view(),
|
||||
name="api-recharge-callback-wechat",
|
||||
),
|
||||
path(
|
||||
"v1/recharge/callback/alipay",
|
||||
AlipayRechargeCallbackView.as_view(),
|
||||
name="api-recharge-callback-alipay",
|
||||
),
|
||||
]
|
||||
|
||||
+80
-1
@@ -1,3 +1,8 @@
|
||||
import logging
|
||||
|
||||
from django.http import HttpResponse
|
||||
from django.utils.decorators import method_decorator
|
||||
from django.views.decorators.csrf import csrf_exempt
|
||||
from rest_framework.exceptions import AuthenticationFailed
|
||||
from rest_framework.permissions import IsAuthenticated
|
||||
from rest_framework.response import Response
|
||||
@@ -15,7 +20,22 @@ from apps.api.serializers import (
|
||||
GenerateImageRequestSerializer,
|
||||
GenerateTitleRequestSerializer,
|
||||
)
|
||||
from apps.billing.services import get_balance_snapshot
|
||||
from apps.billing.payment_gateways import (
|
||||
PaymentVerificationError,
|
||||
verify_alipay_callback,
|
||||
verify_wechat_callback,
|
||||
)
|
||||
from apps.billing.services import (
|
||||
InvalidRechargeOrderStateError,
|
||||
RechargeAmountMismatchError,
|
||||
RechargeCallbackError,
|
||||
RechargeOrderNotFoundError,
|
||||
RechargePayMethodMismatchError,
|
||||
apply_recharge_payment,
|
||||
get_balance_snapshot,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ExternalApiView(APIView):
|
||||
@@ -77,3 +97,62 @@ class BalanceView(ExternalApiView):
|
||||
},
|
||||
status=status.HTTP_200_OK,
|
||||
)
|
||||
|
||||
|
||||
class RechargeCallbackView(APIView):
|
||||
authentication_classes = ()
|
||||
permission_classes = ()
|
||||
|
||||
@staticmethod
|
||||
def _error_response(exc: Exception):
|
||||
if isinstance(exc, PaymentVerificationError):
|
||||
return (
|
||||
api_error("signature_invalid", "支付回调验签失败"),
|
||||
status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
if isinstance(exc, RechargeAmountMismatchError):
|
||||
return (
|
||||
api_error("amount_mismatch", "支付回调金额与本地订单金额不一致"),
|
||||
status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
if isinstance(exc, RechargeCallbackError):
|
||||
return api_error(exc.code, "支付回调处理失败"), status.HTTP_400_BAD_REQUEST
|
||||
return api_error("bad_request", "支付回调处理失败"), status.HTTP_400_BAD_REQUEST
|
||||
|
||||
|
||||
@method_decorator(csrf_exempt, name="dispatch")
|
||||
class WechatRechargeCallbackView(RechargeCallbackView):
|
||||
def post(self, request):
|
||||
try:
|
||||
payment = verify_wechat_callback(request.headers, request.body)
|
||||
apply_recharge_payment(payment)
|
||||
except (
|
||||
PaymentVerificationError,
|
||||
RechargeAmountMismatchError,
|
||||
InvalidRechargeOrderStateError,
|
||||
RechargeOrderNotFoundError,
|
||||
RechargePayMethodMismatchError,
|
||||
) as exc:
|
||||
logger.warning("Rejected WeChat recharge callback: %s", exc.__class__.__name__)
|
||||
data, http_status = self._error_response(exc)
|
||||
return Response(data, status=http_status)
|
||||
return Response({"code": "SUCCESS", "message": "成功"}, status=status.HTTP_200_OK)
|
||||
|
||||
|
||||
@method_decorator(csrf_exempt, name="dispatch")
|
||||
class AlipayRechargeCallbackView(RechargeCallbackView):
|
||||
def post(self, request):
|
||||
try:
|
||||
payload = request.data.dict() if hasattr(request.data, "dict") else dict(request.data)
|
||||
payment = verify_alipay_callback(payload)
|
||||
apply_recharge_payment(payment)
|
||||
except (
|
||||
PaymentVerificationError,
|
||||
RechargeAmountMismatchError,
|
||||
InvalidRechargeOrderStateError,
|
||||
RechargeOrderNotFoundError,
|
||||
RechargePayMethodMismatchError,
|
||||
) as exc:
|
||||
logger.warning("Rejected Alipay recharge callback: %s", exc.__class__.__name__)
|
||||
return HttpResponse("fail", status=status.HTTP_400_BAD_REQUEST)
|
||||
return HttpResponse("success", content_type="text/plain", status=status.HTTP_200_OK)
|
||||
|
||||
Reference in New Issue
Block a user