feat: add device registration observation
This commit is contained in:
@@ -34,6 +34,9 @@ MYSQL_WRITE_TIMEOUT=120
|
|||||||
|
|
||||||
# AI key encryption
|
# AI key encryption
|
||||||
AI_KEY_ENCRYPTION_KEY=base64-fernet-key
|
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
|
AI_IMAGE_UPSTREAM_DEADLINE_SECONDS=180
|
||||||
PUBLIC_BASE_URL=
|
PUBLIC_BASE_URL=
|
||||||
MEDIA_PUBLIC_BASE_URL=
|
MEDIA_PUBLIC_BASE_URL=
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ from django.conf import settings
|
|||||||
from rest_framework import serializers
|
from rest_framework import serializers
|
||||||
|
|
||||||
from apps.billing.models import RechargeOrder
|
from apps.billing.models import RechargeOrder
|
||||||
|
from apps.licensing.models import ClientDevice
|
||||||
|
|
||||||
|
|
||||||
class GenerateTitleRequestSerializer(serializers.Serializer):
|
class GenerateTitleRequestSerializer(serializers.Serializer):
|
||||||
@@ -129,3 +130,28 @@ class RechargeStatusRequestSerializer(serializers.Serializer):
|
|||||||
allow_blank=False,
|
allow_blank=False,
|
||||||
max_length=64,
|
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,
|
||||||
|
)
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ from .views import (
|
|||||||
AnalyzeImagesView,
|
AnalyzeImagesView,
|
||||||
BalanceView,
|
BalanceView,
|
||||||
ClientLatestReleaseView,
|
ClientLatestReleaseView,
|
||||||
|
DeviceHeartbeatView,
|
||||||
|
DeviceRegistrationView,
|
||||||
GenerateImageTaskDetailView,
|
GenerateImageTaskDetailView,
|
||||||
GenerateImageTaskSubmitView,
|
GenerateImageTaskSubmitView,
|
||||||
GenerateImageView,
|
GenerateImageView,
|
||||||
@@ -18,6 +20,16 @@ from .views import (
|
|||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
path("v1/balance", BalanceView.as_view(), name="api-balance"),
|
path("v1/balance", BalanceView.as_view(), name="api-balance"),
|
||||||
path("v1/models", ModelsView.as_view(), name="api-models"),
|
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(
|
path(
|
||||||
"v1/client/releases/latest",
|
"v1/client/releases/latest",
|
||||||
ClientLatestReleaseView.as_view(),
|
ClientLatestReleaseView.as_view(),
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ from apps.api.image_tasks import (
|
|||||||
from apps.api.models import ImageGenerationTask
|
from apps.api.models import ImageGenerationTask
|
||||||
from apps.api.serializers import (
|
from apps.api.serializers import (
|
||||||
AnalyzeImagesRequestSerializer,
|
AnalyzeImagesRequestSerializer,
|
||||||
|
DeviceRegistrationRequestSerializer,
|
||||||
GenerateImageRequestSerializer,
|
GenerateImageRequestSerializer,
|
||||||
GenerateTitleRequestSerializer,
|
GenerateTitleRequestSerializer,
|
||||||
RechargeCreateRequestSerializer,
|
RechargeCreateRequestSerializer,
|
||||||
@@ -63,6 +64,12 @@ from apps.billing.services import (
|
|||||||
query_and_apply_recharge_payment,
|
query_and_apply_recharge_payment,
|
||||||
)
|
)
|
||||||
from apps.portal.models import DownloadRelease
|
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__)
|
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:
|
def _release_unpublished_response(platform: str) -> dict:
|
||||||
return {
|
return {
|
||||||
"platform": platform,
|
"platform": platform,
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
@@ -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
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
from django.apps import AppConfig
|
||||||
|
|
||||||
|
|
||||||
|
class LicensingConfig(AppConfig):
|
||||||
|
default_auto_field = "django.db.models.BigAutoField"
|
||||||
|
name = "apps.licensing"
|
||||||
|
verbose_name = "软件授权"
|
||||||
@@ -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"
|
||||||
@@ -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'),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -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}"
|
||||||
@@ -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
|
||||||
@@ -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)
|
||||||
@@ -63,6 +63,9 @@ load_dotenv(BASE_DIR / ".env")
|
|||||||
# SECURITY WARNING: keep the secret key used in production secret.
|
# SECURITY WARNING: keep the secret key used in production secret.
|
||||||
SECRET_KEY = os.environ.get("DJANGO_SECRET_KEY", "django-insecure-dev-only-change-me")
|
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", "")
|
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!
|
# SECURITY WARNING: don't run with debug turned on in production!
|
||||||
DEBUG = env_bool("DJANGO_DEBUG", True)
|
DEBUG = env_bool("DJANGO_DEBUG", True)
|
||||||
@@ -158,6 +161,7 @@ INSTALLED_APPS = [
|
|||||||
'allauth.account',
|
'allauth.account',
|
||||||
'rest_framework',
|
'rest_framework',
|
||||||
'apps.users',
|
'apps.users',
|
||||||
|
'apps.licensing',
|
||||||
'apps.portal',
|
'apps.portal',
|
||||||
'apps.billing',
|
'apps.billing',
|
||||||
'apps.ai',
|
'apps.ai',
|
||||||
|
|||||||
@@ -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` 配置当前模板。
|
- **用户端层(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/`。
|
- **用户与账号层**:注册用户 `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` 仅表示下载文件大小字节数。
|
- **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/`。
|
- **计费层**:点数计算、原子扣减(锁 `UserWallet` 行)、退点、充值入账、流水记账。入口 `apps/billing/`。
|
||||||
- **AI 调用层(Provider Adapter 架构)**:对外只暴露稳定能力,内部用「能力别名 → 具体供应商适配器」解耦。入口 `apps/ai/`,适配器在 `apps/ai/providers/`。
|
- **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-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-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 字结果摘要。
|
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 |
|
| 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;每平台当前版本由应用层事务保存时互斥 |
|
| 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;当前模板由应用层事务保存时互斥 |
|
| 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
@@ -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-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-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-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-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-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 |
|
| T-627 | 存量用户迁移权益、网页确认与设备凭证 | T-624, T-626, T-501 | 实施“自动迁移权益 + 网页确认当前设备”而非旧 API Key 自动抢占设备。新增 `LegacyMigrationGrant` / 一次性 `MigrationRequest` 和产品专用 `DeviceCredential`(均只存 hash/摘要);提供后台按资格快照批量或受控创建迁移权益,默认设备数和迁移到期日由明确配置/管理动作决定,新注册用户不得自动领取。客户端以旧 API Key 仅可为当前设备创建短时迁移请求;网页登录的 portal 页面确认同账号、同设备和未占用席位后,在事务内绑定席位并签发仅本次显示的设备凭证。重复确认、客户端轮询重试、浏览器刷新和响应丢失均幂等;复制旧 API Key 不能绕过网页登录确认。支持受审计的自助/客服解绑,吊销旧凭证;不改变旧生成接口,迁移窗口内旧客户端仍可用。测试覆盖资格边界、跨账号拒绝、过期/重复请求、并发确认只占一席、凭证 hash 与吊销、旧接口兼容;同步 portal 路由、API 契约、迁移与隐私文档。 | TODO |
|
||||||
|
|||||||
+50
@@ -18,6 +18,7 @@
|
|||||||
- T-608 起新用户注册成功一次性赠送 **10 点**试用点数;赠点必须经计费层写入钱包和 `PointsLedger(change_type=signup_bonus)`,不得直接改余额字段。历史用户是否补发不属于默认注册流程。
|
- T-608 起新用户注册成功一次性赠送 **10 点**试用点数;赠点必须经计费层写入钱包和 `PointsLedger(change_type=signup_bonus)`,不得直接改余额字段。历史用户是否补发不属于默认注册流程。
|
||||||
- API Key 库内只存 `key_hash`(SHA-256)与 `key_prefix`,明文只在创建时返回一次,不在 admin、日志或调用记录中回显。
|
- API Key 库内只存 `key_hash`(SHA-256)与 `key_prefix`,明文只在创建时返回一次,不在 admin、日志或调用记录中回显。
|
||||||
- 每次生成调用写 `CallRecord`;只允许保存 `result_ref` / `result_summary` 这类引用或摘要,不保存 provider `raw`、base64 图片或敏感上游字段。
|
- 每次生成调用写 `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。
|
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 |
|
| `payment_order_create_failed` | 支付平台下单失败 | 502 |
|
||||||
| `order_not_found` | 充值订单不存在或不属于当前用户 | 404 |
|
| `order_not_found` | 充值订单不存在或不属于当前用户 | 404 |
|
||||||
| `rate_limited` | 请求过于频繁,请稍后再试 | 429 |
|
| `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 模型配置排查步骤执行。
|
`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` 顶层字段。
|
要点:接口只查 `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`
|
### `POST /api/v1/generate/title`
|
||||||
|
|
||||||
生成标题。请求:
|
生成标题。请求:
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -51,6 +51,9 @@
|
|||||||
| 变量 | 必填 | 示例 | 说明 |
|
| 变量 | 必填 | 示例 | 说明 |
|
||||||
| --- | --- | --- | --- |
|
| --- | --- | --- | --- |
|
||||||
| `AI_KEY_ENCRYPTION_KEY` | 是 | `base64-fernet-key` | Fernet 主密钥,用于加密 `AiModel.api_key_encrypted`;生产不可更换,除非完成密钥轮换 |
|
| `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 / 客户端超时必须大于该值 |
|
| `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 |
|
| `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` |
|
| `MEDIA_PUBLIC_BASE_URL` | 否 | `https://cm.833729.com` | 媒体文件公开基础 URL;优先于 `PUBLIC_BASE_URL`,用于 T-614 异步生图 worker 返回 `result.image_url` |
|
||||||
|
|||||||
@@ -31,6 +31,8 @@ T-606 已落地 `/` 公开首页:匿名访问返回 200,不再重定向到 `
|
|||||||
| `/api/v1/generate/image/tasks/{task_id}` | GET | 轮询异步图片生成任务状态和结果 | API Key |
|
| `/api/v1/generate/image/tasks/{task_id}` | GET | 轮询异步图片生成任务状态和结果 | API Key |
|
||||||
| `/api/v1/balance` | GET | 查询点数余额 | API Key |
|
| `/api/v1/balance` | GET | 查询点数余额 | API Key |
|
||||||
| `/api/v1/models` | 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/client/releases/latest` | GET | 桌面端检查最新客户端版本(公开发布元数据) | 公开 |
|
||||||
| `/api/v1/recharge/create` | POST | 用户端发起充值(weixin/alipay),下单取二维码 | Session(用户端) |
|
| `/api/v1/recharge/create` | POST | 用户端发起充值(weixin/alipay),下单取二维码 | Session(用户端) |
|
||||||
| `/api/v1/recharge/status` | GET | 轮询订单状态(前端每秒) | 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-620 已扩展两个图生图路由:旧 `image_url` / `image_base64` 单图字段保持兼容;新 `images` 为有序列表,每项必须且只能提供其中一种来源,且不能和旧字段混用。服务端固定把第 1 张作为主商品图、后续图作为参考图;异步任务在后台可查看有序输入文件,不保存 base64 原文。
|
||||||
|
|
||||||
|
T-624 已新增设备登记 / 心跳路由:登记接口继续只认 API Key,返回仅本次展示的短期设备会话;心跳接口只认 `X-Device-Session`。两条接口仅为蝦皮圈设备观测服务,未改变生成、余额、模型目录、异步任务或通用 API Key 的鉴权规则。
|
||||||
|
|
||||||
## 运营后台(django-admin,`/admin/`)
|
## 运营后台(django-admin,`/admin/`)
|
||||||
|
|
||||||
后台用 Django Session 登录,按模型注册 Admin:
|
后台用 Django Session 登录,按模型注册 Admin:
|
||||||
@@ -64,6 +68,8 @@ T-620 已扩展两个图生图路由:旧 `image_url` / `image_base64` 单图
|
|||||||
| 配置审计 | AiConfigAuditLog | 只读查看 AiModel / ModelAlias / 密钥变更:谁、何时、改了什么 |
|
| 配置审计 | AiConfigAuditLog | 只读查看 AiModel / ModelAlias / 密钥变更:谁、何时、改了什么 |
|
||||||
| 客户端下载版本 | DownloadRelease | 上传安装包或填写外部下载地址,标记每个平台当前版本、是否强制更新和文件大小字节数;新增 / 编辑时必须填写 SHA256 与文件大小字节数;展示版本、SHA256、强制更新标记与发布说明 |
|
| 客户端下载版本 | DownloadRelease | 上传安装包或填写外部下载地址,标记每个平台当前版本、是否强制更新和文件大小字节数;新增 / 编辑时必须填写 SHA256 与文件大小字节数;展示版本、SHA256、强制更新标记与发布说明 |
|
||||||
| 导入模板 | ImportTemplate | 上传导入模板或填写外部下载地址,标记当前模板;展示模板名称、SHA256 与说明 |
|
| 导入模板 | ImportTemplate | 上传导入模板或填写外部下载地址,标记当前模板;展示模板名称、SHA256 与说明 |
|
||||||
|
| 客户端设备 | ClientDevice | 只读检索蝦皮圈设备平台、版本、状态和最近活跃时间;设备摘要仅脱敏显示 |
|
||||||
|
| 设备会话 / 审计 | DeviceSession / DeviceBindingAudit | 只读查看会话有效期、吊销状态及登记/心跳审计;不展示会话令牌或安装公钥 |
|
||||||
|
|
||||||
## 后台职责约定
|
## 后台职责约定
|
||||||
|
|
||||||
|
|||||||
@@ -2050,3 +2050,11 @@
|
|||||||
|
|
||||||
- 实现:`ImageInputTypeFilter(SimpleListFilter)` 加入 `ImageGenerationTaskAdmin.list_filter`。筛选通过 `Count("input_images")` 生成数据库子查询:1 条子输入为新单图、2 条及以上为多图;无子输入但旧 `input_image` 有值的任务作为兼容单图;无输入任务不返回。结果仍为原 queryset,可与状态 / 日期过滤叠加,不在 Python 层遍历或去重。
|
- 实现:`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` 通过。
|
- 验证:`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,将有效设备会话关联到调用记录和遥测,但继续保持只观测、不阻断。
|
||||||
|
|||||||
Reference in New Issue
Block a user