feat: add software subscription orders

This commit is contained in:
QiuSW
2026-07-21 11:52:49 +08:00
parent 341864dc70
commit bc9d1aa0f7
25 changed files with 1179 additions and 47 deletions
+8
View File
@@ -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
View File
@@ -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)
+12
View File
@@ -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
View File
@@ -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)