feat: add legacy device migration flow
This commit is contained in:
@@ -8,16 +8,20 @@ from django.urls import path, reverse
|
||||
|
||||
from .models import (
|
||||
ClientDevice,
|
||||
DeviceCredential,
|
||||
DeviceBindingAudit,
|
||||
DeviceSession,
|
||||
LicenseEvent,
|
||||
LicenseSeat,
|
||||
LegacyMigrationGrant,
|
||||
MigrationRequest,
|
||||
SoftwareEntitlement,
|
||||
SoftwarePlan,
|
||||
)
|
||||
from .services import (
|
||||
LicensingError,
|
||||
grant_software_entitlement,
|
||||
create_legacy_migration_grant,
|
||||
renew_software_entitlement,
|
||||
revoke_software_entitlement,
|
||||
)
|
||||
@@ -351,3 +355,70 @@ class LicenseEventAdmin(ReadOnlyLicenseAdmin):
|
||||
"reason",
|
||||
)
|
||||
list_select_related = ("entitlement", "seat", "device", "actor")
|
||||
|
||||
|
||||
class LegacyMigrationGrantForm(EntitlementGrantForm):
|
||||
reason = forms.CharField(label="迁移原因", widget=forms.Textarea(attrs={"rows": 4}))
|
||||
|
||||
|
||||
@admin.register(LegacyMigrationGrant)
|
||||
class LegacyMigrationGrantAdmin(ReadOnlyLicenseAdmin):
|
||||
list_display = ("user", "product_code", "entitlement", "status", "actor", "created_at")
|
||||
list_filter = ("product_code", "status", "created_at")
|
||||
search_fields = ("user__username", "user__email", "reason")
|
||||
list_select_related = ("user", "entitlement", "actor")
|
||||
|
||||
def get_urls(self):
|
||||
urls = super().get_urls()
|
||||
return [
|
||||
path(
|
||||
"grant/",
|
||||
self.admin_site.admin_view(self.grant_view),
|
||||
name="licensing_legacymigrationgrant_grant",
|
||||
),
|
||||
] + urls
|
||||
|
||||
def grant_view(self, request):
|
||||
form = LegacyMigrationGrantForm(request.POST or None)
|
||||
if request.method == "POST" and form.is_valid():
|
||||
try:
|
||||
grant = create_legacy_migration_grant(
|
||||
user=form.cleaned_data["user"],
|
||||
plan=form.cleaned_data["plan"],
|
||||
reason=form.cleaned_data["reason"],
|
||||
actor=request.user,
|
||||
)
|
||||
except LicensingError as exc:
|
||||
form.add_error(None, exc.message)
|
||||
else:
|
||||
self.message_user(request, "存量迁移资格已授予。", messages.SUCCESS)
|
||||
return HttpResponseRedirect(
|
||||
reverse("admin:licensing_legacymigrationgrant_change", args=(grant.pk,))
|
||||
)
|
||||
context = {
|
||||
**self.admin_site.each_context(request),
|
||||
"title": "授予存量迁移资格",
|
||||
"opts": self.model._meta,
|
||||
"form": form,
|
||||
}
|
||||
return TemplateResponse(
|
||||
request,
|
||||
"admin/licensing/entitlement_operation.html",
|
||||
context,
|
||||
)
|
||||
|
||||
|
||||
@admin.register(MigrationRequest)
|
||||
class MigrationRequestAdmin(ReadOnlyLicenseAdmin):
|
||||
list_display = ("request_id", "user", "device", "migration_grant", "status", "expires_at", "confirmed_at")
|
||||
list_filter = ("status", "device__product_code", "expires_at")
|
||||
search_fields = ("=request_id", "user__username", "user__email")
|
||||
list_select_related = ("user", "device", "migration_grant")
|
||||
|
||||
|
||||
@admin.register(DeviceCredential)
|
||||
class DeviceCredentialAdmin(ReadOnlyLicenseAdmin):
|
||||
list_display = ("token_prefix", "user", "product_code", "device", "entitlement", "expires_at", "revoked_at")
|
||||
list_filter = ("product_code", "revoked_at", "expires_at")
|
||||
search_fields = ("token_prefix", "user__username", "user__email")
|
||||
list_select_related = ("user", "device", "entitlement", "seat")
|
||||
|
||||
+116
@@ -0,0 +1,116 @@
|
||||
# Generated by Django 5.2.15 on 2026-07-21 01:57
|
||||
|
||||
import django.db.models.deletion
|
||||
import uuid
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('licensing', '0002_softwareentitlement_licenseseat_licenseevent_and_more'),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='licenseevent',
|
||||
name='action',
|
||||
field=models.CharField(choices=[('granted', '人工授予'), ('renewed', '续期'), ('revoked', '撤销'), ('seat_assigned', '绑定席位'), ('seat_released', '解绑席位'), ('migration_granted', '迁移资格授予'), ('credential_issued', '设备凭证签发'), ('credential_revoked', '设备凭证吊销')], max_length=32, verbose_name='动作'),
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='LegacyMigrationGrant',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('product_code', models.CharField(choices=[('cmshopee', '虾皮圈优化助手')], max_length=32, verbose_name='产品代码')),
|
||||
('eligibility_snapshot', models.JSONField(blank=True, default=dict, verbose_name='资格快照')),
|
||||
('status', models.CharField(choices=[('active', '有效'), ('revoked', '已撤销')], default='active', max_length=20, verbose_name='状态')),
|
||||
('reason', models.CharField(max_length=255, verbose_name='迁移原因')),
|
||||
('revoked_at', models.DateTimeField(blank=True, null=True, verbose_name='撤销时间')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='创建时间')),
|
||||
('updated_at', models.DateTimeField(auto_now=True, verbose_name='更新时间')),
|
||||
('actor', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='legacy_migration_grants_performed', to=settings.AUTH_USER_MODEL, verbose_name='操作人')),
|
||||
('entitlement', models.OneToOneField(on_delete=django.db.models.deletion.PROTECT, related_name='legacy_migration_grant', to='licensing.softwareentitlement', verbose_name='迁移权益')),
|
||||
('user', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='legacy_migration_grants', to=settings.AUTH_USER_MODEL, verbose_name='用户')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': '存量迁移资格',
|
||||
'verbose_name_plural': '存量迁移资格',
|
||||
'db_table': 'legacy_migration_grant',
|
||||
'ordering': ('-created_at', '-id'),
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='MigrationRequest',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('request_id', models.UUIDField(default=uuid.uuid4, editable=False, unique=True, verbose_name='公开迁移请求 ID')),
|
||||
('credential_token_hash', models.CharField(editable=False, max_length=64, unique=True, verbose_name='待签发凭证哈希')),
|
||||
('credential_token_prefix', models.CharField(editable=False, max_length=20, verbose_name='待签发凭证前缀')),
|
||||
('status', models.CharField(choices=[('pending', '待确认'), ('confirmed', '已确认'), ('expired', '已过期'), ('revoked', '已撤销')], default='pending', max_length=20, verbose_name='状态')),
|
||||
('expires_at', models.DateTimeField(verbose_name='确认截止时间')),
|
||||
('confirmed_at', models.DateTimeField(blank=True, null=True, verbose_name='确认时间')),
|
||||
('revoked_at', models.DateTimeField(blank=True, null=True, verbose_name='撤销时间')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='创建时间')),
|
||||
('updated_at', models.DateTimeField(auto_now=True, verbose_name='更新时间')),
|
||||
('device', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='migration_requests', to='licensing.clientdevice', verbose_name='当前设备')),
|
||||
('migration_grant', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='requests', to='licensing.legacymigrationgrant', verbose_name='迁移资格')),
|
||||
('user', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='migration_requests', to=settings.AUTH_USER_MODEL, verbose_name='用户')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': '迁移确认请求',
|
||||
'verbose_name_plural': '迁移确认请求',
|
||||
'db_table': 'migration_request',
|
||||
'ordering': ('-created_at', '-id'),
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='DeviceCredential',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('product_code', models.CharField(choices=[('cmshopee', '虾皮圈优化助手')], max_length=32, verbose_name='产品代码')),
|
||||
('token_hash', models.CharField(editable=False, max_length=64, unique=True, verbose_name='设备凭证哈希')),
|
||||
('token_prefix', models.CharField(editable=False, max_length=20, verbose_name='设备凭证前缀')),
|
||||
('expires_at', models.DateTimeField(verbose_name='凭证到期时间')),
|
||||
('revoked_at', models.DateTimeField(blank=True, null=True, verbose_name='吊销时间')),
|
||||
('revoke_reason', models.CharField(blank=True, max_length=255, verbose_name='吊销原因')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='签发时间')),
|
||||
('device', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='credentials', to='licensing.clientdevice', verbose_name='设备')),
|
||||
('entitlement', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='device_credentials', to='licensing.softwareentitlement', verbose_name='软件权益')),
|
||||
('seat', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='device_credentials', to='licensing.licenseseat', verbose_name='授权席位')),
|
||||
('user', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='device_credentials', to=settings.AUTH_USER_MODEL, verbose_name='用户')),
|
||||
('migration_request', models.OneToOneField(on_delete=django.db.models.deletion.PROTECT, related_name='credential', to='licensing.migrationrequest', verbose_name='来源迁移请求')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': '设备凭证',
|
||||
'verbose_name_plural': '设备凭证',
|
||||
'db_table': 'device_credential',
|
||||
'ordering': ('-created_at', '-id'),
|
||||
},
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='legacymigrationgrant',
|
||||
index=models.Index(fields=['product_code', 'status'], name='legacy_migr_product_1a9e68_idx'),
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name='legacymigrationgrant',
|
||||
constraint=models.UniqueConstraint(fields=('user', 'product_code'), name='legacy_migration_grant_user_product_unique'),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='migrationrequest',
|
||||
index=models.Index(fields=['user', 'device', 'status'], name='migration_r_user_id_95c575_idx'),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='migrationrequest',
|
||||
index=models.Index(fields=['status', 'expires_at'], name='migration_r_status_3ec1e9_idx'),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='devicecredential',
|
||||
index=models.Index(fields=['user', 'product_code', 'revoked_at'], name='device_cred_user_id_7bb723_idx'),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='devicecredential',
|
||||
index=models.Index(fields=['device', 'revoked_at'], name='device_cred_device__92d6db_idx'),
|
||||
),
|
||||
]
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -11,20 +11,27 @@ from rest_framework.test import APIClient
|
||||
from apps.licensing.models import (
|
||||
ClientDevice,
|
||||
DeviceBindingAudit,
|
||||
DeviceCredential,
|
||||
DeviceSession,
|
||||
LegacyMigrationGrant,
|
||||
LicenseEvent,
|
||||
LicenseSeat,
|
||||
MigrationRequest,
|
||||
SoftwareEntitlement,
|
||||
SoftwarePlan,
|
||||
)
|
||||
from apps.licensing.services import (
|
||||
LicensingError,
|
||||
assign_license_seat,
|
||||
confirm_migration_request,
|
||||
create_legacy_migration_grant,
|
||||
create_migration_request,
|
||||
grant_software_entitlement,
|
||||
record_device_heartbeat,
|
||||
release_license_seat,
|
||||
renew_software_entitlement,
|
||||
revoke_software_entitlement,
|
||||
register_device,
|
||||
)
|
||||
from apps.users.models import ApiKey, User, UserWallet
|
||||
|
||||
@@ -446,3 +453,175 @@ class LicenseSeatConcurrencyTests(TransactionTestCase):
|
||||
self.assertEqual(sum(outcome[0] == "seat_limit_reached" for outcome in outcomes), 1)
|
||||
seat = LicenseSeat.objects.get(entitlement=self.entitlement)
|
||||
self.assertIn(seat.device_id, {self.first_device.pk, self.second_device.pk})
|
||||
|
||||
|
||||
class LegacyMigrationFlowTests(TestCase):
|
||||
def setUp(self):
|
||||
self.user = User.objects.create_user(
|
||||
username="migration-user",
|
||||
email="migration@example.com",
|
||||
password="test-password",
|
||||
)
|
||||
self.other_user = User.objects.create_user(
|
||||
username="migration-other",
|
||||
email="migration-other@example.com",
|
||||
password="test-password",
|
||||
)
|
||||
self.plan = SoftwarePlan.objects.create(
|
||||
product_code=ClientDevice.ProductCode.CMSHOPEE,
|
||||
name="存量迁移套餐",
|
||||
duration_days=30,
|
||||
price=Decimal("1.00"),
|
||||
device_limit=1,
|
||||
)
|
||||
self.api_key, self.raw_api_key = ApiKey.create_for_user(self.user, name="legacy")
|
||||
self.device, self.device_session_token = self.register_device_session()
|
||||
self.client = APIClient()
|
||||
|
||||
def register_device_session(self):
|
||||
result = register_device(
|
||||
user=self.user,
|
||||
api_key=self.api_key,
|
||||
product_code=ClientDevice.ProductCode.CMSHOPEE,
|
||||
device_id_version="v1",
|
||||
device_id="migration-device-id",
|
||||
public_key="migration-device-public-key",
|
||||
platform=ClientDevice.Platform.WINDOWS,
|
||||
client_version="0.2.0",
|
||||
)
|
||||
return result.device, result.session_token
|
||||
|
||||
def grant_migration(self):
|
||||
return create_legacy_migration_grant(
|
||||
user=self.user,
|
||||
plan=self.plan,
|
||||
reason="历史付费用户迁移",
|
||||
eligibility_snapshot={"legacy_customer_id": "legacy-001"},
|
||||
)
|
||||
|
||||
def api_headers(self):
|
||||
return {
|
||||
"HTTP_AUTHORIZATION": f"Bearer {self.raw_api_key}",
|
||||
"HTTP_X_DEVICE_SESSION": self.device_session_token,
|
||||
}
|
||||
|
||||
def test_request_confirm_poll_and_revoke_flow_keeps_credential_hashed(self):
|
||||
self.grant_migration()
|
||||
create_response = self.client.post(
|
||||
reverse("api-client-migration-request-create"),
|
||||
{},
|
||||
format="json",
|
||||
**self.api_headers(),
|
||||
)
|
||||
self.assertEqual(create_response.status_code, 201)
|
||||
raw_credential = create_response.data["device_credential_token"]
|
||||
self.assertTrue(create_response.data["confirmation_url"].endswith(create_response.data["request_id"]))
|
||||
migration_request = MigrationRequest.objects.get(
|
||||
request_id=create_response.data["request_id"]
|
||||
)
|
||||
self.assertNotEqual(migration_request.credential_token_hash, raw_credential)
|
||||
|
||||
pending = self.client.get(
|
||||
reverse(
|
||||
"api-client-migration-request-detail",
|
||||
args=(migration_request.request_id,),
|
||||
),
|
||||
**self.api_headers(),
|
||||
)
|
||||
self.assertEqual(pending.status_code, 200)
|
||||
self.assertEqual(pending.data["status"], MigrationRequest.Status.PENDING)
|
||||
self.assertNotIn("device_credential_token", pending.data)
|
||||
|
||||
self.client.force_login(self.user)
|
||||
confirm_url = reverse("portal-migration-confirm", args=(migration_request.request_id,))
|
||||
self.assertEqual(self.client.get(confirm_url).status_code, 200)
|
||||
self.assertEqual(self.client.post(confirm_url).status_code, 302)
|
||||
self.assertEqual(self.client.post(confirm_url).status_code, 302)
|
||||
|
||||
credential = DeviceCredential.objects.get(migration_request=migration_request)
|
||||
self.assertNotEqual(credential.token_hash, raw_credential)
|
||||
self.assertEqual(credential.token_hash, MigrationRequest.hash_credential_token(raw_credential))
|
||||
self.assertEqual(credential.seat.device_id, self.device.id)
|
||||
self.assertEqual(DeviceCredential.objects.count(), 1)
|
||||
self.assertEqual(
|
||||
LicenseEvent.objects.filter(
|
||||
action=LicenseEvent.Action.CREDENTIAL_ISSUED,
|
||||
).count(),
|
||||
1,
|
||||
)
|
||||
|
||||
confirmed = self.client.get(
|
||||
reverse(
|
||||
"api-client-migration-request-detail",
|
||||
args=(migration_request.request_id,),
|
||||
),
|
||||
**self.api_headers(),
|
||||
)
|
||||
self.assertEqual(confirmed.status_code, 200)
|
||||
self.assertEqual(confirmed.data["status"], MigrationRequest.Status.CONFIRMED)
|
||||
|
||||
revoke = self.client.post(
|
||||
reverse("portal-device-credential-revoke", args=(credential.pk,))
|
||||
)
|
||||
self.assertEqual(revoke.status_code, 302)
|
||||
credential.refresh_from_db()
|
||||
credential.seat.refresh_from_db()
|
||||
self.assertIsNotNone(credential.revoked_at)
|
||||
self.assertIsNone(credential.seat.device_id)
|
||||
self.assertTrue(
|
||||
LicenseEvent.objects.filter(
|
||||
action=LicenseEvent.Action.CREDENTIAL_REVOKED,
|
||||
reason="用户自助解绑设备",
|
||||
).exists()
|
||||
)
|
||||
|
||||
def test_request_requires_eligible_current_device_and_web_confirmation_same_user(self):
|
||||
no_grant = self.client.post(
|
||||
reverse("api-client-migration-request-create"),
|
||||
{},
|
||||
format="json",
|
||||
**self.api_headers(),
|
||||
)
|
||||
self.assertEqual(no_grant.status_code, 403)
|
||||
self.assertEqual(no_grant.data["error"]["code"], "migration_not_eligible")
|
||||
|
||||
self.grant_migration()
|
||||
missing_device_session = self.client.post(
|
||||
reverse("api-client-migration-request-create"),
|
||||
{},
|
||||
format="json",
|
||||
HTTP_AUTHORIZATION=f"Bearer {self.raw_api_key}",
|
||||
)
|
||||
self.assertEqual(missing_device_session.status_code, 401)
|
||||
self.assertEqual(missing_device_session.data["error"]["code"], "device_session_required")
|
||||
|
||||
created = self.client.post(
|
||||
reverse("api-client-migration-request-create"),
|
||||
{},
|
||||
format="json",
|
||||
**self.api_headers(),
|
||||
)
|
||||
migration_request = MigrationRequest.objects.get(request_id=created.data["request_id"])
|
||||
self.client.force_login(self.other_user)
|
||||
confirmation = self.client.get(
|
||||
reverse("portal-migration-confirm", args=(migration_request.request_id,))
|
||||
)
|
||||
self.assertEqual(confirmation.status_code, 403)
|
||||
self.assertFalse(DeviceCredential.objects.exists())
|
||||
|
||||
def test_expired_request_and_duplicate_migration_grant_are_rejected(self):
|
||||
self.grant_migration()
|
||||
with self.assertRaisesRegex(LicensingError, "已有此产品的迁移资格"):
|
||||
self.grant_migration()
|
||||
migration_request, _raw_token = create_migration_request(
|
||||
user=self.user,
|
||||
device=self.device,
|
||||
now=timezone.now() - timedelta(minutes=20),
|
||||
)
|
||||
with self.assertRaisesRegex(LicensingError, "迁移请求已过期"):
|
||||
confirm_migration_request(
|
||||
request_id=migration_request.request_id,
|
||||
user=self.user,
|
||||
now=timezone.now(),
|
||||
)
|
||||
self.assertFalse(DeviceCredential.objects.exists())
|
||||
|
||||
Reference in New Issue
Block a user