feat: add image route usage telemetry
This commit is contained in:
@@ -25,7 +25,7 @@ Python 3.12 / Django 5.2 LTS + DRF / django-admin / 用户端 Django 模板 SSR
|
||||
|
||||
## 当前状态
|
||||
|
||||
Phase 2 计费核心已完成,Phase 3 对外 API 与充值已完成到 T-306,Phase 4 用户端已完成 T-501~T-505,Phase 5 已完成 T-401 运营后台完善、T-402 MVP 完整验收与 T-403 部署 / 运行文档,Phase 6 已完成 T-601 可用别名发现、T-602/T-603 django-admin 中文化、T-604 中文敏感词本地过滤、T-605 免邮箱验证策略落地、T-606 公开首页 + 客户端下载入口、T-607 桌面端最新版本检查接口、T-608 新用户注册赠送 100 点试用点数、T-609 桌面端版本强制更新标记、T-610 首页导入模板下载入口、T-611 用户端品牌名统一为“虾皮圈”、T-612 生图同步接口止血、T-613 抽生成核心 service 与 T-614 生图异步任务化接口。用户可通过公开首页进入注册、登录、下载客户端和下载导入模板;新用户注册后经计费层自动获得 100 点并写注册赠点流水;登录后可扫码充值并轮询到账,生成 / 删除(吊销)API Key,查看余额、充值总额、分页充值记录、分页点数记录与可用模型;桌面端可匿名请求最新客户端版本 JSON,并读取 `release.force_update` 判断是否必须升级;新版桌面端可用异步生图提交 / 轮询接口,旧同步生图接口继续兼容;运营可在 django-admin 检索用户、钱包、API Key、计费规则、汇率、充值订单、点数流水、注册赠点记录、调用记录、图片生成任务、客户端发布版本和导入模板,并通过计费层带原因手工调点。下一步为 T-615 旧同步生图接口遥测;生产侧仍需补真实支付回调到账闭环。详见 [`docs/current-state.md`](docs/current-state.md)。
|
||||
Phase 2 计费核心已完成,Phase 3 对外 API 与充值已完成到 T-306,Phase 4 用户端已完成 T-501~T-505,Phase 5 已完成 T-401 运营后台完善、T-402 MVP 完整验收与 T-403 部署 / 运行文档,Phase 6 已完成 T-601 可用别名发现、T-602/T-603 django-admin 中文化、T-604 中文敏感词本地过滤、T-605 免邮箱验证策略落地、T-606 公开首页 + 客户端下载入口、T-607 桌面端最新版本检查接口、T-608 新用户注册赠送 100 点试用点数、T-609 桌面端版本强制更新标记、T-610 首页导入模板下载入口、T-611 用户端品牌名统一为“虾皮圈”、T-612 生图同步接口止血、T-613 抽生成核心 service、T-614 生图异步任务化接口与 T-615 旧同步生图接口遥测 / 弃用口径。用户可通过公开首页进入注册、登录、下载客户端和下载导入模板;新用户注册后经计费层自动获得 100 点并写注册赠点流水;登录后可扫码充值并轮询到账,生成 / 删除(吊销)API Key,查看余额、充值总额、分页充值记录、分页点数记录与可用模型;桌面端可匿名请求最新客户端版本 JSON,并读取 `release.force_update` 判断是否必须升级;新版桌面端可用异步生图提交 / 轮询接口,旧同步生图接口继续兼容并写结构化用量日志;运营可在 django-admin 检索用户、钱包、API Key、计费规则、汇率、充值订单、点数流水、注册赠点记录、调用记录、图片生成任务、客户端发布版本和导入模板,并通过计费层带原因手工调点。生产侧仍需补真实支付回调到账闭环,并按日志观察旧同步生图接口迁移进度。详见 [`docs/current-state.md`](docs/current-state.md)。
|
||||
|
||||
> ⚠️ 涉及资金/点数。改动充值、扣费、退款、对账相关代码前,先读 [`docs/05-coding-rules.md`](docs/05-coding-rules.md) 第 8 节与 [`docs/04-architecture.md`](docs/04-architecture.md) 第四节计费时序。
|
||||
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
from time import perf_counter
|
||||
from typing import Any, Mapping
|
||||
|
||||
|
||||
logger = logging.getLogger("cmhub.api.generation_usage")
|
||||
|
||||
EVENT_NAME = "generation_route_usage"
|
||||
MAX_CLIENT_VERSION_LENGTH = 64
|
||||
MAX_ALIAS_LENGTH = 64
|
||||
|
||||
|
||||
def telemetry_start_time() -> float:
|
||||
return perf_counter()
|
||||
|
||||
|
||||
def telemetry_elapsed_ms(started: float) -> int:
|
||||
return max(0, int((perf_counter() - started) * 1000))
|
||||
|
||||
|
||||
def request_alias(data: Mapping[str, Any]) -> str:
|
||||
try:
|
||||
return normalize_text(data.get("model", ""), MAX_ALIAS_LENGTH)
|
||||
except AttributeError:
|
||||
return ""
|
||||
|
||||
|
||||
def log_generation_route_usage(
|
||||
*,
|
||||
route_type: str,
|
||||
request,
|
||||
alias: str,
|
||||
status: str,
|
||||
latency_ms: int,
|
||||
error_code: str = "",
|
||||
http_status: int | None = None,
|
||||
) -> dict[str, Any]:
|
||||
event = build_generation_route_usage_event(
|
||||
route_type=route_type,
|
||||
request=request,
|
||||
alias=alias,
|
||||
status=status,
|
||||
latency_ms=latency_ms,
|
||||
error_code=error_code,
|
||||
http_status=http_status,
|
||||
)
|
||||
logger.info(
|
||||
"%s %s",
|
||||
EVENT_NAME,
|
||||
json.dumps(event, ensure_ascii=False, sort_keys=True),
|
||||
extra={"generation_route_usage": event},
|
||||
)
|
||||
return event
|
||||
|
||||
|
||||
def build_generation_route_usage_event(
|
||||
*,
|
||||
route_type: str,
|
||||
request,
|
||||
alias: str,
|
||||
status: str,
|
||||
latency_ms: int,
|
||||
error_code: str = "",
|
||||
http_status: int | None = None,
|
||||
) -> dict[str, Any]:
|
||||
api_key = getattr(request, "auth", None)
|
||||
user = getattr(request, "user", 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),
|
||||
"client_version": normalize_text(
|
||||
request.headers.get("X-Client-Version", ""),
|
||||
MAX_CLIENT_VERSION_LENGTH,
|
||||
),
|
||||
"alias": normalize_text(alias, MAX_ALIAS_LENGTH),
|
||||
"status": normalize_text(status, 32),
|
||||
"latency_ms": max(0, int(latency_ms)),
|
||||
"error_code": normalize_text(error_code, 64),
|
||||
"http_status": http_status,
|
||||
}
|
||||
|
||||
|
||||
def normalize_text(value: Any, max_length: int) -> str:
|
||||
return str(value or "").strip()[:max_length]
|
||||
@@ -1168,6 +1168,43 @@ class GenerateApiTests(TestCase):
|
||||
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)
|
||||
|
||||
def telemetry_event_from_logs(self, captured):
|
||||
events = [
|
||||
getattr(record, "generation_route_usage", None)
|
||||
for record in captured.records
|
||||
if getattr(record, "generation_route_usage", None)
|
||||
]
|
||||
self.assertEqual(len(events), 1)
|
||||
return events[0]
|
||||
|
||||
def assert_generation_telemetry_is_safe(self, event, *, payload=None):
|
||||
self.assertEqual(
|
||||
set(event),
|
||||
{
|
||||
"event",
|
||||
"route_type",
|
||||
"api_key_id",
|
||||
"api_key_prefix",
|
||||
"user_id",
|
||||
"client_version",
|
||||
"alias",
|
||||
"status",
|
||||
"latency_ms",
|
||||
"error_code",
|
||||
"http_status",
|
||||
},
|
||||
)
|
||||
serialized = json.dumps(event, ensure_ascii=False)
|
||||
self.assertNotIn(self.raw_key, serialized)
|
||||
self.assertNotIn("SECRET_RAW", serialized)
|
||||
self.assertNotIn("prompt", serialized)
|
||||
self.assertNotIn("image_base64", serialized)
|
||||
if payload:
|
||||
self.assertNotIn(str(payload.get("prompt") or ""), serialized)
|
||||
encoded_image = str(payload.get("image_base64") or "")
|
||||
if encoded_image:
|
||||
self.assertNotIn(encoded_image, serialized)
|
||||
|
||||
def assert_generation_not_charged(self):
|
||||
self.wallet.refresh_from_db()
|
||||
self.assertEqual(self.wallet.points_balance, 100)
|
||||
@@ -1288,6 +1325,92 @@ class GenerateApiTests(TestCase):
|
||||
self.assertEqual(call.result_summary, "image_bytes=21")
|
||||
self.assertNotIn("SECRET_RAW", call.result_ref + call.result_summary)
|
||||
|
||||
def test_sync_image_usage_telemetry_logs_safe_client_version_and_key_identity(self):
|
||||
encoded = base64.b64encode(b"input-image").decode("ascii")
|
||||
payload = {
|
||||
"prompt": "生成图片遥测测试",
|
||||
"model": self.image_alias,
|
||||
"image_base64": f"data:image/png;base64,{encoded}",
|
||||
"resolution": "1K",
|
||||
"aspect_ratio": "1:1",
|
||||
}
|
||||
|
||||
with self.assertLogs("cmhub.api.generation_usage", level="INFO") as captured:
|
||||
response = self.post_with_provider(
|
||||
"/api/v1/generate/image",
|
||||
payload,
|
||||
HTTP_X_CLIENT_VERSION="0.1.1",
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
event = self.telemetry_event_from_logs(captured)
|
||||
self.assertEqual(event["event"], "generation_route_usage")
|
||||
self.assertEqual(event["route_type"], "sync")
|
||||
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["client_version"], "0.1.1")
|
||||
self.assertEqual(event["alias"], self.image_alias)
|
||||
self.assertEqual(event["status"], "success")
|
||||
self.assertEqual(event["error_code"], "")
|
||||
self.assertEqual(event["http_status"], 200)
|
||||
self.assertIsInstance(event["latency_ms"], int)
|
||||
self.assertGreaterEqual(event["latency_ms"], 0)
|
||||
self.assert_generation_telemetry_is_safe(event, payload=payload)
|
||||
|
||||
def test_async_image_submit_usage_telemetry_logs_safe_success_event(self):
|
||||
payload = {
|
||||
"prompt": "生成异步图片遥测测试",
|
||||
"model": self.image_alias,
|
||||
"resolution": "1K",
|
||||
}
|
||||
|
||||
with self.assertLogs("cmhub.api.generation_usage", level="INFO") as captured:
|
||||
response = self.post_with_provider(
|
||||
"/api/v1/generate/image/tasks",
|
||||
payload,
|
||||
HTTP_X_CLIENT_VERSION="0.1.2",
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 202)
|
||||
event = self.telemetry_event_from_logs(captured)
|
||||
self.assertEqual(event["route_type"], "async")
|
||||
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["client_version"], "0.1.2")
|
||||
self.assertEqual(event["alias"], self.image_alias)
|
||||
self.assertEqual(event["status"], "success")
|
||||
self.assertEqual(event["error_code"], "")
|
||||
self.assertEqual(event["http_status"], 202)
|
||||
self.assert_generation_telemetry_is_safe(event, payload=payload)
|
||||
|
||||
def test_async_image_submit_usage_telemetry_logs_error_code_without_sensitive_data(self):
|
||||
self.wallet.points_balance = 1
|
||||
self.wallet.save(update_fields=("points_balance", "updated_at"))
|
||||
payload = {
|
||||
"prompt": "余额不足遥测测试",
|
||||
"model": self.image_alias,
|
||||
"resolution": "1K",
|
||||
}
|
||||
|
||||
with self.assertLogs("cmhub.api.generation_usage", level="INFO") as captured:
|
||||
response = self.post_with_provider(
|
||||
"/api/v1/generate/image/tasks",
|
||||
payload,
|
||||
HTTP_X_CLIENT_VERSION="0.1.3",
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 402)
|
||||
event = self.telemetry_event_from_logs(captured)
|
||||
self.assertEqual(event["route_type"], "async")
|
||||
self.assertEqual(event["status"], "error")
|
||||
self.assertEqual(event["error_code"], "insufficient_points")
|
||||
self.assertEqual(event["http_status"], 402)
|
||||
self.assertEqual(event["client_version"], "0.1.3")
|
||||
self.assertEqual(event["alias"], self.image_alias)
|
||||
self.assert_generation_telemetry_is_safe(event, payload=payload)
|
||||
|
||||
@override_settings(
|
||||
MODERATION_ENABLED=True,
|
||||
MODERATION_PROVIDER="keyword",
|
||||
|
||||
@@ -30,6 +30,12 @@ from apps.api.serializers import (
|
||||
RechargeCreateRequestSerializer,
|
||||
RechargeStatusRequestSerializer,
|
||||
)
|
||||
from apps.api.telemetry import (
|
||||
log_generation_route_usage,
|
||||
request_alias,
|
||||
telemetry_elapsed_ms,
|
||||
telemetry_start_time,
|
||||
)
|
||||
from apps.api.throttles import GenerateRateThrottle, throttle_api_auth_failure
|
||||
from apps.ai.catalog import get_public_model_catalog
|
||||
from apps.billing.models import RechargeOrder
|
||||
@@ -95,12 +101,24 @@ class GenerateImageView(ExternalApiView):
|
||||
throttle_classes = (GenerateRateThrottle,)
|
||||
|
||||
def post(self, request):
|
||||
started = telemetry_start_time()
|
||||
alias = request_alias(request.data)
|
||||
serializer = GenerateImageRequestSerializer(data=request.data)
|
||||
if not serializer.is_valid():
|
||||
log_generation_route_usage(
|
||||
route_type="sync",
|
||||
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,
|
||||
)
|
||||
alias = serializer.validated_data.get("model") or alias
|
||||
try:
|
||||
data = generate_image_response(
|
||||
user=request.user,
|
||||
@@ -109,7 +127,24 @@ class GenerateImageView(ExternalApiView):
|
||||
image_url_builder=request.build_absolute_uri,
|
||||
)
|
||||
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)
|
||||
log_generation_route_usage(
|
||||
route_type="sync",
|
||||
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)
|
||||
|
||||
|
||||
@@ -117,12 +152,24 @@ class GenerateImageTaskSubmitView(ExternalApiView):
|
||||
throttle_classes = (GenerateRateThrottle,)
|
||||
|
||||
def post(self, request):
|
||||
started = telemetry_start_time()
|
||||
alias = request_alias(request.data)
|
||||
serializer = GenerateImageRequestSerializer(data=request.data)
|
||||
if not serializer.is_valid():
|
||||
log_generation_route_usage(
|
||||
route_type="async",
|
||||
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,
|
||||
)
|
||||
alias = serializer.validated_data.get("model") or alias
|
||||
try:
|
||||
task, _created = create_image_generation_task(
|
||||
user=request.user,
|
||||
@@ -131,7 +178,24 @@ class GenerateImageTaskSubmitView(ExternalApiView):
|
||||
idempotency_key=request.headers.get("Idempotency-Key", ""),
|
||||
)
|
||||
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)
|
||||
log_generation_route_usage(
|
||||
route_type="async",
|
||||
request=request,
|
||||
alias=task.call_record.alias,
|
||||
status="success",
|
||||
latency_ms=telemetry_elapsed_ms(started),
|
||||
http_status=status.HTTP_202_ACCEPTED,
|
||||
)
|
||||
return Response(task_submit_response(task), status=status.HTTP_202_ACCEPTED)
|
||||
|
||||
|
||||
|
||||
@@ -38,7 +38,7 @@
|
||||
|
||||
## 当前阶段
|
||||
|
||||
当前项目处于:**Phase 6 增强任务推进期**。Phase 2 计费核心已完成到 T-204;Phase 3 已完成 T-301 API Key 鉴权、T-302 生成标题 / 图片接口、T-303 余额查询接口、T-304 充值回调、T-305 扫码充值下单 + 轮询与 T-306 对外 API 安全加固;Phase 4 已完成 T-501 注册 / 登录(allauth)、T-502 API Key 自助管理页、T-503 个人中心 / 记录页、T-504 充值页与 T-505 用户端审核优化;Phase 5 已完成 T-401 运营后台完善、T-402 MVP 完整验收与 T-403 部署 / 运行文档;Phase 6 已完成 T-601「可用别名发现」、T-602「django-admin 中文化第 1-3 层」、T-603「django-admin 字段级中文化」、T-604「中文敏感词本地过滤」、T-605「免邮箱验证策略落地」、T-606「公开首页 + 客户端下载入口」、T-607「桌面端最新版本检查接口」、T-608「新用户注册赠送 100 点试用点数」、T-609「桌面端版本检查接口增加强制更新标记」、T-610「首页导入模板下载入口」、T-611「用户端品牌名统一为虾皮圈」、T-612「生图同步接口止血」、T-613「抽生成核心 service」与 T-614「生图异步任务化接口」。生图慢 / 504 / 客户端超时已拆为 T-612~T-615:T-612 已先做同步接口上游硬截止止血,T-613 已抽共享生成 core,T-614 已新增异步提交 / 轮询接口与 DB worker;下一步 T-615 观察旧同步接口用量并形成弃用口径。生产侧仍需补真实支付回调到账闭环;邮件服务仅用于后续密码找回/通知等邮件能力,不阻塞注册登录。
|
||||
当前项目处于:**Phase 6 增强任务推进期**。Phase 2 计费核心已完成到 T-204;Phase 3 已完成 T-301 API Key 鉴权、T-302 生成标题 / 图片接口、T-303 余额查询接口、T-304 充值回调、T-305 扫码充值下单 + 轮询与 T-306 对外 API 安全加固;Phase 4 已完成 T-501 注册 / 登录(allauth)、T-502 API Key 自助管理页、T-503 个人中心 / 记录页、T-504 充值页与 T-505 用户端审核优化;Phase 5 已完成 T-401 运营后台完善、T-402 MVP 完整验收与 T-403 部署 / 运行文档;Phase 6 已完成 T-601「可用别名发现」、T-602「django-admin 中文化第 1-3 层」、T-603「django-admin 字段级中文化」、T-604「中文敏感词本地过滤」、T-605「免邮箱验证策略落地」、T-606「公开首页 + 客户端下载入口」、T-607「桌面端最新版本检查接口」、T-608「新用户注册赠送 100 点试用点数」、T-609「桌面端版本检查接口增加强制更新标记」、T-610「首页导入模板下载入口」、T-611「用户端品牌名统一为虾皮圈」、T-612「生图同步接口止血」、T-613「抽生成核心 service」、T-614「生图异步任务化接口」与 T-615「旧同步生图接口遥测 / 弃用口径」。生图慢 / 504 / 客户端超时已拆为 T-612~T-615 并完成:T-612 已做同步接口上游硬截止止血,T-613 已抽共享生成 core,T-614 已新增异步提交 / 轮询接口与 DB worker,T-615 已给旧同步 / 新异步提交路径接入结构化日志并形成旧同步接口退出条件。生产侧仍需补真实支付回调到账闭环;邮件服务仅用于后续密码找回/通知等邮件能力,不阻塞注册登录。
|
||||
|
||||
优先路径:
|
||||
|
||||
@@ -48,7 +48,7 @@
|
||||
4. Phase 3:对外 API 与充值 —— T-301 Key 鉴权、T-302 生成接口、T-303 余额查询、T-304 充值回调、T-305 扫码下单与轮询、T-306 安全加固已完成。
|
||||
5. Phase 4:用户端(Django 模板 SSR)—— T-501 注册登录、T-502 API Key 管理、T-503 个人中心 / 记录页、T-504 充值页与 T-505 用户端审核优化已完成。
|
||||
6. Phase 5:后台与发布 —— T-401 运营后台完善、T-402 完整验收 MVP、T-403 部署 / 运行文档已完成;计划内 MVP 任务已收尾。
|
||||
7. Phase 6:增强(MVP 后)—— T-601 可用别名发现已完成,实现 `/api/v1/models` 与 portal 只读「可用模型」页;T-602 已完成 django-admin 分组/表名中文化;T-603 已完成字段级中文标签代码与 no-op 迁移并人工确认 admin 字段中文化;T-604 已完成中文敏感词本地过滤;T-605 已完成免邮箱验证策略落地;T-606 已完成公开首页 + 客户端下载入口;T-607 已完成桌面端最新版本检查接口;T-608 已完成新用户注册赠送 100 点试用点数;T-609 已完成桌面端版本检查接口增加强制更新标记;T-610 已完成首页导入模板下载入口;T-611 已完成用户端品牌名统一为虾皮圈;T-612 已完成生图同步接口止血;T-613 已完成抽生成核心 service;T-614 已完成生图异步任务化接口;当前下一个可领取任务为 T-615。
|
||||
7. Phase 6:增强(MVP 后)—— T-601 可用别名发现已完成,实现 `/api/v1/models` 与 portal 只读「可用模型」页;T-602 已完成 django-admin 分组/表名中文化;T-603 已完成字段级中文标签代码与 no-op 迁移并人工确认 admin 字段中文化;T-604 已完成中文敏感词本地过滤;T-605 已完成免邮箱验证策略落地;T-606 已完成公开首页 + 客户端下载入口;T-607 已完成桌面端最新版本检查接口;T-608 已完成新用户注册赠送 100 点试用点数;T-609 已完成桌面端版本检查接口增加强制更新标记;T-610 已完成首页导入模板下载入口;T-611 已完成用户端品牌名统一为虾皮圈;T-612 已完成生图同步接口止血;T-613 已完成抽生成核心 service;T-614 已完成生图异步任务化接口;T-615 已完成旧同步生图接口遥测与弃用口径;当前没有未领取的编号任务。
|
||||
|
||||
## 领取任务规则
|
||||
|
||||
|
||||
@@ -43,6 +43,8 @@ T-302 已实现 `/api/v1/generate/title` 与 `/api/v1/generate/image`:API 层
|
||||
|
||||
T-614 已实现 `/api/v1/generate/image/tasks` 与 `/api/v1/generate/image/tasks/{task_id}`:提交接口同步审核 prompt、解析图片输入和预扣点,创建 `ImageGenerationTask(status=queued)` 后立即返回公开 UUID `task_id`;后台 worker 通过 `select_for_update(skip_locked)` 抢任务,复用 T-613 `execute_precharged_generation()` 对已预扣 `CallRecord` 调上游、保存结果、成功确认或失败退点。任务表记录 worker 租约、心跳和尝试次数;reaper 识别僵尸 `running` 任务后默认判失败并幂等退点,不默认重排队。异步 worker 没有 DRF request,结果 URL 由 `MEDIA_PUBLIC_BASE_URL` / `PUBLIC_BASE_URL` 生成。
|
||||
|
||||
T-615 已给旧同步生图接口和新异步提交接口接入 `cmhub.api.generation_usage` 结构化日志,事件名为 `generation_route_usage`。日志字段只包含 `route_type(sync/async)`、`api_key_id`、`api_key_prefix`、`user_id`、`client_version`、`alias`、`status`、`latency_ms`、`error_code`、`http_status` 等白名单信息;不得记录 API Key 明文、prompt 全文、`image_base64`、provider raw 或上游密钥。第一版用日志查询完成用量观察,不新增报表表结构;如后续要在 admin 做统计报表,需单独评估数据量、索引和保留周期。
|
||||
|
||||
T-303 已实现 `/api/v1/balance`:外部 API 继续只认 API Key,API 层调用 `apps.billing.services.get_balance_snapshot()` 读取当前钱包余额;测试覆盖余额响应与流水累加一致的场景。
|
||||
|
||||
T-304 已实现 `/api/v1/recharge/callback/wechat` 与 `/api/v1/recharge/callback/alipay`:两个回调端点均 `@csrf_exempt` 且不挂登录态;回调验签后调用 `apps.billing.services.apply_recharge_payment()`,按 `order_no` 锁定 `RechargeOrder` 幂等入账,金额或通道不一致不加点。缺真实商户配置时仅允许使用明确的 HMAC mock 模式联调,生产应切换 `PAYMENT_CALLBACK_MODE=sdk`。
|
||||
@@ -488,7 +490,7 @@ CREATE TABLE image_generation_task (
|
||||
1. **超时链路层层放大且对齐**(外层 ≥ 内层):桌面端读超时 ≥ Nginx `proxy_read_timeout` ≥ Gunicorn `--timeout` ≥ cmhub→中转站实际读取超时。T-612 后生图读取超时为 `min(AiModel.timeout_seconds 或分辨率默认值, AI_IMAGE_UPSTREAM_DEADLINE_SECONDS)`;默认硬截止约 180s,生产按真实图片 smoke 校准。三个默认值仍是雷:`AiModel.timeout_seconds`(`0` 表示按 Provider 分辨率默认值,而不是无限等待)、Gunicorn 默认 `30s`(会直接杀掉跑图片的 worker)、Nginx 默认 `60s`。
|
||||
2. **worker/线程数按峰值总并发预留**:同步下每个在飞请求占住一个 worker 整个生成周期(几十秒~分钟)。用 Gunicorn `gthread`(`--threads`)或多 sync worker,数量 ≥ 预期峰值总并发 + 余量,避免慢图片把快的生文接口和后台挤死。
|
||||
|
||||
适用边界:**旧客户端 / 小并发批量**(桌面端 `concurrency` 1~4)仍可继续同步使用,点数一致性简单。T-614 起新版客户端优先走异步任务接口:提交后拿 `task_id`,后台 worker 慢慢生成,客户端轮询状态和结果,适合图片耗时长、窗口重启后继续查询、以及避免 HTTP 长连接占满生成池的场景。旧同步接口的用量观察和弃用条件放到 T-615。
|
||||
适用边界:**旧客户端 / 小并发批量**(桌面端 `concurrency` 1~4)仍可继续同步使用,点数一致性简单。T-614 起新版客户端优先走异步任务接口:提交后拿 `task_id`,后台 worker 慢慢生成,客户端轮询状态和结果,适合图片耗时长、窗口重启后继续查询、以及避免 HTTP 长连接占满生成池的场景。T-615 起通过 `generation_route_usage` 日志观察旧同步路与新异步路的调用量、客户端版本、错误率和耗时;旧同步接口下线前必须先保持成功响应兼容,待新版客户端默认异步、旧路调用归零或连续一段时间低于阈值后,再宣布 deprecate 并单独立任务下线。
|
||||
|
||||
## 六、推荐开发顺序
|
||||
|
||||
|
||||
+1
-1
File diff suppressed because one or more lines are too long
+11
@@ -25,6 +25,8 @@ T-302 已实现生成接口基线:`POST /api/v1/generate/title` 与 `POST /api
|
||||
|
||||
T-614 已实现生图异步任务化:`POST /api/v1/generate/image/tasks` 返回 `202` 与公开 UUID `task_id`,`GET /api/v1/generate/image/tasks/{task_id}` 轮询任务状态和结果。任务表 `ImageGenerationTask` 关联 `user`、`api_key` 与已预扣 `CallRecord`,支持 `Idempotency-Key` 去重、payload 冲突 `409 idempotency_conflict`、租约 / 心跳 / reaper 处理僵尸 running 任务并幂等退点。异步结果 URL 由 `MEDIA_PUBLIC_BASE_URL` 或 `PUBLIC_BASE_URL` 生成,不透传上游临时链接。
|
||||
|
||||
T-615 已实现生图接口用量遥测:旧同步 `POST /api/v1/generate/image` 与新异步提交 `POST /api/v1/generate/image/tasks` 都会写 `cmhub.api.generation_usage` 结构化日志,事件名 `generation_route_usage`。调用方可选带 `X-Client-Version` 请求头,便于按客户端版本观察旧同步路迁移进度;该请求头不参与鉴权、计费或幂等判断。日志只记录 route/user/key 前缀/客户端版本/别名/状态/耗时/错误码等白名单字段,不记录 API Key 明文、prompt、图片 base64 或 provider raw。
|
||||
|
||||
T-303 已实现余额查询基线:`GET /api/v1/balance` 已接入 API Key 鉴权,返回当前 `UserWallet.points_balance`,并保留旧字段同时新增不含邮箱的 `account` 账号展示对象;测试覆盖响应余额与 `PointsLedger.points_delta` 累加值一致的账务场景,并确认 Web session 不能调用该外部接口。
|
||||
|
||||
T-601 已实现可用别名发现:`GET /api/v1/models` 已接入 API Key 鉴权,只返回当前 active 且具备对应能力的公开别名、能力、是否需要原图和点数单价;接口不调用 `AiModel.to_resolved_model()`,不解密 provider key,不返回底层 SKU、模型 URL、`api_key_encrypted`、`extra_body` 等内部配置,且成功请求不占用生成接口限流额度。
|
||||
@@ -172,6 +174,14 @@ GET /api/v1/client/releases/latest?platform=windows
|
||||
|
||||
生成图片(同步等待)。请求:
|
||||
|
||||
可选请求头:
|
||||
|
||||
```http
|
||||
X-Client-Version: 0.1.1
|
||||
```
|
||||
|
||||
该字段仅用于 T-615 的用量遥测,帮助服务端区分旧同步接口由哪些客户端版本调用;不影响响应结构。
|
||||
|
||||
```json
|
||||
{
|
||||
"prompt": "把这件衣服换成模特上身的穿搭图",
|
||||
@@ -208,6 +218,7 @@ GET /api/v1/client/releases/latest?platform=windows
|
||||
POST /api/v1/generate/image/tasks
|
||||
Authorization: Bearer sk_cmhub_xxx
|
||||
Idempotency-Key: desktop-job-20260708-0001
|
||||
X-Client-Version: 0.1.1
|
||||
Content-Type: application/json
|
||||
```
|
||||
|
||||
|
||||
+11
-9
File diff suppressed because one or more lines are too long
+44
-1
@@ -23,7 +23,7 @@
|
||||
当前约束:
|
||||
|
||||
- 使用系统 Python 3.12,不使用虚拟环境。
|
||||
- 图片生成仍是同步接口;真实图片生成耗时尚未在生产链路验证,**上线前必须跑一次真实图片 smoke 并记录耗时**。
|
||||
- 图片生成已提供旧同步接口和新异步提交 / 轮询接口;真实图片生成耗时仍需在生产链路记录,并用于校准旧同步超时和异步 worker 容量。
|
||||
- 真实支付需微信 / 支付宝商户密钥、证书、生产 SDK 与公网回调地址;未配置前只能用 `PAYMENT_CALLBACK_MODE=mock`。
|
||||
|
||||
## 二、系统准备
|
||||
@@ -307,6 +307,49 @@ WantedBy=multi-user.target
|
||||
- `cmhub-generate.service`:旧同步生成接口,保留给老客户端。
|
||||
- `cmhub-image-worker.service`:新异步任务真正调上游生成图片。
|
||||
|
||||
### 生图接口用量遥测与旧同步弃用
|
||||
|
||||
T-615 起,旧同步 `POST /api/v1/generate/image` 和新异步提交 `POST /api/v1/generate/image/tasks` 会向 `cmhub.api.generation_usage` 写结构化日志,日志消息以 `generation_route_usage` 开头,JSON 字段包含:
|
||||
|
||||
```json
|
||||
{
|
||||
"event": "generation_route_usage",
|
||||
"route_type": "sync",
|
||||
"api_key_id": 12,
|
||||
"api_key_prefix": "sk_cmhub_xxxxxx",
|
||||
"user_id": 34,
|
||||
"client_version": "0.1.1",
|
||||
"alias": "image-hd",
|
||||
"status": "success",
|
||||
"latency_ms": 123456,
|
||||
"error_code": "",
|
||||
"http_status": 200
|
||||
}
|
||||
```
|
||||
|
||||
安全边界:日志不得包含 API Key 明文、prompt 全文、`image_base64`、provider raw、上游密钥或图片内容。调用方建议在两个生图提交接口都带 `X-Client-Version`,便于按客户端版本观察迁移进度。
|
||||
|
||||
systemd 日志查询示例:
|
||||
|
||||
```bash
|
||||
journalctl -u cmhub-generate.service --since "24 hours ago" | grep generation_route_usage
|
||||
journalctl -u cmhub-web.service --since "24 hours ago" | grep generation_route_usage
|
||||
```
|
||||
|
||||
常用观察维度:
|
||||
|
||||
- 按 `route_type` 看旧同步 `sync` 与新异步 `async` 的调用占比。
|
||||
- 按 `client_version` 找仍在调用旧同步接口的客户端版本。
|
||||
- 按 `api_key_id` / `api_key_prefix` 找未迁移的接入账号。
|
||||
- 按 `status` / `error_code` / `latency_ms` 比较旧路和新路错误率、超时率和耗时。
|
||||
|
||||
旧同步接口退出条件:
|
||||
|
||||
1. 新版桌面端默认走异步提交 / 轮询接口。
|
||||
2. 连续观察一段生产窗口后,旧同步 `route_type=sync` 调用归零,或低于运营确认的阈值,且没有关键客户仍依赖旧路。
|
||||
3. 先在发布说明和接口文档中标记旧同步接口 deprecated。
|
||||
4. 单独立任务下线旧同步接口;下线前必须继续保持旧同步成功响应字段兼容。
|
||||
|
||||
## 八、宝塔 / Nginx 配置
|
||||
|
||||
宝塔中新建站点并绑定域名和 SSL 后,在站点 Nginx 配置中加入或调整:
|
||||
|
||||
@@ -108,8 +108,8 @@
|
||||
- Phase 5 已完成 T-401:运营后台可管理/检索用户、钱包、API Key(脱敏)、计费规则、汇率、充值订单、点数流水和调用记录;手工调点必须填写原因,并经计费层锁钱包、写 `adjust` 流水。
|
||||
- Phase 5 已完成 T-402:MVP P0 验收通过,注册/充值/API Key/调用/余额/记录/后台/别名映射均有测试证据,详见 `mvp-acceptance.md`。
|
||||
- Phase 5 已完成 T-403:已补部署 / 运行文档,明确宝塔/Nginx/Gunicorn、生产静态与媒体文件、共享缓存限流、图片同步超时、真实商户配置和上线检查;当前免邮箱验证,邮件服务仅作为后续密码找回/通知等邮件能力配置项,详见 `deployment.md`。
|
||||
- Phase 6 已完成 T-601 可用别名发现、T-602 后台表名/分组中文化、T-603 字段级中文化、T-604 中文敏感词本地过滤、T-605 免邮箱验证策略落地、T-606 公开首页 + 客户端下载入口、T-607 桌面端最新版本检查接口、T-608 新用户注册赠送 100 点试用点数与 T-609 桌面端版本强制更新标记;线上真实标题生成已恢复并验证扣点。
|
||||
- 下一步可继续补跑真实支付回调到账闭环、配置并发布客户端下载包,并按 T-615 观察旧同步生图接口用量与弃用条件。
|
||||
- Phase 6 已完成 T-601 可用别名发现、T-602 后台表名/分组中文化、T-603 字段级中文化、T-604 中文敏感词本地过滤、T-605 免邮箱验证策略落地、T-606 公开首页 + 客户端下载入口、T-607 桌面端最新版本检查接口、T-608 新用户注册赠送 100 点试用点数、T-609 桌面端版本强制更新标记、T-610 首页导入模板下载入口、T-611 用户端品牌名统一、T-612~T-615 生图异步化与旧同步接口遥测;线上真实标题生成已恢复并验证扣点。
|
||||
- 下一步可继续补跑真实支付回调到账闭环、配置并发布客户端下载包,并在生产日志中观察旧同步生图接口用量与弃用条件。
|
||||
|
||||
---
|
||||
*更多细节:愿景 `01-vision.md` | 需求与验收 `02-requirements.md` | 架构 `04-architecture.md` | 任务计划 `06-tasks.md`。*
|
||||
|
||||
@@ -40,7 +40,7 @@
|
||||
|
||||
## 进度
|
||||
|
||||
M1 骨架已完成;M2 已通过录制生成 smoke 验证 AI 调用链路;M3 计费、对外 API、充值闭环与上线前安全加固已完成到 T-306;M4 用户端已完成注册 / 登录、API Key 管理、个人中心 / 记录页、充值页和审核优化;M5 已完成 T-401 运营后台完善、T-402 MVP 完整验收与 T-403 部署 / 运行文档;M7 注册试用额度已完成,注册后经账本发放 100 点并留流水;M11 生图异步任务化已完成到 T-614,旧同步接口继续兼容。下一步是在 VPS 上按部署文档接真实支付回调闭环,并按 T-615 观察旧同步生图用量。里程碑:M1 骨架可跑 · M2 跑通生成 · M3 计费充值闭环 · M4 用户端可用 · M5 验收上线 · M7 试用额度安全落地 · M11 生图异步任务化。
|
||||
M1 骨架已完成;M2 已通过录制生成 smoke 验证 AI 调用链路;M3 计费、对外 API、充值闭环与上线前安全加固已完成到 T-306;M4 用户端已完成注册 / 登录、API Key 管理、个人中心 / 记录页、充值页和审核优化;M5 已完成 T-401 运营后台完善、T-402 MVP 完整验收与 T-403 部署 / 运行文档;M7 注册试用额度已完成,注册后经账本发放 100 点并留流水;M11 生图异步任务化已完成到 T-615,旧同步接口继续兼容并写结构化用量日志。下一步是在 VPS 上按部署文档接真实支付回调闭环、发布客户端,并观察旧同步生图用量。里程碑:M1 骨架可跑 · M2 跑通生成 · M3 计费充值闭环 · M4 用户端可用 · M5 验收上线 · M7 试用额度安全落地 · M11 生图异步任务化。
|
||||
|
||||
---
|
||||
*详见 `project-brief.md`(完整介绍)。*
|
||||
|
||||
+26
@@ -1734,3 +1734,29 @@
|
||||
- 本期不暴露 cancel 路由;后续如需取消,仅允许取消 queued 并单独拆任务。
|
||||
- `image_base64` 不进 DB 原文,提交阶段解码为输入文件引用;异步结果不透传上游临时链接,只返回 cmhub 托管 URL。
|
||||
- 下一步:领取 T-615,给旧同步生图接口和新异步路径补结构化遥测,并形成旧同步接口弃用条件。
|
||||
|
||||
## 2026-07-08 实施:T-615 旧同步生图接口用量遥测 + 弃用口径
|
||||
|
||||
- 状态:DONE。
|
||||
- 代码变更:
|
||||
- 新增 `apps/api/telemetry.py`,集中构造并写入 `cmhub.api.generation_usage` 结构化日志事件 `generation_route_usage`。
|
||||
- `apps/api/views.py`:旧同步 `POST /api/v1/generate/image` 与新异步提交 `POST /api/v1/generate/image/tasks` 在参数错误、业务错误和成功返回前写遥测日志;旧同步成功响应和异步提交响应契约不变。
|
||||
- `apps/api/tests.py`:新增 3 条目标测试,覆盖旧同步成功日志、异步提交成功日志、异步余额不足错误日志,并断言日志只含白名单字段,不包含完整 API Key、prompt、`image_base64` 或 provider raw。
|
||||
- 文档变更:
|
||||
- `docs/04-architecture.md`、`docs/api.md`、`docs/deployment.md`:同步遥测字段、`X-Client-Version` 请求头建议、日志查看方式和旧同步接口弃用条件。
|
||||
- `README.md`、`docs/00-ai-start-here.md`、`docs/current-state.md`、`docs/project-brief.md`、`docs/project-onepager.md`:同步 T-615 已完成和后续生产观察口径。
|
||||
- `docs/06-tasks.md`:T-615 标记为 DONE。
|
||||
- 验证:
|
||||
- `py -3.12 -m py_compile apps\api\telemetry.py apps\api\views.py apps\api\tests.py`:通过。
|
||||
- `py -3.12 manage.py test apps.api.tests.GenerateApiTests.test_sync_image_usage_telemetry_logs_safe_client_version_and_key_identity apps.api.tests.GenerateApiTests.test_async_image_submit_usage_telemetry_logs_safe_success_event apps.api.tests.GenerateApiTests.test_async_image_submit_usage_telemetry_logs_error_code_without_sensitive_data --keepdb --noinput --verbosity 2`:通过,3 tests OK。
|
||||
- `py -3.12 manage.py check`:通过,0 issues。
|
||||
- `py -3.12 manage.py makemigrations --check --dry-run`:通过,No changes detected。
|
||||
- `py -3.12 manage.py test apps.api.tests.GenerateApiTests --keepdb --noinput --verbosity 2`:通过,31 tests OK。
|
||||
- `.\init.ps1`:通过(Python 3.12.3,依赖已满足,`manage.py check` 0 issues,打印启动命令)。
|
||||
- `git diff --check`:通过,仅 Windows CRLF 提示。
|
||||
- 测试环境现象:
|
||||
- 测试期仍保留 allauth `account.EmailAddress` 条件唯一约束在 MySQL 上不可创建的既有 `models.W036` 警告。
|
||||
- 决策:
|
||||
- T-615 第一版只做日志遥测,不新增 admin 报表或统计表;如后续需要报表,单独评估数据量、索引和保留周期。
|
||||
- 旧同步接口下线前必须继续保持成功响应兼容;待新版客户端默认异步、旧路调用归零或低于运营阈值一段时间后,再宣布 deprecated 并单独立下线任务。
|
||||
- 下一步:部署后观察 `generation_route_usage` 日志;继续处理真实支付回调到账闭环、客户端下载包发布等业务事项。
|
||||
|
||||
Reference in New Issue
Block a user