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
+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: