feat: add software subscription orders
This commit is contained in:
@@ -132,6 +132,14 @@ class RechargeStatusRequestSerializer(serializers.Serializer):
|
||||
)
|
||||
|
||||
|
||||
class SoftwareOrderStatusRequestSerializer(serializers.Serializer):
|
||||
order_no = serializers.CharField(
|
||||
trim_whitespace=True,
|
||||
allow_blank=False,
|
||||
max_length=64,
|
||||
)
|
||||
|
||||
|
||||
class DeviceRegistrationRequestSerializer(serializers.Serializer):
|
||||
product_code = serializers.ChoiceField(choices=ClientDevice.ProductCode.values)
|
||||
device_id = serializers.CharField(
|
||||
|
||||
+85
-1
@@ -57,12 +57,14 @@ from apps.billing.models import (
|
||||
from apps.billing.payment_gateways import (
|
||||
build_mock_alipay_signature,
|
||||
build_mock_body_signature,
|
||||
PaymentOrderCode,
|
||||
)
|
||||
from apps.billing.services import RechargePayment
|
||||
from apps.moderation.models import SensitiveWord
|
||||
from apps.moderation.providers.keyword import reset_keyword_matcher_cache
|
||||
from apps.portal.models import DownloadRelease
|
||||
from apps.licensing.services import register_device
|
||||
from apps.licensing.models import ClientDevice, SoftwareOrder, SoftwarePlan
|
||||
from apps.licensing.services import create_software_order, register_device
|
||||
from apps.users.models import ApiKey
|
||||
from apps.users.models import UserWallet
|
||||
|
||||
@@ -3099,3 +3101,85 @@ class GenerateApiTests(TestCase):
|
||||
).count(),
|
||||
1,
|
||||
)
|
||||
|
||||
|
||||
@override_settings(
|
||||
PAYMENT_CALLBACK_MODE="mock",
|
||||
PAYMENT_MOCK_CALLBACK_SECRET="test-payment-callback-secret",
|
||||
)
|
||||
class SoftwareOrderCallbackApiTests(TestCase):
|
||||
callback_url = "/api/v1/software-orders/callback/wechat"
|
||||
|
||||
def setUp(self):
|
||||
self.user = get_user_model().objects.create_user(
|
||||
username="software-callback-user",
|
||||
email="software-callback@example.com",
|
||||
password="test-password",
|
||||
)
|
||||
self.plan = SoftwarePlan.objects.create(
|
||||
product_code=ClientDevice.ProductCode.CMSHOPEE,
|
||||
name="软件月度套餐",
|
||||
duration_days=30,
|
||||
price=Decimal("19.90"),
|
||||
device_limit=1,
|
||||
)
|
||||
self.order = create_software_order(
|
||||
user=self.user,
|
||||
plan=self.plan,
|
||||
pay_method=SoftwareOrder.PayMethod.WEIXIN,
|
||||
payment_order_func=lambda _order: PaymentOrderCode(
|
||||
code_url="weixin://software-order-test",
|
||||
expires_at=timezone.now() + timedelta(minutes=10),
|
||||
),
|
||||
)
|
||||
|
||||
def signed_body(self, *, amount_cents=1990, transaction_id="wx-software-callback-001"):
|
||||
payload = {
|
||||
"event_type": "TRANSACTION.SUCCESS",
|
||||
"resource": {
|
||||
"trade_state": "SUCCESS",
|
||||
"out_trade_no": self.order.order_no,
|
||||
"transaction_id": transaction_id,
|
||||
"success_time": "2026-07-21T12:00:00+08:00",
|
||||
"amount": {"total": amount_cents},
|
||||
},
|
||||
}
|
||||
body = json.dumps(payload, separators=(",", ":")).encode("utf-8")
|
||||
return body, build_mock_body_signature(body)
|
||||
|
||||
def test_callback_fulfills_once_without_writing_points_ledger(self):
|
||||
body, signature = self.signed_body()
|
||||
first = self.client.post(
|
||||
self.callback_url,
|
||||
data=body,
|
||||
content_type="application/json",
|
||||
HTTP_WECHATPAY_SIGNATURE=signature,
|
||||
)
|
||||
second = self.client.post(
|
||||
self.callback_url,
|
||||
data=body,
|
||||
content_type="application/json",
|
||||
HTTP_WECHATPAY_SIGNATURE=signature,
|
||||
)
|
||||
|
||||
self.assertEqual(first.status_code, 200)
|
||||
self.assertEqual(second.status_code, 200)
|
||||
self.order.refresh_from_db()
|
||||
self.assertEqual(self.order.status, SoftwareOrder.Status.PAID)
|
||||
self.assertIsNotNone(self.order.entitlement_id)
|
||||
self.assertEqual(PointsLedger.objects.filter(user=self.user).count(), 0)
|
||||
|
||||
def test_callback_rejects_amount_mismatch_without_fulfilling(self):
|
||||
body, signature = self.signed_body(amount_cents=1989)
|
||||
response = self.client.post(
|
||||
self.callback_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.order.refresh_from_db()
|
||||
self.assertEqual(self.order.status, SoftwareOrder.Status.PENDING)
|
||||
self.assertEqual(PointsLedger.objects.filter(user=self.user).count(), 0)
|
||||
|
||||
@@ -20,7 +20,9 @@ from .views import (
|
||||
MigrationRequestDetailView,
|
||||
RechargeCreateView,
|
||||
RechargeStatusView,
|
||||
SoftwareOrderStatusView,
|
||||
WechatRechargeCallbackView,
|
||||
WechatSoftwareOrderCallbackView,
|
||||
)
|
||||
|
||||
urlpatterns = [
|
||||
@@ -86,6 +88,11 @@ urlpatterns = [
|
||||
),
|
||||
path("v1/recharge/create", RechargeCreateView.as_view(), name="api-recharge-create"),
|
||||
path("v1/recharge/status", RechargeStatusView.as_view(), name="api-recharge-status"),
|
||||
path(
|
||||
"v1/software-orders/status",
|
||||
SoftwareOrderStatusView.as_view(),
|
||||
name="api-software-order-status",
|
||||
),
|
||||
path(
|
||||
"v1/recharge/callback/wechat",
|
||||
WechatRechargeCallbackView.as_view(),
|
||||
@@ -96,4 +103,9 @@ urlpatterns = [
|
||||
AlipayRechargeCallbackView.as_view(),
|
||||
name="api-recharge-callback-alipay",
|
||||
),
|
||||
path(
|
||||
"v1/software-orders/callback/wechat",
|
||||
WechatSoftwareOrderCallbackView.as_view(),
|
||||
name="api-software-order-callback-wechat",
|
||||
),
|
||||
]
|
||||
|
||||
+103
-2
@@ -33,6 +33,7 @@ from apps.api.serializers import (
|
||||
GenerateTitleRequestSerializer,
|
||||
RechargeCreateRequestSerializer,
|
||||
RechargeStatusRequestSerializer,
|
||||
SoftwareOrderStatusRequestSerializer,
|
||||
)
|
||||
from apps.api.telemetry import (
|
||||
log_generation_route_usage,
|
||||
@@ -67,16 +68,23 @@ from apps.billing.services import (
|
||||
from apps.portal.models import DownloadRelease
|
||||
from apps.licensing.authentication import DeviceSessionAuthentication
|
||||
from apps.licensing.services import (
|
||||
LicensingError,
|
||||
DeviceRegistrationError,
|
||||
DeviceSessionValidationError,
|
||||
LicensingError,
|
||||
SoftwareOrderAmountMismatchError,
|
||||
SoftwareOrderError,
|
||||
SoftwareOrderNotFoundError,
|
||||
SoftwareOrderPayMethodMismatchError,
|
||||
apply_software_payment,
|
||||
create_migration_request,
|
||||
evaluate_device_authorization,
|
||||
expire_software_order,
|
||||
query_and_apply_software_payment,
|
||||
record_device_heartbeat,
|
||||
register_device,
|
||||
resolve_optional_device_session,
|
||||
)
|
||||
from apps.licensing.models import MigrationRequest
|
||||
from apps.licensing.models import MigrationRequest, SoftwareOrder
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
authorization_logger = logging.getLogger("cmhub.licensing.authorization")
|
||||
@@ -790,6 +798,65 @@ class RechargeStatusView(PortalSessionApiView):
|
||||
return Response(_recharge_order_response(order), status=status.HTTP_200_OK)
|
||||
|
||||
|
||||
def _software_order_response(order: SoftwareOrder) -> dict:
|
||||
return {
|
||||
"order_no": order.order_no,
|
||||
"product_code": order.product_code,
|
||||
"plan_name": order.plan_name,
|
||||
"amount": f"{order.amount_money:.2f}",
|
||||
"currency": order.currency,
|
||||
"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,
|
||||
"fulfilled_at": order.fulfilled_at.isoformat() if order.fulfilled_at else None,
|
||||
}
|
||||
|
||||
|
||||
class SoftwareOrderStatusView(PortalSessionApiView):
|
||||
def get(self, request):
|
||||
serializer = SoftwareOrderStatusRequestSerializer(data=request.query_params)
|
||||
if not serializer.is_valid():
|
||||
return Response(api_error("bad_request", "参数错误"), status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
order = SoftwareOrder.objects.filter(
|
||||
user=request.user,
|
||||
order_no=serializer.validated_data["order_no"],
|
||||
).first()
|
||||
if order is None:
|
||||
return Response(
|
||||
api_error("software_order_not_found", "软件订单不存在"),
|
||||
status=status.HTTP_404_NOT_FOUND,
|
||||
)
|
||||
if order.status == SoftwareOrder.Status.PENDING:
|
||||
order = expire_software_order(order=order)
|
||||
if order.status == SoftwareOrder.Status.PENDING:
|
||||
try:
|
||||
result = query_and_apply_software_payment(order.order_no, query_payment_order)
|
||||
order = result.order
|
||||
except PaymentQueryUnavailableError:
|
||||
order.refresh_from_db()
|
||||
except SoftwareOrderError as exc:
|
||||
logger.warning(
|
||||
"Rejected active software order query for %s: %s",
|
||||
order.order_no,
|
||||
exc.code,
|
||||
)
|
||||
return Response(
|
||||
api_error(exc.code, "支付查单结果与本地软件订单不一致"),
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
except Exception as exc: # pragma: no cover - external gateway/runtime.
|
||||
logger.warning(
|
||||
"Active software order query failed for %s: %s",
|
||||
order.order_no,
|
||||
exc.__class__.__name__,
|
||||
)
|
||||
order.refresh_from_db()
|
||||
return Response(_software_order_response(order), status=status.HTTP_200_OK)
|
||||
|
||||
|
||||
class RechargeCallbackView(APIView):
|
||||
authentication_classes = ()
|
||||
permission_classes = ()
|
||||
@@ -847,3 +914,37 @@ class AlipayRechargeCallbackView(RechargeCallbackView):
|
||||
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)
|
||||
|
||||
|
||||
@method_decorator(csrf_exempt, name="dispatch")
|
||||
class WechatSoftwareOrderCallbackView(APIView):
|
||||
authentication_classes = ()
|
||||
permission_classes = ()
|
||||
|
||||
def post(self, request):
|
||||
try:
|
||||
payment = verify_wechat_callback(request.headers, request.body)
|
||||
apply_software_payment(payment)
|
||||
except PaymentVerificationError:
|
||||
logger.warning("Rejected WeChat software order callback: signature_invalid")
|
||||
return Response(
|
||||
api_error("signature_invalid", "支付回调验签失败"),
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
except SoftwareOrderAmountMismatchError:
|
||||
logger.warning("Rejected WeChat software order callback: amount_mismatch")
|
||||
return Response(
|
||||
api_error("amount_mismatch", "支付回调金额与本地软件订单金额不一致"),
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
except (
|
||||
SoftwareOrderNotFoundError,
|
||||
SoftwareOrderPayMethodMismatchError,
|
||||
SoftwareOrderError,
|
||||
) as exc:
|
||||
logger.warning("Rejected WeChat software order callback: %s", exc.code)
|
||||
return Response(
|
||||
api_error(exc.code, "支付回调处理失败"),
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
return Response({"code": "SUCCESS", "message": "成功"}, status=status.HTTP_200_OK)
|
||||
|
||||
Reference in New Issue
Block a user