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)
+58 -24
View File
@@ -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")),
+2 -8
View File
@@ -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)
+20
View File
@@ -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")
@@ -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')],
},
),
]
+105
View File
@@ -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,
+294 -3
View File
@@ -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:
+215
View File
@@ -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()
)
+23
View File
@@ -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")
+1
View File
@@ -17,6 +17,7 @@
{% if user.is_authenticated %}
<a class="btn btn-sm {% if current_url == 'portal-dashboard' %}btn-primary{% else %}btn-outline-secondary{% endif %}"{% if current_url == 'portal-dashboard' %} aria-current="page"{% endif %} href="{% url 'portal-dashboard' %}">控制台</a>
<a class="btn btn-sm {% if current_url == 'portal-recharge' %}btn-primary{% else %}btn-outline-secondary{% endif %}"{% if current_url == 'portal-recharge' %} aria-current="page"{% endif %} href="{% url 'portal-recharge' %}">充值</a>
<a class="btn btn-sm {% if current_url == 'portal-subscription' %}btn-primary{% else %}btn-outline-secondary{% endif %}"{% if current_url == 'portal-subscription' %} aria-current="page"{% endif %} href="{% url 'portal-subscription' %}">软件订阅</a>
<a class="btn btn-sm {% if current_url == 'portal-apikeys' %}btn-primary{% else %}btn-outline-secondary{% endif %}"{% if current_url == 'portal-apikeys' %} aria-current="page"{% endif %} href="{% url 'portal-apikeys' %}">API Key</a>
<a class="btn btn-sm {% if current_url == 'portal-models' %}btn-primary{% else %}btn-outline-secondary{% endif %}"{% if current_url == 'portal-models' %} aria-current="page"{% endif %} href="{% url 'portal-models' %}">可用模型</a>
<a class="btn btn-sm {% if current_url == 'portal-recharge-records' %}btn-primary{% else %}btn-outline-secondary{% endif %}"{% if current_url == 'portal-recharge-records' %} aria-current="page"{% endif %} href="{% url 'portal-recharge-records' %}">充值记录</a>
@@ -0,0 +1,109 @@
{% extends "portal/base.html" %}
{% load static %}
{% block title %}软件订阅 - 虾皮圈{% endblock %}
{% block content %}
<div class="d-flex flex-column gap-4">
<div>
<h1 class="h3 mb-1">软件订阅</h1>
<div class="text-secondary">虾皮圈优化助手</div>
</div>
<section class="cmhub-surface">
<h2 class="h5 mb-3">当前授权</h2>
{% if entitlement %}
<dl class="row mb-0">
<dt class="col-sm-3">套餐</dt><dd class="col-sm-9">{{ entitlement.plan_name }}</dd>
<dt class="col-sm-3">状态</dt><dd class="col-sm-9">{{ entitlement.status }}</dd>
<dt class="col-sm-3">到期时间</dt><dd class="col-sm-9">{{ entitlement.expires_at|date:"Y-m-d H:i" }}</dd>
<dt class="col-sm-3">宽限截止</dt><dd class="col-sm-9">{{ entitlement.grace_expires_at|date:"Y-m-d H:i" }}</dd>
</dl>
{% else %}
<div class="text-secondary">暂无有效授权记录</div>
{% endif %}
</section>
<section class="cmhub-surface">
<h2 class="h5 mb-3">购买或续订</h2>
<form method="post" class="row g-3 align-items-end" novalidate>
{% csrf_token %}
{% if form.non_field_errors %}
<div class="col-12"><div class="alert alert-danger mb-0">{{ form.non_field_errors|striptags }}</div></div>
{% endif %}
<div class="col-md-6">
<label class="form-label" for="{{ form.plan.id_for_label }}">{{ form.plan.label }}</label>
{{ form.plan }}
{% if form.plan.errors %}<div class="text-danger small mt-1">{{ form.plan.errors|striptags }}</div>{% endif %}
</div>
<div class="col-md-3">
<div class="form-label">{{ form.pay_method.label }}</div>
{% for radio in form.pay_method %}
<div class="form-check">{{ radio.tag }}<label class="form-check-label" for="{{ radio.id_for_label }}">{{ radio.choice_label }}</label></div>
{% endfor %}
</div>
<div class="col-md-3"><button class="btn btn-primary w-100" type="submit">创建订单</button></div>
</form>
</section>
{% if current_order %}
<section class="cmhub-surface" data-software-order data-order-no="{{ current_order.order_no }}" data-status="{{ current_order.status }}" data-code-url="{{ current_order.code_url }}" data-status-url="{% url 'api-software-order-status' %}">
<div class="d-flex justify-content-between align-items-start gap-3 flex-wrap mb-3">
<div><h2 class="h5 mb-1">订阅订单</h2><div class="text-secondary small">订单号:<code>{{ current_order.order_no }}</code></div></div>
<span class="badge text-bg-secondary" id="software-order-status">{{ current_order.status }}</span>
</div>
{% if is_mock_payment_mode %}<div class="alert alert-warning">当前为支付测试模式,二维码不能用于真实付款。</div>{% endif %}
<div class="row g-4 align-items-start">
<div class="col-md-5"><canvas id="software-order-qr" class="border rounded bg-white p-2" width="240" height="240"></canvas></div>
<div class="col-md-7">
<dl class="row mb-0">
<dt class="col-sm-4">套餐</dt><dd class="col-sm-8">{{ current_order.plan_name }}</dd>
<dt class="col-sm-4">金额</dt><dd class="col-sm-8">{{ current_order.amount_money|floatformat:2 }} {{ current_order.currency }}</dd>
<dt class="col-sm-4">支付票据有效期</dt><dd class="col-sm-8">{{ current_order.expires_at|date:"Y-m-d H:i"|default:"-" }}</dd>
<dt class="col-sm-4">权益发放时间</dt><dd class="col-sm-8" id="software-order-fulfilled">{{ current_order.fulfilled_at|date:"Y-m-d H:i"|default:"-" }}</dd>
</dl>
<div class="alert alert-info mt-3 mb-0" id="software-order-hint" aria-live="polite">正在等待支付结果。</div>
</div>
</div>
</section>
{% endif %}
<section class="cmhub-surface">
<h2 class="h5 mb-3">最近订单</h2>
{% if recent_orders %}
<div class="table-responsive"><table class="table align-middle mb-0"><thead><tr><th>订单号</th><th>套餐</th><th>金额</th><th>状态</th><th>创建时间</th></tr></thead><tbody>
{% for order in recent_orders %}<tr><td><code>{{ order.order_no }}</code></td><td>{{ order.plan_name }}</td><td>{{ order.amount_money|floatformat:2 }} {{ order.currency }}</td><td>{{ order.status }}</td><td>{{ order.created_at|date:"Y-m-d H:i" }}</td></tr>{% endfor %}
</tbody></table></div>
{% else %}<div class="text-secondary">暂无软件订阅订单</div>{% endif %}
</section>
</div>
<script src="{% static 'portal/vendor/qrcode/qrcode.js' %}"></script>
<script>
(function () {
const panel = document.querySelector("[data-software-order]");
if (!panel) return;
const canvas = document.getElementById("software-order-qr");
const statusBadge = document.getElementById("software-order-status");
const fulfilled = document.getElementById("software-order-fulfilled");
const hint = document.getElementById("software-order-hint");
if (panel.dataset.codeUrl && window.QRCode && typeof window.QRCode.toCanvas === "function") {
window.QRCode.toCanvas(canvas, panel.dataset.codeUrl, { width: 240, margin: 1 });
}
function poll() {
fetch(panel.dataset.statusUrl + "?order_no=" + encodeURIComponent(panel.dataset.orderNo), { credentials: "same-origin", headers: { Accept: "application/json" } })
.then(function (response) { return response.json().then(function (data) { if (!response.ok) throw new Error(); return data; }); })
.then(function (data) {
statusBadge.textContent = data.status;
statusBadge.className = data.status === "paid" ? "badge text-bg-success" : "badge text-bg-secondary";
if (data.fulfilled_at) fulfilled.textContent = new Date(data.fulfilled_at).toLocaleString();
if (data.status === "paid") { hint.textContent = "订阅权益已发放,页面即将刷新。"; window.setTimeout(function () { window.location.reload(); }, 1200); return; }
hint.textContent = data.status === "pending" ? "正在等待支付结果。" : "订单当前状态为 " + data.status + "。";
if (data.status === "pending") window.setTimeout(poll, 1000);
})
.catch(function () { hint.textContent = "状态查询失败,稍后自动重试。"; window.setTimeout(poll, 3000); });
}
if (panel.dataset.status === "pending") window.setTimeout(poll, 1000);
}());
</script>
{% endblock %}
+2
View File
@@ -12,6 +12,7 @@ from .views import (
MigrationConfirmView,
RechargePageView,
RechargeRecordListView,
SubscriptionPageView,
UsageRecordListView,
)
@@ -25,6 +26,7 @@ urlpatterns = [
path("apikeys/<int:pk>/delete", ApiKeyDeleteView.as_view(), name="portal-apikey-delete"),
path("models", ModelCatalogView.as_view(), name="portal-models"),
path("recharge", RechargePageView.as_view(), name="portal-recharge"),
path("subscription", SubscriptionPageView.as_view(), name="portal-subscription"),
path(
"migration/confirm/<uuid:request_id>",
MigrationConfirmView.as_view(),
+48 -2
View File
@@ -18,14 +18,16 @@ from apps.billing.services import (
get_balance_snapshot,
)
from apps.users.models import ApiKey
from apps.licensing.models import DeviceCredential, MigrationRequest
from apps.licensing.models import DeviceCredential, MigrationRequest, SoftwareEntitlement, SoftwareOrder
from apps.licensing.services import (
LicensingError,
SoftwareOrderError,
confirm_migration_request,
create_software_order,
revoke_device_credential,
)
from .forms import ApiKeyCreateForm, RechargeCreateForm
from .forms import ApiKeyCreateForm, RechargeCreateForm, SoftwareOrderCreateForm
from .models import DownloadRelease, ImportTemplate
@@ -250,6 +252,50 @@ class RechargePageView(LoginRequiredMixin, FormView):
return redirect(f"{recharge_url}?order_no={order.order_no}")
class SubscriptionPageView(LoginRequiredMixin, FormView):
template_name = "portal/subscription.html"
form_class = SoftwareOrderCreateForm
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
order_no = str(self.request.GET.get("order_no") or "").strip()
context["current_order"] = SoftwareOrder.objects.filter(
user=self.request.user,
order_no=order_no,
).first() if order_no else None
context["entitlement"] = (
SoftwareEntitlement.objects.filter(
user=self.request.user,
product_code="cmshopee",
)
.order_by("-expires_at", "-id")
.first()
)
context["recent_orders"] = SoftwareOrder.objects.filter(
user=self.request.user,
product_code="cmshopee",
).order_by("-created_at", "-id")[:10]
context["is_mock_payment_mode"] = payment_callback_mode() == "mock"
return context
def form_valid(self, form):
try:
order = create_software_order(
user=self.request.user,
plan=form.cleaned_data["plan"],
pay_method=form.cleaned_data["pay_method"],
)
except SoftwareOrderError as exc:
form.add_error(None, exc.message)
return self.form_invalid(form)
except PaymentOrderCreateError:
form.add_error(None, "支付下单失败,请稍后重试。")
return self.form_invalid(form)
messages.success(self.request, "软件订阅订单已创建,请扫码支付。")
subscription_url = reverse("portal-subscription")
return redirect(f"{subscription_url}?order_no={order.order_no}")
class MigrationConfirmView(LoginRequiredMixin, TemplateView):
template_name = "portal/migration_confirm.html"