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 class DeviceRegistrationError(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