From 1af47d043e8d321a82135623bc4b6c4b27ba0b51 Mon Sep 17 00:00:00 2001 From: QiuSW <105186638@qq.com> Date: Tue, 21 Jul 2026 10:06:51 +0800 Subject: [PATCH] feat: add legacy device migration flow --- .env.example | 1 + apps/api/urls.py | 12 ++ apps/api/views.py | 85 ++++++++ apps/licensing/admin.py | 71 +++++++ ...nt_action_legacymigrationgrant_and_more.py | 116 +++++++++++ apps/licensing/models.py | 192 ++++++++++++++++++ apps/licensing/services.py | 190 +++++++++++++++++ apps/licensing/tests.py | 179 ++++++++++++++++ .../templates/portal/device_credentials.html | 17 ++ .../templates/portal/migration_confirm.html | 19 ++ apps/portal/urls.py | 14 ++ apps/portal/views.py | 70 +++++++ config/settings.py | 1 + docs/04-architecture.md | 5 + docs/06-tasks.md | 2 +- docs/api.md | 35 ++++ docs/current-state.md | 10 +- docs/env.md | 1 + docs/routes.md | 8 + progress.md | 8 + 20 files changed, 1030 insertions(+), 6 deletions(-) create mode 100644 apps/licensing/migrations/0003_alter_licenseevent_action_legacymigrationgrant_and_more.py create mode 100644 apps/portal/templates/portal/device_credentials.html create mode 100644 apps/portal/templates/portal/migration_confirm.html diff --git a/.env.example b/.env.example index 9d6a11e..0ca97f9 100644 --- a/.env.example +++ b/.env.example @@ -37,6 +37,7 @@ AI_KEY_ENCRYPTION_KEY=base64-fernet-key DEVICE_IDENTIFIER_PEPPER=change-me-device-identifier-pepper DEVICE_SESSION_TTL_SECONDS=3600 DEVICE_ACTIVITY_UPDATE_SECONDS=86400 +MIGRATION_REQUEST_TTL_SECONDS=900 AI_IMAGE_UPSTREAM_DEADLINE_SECONDS=180 PUBLIC_BASE_URL= MEDIA_PUBLIC_BASE_URL= diff --git a/apps/api/urls.py b/apps/api/urls.py index 9bd52b8..e3e8724 100644 --- a/apps/api/urls.py +++ b/apps/api/urls.py @@ -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/", + MigrationRequestDetailView.as_view(), + name="api-client-migration-request-detail", + ), path( "v1/client/releases/latest", ClientLatestReleaseView.as_view(), diff --git a/apps/api/views.py b/apps/api/views.py index 345b555..ab21d2c 100644 --- a/apps/api/views.py +++ b/apps/api/views.py @@ -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, diff --git a/apps/licensing/admin.py b/apps/licensing/admin.py index 1590d1a..a7193bf 100644 --- a/apps/licensing/admin.py +++ b/apps/licensing/admin.py @@ -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") diff --git a/apps/licensing/migrations/0003_alter_licenseevent_action_legacymigrationgrant_and_more.py b/apps/licensing/migrations/0003_alter_licenseevent_action_legacymigrationgrant_and_more.py new file mode 100644 index 0000000..760b4eb --- /dev/null +++ b/apps/licensing/migrations/0003_alter_licenseevent_action_legacymigrationgrant_and_more.py @@ -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'), + ), + ] diff --git a/apps/licensing/models.py b/apps/licensing/models.py index 4082874..21caeef 100644 --- a/apps/licensing/models.py +++ b/apps/licensing/models.py @@ -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 diff --git a/apps/licensing/services.py b/apps/licensing/services.py index d4f8de1..186fc64 100644 --- a/apps/licensing/services.py +++ b/apps/licensing/services.py @@ -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 diff --git a/apps/licensing/tests.py b/apps/licensing/tests.py index 3b50ff5..b314adf 100644 --- a/apps/licensing/tests.py +++ b/apps/licensing/tests.py @@ -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()) diff --git a/apps/portal/templates/portal/device_credentials.html b/apps/portal/templates/portal/device_credentials.html new file mode 100644 index 0000000..6a823ad --- /dev/null +++ b/apps/portal/templates/portal/device_credentials.html @@ -0,0 +1,17 @@ +{% extends "portal/base.html" %} + +{% block title %}设备授权 | 虾皮圈{% endblock %} + +{% block content %} +
+

设备授权

+
+ + {% for credential in credentials %} + + + + {% empty %}{% endfor %} +
产品设备平台客户端版本到期时间状态
{{ credential.get_product_code_display }}{{ credential.device.get_platform_display }}{{ credential.device.client_version }}{{ credential.expires_at }}{% if credential.revoked_at %}已吊销{% else %}有效{% endif %}{% if not credential.revoked_at %}
{% csrf_token %}
{% endif %}
暂无已确认设备。
+
+{% endblock %} diff --git a/apps/portal/templates/portal/migration_confirm.html b/apps/portal/templates/portal/migration_confirm.html new file mode 100644 index 0000000..fd722c0 --- /dev/null +++ b/apps/portal/templates/portal/migration_confirm.html @@ -0,0 +1,19 @@ +{% extends "portal/base.html" %} + +{% block title %}确认设备迁移 | 虾皮圈{% endblock %} + +{% block content %} +
+

确认设备迁移

+

确认后,当前设备会占用一个软件授权席位。

