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