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
+12
View File
@@ -12,6 +12,8 @@ from .views import (
GenerateImageView,
GenerateTitleView,
ModelsView,
MigrationRequestCreateView,
MigrationRequestDetailView,
RechargeCreateView,
RechargeStatusView,
WechatRechargeCallbackView,
@@ -30,6 +32,16 @@ urlpatterns = [
DeviceHeartbeatView.as_view(),
name="api-client-device-heartbeat",
),
path(
"v1/client/migration-requests",
MigrationRequestCreateView.as_view(),
name="api-client-migration-request-create",
),
path(
"v1/client/migration-requests/<uuid:request_id>",
MigrationRequestDetailView.as_view(),
name="api-client-migration-request-detail",
),
path(
"v1/client/releases/latest",
ClientLatestReleaseView.as_view(),
+85
View File
@@ -66,12 +66,15 @@ from apps.billing.services import (
from apps.portal.models import DownloadRelease
from apps.licensing.authentication import DeviceSessionAuthentication
from apps.licensing.services import (
LicensingError,
DeviceRegistrationError,
DeviceSessionValidationError,
create_migration_request,
record_device_heartbeat,
register_device,
resolve_optional_device_session,
)
from apps.licensing.models import MigrationRequest
logger = logging.getLogger(__name__)
@@ -478,6 +481,88 @@ class DeviceHeartbeatView(DeviceSessionApiView):
)
def _migration_request_response(migration_request, *, credential_token=None, confirmation_url=""):
data = {
"request_id": str(migration_request.request_id),
"status": migration_request.status,
"product_code": migration_request.device.product_code,
"expires_at": migration_request.expires_at.isoformat(),
"confirmed_at": (
migration_request.confirmed_at.isoformat()
if migration_request.confirmed_at
else None
),
"confirmation_url": confirmation_url,
}
if credential_token is not None:
data["device_credential_token"] = credential_token
return data
class MigrationRequestCreateView(ExternalApiView):
def post(self, request):
try:
device = self.optional_client_device(request)
except ApiRequestError as exc:
return Response(exc.as_response_data(), status=exc.http_status)
if device is None:
return Response(
api_error("device_session_required", "必须提供当前设备会话"),
status=status.HTTP_401_UNAUTHORIZED,
)
try:
migration_request, credential_token = create_migration_request(
user=request.user,
device=device,
)
except LicensingError as exc:
response_status = (
status.HTTP_403_FORBIDDEN
if exc.code in {"migration_not_eligible", "license_expired"}
else status.HTTP_400_BAD_REQUEST
)
return Response(api_error(exc.code, exc.message), status=response_status)
confirmation_url = request.build_absolute_uri(
f"/migration/confirm/{migration_request.request_id}"
)
return Response(
_migration_request_response(
migration_request,
credential_token=credential_token,
confirmation_url=confirmation_url,
),
status=status.HTTP_201_CREATED,
)
class MigrationRequestDetailView(ExternalApiView):
def get(self, request, request_id):
try:
device = self.optional_client_device(request)
except ApiRequestError as exc:
return Response(exc.as_response_data(), status=exc.http_status)
if device is None:
return Response(
api_error("device_session_required", "必须提供当前设备会话"),
status=status.HTTP_401_UNAUTHORIZED,
)
migration_request = (
MigrationRequest.objects.select_related("device")
.filter(request_id=request_id, user=request.user, device=device)
.first()
)
if migration_request is None:
return Response(
api_error("migration_request_not_found", "迁移请求不存在"),
status=status.HTTP_404_NOT_FOUND,
)
return Response(
_migration_request_response(migration_request),
status=status.HTTP_200_OK,
)
def _release_unpublished_response(platform: str) -> dict:
return {
"platform": platform,
+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())
@@ -0,0 +1,17 @@
{% extends "portal/base.html" %}
{% block title %}设备授权 | 虾皮圈{% endblock %}
{% block content %}
<section class="container py-5">
<h1 class="h3 mb-4">设备授权</h1>
<div class="table-responsive"><table class="table align-middle">
<thead><tr><th>产品</th><th>设备平台</th><th>客户端版本</th><th>到期时间</th><th>状态</th><th></th></tr></thead>
<tbody>{% for credential in credentials %}<tr>
<td>{{ credential.get_product_code_display }}</td><td>{{ credential.device.get_platform_display }}</td><td>{{ credential.device.client_version }}</td><td>{{ credential.expires_at }}</td>
<td>{% if credential.revoked_at %}已吊销{% else %}有效{% endif %}</td>
<td>{% if not credential.revoked_at %}<form method="post" action="{% url 'portal-device-credential-revoke' credential.pk %}">{% csrf_token %}<button class="btn btn-outline-danger btn-sm">解绑</button></form>{% endif %}</td>
</tr>{% empty %}<tr><td colspan="6" class="text-muted">暂无已确认设备。</td></tr>{% endfor %}</tbody>
</table></div>
</section>
{% endblock %}
@@ -0,0 +1,19 @@
{% extends "portal/base.html" %}
{% block title %}确认设备迁移 | 虾皮圈{% endblock %}
{% block content %}
<section class="container py-5" style="max-width: 720px;">
<h1 class="h3 mb-3">确认设备迁移</h1>
<p class="text-muted">确认后,当前设备会占用一个软件授权席位。</p>
<dl class="row">
<dt class="col-sm-4">产品</dt><dd class="col-sm-8">{{ migration_request.device.get_product_code_display }}</dd>
<dt class="col-sm-4">客户端版本</dt><dd class="col-sm-8">{{ migration_request.device.client_version }}</dd>
<dt class="col-sm-4">确认截止</dt><dd class="col-sm-8">{{ migration_request.expires_at }}</dd>
<dt class="col-sm-4">状态</dt><dd class="col-sm-8">{{ migration_request.get_status_display }}</dd>
</dl>
{% if migration_request.status == "pending" %}
<form method="post">{% csrf_token %}<button type="submit" class="btn btn-primary">确认当前设备</button></form>
{% endif %}
</section>
{% endblock %}
+14
View File
@@ -5,8 +5,11 @@ from .views import (
ApiKeyDeleteView,
ApiKeyListCreateView,
DashboardView,
DeviceCredentialListView,
DeviceCredentialRevokeView,
HomeView,
ModelCatalogView,
MigrationConfirmView,
RechargePageView,
RechargeRecordListView,
UsageRecordListView,
@@ -22,6 +25,17 @@ urlpatterns = [
path("apikeys/<int:pk>/delete", ApiKeyDeleteView.as_view(), name="portal-apikey-delete"),
path("models", ModelCatalogView.as_view(), name="portal-models"),
path("recharge", RechargePageView.as_view(), name="portal-recharge"),
path(
"migration/confirm/<uuid:request_id>",
MigrationConfirmView.as_view(),
name="portal-migration-confirm",
),
path("migration/devices", DeviceCredentialListView.as_view(), name="portal-device-credentials"),
path(
"migration/credentials/<int:pk>/revoke",
DeviceCredentialRevokeView.as_view(),
name="portal-device-credential-revoke",
),
path("records/recharge", RechargeRecordListView.as_view(), name="portal-recharge-records"),
path("records/usage", UsageRecordListView.as_view(), name="portal-usage-records"),
]
+70
View File
@@ -1,5 +1,6 @@
from django.contrib import messages
from django.contrib.auth.mixins import LoginRequiredMixin
from django.core.exceptions import PermissionDenied
from django.core.paginator import Paginator
from django.db.models import Sum
from django.shortcuts import get_object_or_404, redirect
@@ -17,6 +18,12 @@ from apps.billing.services import (
get_balance_snapshot,
)
from apps.users.models import ApiKey
from apps.licensing.models import DeviceCredential, MigrationRequest
from apps.licensing.services import (
LicensingError,
confirm_migration_request,
revoke_device_credential,
)
from .forms import ApiKeyCreateForm, RechargeCreateForm
from .models import DownloadRelease, ImportTemplate
@@ -243,6 +250,69 @@ class RechargePageView(LoginRequiredMixin, FormView):
return redirect(f"{recharge_url}?order_no={order.order_no}")
class MigrationConfirmView(LoginRequiredMixin, TemplateView):
template_name = "portal/migration_confirm.html"
def get_migration_request(self):
migration_request = get_object_or_404(
MigrationRequest.objects.select_related("device", "migration_grant__entitlement"),
request_id=self.kwargs["request_id"],
)
if migration_request.user_id != self.request.user.id:
raise PermissionDenied("迁移请求不属于当前账号")
return migration_request
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context["migration_request"] = self.get_migration_request()
return context
def post(self, request, *args, **kwargs):
migration_request = self.get_migration_request()
try:
_request, _credential, created = confirm_migration_request(
request_id=migration_request.request_id,
user=request.user,
)
except LicensingError as exc:
messages.error(request, exc.message)
else:
messages.success(
request,
"设备迁移已确认。" if created else "该设备迁移已确认,无需重复操作。",
)
return redirect("portal-migration-confirm", request_id=migration_request.request_id)
class DeviceCredentialListView(LoginRequiredMixin, TemplateView):
template_name = "portal/device_credentials.html"
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context["credentials"] = (
DeviceCredential.objects.filter(user=self.request.user)
.select_related("device", "entitlement", "seat")
.order_by("-created_at", "-id")
)
return context
class DeviceCredentialRevokeView(LoginRequiredMixin, View):
def post(self, request, pk):
credential = get_object_or_404(DeviceCredential, pk=pk, user=request.user)
try:
revoke_device_credential(
credential=credential,
reason="用户自助解绑设备",
actor=request.user,
)
except LicensingError as exc:
messages.error(request, exc.message)
else:
messages.success(request, "设备已解绑,原设备凭证已吊销。")
return redirect("portal-device-credentials")
class RechargeRecordListView(LoginRequiredMixin, TemplateView):
template_name = "portal/recharge_records.html"