feat: add software entitlement foundations
This commit is contained in:
+212
-1
@@ -7,7 +7,15 @@ from django.conf import settings
|
||||
from django.db import IntegrityError, transaction
|
||||
from django.utils import timezone
|
||||
|
||||
from apps.licensing.models import ClientDevice, DeviceBindingAudit, DeviceSession
|
||||
from apps.licensing.models import (
|
||||
ClientDevice,
|
||||
DeviceBindingAudit,
|
||||
DeviceSession,
|
||||
LicenseEvent,
|
||||
LicenseSeat,
|
||||
SoftwareEntitlement,
|
||||
SoftwarePlan,
|
||||
)
|
||||
|
||||
|
||||
class DeviceRegistrationError(Exception):
|
||||
@@ -21,6 +29,13 @@ class DeviceSessionValidationError(DeviceRegistrationError):
|
||||
pass
|
||||
|
||||
|
||||
class LicensingError(Exception):
|
||||
def __init__(self, code: str, message: str):
|
||||
self.code = code
|
||||
self.message = message
|
||||
super().__init__(message)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class DeviceRegistrationResult:
|
||||
device: ClientDevice
|
||||
@@ -178,3 +193,199 @@ def resolve_optional_device_session(*, user, raw_token: str) -> DeviceSession |
|
||||
if session.device.user_id != user.id:
|
||||
raise DeviceSessionValidationError("device_mismatch", "设备会话不属于当前账号")
|
||||
return session
|
||||
|
||||
|
||||
def _required_reason(reason: str) -> str:
|
||||
normalized_reason = str(reason or "").strip()
|
||||
if not normalized_reason:
|
||||
raise LicensingError("reason_required", "必须填写操作原因")
|
||||
return normalized_reason
|
||||
|
||||
|
||||
def _create_license_event(
|
||||
*,
|
||||
entitlement: SoftwareEntitlement,
|
||||
action: str,
|
||||
reason: str,
|
||||
actor=None,
|
||||
seat: LicenseSeat | None = None,
|
||||
device: ClientDevice | None = None,
|
||||
metadata: dict | None = None,
|
||||
) -> LicenseEvent:
|
||||
return LicenseEvent.objects.create(
|
||||
entitlement=entitlement,
|
||||
seat=seat,
|
||||
device=device,
|
||||
actor=actor,
|
||||
action=action,
|
||||
reason=_required_reason(reason),
|
||||
metadata=metadata or {},
|
||||
)
|
||||
|
||||
|
||||
@transaction.atomic
|
||||
def grant_software_entitlement(*, user, plan: SoftwarePlan, reason: str, actor=None, starts_at=None):
|
||||
reason = _required_reason(reason)
|
||||
if plan.status != SoftwarePlan.Status.ACTIVE:
|
||||
raise LicensingError("plan_inactive", "套餐已停用,不能授予权益")
|
||||
|
||||
now = timezone.now()
|
||||
starts_at = starts_at or now
|
||||
expires_at = starts_at + timedelta(days=plan.duration_days)
|
||||
grace_expires_at = expires_at + timedelta(days=plan.grace_days)
|
||||
entitlement = SoftwareEntitlement.objects.create(
|
||||
user=user,
|
||||
product_code=plan.product_code,
|
||||
source_plan=plan,
|
||||
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,
|
||||
starts_at=starts_at,
|
||||
expires_at=expires_at,
|
||||
grace_expires_at=grace_expires_at,
|
||||
)
|
||||
LicenseSeat.objects.bulk_create(
|
||||
[
|
||||
LicenseSeat(entitlement=entitlement, seat_number=seat_number)
|
||||
for seat_number in range(1, plan.device_limit + 1)
|
||||
]
|
||||
)
|
||||
_create_license_event(
|
||||
entitlement=entitlement,
|
||||
action=LicenseEvent.Action.GRANTED,
|
||||
reason=reason,
|
||||
actor=actor,
|
||||
metadata={
|
||||
"source_plan_id": plan.id,
|
||||
"expires_at": expires_at.isoformat(),
|
||||
"device_limit": plan.device_limit,
|
||||
},
|
||||
)
|
||||
return entitlement
|
||||
|
||||
|
||||
@transaction.atomic
|
||||
def renew_software_entitlement(*, entitlement: SoftwareEntitlement, reason: str, actor=None, now=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", "已撤销权益不能续期")
|
||||
|
||||
extension_start = max(now, locked_entitlement.expires_at)
|
||||
locked_entitlement.expires_at = extension_start + timedelta(
|
||||
days=locked_entitlement.plan_duration_days
|
||||
)
|
||||
locked_entitlement.grace_expires_at = locked_entitlement.expires_at + timedelta(
|
||||
days=locked_entitlement.plan_grace_days
|
||||
)
|
||||
locked_entitlement.status = SoftwareEntitlement.Status.ACTIVE
|
||||
locked_entitlement.revoked_at = None
|
||||
locked_entitlement.save(
|
||||
update_fields=(
|
||||
"expires_at",
|
||||
"grace_expires_at",
|
||||
"status",
|
||||
"revoked_at",
|
||||
"updated_at",
|
||||
)
|
||||
)
|
||||
_create_license_event(
|
||||
entitlement=locked_entitlement,
|
||||
action=LicenseEvent.Action.RENEWED,
|
||||
reason=reason,
|
||||
actor=actor,
|
||||
metadata={
|
||||
"extension_start": extension_start.isoformat(),
|
||||
"expires_at": locked_entitlement.expires_at.isoformat(),
|
||||
},
|
||||
)
|
||||
return locked_entitlement
|
||||
|
||||
|
||||
@transaction.atomic
|
||||
def revoke_software_entitlement(*, entitlement: SoftwareEntitlement, reason: str, actor=None, now=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:
|
||||
return locked_entitlement
|
||||
|
||||
locked_entitlement.status = SoftwareEntitlement.Status.REVOKED
|
||||
locked_entitlement.revoked_at = now
|
||||
locked_entitlement.save(update_fields=("status", "revoked_at", "updated_at"))
|
||||
_create_license_event(
|
||||
entitlement=locked_entitlement,
|
||||
action=LicenseEvent.Action.REVOKED,
|
||||
reason=reason,
|
||||
actor=actor,
|
||||
)
|
||||
return locked_entitlement
|
||||
|
||||
|
||||
@transaction.atomic
|
||||
def assign_license_seat(*, entitlement: SoftwareEntitlement, device: ClientDevice, reason: str, actor=None, now=None):
|
||||
reason = _required_reason(reason)
|
||||
now = now or timezone.now()
|
||||
locked_entitlement = SoftwareEntitlement.objects.select_for_update().get(pk=entitlement.pk)
|
||||
if not locked_entitlement.is_usable_at(now):
|
||||
raise LicensingError("entitlement_unavailable", "软件权益当前不可用")
|
||||
if device.user_id != locked_entitlement.user_id:
|
||||
raise LicensingError("device_user_mismatch", "设备不属于权益用户")
|
||||
if device.product_code != locked_entitlement.product_code:
|
||||
raise LicensingError("device_product_mismatch", "设备产品与权益不匹配")
|
||||
if device.status != ClientDevice.Status.ACTIVE:
|
||||
raise LicensingError("device_revoked", "设备已被吊销")
|
||||
|
||||
seats = list(
|
||||
LicenseSeat.objects.select_for_update()
|
||||
.filter(entitlement=locked_entitlement)
|
||||
.order_by("seat_number")
|
||||
)
|
||||
existing_seat = next((seat for seat in seats if seat.device_id == device.id), None)
|
||||
if existing_seat is not None:
|
||||
return existing_seat
|
||||
available_seat = next((seat for seat in seats if seat.device_id is None), None)
|
||||
if available_seat is None:
|
||||
raise LicensingError("seat_limit_reached", "设备席位已用完")
|
||||
|
||||
available_seat.device = device
|
||||
available_seat.bound_at = now
|
||||
available_seat.released_at = None
|
||||
available_seat.save(update_fields=("device", "bound_at", "released_at", "updated_at"))
|
||||
_create_license_event(
|
||||
entitlement=locked_entitlement,
|
||||
seat=available_seat,
|
||||
device=device,
|
||||
action=LicenseEvent.Action.SEAT_ASSIGNED,
|
||||
reason=reason,
|
||||
actor=actor,
|
||||
)
|
||||
return available_seat
|
||||
|
||||
|
||||
@transaction.atomic
|
||||
def release_license_seat(*, seat: LicenseSeat, reason: str, actor=None, now=None):
|
||||
reason = _required_reason(reason)
|
||||
now = now or timezone.now()
|
||||
locked_seat = LicenseSeat.objects.select_for_update().select_related("entitlement", "device").get(
|
||||
pk=seat.pk
|
||||
)
|
||||
if locked_seat.device_id is None:
|
||||
return locked_seat
|
||||
|
||||
previous_device = locked_seat.device
|
||||
locked_seat.device = None
|
||||
locked_seat.released_at = now
|
||||
locked_seat.save(update_fields=("device", "released_at", "updated_at"))
|
||||
_create_license_event(
|
||||
entitlement=locked_seat.entitlement,
|
||||
seat=locked_seat,
|
||||
device=previous_device,
|
||||
action=LicenseEvent.Action.SEAT_RELEASED,
|
||||
reason=reason,
|
||||
actor=actor,
|
||||
)
|
||||
return locked_seat
|
||||
|
||||
Reference in New Issue
Block a user