Files
cmhub/apps/licensing/services.py
T

392 lines
13 KiB
Python

from __future__ import annotations
from dataclasses import dataclass
from datetime import timedelta
from django.conf import settings
from django.db import IntegrityError, transaction
from django.utils import timezone
from apps.licensing.models import (
ClientDevice,
DeviceBindingAudit,
DeviceSession,
LicenseEvent,
LicenseSeat,
SoftwareEntitlement,
SoftwarePlan,
)
class DeviceRegistrationError(Exception):
def __init__(self, code: str, message: str):
self.code = code
self.message = message
super().__init__(message)
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
session: DeviceSession
session_token: str
created: bool
def _session_expiry(now):
return now + timedelta(seconds=max(60, settings.DEVICE_SESSION_TTL_SECONDS))
def _find_or_create_device(*, user, product_code, device_id_version, device_id, public_key, platform, client_version, now):
device_fingerprint = ClientDevice.fingerprint_device_id(device_id_version, device_id)
public_key_fingerprint = ClientDevice.fingerprint_public_key(public_key)
lookup = {
"user": user,
"product_code": product_code,
"device_fingerprint": device_fingerprint,
}
device = ClientDevice.objects.select_for_update().filter(**lookup).first()
created = False
if device is None:
try:
with transaction.atomic():
device = ClientDevice.objects.create(
**lookup,
device_id_version=device_id_version,
public_key_fingerprint=public_key_fingerprint,
platform=platform,
client_version=client_version,
last_seen_at=now,
)
created = True
except IntegrityError:
device = ClientDevice.objects.select_for_update().get(**lookup)
if device.status != ClientDevice.Status.ACTIVE:
raise DeviceRegistrationError("device_revoked", "设备已被吊销")
if not device.public_key_fingerprint == public_key_fingerprint:
raise DeviceRegistrationError("device_identity_mismatch", "设备身份校验失败")
updates = []
if device.platform != platform:
device.platform = platform
updates.append("platform")
if device.client_version != client_version:
device.client_version = client_version
updates.append("client_version")
update_after = timedelta(seconds=max(60, settings.DEVICE_ACTIVITY_UPDATE_SECONDS))
if now - device.last_seen_at >= update_after:
device.last_seen_at = now
updates.append("last_seen_at")
if updates:
device.save(update_fields=[*updates, "updated_at"])
return device, created
@transaction.atomic
def register_device(*, user, api_key, product_code, device_id_version, device_id, public_key, platform, client_version):
now = timezone.now()
device, created = _find_or_create_device(
user=user,
product_code=product_code,
device_id_version=device_id_version,
device_id=device_id,
public_key=public_key,
platform=platform,
client_version=client_version,
now=now,
)
# Registration rotates the short-lived session while keeping one device row.
DeviceSession.objects.filter(
device=device,
revoked_at__isnull=True,
expires_at__gt=now,
).update(revoked_at=now)
raw_token = DeviceSession.generate_plaintext_token()
session = DeviceSession.objects.create(
device=device,
token_hash=DeviceSession.hash_token(raw_token),
expires_at=_session_expiry(now),
)
if created:
DeviceBindingAudit.objects.create(
user=user,
device=device,
api_key=api_key,
action=DeviceBindingAudit.Action.REGISTERED,
client_version=client_version,
)
DeviceBindingAudit.objects.create(
user=user,
device=device,
api_key=api_key,
action=DeviceBindingAudit.Action.SESSION_ISSUED,
client_version=client_version,
)
return DeviceRegistrationResult(
device=device,
session=session,
session_token=raw_token,
created=created,
)
@transaction.atomic
def record_device_heartbeat(session: DeviceSession, *, now=None) -> bool:
now = now or timezone.now()
session = (
DeviceSession.objects.select_for_update()
.select_related("device", "device__user")
.get(pk=session.pk)
)
if not session.is_active_at(now) or session.device.status != ClientDevice.Status.ACTIVE:
raise DeviceRegistrationError("device_revoked", "设备会话不可用")
update_after = timedelta(seconds=max(60, settings.DEVICE_ACTIVITY_UPDATE_SECONDS))
last_seen_at = session.device.last_seen_at
if now - last_seen_at < update_after:
return False
ClientDevice.objects.filter(pk=session.device_id).update(last_seen_at=now)
DeviceSession.objects.filter(pk=session.pk).update(last_used_at=now)
DeviceBindingAudit.objects.create(
user=session.device.user,
device=session.device,
action=DeviceBindingAudit.Action.HEARTBEAT,
client_version=session.device.client_version,
)
return True
def resolve_optional_device_session(*, user, raw_token: str) -> DeviceSession | None:
raw_token = str(raw_token or "").strip()
if not raw_token:
return None
try:
session = DeviceSession.objects.select_related("device", "device__user").get(
token_hash=DeviceSession.hash_token(raw_token)
)
except DeviceSession.DoesNotExist as exc:
raise DeviceSessionValidationError(
"device_session_invalid",
"设备会话无效或已过期",
) from exc
if not session.matches_token(raw_token) or not session.is_active_at():
raise DeviceSessionValidationError("device_session_invalid", "设备会话无效或已过期")
if session.device.status != ClientDevice.Status.ACTIVE:
raise DeviceSessionValidationError("device_revoked", "设备已被吊销")
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