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
@@ -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"