diff --git a/.env.example b/.env.example index 30b0c31..9d6a11e 100644 --- a/.env.example +++ b/.env.example @@ -34,6 +34,9 @@ MYSQL_WRITE_TIMEOUT=120 # AI key encryption 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 AI_IMAGE_UPSTREAM_DEADLINE_SECONDS=180 PUBLIC_BASE_URL= MEDIA_PUBLIC_BASE_URL= diff --git a/apps/api/serializers.py b/apps/api/serializers.py index b72db95..c2d4a94 100644 --- a/apps/api/serializers.py +++ b/apps/api/serializers.py @@ -4,6 +4,7 @@ from django.conf import settings from rest_framework import serializers from apps.billing.models import RechargeOrder +from apps.licensing.models import ClientDevice class GenerateTitleRequestSerializer(serializers.Serializer): @@ -129,3 +130,28 @@ class RechargeStatusRequestSerializer(serializers.Serializer): allow_blank=False, max_length=64, ) + + +class DeviceRegistrationRequestSerializer(serializers.Serializer): + product_code = serializers.ChoiceField(choices=ClientDevice.ProductCode.values) + device_id = serializers.CharField( + trim_whitespace=True, + allow_blank=False, + max_length=256, + ) + device_id_version = serializers.CharField( + trim_whitespace=True, + allow_blank=False, + max_length=32, + ) + installation_public_key = serializers.CharField( + trim_whitespace=True, + allow_blank=False, + max_length=4096, + ) + platform = serializers.ChoiceField(choices=ClientDevice.Platform.values) + client_version = serializers.CharField( + trim_whitespace=True, + allow_blank=False, + max_length=64, + ) diff --git a/apps/api/urls.py b/apps/api/urls.py index b0fd954..9bd52b8 100644 --- a/apps/api/urls.py +++ b/apps/api/urls.py @@ -5,6 +5,8 @@ from .views import ( AnalyzeImagesView, BalanceView, ClientLatestReleaseView, + DeviceHeartbeatView, + DeviceRegistrationView, GenerateImageTaskDetailView, GenerateImageTaskSubmitView, GenerateImageView, @@ -18,6 +20,16 @@ from .views import ( urlpatterns = [ path("v1/balance", BalanceView.as_view(), name="api-balance"), path("v1/models", ModelsView.as_view(), name="api-models"), + path( + "v1/client/devices/register", + DeviceRegistrationView.as_view(), + name="api-client-device-register", + ), + path( + "v1/client/devices/heartbeat", + DeviceHeartbeatView.as_view(), + name="api-client-device-heartbeat", + ), path( "v1/client/releases/latest", ClientLatestReleaseView.as_view(), diff --git a/apps/api/views.py b/apps/api/views.py index d72c82f..5d3ba68 100644 --- a/apps/api/views.py +++ b/apps/api/views.py @@ -27,6 +27,7 @@ from apps.api.image_tasks import ( from apps.api.models import ImageGenerationTask from apps.api.serializers import ( AnalyzeImagesRequestSerializer, + DeviceRegistrationRequestSerializer, GenerateImageRequestSerializer, GenerateTitleRequestSerializer, RechargeCreateRequestSerializer, @@ -63,6 +64,12 @@ from apps.billing.services import ( query_and_apply_recharge_payment, ) from apps.portal.models import DownloadRelease +from apps.licensing.authentication import DeviceSessionAuthentication +from apps.licensing.services import ( + DeviceRegistrationError, + record_device_heartbeat, + register_device, +) logger = logging.getLogger(__name__) @@ -263,6 +270,82 @@ class ModelsView(ExternalApiView): ) +def _device_response(device) -> dict: + return { + "product_code": device.product_code, + "platform": device.platform, + "client_version": device.client_version, + "status": device.status, + "first_seen_at": device.first_seen_at.isoformat(), + "last_seen_at": device.last_seen_at.isoformat(), + } + + +class DeviceRegistrationView(ExternalApiView): + def post(self, request): + serializer = DeviceRegistrationRequestSerializer(data=request.data) + if not serializer.is_valid(): + return Response( + api_error("bad_request", "参数错误"), + status=status.HTTP_400_BAD_REQUEST, + ) + data = serializer.validated_data + try: + result = register_device( + user=request.user, + api_key=request.auth, + product_code=data["product_code"], + device_id_version=data["device_id_version"], + device_id=data["device_id"], + public_key=data["installation_public_key"], + platform=data["platform"], + client_version=data["client_version"], + ) + except DeviceRegistrationError as exc: + return Response( + api_error(exc.code, exc.message), + status=status.HTTP_403_FORBIDDEN, + ) + return Response( + { + "device": _device_response(result.device), + "device_session_token": result.session_token, + "expires_at": result.session.expires_at.isoformat(), + }, + status=status.HTTP_201_CREATED if result.created else status.HTTP_200_OK, + ) + + +class DeviceSessionApiView(APIView): + authentication_classes = (DeviceSessionAuthentication,) + permission_classes = (IsAuthenticated,) + + def permission_denied(self, request, message=None, code=None): + if request.authenticators and not request.successful_authenticator: + raise DeviceSessionAuthentication.authentication_failed(request) + super().permission_denied(request, message=message, code=code) + + +class DeviceHeartbeatView(DeviceSessionApiView): + def post(self, request): + try: + updated = record_device_heartbeat(request.auth) + except DeviceRegistrationError as exc: + return Response( + api_error(exc.code, exc.message), + status=status.HTTP_403_FORBIDDEN, + ) + request.auth.device.refresh_from_db() + return Response( + { + "device": _device_response(request.auth.device), + "activity_updated": updated, + "expires_at": request.auth.expires_at.isoformat(), + }, + status=status.HTTP_200_OK, + ) + + def _release_unpublished_response(platform: str) -> dict: return { "platform": platform, diff --git a/apps/licensing/__init__.py b/apps/licensing/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/apps/licensing/__init__.py @@ -0,0 +1 @@ + diff --git a/apps/licensing/admin.py b/apps/licensing/admin.py new file mode 100644 index 0000000..68be3b0 --- /dev/null +++ b/apps/licensing/admin.py @@ -0,0 +1,107 @@ +from django.contrib import admin + +from .models import ClientDevice, DeviceBindingAudit, DeviceSession + + +def _masked_fingerprint(value: str) -> str: + if not value: + return "" + return f"{value[:8]}...{value[-6:]}" + + +@admin.register(ClientDevice) +class ClientDeviceAdmin(admin.ModelAdmin): + list_display = ( + "user", + "product_code", + "platform", + "client_version", + "status", + "last_seen_at", + "first_seen_at", + ) + list_filter = ("product_code", "platform", "status", "last_seen_at") + search_fields = ("user__username", "user__email") + list_select_related = ("user",) + ordering = ("-last_seen_at", "-id") + readonly_fields = ( + "user", + "product_code", + "device_id_version", + "device_fingerprint_masked", + "public_key_fingerprint_masked", + "platform", + "client_version", + "status", + "first_seen_at", + "last_seen_at", + "created_at", + "updated_at", + ) + fields = readonly_fields + + def has_add_permission(self, request): + return False + + def has_delete_permission(self, request, obj=None): + return False + + @admin.display(description="设备摘要") + def device_fingerprint_masked(self, obj): + return _masked_fingerprint(obj.device_fingerprint) + + @admin.display(description="安装公钥摘要") + def public_key_fingerprint_masked(self, obj): + return _masked_fingerprint(obj.public_key_fingerprint) + + +@admin.register(DeviceSession) +class DeviceSessionAdmin(admin.ModelAdmin): + list_display = ("device", "expires_at", "last_used_at", "revoked_at", "created_at") + list_filter = ("revoked_at", "expires_at") + search_fields = ("device__user__username", "device__user__email") + list_select_related = ("device", "device__user") + readonly_fields = ( + "device", + "expires_at", + "last_used_at", + "revoked_at", + "created_at", + ) + fields = readonly_fields + + def has_add_permission(self, request): + return False + + def has_change_permission(self, request, obj=None): + return False + + def has_delete_permission(self, request, obj=None): + return False + + +@admin.register(DeviceBindingAudit) +class DeviceBindingAuditAdmin(admin.ModelAdmin): + list_display = ("user", "device", "api_key", "action", "reason", "created_at") + list_filter = ("action", "created_at") + search_fields = ("user__username", "user__email", "api_key__key_prefix") + list_select_related = ("user", "device", "api_key") + readonly_fields = ( + "user", + "device", + "api_key", + "action", + "reason", + "client_version", + "created_at", + ) + fields = readonly_fields + + def has_add_permission(self, request): + return False + + def has_change_permission(self, request, obj=None): + return False + + def has_delete_permission(self, request, obj=None): + return False diff --git a/apps/licensing/apps.py b/apps/licensing/apps.py new file mode 100644 index 0000000..1037ece --- /dev/null +++ b/apps/licensing/apps.py @@ -0,0 +1,7 @@ +from django.apps import AppConfig + + +class LicensingConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "apps.licensing" + verbose_name = "软件授权" diff --git a/apps/licensing/authentication.py b/apps/licensing/authentication.py new file mode 100644 index 0000000..2e194e2 --- /dev/null +++ b/apps/licensing/authentication.py @@ -0,0 +1,41 @@ +from __future__ import annotations + +from django.utils import timezone +from rest_framework.authentication import BaseAuthentication +from rest_framework.exceptions import AuthenticationFailed, PermissionDenied + +from apps.api.errors import api_error +from apps.api.throttles import throttle_api_auth_failure +from apps.licensing.models import ClientDevice, DeviceSession + + +class DeviceSessionAuthentication(BaseAuthentication): + header_name = "HTTP_X_DEVICE_SESSION" + + def authenticate(self, request): + raw_token = request.META.get(self.header_name, "").strip() + if not raw_token: + return None + + try: + session = DeviceSession.objects.select_related("device", "device__user").get( + token_hash=DeviceSession.hash_token(raw_token) + ) + except DeviceSession.DoesNotExist as exc: + raise self.authentication_failed(request) from exc + + if not session.matches_token(raw_token) or not session.is_active_at(timezone.now()): + raise self.authentication_failed(request) + if session.device.status != ClientDevice.Status.ACTIVE: + raise PermissionDenied(api_error("device_revoked", "设备已被吊销")) + if not session.device.user.is_business_active: + raise PermissionDenied(api_error("account_disabled", "账号或设备已禁用")) + return session.device.user, session + + @staticmethod + def authentication_failed(request): + throttle_api_auth_failure(request) + return AuthenticationFailed(api_error("device_session_invalid", "缺失或无效设备会话")) + + def authenticate_header(self, request) -> str: + return "DeviceSession" diff --git a/apps/licensing/migrations/0001_initial.py b/apps/licensing/migrations/0001_initial.py new file mode 100644 index 0000000..119bd07 --- /dev/null +++ b/apps/licensing/migrations/0001_initial.py @@ -0,0 +1,107 @@ +# Generated by Django 5.2.15 on 2026-07-21 01:16 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('users', '0005_alter_apikey_created_at_alter_apikey_key_hash_and_more'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='ClientDevice', + 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='产品代码')), + ('device_id_version', models.CharField(max_length=32, verbose_name='设备标识版本')), + ('device_fingerprint', models.CharField(editable=False, max_length=64, verbose_name='设备摘要')), + ('public_key_fingerprint', models.CharField(editable=False, max_length=64, verbose_name='安装公钥摘要')), + ('platform', models.CharField(choices=[('windows', 'Windows'), ('macos', 'macOS'), ('linux', 'Linux')], max_length=16, verbose_name='平台')), + ('client_version', models.CharField(max_length=64, verbose_name='客户端版本')), + ('status', models.CharField(choices=[('active', '有效'), ('revoked', '已吊销')], default='active', max_length=20, verbose_name='状态')), + ('first_seen_at', models.DateTimeField(auto_now_add=True, verbose_name='首次登记时间')), + ('last_seen_at', models.DateTimeField(auto_now_add=True, verbose_name='最后活跃时间')), + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='创建时间')), + ('updated_at', models.DateTimeField(auto_now=True, verbose_name='更新时间')), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='client_devices', to=settings.AUTH_USER_MODEL, verbose_name='用户')), + ], + options={ + 'verbose_name': '客户端设备', + 'verbose_name_plural': '客户端设备', + 'db_table': 'client_device', + 'ordering': ('-last_seen_at', '-id'), + }, + ), + migrations.CreateModel( + name='DeviceBindingAudit', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('action', models.CharField(choices=[('registered', '已登记'), ('session_issued', '会话已签发'), ('heartbeat', '心跳'), ('rejected', '请求被拒绝'), ('revoked', '设备已吊销')], max_length=32, verbose_name='动作')), + ('reason', models.CharField(blank=True, max_length=120, verbose_name='原因')), + ('client_version', models.CharField(blank=True, max_length=64, verbose_name='客户端版本')), + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='创建时间')), + ('api_key', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='device_binding_audits', to='users.apikey', verbose_name='来源 API 密钥')), + ('device', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='audits', to='licensing.clientdevice', verbose_name='客户端设备')), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='device_binding_audits', to=settings.AUTH_USER_MODEL, verbose_name='用户')), + ], + options={ + 'verbose_name': '设备绑定审计', + 'verbose_name_plural': '设备绑定审计', + 'db_table': 'device_binding_audit', + 'ordering': ('-created_at', '-id'), + }, + ), + migrations.CreateModel( + name='DeviceSession', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('token_hash', models.CharField(editable=False, max_length=64, unique=True, verbose_name='会话令牌哈希')), + ('expires_at', models.DateTimeField(verbose_name='过期时间')), + ('last_used_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='创建时间')), + ('device', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='sessions', to='licensing.clientdevice', verbose_name='客户端设备')), + ], + options={ + 'verbose_name': '设备会话', + 'verbose_name_plural': '设备会话', + 'db_table': 'device_session', + 'ordering': ('-created_at', '-id'), + }, + ), + migrations.AddIndex( + model_name='clientdevice', + index=models.Index(fields=['user', 'product_code', 'status'], name='client_devi_user_id_bdec20_idx'), + ), + migrations.AddIndex( + model_name='clientdevice', + index=models.Index(fields=['product_code', 'last_seen_at'], name='client_devi_product_a3ba66_idx'), + ), + migrations.AddConstraint( + model_name='clientdevice', + constraint=models.UniqueConstraint(fields=('user', 'product_code', 'device_fingerprint'), name='client_device_user_product_fingerprint_unique'), + ), + migrations.AddIndex( + model_name='devicebindingaudit', + index=models.Index(fields=['user', 'created_at'], name='device_bind_user_id_d2befc_idx'), + ), + migrations.AddIndex( + model_name='devicebindingaudit', + index=models.Index(fields=['device', 'created_at'], name='device_bind_device__f566a5_idx'), + ), + migrations.AddIndex( + model_name='devicesession', + index=models.Index(fields=['device', 'expires_at'], name='device_sess_device__ab6307_idx'), + ), + migrations.AddIndex( + model_name='devicesession', + index=models.Index(fields=['expires_at', 'revoked_at'], name='device_sess_expires_04b16a_idx'), + ), + ] diff --git a/apps/licensing/migrations/__init__.py b/apps/licensing/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/licensing/models.py b/apps/licensing/models.py new file mode 100644 index 0000000..fe886b6 --- /dev/null +++ b/apps/licensing/models.py @@ -0,0 +1,172 @@ +from __future__ import annotations + +import hashlib +import hmac +import secrets + +from django.conf import settings +from django.db import models +from django.utils import timezone + + +class ClientDevice(models.Model): + class ProductCode(models.TextChoices): + CMSHOPEE = "cmshopee", "虾皮圈优化助手" + + class Platform(models.TextChoices): + WINDOWS = "windows", "Windows" + MACOS = "macos", "macOS" + LINUX = "linux", "Linux" + + class Status(models.TextChoices): + ACTIVE = "active", "有效" + REVOKED = "revoked", "已吊销" + + user = models.ForeignKey( + settings.AUTH_USER_MODEL, + verbose_name="用户", + on_delete=models.PROTECT, + related_name="client_devices", + ) + product_code = models.CharField( + "产品代码", + max_length=32, + choices=ProductCode.choices, + ) + device_id_version = models.CharField("设备标识版本", max_length=32) + device_fingerprint = models.CharField("设备摘要", max_length=64, editable=False) + public_key_fingerprint = models.CharField("安装公钥摘要", max_length=64, editable=False) + platform = models.CharField("平台", max_length=16, choices=Platform.choices) + client_version = models.CharField("客户端版本", max_length=64) + status = models.CharField( + "状态", + max_length=20, + choices=Status.choices, + default=Status.ACTIVE, + ) + first_seen_at = models.DateTimeField("首次登记时间", auto_now_add=True) + last_seen_at = models.DateTimeField("最后活跃时间", auto_now_add=True) + created_at = models.DateTimeField("创建时间", auto_now_add=True) + updated_at = models.DateTimeField("更新时间", auto_now=True) + + class Meta: + db_table = "client_device" + verbose_name = "客户端设备" + verbose_name_plural = "客户端设备" + ordering = ("-last_seen_at", "-id") + constraints = [ + models.UniqueConstraint( + fields=("user", "product_code", "device_fingerprint"), + name="client_device_user_product_fingerprint_unique", + ), + ] + indexes = [ + models.Index(fields=("user", "product_code", "status")), + models.Index(fields=("product_code", "last_seen_at")), + ] + + def __str__(self) -> str: + return f"{self.user} {self.product_code} {self.platform}" + + @staticmethod + def fingerprint_device_id(device_id_version: str, device_id: str) -> str: + payload = f"{device_id_version}:{device_id}".encode("utf-8") + pepper = settings.DEVICE_IDENTIFIER_PEPPER.encode("utf-8") + return hmac.new(pepper, payload, hashlib.sha256).hexdigest() + + @staticmethod + def fingerprint_public_key(public_key: str) -> str: + return hashlib.sha256(public_key.encode("utf-8")).hexdigest() + + +class DeviceSession(models.Model): + TOKEN_PREFIX_LENGTH = 12 + + device = models.ForeignKey( + ClientDevice, + verbose_name="客户端设备", + on_delete=models.PROTECT, + related_name="sessions", + ) + token_hash = models.CharField("会话令牌哈希", max_length=64, unique=True, editable=False) + expires_at = models.DateTimeField("过期时间") + last_used_at = models.DateTimeField("最后使用时间", null=True, blank=True) + revoked_at = models.DateTimeField("吊销时间", null=True, blank=True) + created_at = models.DateTimeField("创建时间", auto_now_add=True) + + class Meta: + db_table = "device_session" + verbose_name = "设备会话" + verbose_name_plural = "设备会话" + ordering = ("-created_at", "-id") + indexes = [ + models.Index(fields=("device", "expires_at")), + models.Index(fields=("expires_at", "revoked_at")), + ] + + def __str__(self) -> str: + return f"{self.device} session" + + @classmethod + def generate_plaintext_token(cls) -> str: + return f"dvs_cmhub_{secrets.token_urlsafe(32)}" + + @staticmethod + def hash_token(raw_token: str) -> str: + return hashlib.sha256(raw_token.encode("utf-8")).hexdigest() + + def matches_token(self, raw_token: str) -> bool: + return hmac.compare_digest(self.token_hash, self.hash_token(raw_token)) + + 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 DeviceBindingAudit(models.Model): + class Action(models.TextChoices): + REGISTERED = "registered", "已登记" + SESSION_ISSUED = "session_issued", "会话已签发" + HEARTBEAT = "heartbeat", "心跳" + REJECTED = "rejected", "请求被拒绝" + REVOKED = "revoked", "设备已吊销" + + user = models.ForeignKey( + settings.AUTH_USER_MODEL, + verbose_name="用户", + on_delete=models.PROTECT, + related_name="device_binding_audits", + ) + device = models.ForeignKey( + ClientDevice, + verbose_name="客户端设备", + null=True, + blank=True, + on_delete=models.SET_NULL, + related_name="audits", + ) + api_key = models.ForeignKey( + "users.ApiKey", + verbose_name="来源 API 密钥", + null=True, + blank=True, + on_delete=models.SET_NULL, + related_name="device_binding_audits", + ) + action = models.CharField("动作", max_length=32, choices=Action.choices) + reason = models.CharField("原因", max_length=120, blank=True) + client_version = models.CharField("客户端版本", max_length=64, blank=True) + created_at = models.DateTimeField("创建时间", auto_now_add=True) + + class Meta: + db_table = "device_binding_audit" + verbose_name = "设备绑定审计" + verbose_name_plural = "设备绑定审计" + ordering = ("-created_at", "-id") + indexes = [ + models.Index(fields=("user", "created_at")), + models.Index(fields=("device", "created_at")), + ] + + def __str__(self) -> str: + return f"{self.user} {self.action}" diff --git a/apps/licensing/services.py b/apps/licensing/services.py new file mode 100644 index 0000000..5af3822 --- /dev/null +++ b/apps/licensing/services.py @@ -0,0 +1,152 @@ +from __future__ import annotations + +from dataclasses import dataclass +from datetime import timedelta + +from django.conf import settings +from django.db import IntegrityError, transaction +from django.utils import timezone + +from apps.licensing.models import ClientDevice, DeviceBindingAudit, DeviceSession + + +class DeviceRegistrationError(Exception): + def __init__(self, code: str, message: str): + self.code = code + self.message = message + super().__init__(message) + + +@dataclass(frozen=True) +class DeviceRegistrationResult: + device: ClientDevice + session: DeviceSession + session_token: str + created: bool + + +def _session_expiry(now): + return now + timedelta(seconds=max(60, settings.DEVICE_SESSION_TTL_SECONDS)) + + +def _find_or_create_device(*, user, product_code, device_id_version, device_id, public_key, platform, client_version, now): + device_fingerprint = ClientDevice.fingerprint_device_id(device_id_version, device_id) + public_key_fingerprint = ClientDevice.fingerprint_public_key(public_key) + lookup = { + "user": user, + "product_code": product_code, + "device_fingerprint": device_fingerprint, + } + + device = ClientDevice.objects.select_for_update().filter(**lookup).first() + created = False + if device is None: + try: + with transaction.atomic(): + device = ClientDevice.objects.create( + **lookup, + device_id_version=device_id_version, + public_key_fingerprint=public_key_fingerprint, + platform=platform, + client_version=client_version, + last_seen_at=now, + ) + created = True + except IntegrityError: + device = ClientDevice.objects.select_for_update().get(**lookup) + + if device.status != ClientDevice.Status.ACTIVE: + raise DeviceRegistrationError("device_revoked", "设备已被吊销") + if not device.public_key_fingerprint == public_key_fingerprint: + raise DeviceRegistrationError("device_identity_mismatch", "设备身份校验失败") + + updates = [] + if device.platform != platform: + device.platform = platform + updates.append("platform") + if device.client_version != client_version: + device.client_version = client_version + updates.append("client_version") + update_after = timedelta(seconds=max(60, settings.DEVICE_ACTIVITY_UPDATE_SECONDS)) + if now - device.last_seen_at >= update_after: + device.last_seen_at = now + updates.append("last_seen_at") + if updates: + device.save(update_fields=[*updates, "updated_at"]) + return device, created + + +@transaction.atomic +def register_device(*, user, api_key, product_code, device_id_version, device_id, public_key, platform, client_version): + now = timezone.now() + device, created = _find_or_create_device( + user=user, + product_code=product_code, + device_id_version=device_id_version, + device_id=device_id, + public_key=public_key, + platform=platform, + client_version=client_version, + now=now, + ) + + # Registration rotates the short-lived session while keeping one device row. + DeviceSession.objects.filter( + device=device, + revoked_at__isnull=True, + expires_at__gt=now, + ).update(revoked_at=now) + raw_token = DeviceSession.generate_plaintext_token() + session = DeviceSession.objects.create( + device=device, + token_hash=DeviceSession.hash_token(raw_token), + expires_at=_session_expiry(now), + ) + if created: + DeviceBindingAudit.objects.create( + user=user, + device=device, + api_key=api_key, + action=DeviceBindingAudit.Action.REGISTERED, + client_version=client_version, + ) + DeviceBindingAudit.objects.create( + user=user, + device=device, + api_key=api_key, + action=DeviceBindingAudit.Action.SESSION_ISSUED, + client_version=client_version, + ) + return DeviceRegistrationResult( + device=device, + session=session, + session_token=raw_token, + created=created, + ) + + +@transaction.atomic +def record_device_heartbeat(session: DeviceSession, *, now=None) -> bool: + now = now or timezone.now() + session = ( + DeviceSession.objects.select_for_update() + .select_related("device", "device__user") + .get(pk=session.pk) + ) + if not session.is_active_at(now) or session.device.status != ClientDevice.Status.ACTIVE: + raise DeviceRegistrationError("device_revoked", "设备会话不可用") + + update_after = timedelta(seconds=max(60, settings.DEVICE_ACTIVITY_UPDATE_SECONDS)) + last_seen_at = session.device.last_seen_at + if now - last_seen_at < update_after: + return False + + ClientDevice.objects.filter(pk=session.device_id).update(last_seen_at=now) + DeviceSession.objects.filter(pk=session.pk).update(last_used_at=now) + DeviceBindingAudit.objects.create( + user=session.device.user, + device=session.device, + action=DeviceBindingAudit.Action.HEARTBEAT, + client_version=session.device.client_version, + ) + return True diff --git a/apps/licensing/tests.py b/apps/licensing/tests.py new file mode 100644 index 0000000..f5c7c49 --- /dev/null +++ b/apps/licensing/tests.py @@ -0,0 +1,162 @@ +from datetime import timedelta + +from django.test import TestCase, override_settings +from django.urls import reverse +from django.utils import timezone +from rest_framework.test import APIClient + +from apps.licensing.models import ClientDevice, DeviceBindingAudit, DeviceSession +from apps.licensing.services import record_device_heartbeat +from apps.users.models import ApiKey, User, UserWallet + + +class DeviceRegistrationApiTests(TestCase): + def setUp(self): + self.user = User.objects.create_user( + username="device-user", + email="device@example.com", + password="test-password", + ) + UserWallet.objects.create(user=self.user, points_balance=20) + self.api_key, self.raw_api_key = ApiKey.create_for_user(self.user, name="desktop") + self.client = APIClient() + self.register_url = reverse("api-client-device-register") + self.heartbeat_url = reverse("api-client-device-heartbeat") + self.payload = { + "product_code": ClientDevice.ProductCode.CMSHOPEE, + "device_id": "v1:installed-device-abcdef", + "device_id_version": "v1", + "installation_public_key": "test-installation-public-key", + "platform": ClientDevice.Platform.WINDOWS, + "client_version": "0.1.0", + } + + def register_device(self): + self.client.credentials(HTTP_AUTHORIZATION=f"Bearer {self.raw_api_key}") + return self.client.post(self.register_url, self.payload, format="json") + + def test_register_requires_api_key(self): + response = self.client.post(self.register_url, self.payload, format="json") + + self.assertEqual(response.status_code, 401) + self.assertEqual(response.data["error"]["code"], "unauthorized") + self.assertFalse(ClientDevice.objects.exists()) + + def test_register_creates_hashed_device_and_one_time_session_token(self): + response = self.register_device() + + self.assertEqual(response.status_code, 201) + self.assertEqual(response.data["device"]["product_code"], "cmshopee") + self.assertIn("device_session_token", response.data) + self.assertNotIn("installation_public_key", response.data) + self.assertNotIn("device_id", response.data) + + device = ClientDevice.objects.get(user=self.user) + session = DeviceSession.objects.get(device=device) + raw_token = response.data["device_session_token"] + self.assertNotEqual(device.device_fingerprint, self.payload["device_id"]) + self.assertNotEqual(device.public_key_fingerprint, self.payload["installation_public_key"]) + self.assertNotEqual(session.token_hash, raw_token) + self.assertTrue(session.matches_token(raw_token)) + self.assertEqual( + DeviceBindingAudit.objects.filter( + device=device, + action=DeviceBindingAudit.Action.REGISTERED, + ).count(), + 1, + ) + + def test_repeat_registration_reuses_device_and_rotates_session(self): + first = self.register_device() + second = self.register_device() + + self.assertEqual(first.status_code, 201) + self.assertEqual(second.status_code, 200) + self.assertEqual(ClientDevice.objects.filter(user=self.user).count(), 1) + device = ClientDevice.objects.get(user=self.user) + self.assertEqual( + DeviceSession.objects.filter(device=device, revoked_at__isnull=True).count(), + 1, + ) + self.assertEqual(DeviceSession.objects.filter(device=device).count(), 2) + self.assertNotEqual( + first.data["device_session_token"], + second.data["device_session_token"], + ) + + @override_settings(DEVICE_SESSION_TTL_SECONDS=172800) + def test_heartbeat_uses_device_session_and_activity_is_throttled(self): + registration = self.register_device() + token = registration.data["device_session_token"] + self.client.credentials(HTTP_X_DEVICE_SESSION=token) + + response = self.client.post(self.heartbeat_url, {}, format="json") + + self.assertEqual(response.status_code, 200) + self.assertFalse(response.data["activity_updated"]) + device = ClientDevice.objects.get(user=self.user) + session = DeviceSession.objects.get(device=device, revoked_at__isnull=True) + updated = record_device_heartbeat( + session, + now=device.last_seen_at + timedelta(days=1, seconds=1), + ) + self.assertTrue(updated) + device.refresh_from_db() + self.assertEqual( + DeviceBindingAudit.objects.filter( + device=device, + action=DeviceBindingAudit.Action.HEARTBEAT, + ).count(), + 1, + ) + + def test_invalid_or_revoked_device_session_is_rejected(self): + self.client.credentials(HTTP_X_DEVICE_SESSION="dvs_cmhub_invalid") + invalid = self.client.post(self.heartbeat_url, {}, format="json") + self.assertEqual(invalid.status_code, 401) + self.assertEqual(invalid.data["error"]["code"], "device_session_invalid") + + registration = self.register_device() + device = ClientDevice.objects.get(user=self.user) + device.status = ClientDevice.Status.REVOKED + device.save(update_fields=("status",)) + self.client.credentials(HTTP_X_DEVICE_SESSION=registration.data["device_session_token"]) + revoked = self.client.post(self.heartbeat_url, {}, format="json") + self.assertEqual(revoked.status_code, 403) + self.assertEqual(revoked.data["error"]["code"], "device_revoked") + + def test_existing_balance_api_remains_compatible_without_device_header(self): + self.client.credentials(HTTP_AUTHORIZATION=f"Bearer {self.raw_api_key}") + + response = self.client.get(reverse("api-balance")) + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.data["points_balance"], 20) + + @override_settings(DEVICE_ACTIVITY_UPDATE_SECONDS=60) + def test_heartbeat_updates_when_device_is_stale(self): + registration = self.register_device() + device = ClientDevice.objects.get(user=self.user) + stale_time = timezone.now() - timedelta(minutes=2) + ClientDevice.objects.filter(pk=device.pk).update(last_seen_at=stale_time) + self.client.credentials(HTTP_X_DEVICE_SESSION=registration.data["device_session_token"]) + + response = self.client.post(self.heartbeat_url, {}, format="json") + + self.assertEqual(response.status_code, 200) + self.assertTrue(response.data["activity_updated"]) + device.refresh_from_db() + self.assertGreater(device.last_seen_at, stale_time) + + @override_settings(DEVICE_ACTIVITY_UPDATE_SECONDS=60) + def test_repeat_registration_updates_stale_device_activity(self): + self.register_device() + device = ClientDevice.objects.get(user=self.user) + stale_time = timezone.now() - timedelta(minutes=2) + ClientDevice.objects.filter(pk=device.pk).update(last_seen_at=stale_time) + + response = self.register_device() + + self.assertEqual(response.status_code, 200) + device.refresh_from_db() + self.assertGreater(device.last_seen_at, stale_time) diff --git a/config/settings.py b/config/settings.py index 8a1c721..4f44d47 100644 --- a/config/settings.py +++ b/config/settings.py @@ -63,6 +63,9 @@ load_dotenv(BASE_DIR / ".env") # SECURITY WARNING: keep the secret key used in production secret. SECRET_KEY = os.environ.get("DJANGO_SECRET_KEY", "django-insecure-dev-only-change-me") 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) # SECURITY WARNING: don't run with debug turned on in production! DEBUG = env_bool("DJANGO_DEBUG", True) @@ -158,6 +161,7 @@ INSTALLED_APPS = [ 'allauth.account', 'rest_framework', 'apps.users', + 'apps.licensing', 'apps.portal', 'apps.billing', 'apps.ai', diff --git a/docs/04-architecture.md b/docs/04-architecture.md index 1d988b5..3d3192e 100644 --- a/docs/04-architecture.md +++ b/docs/04-architecture.md @@ -20,6 +20,7 @@ - **用户端层(Django 模板 SSR)**:公开首页、注册/登录(Django auth / allauth)、个人中心(余额/充值总额/充值记录/点数记录)、API Key 自助管理、发起扫码充值、模型目录与客户端下载入口。入口 `apps/portal/`,公开首页匿名可访问,其他自助页面用 session 鉴权。T-501/T-608 已落地 `/signup`、`/login`、`/logout` 与 `/dashboard`;注册成功后经计费层一次性发放 10 点试用点数,并写 `signup_bonus` 点数流水。T-502 已落地 `/apikeys`,用户可自助生成和删除(吊销)自己的 API Key,明文只显示一次,列表只显示 prefix。T-503/T-608 已扩展 `/dashboard` 并新增 `/records/recharge`、`/records/usage`,只读展示当前用户余额、充值订单、注册赠点与消费/退款流水。T-504 已落地 `/recharge`,用户可创建 pending 充值订单、查看二维码票据,并轮询订单状态;到账仍以服务端回调或主动查单入账后的本地订单状态为准。T-505 已把 Bootstrap/qrcode.js 改成本地 static 自托管,并把充值/点数记录页从固定切片改为分页。T-606 已把 `/` 改为公开首页,并新增 `DownloadRelease` 下载版本配置用于展示 Windows 客户端版本、下载地址、SHA256 与发布说明;T-607/T-609/T-617 已新增公开 JSON 版本检查接口给桌面端自动更新使用,并返回强制更新标记和安装包文件大小字节数。T-610 已在公开首页下载区增加导入模板下载入口,由后台 `ImportTemplate` 配置当前模板。 - **用户与账号层**:注册用户 `User`、点数钱包 `UserWallet`、`ApiKey`(一用户多把、哈希存储)。入口 `apps/users/`。 +- **软件授权层**:蝦皮圈客户端设备、短期设备会话和设备审计。入口 `apps/licensing/`;T-624 仅建立设备观测,不改变通用 API Key 或点数授权,套餐/权益/设备席位由后续任务实现。 - **API 层(DRF)**:对外生成接口、异步图片任务接口、余额查询、支付回调接收、扫码下单、公开版本检查接口。入口 `apps/api/`。生成/余额/模型目录这类对外业务 API **只认 API Key,不接受 Web session**;充值下单/状态查询属于用户端流程,走 Web session + CSRF;支付回调走平台验签;T-607/T-609/T-617 的客户端下载版本检查接口为公开只读例外,不需要 API Key,不读取用户、不扣点,`release.force_update` 仅表示当前发布版本是否强制升级,`release.size_bytes` 仅表示下载文件大小字节数。 - **计费层**:点数计算、原子扣减(锁 `UserWallet` 行)、退点、充值入账、流水记账。入口 `apps/billing/`。 - **AI 调用层(Provider Adapter 架构)**:对外只暴露稳定能力,内部用「能力别名 → 具体供应商适配器」解耦。入口 `apps/ai/`,适配器在 `apps/ai/providers/`。 @@ -39,6 +40,8 @@ T-301 已实现 `ApiKeyAuthentication` 与 `ExternalApiView`:外部 API 使用 `Authorization: Bearer `,通过 SHA-256 hash 定位 `ApiKey -> User`,成功后 `request.user` 为所属用户、`request.auth` 为本次 API Key;缺失/无效 Key 返回 `401 unauthorized`,用户或 Key 禁用返回 `403 account_disabled`。生成/余额等外部 API 应继承 `ExternalApiView`,不要挂 `SessionAuthentication`。 +T-624 已新增 `apps.licensing`:`POST /api/v1/client/devices/register` 用既有 API Key 建立或复用设备记录,并签发数据库仅存 hash 的短期设备会话;`POST /api/v1/client/devices/heartbeat` 用 `X-Device-Session` 更新活跃观测。设备 ID 使用服务端 pepper 后的摘要,安装公钥只存摘要,默认按设备每日节流 `last_seen_at` 写入。现有生成、余额、模型目录与任务接口不读取设备会话,确保开放 API 与旧客户端保持兼容;T-625 才关联调用记录,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 字结果摘要。 @@ -110,6 +113,9 @@ T-619 多图理解继续复用上述 URL 下载器和逐跳 SSRF 校验,并额 | SensitiveWord | 运营配置 | 本地敏感词快筛词库;word、normalized_word、category、action、is_active;T-604 MVP 仅支持 action=block | | DownloadRelease | 运营配置 | 客户端下载版本;platform、version、file、external_url、size_bytes、sha256、is_current、force_update、release_notes、created_at、updated_at;每平台当前版本由应用层事务保存时互斥 | | ImportTemplate | 运营配置 | 导入商品数据模板;name、file、external_url、sha256、is_current、notes、created_at、updated_at;当前模板由应用层事务保存时互斥 | +| ClientDevice | 客户端登记 | 用户、产品、版本化设备摘要、安装公钥摘要、平台、客户端版本、状态、首次/最后活跃时间;不保存原始机器标识或私钥;`(user, product_code, device_fingerprint)` 唯一 | +| DeviceSession | 设备登记服务 | 设备、会话 token hash、过期/最后使用/吊销时间;明文只在登记响应出现一次 | +| DeviceBindingAudit | 设备登记服务 | 用户、设备、来源 API Key、登记/会话/心跳等动作、原因、客户端版本、时间;只读审计 | 关键事实: diff --git a/docs/06-tasks.md b/docs/06-tasks.md index 4ce32f4..5a73449 100644 --- a/docs/06-tasks.md +++ b/docs/06-tasks.md @@ -102,7 +102,7 @@ | T-620 | 图生图支持单图 / 多图主图与参考图 | T-613, T-614, T-616, T-619 | 已完成:同步 / 异步图生图支持有序 `images`,旧单图字段继续兼容且禁止混用;服务端固定注入首图主商品、后续参考图规则。新增 `IMAGE_MAX_INPUT_IMAGES` / `IMAGE_MAX_INPUT_IMAGE_BYTES` / `IMAGE_MAX_INPUT_TOTAL_BYTES`,图片 URL 同时受新单图限制与既有 `IMAGE_URL_MAX_BYTES` 的较小值保护。Provider 已验证 Chat 多段、JSON `image_urls` 与 `images_edits` 重复 multipart `image` 的顺序传递;异步任务新增 `ImageGenerationTaskInput` 有序输入表,旧 `input_image` 读取保持兼容。已通过 `check`、迁移一致性、13 条 Provider 测试、序列化器边界校验与 6 条目标 API 测试(同步多图 / URL+base64 混合 / 互斥输入 / 超限 / 异步恢复 / URL 上限);完整 `GenerateApiTests` 曾跑出 53 条且发现并修复 URL 上限回归,远程 MySQL 测试库后续整类重跑超过 5 分钟未完成。此前已使用真实中转站 `gpt-image-2` 对两张图片重复 multipart `image` 调用获得 HTTP 200 与图片结果,确认上游多文件传输契约。详见 `progress.md`。 | DONE | | T-622 | 图片生成任务后台图片缩略预览 | T-614, T-620 | 已完成:django-admin 的 `ImageGenerationTask` 详情页新增输入图 / 结果图缩略画廊;输入按 `ordinal` 标为主图和参考图,兼容历史 `input_image` 单图;成功任务以 `result_url` 展示生成结果。双击缩略图弹出后台 `` 大图预览,支持关闭按钮、遮罩和 `Escape`;图片加载失败显示降级提示。未改 `CallRecord`、对外 API、计费、worker、迁移、媒体访问策略或列表页查询。已通过 4 条目标 admin 测试、`check`、迁移一致性、静态资源发现和 `init.ps1`。 | DONE | | T-623 | 图片生成任务单图 / 多图筛选 | T-614, T-620 | 已完成:`ImageGenerationTaskAdmin` 的列表右侧新增“输入图片类型”筛选,提供“单图生图”和“多图生图”。数据库聚合计数识别 1 条子输入的单图、2 条及以上的多图;无子输入且历史 `input_image` 非空的任务兼容归单图;无输入图任务不归类。筛选可与既有状态 / 日期过滤叠加,结果不重复;未改模型、迁移、任务状态、计费、API、worker、媒体文件或详情页缩略图。已通过 6 条目标 admin 测试、`check`、迁移一致性和 `init.ps1`。 | DONE | -| T-624 | 蝦皮圈设备登记与会话观测 | T-301, T-401 | **阶段 1,只新增观测,不阻断现有调用。** 新增独立授权模块或等价边界内的 `ClientDevice`、`DeviceSession`、`DeviceBindingAudit`;设备保存版本化安装摘要、安装公钥指纹、平台、客户端版本、状态和活跃时间,**不得保存**原始 MachineGuid、MAC、硬盘序列号、本地私钥或会话明文。新增 API Key 鉴权的 `POST /api/v1/client/devices/register` 与设备会话鉴权的 `POST /api/v1/client/devices/heartbeat`;注册/心跳幂等,返回仅本次展示的短期 `device_session_token`,库内仅存 hash。客户端后续请求可选传 `X-Device-Session`,未传必须保持旧 API 行为;`last_seen` 至少按设备/日节流,禁止每次生成写库。admin 可按用户检索设备、会话/审计摘要和活跃时间,不展示敏感凭证。文档同步 `04-architecture.md`、`api.md`、`routes.md`、`env.md`、`current-state.md`。测试覆盖认证、注册/心跳幂等、令牌 hash、不泄露、节流、禁用设备、旧 API Key 不传设备头仍成功;`makemigrations`/`migrate`/`check`/目标测试/`init` 留证据。 | TODO | +| 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`,不信任请求体裸 device_id;无会话的调用标记为可观测的 legacy 路径但不得改变返回、扣点、任务或轮询契约。扩展既有 `generation_route_usage` 白名单日志,加入 `product_code`、`client_device_id`(内部 ID)和 `device_session_present`,不得记录设备摘要、公钥、会话、API Key 明文、prompt 或图片。admin 增加设备调用只读关联和按设备筛选;统计同账号多设备、旧客户端占比和异常会话失败,但第一版只写审计/日志。测试覆盖四类生成入口关联、无头兼容、无效会话拒绝、跨用户会话拒绝、日志脱敏与异步任务轮询不受影响;同步架构/API/运营说明并完成迁移验证。 | TODO | | T-626 | 软件套餐、权益与设备席位基础模型 | T-624, T-401 | **阶段 2 的数据与后台基础,不接购买页、不改生成授权。** 新增 `SoftwarePlan`、`SoftwareEntitlement`、`LicenseSeat`、`LicenseEvent` 模型及 django-admin;套餐保存 `product_code`、名称、时长、价格、设备数、状态,权益保存用户、产品、购买时套餐快照、状态、开始/到期/宽限时间;席位按权益和序号唯一,事件记录人工授予、续期、撤销、解绑等原因。建立单一 licensing service,提供手工授予、续期、撤销、分配/释放席位的事务方法;续期从 `max(now, expires_at)` 计算,禁止直接改钱包或 `points_balance`。后台人工操作必须填原因并生成审计事件,历史权益不可被套餐后来编辑反向篡改。测试覆盖套餐快照、同一权益并发占用席位不超限、续期计算、撤销、审计和 admin 权限;同步数据模型、运营流程与迁移说明。 | TODO | | T-627 | 存量用户迁移权益、网页确认与设备凭证 | T-624, T-626, T-501 | 实施“自动迁移权益 + 网页确认当前设备”而非旧 API Key 自动抢占设备。新增 `LegacyMigrationGrant` / 一次性 `MigrationRequest` 和产品专用 `DeviceCredential`(均只存 hash/摘要);提供后台按资格快照批量或受控创建迁移权益,默认设备数和迁移到期日由明确配置/管理动作决定,新注册用户不得自动领取。客户端以旧 API Key 仅可为当前设备创建短时迁移请求;网页登录的 portal 页面确认同账号、同设备和未占用席位后,在事务内绑定席位并签发仅本次显示的设备凭证。重复确认、客户端轮询重试、浏览器刷新和响应丢失均幂等;复制旧 API Key 不能绕过网页登录确认。支持受审计的自助/客服解绑,吊销旧凭证;不改变旧生成接口,迁移窗口内旧客户端仍可用。测试覆盖资格边界、跨账号拒绝、过期/重复请求、并发确认只占一席、凭证 hash 与吊销、旧接口兼容;同步 portal 路由、API 契约、迁移与隐私文档。 | TODO | diff --git a/docs/api.md b/docs/api.md index ea802ca..e23894a 100644 --- a/docs/api.md +++ b/docs/api.md @@ -18,6 +18,7 @@ - T-608 起新用户注册成功一次性赠送 **10 点**试用点数;赠点必须经计费层写入钱包和 `PointsLedger(change_type=signup_bonus)`,不得直接改余额字段。历史用户是否补发不属于默认注册流程。 - API Key 库内只存 `key_hash`(SHA-256)与 `key_prefix`,明文只在创建时返回一次,不在 admin、日志或调用记录中回显。 - 每次生成调用写 `CallRecord`;只允许保存 `result_ref` / `result_summary` 这类引用或摘要,不保存 provider `raw`、base64 图片或敏感上游字段。 +- T-624 起,蝦皮圈客户端可先用 API Key 登记设备并取得短期 `X-Device-Session`;设备会话仅用于设备心跳,尚**不**参与现有生成/余额接口鉴权、计费或授权拦截。设备标识、公钥和会话令牌不写入调用日志或响应中的设备对象。 T-301 已实现对外 API 鉴权基线:`apps.api.authentication.ApiKeyAuthentication` 只解析 `Authorization: Bearer `;生成、余额等外部 API 视图应继承 `apps.api.views.ExternalApiView`,不接受 Web session。 @@ -88,6 +89,9 @@ T-607/T-609/T-617 已实现 `GET /api/v1/client/releases/latest?platform=windows | `payment_order_create_failed` | 支付平台下单失败 | 502 | | `order_not_found` | 充值订单不存在或不属于当前用户 | 404 | | `rate_limited` | 请求过于频繁,请稍后再试 | 429 | +| `device_session_invalid` | 缺失、无效或过期设备会话 | 401 | +| `device_revoked` | 客户端设备已被吊销 | 403 | +| `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 模型配置排查步骤执行。 @@ -144,6 +148,52 @@ GET /api/v1/client/releases/latest?platform=windows 要点:接口只查 `DownloadRelease(platform, is_current=True)`;`download_url` 优先使用 `external_url`,否则用 `file.url` 生成绝对 HTTPS URL;`published_at` MVP 可使用 `DownloadRelease.updated_at`;`force_update` 使用后台发布版本上的布尔配置,默认 `false`;`size_bytes` 使用后台填写的安装包字节数,可为空以兼容历史记录。响应不得包含本地 `MEDIA_ROOT`、文件系统路径、后台 ID、`is_current`、用户信息、API Key、模型配置或任何密钥字段。成功和“暂未发布”均返回 HTTP 200,方便桌面端静默检查;非法平台返回 `400 bad_request`。无当前版本或当前版本没有下载地址时返回 `release:null`,不返回独立的 `force_update` / `size_bytes` 顶层字段。 +### `POST /api/v1/client/devices/register` + +蝦皮圈客户端登记当前安装实例并获取短期设备会话。该接口使用既有 API Key 鉴权,不接受 Web session;第一阶段只登记和观测,不影响已有生成接口。 + +请求: + +```json +{ + "product_code": "cmshopee", + "device_id": "v1:client-generated-installation-id", + "device_id_version": "v1", + "installation_public_key": "client-installation-public-key", + "platform": "windows", + "client_version": "0.1.0" +} +``` + +成功响应: + +```json +{ + "device": { + "product_code": "cmshopee", + "platform": "windows", + "client_version": "0.1.0", + "status": "active", + "first_seen_at": "2026-07-21T10:00:00+08:00", + "last_seen_at": "2026-07-21T10:00:00+08:00" + }, + "device_session_token": "dvs_cmhub_", + "expires_at": "2026-07-21T11:00:00+08:00" +} +``` + +首次登记返回 `201`;同一用户、产品和设备重复登记保持同一设备记录、轮换会话令牌并返回 `200`。服务端对 `device_id_version + device_id` 加私有 pepper 后只保存 HMAC 摘要,对安装公钥只保存 SHA-256 摘要;不得上传 MachineGuid、MAC、硬盘序列号或私钥。`device_session_token` 只在本次响应返回,数据库只存 hash,客户端应使用 Windows DPAPI 等本地安全存储保护它。 + +### `POST /api/v1/client/devices/heartbeat` + +刷新已登记设备的活跃观测。请求头: + +```http +X-Device-Session: dvs_cmhub_ +``` + +成功响应返回当前设备公开摘要、会话到期时间和 `activity_updated`。默认同一设备至少间隔 24 小时才写入一次 `last_seen_at`,因此频繁心跳可能返回 `activity_updated=false`,这是正常行为。缺失、无效或过期会话返回 `401 device_session_invalid`;已吊销设备返回 `403 device_revoked`。该接口不扣点、不创建调用记录,也不刷新过期会话;客户端应重新调用登记接口获取新令牌。 + ### `POST /api/v1/generate/title` 生成标题。请求: diff --git a/docs/current-state.md b/docs/current-state.md index c94cc9a..1820ede 100644 --- a/docs/current-state.md +++ b/docs/current-state.md @@ -11,13 +11,14 @@ ## 当前快照 -- 日期:2026-07-20 +- 日期:2026-07-21 - 阶段:Phase 6 增强(MVP 后);Phase 3 对外 API 与充值已完成到 T-306,Phase 4 用户端 T-501 注册 / 登录(allauth)、T-502 API Key 自助管理页、T-503 个人中心 / 记录页、T-504 充值页与 T-505 用户端审核优化已完成,Phase 5 T-401 运营后台完善、T-402 MVP 完整验收与 T-403 部署 / 运行文档已完成,Phase 6 T-601 可用别名发现、T-602 django-admin 中文化第 1-3 层、T-603 django-admin 字段级中文化、T-604 中文敏感词本地过滤、T-605 免邮箱验证策略落地、T-606 公开首页 + 客户端下载入口、T-607 桌面端最新版本检查接口、T-608 新用户注册赠送试用点数(当前 10 点)、T-609 桌面端版本检查接口增加强制更新标记、T-610 首页导入模板下载入口、T-611 用户端品牌名统一为虾皮圈、T-612 生图同步接口止血、T-613 抽生成核心 service、T-614 生图异步任务化接口、T-615 旧同步生图接口遥测 / 弃用口径、T-616 生图失败自动重试 2 次、T-617 桌面端版本文件大小字段与 T-618 客户端发布版本后台必填校验元数据已完成;后续仍需处理真实支付回调到账闭环、客户端下载包发布和生产侧旧同步接口用量观察 - 技术栈:系统 Python 3.12.3 + Django 5.2.15 + DRF 3.16.1 + django-allauth 65.18.0 + PyMySQL 1.1.3 + cryptography 49.0.0 + requests 2.34.2 + ahocorapy 1.6.2 + wechatpayv3 2.0.2 + python-alipay-sdk 3.4.0 + django-admin;MySQL 8.4 已接入 settings,并支持 `MYSQL_CONNECT_TIMEOUT` / `MYSQL_READ_TIMEOUT` / `MYSQL_WRITE_TIMEOUT`;用户端已用 Django 模板 SSR + Bootstrap + allauth 落地注册登录;生产部署口径为 VPS / 宝塔 + Nginx + Gunicorn(gthread) + systemd;详见 `03-tech-stack.md` 与 `deployment.md` - 生产代码:已有最小 Django 工程骨架:`manage.py`、`config/`;T-002 已创建 `apps/users|portal|billing|ai|api`;T-003 已把自定义 `User` 注册进 django-admin;T-004 已完成 email 唯一性、init 版本断言、app 顺序、`.env.example` 与 `pyproject.toml`;T-101 已新增 `apps/ai/providers/`(Provider 接口、注册表、chat/gemini/images/images_edits 适配器);T-102 已新增 `AiModel` / `ModelAlias`、Fernet 加密密钥存储、别名解析、admin 配置页、`import_ai_models` 导入命令;T-103 已新增 `AiConfigAuditLog` 审计表、admin 只读页面和后台保存/删除审计 hook;T-104/T-105 已完成录制 title/image smoke 与审核修补;T-201 已新增 `UserWallet` / `ApiKey`、`PointsLedger` / `CallRecord`、对应 admin 与迁移;T-202 已新增 `PricingRule` / `ExchangeRate`、`apps.billing.pricing` 计费计算函数、admin 配置页与迁移;T-203 已新增 `apps.billing.services`,实现并发安全预扣、成功确认与幂等失败退点;T-204 已新增 `billing.0003_pointsledger_unique_ledger_change_type_per_call`,用 MySQL 可落地的 `ref_call + change_type` 复合唯一约束兜底防重复 refund;T-301 已新增 `apps.api.authentication.ApiKeyAuthentication` 与 `ExternalApiView`;T-302 已新增生成接口编排、序列化器、图片本地存储和 `/api/v1/generate/title|image` 路由;T-303 已新增 `apps.billing.services.get_balance_snapshot()` 与 `/api/v1/balance` 余额查询接口;T-304 已新增 `RechargeOrder`、充值回调验签适配器、幂等入账服务、微信/支付宝回调路由与迁移 `billing.0004_rechargeorder_and_more`;T-305 已新增 `create_recharge_order()`、微信/支付宝扫码下单 mock/SDK 入口、`/api/v1/recharge/create` 与 `/api/v1/recharge/status`;T-306 已新增 `apps.api.throttles`、`apps.api.exceptions`、`REST_FRAMEWORK` 安全默认认证、生成/认证失败限流、`image_url` SSRF 防护与响应大小上限、充值单笔金额上限;T-501/T-608 已接入 allauth 注册登录路径,注册成功后 adapter 调用 `grant_signup_bonus()` 经 billing 一次性发放 10 点并写 `signup_bonus` 流水,新增 `SignupBonusGrant(user UNIQUE)` 幂等标记、admin 只读检索和 `billing.0007` 迁移;T-502 已新增 `/apikeys`、API Key 创建表单、列表页和删除(吊销)动作,生成后明文只显示一次,列表只显示 prefix;T-503/T-608 已扩展 `/dashboard` 为个人中心汇总,并新增 `/records/recharge` 充值记录与 `/records/usage` 点数记录,只读展示当前用户数据和注册赠点 / 消费 / 退款流水;T-504 已新增 `/recharge` 页面、`RechargeCreateForm`、充值导航入口和轮询脚本,页面创建 pending 订单、展示二维码票据、轮询 `/api/v1/recharge/status`,订单 paid 后刷新余额;T-505 已把 Bootstrap 5 CSS 与 qrcode.js vendoring 到 `apps/portal/static/portal/vendor/`,页面不再依赖 jsdelivr,并把充值记录 / 点数记录改为 Django `Paginator` 分页;T-401 已新增 `adjust_wallet_points()` 手工调点服务、钱包 admin 专用调点表单与模板,后台可管理/检索用户、钱包、API Key(脱敏)、计费规则、汇率、充值订单、点数流水、注册赠点记录和调用记录,流水/订单/调用记录保持只读;T-402 已新增 `docs/mvp-acceptance.md`,按 P0 验收矩阵记录 MVP 完整验收结论、测试证据和已知限制;T-403 已新增 `docs/deployment.md` 与 `requirements-production.txt`,并在 settings 中补齐 `STATIC_ROOT`、`CSRF_TRUSTED_ORIGINS`、共享 `CACHES`、HTTPS cookie、proxy SSL、HSTS 环境变量与 `ACCOUNT_SIGNUP_RATE_LIMIT` 注册限流配置;T-601 已新增 `apps.ai.catalog.get_public_model_catalog()`、`GET /api/v1/models` 与 portal `/models` 只读页面,只展示 active 可调用别名、能力、是否需要原图和点数单价,不解密 provider key,不暴露底层 SKU / URL / key / `extra_body`;T-604 已新增 `apps.moderation`、`SensitiveWord` 模型/admin/迁移、keyword provider、归一化管线和共享 cache 版本失效,生成接口已改为 prompt 先审再读取图片/计费/扣点/调上游;T-606 已新增公开首页 `/`、`DownloadRelease` 模型/admin/迁移、首页 SSR 模板、共享 `portal/brand.css`,并把现有 portal 页面套入同一套品牌 token;T-607/T-609 已新增 `ClientLatestReleaseView` 与 `/api/v1/client/releases/latest`,公开匿名返回当前客户端版本 JSON,`release.force_update` 表示该版本是否强制升级;`portal.0002_downloadrelease_force_update` 已给 `DownloadRelease` 增加 `force_update` 字段,admin 可编辑和筛选;T-610 已新增 `ImportTemplate` 模型/admin/迁移 `portal.0003_importtemplate`,首页读取当前模板并在“下载客户端”旁展示“下载导入模板”,本地文件 URL 转为当前站点绝对 URL,`external_url` 优先;T-611 已把用户端 portal 可见品牌名统一为“虾皮圈”,包括页面标题、顶部导航、首页 H1、用户端“虾皮圈 API Key”文案和 allauth 邮件模板;T-612 已新增 `AI_IMAGE_UPSTREAM_DEADLINE_SECONDS`,生图 Provider 上游请求和上游返回图片 URL 下载会按 `min(AiModel.timeout_seconds 或分辨率默认值, 硬截止)` 控制读取超时,超时返回 `upstream_timeout` 并走既有失败退点路径;T-613 已把旧同步生成链路抽成 `GenerationInput`、`prepare_generation()`、`precharge_generation()`、`execute_precharged_generation()` 与 `GenerationResult`,旧 view 只负责 serializer 和异常转 HTTP,后续异步 worker 可复用已预扣执行 / 确认 / 退点阶段。 - 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 生产代码补充:已新增 `apps.licensing` 和迁移 `licensing.0001_initial`,包含 `ClientDevice`、`DeviceSession`、`DeviceBindingAudit`;新增 API Key 鉴权的设备登记与设备会话鉴权的心跳接口。设备/公钥/会话仅存摘要或 hash,设备活跃默认按日节流,现有生成/余额/模型/任务接口未改变;尚未部署生产。 - 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` 警告。 @@ -74,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-619 多张图片理解并返回文字;T-620 图生图支持单图 / 多图主图与参考图;T-622 图片生成任务后台图片缩略预览;T-623 图片生成任务单图 / 多图筛选;T-624 蝦皮圈设备登记与会话观测。 - 正在进行:无。 -- 待开始:T-624 蝦皮圈设备登记与会话观测;其后依赖链为 T-625 设备使用关联与观测、T-626 软件套餐/权益/席位基础、T-627 存量迁移与设备凭证、T-628 蝦皮圈专属授权影子校验、T-629 套餐购买/续订订单与权益入账。T-621 注册赠点运营后台配置继续留在 Backlog。真实支付回调到账闭环、客户端发布、生产多图理解模型配置和线上旧同步接口用量观察仍可继续拆任务。 +- 待开始:T-625 蝦皮圈设备使用关联与迁移观测;其后依赖链为 T-626 软件套餐/权益/席位基础、T-627 存量迁移与设备凭证、T-628 蝦皮圈专属授权影子校验、T-629 套餐购买/续订订单与权益入账。T-621 注册赠点运营后台配置继续留在 Backlog。真实支付回调到账闭环、客户端发布、生产多图理解模型配置和线上旧同步接口用量观察仍可继续拆任务。 - 当前 blocker:支付商户真实密钥/证书与生产 SDK 依赖仍待提供;微信回调到账闭环仍需真实支付验收;真实 AI 标题生成已在线上跑通,图片生成慢 / 504 / 客户端超时风险已拆为 T-612~T-616 并完成工程侧处理。 -- 下一个可领取任务:暂无;需先将新的工作项登记为任务后再领取。T-621 保留在 Backlog,具体范围见 [`06-tasks.md`](06-tasks.md)。 +- 下一个可领取任务:T-625 蝦皮圈设备使用关联与迁移观测。T-621 保留在 Backlog,具体范围见 [`06-tasks.md`](06-tasks.md)。 ## 当前可运行内容 @@ -115,6 +116,8 @@ python3.12 manage.py run_image_tasks --worker-id cmhub-image-worker-1 --sleep-se - `POST /api/v1/generate/title` - `POST /api/v1/analyze/images` +- `POST /api/v1/client/devices/register` +- `POST /api/v1/client/devices/heartbeat` - `POST /api/v1/generate/image` - `POST /api/v1/generate/image/tasks` - `GET /api/v1/generate/image/tasks/{task_id}` diff --git a/docs/env.md b/docs/env.md index fcee23a..54cbceb 100644 --- a/docs/env.md +++ b/docs/env.md @@ -51,6 +51,9 @@ | 变量 | 必填 | 示例 | 说明 | | --- | --- | --- | --- | | `AI_KEY_ENCRYPTION_KEY` | 是 | `base64-fernet-key` | Fernet 主密钥,用于加密 `AiModel.api_key_encrypted`;生产不可更换,除非完成密钥轮换 | +| `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 同一设备更新最后活跃时间的最小间隔秒数,默认每日一次,避免每次生成写库 | | `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 d30e5bf..ecee092 100644 --- a/docs/routes.md +++ b/docs/routes.md @@ -31,6 +31,8 @@ T-606 已落地 `/` 公开首页:匿名访问返回 200,不再重定向到 ` | `/api/v1/generate/image/tasks/{task_id}` | GET | 轮询异步图片生成任务状态和结果 | API Key | | `/api/v1/balance` | GET | 查询点数余额 | API Key | | `/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/releases/latest` | GET | 桌面端检查最新客户端版本(公开发布元数据) | 公开 | | `/api/v1/recharge/create` | POST | 用户端发起充值(weixin/alipay),下单取二维码 | Session(用户端) | | `/api/v1/recharge/status` | GET | 轮询订单状态(前端每秒) | Session(用户端) | @@ -44,6 +46,8 @@ T-619 已落地 `/api/v1/analyze/images`:使用独立 `vision` 操作和能力 T-620 已扩展两个图生图路由:旧 `image_url` / `image_base64` 单图字段保持兼容;新 `images` 为有序列表,每项必须且只能提供其中一种来源,且不能和旧字段混用。服务端固定把第 1 张作为主商品图、后续图作为参考图;异步任务在后台可查看有序输入文件,不保存 base64 原文。 +T-624 已新增设备登记 / 心跳路由:登记接口继续只认 API Key,返回仅本次展示的短期设备会话;心跳接口只认 `X-Device-Session`。两条接口仅为蝦皮圈设备观测服务,未改变生成、余额、模型目录、异步任务或通用 API Key 的鉴权规则。 + ## 运营后台(django-admin,`/admin/`) 后台用 Django Session 登录,按模型注册 Admin: @@ -64,6 +68,8 @@ T-620 已扩展两个图生图路由:旧 `image_url` / `image_base64` 单图 | 配置审计 | AiConfigAuditLog | 只读查看 AiModel / ModelAlias / 密钥变更:谁、何时、改了什么 | | 客户端下载版本 | DownloadRelease | 上传安装包或填写外部下载地址,标记每个平台当前版本、是否强制更新和文件大小字节数;新增 / 编辑时必须填写 SHA256 与文件大小字节数;展示版本、SHA256、强制更新标记与发布说明 | | 导入模板 | ImportTemplate | 上传导入模板或填写外部下载地址,标记当前模板;展示模板名称、SHA256 与说明 | +| 客户端设备 | ClientDevice | 只读检索蝦皮圈设备平台、版本、状态和最近活跃时间;设备摘要仅脱敏显示 | +| 设备会话 / 审计 | DeviceSession / DeviceBindingAudit | 只读查看会话有效期、吊销状态及登记/心跳审计;不展示会话令牌或安装公钥 | ## 后台职责约定 diff --git a/progress.md b/progress.md index 74a2cb5..46ded5d 100644 --- a/progress.md +++ b/progress.md @@ -2050,3 +2050,11 @@ - 实现:`ImageInputTypeFilter(SimpleListFilter)` 加入 `ImageGenerationTaskAdmin.list_filter`。筛选通过 `Count("input_images")` 生成数据库子查询:1 条子输入为新单图、2 条及以上为多图;无子输入但旧 `input_image` 有值的任务作为兼容单图;无输入任务不返回。结果仍为原 queryset,可与状态 / 日期过滤叠加,不在 Python 层遍历或去重。 - 验证:`C:/Python312/python.exe manage.py check` 通过;`manage.py test apps.api.tests.ImageGenerationTaskAdminTests --keepdb --noinput --verbosity 2` 通过,6 tests OK(T-622 画廊回归、新/旧单图、多图、无输入排除、状态组合、权限);`makemigrations --check --dry-run` 返回 No changes detected,期间开发库 `43.128.3.240` 出现一次既有连接超时 `WinError 10060` 警告;`./init.ps1` 通过;`git diff --check` 通过。 + +## 2026-07-21 完成:T-624 蝦皮圈设备登记与会话观测 + +- 实现:新增 `apps/licensing`,通过 `ClientDevice`、`DeviceSession`、`DeviceBindingAudit` 记录设备、短期会话和审计;新增 `licensing.0001_initial`。设备登记 `POST /api/v1/client/devices/register` 继续使用 API Key,返回仅本次展示的设备会话令牌;心跳 `POST /api/v1/client/devices/heartbeat` 使用 `X-Device-Session`。设备标识使用服务端 pepper HMAC 摘要、安装公钥和会话令牌仅存 SHA-256 hash;重复登记复用设备且轮换会话,活跃写入默认按日节流。 +- 兼容性:本任务没有修改现有生成、余额、模型目录、异步任务、点数账本或通用 API Key 鉴权。设备会话暂不参与生成授权,旧客户端不传设备头继续正常调用。 +- 文档:同步 `api.md`、`routes.md`、`04-architecture.md`、`env.md`、`current-state.md` 和任务状态;新增生产需设置的 `DEVICE_IDENTIFIER_PEPPER`、`DEVICE_SESSION_TTL_SECONDS`、`DEVICE_ACTIVITY_UPDATE_SECONDS`。 +- 验证:本地执行 `py -3.12 manage.py migrate licensing --noinput`,应用 `licensing.0001_initial`;`py -3.12 manage.py test apps.licensing apps.api.tests.ApiKeyAuthenticationTests apps.api.tests.BalanceApiTests --keepdb --noinput --verbosity 2` 通过,21 tests OK;`py -3.12 manage.py makemigrations --check --dry-run` 无变化;`py -3.12 manage.py check` 通过;`./init.ps1` 通过。测试保留既有 allauth MySQL 条件唯一约束 `models.W036` 警告。 +- 下一步:领取 T-625,将有效设备会话关联到调用记录和遥测,但继续保持只观测、不阻断。