fix: stabilize production payment flow

This commit is contained in:
QiuSW
2026-07-06 08:56:30 +08:00
parent 0d6b97f44d
commit e4565de0c4
14 changed files with 270 additions and 13 deletions
+21
View File
@@ -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):
+7
View File
@@ -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)
+27 -3
View File
@@ -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":
+96
View File
@@ -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,
-1
View File
@@ -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
View File
@@ -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)
+4 -1
View File
@@ -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]