feat: add device registration observation

This commit is contained in:
QiuSW
2026-07-21 09:27:05 +08:00
parent 20bdf4a8ce
commit 69f4517958
21 changed files with 958 additions and 5 deletions
+3
View File
@@ -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=
+26
View File
@@ -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,
)
+12
View File
@@ -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(),
+83
View File
@@ -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,
+1
View File
@@ -0,0 +1 @@
+107
View File
@@ -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
+7
View File
@@ -0,0 +1,7 @@
from django.apps import AppConfig
class LicensingConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "apps.licensing"
verbose_name = "软件授权"
+41
View File
@@ -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"
+107
View File
@@ -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'),
),
]
+172
View File
@@ -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}"
+152
View File
@@ -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
+162
View File
@@ -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)
+4
View File
@@ -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',
+6
View File
@@ -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 <API_KEY>`,通过 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、登记/会话/心跳等动作、原因、客户端版本、时间;只读审计 |
关键事实:
+1 -1
View File
@@ -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` 展示生成结果。双击缩略图弹出后台 `<dialog>` 大图预览,支持关闭按钮、遮罩和 `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 |
+50
View File
@@ -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_KEY>`;生成、余额等外部 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_<only-returned-on-this-registration>",
"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_<device_session_token>
```
成功响应返回当前设备公开摘要、会话到期时间和 `activity_updated`。默认同一设备至少间隔 24 小时才写入一次 `last_seen_at`,因此频繁心跳可能返回 `activity_updated=false`,这是正常行为。缺失、无效或过期会话返回 `401 device_session_invalid`;已吊销设备返回 `403 device_revoked`。该接口不扣点、不创建调用记录,也不刷新过期会话;客户端应重新调用登记接口获取新令牌。
### `POST /api/v1/generate/title`
生成标题。请求:
File diff suppressed because one or more lines are too long
+3
View File
@@ -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` |
+6
View File
@@ -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 | 只读查看会话有效期、吊销状态及登记/心跳审计;不展示会话令牌或安装公钥 |
## 后台职责约定
+8
View File
@@ -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,将有效设备会话关联到调用记录和遥测,但继续保持只观测、不阻断。