feat: add recharge callback processing

This commit is contained in:
QiuSW
2026-07-03 09:07:21 +08:00
parent 8931960b35
commit c15a07a5c2
20 changed files with 1073 additions and 25 deletions
+185 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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)