From 11c0d63ac870d819e96116fba13e7066cd52097b Mon Sep 17 00:00:00 2001 From: QiuSW <105186638@qq.com> Date: Tue, 21 Jul 2026 09:40:40 +0800 Subject: [PATCH] feat: associate generation calls with devices --- apps/api/generation.py | 12 +- apps/api/image_tasks.py | 2 + apps/api/telemetry.py | 9 ++ apps/api/tests.py | 138 ++++++++++++++++++ apps/api/views.py | 132 +++++++++++++++++ apps/billing/admin.py | 5 +- .../0010_callrecord_client_device_and_more.py | 27 ++++ apps/billing/models.py | 9 ++ apps/billing/services.py | 2 + apps/licensing/services.py | 28 ++++ docs/04-architecture.md | 10 +- docs/06-tasks.md | 2 +- docs/api.md | 17 ++- docs/current-state.md | 10 +- docs/routes.md | 6 +- progress.md | 8 + 16 files changed, 398 insertions(+), 19 deletions(-) create mode 100644 apps/billing/migrations/0010_callrecord_client_device_and_more.py diff --git a/apps/api/generation.py b/apps/api/generation.py index f26a9ad..8b43c8d 100644 --- a/apps/api/generation.py +++ b/apps/api/generation.py @@ -73,6 +73,7 @@ class GenerationInput: api_key: Any operation_type: str prompt: str + client_device: Any | None = None alias: str | None = None resolution: str = "1K" parameters: Mapping[str, Any] = field(default_factory=dict) @@ -86,6 +87,7 @@ class GenerationInput: class PreparedGeneration: user: Any api_key: Any + client_device: Any | None operation_type: str prompt: str alias: str @@ -139,11 +141,12 @@ IMAGE_URL_ALLOWED_SCHEMES = {"http", "https"} IMAGE_URL_CHUNK_SIZE = 64 * 1024 -def generate_title_response(*, user, api_key, request_data: Mapping[str, Any]) -> dict: +def generate_title_response(*, user, api_key, client_device=None, request_data: Mapping[str, Any]) -> dict: result = run_synchronous_generation( GenerationInput( user=user, api_key=api_key, + client_device=client_device, operation_type=CallRecord.OperationType.TITLE, prompt=request_data["prompt"], alias=request_data.get("model") or None, @@ -160,6 +163,7 @@ def generate_image_response( *, user, api_key, + client_device=None, request_data: Mapping[str, Any], image_url_builder: ImageUrlBuilder | None = None, ) -> dict: @@ -167,6 +171,7 @@ def generate_image_response( GenerationInput( user=user, api_key=api_key, + client_device=client_device, operation_type=CallRecord.OperationType.IMAGE, prompt=request_data["prompt"], alias=request_data.get("model") or None, @@ -182,11 +187,12 @@ def generate_image_response( return result.as_response_data() -def analyze_images_response(*, user, api_key, request_data: Mapping[str, Any]) -> dict: +def analyze_images_response(*, user, api_key, client_device=None, request_data: Mapping[str, Any]) -> dict: result = run_synchronous_generation( GenerationInput( user=user, api_key=api_key, + client_device=client_device, operation_type=CallRecord.OperationType.VISION, prompt=request_data["prompt"], alias=request_data.get("model") or None, @@ -257,6 +263,7 @@ def prepare_generation(generation_input: GenerationInput) -> PreparedGeneration: return PreparedGeneration( user=generation_input.user, api_key=generation_input.api_key, + client_device=generation_input.client_device, operation_type=operation_type, prompt=prompt, alias=model_alias.alias, @@ -276,6 +283,7 @@ def precharge_generation(prepared: PreparedGeneration) -> PrechargedGeneration: charge = precharge_or_raise( user=prepared.user, api_key=prepared.api_key, + client_device=prepared.client_device, operation_type=prepared.operation_type, alias=prepared.alias, model_used=prepared.resolved_model.model, diff --git a/apps/api/image_tasks.py b/apps/api/image_tasks.py index 3e01259..10f1a37 100644 --- a/apps/api/image_tasks.py +++ b/apps/api/image_tasks.py @@ -38,6 +38,7 @@ def create_image_generation_task( *, user, api_key, + client_device=None, request_data: Mapping[str, Any], idempotency_key: str = "", ) -> tuple[ImageGenerationTask, bool]: @@ -53,6 +54,7 @@ def create_image_generation_task( GenerationInput( user=user, api_key=api_key, + client_device=client_device, operation_type=CallRecord.OperationType.IMAGE, prompt=request_data["prompt"], alias=request_data.get("model") or None, diff --git a/apps/api/telemetry.py b/apps/api/telemetry.py index 51278d5..42bfe70 100644 --- a/apps/api/telemetry.py +++ b/apps/api/telemetry.py @@ -68,12 +68,21 @@ def build_generation_route_usage_event( ) -> dict[str, Any]: api_key = getattr(request, "auth", None) user = getattr(request, "user", None) + client_device = getattr(request, "client_device", None) return { "event": EVENT_NAME, "route_type": normalize_text(route_type, 16), "api_key_id": getattr(api_key, "id", None), "api_key_prefix": normalize_text(getattr(api_key, "key_prefix", ""), 32), "user_id": getattr(user, "id", None), + "product_code": normalize_text( + getattr(client_device, "product_code", ""), + 32, + ), + "client_device_id": getattr(client_device, "id", None), + "device_session_present": bool( + getattr(request, "device_session_present", False) + ), "client_version": normalize_text( request.headers.get("X-Client-Version", ""), MAX_CLIENT_VERSION_LENGTH, diff --git a/apps/api/tests.py b/apps/api/tests.py index 93c2adc..c9e085f 100644 --- a/apps/api/tests.py +++ b/apps/api/tests.py @@ -62,6 +62,7 @@ from apps.billing.services import RechargePayment from apps.moderation.models import SensitiveWord from apps.moderation.providers.keyword import reset_keyword_matcher_cache from apps.portal.models import DownloadRelease +from apps.licensing.services import register_device from apps.users.models import ApiKey from apps.users.models import UserWallet @@ -1395,6 +1396,21 @@ class GenerateApiTests(TestCase): def auth_header(self) -> dict: return {"HTTP_AUTHORIZATION": f"Bearer {self.raw_key}"} + def register_device_session(self, *, user=None, api_key=None): + user = user or self.user + api_key = api_key or self.api_key + result = register_device( + user=user, + api_key=api_key, + product_code="cmshopee", + device_id_version="v1", + device_id=f"test-device-{uuid.uuid4().hex}", + public_key=f"test-public-key-{uuid.uuid4().hex}", + platform="windows", + client_version="0.1.0", + ) + return result.device, result.session_token + def post_with_provider(self, path, payload, provider=None, **extra): with patch("apps.api.generation.get_provider", return_value=provider or self.provider): return self.client.post(path, payload, format="json", **self.auth_header(), **extra) @@ -1417,6 +1433,9 @@ class GenerateApiTests(TestCase): "api_key_id", "api_key_prefix", "user_id", + "product_code", + "client_device_id", + "device_session_present", "client_version", "alias", "status", @@ -1525,6 +1544,97 @@ class GenerateApiTests(TestCase): 1, ) + def test_generation_endpoints_link_call_records_to_valid_device_session(self): + device, session_token = self.register_device_session() + device_header = {"HTTP_X_DEVICE_SESSION": session_token} + encoded = base64.b64encode(b"device-linked-image").decode("ascii") + + title = self.post_with_provider( + "/api/v1/generate/title", + {"prompt": "生成标题", "model": self.title_alias}, + **device_header, + ) + vision = self.post_with_provider( + "/api/v1/analyze/images", + { + "prompt": "理解商品图", + "model": self.vision_alias, + "images": [{"image_base64": encoded}], + }, + **device_header, + ) + image = self.post_with_provider( + "/api/v1/generate/image", + { + "prompt": "生成图片", + "model": self.image_alias, + "image_base64": encoded, + }, + **device_header, + ) + task_submit = self.post_with_provider( + "/api/v1/generate/image/tasks", + {"prompt": "异步生成图片", "model": self.image_alias}, + **device_header, + ) + + for response in (title, vision, image): + self.assertEqual(response.status_code, 200) + self.assertEqual( + CallRecord.objects.get(pk=response.data["call_id"]).client_device_id, + device.id, + ) + self.assertEqual(task_submit.status_code, 202) + task = ImageGenerationTask.objects.get(task_id=task_submit.data["task_id"]) + self.assertEqual(task.call_record.client_device_id, device.id) + + poll = self.client.get( + f"/api/v1/generate/image/tasks/{task.task_id}", + **self.auth_header(), + ) + self.assertEqual(poll.status_code, 200) + + def test_generation_without_device_session_remains_legacy_compatible(self): + response = self.post_with_provider( + "/api/v1/generate/title", + {"prompt": "无设备头标题", "model": self.title_alias}, + ) + + self.assertEqual(response.status_code, 200) + call = CallRecord.objects.get(pk=response.data["call_id"]) + self.assertIsNone(call.client_device_id) + self.assertEqual(response.data["points_cost"], 2) + self.assertEqual(response.data["points_balance"], 98) + + def test_invalid_or_cross_user_device_session_is_rejected_before_charge(self): + invalid = self.post_with_provider( + "/api/v1/generate/title", + {"prompt": "无效会话", "model": self.title_alias}, + HTTP_X_DEVICE_SESSION="dvs_cmhub_invalid", + ) + self.assertEqual(invalid.status_code, 401) + self.assertEqual(invalid.data["error"]["code"], "device_session_invalid") + self.assert_generation_not_charged() + + other_user = get_user_model().objects.create_user( + username=f"other-device-user-{uuid.uuid4().hex[:8]}", + email=f"other-device-user-{uuid.uuid4().hex[:8]}@example.com", + password="password", + ) + other_key, _raw_other_key = ApiKey.create_for_user(other_user, name="other-device") + _device, other_session_token = self.register_device_session( + user=other_user, + api_key=other_key, + ) + cross_user = self.post_with_provider( + "/api/v1/generate/title", + {"prompt": "跨账号会话", "model": self.title_alias}, + HTTP_X_DEVICE_SESSION=other_session_token, + ) + self.assertEqual(cross_user.status_code, 403) + self.assertEqual(cross_user.data["error"]["code"], "device_mismatch") + self.assert_generation_not_charged() + def test_analyze_images_supports_single_image_with_explicit_alias(self): encoded = base64.b64encode(b"single-image").decode("ascii") @@ -1963,6 +2073,9 @@ class GenerateApiTests(TestCase): self.assertEqual(event["api_key_id"], self.api_key.id) self.assertEqual(event["api_key_prefix"], self.api_key.key_prefix) self.assertEqual(event["user_id"], self.user.id) + self.assertEqual(event["product_code"], "") + self.assertIsNone(event["client_device_id"]) + self.assertFalse(event["device_session_present"]) self.assertEqual(event["client_version"], "0.1.1") self.assertEqual(event["alias"], self.image_alias) self.assertEqual(event["status"], "success") @@ -1972,6 +2085,31 @@ class GenerateApiTests(TestCase): self.assertGreaterEqual(event["latency_ms"], 0) self.assert_generation_telemetry_is_safe(event, payload=payload) + def test_sync_image_usage_telemetry_records_only_safe_device_metadata(self): + device, session_token = self.register_device_session() + encoded = base64.b64encode(b"telemetry-device-image").decode("ascii") + payload = { + "prompt": "设备遥测图片生成", + "model": self.image_alias, + "image_base64": encoded, + } + + with self.assertLogs("cmhub.api.generation_usage", level="INFO") as captured: + response = self.post_with_provider( + "/api/v1/generate/image", + payload, + HTTP_X_DEVICE_SESSION=session_token, + HTTP_X_CLIENT_VERSION="0.1.3", + ) + + self.assertEqual(response.status_code, 200) + event = self.telemetry_event_from_logs(captured) + self.assertEqual(event["product_code"], "cmshopee") + self.assertEqual(event["client_device_id"], device.id) + self.assertTrue(event["device_session_present"]) + self.assertNotIn(session_token, json.dumps(event, ensure_ascii=False)) + self.assert_generation_telemetry_is_safe(event, payload=payload) + def test_async_image_submit_usage_telemetry_logs_safe_success_event(self): payload = { "prompt": "生成异步图片遥测测试", diff --git a/apps/api/views.py b/apps/api/views.py index 5d3ba68..345b555 100644 --- a/apps/api/views.py +++ b/apps/api/views.py @@ -67,8 +67,10 @@ from apps.portal.models import DownloadRelease from apps.licensing.authentication import DeviceSessionAuthentication from apps.licensing.services import ( DeviceRegistrationError, + DeviceSessionValidationError, record_device_heartbeat, register_device, + resolve_optional_device_session, ) logger = logging.getLogger(__name__) @@ -84,13 +86,55 @@ class ExternalApiView(APIView): raise AuthenticationFailed(api_error("unauthorized", "缺失或无效 API Key")) super().permission_denied(request, message=message, code=code) + def optional_client_device(self, request): + raw_token = request.headers.get("X-Device-Session", "") + request.device_session_present = bool(raw_token.strip()) + try: + session = resolve_optional_device_session( + user=request.user, + raw_token=raw_token, + ) + except DeviceSessionValidationError as exc: + http_status = ( + status.HTTP_401_UNAUTHORIZED + if exc.code == "device_session_invalid" + else status.HTTP_403_FORBIDDEN + ) + raise ApiRequestError(exc.code, exc.message, http_status) from exc + request.client_device = session.device if session is not None else None + return request.client_device + class GenerateTitleView(ExternalApiView): throttle_classes = (GenerateRateThrottle,) def post(self, request): + started = telemetry_start_time() + alias = request_alias(request.data) + try: + client_device = self.optional_client_device(request) + except ApiRequestError as exc: + log_generation_route_usage( + route_type="title", + request=request, + alias=alias, + status="error", + latency_ms=telemetry_elapsed_ms(started), + error_code=exc.code, + http_status=exc.http_status, + ) + return Response(exc.as_response_data(), status=exc.http_status) serializer = GenerateTitleRequestSerializer(data=request.data) if not serializer.is_valid(): + log_generation_route_usage( + route_type="title", + request=request, + alias=alias, + status="error", + latency_ms=telemetry_elapsed_ms(started), + error_code="bad_request", + http_status=status.HTTP_400_BAD_REQUEST, + ) return Response( api_error("bad_request", "参数错误"), status=status.HTTP_400_BAD_REQUEST, @@ -99,10 +143,28 @@ class GenerateTitleView(ExternalApiView): data = generate_title_response( user=request.user, api_key=request.auth, + client_device=client_device, request_data=serializer.validated_data, ) except ApiRequestError as exc: + log_generation_route_usage( + route_type="title", + request=request, + alias=alias, + status="error", + latency_ms=telemetry_elapsed_ms(started), + error_code=exc.code, + http_status=exc.http_status, + ) return Response(exc.as_response_data(), status=exc.http_status) + log_generation_route_usage( + route_type="title", + request=request, + alias=data.get("alias") or alias, + status="success", + latency_ms=telemetry_elapsed_ms(started), + http_status=status.HTTP_200_OK, + ) return Response(data, status=status.HTTP_200_OK) @@ -110,8 +172,32 @@ class AnalyzeImagesView(ExternalApiView): throttle_classes = (GenerateRateThrottle,) def post(self, request): + started = telemetry_start_time() + alias = request_alias(request.data) + try: + client_device = self.optional_client_device(request) + except ApiRequestError as exc: + log_generation_route_usage( + route_type="vision", + request=request, + alias=alias, + status="error", + latency_ms=telemetry_elapsed_ms(started), + error_code=exc.code, + http_status=exc.http_status, + ) + return Response(exc.as_response_data(), status=exc.http_status) serializer = AnalyzeImagesRequestSerializer(data=request.data) if not serializer.is_valid(): + log_generation_route_usage( + route_type="vision", + request=request, + alias=alias, + status="error", + latency_ms=telemetry_elapsed_ms(started), + error_code="bad_request", + http_status=status.HTTP_400_BAD_REQUEST, + ) return Response( api_error("bad_request", "参数错误"), status=status.HTTP_400_BAD_REQUEST, @@ -120,10 +206,28 @@ class AnalyzeImagesView(ExternalApiView): data = analyze_images_response( user=request.user, api_key=request.auth, + client_device=client_device, request_data=serializer.validated_data, ) except ApiRequestError as exc: + log_generation_route_usage( + route_type="vision", + request=request, + alias=alias, + status="error", + latency_ms=telemetry_elapsed_ms(started), + error_code=exc.code, + http_status=exc.http_status, + ) return Response(exc.as_response_data(), status=exc.http_status) + log_generation_route_usage( + route_type="vision", + request=request, + alias=data.get("alias") or alias, + status="success", + latency_ms=telemetry_elapsed_ms(started), + http_status=status.HTTP_200_OK, + ) return Response(data, status=status.HTTP_200_OK) @@ -133,6 +237,19 @@ class GenerateImageView(ExternalApiView): def post(self, request): started = telemetry_start_time() alias = request_alias(request.data) + try: + client_device = self.optional_client_device(request) + except ApiRequestError as exc: + log_generation_route_usage( + route_type="sync", + request=request, + alias=alias, + status="error", + latency_ms=telemetry_elapsed_ms(started), + error_code=exc.code, + http_status=exc.http_status, + ) + return Response(exc.as_response_data(), status=exc.http_status) serializer = GenerateImageRequestSerializer(data=request.data) if not serializer.is_valid(): log_generation_route_usage( @@ -153,6 +270,7 @@ class GenerateImageView(ExternalApiView): data = generate_image_response( user=request.user, api_key=request.auth, + client_device=client_device, request_data=serializer.validated_data, image_url_builder=request.build_absolute_uri, ) @@ -184,6 +302,19 @@ class GenerateImageTaskSubmitView(ExternalApiView): def post(self, request): started = telemetry_start_time() alias = request_alias(request.data) + try: + client_device = self.optional_client_device(request) + except ApiRequestError as exc: + log_generation_route_usage( + route_type="async", + request=request, + alias=alias, + status="error", + latency_ms=telemetry_elapsed_ms(started), + error_code=exc.code, + http_status=exc.http_status, + ) + return Response(exc.as_response_data(), status=exc.http_status) serializer = GenerateImageRequestSerializer(data=request.data) if not serializer.is_valid(): log_generation_route_usage( @@ -204,6 +335,7 @@ class GenerateImageTaskSubmitView(ExternalApiView): task, _created = create_image_generation_task( user=request.user, api_key=request.auth, + client_device=client_device, request_data=serializer.validated_data, idempotency_key=request.headers.get("Idempotency-Key", ""), ) diff --git a/apps/billing/admin.py b/apps/billing/admin.py index f50cc6a..da94b20 100644 --- a/apps/billing/admin.py +++ b/apps/billing/admin.py @@ -109,6 +109,7 @@ class CallRecordAdmin(ReadOnlyLedgerAdmin): "created_at", "user", "api_key", + "client_device", "operation_type", "alias", "model_used", @@ -116,7 +117,7 @@ class CallRecordAdmin(ReadOnlyLedgerAdmin): "status", "upstream_latency_ms", ) - list_filter = ("operation_type", "status", "created_at") + list_filter = ("operation_type", "status", "client_device", "created_at") search_fields = ( "user__username", "user__email", @@ -129,7 +130,7 @@ class CallRecordAdmin(ReadOnlyLedgerAdmin): "result_summary", ) ordering = ("-created_at", "-id") - list_select_related = ("user", "api_key") + list_select_related = ("user", "api_key", "client_device") @admin.register(SignupBonusGrant) diff --git a/apps/billing/migrations/0010_callrecord_client_device_and_more.py b/apps/billing/migrations/0010_callrecord_client_device_and_more.py new file mode 100644 index 0000000..d0f9916 --- /dev/null +++ b/apps/billing/migrations/0010_callrecord_client_device_and_more.py @@ -0,0 +1,27 @@ +# Generated by Django 5.2.15 on 2026-07-21 01:34 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('billing', '0009_signup_bonus_default_ten'), + ('licensing', '0001_initial'), + ('users', '0005_alter_apikey_created_at_alter_apikey_key_hash_and_more'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.AddField( + model_name='callrecord', + name='client_device', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='call_records', to='licensing.clientdevice', verbose_name='客户端设备'), + ), + migrations.AddIndex( + model_name='callrecord', + index=models.Index(fields=['client_device', 'created_at'], name='call_record_client__d5a0fb_idx'), + ), + ] diff --git a/apps/billing/models.py b/apps/billing/models.py index ea1a326..f7f1a82 100644 --- a/apps/billing/models.py +++ b/apps/billing/models.py @@ -35,6 +35,14 @@ class CallRecord(models.Model): on_delete=models.SET_NULL, related_name="call_records", ) + client_device = models.ForeignKey( + "licensing.ClientDevice", + verbose_name="客户端设备", + null=True, + blank=True, + on_delete=models.SET_NULL, + related_name="call_records", + ) operation_type = models.CharField("操作类型", max_length=32, choices=OperationType.choices) alias = models.CharField("能力别名", max_length=64, blank=True) model_used = models.CharField("实际模型", max_length=128, blank=True) @@ -68,6 +76,7 @@ class CallRecord(models.Model): indexes = [ models.Index(fields=("user", "created_at")), models.Index(fields=("api_key", "created_at")), + models.Index(fields=("client_device", "created_at")), models.Index(fields=("operation_type", "status")), models.Index(fields=("alias",)), ] diff --git a/apps/billing/services.py b/apps/billing/services.py index 954272e..a40e2bf 100644 --- a/apps/billing/services.py +++ b/apps/billing/services.py @@ -504,6 +504,7 @@ def precharge_call( model_used: str = "", resolution: str | None = None, api_key=None, + client_device=None, prompt: str = "", ) -> CallCharge: points_cost = _validate_positive_points(points_cost) @@ -524,6 +525,7 @@ def precharge_call( call_record = CallRecord.objects.create( user=user, api_key=api_key, + client_device=client_device, operation_type=operation_type, alias=normalized_alias, model_used=str(model_used or "").strip(), diff --git a/apps/licensing/services.py b/apps/licensing/services.py index 5af3822..f2fc30a 100644 --- a/apps/licensing/services.py +++ b/apps/licensing/services.py @@ -17,6 +17,10 @@ class DeviceRegistrationError(Exception): super().__init__(message) +class DeviceSessionValidationError(DeviceRegistrationError): + pass + + @dataclass(frozen=True) class DeviceRegistrationResult: device: ClientDevice @@ -150,3 +154,27 @@ def record_device_heartbeat(session: DeviceSession, *, now=None) -> bool: client_version=session.device.client_version, ) return True + + +def resolve_optional_device_session(*, user, raw_token: str) -> DeviceSession | None: + raw_token = str(raw_token or "").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 DeviceSessionValidationError( + "device_session_invalid", + "设备会话无效或已过期", + ) from exc + + if not session.matches_token(raw_token) or not session.is_active_at(): + raise DeviceSessionValidationError("device_session_invalid", "设备会话无效或已过期") + if session.device.status != ClientDevice.Status.ACTIVE: + raise DeviceSessionValidationError("device_revoked", "设备已被吊销") + if session.device.user_id != user.id: + raise DeviceSessionValidationError("device_mismatch", "设备会话不属于当前账号") + return session diff --git a/docs/04-architecture.md b/docs/04-architecture.md index 3d3192e..1a2066c 100644 --- a/docs/04-architecture.md +++ b/docs/04-architecture.md @@ -20,7 +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 或点数授权,套餐/权益/设备席位由后续任务实现。 +- **软件授权层**:蝦皮圈客户端设备、短期设备会话和设备审计。入口 `apps/licensing/`;T-624/T-625 建立设备观测及调用关联,不改变通用 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/`。 @@ -40,7 +40,7 @@ T-301 已实现 `ApiKeyAuthentication` 与 `ExternalApiView`:外部 API 使用 `Authorization: Bearer `,通过 SHA-256 hash 定位 `ApiKey -> User`,成功后 `request.user` 为所属用户、`request.auth` 为本次 API Key;缺失/无效 Key 返回 `401 unauthorized`,用户或 Key 禁用返回 `403 account_disabled`。生成/余额等外部 API 应继承 `ExternalApiView`,不要挂 `SessionAuthentication`。 -T-624 已新增 `apps.licensing`:`POST /api/v1/client/devices/register` 用既有 API Key 建立或复用设备记录,并签发数据库仅存 hash 的短期设备会话;`POST /api/v1/client/devices/heartbeat` 用 `X-Device-Session` 更新活跃观测。设备 ID 使用服务端 pepper 后的摘要,安装公钥只存摘要,默认按设备每日节流 `last_seen_at` 写入。现有生成、余额、模型目录与任务接口不读取设备会话,确保开放 API 与旧客户端保持兼容;T-625 才关联调用记录,T-628 才进入专属授权影子校验。 +T-624/T-625 已新增 `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` 写入。四个生成提交入口可选解析同账号有效会话,将服务端解析的 `ClientDevice` 写入可空 `CallRecord.client_device`;无会话仍走原 API Key、计费与任务路径,异步轮询不读取设备会话。伪造/过期会话在预扣前拒绝,跨账号会话拒绝;`generation_route_usage` 仅记录产品代码、内部设备 ID 与是否带会话等白名单字段。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。 @@ -116,6 +116,7 @@ T-619 多图理解继续复用上述 URL 下载器和逐跳 SSRF 校验,并额 | ClientDevice | 客户端登记 | 用户、产品、版本化设备摘要、安装公钥摘要、平台、客户端版本、状态、首次/最后活跃时间;不保存原始机器标识或私钥;`(user, product_code, device_fingerprint)` 唯一 | | DeviceSession | 设备登记服务 | 设备、会话 token hash、过期/最后使用/吊销时间;明文只在登记响应出现一次 | | DeviceBindingAudit | 设备登记服务 | 用户、设备、来源 API Key、登记/会话/心跳等动作、原因、客户端版本、时间;只读审计 | +| CallRecord.client_device | 生成调用 | 可空关联到服务端验证后的 `ClientDevice`;仅有效设备会话的标题、图片、异步提交和 vision 写入,历史/无头调用保持为空 | 关键事实: @@ -317,6 +318,7 @@ CREATE TABLE call_record ( id INTEGER PRIMARY KEY, user_id INTEGER NOT NULL REFERENCES "user"(id), api_key_id INTEGER REFERENCES api_key(id), -- 本次调用所用的 Key + client_device_id INTEGER REFERENCES client_device(id), -- 可空,仅服务端验证的设备会话关联 operation_type TEXT NOT NULL, -- title / image / vision alias TEXT, -- 调用方请求的能力别名(对外稳定标识) model_used TEXT, -- 实际服务该次请求的具体模型(解析后,便于排障/对账) @@ -379,13 +381,13 @@ CREATE TABLE image_generation_task_input ( 需要说明: - 主键自增;`api_key.key_hash`、`pricing_rule(operation_type, alias, resolution)`、`recharge_order.order_no`、`user.username` 唯一。 -- 重要索引:`call_record(user_id, created_at)`、`points_ledger(user_id, created_at)`、`recharge_order(order_no)`、`api_key(key_hash)`、`signup_bonus_grant(user_id)`、`image_generation_task(status, created_at)`、`image_generation_task(status, next_attempt_at)`、`image_generation_task(status, lease_expires_at)`、`image_generation_task(user_id, created_at)`。 +- 重要索引:`call_record(user_id, created_at)`、`call_record(client_device_id, created_at)`、`points_ledger(user_id, created_at)`、`recharge_order(order_no)`、`api_key(key_hash)`、`signup_bonus_grant(user_id)`、`image_generation_task(status, created_at)`、`image_generation_task(status, next_attempt_at)`、`image_generation_task(status, lease_expires_at)`、`image_generation_task(user_id, created_at)`。 - 不软删除业务流水;用户/账号可标记 `disabled` 而非物理删;API Key 用 `revoked` 状态而非物理删。 - 服务端生成字段:`api_key.key_hash`/`key_prefix`、`points_balance`、`balance_after`、各 `created_at` / `updated_at`。 - `payment_user_id`、`payment_txn_no` 为对账预留,字段先建。 - `recharge_order.exchange_rate` 与 `points_granted` 在下单时写入,状态为 `pending` 时也必须有值;支付回调金额必须与订单金额一致,入账时不得按新的汇率重算。 - `call_record.status` 状态机为 `pending -> success / failed`。异步任务临时性失败等待重试时仍保持 `pending`;最终上游失败退点后才变为 `failed`,退款流水通过 `points_ledger(change_type=refund, ref_call_id=call_record.id)` 关联,不单独增加 `refunded` 状态,避免调用结果与账务动作混在一个字段里。 -- T-201 已落地 `UserWallet` / `ApiKey` 于 `apps.users`,`PointsLedger` / `CallRecord` 于 `apps.billing`;T-203 已落地扣点/退点服务;T-304 已落地 `RechargeOrder`、回调幂等入账服务和 `points_ledger(ref_order_id, change_type)` 复合唯一约束,`ref_order_id` 当前仍为数值引用 `RechargeOrder.id`;T-305 已落地 `create_recharge_order()`,负责创建 pending 订单、锁定汇率/点数并回填二维码票据;T-401 已落地 `adjust_wallet_points()`,手工调整点数必须带原因并写 `adjust` 流水,后台钱包余额字段只读;T-608 已新增 `signup_bonus` 流水类型和 MySQL 兼容的注册赠点幂等标记 `SignupBonusGrant(user UNIQUE)`,并把 allauth 自助注册路径改为调用 `grant_signup_bonus()` 发放 10 点;T-614 已在 `apps.api` 落地 `ImageGenerationTask` 与 `api.0001_initial` 迁移,使用 MySQL 兼容的 `(api_key, idempotency_key_hash)` 唯一约束处理幂等键,不使用条件唯一约束;T-616 已在 `ImageGenerationTask` 增加 `next_attempt_at` 与 `(status, next_attempt_at)` 索引,用于 worker 避免立即反复抢占等待重试的任务;T-502 已把 API Key 自助管理接到 `ApiKey.create_for_user()`,删除动作写为 `revoked` 状态而非物理删除;T-503/T-608 已把个人中心和记录页接到只读查询,余额用 `get_balance_snapshot()`,充值总额按 paid `RechargeOrder` 汇总,充值 / 注册赠点 / 消费 / 退款按 `PointsLedger` 汇总;T-504 已把 `/recharge` 页面接到 `create_recharge_order()` 与 `/api/v1/recharge/status`。 +- T-201 已落地 `UserWallet` / `ApiKey` 于 `apps.users`,`PointsLedger` / `CallRecord` 于 `apps.billing`;T-203 已落地扣点/退点服务;T-304 已落地 `RechargeOrder`、回调幂等入账服务和 `points_ledger(ref_order_id, change_type)` 复合唯一约束,`ref_order_id` 当前仍为数值引用 `RechargeOrder.id`;T-305 已落地 `create_recharge_order()`,负责创建 pending 订单、锁定汇率/点数并回填二维码票据;T-401 已落地 `adjust_wallet_points()`,手工调整点数必须带原因并写 `adjust` 流水,后台钱包余额字段只读;T-608 已新增 `signup_bonus` 流水类型和 MySQL 兼容的注册赠点幂等标记 `SignupBonusGrant(user UNIQUE)`,并把 allauth 自助注册路径改为调用 `grant_signup_bonus()` 发放 10 点;T-614 已在 `apps.api` 落地 `ImageGenerationTask` 与 `api.0001_initial` 迁移,使用 MySQL 兼容的 `(api_key, idempotency_key_hash)` 唯一约束处理幂等键,不使用条件唯一约束;T-616 已在 `ImageGenerationTask` 增加 `next_attempt_at` 与 `(status, next_attempt_at)` 索引,用于 worker 避免立即反复抢占等待重试的任务;T-625 已以 `billing.0010_callrecord_client_device_and_more` 为 `CallRecord` 增加可空设备外键和 `(client_device, created_at)` 索引,仅用于观测和 admin 检索,不改变点数流水;T-502 已把 API Key 自助管理接到 `ApiKey.create_for_user()`,删除动作写为 `revoked` 状态而非物理删除;T-503/T-608 已把个人中心和记录页接到只读查询,余额用 `get_balance_snapshot()`,充值总额按 paid `RechargeOrder` 汇总,充值 / 注册赠点 / 消费 / 退款按 `PointsLedger` 汇总;T-504 已把 `/recharge` 页面接到 `create_recharge_order()` 与 `/api/v1/recharge/status`。 ## 四、计费时序(核心,务必照此实现) diff --git a/docs/06-tasks.md b/docs/06-tasks.md index 5a73449..031bbb3 100644 --- a/docs/06-tasks.md +++ b/docs/06-tasks.md @@ -103,7 +103,7 @@ | T-622 | 图片生成任务后台图片缩略预览 | T-614, T-620 | 已完成:django-admin 的 `ImageGenerationTask` 详情页新增输入图 / 结果图缩略画廊;输入按 `ordinal` 标为主图和参考图,兼容历史 `input_image` 单图;成功任务以 `result_url` 展示生成结果。双击缩略图弹出后台 `` 大图预览,支持关闭按钮、遮罩和 `Escape`;图片加载失败显示降级提示。未改 `CallRecord`、对外 API、计费、worker、迁移、媒体访问策略或列表页查询。已通过 4 条目标 admin 测试、`check`、迁移一致性、静态资源发现和 `init.ps1`。 | DONE | | T-623 | 图片生成任务单图 / 多图筛选 | T-614, T-620 | 已完成:`ImageGenerationTaskAdmin` 的列表右侧新增“输入图片类型”筛选,提供“单图生图”和“多图生图”。数据库聚合计数识别 1 条子输入的单图、2 条及以上的多图;无子输入且历史 `input_image` 非空的任务兼容归单图;无输入图任务不归类。筛选可与既有状态 / 日期过滤叠加,结果不重复;未改模型、迁移、任务状态、计费、API、worker、媒体文件或详情页缩略图。已通过 6 条目标 admin 测试、`check`、迁移一致性和 `init.ps1`。 | DONE | | T-624 | 蝦皮圈设备登记与会话观测 | T-301, T-401 | 已完成:新增 `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` 写入服务端解析的可空 `client_device`;无头旧调用保持原返回、扣点、提交和轮询。新增 `billing.0010_callrecord_client_device_and_more` 与 `(client_device, created_at)` 索引;admin 支持展示 / 按设备筛选。`generation_route_usage` 新增 `product_code`、内部 `client_device_id`、`device_session_present` 白名单字段,不记录设备摘要、公钥、令牌、Key、prompt 或图片。伪造/过期会话在预扣前返回 401,吊销/跨账号会话返回 403;仍不做订阅授权拦截。已通过 4 条新增入口/兼容/安全/遥测测试及 14 条 licensing/生成回归、迁移、`check` 和一致性验证。 | DONE | | T-626 | 软件套餐、权益与设备席位基础模型 | T-624, T-401 | **阶段 2 的数据与后台基础,不接购买页、不改生成授权。** 新增 `SoftwarePlan`、`SoftwareEntitlement`、`LicenseSeat`、`LicenseEvent` 模型及 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-628 | 蝦皮圈专属授权入口与影子校验 | T-625, T-626, T-627, T-613, T-614 | 新增蝦皮圈**专属**产品调用入口或明确的产品认证分流,使用 `DeviceCredential + X-Device-Session` 解析产品、用户和设备;不得在通用 `/api/v1/generate/*` 上给全部 API Key 强加订阅校验。实现统一 `licensing` 授权服务/permission,区分“可新提交生成”和“可读取本人已接受任务”,返回稳定结构化错误码(如 `device_not_bound`、`device_mismatch`、`license_required`、`license_expired`),但本任务先以影子模式记录 `would_reject`,不实际阻断已迁移或旧客户端。蝦皮圈专属 title、vision、异步 image submit 接入该服务;已接受任务的查询/下载始终按任务归属可读。测试覆盖通用 API 完全不回归、专属凭证认证、跨用户拒绝、影子结果日志脱敏、过期权益的 would-reject、已接受任务读取和点数账本不被授权检查重复修改;同步 API、架构、部署开关和桌面端对接文档。 | TODO | diff --git a/docs/api.md b/docs/api.md index e23894a..9936264 100644 --- a/docs/api.md +++ b/docs/api.md @@ -18,7 +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-624/T-625 起,蝦皮圈客户端可先用 API Key 登记设备并取得短期 `X-Device-Session`;标题、图片、异步图片提交和图片理解可选携带该头,服务端只把已验证的设备关联到本次 `CallRecord` 与白名单遥测。没有该头的旧客户端继续按原 API Key 路径调用,响应、计费、任务提交和轮询均不变;设备会话尚**不**参与订阅授权拦截。设备标识、公钥和会话令牌不写入调用日志或响应中的设备对象。 T-301 已实现对外 API 鉴权基线:`apps.api.authentication.ApiKeyAuthentication` 只解析 `Authorization: Bearer `;生成、余额等外部 API 视图应继承 `apps.api.views.ExternalApiView`,不接受 Web session。 @@ -91,6 +91,7 @@ T-607/T-609/T-617 已实现 `GET /api/v1/client/releases/latest?platform=windows | `rate_limited` | 请求过于频繁,请稍后再试 | 429 | | `device_session_invalid` | 缺失、无效或过期设备会话 | 401 | | `device_revoked` | 客户端设备已被吊销 | 403 | +| `device_mismatch` | 设备会话不属于当前 API Key 所属账号 | 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 模型配置排查步骤执行。 @@ -194,6 +195,16 @@ X-Device-Session: dvs_cmhub_ 成功响应返回当前设备公开摘要、会话到期时间和 `activity_updated`。默认同一设备至少间隔 24 小时才写入一次 `last_seen_at`,因此频繁心跳可能返回 `activity_updated=false`,这是正常行为。缺失、无效或过期会话返回 `401 device_session_invalid`;已吊销设备返回 `403 device_revoked`。该接口不扣点、不创建调用记录,也不刷新过期会话;客户端应重新调用登记接口获取新令牌。 +### 生成接口的可选设备会话 + +以下生成提交路由可在既有 `Authorization: Bearer ` 外,额外携带同账号登记得到的设备会话:`POST /api/v1/generate/title`、`POST /api/v1/analyze/images`、`POST /api/v1/generate/image`、`POST /api/v1/generate/image/tasks`。 + +```http +X-Device-Session: dvs_cmhub_ +``` + +会话有效时,服务端将对应 `ClientDevice` 写入本次调用记录;不会把设备原始标识、公钥、会话明文、prompt 或图片写入用量日志。未携带该头时保留存量 API 契约和账务语义。调用方一旦携带该头,伪造或过期会话返回 `401 device_session_invalid`,已吊销设备或跨账号会话返回 `403 device_revoked` / `403 device_mismatch`,且请求在预扣前被拒绝。异步任务的后续 `GET` 轮询不需要该头,仍只按 API Key 所属用户校验任务归属。 + ### `POST /api/v1/generate/title` 生成标题。请求: @@ -270,9 +281,10 @@ X-Device-Session: dvs_cmhub_ ```http X-Client-Version: 0.1.1 +X-Device-Session: dvs_cmhub_ ``` -该字段仅用于 T-615 的用量遥测,帮助服务端区分旧同步接口由哪些客户端版本调用;不影响响应结构。 +`X-Client-Version` 仅用于 T-615 的用量遥测,帮助服务端区分旧同步接口由哪些客户端版本调用;不影响响应结构。`X-Device-Session` 为 T-625 可选设备关联头,省略时保持旧客户端行为。 ```json { @@ -326,6 +338,7 @@ POST /api/v1/generate/image/tasks Authorization: Bearer sk_cmhub_xxx Idempotency-Key: desktop-job-20260708-0001 X-Client-Version: 0.1.1 +X-Device-Session: dvs_cmhub_ Content-Type: application/json ``` diff --git a/docs/current-state.md b/docs/current-state.md index 1820ede..3f7e8be 100644 --- a/docs/current-state.md +++ b/docs/current-state.md @@ -18,7 +18,7 @@ - T-614/T-616 生产代码补充:已新增 `ImageGenerationTask` 与 `api.0001_initial`,新增 `POST /api/v1/generate/image/tasks`、`GET /api/v1/generate/image/tasks/{task_id}`、`apps.api.image_tasks` 任务服务、`run_image_tasks` management command 和只读 admin;异步提交支持 `Idempotency-Key` 去重 / 冲突检测,worker 使用 DB 任务表、租约、心跳与 reaper,成功返回 cmhub 托管 URL;T-616 已通过 `api.0002_imagegenerationtask_next_attempt_at_and_more` 增加 `next_attempt_at` 与 `(status, next_attempt_at)` 索引,临时性 `upstream_timeout` / `upstream_error` 默认最多重试 2 次,重试期间不退点,最终失败 / 僵任务才走计费层幂等退款。 - T-617 生产代码补充:`DownloadRelease` 已新增 `size_bytes` 可空正整数字段和迁移 `portal.0004_downloadrelease_size_bytes`,django-admin 可填写并在列表展示;`GET /api/v1/client/releases/latest` 的 `release` 对象新增 `size_bytes`,有值返回整数,未配置返回 `null`,未发布时仍只返回 `release:null`。 - T-618 生产代码补充:`DownloadReleaseAdmin` 已挂专用 `ModelForm`,后台新增 / 编辑客户端发布版本时 `sha256` 与 `size_bytes` 必填;数据库字段仍兼容历史空值,API 仍可读取历史 `size_bytes=null` 记录。 -- T-624 生产代码补充:已新增 `apps.licensing` 和迁移 `licensing.0001_initial`,包含 `ClientDevice`、`DeviceSession`、`DeviceBindingAudit`;新增 API Key 鉴权的设备登记与设备会话鉴权的心跳接口。设备/公钥/会话仅存摘要或 hash,设备活跃默认按日节流,现有生成/余额/模型/任务接口未改变;尚未部署生产。 +- T-624/T-625 本地代码补充:已新增 `apps.licensing` 和迁移 `licensing.0001_initial`,包含 `ClientDevice`、`DeviceSession`、`DeviceBindingAudit`;新增 API Key 鉴权的设备登记与设备会话鉴权的心跳接口。`billing.0010_callrecord_client_device_and_more` 为调用记录增加可空设备关联和索引;四个生成提交入口可选解析同账号设备会话,关联调用记录和白名单遥测。无会话旧客户端及异步轮询保持不变,伪造/过期/跨账号会话在预扣前拒绝;尚未部署生产。 - T-619 生产代码补充:`ModelAlias` / `CallRecord` 已新增 `vision` 操作并生成 `ai.0005` / `billing.0008` 迁移;`POST /api/v1/analyze/images` 已接入 API Key 鉴权、生成限流、prompt 前置审核、有序多图 URL/Base64 读取、SSRF 与大小保护、Chat/Gemini 多模态 Provider、固定单次预扣 / 成功确认 / 幂等退款及安全调用摘要。模型目录与 portal 可用模型页会展示能力匹配的 active `vision` 别名并标记 `requires_image=true`。2026-07-16 已在 `185.216.248.75` 部署 `707941a` 并应用迁移,复用 active `GPT-5.5 文本`(`text + vision`)创建默认 active `vision-standard`,默认价格为 1 点/次;Nginx 已把该同步接口分流到 `cmhub-generate` 长请求池。 - T-619 生产验收:真实提交两张图片成功返回中文文字,HTTP 200,`model_used=gpt-5.5`,`call_id=6096`;余额 234→233,调用记录为 `vision/success`,对应 consume 1 条、refund 0 条。验收临时 API Key 已吊销;`cmhub-web`、`cmhub-generate`、Nginx、MySQL 8.4 均为 active,66 个异步生图 worker 未因本次部署重启。 - 最新验证:T-619 已完成 `ai.0005_alter_modelalias_operation_type` 与 `billing.0008_alter_callrecord_operation_type_and_more` 迁移并应用到当前开发库;`manage.py check` 0 issues,`makemigrations --check --dry-run` 无变化;Provider 专项 10 tests OK,T-619 API / 别名 / 目录专项 13 tests OK,包含旧标题 / 生图的扩展回归 84 tests OK,users / billing / moderation 分组 46 tests OK。完整单命令回归在 604 秒达到执行器超时,之后 API 全量重试在测试库初始化阶段遇到远程 MySQL `43.128.3.240` 连接超时,均未产生断言失败;测试期仍有 allauth 在 MySQL 条件唯一约束上的既有 `models.W036` 警告。 @@ -75,11 +75,11 @@ 任务状态以 [`06-tasks.md`](06-tasks.md) 为准,历史执行记录见 [`../progress.md`](../progress.md)。 - 已完成:T-001 初始化 Django + DRF 项目骨架;T-002 建立 apps 目录、自定义 User 与配置;T-003 接通 django-admin 与最小测试;T-004 Phase 0 骨架审核修补;T-101 Provider 适配器层 + 移植 cmbot 调用;T-102 AiModel + ModelAlias 模型 + 别名解析;T-103 配置变更审计;T-104 跑通一次录制标题生成;T-105 Phase 1 AI 层审核修补;T-201 User / UserWallet / ApiKey / PointsLedger / CallRecord 模型;T-202 PricingRule / ExchangeRate 模型 + 计费计算;T-203 并发安全扣点 / 退点;T-204 Phase 2 计费核心审核加固;T-301 API Key 鉴权;T-302 生成标题 / 图片接口;T-303 余额查询接口;T-304 充值回调;T-305 扫码充值下单 + 轮询;T-306 Phase 3 对外 API 安全加固;T-501 注册 / 登录(allauth);T-502 API Key 自助管理页;T-503 个人中心 / 记录页;T-504 充值页(扫码 + 轮询到账);T-505 Phase 4 用户端审核优化;T-401 运营后台完善;T-402 完整验收 MVP;T-403 部署 / 运行文档;T-601 可用别名发现;T-602 django-admin 中文化(第 1-3 层);T-603 django-admin 中文化(第 4 层·字段级);T-604 中文敏感词本地过滤;T-605 免邮箱验证策略落地;T-606 公开首页 + 客户端下载入口;T-607 桌面端最新版本检查接口;T-608 新用户注册赠送试用点数(当前 10 点);T-609 桌面端版本检查接口增加强制更新标记;T-610 首页导入模板下载入口;T-611 用户端品牌名统一为虾皮圈;T-612 生图同步接口止血(上游硬截止 + 长请求池校准);T-613 抽生成核心 service(计费+审核+上游共享 core);T-614 生图异步任务化接口(提交+轮询,新增不动旧接口);T-615 旧同步生图接口用量遥测 + 弃用口径;T-616 生图失败自动重试 2 次;T-617 桌面端版本检查接口增加文件大小字段;T-618 客户端发布版本后台必填文件校验元数据。 -- 已完成补充:T-619 多张图片理解并返回文字;T-620 图生图支持单图 / 多图主图与参考图;T-622 图片生成任务后台图片缩略预览;T-623 图片生成任务单图 / 多图筛选;T-624 蝦皮圈设备登记与会话观测。 +- 已完成补充:T-619 多张图片理解并返回文字;T-620 图生图支持单图 / 多图主图与参考图;T-622 图片生成任务后台图片缩略预览;T-623 图片生成任务单图 / 多图筛选;T-624 蝦皮圈设备登记与会话观测;T-625 蝦皮圈设备使用关联与迁移观测。 - 正在进行:无。 -- 待开始:T-625 蝦皮圈设备使用关联与迁移观测;其后依赖链为 T-626 软件套餐/权益/席位基础、T-627 存量迁移与设备凭证、T-628 蝦皮圈专属授权影子校验、T-629 套餐购买/续订订单与权益入账。T-621 注册赠点运营后台配置继续留在 Backlog。真实支付回调到账闭环、客户端发布、生产多图理解模型配置和线上旧同步接口用量观察仍可继续拆任务。 +- 待开始:T-626 软件套餐/权益/席位基础;其后依赖链为 T-627 存量迁移与设备凭证、T-628 蝦皮圈专属授权影子校验、T-629 套餐购买/续订订单与权益入账。T-621 注册赠点运营后台配置继续留在 Backlog。真实支付回调到账闭环、客户端发布、生产多图理解模型配置和线上旧同步接口用量观察仍可继续拆任务。 - 当前 blocker:支付商户真实密钥/证书与生产 SDK 依赖仍待提供;微信回调到账闭环仍需真实支付验收;真实 AI 标题生成已在线上跑通,图片生成慢 / 504 / 客户端超时风险已拆为 T-612~T-616 并完成工程侧处理。 -- 下一个可领取任务:T-625 蝦皮圈设备使用关联与迁移观测。T-621 保留在 Backlog,具体范围见 [`06-tasks.md`](06-tasks.md)。 +- 下一个可领取任务:T-626 软件套餐、权益与设备席位基础。T-621 保留在 Backlog,具体范围见 [`06-tasks.md`](06-tasks.md)。 ## 当前可运行内容 @@ -153,7 +153,7 @@ T-619 已落地同步多图理解:调用方提交有序 `images` 列表,服 1. 读仓库级 `AGENTS.md` / `CLAUDE.md`。 2. 读 `docs/00-ai-start-here.md`。 3. 读 `docs/05-coding-rules.md`(尤其第 8 节资金安全)。 -4. 在 `docs/06-tasks.md` 领取第一个 `TODO` 且依赖均 `DONE` 的任务;当前应先做 T-624。 +4. 在 `docs/06-tasks.md` 领取第一个 `TODO` 且依赖均 `DONE` 的任务;当前应先做 T-626。 5. T-624~T-629 按设备观测 → 套餐权益 → 存量迁移 → 专属授权影子校验 → 订阅订单顺序执行;T-629 前须保留并验证真实支付回调到账闭环。 ## 维护规则 diff --git a/docs/routes.md b/docs/routes.md index ecee092..5431912 100644 --- a/docs/routes.md +++ b/docs/routes.md @@ -46,7 +46,7 @@ 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 的鉴权规则。 +T-624/T-625 已新增设备登记 / 心跳及调用关联:登记接口继续只认 API Key,返回仅本次展示的短期设备会话;心跳接口只认 `X-Device-Session`。四个生成提交路由可选带同一会话,服务端仅关联 `CallRecord` 与白名单遥测;无头旧客户端与异步任务轮询保持原鉴权、响应和计费语义,尚未进入订阅授权拦截。 ## 运营后台(django-admin,`/admin/`) @@ -61,7 +61,7 @@ T-624 已新增设备登记 / 心跳路由:登记接口继续只认 API Key, | 汇率 | ExchangeRate | 配置金额→点数汇率 | | 充值订单 | RechargeOrder | 检索订单、查看状态/金额/入账点数/支付流水号(只读) | | 点数流水 | PointsLedger | 按账号、类型、时间检索充值/消费/调整/冲正流水(只读,对账用) | -| 调用记录 | CallRecord | 按账号、API Key、时间、状态检索调用,查看别名/实际模型/消耗/错误(只读) | +| 调用记录 | CallRecord | 按账号、API Key、设备、时间、状态检索调用,查看别名/实际模型/消耗/错误(只读) | | 图片生成任务 | ImageGenerationTask | 查看异步生图任务状态、公开 task_id、关联调用记录、worker 租约、结果 URL 与错误信息(只读排障) | | 模型配置 | AiModel | 维护上游模型(url/model/api_key[加密脱敏]/api_type/capabilities/timeout) | | 能力别名 | ModelAlias | 维护对外别名 → 具体模型的映射;换供应商在此改指向 | @@ -87,7 +87,7 @@ T-624 已新增设备登记 / 心跳路由:登记接口继续只认 API Key, ### 流水 / 调用记录 -- 只读列表,支持按账号、时间范围、类型/状态筛选与搜索。 +- 只读列表,支持按账号、API Key、设备、时间范围、类型/状态筛选与搜索;设备关联为空表示存量无头调用,不代表调用失败。 - 不允许在后台修改或删除流水与调用记录(账目不可篡改)。 ### 列表与检索 diff --git a/progress.md b/progress.md index 46ded5d..3719de9 100644 --- a/progress.md +++ b/progress.md @@ -2058,3 +2058,11 @@ - 文档:同步 `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,将有效设备会话关联到调用记录和遥测,但继续保持只观测、不阻断。 + +## 2026-07-21 完成:T-625 蝦皮圈设备使用关联与迁移观测 + +- 实现:`CallRecord` 新增可空 `client_device` 外键和 `(client_device, created_at)` 索引,迁移为 `billing.0010_callrecord_client_device_and_more`。标题、图片、异步图片提交和图片理解在 API Key 鉴权后可选解析 `X-Device-Session`,仅把服务端验证的设备传给既有预扣服务写入调用记录;异步 worker 不重复解析会话,既有任务轮询保持 API Key 所属用户校验。 +- 安全与兼容:未携带设备会话的调用保持既有响应、账务和任务语义。显式提供伪造或过期会话返回 `401 device_session_invalid`,吊销设备或跨账号会话返回 `403`,均发生在审核/预扣/上游调用前。`generation_route_usage` 只新增 `product_code`、内部 `client_device_id` 和 `device_session_present`,不记录原始设备 ID、公钥、令牌、API Key、prompt 或图片。 +- 后台与文档:`CallRecordAdmin` 增加只读设备展示与筛选;同步 `api.md`、`routes.md`、`04-architecture.md`、`current-state.md` 和任务看板,明确本期只做关联观测,不实施订阅授权拦截。 +- 验证:本地已应用 billing 迁移;新增 4 条测试覆盖四个生成入口关联、无头兼容、伪造/跨账号会话拒绝、遥测脱敏和异步轮询。`py -3.12 manage.py test` 定向新增 4 条通过;随后 licensing 加关键标题/vision/同步图片/异步提交/遥测回归共 14 条通过;`manage.py check` 和 `makemigrations --check --dry-run` 通过。测试仅保留既有 allauth MySQL 条件唯一约束 `models.W036` 警告。 +- 下一步:领取 T-626,建立套餐、权益、席位和审计的基础模型与后台,不接入生成授权或购买流程。