feat: add software subscription orders
This commit is contained in:
@@ -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')],
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -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
@@ -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:
|
||||
|
||||
@@ -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()
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user