feat: add legacy device migration flow

This commit is contained in:
QiuSW
2026-07-21 10:06:51 +08:00
parent 7b80ae3a7a
commit 1af47d043e
20 changed files with 1030 additions and 6 deletions
+190
View File
@@ -9,10 +9,13 @@ from django.utils import timezone
from apps.licensing.models import (
ClientDevice,
DeviceCredential,
DeviceBindingAudit,
DeviceSession,
LegacyMigrationGrant,
LicenseEvent,
LicenseSeat,
MigrationRequest,
SoftwareEntitlement,
SoftwarePlan,
)
@@ -389,3 +392,190 @@ def release_license_seat(*, seat: LicenseSeat, reason: str, actor=None, now=None
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