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
+71
View File
@@ -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")
@@ -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'),
),
]
+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
+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
+179
View File
@@ -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())