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
+192
View File
@@ -3,6 +3,7 @@ from __future__ import annotations
import hashlib
import hmac
import secrets
import uuid
from django.conf import settings
from django.core.exceptions import ValidationError
@@ -269,6 +270,9 @@ class LicenseEvent(models.Model):
REVOKED = "revoked", "撤销"
SEAT_ASSIGNED = "seat_assigned", "绑定席位"
SEAT_RELEASED = "seat_released", "解绑席位"
MIGRATION_GRANTED = "migration_granted", "迁移资格授予"
CREDENTIAL_ISSUED = "credential_issued", "设备凭证签发"
CREDENTIAL_REVOKED = "credential_revoked", "设备凭证吊销"
entitlement = models.ForeignKey(
SoftwareEntitlement,
@@ -319,6 +323,194 @@ class LicenseEvent(models.Model):
return f"{self.entitlement} {self.action}"
class LegacyMigrationGrant(models.Model):
class Status(models.TextChoices):
ACTIVE = "active", "有效"
REVOKED = "revoked", "已撤销"
user = models.ForeignKey(
settings.AUTH_USER_MODEL,
verbose_name="用户",
on_delete=models.PROTECT,
related_name="legacy_migration_grants",
)
product_code = models.CharField(
"产品代码",
max_length=32,
choices=ClientDevice.ProductCode.choices,
)
entitlement = models.OneToOneField(
SoftwareEntitlement,
verbose_name="迁移权益",
on_delete=models.PROTECT,
related_name="legacy_migration_grant",
)
eligibility_snapshot = models.JSONField("资格快照", default=dict, blank=True)
status = models.CharField(
"状态",
max_length=20,
choices=Status.choices,
default=Status.ACTIVE,
)
reason = models.CharField("迁移原因", max_length=255)
actor = models.ForeignKey(
settings.AUTH_USER_MODEL,
verbose_name="操作人",
null=True,
blank=True,
on_delete=models.SET_NULL,
related_name="legacy_migration_grants_performed",
)
revoked_at = models.DateTimeField("撤销时间", null=True, blank=True)
created_at = models.DateTimeField("创建时间", auto_now_add=True)
updated_at = models.DateTimeField("更新时间", auto_now=True)
class Meta:
db_table = "legacy_migration_grant"
verbose_name = "存量迁移资格"
verbose_name_plural = "存量迁移资格"
ordering = ("-created_at", "-id")
constraints = [
models.UniqueConstraint(
fields=("user", "product_code"),
name="legacy_migration_grant_user_product_unique",
),
]
indexes = [
models.Index(fields=("product_code", "status")),
]
def __str__(self) -> str:
return f"{self.user} {self.product_code} 迁移资格"
class MigrationRequest(models.Model):
class Status(models.TextChoices):
PENDING = "pending", "待确认"
CONFIRMED = "confirmed", "已确认"
EXPIRED = "expired", "已过期"
REVOKED = "revoked", "已撤销"
request_id = models.UUIDField("公开迁移请求 ID", default=uuid.uuid4, unique=True, editable=False)
user = models.ForeignKey(
settings.AUTH_USER_MODEL,
verbose_name="用户",
on_delete=models.PROTECT,
related_name="migration_requests",
)
device = models.ForeignKey(
ClientDevice,
verbose_name="当前设备",
on_delete=models.PROTECT,
related_name="migration_requests",
)
migration_grant = models.ForeignKey(
LegacyMigrationGrant,
verbose_name="迁移资格",
on_delete=models.PROTECT,
related_name="requests",
)
credential_token_hash = models.CharField("待签发凭证哈希", max_length=64, unique=True, editable=False)
credential_token_prefix = models.CharField("待签发凭证前缀", max_length=20, editable=False)
status = models.CharField(
"状态",
max_length=20,
choices=Status.choices,
default=Status.PENDING,
)
expires_at = models.DateTimeField("确认截止时间")
confirmed_at = models.DateTimeField("确认时间", null=True, blank=True)
revoked_at = models.DateTimeField("撤销时间", null=True, blank=True)
created_at = models.DateTimeField("创建时间", auto_now_add=True)
updated_at = models.DateTimeField("更新时间", auto_now=True)
class Meta:
db_table = "migration_request"
verbose_name = "迁移确认请求"
verbose_name_plural = "迁移确认请求"
ordering = ("-created_at", "-id")
indexes = [
models.Index(fields=("user", "device", "status")),
models.Index(fields=("status", "expires_at")),
]
def __str__(self) -> str:
return f"{self.user} {self.device} {self.status}"
@staticmethod
def generate_plaintext_credential_token() -> str:
return f"dvc_cmhub_{secrets.token_urlsafe(32)}"
@staticmethod
def hash_credential_token(raw_token: str) -> str:
return hashlib.sha256(raw_token.encode("utf-8")).hexdigest()
def is_pending_at(self, now=None) -> bool:
now = now or timezone.now()
return self.status == self.Status.PENDING and self.expires_at > now
class DeviceCredential(models.Model):
user = models.ForeignKey(
settings.AUTH_USER_MODEL,
verbose_name="用户",
on_delete=models.PROTECT,
related_name="device_credentials",
)
product_code = models.CharField(
"产品代码",
max_length=32,
choices=ClientDevice.ProductCode.choices,
)
device = models.ForeignKey(
ClientDevice,
verbose_name="设备",
on_delete=models.PROTECT,
related_name="credentials",
)
entitlement = models.ForeignKey(
SoftwareEntitlement,
verbose_name="软件权益",
on_delete=models.PROTECT,
related_name="device_credentials",
)
seat = models.ForeignKey(
LicenseSeat,
verbose_name="授权席位",
on_delete=models.PROTECT,
related_name="device_credentials",
)
migration_request = models.OneToOneField(
MigrationRequest,
verbose_name="来源迁移请求",
on_delete=models.PROTECT,
related_name="credential",
)
token_hash = models.CharField("设备凭证哈希", max_length=64, unique=True, editable=False)
token_prefix = models.CharField("设备凭证前缀", max_length=20, editable=False)
expires_at = models.DateTimeField("凭证到期时间")
revoked_at = models.DateTimeField("吊销时间", null=True, blank=True)
revoke_reason = models.CharField("吊销原因", max_length=255, blank=True)
created_at = models.DateTimeField("签发时间", auto_now_add=True)
class Meta:
db_table = "device_credential"
verbose_name = "设备凭证"
verbose_name_plural = "设备凭证"
ordering = ("-created_at", "-id")
indexes = [
models.Index(fields=("user", "product_code", "revoked_at")),
models.Index(fields=("device", "revoked_at")),
]
def __str__(self) -> str:
return f"{self.user} {self.product_code} credential"
def is_active_at(self, now=None) -> bool:
now = now or timezone.now()
return self.revoked_at is None and self.expires_at > now
class DeviceSession(models.Model):
TOKEN_PREFIX_LENGTH = 12