fix: stabilize production payment flow
This commit is contained in:
@@ -742,6 +742,27 @@ class RechargeCreateStatusApiTests(TestCase):
|
||||
1,
|
||||
)
|
||||
|
||||
def test_recharge_status_keeps_pending_when_active_query_raises_unexpected_error(self):
|
||||
order = self.create_order(amount="20.00", points_granted=200)
|
||||
self.client.force_login(self.user)
|
||||
|
||||
with patch("apps.api.views.query_payment_order", side_effect=RuntimeError("gateway down")):
|
||||
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.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,
|
||||
change_type=PointsLedger.ChangeType.RECHARGE,
|
||||
).exists()
|
||||
)
|
||||
|
||||
|
||||
class FakeGenerationProvider:
|
||||
def __init__(self, *, capabilities=None):
|
||||
|
||||
@@ -222,6 +222,13 @@ class RechargeStatusView(PortalSessionApiView):
|
||||
api_error(exc.code, "支付查单结果与本地订单不一致"),
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
except Exception as exc: # pragma: no cover - external gateway/runtime.
|
||||
logger.warning(
|
||||
"Active recharge query failed for %s: %s",
|
||||
order.order_no,
|
||||
exc.__class__.__name__,
|
||||
)
|
||||
order.refresh_from_db()
|
||||
|
||||
return Response(_recharge_order_response(order), status=status.HTTP_200_OK)
|
||||
|
||||
|
||||
@@ -94,6 +94,25 @@ def _wechat_amount_cents(value) -> int:
|
||||
return int((_decimal_money(value) * Decimal("100")).to_integral_value())
|
||||
|
||||
|
||||
def _wechat_response_payload(response) -> dict:
|
||||
if isinstance(response, dict):
|
||||
return response
|
||||
if isinstance(response, tuple) and len(response) >= 2:
|
||||
payload = response[1]
|
||||
if isinstance(payload, dict):
|
||||
return payload
|
||||
if isinstance(payload, bytes):
|
||||
payload = payload.decode("utf-8")
|
||||
if isinstance(payload, str):
|
||||
try:
|
||||
parsed = json.loads(payload)
|
||||
except json.JSONDecodeError as exc:
|
||||
raise ValueError("WeChat API response is not valid JSON.") from exc
|
||||
if isinstance(parsed, dict):
|
||||
return parsed
|
||||
raise ValueError("WeChat API response has an unsupported format.")
|
||||
|
||||
|
||||
def _default_expires_at():
|
||||
minutes = int(getattr(settings, "PAYMENT_QR_EXPIRES_MINUTES", 10) or 10)
|
||||
return timezone.now() + timedelta(minutes=minutes)
|
||||
@@ -198,7 +217,7 @@ def _create_mock_payment_order(order: RechargeOrder) -> PaymentOrderCode:
|
||||
|
||||
def _create_wechat_payment_order_with_sdk(order: RechargeOrder) -> PaymentOrderCode:
|
||||
try:
|
||||
from wechatpayv3 import WeChatPay # type: ignore
|
||||
from wechatpayv3 import WeChatPay, WeChatPayType # type: ignore
|
||||
except ImportError as exc:
|
||||
raise PaymentOrderCreateError("wechatpayv3 is not installed.") from exc
|
||||
|
||||
@@ -231,11 +250,16 @@ def _create_wechat_payment_order_with_sdk(order: RechargeOrder) -> PaymentOrderC
|
||||
"total": _wechat_amount_cents(order.amount_money),
|
||||
"currency": order.currency,
|
||||
},
|
||||
pay_type=WeChatPayType.NATIVE,
|
||||
)
|
||||
except Exception as exc: # pragma: no cover - depends on merchant SDK/runtime.
|
||||
raise PaymentOrderCreateError("WeChat native order creation failed.") from exc
|
||||
|
||||
code_url = response.get("code_url") if isinstance(response, dict) else None
|
||||
try:
|
||||
payload = _wechat_response_payload(response)
|
||||
except ValueError as exc:
|
||||
raise PaymentOrderCreateError("WeChat native order response was invalid.") from exc
|
||||
code_url = payload.get("code_url")
|
||||
if not code_url:
|
||||
raise PaymentOrderCreateError("WeChat native order did not return code_url.")
|
||||
return PaymentOrderCode(code_url=str(code_url), expires_at=_default_expires_at())
|
||||
@@ -398,7 +422,7 @@ def _query_wechat_payment_order_with_sdk(order: RechargeOrder) -> RechargePaymen
|
||||
"WeChat active payment query SDK binding is not configured."
|
||||
)
|
||||
try:
|
||||
resource = query(out_trade_no=order.order_no)
|
||||
resource = _wechat_response_payload(query(out_trade_no=order.order_no))
|
||||
except Exception as exc: # pragma: no cover - depends on merchant SDK/runtime.
|
||||
raise PaymentQueryUnavailableError("WeChat trade query failed.") from exc
|
||||
if resource.get("trade_state") != "SUCCESS":
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
import threading
|
||||
import time
|
||||
import uuid
|
||||
import sys
|
||||
import tempfile
|
||||
from datetime import timedelta
|
||||
from decimal import Decimal
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import patch
|
||||
|
||||
from cryptography.fernet import Fernet
|
||||
from django.contrib import admin
|
||||
@@ -28,6 +32,7 @@ from apps.billing.pricing import (
|
||||
get_pricing_rule,
|
||||
quote_recharge_points,
|
||||
)
|
||||
from apps.billing import payment_gateways
|
||||
from apps.billing.payment_gateways import PaymentOrderCode
|
||||
from apps.billing.services import (
|
||||
InsufficientPointsError,
|
||||
@@ -497,6 +502,97 @@ class BillingServiceTests(TestCase):
|
||||
self.assertEqual(order.status, RechargeOrder.Status.FAILED)
|
||||
self.assertEqual(order.code_url, "")
|
||||
|
||||
def test_wechat_sdk_native_order_passes_explicit_pay_type(self):
|
||||
order = self.create_recharge_order(amount="1.00", points_granted=10)
|
||||
|
||||
class FakeWeChatPayType:
|
||||
NATIVE = object()
|
||||
|
||||
class FakeWeChatPay:
|
||||
pay_kwargs = None
|
||||
|
||||
def __init__(self, **_kwargs):
|
||||
pass
|
||||
|
||||
def pay(self, **kwargs):
|
||||
FakeWeChatPay.pay_kwargs = kwargs
|
||||
return (
|
||||
200,
|
||||
'{"code_url":"weixin://wxpay/bizpayurl?pr=test-ticket"}',
|
||||
)
|
||||
|
||||
fake_module = SimpleNamespace(
|
||||
WeChatPay=FakeWeChatPay,
|
||||
WeChatPayType=FakeWeChatPayType,
|
||||
)
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
private_key_path = f"{tmpdir}/apiclient_key.pem"
|
||||
with open(private_key_path, "w", encoding="utf-8") as private_key:
|
||||
private_key.write("test-private-key")
|
||||
|
||||
with (
|
||||
patch.dict(sys.modules, {"wechatpayv3": fake_module}),
|
||||
override_settings(
|
||||
WECHAT_PAY_APPID="wx-test-appid",
|
||||
WECHAT_PAY_MCHID="1900000001",
|
||||
WECHAT_PAY_API_V3_KEY="a" * 32,
|
||||
WECHAT_PAY_CERT_SERIAL_NO="ABC123",
|
||||
WECHAT_PAY_PRIVATE_KEY_PATH=private_key_path,
|
||||
WECHAT_PAY_NOTIFY_URL="https://cm.example.test/api/v1/recharge/callback/wechat",
|
||||
),
|
||||
):
|
||||
payment_order = payment_gateways._create_wechat_payment_order_with_sdk(order)
|
||||
|
||||
self.assertEqual(
|
||||
payment_order.code_url,
|
||||
"weixin://wxpay/bizpayurl?pr=test-ticket",
|
||||
)
|
||||
self.assertIs(FakeWeChatPay.pay_kwargs["pay_type"], FakeWeChatPayType.NATIVE)
|
||||
|
||||
def test_wechat_sdk_query_parses_tuple_json_response(self):
|
||||
order = self.create_recharge_order(amount="1.00", points_granted=10)
|
||||
|
||||
class FakeWeChatPay:
|
||||
def __init__(self, **_kwargs):
|
||||
pass
|
||||
|
||||
def query(self, **_kwargs):
|
||||
return (
|
||||
200,
|
||||
(
|
||||
'{"trade_state":"SUCCESS",'
|
||||
f'"out_trade_no":"{order.order_no}",'
|
||||
'"amount":{"total":100},'
|
||||
'"transaction_id":"wx-transaction-001",'
|
||||
'"success_time":"2026-07-04T17:40:00+08:00"}'
|
||||
),
|
||||
)
|
||||
|
||||
fake_module = SimpleNamespace(WeChatPay=FakeWeChatPay)
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
private_key_path = f"{tmpdir}/apiclient_key.pem"
|
||||
with open(private_key_path, "w", encoding="utf-8") as private_key:
|
||||
private_key.write("test-private-key")
|
||||
|
||||
with (
|
||||
patch.dict(sys.modules, {"wechatpayv3": fake_module}),
|
||||
override_settings(
|
||||
WECHAT_PAY_APPID="wx-test-appid",
|
||||
WECHAT_PAY_MCHID="1900000001",
|
||||
WECHAT_PAY_API_V3_KEY="a" * 32,
|
||||
WECHAT_PAY_CERT_SERIAL_NO="ABC123",
|
||||
WECHAT_PAY_PRIVATE_KEY_PATH=private_key_path,
|
||||
WECHAT_PAY_NOTIFY_URL="https://cm.example.test/api/v1/recharge/callback/wechat",
|
||||
),
|
||||
):
|
||||
payment = payment_gateways._query_wechat_payment_order_with_sdk(order)
|
||||
|
||||
self.assertEqual(payment.order_no, order.order_no)
|
||||
self.assertEqual(payment.amount, Decimal("1.00"))
|
||||
self.assertEqual(payment.transaction_id, "wx-transaction-001")
|
||||
|
||||
def test_precharge_call_debits_wallet_and_writes_pending_call_and_consume_ledger(self):
|
||||
charge = precharge_call(
|
||||
user=self.user,
|
||||
|
||||
@@ -41,7 +41,6 @@ class RechargeCreateForm(forms.Form):
|
||||
label="支付方式",
|
||||
choices=(
|
||||
(RechargeOrder.PayMethod.WEIXIN, "微信"),
|
||||
(RechargeOrder.PayMethod.ALIPAY, "支付宝"),
|
||||
),
|
||||
initial=RechargeOrder.PayMethod.WEIXIN,
|
||||
widget=forms.RadioSelect(attrs={"class": "form-check-input"}),
|
||||
|
||||
@@ -87,6 +87,12 @@
|
||||
<span class="badge text-bg-secondary" id="recharge-status-badge">{{ current_order.status }}</span>
|
||||
</div>
|
||||
|
||||
{% if is_mock_payment_mode %}
|
||||
<div class="alert alert-warning" role="alert">
|
||||
当前为支付测试模式。下方二维码只用于联调订单创建、页面渲染和状态轮询,不能使用微信或支付宝真实付款。
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="row g-4 align-items-start">
|
||||
<div class="col-md-5">
|
||||
<canvas id="recharge-qr" class="border rounded bg-white p-2" width="240" height="240"></canvas>
|
||||
|
||||
+49
-1
@@ -666,6 +666,8 @@ class PortalAccountFlowTests(TestCase):
|
||||
self.assertEqual(response.context["balance"].points_balance, 25)
|
||||
self.assertContains(response, "充值金额")
|
||||
self.assertContains(response, "创建订单")
|
||||
self.assertContains(response, "微信")
|
||||
self.assertNotContains(response, "支付宝")
|
||||
self.assertContains(response, "portal/vendor/bootstrap/bootstrap.min.css")
|
||||
self.assertContains(response, "portal/vendor/qrcode/qrcode.js")
|
||||
self.assertContains(response, "QRCode.toCanvas")
|
||||
@@ -675,6 +677,7 @@ class PortalAccountFlowTests(TestCase):
|
||||
)
|
||||
self.assertIsNotNone(finders.find("portal/vendor/qrcode/qrcode.js"))
|
||||
|
||||
@override_settings(PAYMENT_CALLBACK_MODE="mock")
|
||||
def test_recharge_page_post_creates_pending_order_without_crediting_wallet_or_ledger(self):
|
||||
user = self.create_verified_user()
|
||||
UserWallet.objects.create(user=user, points_balance=5)
|
||||
@@ -714,9 +717,35 @@ class PortalAccountFlowTests(TestCase):
|
||||
self.assertEqual(follow_response.context["current_order"], order)
|
||||
self.assertContains(follow_response, order.order_no)
|
||||
self.assertContains(follow_response, "weixin://wxpay/cmhub-mock")
|
||||
self.assertTrue(follow_response.context["is_mock_payment_mode"])
|
||||
self.assertContains(follow_response, "当前为支付测试模式")
|
||||
self.assertContains(follow_response, "不能使用微信或支付宝真实付款")
|
||||
self.assertContains(follow_response, 'data-status-url="/api/v1/recharge/status"')
|
||||
self.assertContains(follow_response, "data-recharge-order")
|
||||
|
||||
@override_settings(PAYMENT_CALLBACK_MODE="sdk")
|
||||
def test_recharge_page_does_not_show_mock_notice_in_sdk_mode(self):
|
||||
user = self.create_verified_user()
|
||||
UserWallet.objects.create(user=user, points_balance=0)
|
||||
order = self.create_recharge_order(
|
||||
user,
|
||||
order_no="R-SDK-RECHARGE-PAGE-504",
|
||||
amount="20.00",
|
||||
points=200,
|
||||
status=RechargeOrder.Status.PENDING,
|
||||
)
|
||||
order.code_url = "weixin://wxpay/bizpayurl?pr=real-native-ticket"
|
||||
order.save(update_fields=("code_url", "updated_at"))
|
||||
self.client.force_login(user)
|
||||
|
||||
response = self.client.get(f"/recharge?order_no={order.order_no}")
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertFalse(response.context["is_mock_payment_mode"])
|
||||
self.assertContains(response, "weixin://wxpay/bizpayurl")
|
||||
self.assertNotContains(response, "当前为支付测试模式")
|
||||
self.assertNotContains(response, "不能使用微信或支付宝真实付款")
|
||||
|
||||
def test_recharge_page_current_order_only_shows_current_user_order(self):
|
||||
user = self.create_verified_user()
|
||||
other_user = self.create_verified_user()
|
||||
@@ -749,13 +778,32 @@ class PortalAccountFlowTests(TestCase):
|
||||
|
||||
response = self.client.post(
|
||||
"/recharge",
|
||||
{"amount": "100.01", "pay_method": RechargeOrder.PayMethod.ALIPAY},
|
||||
{"amount": "100.01", "pay_method": RechargeOrder.PayMethod.WEIXIN},
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertContains(response, "单笔充值金额不能超过 100.00 CNY")
|
||||
self.assertFalse(RechargeOrder.objects.filter(user=user).exists())
|
||||
|
||||
def test_recharge_page_rejects_hidden_alipay_submit(self):
|
||||
user = self.create_verified_user()
|
||||
UserWallet.objects.create(user=user, points_balance=0)
|
||||
ExchangeRate.objects.create(
|
||||
currency="CNY",
|
||||
points_per_unit=Decimal("10.0000"),
|
||||
effective_from=timezone.now(),
|
||||
)
|
||||
self.client.force_login(user)
|
||||
|
||||
response = self.client.post(
|
||||
"/recharge",
|
||||
{"amount": "20.00", "pay_method": RechargeOrder.PayMethod.ALIPAY},
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertContains(response, "选择一个有效的选项")
|
||||
self.assertFalse(RechargeOrder.objects.filter(user=user).exists())
|
||||
|
||||
def test_recharge_page_post_is_csrf_protected(self):
|
||||
user = self.create_verified_user()
|
||||
csrf_client = Client(enforce_csrf_checks=True)
|
||||
|
||||
@@ -8,7 +8,7 @@ from django.views import View
|
||||
from django.views.generic import FormView, TemplateView
|
||||
|
||||
from apps.ai.catalog import get_public_model_catalog
|
||||
from apps.billing.payment_gateways import PaymentOrderCreateError
|
||||
from apps.billing.payment_gateways import PaymentOrderCreateError, payment_callback_mode
|
||||
from apps.billing.pricing import NoExchangeRateError
|
||||
from apps.billing.models import PointsLedger, RechargeOrder
|
||||
from apps.billing.services import (
|
||||
@@ -164,7 +164,10 @@ class RechargePageView(LoginRequiredMixin, FormView):
|
||||
).first()
|
||||
context["balance"] = get_balance_snapshot(self.request.user)
|
||||
context.update(get_portal_account_summary(self.request.user))
|
||||
current_payment_mode = payment_callback_mode()
|
||||
context["current_order"] = current_order
|
||||
context["payment_callback_mode"] = current_payment_mode
|
||||
context["is_mock_payment_mode"] = current_payment_mode == "mock"
|
||||
context["recent_recharge_orders"] = get_recharge_orders_for_user(
|
||||
self.request.user
|
||||
)[:5]
|
||||
|
||||
Reference in New Issue
Block a user