diff --git a/.env.example b/.env.example index 7b084c5..474eaee 100644 --- a/.env.example +++ b/.env.example @@ -83,6 +83,7 @@ WECHAT_PAY_API_V3_KEY=change-me WECHAT_PAY_CERT_SERIAL_NO=your-cert-serial-no WECHAT_PAY_PRIVATE_KEY_PATH=/secure/wechat/apiclient_key.pem WECHAT_PAY_NOTIFY_URL=https://cmhub.example.com/api/v1/recharge/callback/wechat +SOFTWARE_WECHAT_PAY_NOTIFY_URL=https://cmhub.example.com/api/v1/software-orders/callback/wechat # Alipay face-to-face payment ALIPAY_APPID=your-alipay-appid diff --git a/apps/api/serializers.py b/apps/api/serializers.py index c2d4a94..19fb50c 100644 --- a/apps/api/serializers.py +++ b/apps/api/serializers.py @@ -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( diff --git a/apps/api/tests.py b/apps/api/tests.py index c9e085f..fc4f5fe 100644 --- a/apps/api/tests.py +++ b/apps/api/tests.py @@ -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) diff --git a/apps/api/urls.py b/apps/api/urls.py index 3989177..a2dd6cb 100644 --- a/apps/api/urls.py +++ b/apps/api/urls.py @@ -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", + ), ] diff --git a/apps/api/views.py b/apps/api/views.py index 996b27c..ebc00e3 100644 --- a/apps/api/views.py +++ b/apps/api/views.py @@ -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) diff --git a/apps/billing/payment_gateways.py b/apps/billing/payment_gateways.py index 614e612..b627ece 100644 --- a/apps/billing/payment_gateways.py +++ b/apps/billing/payment_gateways.py @@ -15,7 +15,6 @@ from django.utils import timezone from django.utils.dateparse import parse_datetime from .models import RechargeOrder -from .services import RechargePayment class PaymentVerificationError(RuntimeError): @@ -36,6 +35,15 @@ class PaymentOrderCode: expires_at: object +@dataclass(frozen=True) +class PaymentReceipt: + order_no: str + pay_method: str + amount: Decimal + transaction_id: str + paid_at: object | None = None + + def payment_callback_mode() -> str: return str(getattr(settings, "PAYMENT_CALLBACK_MODE", "") or "").strip().lower() @@ -140,7 +148,7 @@ def _verify_mock_alipay_signature(data: dict) -> None: raise PaymentVerificationError("Invalid mock Alipay callback signature.") -def verify_wechat_callback(headers, body: bytes) -> RechargePayment: +def verify_wechat_callback(headers, body: bytes) -> PaymentReceipt: if payment_callback_mode() == "mock": _verify_mock_wechat_signature(headers, body) try: @@ -158,7 +166,7 @@ def verify_wechat_callback(headers, body: bytes) -> RechargePayment: total_cents = Decimal(str((resource.get("amount") or {}).get("total"))) except (InvalidOperation, TypeError, ValueError) as exc: raise PaymentVerificationError("Invalid WeChat payment amount.") from exc - return RechargePayment( + return PaymentReceipt( order_no=str(resource.get("out_trade_no") or ""), pay_method=RechargeOrder.PayMethod.WEIXIN, amount=(total_cents / Decimal("100")).quantize(Decimal("0.01")), @@ -169,7 +177,7 @@ def verify_wechat_callback(headers, body: bytes) -> RechargePayment: return _verify_wechat_callback_with_sdk(headers, body) -def verify_alipay_callback(data: dict) -> RechargePayment: +def verify_alipay_callback(data: dict) -> PaymentReceipt: callback_data = {key: str(value) for key, value in data.items()} if payment_callback_mode() == "mock": _verify_mock_alipay_signature(callback_data) @@ -179,7 +187,7 @@ def verify_alipay_callback(data: dict) -> RechargePayment: if callback_data.get("trade_status") not in {"TRADE_SUCCESS", "TRADE_FINISHED"}: raise PaymentVerificationError("Alipay trade is not successful.") - return RechargePayment( + return PaymentReceipt( order_no=str(callback_data.get("out_trade_no") or ""), pay_method=RechargeOrder.PayMethod.ALIPAY, amount=_decimal_money(callback_data.get("total_amount")), @@ -188,13 +196,27 @@ def verify_alipay_callback(data: dict) -> RechargePayment: ) -def create_payment_order(order: RechargeOrder) -> PaymentOrderCode: +def create_payment_order( + order, + *, + description: str = "cmhub points recharge", + wechat_notify_url: str | None = None, + alipay_notify_url: str | None = None, +) -> PaymentOrderCode: if payment_callback_mode() == "mock": return _create_mock_payment_order(order) if order.pay_method == RechargeOrder.PayMethod.WEIXIN: - return _create_wechat_payment_order_with_sdk(order) + return _create_wechat_payment_order_with_sdk( + order, + description=description, + notify_url=wechat_notify_url, + ) if order.pay_method == RechargeOrder.PayMethod.ALIPAY: - return _create_alipay_payment_order_with_sdk(order) + return _create_alipay_payment_order_with_sdk( + order, + description=description, + notify_url=alipay_notify_url, + ) raise PaymentOrderCreateError("Unsupported payment method.") @@ -215,12 +237,18 @@ def _create_mock_payment_order(order: RechargeOrder) -> PaymentOrderCode: return PaymentOrderCode(code_url=code_url, expires_at=_default_expires_at()) -def _create_wechat_payment_order_with_sdk(order: RechargeOrder) -> PaymentOrderCode: +def _create_wechat_payment_order_with_sdk( + order, + *, + description: str = "cmhub points recharge", + notify_url: str | None = None, +) -> PaymentOrderCode: try: from wechatpayv3 import WeChatPay, WeChatPayType # type: ignore except ImportError as exc: raise PaymentOrderCreateError("wechatpayv3 is not installed.") from exc + effective_notify_url = str(notify_url or settings.WECHAT_PAY_NOTIFY_URL or "").strip() _require_payment_config( { "WECHAT_PAY_APPID": settings.WECHAT_PAY_APPID, @@ -228,7 +256,7 @@ def _create_wechat_payment_order_with_sdk(order: RechargeOrder) -> PaymentOrderC "WECHAT_PAY_API_V3_KEY": settings.WECHAT_PAY_API_V3_KEY, "WECHAT_PAY_CERT_SERIAL_NO": settings.WECHAT_PAY_CERT_SERIAL_NO, "WECHAT_PAY_PRIVATE_KEY_PATH": settings.WECHAT_PAY_PRIVATE_KEY_PATH, - "WECHAT_PAY_NOTIFY_URL": settings.WECHAT_PAY_NOTIFY_URL, + "WECHAT_PAY_NOTIFY_URL": effective_notify_url, }, "WeChat", ) @@ -240,11 +268,11 @@ def _create_wechat_payment_order_with_sdk(order: RechargeOrder) -> PaymentOrderC cert_serial_no=settings.WECHAT_PAY_CERT_SERIAL_NO, apiv3_key=settings.WECHAT_PAY_API_V3_KEY, appid=settings.WECHAT_PAY_APPID, - notify_url=settings.WECHAT_PAY_NOTIFY_URL, + notify_url=effective_notify_url, ) try: response = client.pay( - description="cmhub points recharge", + description=description, out_trade_no=order.order_no, amount={ "total": _wechat_amount_cents(order.amount_money), @@ -265,18 +293,24 @@ def _create_wechat_payment_order_with_sdk(order: RechargeOrder) -> PaymentOrderC return PaymentOrderCode(code_url=str(code_url), expires_at=_default_expires_at()) -def _create_alipay_payment_order_with_sdk(order: RechargeOrder) -> PaymentOrderCode: +def _create_alipay_payment_order_with_sdk( + order, + *, + description: str = "cmhub points recharge", + notify_url: str | None = None, +) -> PaymentOrderCode: try: from alipay import AliPay # type: ignore except ImportError as exc: raise PaymentOrderCreateError("python-alipay-sdk is not installed.") from exc + effective_notify_url = str(notify_url or settings.ALIPAY_NOTIFY_URL or "").strip() _require_payment_config( { "ALIPAY_APPID": settings.ALIPAY_APPID, "ALIPAY_APP_PRIVATE_KEY_PATH": settings.ALIPAY_APP_PRIVATE_KEY_PATH, "ALIPAY_PUBLIC_KEY_PATH": settings.ALIPAY_PUBLIC_KEY_PATH, - "ALIPAY_NOTIFY_URL": settings.ALIPAY_NOTIFY_URL, + "ALIPAY_NOTIFY_URL": effective_notify_url, }, "Alipay", ) @@ -288,7 +322,7 @@ def _create_alipay_payment_order_with_sdk(order: RechargeOrder) -> PaymentOrderC ) client = AliPay( appid=settings.ALIPAY_APPID, - app_notify_url=settings.ALIPAY_NOTIFY_URL, + app_notify_url=effective_notify_url, app_private_key_string=app_private_key, alipay_public_key_string=alipay_public_key, sign_type="RSA2", @@ -296,10 +330,10 @@ def _create_alipay_payment_order_with_sdk(order: RechargeOrder) -> PaymentOrderC ) try: response = client.api_alipay_trade_precreate( - subject="cmhub points recharge", + subject=description, out_trade_no=order.order_no, total_amount=_format_money(order.amount_money), - notify_url=settings.ALIPAY_NOTIFY_URL, + notify_url=effective_notify_url, ) except Exception as exc: # pragma: no cover - depends on merchant SDK/runtime. raise PaymentOrderCreateError("Alipay precreate order failed.") from exc @@ -310,7 +344,7 @@ def _create_alipay_payment_order_with_sdk(order: RechargeOrder) -> PaymentOrderC return PaymentOrderCode(code_url=str(qr_code), expires_at=_default_expires_at()) -def _verify_wechat_callback_with_sdk(headers, body: bytes) -> RechargePayment: +def _verify_wechat_callback_with_sdk(headers, body: bytes) -> PaymentReceipt: try: from wechatpayv3 import WeChatPay # type: ignore except ImportError as exc: @@ -338,7 +372,7 @@ def _verify_wechat_callback_with_sdk(headers, body: bytes) -> RechargePayment: total_cents = Decimal(str((resource.get("amount") or {}).get("total"))) except (InvalidOperation, TypeError, ValueError) as exc: raise PaymentVerificationError("Invalid WeChat payment amount.") from exc - return RechargePayment( + return PaymentReceipt( order_no=str(resource.get("out_trade_no") or ""), pay_method=RechargeOrder.PayMethod.WEIXIN, amount=(total_cents / Decimal("100")).quantize(Decimal("0.01")), @@ -372,7 +406,7 @@ def _verify_alipay_callback_with_sdk(data: dict) -> None: raise PaymentVerificationError("Invalid Alipay callback signature.") -def query_payment_order(order: RechargeOrder) -> RechargePayment: +def query_payment_order(order) -> PaymentReceipt: if payment_callback_mode() == "mock": raise PaymentQueryUnavailableError( f"Mock payment query for {order.order_no} is not configured." @@ -386,7 +420,7 @@ def query_payment_order(order: RechargeOrder) -> RechargePayment: ) -def _query_wechat_payment_order_with_sdk(order: RechargeOrder) -> RechargePayment: +def _query_wechat_payment_order_with_sdk(order) -> PaymentReceipt: try: from wechatpayv3 import WeChatPay # type: ignore except ImportError as exc: @@ -432,7 +466,7 @@ def _query_wechat_payment_order_with_sdk(order: RechargeOrder) -> RechargePaymen total_cents = Decimal(str((resource.get("amount") or {}).get("total"))) except (InvalidOperation, TypeError, ValueError) as exc: raise PaymentQueryUnavailableError("Invalid WeChat payment amount.") from exc - return RechargePayment( + return PaymentReceipt( order_no=str(resource.get("out_trade_no") or order.order_no), pay_method=RechargeOrder.PayMethod.WEIXIN, amount=(total_cents / Decimal("100")).quantize(Decimal("0.01")), @@ -441,7 +475,7 @@ def _query_wechat_payment_order_with_sdk(order: RechargeOrder) -> RechargePaymen ) -def _query_alipay_payment_order_with_sdk(order: RechargeOrder) -> RechargePayment: +def _query_alipay_payment_order_with_sdk(order) -> PaymentReceipt: try: from alipay import AliPay # type: ignore except ImportError as exc: @@ -477,7 +511,7 @@ def _query_alipay_payment_order_with_sdk(order: RechargeOrder) -> RechargePaymen trade_status = response.get("trade_status") if isinstance(response, dict) else None if trade_status not in {"TRADE_SUCCESS", "TRADE_FINISHED"}: raise PaymentQueryUnavailableError("Alipay trade is not paid yet.") - return RechargePayment( + return PaymentReceipt( order_no=str(response.get("out_trade_no") or order.order_no), pay_method=RechargeOrder.PayMethod.ALIPAY, amount=_decimal_money(response.get("total_amount")), diff --git a/apps/billing/services.py b/apps/billing/services.py index a40e2bf..8280150 100644 --- a/apps/billing/services.py +++ b/apps/billing/services.py @@ -2,7 +2,6 @@ from __future__ import annotations import secrets from dataclasses import dataclass -from datetime import datetime from decimal import Decimal, InvalidOperation from django.db import transaction @@ -18,6 +17,7 @@ from .models import ( SignupBonusGrant, normalize_resolution, ) +from .payment_gateways import PaymentReceipt from .pricing import quote_recharge_points @@ -127,13 +127,7 @@ class BalanceSnapshot: ledger_balance: int -@dataclass(frozen=True) -class RechargePayment: - order_no: str - pay_method: str - amount: Decimal - transaction_id: str - paid_at: datetime | None = None +RechargePayment = PaymentReceipt @dataclass(frozen=True) diff --git a/apps/licensing/admin.py b/apps/licensing/admin.py index a7193bf..bce7b19 100644 --- a/apps/licensing/admin.py +++ b/apps/licensing/admin.py @@ -16,6 +16,7 @@ from .models import ( LegacyMigrationGrant, MigrationRequest, SoftwareEntitlement, + SoftwareOrder, SoftwarePlan, ) from .services import ( @@ -422,3 +423,22 @@ class DeviceCredentialAdmin(ReadOnlyLicenseAdmin): list_filter = ("product_code", "revoked_at", "expires_at") search_fields = ("token_prefix", "user__username", "user__email") list_select_related = ("user", "device", "entitlement", "seat") + + +@admin.register(SoftwareOrder) +class SoftwareOrderAdmin(ReadOnlyLicenseAdmin): + list_display = ( + "created_at", + "order_no", + "user", + "plan_name", + "amount_money", + "pay_method", + "status", + "payment_txn_no", + "entitlement", + "fulfilled_at", + ) + list_filter = ("product_code", "pay_method", "status", "created_at") + search_fields = ("=order_no", "=payment_txn_no", "user__username", "user__email") + list_select_related = ("user", "source_plan", "entitlement", "fulfillment_event") diff --git a/apps/licensing/migrations/0004_alter_licenseevent_action_softwareorder.py b/apps/licensing/migrations/0004_alter_licenseevent_action_softwareorder.py new file mode 100644 index 0000000..4060893 --- /dev/null +++ b/apps/licensing/migrations/0004_alter_licenseevent_action_softwareorder.py @@ -0,0 +1,57 @@ +# Generated by Django 5.2.15 on 2026-07-21 03:25 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('licensing', '0003_alter_licenseevent_action_legacymigrationgrant_and_more'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.AlterField( + model_name='licenseevent', + name='action', + field=models.CharField(choices=[('granted', '人工授予'), ('renewed', '续期'), ('revoked', '撤销'), ('seat_assigned', '绑定席位'), ('seat_released', '解绑席位'), ('migration_granted', '迁移资格授予'), ('credential_issued', '设备凭证签发'), ('credential_revoked', '设备凭证吊销'), ('order_fulfilled', '套餐订单权益发放')], max_length=32, verbose_name='动作'), + ), + migrations.CreateModel( + name='SoftwareOrder', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('order_no', models.CharField(editable=False, max_length=64, unique=True, verbose_name='软件订单号')), + ('product_code', models.CharField(choices=[('cmshopee', '虾皮圈优化助手')], max_length=32, verbose_name='产品代码')), + ('plan_name', models.CharField(max_length=120, verbose_name='套餐名称快照')), + ('plan_duration_days', models.PositiveIntegerField(verbose_name='套餐有效天数快照')), + ('plan_price', models.DecimalField(decimal_places=2, max_digits=12, verbose_name='套餐价格快照')), + ('plan_device_limit', models.PositiveSmallIntegerField(verbose_name='设备数量快照')), + ('plan_grace_days', models.PositiveSmallIntegerField(default=0, verbose_name='宽限天数快照')), + ('amount_money', models.DecimalField(decimal_places=2, max_digits=12, verbose_name='支付金额')), + ('currency', models.CharField(default='CNY', max_length=8, verbose_name='币种')), + ('pay_method', models.CharField(choices=[('weixin', '微信')], max_length=20, verbose_name='支付方式')), + ('status', models.CharField(choices=[('pending', '待支付'), ('paid', '已支付'), ('failed', '下单失败'), ('expired', '已过期')], default='pending', max_length=20, verbose_name='订单状态')), + ('code_url', models.TextField(blank=True, verbose_name='二维码票据')), + ('expires_at', models.DateTimeField(blank=True, null=True, verbose_name='支付票据过期时间')), + ('payment_txn_no', models.CharField(blank=True, max_length=128, null=True, verbose_name='支付交易号')), + ('paid_at', models.DateTimeField(blank=True, null=True, verbose_name='支付时间')), + ('fulfilled_at', models.DateTimeField(blank=True, null=True, verbose_name='权益发放时间')), + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='创建时间')), + ('updated_at', models.DateTimeField(auto_now=True, verbose_name='更新时间')), + ('entitlement', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='software_orders', to='licensing.softwareentitlement', verbose_name='发放权益')), + ('fulfillment_event', models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='software_order', to='licensing.licenseevent', verbose_name='权益发放事件')), + ('source_plan', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='software_orders', to='licensing.softwareplan', verbose_name='来源套餐')), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='software_orders', to=settings.AUTH_USER_MODEL, verbose_name='用户')), + ], + options={ + 'verbose_name': '软件套餐订单', + 'verbose_name_plural': '软件套餐订单', + 'db_table': 'software_order', + 'ordering': ('-created_at', '-id'), + 'indexes': [models.Index(fields=['user', 'product_code', 'status'], name='software_or_user_id_1781fe_idx'), models.Index(fields=['status', 'expires_at'], name='software_or_status_7eb559_idx')], + 'constraints': [models.UniqueConstraint(fields=('pay_method', 'payment_txn_no'), name='software_order_pay_method_txn_unique'), models.CheckConstraint(condition=models.Q(('plan_duration_days__gt', 0)), name='software_order_duration_positive'), models.CheckConstraint(condition=models.Q(('plan_price__gt', 0)), name='software_order_plan_price_positive'), models.CheckConstraint(condition=models.Q(('plan_device_limit__gt', 0)), name='software_order_device_limit_positive'), models.CheckConstraint(condition=models.Q(('amount_money__gt', 0)), name='software_order_amount_positive')], + }, + ), + ] diff --git a/apps/licensing/models.py b/apps/licensing/models.py index 21caeef..bfda92b 100644 --- a/apps/licensing/models.py +++ b/apps/licensing/models.py @@ -219,6 +219,110 @@ class SoftwareEntitlement(models.Model): return self.status == self.Status.ACTIVE and self.grace_expires_at > now +class SoftwareOrder(models.Model): + class PayMethod(models.TextChoices): + WEIXIN = "weixin", "微信" + + class Status(models.TextChoices): + PENDING = "pending", "待支付" + PAID = "paid", "已支付" + FAILED = "failed", "下单失败" + EXPIRED = "expired", "已过期" + + user = models.ForeignKey( + settings.AUTH_USER_MODEL, + verbose_name="用户", + on_delete=models.PROTECT, + related_name="software_orders", + ) + source_plan = models.ForeignKey( + SoftwarePlan, + verbose_name="来源套餐", + null=True, + blank=True, + on_delete=models.SET_NULL, + related_name="software_orders", + ) + entitlement = models.ForeignKey( + SoftwareEntitlement, + verbose_name="发放权益", + null=True, + blank=True, + on_delete=models.PROTECT, + related_name="software_orders", + ) + fulfillment_event = models.OneToOneField( + "LicenseEvent", + verbose_name="权益发放事件", + null=True, + blank=True, + on_delete=models.PROTECT, + related_name="software_order", + ) + order_no = models.CharField("软件订单号", max_length=64, unique=True, editable=False) + product_code = models.CharField( + "产品代码", + max_length=32, + choices=ClientDevice.ProductCode.choices, + ) + plan_name = models.CharField("套餐名称快照", max_length=120) + plan_duration_days = models.PositiveIntegerField("套餐有效天数快照") + plan_price = models.DecimalField("套餐价格快照", max_digits=12, decimal_places=2) + plan_device_limit = models.PositiveSmallIntegerField("设备数量快照") + plan_grace_days = models.PositiveSmallIntegerField("宽限天数快照", default=0) + amount_money = models.DecimalField("支付金额", max_digits=12, decimal_places=2) + currency = models.CharField("币种", max_length=8, default="CNY") + pay_method = models.CharField("支付方式", max_length=20, choices=PayMethod.choices) + status = models.CharField( + "订单状态", + max_length=20, + choices=Status.choices, + default=Status.PENDING, + ) + code_url = models.TextField("二维码票据", blank=True) + expires_at = models.DateTimeField("支付票据过期时间", null=True, blank=True) + payment_txn_no = models.CharField("支付交易号", max_length=128, null=True, blank=True) + paid_at = models.DateTimeField("支付时间", null=True, blank=True) + fulfilled_at = models.DateTimeField("权益发放时间", null=True, blank=True) + created_at = models.DateTimeField("创建时间", auto_now_add=True) + updated_at = models.DateTimeField("更新时间", auto_now=True) + + class Meta: + db_table = "software_order" + verbose_name = "软件套餐订单" + verbose_name_plural = "软件套餐订单" + ordering = ("-created_at", "-id") + constraints = [ + models.UniqueConstraint( + fields=("pay_method", "payment_txn_no"), + name="software_order_pay_method_txn_unique", + ), + models.CheckConstraint( + condition=Q(plan_duration_days__gt=0), + name="software_order_duration_positive", + ), + models.CheckConstraint( + condition=Q(plan_price__gt=0), + name="software_order_plan_price_positive", + ), + models.CheckConstraint( + condition=Q(plan_device_limit__gt=0), + name="software_order_device_limit_positive", + ), + models.CheckConstraint( + condition=Q(amount_money__gt=0), + name="software_order_amount_positive", + ), + ] + indexes = [ + models.Index(fields=("user", "product_code", "status")), + models.Index(fields=("status", "expires_at")), + ] + + def __str__(self) -> str: + return f"{self.order_no} {self.user} {self.plan_name}" + + class LicenseSeat(models.Model): entitlement = models.ForeignKey( SoftwareEntitlement, @@ -273,6 +377,7 @@ class LicenseEvent(models.Model): MIGRATION_GRANTED = "migration_granted", "迁移资格授予" CREDENTIAL_ISSUED = "credential_issued", "设备凭证签发" CREDENTIAL_REVOKED = "credential_revoked", "设备凭证吊销" + ORDER_FULFILLED = "order_fulfilled", "套餐订单权益发放" entitlement = models.ForeignKey( SoftwareEntitlement, diff --git a/apps/licensing/services.py b/apps/licensing/services.py index 65f41b0..ad3ab5e 100644 --- a/apps/licensing/services.py +++ b/apps/licensing/services.py @@ -1,7 +1,10 @@ from __future__ import annotations +import secrets from dataclasses import dataclass from datetime import timedelta +from decimal import Decimal +from decimal import InvalidOperation from django.conf import settings from django.db import IntegrityError, transaction @@ -17,6 +20,7 @@ from apps.licensing.models import ( LicenseSeat, MigrationRequest, SoftwareEntitlement, + SoftwareOrder, SoftwarePlan, ) @@ -39,6 +43,37 @@ class LicensingError(Exception): super().__init__(message) +class SoftwareOrderError(LicensingError): + pass + + +class SoftwareOrderNotFoundError(SoftwareOrderError): + def __init__(self, order_no: str): + super().__init__("software_order_not_found", f"软件订单不存在:{order_no}") + + +class SoftwareOrderAmountMismatchError(SoftwareOrderError): + def __init__(self): + super().__init__("amount_mismatch", "支付回调金额与本地软件订单金额不一致") + + +class SoftwareOrderPayMethodMismatchError(SoftwareOrderError): + def __init__(self): + super().__init__("bad_request", "支付回调通道与本地软件订单不一致") + + +class SoftwareOrderTransactionMismatchError(SoftwareOrderError): + def __init__(self): + super().__init__("bad_request", "支付交易号与已处理软件订单不一致") + + +@dataclass(frozen=True) +class SoftwarePaymentResult: + order: SoftwareOrder + entitlement: SoftwareEntitlement + applied: bool + + @dataclass(frozen=True) class AuthorizationDecision: product_code: str @@ -282,19 +317,32 @@ def grant_software_entitlement(*, user, plan: SoftwarePlan, reason: str, actor=N @transaction.atomic -def renew_software_entitlement(*, entitlement: SoftwareEntitlement, reason: str, actor=None, now=None): +def renew_software_entitlement( + *, + entitlement: SoftwareEntitlement, + reason: str, + actor=None, + now=None, + duration_days: int | None = None, + grace_days: int | None = None, +): reason = _required_reason(reason) now = now or timezone.now() locked_entitlement = SoftwareEntitlement.objects.select_for_update().get(pk=entitlement.pk) if locked_entitlement.status == SoftwareEntitlement.Status.REVOKED: raise LicensingError("entitlement_revoked", "已撤销权益不能续期") + duration_days = duration_days or locked_entitlement.plan_duration_days + grace_days = grace_days if grace_days is not None else locked_entitlement.plan_grace_days + if duration_days <= 0 or grace_days < 0: + raise LicensingError("invalid_plan_snapshot", "套餐快照无效") + extension_start = max(now, locked_entitlement.expires_at) locked_entitlement.expires_at = extension_start + timedelta( - days=locked_entitlement.plan_duration_days + days=duration_days ) locked_entitlement.grace_expires_at = locked_entitlement.expires_at + timedelta( - days=locked_entitlement.plan_grace_days + days=grace_days ) locked_entitlement.status = SoftwareEntitlement.Status.ACTIVE locked_entitlement.revoked_at = None @@ -307,6 +355,10 @@ def renew_software_entitlement(*, entitlement: SoftwareEntitlement, reason: str, "updated_at", ) ) + DeviceCredential.objects.filter( + entitlement=locked_entitlement, + revoked_at__isnull=True, + ).update(expires_at=locked_entitlement.grace_expires_at) _create_license_event( entitlement=locked_entitlement, action=LicenseEvent.Action.RENEWED, @@ -315,6 +367,8 @@ def renew_software_entitlement(*, entitlement: SoftwareEntitlement, reason: str, metadata={ "extension_start": extension_start.isoformat(), "expires_at": locked_entitlement.expires_at.isoformat(), + "duration_days": duration_days, + "grace_days": grace_days, }, ) return locked_entitlement @@ -328,6 +382,12 @@ def revoke_software_entitlement(*, entitlement: SoftwareEntitlement, reason: str if locked_entitlement.status == SoftwareEntitlement.Status.REVOKED: return locked_entitlement + active_credentials = list( + DeviceCredential.objects.select_for_update() + .filter(entitlement=locked_entitlement, revoked_at__isnull=True) + .order_by("id") + ) + locked_entitlement.status = SoftwareEntitlement.Status.REVOKED locked_entitlement.revoked_at = now locked_entitlement.save(update_fields=("status", "revoked_at", "updated_at")) @@ -337,6 +397,13 @@ def revoke_software_entitlement(*, entitlement: SoftwareEntitlement, reason: str reason=reason, actor=actor, ) + for credential in active_credentials: + revoke_device_credential( + credential=credential, + reason=reason, + actor=actor, + now=now, + ) return locked_entitlement @@ -593,6 +660,230 @@ def revoke_device_credential(*, credential: DeviceCredential, reason: str, actor return locked_credential +def _normalize_software_order_amount(value) -> Decimal: + try: + return Decimal(str(value)).quantize(Decimal("0.01")) + except (InvalidOperation, TypeError, ValueError) as exc: + raise SoftwareOrderError("bad_request", "软件订单金额无效") from exc + + +def _generate_software_order_no() -> str: + for _attempt in range(10): + timestamp = timezone.now().strftime("%Y%m%d%H%M%S") + order_no = f"S{timestamp}{secrets.token_hex(4).upper()}" + if not SoftwareOrder.objects.filter(order_no=order_no).exists(): + return order_no + raise SoftwareOrderError("order_number_failed", "无法生成软件订单号") + + +def _active_entitlement_for_software_order(*, user, product_code): + return ( + SoftwareEntitlement.objects.select_for_update() + .filter( + user=user, + product_code=product_code, + status=SoftwareEntitlement.Status.ACTIVE, + ) + .order_by("-expires_at", "-id") + .first() + ) + + +def create_software_order(*, user, plan: SoftwarePlan, pay_method: str, payment_order_func=None): + if plan.status != SoftwarePlan.Status.ACTIVE: + raise SoftwareOrderError("plan_inactive", "套餐已停用,无法购买") + if pay_method != SoftwareOrder.PayMethod.WEIXIN: + raise SoftwareOrderError("bad_request", "当前软件订阅仅支持微信支付") + existing_entitlement = ( + SoftwareEntitlement.objects.filter( + user=user, + product_code=plan.product_code, + status=SoftwareEntitlement.Status.ACTIVE, + ) + .order_by("-expires_at", "-id") + .first() + ) + if existing_entitlement is not None and existing_entitlement.source_plan_id != plan.id: + raise SoftwareOrderError("plan_change_not_supported", "当前套餐变更请联系运营处理") + + order = SoftwareOrder.objects.create( + user=user, + source_plan=plan, + order_no=_generate_software_order_no(), + product_code=plan.product_code, + plan_name=plan.name, + plan_duration_days=plan.duration_days, + plan_price=plan.price, + plan_device_limit=plan.device_limit, + plan_grace_days=plan.grace_days, + amount_money=plan.price, + currency="CNY", + pay_method=pay_method, + ) + if payment_order_func is None: + from apps.billing.payment_gateways import create_payment_order + + def payment_order_func(payment_order): + return create_payment_order( + payment_order, + description="虾皮圈软件订阅", + wechat_notify_url=settings.SOFTWARE_WECHAT_PAY_NOTIFY_URL, + ) + + try: + payment_order = payment_order_func(order) + code_url = str(getattr(payment_order, "code_url", "") or "").strip() + if not code_url: + raise SoftwareOrderError("payment_order_create_failed", "支付平台未返回二维码") + except Exception: + order.status = SoftwareOrder.Status.FAILED + order.save(update_fields=("status", "updated_at")) + raise + + order.code_url = code_url + order.expires_at = getattr(payment_order, "expires_at", None) + order.save(update_fields=("code_url", "expires_at", "updated_at")) + return order + + +def _grant_software_order_entitlement(*, order: SoftwareOrder, now): + entitlement = SoftwareEntitlement.objects.create( + user=order.user, + product_code=order.product_code, + source_plan=order.source_plan, + plan_name=order.plan_name, + plan_duration_days=order.plan_duration_days, + plan_price=order.plan_price, + plan_device_limit=order.plan_device_limit, + plan_grace_days=order.plan_grace_days, + starts_at=now, + expires_at=now + timedelta(days=order.plan_duration_days), + grace_expires_at=now + timedelta(days=order.plan_duration_days + order.plan_grace_days), + ) + LicenseSeat.objects.bulk_create( + [ + LicenseSeat(entitlement=entitlement, seat_number=seat_number) + for seat_number in range(1, order.plan_device_limit + 1) + ] + ) + _create_license_event( + entitlement=entitlement, + action=LicenseEvent.Action.GRANTED, + reason="软件套餐订单首次发放权益", + metadata={"software_order_no": order.order_no}, + ) + return entitlement + + +@transaction.atomic +def apply_software_payment(payment) -> SoftwarePaymentResult: + order_no = str(getattr(payment, "order_no", "") or "").strip() + transaction_id = str(getattr(payment, "transaction_id", "") or "").strip() + callback_amount = _normalize_software_order_amount(getattr(payment, "amount", None)) + callback_pay_method = str(getattr(payment, "pay_method", "") or "").strip().lower() + paid_at = getattr(payment, "paid_at", None) or timezone.now() + if not transaction_id: + raise SoftwareOrderError("bad_request", "支付回调缺少交易号") + + try: + order = ( + SoftwareOrder.objects.select_for_update() + .select_related("user", "source_plan", "entitlement") + .get(order_no=order_no) + ) + except SoftwareOrder.DoesNotExist as exc: + raise SoftwareOrderNotFoundError(order_no) from exc + + if order.status == SoftwareOrder.Status.PAID: + if order.payment_txn_no != transaction_id: + raise SoftwareOrderTransactionMismatchError() + return SoftwarePaymentResult(order=order, entitlement=order.entitlement, applied=False) + if order.status != SoftwareOrder.Status.PENDING: + raise SoftwareOrderError("bad_request", "软件订单当前状态不能入账") + if order.pay_method != callback_pay_method: + raise SoftwareOrderPayMethodMismatchError() + if _normalize_software_order_amount(order.amount_money) != callback_amount: + raise SoftwareOrderAmountMismatchError() + if SoftwareOrder.objects.filter( + pay_method=order.pay_method, + payment_txn_no=transaction_id, + ).exclude(pk=order.pk).exists(): + raise SoftwareOrderTransactionMismatchError() + + entitlement = _active_entitlement_for_software_order( + user=order.user, + product_code=order.product_code, + ) + if entitlement is None: + entitlement = _grant_software_order_entitlement(order=order, now=paid_at) + elif entitlement.source_plan_id == order.source_plan_id: + entitlement = renew_software_entitlement( + entitlement=entitlement, + reason="软件套餐订单续订", + now=paid_at, + duration_days=order.plan_duration_days, + grace_days=order.plan_grace_days, + ) + else: + raise SoftwareOrderError("plan_change_not_supported", "当前套餐变更请联系运营处理") + + fulfillment_event = _create_license_event( + entitlement=entitlement, + action=LicenseEvent.Action.ORDER_FULFILLED, + reason="软件套餐订单权益发放", + metadata={ + "software_order_no": order.order_no, + "payment_txn_no": transaction_id, + "pay_method": order.pay_method, + }, + ) + order.status = SoftwareOrder.Status.PAID + order.payment_txn_no = transaction_id + order.paid_at = paid_at + order.fulfilled_at = timezone.now() + order.entitlement = entitlement + order.fulfillment_event = fulfillment_event + try: + order.save( + update_fields=( + "status", + "payment_txn_no", + "paid_at", + "fulfilled_at", + "entitlement", + "fulfillment_event", + "updated_at", + ) + ) + except IntegrityError as exc: + raise SoftwareOrderTransactionMismatchError() from exc + return SoftwarePaymentResult(order=order, entitlement=entitlement, applied=True) + + +def query_and_apply_software_payment(order_no: str, query_func) -> SoftwarePaymentResult: + order = SoftwareOrder.objects.filter(order_no=order_no).first() + if order is None: + raise SoftwareOrderNotFoundError(order_no) + if order.status == SoftwareOrder.Status.PAID: + return SoftwarePaymentResult(order=order, entitlement=order.entitlement, applied=False) + payment = query_func(order) + return apply_software_payment(payment) + + +@transaction.atomic +def expire_software_order(*, order: SoftwareOrder, now=None) -> SoftwareOrder: + now = now or timezone.now() + locked_order = SoftwareOrder.objects.select_for_update().get(pk=order.pk) + if ( + locked_order.status == SoftwareOrder.Status.PENDING + and locked_order.expires_at is not None + and locked_order.expires_at <= now + ): + locked_order.status = SoftwareOrder.Status.EXPIRED + locked_order.save(update_fields=("status", "updated_at")) + return locked_order + + def evaluate_device_authorization(*, user, product_code: str, device=None, raw_credential_token: str = "", now=None): now = now or timezone.now() if device is None: diff --git a/apps/licensing/tests.py b/apps/licensing/tests.py index e859f8c..1f2634c 100644 --- a/apps/licensing/tests.py +++ b/apps/licensing/tests.py @@ -8,6 +8,8 @@ from django.urls import reverse from django.utils import timezone from rest_framework.test import APIClient +from apps.billing.models import PointsLedger +from apps.billing.payment_gateways import PaymentOrderCode, PaymentReceipt from apps.licensing.models import ( ClientDevice, DeviceBindingAudit, @@ -18,14 +20,19 @@ from apps.licensing.models import ( LicenseSeat, MigrationRequest, SoftwareEntitlement, + SoftwareOrder, SoftwarePlan, ) from apps.licensing.services import ( LicensingError, + SoftwareOrderAmountMismatchError, + SoftwareOrderTransactionMismatchError, + apply_software_payment, assign_license_seat, confirm_migration_request, create_legacy_migration_grant, create_migration_request, + create_software_order, evaluate_device_authorization, grant_software_entitlement, record_device_heartbeat, @@ -345,6 +352,152 @@ class SoftwareEntitlementServiceTests(TestCase): grant_software_entitlement(user=self.user, plan=self.plan, reason="") +class SoftwareOrderServiceTests(TestCase): + def setUp(self): + self.user = User.objects.create_user( + username="software-order-user", + email="software-order@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, + grace_days=3, + ) + + def create_order(self): + return 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), + ), + ) + + @staticmethod + def payment_for(order, *, amount=None, transaction_id="wx-software-001"): + return PaymentReceipt( + order_no=order.order_no, + pay_method=SoftwareOrder.PayMethod.WEIXIN, + amount=amount or order.amount_money, + transaction_id=transaction_id, + paid_at=timezone.now(), + ) + + def test_order_snapshots_payment_once_and_never_credits_points(self): + order = self.create_order() + self.plan.name = "已修改套餐" + self.plan.price = Decimal("29.90") + self.plan.save() + + first = apply_software_payment(self.payment_for(order)) + second = apply_software_payment(self.payment_for(order)) + order.refresh_from_db() + + self.assertTrue(first.applied) + self.assertFalse(second.applied) + self.assertEqual(order.status, SoftwareOrder.Status.PAID) + self.assertEqual(order.plan_name, "月度订阅") + self.assertEqual(order.amount_money, Decimal("19.90")) + self.assertEqual(order.entitlement.plan_name, "月度订阅") + self.assertEqual(order.fulfillment_event.action, LicenseEvent.Action.ORDER_FULFILLED) + self.assertEqual( + PointsLedger.objects.filter(user=self.user).count(), + 0, + ) + + def test_duplicate_callback_with_changed_transaction_or_amount_is_rejected(self): + order = self.create_order() + with self.assertRaises(SoftwareOrderAmountMismatchError): + apply_software_payment(self.payment_for(order, amount=Decimal("19.89"))) + self.assertEqual(SoftwareEntitlement.objects.count(), 0) + + apply_software_payment(self.payment_for(order)) + with self.assertRaises(SoftwareOrderTransactionMismatchError): + apply_software_payment( + self.payment_for(order, transaction_id="wx-software-other") + ) + + def test_second_paid_order_renews_same_plan_entitlement_once(self): + first_order = self.create_order() + first = apply_software_payment(self.payment_for(first_order)) + first_expiry = first.entitlement.expires_at + + second_order = self.create_order() + second = apply_software_payment( + self.payment_for(second_order, transaction_id="wx-software-002") + ) + + self.assertEqual(second.entitlement.pk, first.entitlement.pk) + self.assertEqual(second.entitlement.expires_at, first_expiry + timedelta(days=30)) + self.assertEqual( + SoftwareEntitlement.objects.filter(user=self.user).count(), + 1, + ) + + +class SoftwareOrderConcurrencyTests(TransactionTestCase): + def setUp(self): + self.user = User.objects.create_user( + username="software-order-concurrency", + email="software-order-concurrency@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-concurrency", + expires_at=timezone.now() + timedelta(minutes=10), + ), + ) + + def test_concurrent_same_order_callback_fulfills_once(self): + def apply_callback(): + close_old_connections() + try: + order = SoftwareOrder.objects.get(pk=self.order.pk) + result = apply_software_payment( + PaymentReceipt( + order_no=order.order_no, + pay_method=SoftwareOrder.PayMethod.WEIXIN, + amount=order.amount_money, + transaction_id="wx-software-concurrency-001", + paid_at=timezone.now(), + ) + ) + return result.applied + finally: + close_old_connections() + + with ThreadPoolExecutor(max_workers=2) as executor: + outcomes = list(executor.map(lambda _index: apply_callback(), range(2))) + + self.order.refresh_from_db() + self.assertEqual(outcomes.count(True), 1) + self.assertEqual(self.order.status, SoftwareOrder.Status.PAID) + self.assertEqual( + LicenseEvent.objects.filter( + action=LicenseEvent.Action.ORDER_FULFILLED, + metadata__software_order_no=self.order.order_no, + ).count(), + 1, + ) + + class SoftwareEntitlementAdminTests(TestCase): def setUp(self): self.operator = User.objects.create_user( @@ -506,6 +659,19 @@ class LegacyMigrationFlowTests(TestCase): "HTTP_X_DEVICE_SESSION": self.device_session_token, } + def issue_credential(self): + grant = self.grant_migration() + migration_request, raw_token = create_migration_request( + user=self.user, + device=self.device, + ) + _request, credential, created = confirm_migration_request( + request_id=migration_request.request_id, + user=self.user, + ) + self.assertTrue(created) + return grant.entitlement, credential, raw_token + def test_request_confirm_poll_and_revoke_flow_keeps_credential_hashed(self): self.grant_migration() create_response = self.client.post( @@ -673,3 +839,52 @@ class LegacyMigrationFlowTests(TestCase): ) self.assertTrue(expired.would_reject) self.assertEqual(expired.code, "license_expired") + + def test_renewal_extends_active_credential_and_keeps_authorization_valid(self): + entitlement, credential, raw_token = self.issue_credential() + previous_credential_expiry = credential.expires_at + + renewed = renew_software_entitlement( + entitlement=entitlement, + reason="用户续订", + now=timezone.now(), + ) + + credential.refresh_from_db() + self.assertGreater(credential.expires_at, previous_credential_expiry) + self.assertEqual(credential.expires_at, renewed.grace_expires_at) + decision = evaluate_device_authorization( + user=self.user, + product_code=ClientDevice.ProductCode.CMSHOPEE, + device=self.device, + raw_credential_token=raw_token, + ) + self.assertTrue(decision.allowed) + + def test_revoking_entitlement_revokes_credentials_and_releases_seats(self): + entitlement, credential, _raw_token = self.issue_credential() + + revoke_software_entitlement( + entitlement=entitlement, + reason="运营撤销套餐权益", + ) + + credential.refresh_from_db() + credential.seat.refresh_from_db() + self.assertIsNotNone(credential.revoked_at) + self.assertEqual(credential.revoke_reason, "运营撤销套餐权益") + self.assertIsNone(credential.seat.device_id) + self.assertTrue( + LicenseEvent.objects.filter( + entitlement=entitlement, + action=LicenseEvent.Action.CREDENTIAL_REVOKED, + reason="运营撤销套餐权益", + ).exists() + ) + self.assertTrue( + LicenseEvent.objects.filter( + entitlement=entitlement, + action=LicenseEvent.Action.SEAT_RELEASED, + reason="运营撤销套餐权益", + ).exists() + ) diff --git a/apps/portal/forms.py b/apps/portal/forms.py index 1eb48a2..a638efd 100644 --- a/apps/portal/forms.py +++ b/apps/portal/forms.py @@ -4,6 +4,7 @@ from django import forms from django.conf import settings from apps.billing.models import RechargeOrder +from apps.licensing.models import ClientDevice, SoftwareOrder, SoftwarePlan class ApiKeyCreateForm(forms.Form): @@ -52,3 +53,25 @@ class RechargeCreateForm(forms.Form): if amount > max_amount: raise forms.ValidationError(f"单笔充值金额不能超过 {max_amount:.2f} CNY") return amount + + +class SoftwareOrderCreateForm(forms.Form): + plan = forms.ModelChoiceField( + label="套餐", + queryset=SoftwarePlan.objects.none(), + empty_label=None, + widget=forms.Select(attrs={"class": "form-select"}), + ) + pay_method = forms.ChoiceField( + label="支付方式", + choices=((SoftwareOrder.PayMethod.WEIXIN, "微信"),), + initial=SoftwareOrder.PayMethod.WEIXIN, + widget=forms.RadioSelect(attrs={"class": "form-check-input"}), + ) + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.fields["plan"].queryset = SoftwarePlan.objects.filter( + product_code=ClientDevice.ProductCode.CMSHOPEE, + status=SoftwarePlan.Status.ACTIVE, + ).order_by("name", "id") diff --git a/apps/portal/templates/portal/base.html b/apps/portal/templates/portal/base.html index 75baa93..357b124 100644 --- a/apps/portal/templates/portal/base.html +++ b/apps/portal/templates/portal/base.html @@ -17,6 +17,7 @@ {% if user.is_authenticated %} 控制台 充值 + 软件订阅 API Key 可用模型 充值记录 diff --git a/apps/portal/templates/portal/subscription.html b/apps/portal/templates/portal/subscription.html new file mode 100644 index 0000000..27a4e77 --- /dev/null +++ b/apps/portal/templates/portal/subscription.html @@ -0,0 +1,109 @@ +{% extends "portal/base.html" %} +{% load static %} + +{% block title %}软件订阅 - 虾皮圈{% endblock %} + +{% block content %} +
{{ current_order.order_no }}| 订单号 | 套餐 | 金额 | 状态 | 创建时间 |
|---|---|---|---|---|
{{ order.order_no }} | {{ order.plan_name }} | {{ order.amount_money|floatformat:2 }} {{ order.currency }} | {{ order.status }} | {{ order.created_at|date:"Y-m-d H:i" }} |