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, DeviceCredential, DeviceBindingAudit, DeviceSession, LegacyMigrationGrant, LicenseEvent, LicenseSeat, MigrationRequest, 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 AuthorizationDecision: product_code: str allowed: bool code: str credential_id: int | None = None @property def would_reject(self) -> bool: return not self.allowed @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 @transaction.atomic def create_legacy_migration_grant( *, user, plan: SoftwarePlan, reason: str, actor=None, eligibility_snapshot: dict | None = None, ): reason = _required_reason(reason) if LegacyMigrationGrant.objects.filter( user=user, product_code=plan.product_code, ).exists(): raise LicensingError("migration_grant_exists", "该用户已有此产品的迁移资格") entitlement = grant_software_entitlement( user=user, plan=plan, reason=reason, actor=actor, ) snapshot = { "source": "manual", "plan_id": plan.id, "plan_name": plan.name, "plan_device_limit": plan.device_limit, "plan_duration_days": plan.duration_days, } snapshot.update(eligibility_snapshot or {}) grant = LegacyMigrationGrant.objects.create( user=user, product_code=plan.product_code, entitlement=entitlement, eligibility_snapshot=snapshot, reason=reason, actor=actor, ) _create_license_event( entitlement=entitlement, action=LicenseEvent.Action.MIGRATION_GRANTED, reason=reason, actor=actor, metadata={"migration_grant_id": grant.id}, ) return grant @transaction.atomic def create_migration_request(*, user, device: ClientDevice, now=None): now = now or timezone.now() if device.user_id != user.id: raise LicensingError("device_mismatch", "设备不属于当前账号") if device.status != ClientDevice.Status.ACTIVE: raise LicensingError("device_revoked", "设备已被吊销") grant = ( LegacyMigrationGrant.objects.select_related("entitlement") .select_for_update() .filter( user=user, product_code=device.product_code, status=LegacyMigrationGrant.Status.ACTIVE, ) .first() ) if grant is None: raise LicensingError("migration_not_eligible", "当前账号没有可用的存量迁移资格") if not grant.entitlement.is_usable_at(now): raise LicensingError("license_expired", "迁移权益已过期或不可用") if DeviceCredential.objects.filter( device=device, entitlement=grant.entitlement, revoked_at__isnull=True, ).exists(): raise LicensingError("device_credential_exists", "当前设备已完成迁移绑定") raw_token = MigrationRequest.generate_plaintext_credential_token() request = MigrationRequest.objects.create( user=user, device=device, migration_grant=grant, credential_token_hash=MigrationRequest.hash_credential_token(raw_token), credential_token_prefix=raw_token[:20], expires_at=now + timedelta(seconds=max(60, settings.MIGRATION_REQUEST_TTL_SECONDS)), ) return request, raw_token @transaction.atomic def confirm_migration_request(*, request_id, user, now=None): now = now or timezone.now() request = ( MigrationRequest.objects.select_for_update() .select_related("user", "device", "migration_grant", "migration_grant__entitlement") .filter(request_id=request_id) .first() ) if request is None: raise LicensingError("migration_request_not_found", "迁移请求不存在") if request.user_id != user.id: raise LicensingError("migration_request_forbidden", "迁移请求不属于当前账号") existing_credential = DeviceCredential.objects.filter(migration_request=request).first() if existing_credential is not None: return request, existing_credential, False if not request.is_pending_at(now): raise LicensingError("migration_request_expired", "迁移请求已过期或不可用") grant = request.migration_grant if grant.status != LegacyMigrationGrant.Status.ACTIVE: raise LicensingError("migration_not_eligible", "迁移资格已撤销") if not grant.entitlement.is_usable_at(now): raise LicensingError("license_expired", "迁移权益已过期或不可用") seat = assign_license_seat( entitlement=grant.entitlement, device=request.device, reason="存量迁移网页登录确认绑定", actor=user, now=now, ) existing_device_credential = DeviceCredential.objects.filter( device=request.device, entitlement=grant.entitlement, revoked_at__isnull=True, ).first() if existing_device_credential is not None: raise LicensingError("device_credential_exists", "当前设备已完成迁移绑定") credential = DeviceCredential.objects.create( user=user, product_code=request.device.product_code, device=request.device, entitlement=grant.entitlement, seat=seat, migration_request=request, token_hash=request.credential_token_hash, token_prefix=request.credential_token_prefix, expires_at=grant.entitlement.grace_expires_at, ) request.status = MigrationRequest.Status.CONFIRMED request.confirmed_at = now request.save(update_fields=("status", "confirmed_at", "updated_at")) _create_license_event( entitlement=grant.entitlement, seat=seat, device=request.device, action=LicenseEvent.Action.CREDENTIAL_ISSUED, reason="存量迁移网页登录确认签发设备凭证", actor=user, metadata={"migration_request_id": str(request.request_id)}, ) return request, credential, True @transaction.atomic def revoke_device_credential(*, credential: DeviceCredential, reason: str, actor=None, now=None): reason = _required_reason(reason) now = now or timezone.now() locked_credential = ( DeviceCredential.objects.select_for_update() .select_related("seat", "entitlement", "device") .get(pk=credential.pk) ) if locked_credential.revoked_at is not None: return locked_credential locked_credential.revoked_at = now locked_credential.revoke_reason = reason locked_credential.save(update_fields=("revoked_at", "revoke_reason")) _create_license_event( entitlement=locked_credential.entitlement, seat=locked_credential.seat, device=locked_credential.device, action=LicenseEvent.Action.CREDENTIAL_REVOKED, reason=reason, actor=actor, ) if locked_credential.seat.device_id == locked_credential.device_id: release_license_seat( seat=locked_credential.seat, reason=reason, actor=actor, now=now, ) return locked_credential 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: return AuthorizationDecision(product_code, False, "device_not_bound") if device.user_id != user.id or device.product_code != product_code: return AuthorizationDecision(product_code, False, "device_mismatch") raw_credential_token = str(raw_credential_token or "").strip() if not raw_credential_token: return AuthorizationDecision(product_code, False, "license_required") credential = ( DeviceCredential.objects.select_related("device", "entitlement", "seat") .filter(token_hash=MigrationRequest.hash_credential_token(raw_credential_token)) .first() ) if credential is None: return AuthorizationDecision(product_code, False, "license_required") if ( credential.user_id != user.id or credential.product_code != product_code or credential.device_id != device.id or credential.seat.device_id != device.id ): return AuthorizationDecision(product_code, False, "device_mismatch", credential.id) if not credential.is_active_at(now) or not credential.entitlement.is_usable_at(now): return AuthorizationDecision(product_code, False, "license_expired", credential.id) return AuthorizationDecision(product_code, True, "", credential.id)