+
+
产品
{{ migration_request.device.get_product_code_display }}
+
客户端版本
{{ migration_request.device.client_version }}
+
确认截止
{{ migration_request.expires_at }}
+
状态
{{ migration_request.get_status_display }}
+
+ {% if migration_request.status == "pending" %} +
{% csrf_token %}
+ {% endif %} +
+{% endblock %} diff --git a/apps/portal/urls.py b/apps/portal/urls.py index ed35bf3..d9ab2fb 100644 --- a/apps/portal/urls.py +++ b/apps/portal/urls.py @@ -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//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/", + MigrationConfirmView.as_view(), + name="portal-migration-confirm", + ), + path("migration/devices", DeviceCredentialListView.as_view(), name="portal-device-credentials"), + path( + "migration/credentials//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"), ] diff --git a/apps/portal/views.py b/apps/portal/views.py index 0eed7f8..0fe822e 100644 --- a/apps/portal/views.py +++ b/apps/portal/views.py @@ -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" diff --git a/config/settings.py b/config/settings.py index 4f44d47..2a24c9e 100644 --- a/config/settings.py +++ b/config/settings.py @@ -66,6 +66,7 @@ AI_KEY_ENCRYPTION_KEY = os.environ.get("AI_KEY_ENCRYPTION_KEY", "") DEVICE_IDENTIFIER_PEPPER = os.environ.get("DEVICE_IDENTIFIER_PEPPER", SECRET_KEY) DEVICE_SESSION_TTL_SECONDS = env_int("DEVICE_SESSION_TTL_SECONDS", 3600) DEVICE_ACTIVITY_UPDATE_SECONDS = env_int("DEVICE_ACTIVITY_UPDATE_SECONDS", 86400) +MIGRATION_REQUEST_TTL_SECONDS = env_int("MIGRATION_REQUEST_TTL_SECONDS", 900) # SECURITY WARNING: don't run with debug turned on in production! DEBUG = env_bool("DJANGO_DEBUG", True) diff --git a/docs/04-architecture.md b/docs/04-architecture.md index 12c4d8d..758b880 100644 --- a/docs/04-architecture.md +++ b/docs/04-architecture.md @@ -44,6 +44,8 @@ T-624/T-625 已新增 `apps.licensing`:`POST /api/v1/client/devices/register` T-626 已在同一 app 建立授权基础:`SoftwarePlan` 可由运营维护产品、时长、价格、设备数、宽限期和状态;`SoftwareEntitlement` 在人工授予时复制套餐名称、价格、时长、设备数和宽限期快照,之后套餐改动不回写历史权益。服务层在事务内预创建固定数量 `LicenseSeat`,绑定时先锁权益和全部席位,避免并发超售;人工授予、续期、撤销、绑定和解绑都必须给出原因并写只读 `LicenseEvent`。续期从 `max(now, expires_at)` 起算,且全程不读取或修改点数钱包。django-admin 通过专用操作页处理授予 / 续期 / 撤销,权益、席位和事件不允许直接篡改。此阶段没有购买入口、支付订单、迁移凭证或生成授权拦截。 +T-627 已建立存量迁移闭环:运营在 admin 显式创建 `LegacyMigrationGrant`,它关联一项套餐快照权益及资格快照,新注册用户不会自动创建。客户端必须以旧 API Key 加服务端验证的当前设备会话申请 `MigrationRequest`;请求只短时有效,生成的设备凭证明文只在该 API 响应出现一次。portal 仅允许请求所属账号确认,确认事务内分配席位、按请求中已存 token hash 签发 `DeviceCredential`,重复确认只返回既有结果。用户自助解绑会吊销凭证、写授权事件并释放对应席位。原始凭证、机器标识、公钥和 API Key 都不写入迁移记录 / 事件;T-628 才会用凭证参与专属接口影子校验。 + T-302 已实现 `/api/v1/generate/title` 与 `/api/v1/generate/image`:API 层只做鉴权、参数校验和编排;别名解析、Provider 选择、计费计算、预扣、成功确认、失败退点分别调用 `apps.ai` / `apps.billing` 既有模块。T-613 已把生成链路抽为 `apps.api.generation` 的核心阶段:`prepare_generation()` 负责审核、图片输入、别名、Provider 与计费准备;`precharge_generation()` 只调用 billing 预扣;`execute_precharged_generation()` 复用已预扣 `CallRecord` 调上游并成功确认或失败退点,供旧同步接口和后续异步 worker 共用。T-620 将图生图输入扩展为有序集合:兼容旧单图字段,新 `images[0]` 为主商品图、`images[1:]` 为参考图;服务端在审核用户 prompt 后固定追加角色规则,再将完整顺序交给 Provider。图片结果 MVP 先用本地 `default_storage` 保存到 `MEDIA_ROOT/generated/images/...` 并返回 `image_url`;核心阶段通过 URL 构建器生成外部 URL,不依赖 DRF `Request`;`CallRecord` 只写 URL / 摘要,不保存 provider `raw` 或 base64。 T-619 已在同一生成核心增加 `vision` 分支和 `/api/v1/analyze/images`:API 接收有序的 `images[]`,每项二选一提供 `image_url` / `image_base64`;prompt 审核通过后才下载或解码图片,随后校验同时具备 `text + vision` 的模型与 Provider、按 `vision + alias` 默认价格预扣一次、同步调用 Chat Completions / Gemini 多模态接口并返回完整文字。输入图片只在请求内存中使用,不落库;`CallRecord` 只保存最多 500 字结果摘要。 @@ -123,6 +125,9 @@ T-619 多图理解继续复用上述 URL 下载器和逐跳 SSRF 校验,并额 | SoftwareEntitlement | 授权服务 | 用户、产品、来源套餐(可空)及购买/授予时套餐快照、状态、开始/到期/宽限截止/撤销时间;不从套餐反向同步 | | LicenseSeat | 授权服务 | 每项权益按序号预创建的固定席位,可空关联已绑定设备及绑定 / 解绑时间;`(entitlement, seat_number)` 唯一 | | LicenseEvent | 授权服务 | 授予、续期、撤销、绑定、解绑等不可变事件;保存权益、可选席位/设备、操作人、必填原因和非敏感元数据 | +| LegacyMigrationGrant | 运营迁移 | 显式授予的存量用户资格、资格快照、关联权益、操作人和原因;`(user, product_code)` 唯一,禁止注册自动创建 | +| MigrationRequest | 客户端迁移 | 公开 UUID、用户、当前设备、资格、待签发凭证 hash/前缀、短时状态和确认时间;不存凭证明文 | +| DeviceCredential | 产品凭证 | 用户、产品、设备、权益、席位、来源迁移请求、token hash/前缀、到期/吊销信息;明文只在请求 API 返回一次 | 关键事实: diff --git a/docs/06-tasks.md b/docs/06-tasks.md index 439523a..982b866 100644 --- a/docs/06-tasks.md +++ b/docs/06-tasks.md @@ -105,7 +105,7 @@ | T-624 | 蝦皮圈设备登记与会话观测 | T-301, T-401 | 已完成:新增 `apps.licensing`、`ClientDevice` / `DeviceSession` / `DeviceBindingAudit` 与 `licensing.0001_initial`。设备登记用 API Key 鉴权、心跳用短期 `X-Device-Session`;重复登记不重复建设备并轮换旧会话,设备/公钥/会话只存摘要或 hash,活跃时间默认每日节流。admin 只读展示设备、会话和审计摘要,不回显敏感值。现有生成、余额、模型目录、异步任务和通用 API Key 语义未改变。已通过本地迁移、21 条目标回归测试、`check`、迁移一致性和 `init.ps1`。 | DONE | | T-625 | 蝦皮圈设备使用关联与迁移观测 | T-624, T-613, T-614, T-615 | 已完成阶段 1 关联观测:有效 `X-Device-Session` 仅在标题、同步图片、异步图片提交、vision 的预扣 `CallRecord` 写入服务端解析的可空 `client_device`;无头旧调用保持原返回、扣点、提交和轮询。新增 `billing.0010_callrecord_client_device_and_more` 与 `(client_device, created_at)` 索引;admin 支持展示 / 按设备筛选。`generation_route_usage` 新增 `product_code`、内部 `client_device_id`、`device_session_present` 白名单字段,不记录设备摘要、公钥、令牌、Key、prompt 或图片。伪造/过期会话在预扣前返回 401,吊销/跨账号会话返回 403;仍不做订阅授权拦截。已通过 4 条新增入口/兼容/安全/遥测测试及 14 条 licensing/生成回归、迁移、`check` 和一致性验证。 | DONE | | T-626 | 软件套餐、权益与设备席位基础模型 | T-624, T-401 | 已完成阶段 2 基础模型:新增 `SoftwarePlan`、`SoftwareEntitlement`、`LicenseSeat`、`LicenseEvent` 和 `licensing.0002_softwareentitlement_licenseseat_licenseevent_and_more`。授予时复制套餐名称/价格/时长/设备数/宽限期快照并预创建固定席位;`(entitlement, seat_number)` 唯一。服务层以事务锁权益和席位执行授予、续期、撤销、绑定与解绑,所有人工操作原因必填并写事件;续期从 `max(now, expires_at)` 起算,不触碰钱包。admin 提供套餐维护及权益授予/续期/撤销专用入口,权益/席位/事件只读。未接购买、支付、迁移凭证或生成授权。已通过套餐快照、续期、撤销、审计、后台权限及双连接单席位并发测试、迁移和 `check`。 | DONE | -| T-627 | 存量用户迁移权益、网页确认与设备凭证 | T-624, T-626, T-501 | 实施“自动迁移权益 + 网页确认当前设备”而非旧 API Key 自动抢占设备。新增 `LegacyMigrationGrant` / 一次性 `MigrationRequest` 和产品专用 `DeviceCredential`(均只存 hash/摘要);提供后台按资格快照批量或受控创建迁移权益,默认设备数和迁移到期日由明确配置/管理动作决定,新注册用户不得自动领取。客户端以旧 API Key 仅可为当前设备创建短时迁移请求;网页登录的 portal 页面确认同账号、同设备和未占用席位后,在事务内绑定席位并签发仅本次显示的设备凭证。重复确认、客户端轮询重试、浏览器刷新和响应丢失均幂等;复制旧 API Key 不能绕过网页登录确认。支持受审计的自助/客服解绑,吊销旧凭证;不改变旧生成接口,迁移窗口内旧客户端仍可用。测试覆盖资格边界、跨账号拒绝、过期/重复请求、并发确认只占一席、凭证 hash 与吊销、旧接口兼容;同步 portal 路由、API 契约、迁移与隐私文档。 | TODO | +| T-627 | 存量用户迁移权益、网页确认与设备凭证 | T-624, T-626, T-501 | 已完成存量迁移闭环:新增 `LegacyMigrationGrant`、短时 `MigrationRequest` 和 `DeviceCredential`,均只保存 hash/摘要;admin 通过显式用户+套餐授予迁移资格和快照,新注册用户无自动路径。客户端用旧 API Key + 当前设备会话申请迁移并一次性取得凭证明文;同账号 portal 确认后事务内绑定席位、按 hash 签发凭证,重复确认/轮询不重复占位或签发。用户可自助解绑,凭证吊销并释放席位且写审计。新增迁移 API、portal 确认/设备页;旧生成 API 完全不变。已覆盖资格、缺会话、跨账号、过期、重复确认、凭证 hash、状态轮询与解绑;迁移 / `check` / 迁移一致性通过。 | DONE | | T-628 | 蝦皮圈专属授权入口与影子校验 | T-625, T-626, T-627, T-613, T-614 | 新增蝦皮圈**专属**产品调用入口或明确的产品认证分流,使用 `DeviceCredential + X-Device-Session` 解析产品、用户和设备;不得在通用 `/api/v1/generate/*` 上给全部 API Key 强加订阅校验。实现统一 `licensing` 授权服务/permission,区分“可新提交生成”和“可读取本人已接受任务”,返回稳定结构化错误码(如 `device_not_bound`、`device_mismatch`、`license_required`、`license_expired`),但本任务先以影子模式记录 `would_reject`,不实际阻断已迁移或旧客户端。蝦皮圈专属 title、vision、异步 image submit 接入该服务;已接受任务的查询/下载始终按任务归属可读。测试覆盖通用 API 完全不回归、专属凭证认证、跨用户拒绝、影子结果日志脱敏、过期权益的 would-reject、已接受任务读取和点数账本不被授权检查重复修改;同步 API、架构、部署开关和桌面端对接文档。 | TODO | | T-629 | 软件套餐购买、续订订单与权益入账 | T-626, T-627, T-628, T-304, T-305 | 新增与 `RechargeOrder` **分离**的 `SoftwareOrder`,实现蝦皮圈套餐选择、创建待支付订单、支付回调/主动查单后的幂等权益发放与续期;不得把软件订阅金额兑换为点数,也不得改动充值订单或既有点数回调契约。订单须锁定套餐名称、价格、时长、设备数等快照,回调必须验签、校验订单金额/通道、按订单号幂等;同一订单重复回调最多延长一次。portal 提供订阅状态、套餐购买/续订入口和订单只读记录,支付失败/取消保持 pending/failed 语义并保留审计。复用现有支付网关的微信路径但不假设自动续费协议;第一版“月订阅”是用户每月主动续订,自动代扣仅登记后续任务。测试覆盖订单快照、重复回调、金额不匹配、并发回调、续期起算、退款/撤销后的权益状态、充值链路回归;真实生产支付验收依赖既有微信回调闭环修复,未提供商户条件时仅按 mock/SDK 契约测试。 | TODO | diff --git a/docs/api.md b/docs/api.md index 9936264..a155858 100644 --- a/docs/api.md +++ b/docs/api.md @@ -19,6 +19,7 @@ - API Key 库内只存 `key_hash`(SHA-256)与 `key_prefix`,明文只在创建时返回一次,不在 admin、日志或调用记录中回显。 - 每次生成调用写 `CallRecord`;只允许保存 `result_ref` / `result_summary` 这类引用或摘要,不保存 provider `raw`、base64 图片或敏感上游字段。 - T-624/T-625 起,蝦皮圈客户端可先用 API Key 登记设备并取得短期 `X-Device-Session`;标题、图片、异步图片提交和图片理解可选携带该头,服务端只把已验证的设备关联到本次 `CallRecord` 与白名单遥测。没有该头的旧客户端继续按原 API Key 路径调用,响应、计费、任务提交和轮询均不变;设备会话尚**不**参与订阅授权拦截。设备标识、公钥和会话令牌不写入调用日志或响应中的设备对象。 +- T-627 起,存量迁移客户端可用旧 API Key 加当前 `X-Device-Session` 申请短时迁移请求;仍必须由同账号网页登录确认后才绑定席位并签发产品专用设备凭证。凭证明文只在申请响应出现一次,数据库仅存 hash;旧 API Key 本身不能绕过网页登录确认,也不改变既有生成接口。 T-301 已实现对外 API 鉴权基线:`apps.api.authentication.ApiKeyAuthentication` 只解析 `Authorization: Bearer `;生成、余额等外部 API 视图应继承 `apps.api.views.ExternalApiView`,不接受 Web session。 @@ -92,6 +93,12 @@ T-607/T-609/T-617 已实现 `GET /api/v1/client/releases/latest?platform=windows | `device_session_invalid` | 缺失、无效或过期设备会话 | 401 | | `device_revoked` | 客户端设备已被吊销 | 403 | | `device_mismatch` | 设备会话不属于当前 API Key 所属账号 | 403 | +| `device_session_required` | 存量迁移申请缺少当前设备会话 | 401 | +| `migration_not_eligible` | 当前账号没有有效存量迁移资格 | 403 | +| `migration_request_not_found` | 迁移请求不存在或不属于当前设备 | 404 | +| `migration_request_expired` | 迁移请求已过期或不可用 | 400 | +| `migration_request_forbidden` | 网页确认账号不属于迁移请求 | 403 | +| `device_credential_exists` | 当前设备已完成迁移绑定 | 400 | | `device_identity_mismatch` | 同一设备标识对应的安装公钥不一致 | 403 | `upstream_timeout` 与 `upstream_error` 都表示本次生成失败且已退点,客户端可按失败 / 重试处理。`upstream_timeout` 通常来自 T-612 的生图上游硬截止或底层 HTTP 超时。`upstream_error` 需要按错误消息继续区分:如果 `/api/v1/balance` 成功、`/api/v1/models` 中目标别名存在且 `pricing_status="priced"`,但生成接口返回 `502 upstream_error` 且消息为「上游模型配置不可用」,优先判定为**服务端上游模型运行配置问题**,不是客户端 payload 问题。常见原因是 `AI_KEY_ENCRYPTION_KEY` 与入库时不一致、`AiModel.api_key_encrypted` 无法解密、别名指向的 `AiModel` 缺 `url` / `model` / `api_type` / API Key,或 `api_type` 无可用 Provider。该错误路径不应最终扣点;修复按 [`deployment.md`](deployment.md) 的 AI 模型配置排查步骤执行。 @@ -195,6 +202,34 @@ X-Device-Session: dvs_cmhub_ 成功响应返回当前设备公开摘要、会话到期时间和 `activity_updated`。默认同一设备至少间隔 24 小时才写入一次 `last_seen_at`,因此频繁心跳可能返回 `activity_updated=false`,这是正常行为。缺失、无效或过期会话返回 `401 device_session_invalid`;已吊销设备返回 `403 device_revoked`。该接口不扣点、不创建调用记录,也不刷新过期会话;客户端应重新调用登记接口获取新令牌。 +### `POST /api/v1/client/migration-requests` + +存量蝦皮圈客户端发起设备迁移确认请求。需要既有 API Key 和当前设备会话;该接口不扣点、不调用上游、不改变旧生成接口。 + +```http +POST /api/v1/client/migration-requests +Authorization: Bearer sk_cmhub_xxx +X-Device-Session: dvs_cmhub_ +``` + +响应中的 `device_credential_token` 是设备凭证明文,只返回本次一次,客户端必须安全保存且不得写入日志;后台只存 hash。用户打开 `confirmation_url` 后登录同一账号并确认,才会实际占用席位、签发凭证。没有后台显式创建的 `LegacyMigrationGrant` 时返回 `403 migration_not_eligible`;没有会话返回 `401 device_session_required`。 + +```json +{ + "request_id": "0f70303b-6ec1-4ea1-8c0c-7c6d9dd7ae17", + "status": "pending", + "product_code": "cmshopee", + "expires_at": "2026-07-21T12:15:00+08:00", + "confirmed_at": null, + "confirmation_url": "https://cm.example.com/migration/confirm/0f70303b-6ec1-4ea1-8c0c-7c6d9dd7ae17", + "device_credential_token": "dvc_cmhub_" +} +``` + +### `GET /api/v1/client/migration-requests/{request_id}` + +客户端以同一 API Key 和当前设备会话轮询迁移状态。只返回请求状态、产品、时间和确认链接,**不会**再次返回设备凭证明文;跨用户或跨设备请求返回 `404 migration_request_not_found`。网页刷新、客户端轮询重试不会重复占用席位或重复签发凭证。 + ### 生成接口的可选设备会话 以下生成提交路由可在既有 `Authorization: Bearer ` 外,额外携带同账号登记得到的设备会话:`POST /api/v1/generate/title`、`POST /api/v1/analyze/images`、`POST /api/v1/generate/image`、`POST /api/v1/generate/image/tasks`。 diff --git a/docs/current-state.md b/docs/current-state.md index 643fd71..8acb2c0 100644 --- a/docs/current-state.md +++ b/docs/current-state.md @@ -18,7 +18,7 @@ - T-614/T-616 生产代码补充:已新增 `ImageGenerationTask` 与 `api.0001_initial`,新增 `POST /api/v1/generate/image/tasks`、`GET /api/v1/generate/image/tasks/{task_id}`、`apps.api.image_tasks` 任务服务、`run_image_tasks` management command 和只读 admin;异步提交支持 `Idempotency-Key` 去重 / 冲突检测,worker 使用 DB 任务表、租约、心跳与 reaper,成功返回 cmhub 托管 URL;T-616 已通过 `api.0002_imagegenerationtask_next_attempt_at_and_more` 增加 `next_attempt_at` 与 `(status, next_attempt_at)` 索引,临时性 `upstream_timeout` / `upstream_error` 默认最多重试 2 次,重试期间不退点,最终失败 / 僵任务才走计费层幂等退款。 - T-617 生产代码补充:`DownloadRelease` 已新增 `size_bytes` 可空正整数字段和迁移 `portal.0004_downloadrelease_size_bytes`,django-admin 可填写并在列表展示;`GET /api/v1/client/releases/latest` 的 `release` 对象新增 `size_bytes`,有值返回整数,未配置返回 `null`,未发布时仍只返回 `release:null`。 - T-618 生产代码补充:`DownloadReleaseAdmin` 已挂专用 `ModelForm`,后台新增 / 编辑客户端发布版本时 `sha256` 与 `size_bytes` 必填;数据库字段仍兼容历史空值,API 仍可读取历史 `size_bytes=null` 记录。 -- T-624/T-625/T-626 本地代码补充:`apps.licensing` 已包含设备、会话、设备审计、套餐、权益、席位和授权事件;`licensing.0002_softwareentitlement_licenseseat_licenseevent_and_more` 建立套餐快照和固定席位基础。`billing.0010_callrecord_client_device_and_more` 为调用记录增加可空设备关联和索引;四个生成提交入口可选解析同账号设备会话,关联调用记录和白名单遥测。套餐/权益服务不触碰钱包;无会话旧客户端及异步轮询保持不变,尚未部署生产。 +- T-624~T-627 本地代码补充:`apps.licensing` 已包含设备、会话、设备审计、套餐、权益、席位、授权事件、存量迁移资格、迁移请求和设备凭证;`licensing.0003_alter_licenseevent_action_legacymigrationgrant_and_more` 建立迁移数据与凭证 hash 存储。旧 API Key + 当前设备会话只能申请短时迁移,portal 同账号确认后才绑定席位;无会话旧客户端及现有生成/异步轮询保持不变,尚未部署生产。 - T-619 生产代码补充:`ModelAlias` / `CallRecord` 已新增 `vision` 操作并生成 `ai.0005` / `billing.0008` 迁移;`POST /api/v1/analyze/images` 已接入 API Key 鉴权、生成限流、prompt 前置审核、有序多图 URL/Base64 读取、SSRF 与大小保护、Chat/Gemini 多模态 Provider、固定单次预扣 / 成功确认 / 幂等退款及安全调用摘要。模型目录与 portal 可用模型页会展示能力匹配的 active `vision` 别名并标记 `requires_image=true`。2026-07-16 已在 `185.216.248.75` 部署 `707941a` 并应用迁移,复用 active `GPT-5.5 文本`(`text + vision`)创建默认 active `vision-standard`,默认价格为 1 点/次;Nginx 已把该同步接口分流到 `cmhub-generate` 长请求池。 - T-619 生产验收:真实提交两张图片成功返回中文文字,HTTP 200,`model_used=gpt-5.5`,`call_id=6096`;余额 234→233,调用记录为 `vision/success`,对应 consume 1 条、refund 0 条。验收临时 API Key 已吊销;`cmhub-web`、`cmhub-generate`、Nginx、MySQL 8.4 均为 active,66 个异步生图 worker 未因本次部署重启。 - 最新验证:T-619 已完成 `ai.0005_alter_modelalias_operation_type` 与 `billing.0008_alter_callrecord_operation_type_and_more` 迁移并应用到当前开发库;`manage.py check` 0 issues,`makemigrations --check --dry-run` 无变化;Provider 专项 10 tests OK,T-619 API / 别名 / 目录专项 13 tests OK,包含旧标题 / 生图的扩展回归 84 tests OK,users / billing / moderation 分组 46 tests OK。完整单命令回归在 604 秒达到执行器超时,之后 API 全量重试在测试库初始化阶段遇到远程 MySQL `43.128.3.240` 连接超时,均未产生断言失败;测试期仍有 allauth 在 MySQL 条件唯一约束上的既有 `models.W036` 警告。 @@ -75,11 +75,11 @@ 任务状态以 [`06-tasks.md`](06-tasks.md) 为准,历史执行记录见 [`../progress.md`](../progress.md)。 - 已完成:T-001 初始化 Django + DRF 项目骨架;T-002 建立 apps 目录、自定义 User 与配置;T-003 接通 django-admin 与最小测试;T-004 Phase 0 骨架审核修补;T-101 Provider 适配器层 + 移植 cmbot 调用;T-102 AiModel + ModelAlias 模型 + 别名解析;T-103 配置变更审计;T-104 跑通一次录制标题生成;T-105 Phase 1 AI 层审核修补;T-201 User / UserWallet / ApiKey / PointsLedger / CallRecord 模型;T-202 PricingRule / ExchangeRate 模型 + 计费计算;T-203 并发安全扣点 / 退点;T-204 Phase 2 计费核心审核加固;T-301 API Key 鉴权;T-302 生成标题 / 图片接口;T-303 余额查询接口;T-304 充值回调;T-305 扫码充值下单 + 轮询;T-306 Phase 3 对外 API 安全加固;T-501 注册 / 登录(allauth);T-502 API Key 自助管理页;T-503 个人中心 / 记录页;T-504 充值页(扫码 + 轮询到账);T-505 Phase 4 用户端审核优化;T-401 运营后台完善;T-402 完整验收 MVP;T-403 部署 / 运行文档;T-601 可用别名发现;T-602 django-admin 中文化(第 1-3 层);T-603 django-admin 中文化(第 4 层·字段级);T-604 中文敏感词本地过滤;T-605 免邮箱验证策略落地;T-606 公开首页 + 客户端下载入口;T-607 桌面端最新版本检查接口;T-608 新用户注册赠送试用点数(当前 10 点);T-609 桌面端版本检查接口增加强制更新标记;T-610 首页导入模板下载入口;T-611 用户端品牌名统一为虾皮圈;T-612 生图同步接口止血(上游硬截止 + 长请求池校准);T-613 抽生成核心 service(计费+审核+上游共享 core);T-614 生图异步任务化接口(提交+轮询,新增不动旧接口);T-615 旧同步生图接口用量遥测 + 弃用口径;T-616 生图失败自动重试 2 次;T-617 桌面端版本检查接口增加文件大小字段;T-618 客户端发布版本后台必填文件校验元数据。 -- 已完成补充:T-619 多张图片理解并返回文字;T-620 图生图支持单图 / 多图主图与参考图;T-622 图片生成任务后台图片缩略预览;T-623 图片生成任务单图 / 多图筛选;T-624 蝦皮圈设备登记与会话观测;T-625 蝦皮圈设备使用关联与迁移观测;T-626 软件套餐、权益与设备席位基础模型。 +- 已完成补充:T-619 多张图片理解并返回文字;T-620 图生图支持单图 / 多图主图与参考图;T-622 图片生成任务后台图片缩略预览;T-623 图片生成任务单图 / 多图筛选;T-624 蝦皮圈设备登记与会话观测;T-625 蝦皮圈设备使用关联与迁移观测;T-626 软件套餐、权益与设备席位基础模型;T-627 存量用户迁移权益、网页确认与设备凭证。 - 正在进行:无。 -- 待开始:T-627 存量迁移与设备凭证;其后依赖链为 T-628 蝦皮圈专属授权影子校验、T-629 套餐购买/续订订单与权益入账。T-621 注册赠点运营后台配置继续留在 Backlog。真实支付回调到账闭环、客户端发布、生产多图理解模型配置和线上旧同步接口用量观察仍可继续拆任务。 +- 待开始:T-628 蝦皮圈专属授权影子校验;其后为 T-629 套餐购买/续订订单与权益入账。T-621 注册赠点运营后台配置继续留在 Backlog。真实支付回调到账闭环、客户端发布、生产多图理解模型配置和线上旧同步接口用量观察仍可继续拆任务。 - 当前 blocker:支付商户真实密钥/证书与生产 SDK 依赖仍待提供;微信回调到账闭环仍需真实支付验收;真实 AI 标题生成已在线上跑通,图片生成慢 / 504 / 客户端超时风险已拆为 T-612~T-616 并完成工程侧处理。 -- 下一个可领取任务:T-627 存量用户迁移权益、网页确认与设备凭证。T-621 保留在 Backlog,具体范围见 [`06-tasks.md`](06-tasks.md)。 +- 下一个可领取任务:T-628 蝦皮圈专属授权入口与影子校验。T-621 保留在 Backlog,具体范围见 [`06-tasks.md`](06-tasks.md)。 ## 当前可运行内容 @@ -153,7 +153,7 @@ T-619 已落地同步多图理解:调用方提交有序 `images` 列表,服 1. 读仓库级 `AGENTS.md` / `CLAUDE.md`。 2. 读 `docs/00-ai-start-here.md`。 3. 读 `docs/05-coding-rules.md`(尤其第 8 节资金安全)。 -4. 在 `docs/06-tasks.md` 领取第一个 `TODO` 且依赖均 `DONE` 的任务;当前应先做 T-627。 +4. 在 `docs/06-tasks.md` 领取第一个 `TODO` 且依赖均 `DONE` 的任务;当前应先做 T-628。 5. T-624~T-629 按设备观测 → 套餐权益 → 存量迁移 → 专属授权影子校验 → 订阅订单顺序执行;T-629 前须保留并验证真实支付回调到账闭环。 ## 维护规则 diff --git a/docs/env.md b/docs/env.md index 54cbceb..1c753c1 100644 --- a/docs/env.md +++ b/docs/env.md @@ -54,6 +54,7 @@ | `DEVICE_IDENTIFIER_PEPPER` | 生产是 | `change-me-device-identifier-pepper` | T-624 设备安装标识的服务端 HMAC pepper;生产必须独立于 `DJANGO_SECRET_KEY` 配置,不写入客户端或日志 | | `DEVICE_SESSION_TTL_SECONDS` | 否 | `3600` | T-624 设备会话令牌有效秒数,默认 1 小时;过期后客户端重新登记刷新令牌 | | `DEVICE_ACTIVITY_UPDATE_SECONDS` | 否 | `86400` | T-624 同一设备更新最后活跃时间的最小间隔秒数,默认每日一次,避免每次生成写库 | +| `MIGRATION_REQUEST_TTL_SECONDS` | 否 | `900` | T-627 存量迁移网页确认请求有效秒数,默认 15 分钟;过期后客户端必须重新申请,凭证明文不在服务端恢复 | | `AI_IMAGE_UPSTREAM_DEADLINE_SECONDS` | 否 | `180` | T-612 生图上游读取硬截止秒数;只作用于 `generate_image` 的上游请求和上游返回图片 URL 下载,Provider 实际读取超时取 `min(AiModel.timeout_seconds 或分辨率默认值, 本值)`;生产按真实图片 smoke 耗时校准,外层 Gunicorn / Nginx / 客户端超时必须大于该值 | | `PUBLIC_BASE_URL` | 否 | `https://cm.833729.com` | 站点公开基础 URL;异步 worker 没有 request 时可用它生成绝对媒体 URL | | `MEDIA_PUBLIC_BASE_URL` | 否 | `https://cm.833729.com` | 媒体文件公开基础 URL;优先于 `PUBLIC_BASE_URL`,用于 T-614 异步生图 worker 返回 `result.image_url` | diff --git a/docs/routes.md b/docs/routes.md index 14c13d4..5e247ef 100644 --- a/docs/routes.md +++ b/docs/routes.md @@ -15,6 +15,9 @@ | `/records/usage` | GET | 点数使用(消费/调用)记录 | session | | `/apikeys` | GET/POST | API Key 管理:列表 / 生成 / 删除(删除即吊销,明文只显示一次) | session | | `/models` | GET | 可用模型:只读展示可调用能力别名、能力、是否需要原图和点数单价 | session | +| `/migration/confirm/{request_id}` | GET/POST | 同账号确认当前设备的存量迁移 | session | +| `/migration/devices` | GET | 查看本人已确认设备与凭证状态 | session | +| `/migration/credentials/{id}/revoke` | POST | 自助解绑本人设备并吊销凭证 | session + CSRF | T-501/T-608 已落地 `/signup`、`/login`、`/logout` 与 `/dashboard`:注册成功后经计费层一次性发放 10 点试用点数并写 `signup_bonus` 流水,注册限流由 allauth signup rate limit 执行。T-502 已落地 `/apikeys`:登录用户只能管理自己的 Key,生成后明文只显示一次,列表只显示 prefix,删除为吊销 `revoked`。T-503/T-505/T-608 已扩展 `/dashboard` 为个人中心汇总,并落地 `/records/recharge` 与 `/records/usage`:充值总额按已支付订单统计,注册赠点 / 入账 / 消费 / 退款点数按 `PointsLedger` 统计,记录页只查询当前登录用户数据并分页展示。T-504/T-505 已落地 `/recharge`:登录用户可选择金额和支付方式创建 pending 充值订单,页面用本地 static 自托管 qrcode.js 展示二维码票据并轮询 `/api/v1/recharge/status`,到账后刷新余额。 T-601 已落地 `/models`:登录用户可查看当前公开可调用别名、能力、是否需要原图和点数单价;页面不展示底层 SKU、模型 URL、provider key、`api_key_encrypted` 或 `extra_body`。 @@ -33,6 +36,8 @@ T-606 已落地 `/` 公开首页:匿名访问返回 200,不再重定向到 ` | `/api/v1/models` | GET | 查询可调用能力别名、能力和点数单价 | API Key | | `/api/v1/client/devices/register` | POST | 蝦皮圈设备登记,签发短期设备会话(仅观测) | API Key | | `/api/v1/client/devices/heartbeat` | POST | 上报已登记设备活跃状态(仅观测) | Device session | +| `/api/v1/client/migration-requests` | POST | 存量客户端申请网页确认与一次性设备凭证 | API Key + Device session | +| `/api/v1/client/migration-requests/{request_id}` | GET | 轮询当前设备的迁移确认状态 | API Key + Device session | | `/api/v1/client/releases/latest` | GET | 桌面端检查最新客户端版本(公开发布元数据) | 公开 | | `/api/v1/recharge/create` | POST | 用户端发起充值(weixin/alipay),下单取二维码 | Session(用户端) | | `/api/v1/recharge/status` | GET | 轮询订单状态(前端每秒) | Session(用户端) | @@ -47,6 +52,7 @@ T-619 已落地 `/api/v1/analyze/images`:使用独立 `vision` 操作和能力 T-620 已扩展两个图生图路由:旧 `image_url` / `image_base64` 单图字段保持兼容;新 `images` 为有序列表,每项必须且只能提供其中一种来源,且不能和旧字段混用。服务端固定把第 1 张作为主商品图、后续图作为参考图;异步任务在后台可查看有序输入文件,不保存 base64 原文。 T-624/T-625 已新增设备登记 / 心跳及调用关联:登记接口继续只认 API Key,返回仅本次展示的短期设备会话;心跳接口只认 `X-Device-Session`。四个生成提交路由可选带同一会话,服务端仅关联 `CallRecord` 与白名单遥测;无头旧客户端与异步任务轮询保持原鉴权、响应和计费语义,尚未进入订阅授权拦截。 +T-627 已新增迁移申请 / 轮询和网页登录确认:客户端必须同时提供旧 API Key 与当前设备会话,申请得到短时确认链接及只出现一次的凭证明文;同账号 session 在 `/migration/confirm/{request_id}` 确认后才绑定席位。用户可在 `/migration/devices` 查看并自助解绑本人凭证;未新增生成授权拦截。 ## 运营后台(django-admin,`/admin/`) @@ -73,6 +79,8 @@ T-624/T-625 已新增设备登记 / 心跳及调用关联:登记接口继续 | 软件套餐 | SoftwarePlan | 维护蝦皮圈套餐的时长、价格、设备数、宽限期与启停状态;修改不回写已有权益快照 | | 软件权益 | SoftwareEntitlement | 只读查看用户权益和套餐快照;通过专用后台页面人工授予、续期或撤销,三种操作均必须填写原因并写授权事件 | | 授权席位 / 授权事件 | LicenseSeat / LicenseEvent | 只读检索设备绑定、解绑时间和全部授权审计;不支持后台直接编辑席位或事件 | +| 存量迁移资格 | LegacyMigrationGrant | 通过专用后台入口按用户与套餐显式授予;资格快照、关联权益和原因只读,新注册用户不会自动创建 | +| 迁移请求 / 设备凭证 | MigrationRequest / DeviceCredential | 只读排查短时确认状态和凭证前缀 / 吊销时间;不展示令牌明文或 hash | ## 后台职责约定 diff --git a/progress.md b/progress.md index b475660..7924c73 100644 --- a/progress.md +++ b/progress.md @@ -2074,3 +2074,11 @@ - 边界:本任务不创建购买页、软件订单、支付回调、存量迁移请求/设备凭证,也不改变通用生成 API 或点数账本。 - 验证:本地已应用 licensing 迁移。`py -3.12 manage.py test apps.licensing --keepdb --noinput --verbosity 2` 通过 14 条测试,包含套餐快照、续期、撤销、审计、后台权限和两个独立数据库连接并发抢占单席位;`manage.py check` 与 `makemigrations --check --dry-run` 通过。测试仅保留既有 allauth MySQL 条件唯一约束 `models.W036` 警告。 - 下一步:领取 T-627,实施存量迁移权益、网页登录确认和产品专用设备凭证,继续保持通用生成 API 兼容。 + +## 2026-07-21 完成:T-627 存量用户迁移权益、网页确认与设备凭证 + +- 实现:新增 `LegacyMigrationGrant`、`MigrationRequest`、`DeviceCredential` 与 `licensing.0003_alter_licenseevent_action_legacymigrationgrant_and_more`。迁移资格只能由 admin 专用入口显式按用户和套餐授予,保存资格快照;不接注册流程,因此新注册用户不会自动获得迁移权益。 +- 流程:客户端以旧 API Key + 当前 `X-Device-Session` 调用 `POST /api/v1/client/migration-requests`,获得短时确认链接和一次性 `device_credential_token`。数据库只保存待签发凭证 hash/前缀。portal 同账号确认后在事务内分配席位、签发绑定到迁移请求的凭证;重复确认与轮询返回既有状态,不重复占席或签发。用户可在 `/migration/devices` 自助解绑,服务会吊销凭证、释放席位并写授权事件。 +- 安全与兼容:跨账号确认返回 403,缺设备会话返回 401,跨设备查询按 404 处理;确认 URL 不含凭证明文。旧生成、余额、模型目录、异步任务和点数账本未改,设备凭证尚未参与生成授权。 +- 验证:本地已应用 licensing 迁移。T-627 迁移专项 3 条测试通过,覆盖资格边界、缺会话、跨账号、过期/重复、确认/轮询、凭证 hash 和自助解绑;此前 T-626 14 条 licensing 基础回归通过。`manage.py check` 与 `makemigrations --check --dry-run` 通过;测试仅保留既有 allauth MySQL 条件唯一约束 `models.W036` 警告。 +- 下一步:领取 T-628,建立蝦皮圈专属调用入口与影子授权校验,不影响通用 `/api/v1/generate/*`。