feat: associate generation calls with devices

This commit is contained in:
QiuSW
2026-07-21 09:40:40 +08:00
parent 69f4517958
commit 11c0d63ac8
16 changed files with 398 additions and 19 deletions
+10 -2
View File
@@ -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,
+2
View File
@@ -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,
+9
View File
@@ -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,
+138
View File
@@ -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": "生成异步图片遥测测试",
+132
View File
@@ -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", ""),
)
+3 -2
View File
@@ -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)
@@ -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'),
),
]
+9
View File
@@ -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",)),
]
+2
View File
@@ -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(),
+28
View File
@@ -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
+6 -4
View File
@@ -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 <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-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`。
## 四、计费时序(核心,务必照此实现)
+1 -1
View File
@@ -103,7 +103,7 @@
| T-622 | 图片生成任务后台图片缩略预览 | T-614, T-620 | 已完成:django-admin 的 `ImageGenerationTask` 详情页新增输入图 / 结果图缩略画廊;输入按 `ordinal` 标为主图和参考图,兼容历史 `input_image` 单图;成功任务以 `result_url` 展示生成结果。双击缩略图弹出后台 `<dialog>` 大图预览,支持关闭按钮、遮罩和 `Escape`;图片加载失败显示降级提示。未改 `CallRecord`、对外 API、计费、worker、迁移、媒体访问策略或列表页查询。已通过 4 条目标 admin 测试、`check`、迁移一致性、静态资源发现和 `init.ps1`。 | DONE |
| T-623 | 图片生成任务单图 / 多图筛选 | T-614, T-620 | 已完成:`ImageGenerationTaskAdmin` 的列表右侧新增“输入图片类型”筛选,提供“单图生图”和“多图生图”。数据库聚合计数识别 1 条子输入的单图、2 条及以上的多图;无子输入且历史 `input_image` 非空的任务兼容归单图;无输入图任务不归类。筛选可与既有状态 / 日期过滤叠加,结果不重复;未改模型、迁移、任务状态、计费、API、worker、媒体文件或详情页缩略图。已通过 6 条目标 admin 测试、`check`、迁移一致性和 `init.ps1`。 | DONE |
| T-624 | 蝦皮圈设备登记与会话观测 | T-301, T-401 | 已完成:新增 `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 |
+15 -2
View File
@@ -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_KEY>`;生成、余额等外部 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_<device_session_token>
成功响应返回当前设备公开摘要、会话到期时间和 `activity_updated`。默认同一设备至少间隔 24 小时才写入一次 `last_seen_at`,因此频繁心跳可能返回 `activity_updated=false`,这是正常行为。缺失、无效或过期会话返回 `401 device_session_invalid`;已吊销设备返回 `403 device_revoked`。该接口不扣点、不创建调用记录,也不刷新过期会话;客户端应重新调用登记接口获取新令牌。
### 生成接口的可选设备会话
以下生成提交路由可在既有 `Authorization: Bearer <API_KEY>` 外,额外携带同账号登记得到的设备会话:`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_<device_session_token>
```
会话有效时,服务端将对应 `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_<device_session_token>
```http
X-Client-Version: 0.1.1
X-Device-Session: dvs_cmhub_<device_session_token>
```
该字段仅用于 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_<device_session_token>
Content-Type: application/json
```
+5 -5
View File
@@ -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 前须保留并验证真实支付回调到账闭环。
## 维护规则
+3 -3
View File
@@ -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、设备、时间范围、类型/状态筛选与搜索;设备关联为空表示存量无头调用,不代表调用失败。
- 不允许在后台修改或删除流水与调用记录(账目不可篡改)。
### 列表与检索
+8
View File
@@ -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,建立套餐、权益、席位和审计的基础模型与后台,不接入生成授权或购买流程。