fix(ai): 平台套图改走商品主图参考 image_edit · 模特上身图锁商品+模特双图
- 平台套图(cover)以前纯文生图,不参考真实商品主图 → 出图与商品对不上; 现以商品主图为参考图1走 image_edit,锁包装一致性,有模特则参考图2=模特 - 新增 build_platform_cover_prompt_refs;model_url 解析对 cover 模式同样生效 - 补回归测试:cover 传商品主图、model 传商品主图+模特图、无主图回落文生图 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -576,6 +576,17 @@ def _find_entity_group(project, kind: str, label: str, group_id: str | None):
|
||||
return None
|
||||
|
||||
|
||||
def _ratio_to_image_size(ratio: str) -> str:
|
||||
"""前端比例(1:1 / 3:4 / 9:16)→ gpt-image 支持的尺寸。竖屏统一 1024x1536,横屏 1536x1024。"""
|
||||
return {
|
||||
"1:1": "1024x1024",
|
||||
"3:4": "1024x1536",
|
||||
"9:16": "1024x1536",
|
||||
"4:3": "1536x1024",
|
||||
"16:9": "1536x1024",
|
||||
}.get((ratio or "").strip(), "1024x1024")
|
||||
|
||||
|
||||
def _product_cover_url(product) -> str:
|
||||
"""商品主图 URL:优先 cover_asset,其次标记为主图的商品图,再次首张商品图。无图返回 ''。"""
|
||||
if product is None:
|
||||
@@ -604,6 +615,50 @@ def build_product_triview_prompt_refs(product, base_prompt: str = "") -> str:
|
||||
return " ".join(lines)
|
||||
|
||||
|
||||
def build_model_tryon_prompt_refs(product, has_model: bool, base_prompt: str = "") -> str:
|
||||
"""模特上身图 image_edit 提示词(refs 版):
|
||||
参考图1=商品真实主图(锁商品外形/品牌/配色),参考图2=选中模特(锁人脸/身形/气质)。
|
||||
生成「该模特自然展示/使用该商品」的电商效果图。"""
|
||||
name = (getattr(product, "title", "") or "商品").strip()
|
||||
lines = [f"参考图1是「{name}」的真实商品。"]
|
||||
if has_model:
|
||||
lines += [
|
||||
"参考图2是出镜模特。请生成参考图2中的这位模特自然地展示/佩戴/使用参考图1中商品的电商效果图。",
|
||||
"模特的五官、发型、肤色、身形与气质必须与参考图2高度一致,不要换人;",
|
||||
"商品的外形、品牌文字、配色、Logo 必须与参考图1高度一致,不要改动或重新设计。",
|
||||
]
|
||||
else:
|
||||
lines += [
|
||||
"请生成一位真人模特自然地展示/佩戴/使用参考图1中商品的电商效果图。",
|
||||
"商品的外形、品牌文字、配色、Logo 必须与参考图1高度一致,不要改动或重新设计。",
|
||||
]
|
||||
lines.append("自然光、真实质感、干净背景、电商主图构图,人物与商品比例真实协调。")
|
||||
if base_prompt and base_prompt.strip():
|
||||
lines.append(base_prompt.strip())
|
||||
return " ".join(lines)
|
||||
|
||||
|
||||
def build_platform_cover_prompt_refs(product, has_model: bool, base_prompt: str = "") -> str:
|
||||
"""平台套图 image_edit 提示词(refs 版):参考图1=商品真实主图(锁外形/品牌/配色/Logo),
|
||||
有模特时参考图2=出镜模特(锁人脸/身形)。生成电商平台主图 / 封面套图,商品须还原真实包装。"""
|
||||
name = (getattr(product, "title", "") or "商品").strip()
|
||||
lines = [f"参考图1是「{name}」的真实商品主图。"]
|
||||
if has_model:
|
||||
lines += [
|
||||
"参考图2是出镜模特。请生成参考图2中的这位模特展示参考图1中商品的电商平台套图(主图 / 封面 / 详情);",
|
||||
"模特的五官、发型、肤色、身形必须与参考图2高度一致,不要换人;",
|
||||
]
|
||||
else:
|
||||
lines.append("请基于该商品生成电商平台套图(主图 / 封面 / 详情排版),统一视觉风格;")
|
||||
lines.append(
|
||||
"商品的外形、品牌文字、配色、Logo、材质必须与参考图1高度一致,严禁改动或重新设计包装;"
|
||||
"干净背景、电商主图构图、真实质感。"
|
||||
)
|
||||
if base_prompt and base_prompt.strip():
|
||||
lines.append(base_prompt.strip())
|
||||
return " ".join(lines)
|
||||
|
||||
|
||||
def generate_base_asset(*, project, user, kind: str, prompt: str, label: str = "", group_id: str | None = None) -> AITask:
|
||||
"""提交基础资产生成(**异步**):Web 请求只建 RESERVED 任务 + 预留额度(秒级),
|
||||
慢出图(文生图 / 商品 image_edit)交给 Celery worker(run_base_asset_task)跑。
|
||||
@@ -1349,7 +1404,7 @@ def _reap_stale_standalone_image_tasks(*, team) -> None:
|
||||
continue
|
||||
|
||||
|
||||
def enqueue_standalone_images(*, team, user, prompt: str, mode: str = "image", count: int = 1, product_id: str | None = None, reference_product: bool = False) -> list[AITask]:
|
||||
def enqueue_standalone_images(*, team, user, prompt: str, mode: str = "image", count: int = 1, product_id: str | None = None, reference_product: bool = False, model_id: str | None = None, ratio: str | None = None) -> list[AITask]:
|
||||
"""独立生图(图片创作 / 模特上身图 / 平台套图)改为**异步**:本函数在 Web 请求里只做「建任务 +
|
||||
预留额度」这种秒级的活,真正 ~30s 的 ARK 出图交给 Celery worker(generate_standalone_image_task)。
|
||||
|
||||
@@ -1375,7 +1430,7 @@ def enqueue_standalone_images(*, team, user, prompt: str, mode: str = "image", c
|
||||
status=AITask.Status.CREATED,
|
||||
model_config=model_config,
|
||||
idempotency_key=f"standalone-image:{team.id}:{uuid.uuid4()}",
|
||||
request_payload={"model": model_config.name, "endpoint": model_config.endpoint, "prompt": prompt, "mode": mode, "index": index, "product_id": str(product_id) if product_id else None, "reference_product": bool(reference_product)},
|
||||
request_payload={"model": model_config.name, "endpoint": model_config.endpoint, "prompt": prompt, "mode": mode, "index": index, "product_id": str(product_id) if product_id else None, "reference_product": bool(reference_product), "model_id": str(model_id) if model_id else None, "ratio": str(ratio) if ratio else None},
|
||||
estimated_cost=cost,
|
||||
)
|
||||
# 预留额度若余额不足会抛 ValueError,在同步的 Web 请求里立刻反馈给前端(不会先建半套任务)
|
||||
@@ -1402,25 +1457,54 @@ def run_standalone_image_task(*, task_id: str) -> None:
|
||||
mode = str(payload.get("mode") or "image")
|
||||
index = int(payload.get("index") or 0)
|
||||
product_id = payload.get("product_id") or None
|
||||
category = _STANDALONE_CATEGORY.get(mode, Asset.Category.UNCATEGORIZED)
|
||||
# 模特上身图(mode=model 且绑了商品)= 该商品的商品图,归到对应商品的 AI 资产,不进人物库;
|
||||
# 「生成演员」同样走 mode=model 但无 product_id,仍归人物库(PERSON)。
|
||||
if mode == "model" and product_id:
|
||||
category = Asset.Category.PRODUCT_IMAGE
|
||||
else:
|
||||
category = _STANDALONE_CATEGORY.get(mode, Asset.Category.UNCATEGORIZED)
|
||||
model_config = task.model_config
|
||||
provider = get_image_provider(model_config)
|
||||
reservation = task.credit_reservation
|
||||
# 商品三视图(商品详情页按 reference_product 提交):有真实商品主图 + 模型支持 image_edit →
|
||||
# 以主图为参考锁包装一致性,否则回落纯文生图(仅凭商品名脑补,不保证还原真实包装)。
|
||||
ref_url = ""
|
||||
# 出图策略(都优先 image_edit 锁真实素材,模型不支持/无素材才回落纯文生图):
|
||||
# · 模特上身图(mode=model):参考图1=商品真实主图 + 参考图2=选中模特 → 生成「该模特用该商品」效果图;
|
||||
# · 平台套图(mode=cover):参考图1=商品真实主图(+ 有模特则参考图2=模特)→ 锁包装一致性出套图;
|
||||
# · 商品三视图(reference_product):参考图1=商品真实主图 → 锁包装一致性;
|
||||
# · 其余(图片创作):纯文生图。
|
||||
product = None
|
||||
if bool(payload.get("reference_product")) and product_id:
|
||||
if product_id:
|
||||
from apps.products.models import Product
|
||||
|
||||
product = Product.objects.filter(id=product_id).first()
|
||||
if product is not None:
|
||||
ref_url = _product_cover_url(product)
|
||||
use_edit = bool(ref_url) and hasattr(provider, "image_edit")
|
||||
can_edit = hasattr(provider, "image_edit")
|
||||
model_url = ""
|
||||
if payload.get("model_id"):
|
||||
model_asset = Asset.objects.filter(id=payload.get("model_id")).first()
|
||||
if model_asset is not None:
|
||||
model_url = _asset_preview_url(model_asset)
|
||||
product_url = _product_cover_url(product) if product is not None else ""
|
||||
|
||||
edit_images: list[str] = []
|
||||
edit_prompt = ""
|
||||
if mode == "model" and can_edit and product_url:
|
||||
# 模特上身图:商品图必有,模特图可缺(缺则让模型自取真人模特)
|
||||
edit_images = [product_url] + ([model_url] if model_url else [])
|
||||
edit_prompt = build_model_tryon_prompt_refs(product, has_model=bool(model_url), base_prompt=prompt)
|
||||
elif mode == "cover" and can_edit and product_url:
|
||||
# 平台套图:参考图1=商品真实主图(锁包装一致性),有模特则参考图2=模特(锁人脸/身形)
|
||||
edit_images = [product_url] + ([model_url] if model_url else [])
|
||||
edit_prompt = build_platform_cover_prompt_refs(product, has_model=bool(model_url), base_prompt=prompt)
|
||||
elif bool(payload.get("reference_product")) and can_edit and product_url:
|
||||
edit_images = [product_url]
|
||||
edit_prompt = build_product_triview_prompt_refs(product, "")
|
||||
use_edit = bool(edit_images)
|
||||
try:
|
||||
if use_edit:
|
||||
edit_prompt = build_product_triview_prompt_refs(product, "")
|
||||
response = provider.image_edit(model=model_config.name, prompt=edit_prompt, images=[ref_url], size="1536x1024")
|
||||
if payload.get("reference_product"):
|
||||
size = "1536x1024" # 三视图固定横向
|
||||
else:
|
||||
size = _ratio_to_image_size(str(payload.get("ratio") or "")) # 模特图按选中比例
|
||||
response = provider.image_edit(model=model_config.name, prompt=edit_prompt, images=edit_images, size=size)
|
||||
else:
|
||||
response = provider.image_generation(model=model_config.name, endpoint=model_config.endpoint, prompt=prompt)
|
||||
media = provider.extract_first_media_url(response)
|
||||
@@ -1436,8 +1520,9 @@ def run_standalone_image_task(*, task_id: str) -> None:
|
||||
asset_id = uuid.uuid4()
|
||||
object_key = f"teams/{team.id}/standalone/{asset_id}{suffix}"
|
||||
stored = TosStorage().upload_fileobj(fileobj=fileobj, object_key=object_key, content_type=content_type)
|
||||
asset_label = {"model": "模特上身图", "cover": "平台套图", "image": "图片创作"}.get(mode, mode)
|
||||
asset = Asset.objects.create(
|
||||
id=asset_id, team=team, created_by=user, name=f"AI 生成 · {mode} · {index + 1}",
|
||||
id=asset_id, team=team, created_by=user, name=f"AI 生成 · {asset_label} · {index + 1}",
|
||||
asset_type=Asset.Type.IMAGE, source=Asset.Source.AI_GENERATED, category=category, origin_task=task,
|
||||
# 记下生图时选中的商品,商品详情页据此只展示「该商品」的 AI 素材(而非全团队)
|
||||
metadata={"product_id": str(product_id)} if product_id else {},
|
||||
|
||||
@@ -93,3 +93,78 @@ class NormalizeDraftTests(SimpleTestCase):
|
||||
self.assertEqual(len(draft["segments"]), 2)
|
||||
self.assertEqual(draft["segments"][0]["narration"], "口播1")
|
||||
self.assertEqual(draft["segments"][1]["visual"], "画面2")
|
||||
|
||||
|
||||
from io import BytesIO
|
||||
from unittest.mock import patch
|
||||
|
||||
from django.test import TestCase
|
||||
|
||||
from apps.accounts.models import Team, User
|
||||
from apps.ai.services import enqueue_standalone_images
|
||||
from apps.assets.models import Asset, AssetFile
|
||||
from apps.billing.models import CreditAccount
|
||||
from apps.products.models import Product
|
||||
|
||||
|
||||
class StandaloneImageReferenceTests(TestCase):
|
||||
"""独立生图(平台套图 / 模特上身图)必须把商品真实主图(+ 模特图)作为参考图走 image_edit,
|
||||
而不是纯文生图——回归保护 #图片生成没参考商品主图# 这个 bug。
|
||||
(图像默认模型由迁移 seed 的 tokenssr:gpt-image-2 提供,get_image_provider 全程 mock。)"""
|
||||
|
||||
def setUp(self):
|
||||
self.user = User.objects.create_user(username="owner", password="pass")
|
||||
self.team = Team.objects.create(name="T", owner=self.user)
|
||||
CreditAccount.objects.create(team=self.team, balance="100.0000")
|
||||
# 商品 + 主图(带可访问 preview_url)
|
||||
self.product = Product.objects.create(team=self.team, created_by=self.user, title="南卡 Lite Pro")
|
||||
cover = Asset.objects.create(
|
||||
team=self.team, created_by=self.user, name="主图", asset_type=Asset.Type.IMAGE,
|
||||
source=Asset.Source.UPLOAD, category=Asset.Category.PRODUCT_IMAGE,
|
||||
)
|
||||
AssetFile.objects.create(asset=cover, object_key="c.png", bucket="b", content_type="image/png", preview_url="http://x/cover.png", is_primary=True)
|
||||
self.product.cover_asset = cover
|
||||
self.product.save(update_fields=["cover_asset"])
|
||||
|
||||
def _patch_provider(self):
|
||||
# image_edit 返回值 + 媒体落库链路全部 mock,聚焦验证「传了哪些参考图」
|
||||
provider = patch("apps.ai.services.get_image_provider").start()
|
||||
prov = provider.return_value
|
||||
prov.image_edit.return_value = {"data": [{"url": "http://x/out.png"}]}
|
||||
prov.image_generation.return_value = {"data": [{"url": "http://x/out.png"}]}
|
||||
prov.extract_first_media_url.return_value = "http://x/out.png"
|
||||
media = patch("apps.ai.services.VolcanoArkProvider.media_to_bytes").start()
|
||||
media.return_value = (BytesIO(b"img"), "image/png")
|
||||
store = patch("apps.ai.services.TosStorage").start()
|
||||
stored = store.return_value.upload_fileobj.return_value
|
||||
stored.object_key, stored.bucket, stored.content_type, stored.size_bytes = "o.png", "b", "image/png", 3
|
||||
self.addCleanup(patch.stopall)
|
||||
return prov
|
||||
|
||||
def test_cover_mode_references_product_main_image(self):
|
||||
prov = self._patch_provider()
|
||||
enqueue_standalone_images(team=self.team, user=self.user, prompt="平台套图", mode="cover", count=1, product_id=str(self.product.id), ratio="4:5")
|
||||
prov.image_edit.assert_called_once()
|
||||
self.assertEqual(prov.image_edit.call_args.kwargs["images"], ["http://x/cover.png"])
|
||||
prov.image_generation.assert_not_called()
|
||||
|
||||
def test_model_tryon_combines_product_and_model_images(self):
|
||||
prov = self._patch_provider()
|
||||
# 模特资产(PERSON)带 preview_url
|
||||
model_asset = Asset.objects.create(
|
||||
team=self.team, created_by=self.user, name="模特", asset_type=Asset.Type.IMAGE,
|
||||
source=Asset.Source.AI_GENERATED, category=Asset.Category.PERSON,
|
||||
)
|
||||
AssetFile.objects.create(asset=model_asset, object_key="m.png", bucket="b", content_type="image/png", preview_url="http://x/model.png", is_primary=True)
|
||||
enqueue_standalone_images(team=self.team, user=self.user, prompt="上身图", mode="model", count=1, product_id=str(self.product.id), model_id=str(model_asset.id), ratio="4:5")
|
||||
prov.image_edit.assert_called_once()
|
||||
self.assertEqual(prov.image_edit.call_args.kwargs["images"], ["http://x/cover.png", "http://x/model.png"])
|
||||
prov.image_generation.assert_not_called()
|
||||
|
||||
def test_cover_mode_falls_back_to_t2i_without_main_image(self):
|
||||
prov = self._patch_provider()
|
||||
self.product.cover_asset = None
|
||||
self.product.save(update_fields=["cover_asset"])
|
||||
enqueue_standalone_images(team=self.team, user=self.user, prompt="平台套图", mode="cover", count=1, product_id=str(self.product.id), ratio="4:5")
|
||||
prov.image_generation.assert_called_once()
|
||||
prov.image_edit.assert_not_called()
|
||||
|
||||
@@ -31,9 +31,11 @@ class GenerateImageView(APIView):
|
||||
count = 1
|
||||
product_id = str(request.data.get("product_id") or "").strip() or None
|
||||
reference_product = bool(request.data.get("reference_product"))
|
||||
model_id = str(request.data.get("model_id") or "").strip() or None
|
||||
ratio = str(request.data.get("ratio") or "").strip() or None
|
||||
team = get_current_team(request.user)
|
||||
try:
|
||||
tasks = enqueue_standalone_images(team=team, user=request.user, prompt=prompt, mode=mode, count=count, product_id=product_id, reference_product=reference_product)
|
||||
tasks = enqueue_standalone_images(team=team, user=request.user, prompt=prompt, mode=mode, count=count, product_id=product_id, reference_product=reference_product, model_id=model_id, ratio=ratio)
|
||||
except ValueError as exc: # 无可用模型 / 余额不足等,立即反馈
|
||||
return Response({"detail": str(exc)}, status=status.HTTP_400_BAD_REQUEST)
|
||||
return Response(
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
from django.contrib.auth import get_user_model
|
||||
|
||||
from airshelf.celery import app
|
||||
from apps.accounts.models import Team
|
||||
|
||||
|
||||
@app.task
|
||||
def ensure_team_notifications_task(team_id: str, user_id: str | None = None) -> None:
|
||||
"""后台生成/补齐团队站内通知。
|
||||
|
||||
原先在 NotificationViewSet.list() 里同步执行:每次刷新都要读计费账本 +
|
||||
对项目/资产/扣费流水逐条 get_or_create,几十次 DB 往返压在请求线程上,拖慢通知接口。
|
||||
现改由 Celery 异步执行,请求只负责读现有通知,生成搬到后台 worker。
|
||||
"""
|
||||
from apps.ops.views import ensure_team_notifications # 延迟导入避免循环依赖
|
||||
|
||||
team = Team.objects.filter(id=team_id).first()
|
||||
if team is None:
|
||||
return
|
||||
user = get_user_model().objects.filter(id=user_id).first() if user_id else None
|
||||
ensure_team_notifications(team, user)
|
||||
@@ -0,0 +1,86 @@
|
||||
from decimal import Decimal
|
||||
|
||||
from django.test import TestCase
|
||||
|
||||
from apps.accounts.models import Team, TeamMember, User
|
||||
from apps.ai.models import AITask, ModelConfig, ModelProvider
|
||||
from apps.assets.models import Asset
|
||||
from apps.billing.models import CreditAccount, CreditLedger
|
||||
from apps.ops.models import Notification
|
||||
from apps.ops.views import ensure_team_notifications
|
||||
from apps.products.models import Product
|
||||
from apps.projects.models import Project
|
||||
|
||||
|
||||
class BillingNotificationTests(TestCase):
|
||||
def setUp(self):
|
||||
self.user = User.objects.create_user(username="owner", password="pass")
|
||||
self.team = Team.objects.create(name="T", owner=self.user)
|
||||
TeamMember.objects.create(team=self.team, user=self.user, role=TeamMember.Role.OWNER)
|
||||
CreditAccount.objects.create(team=self.team, balance="500.0000")
|
||||
self.provider = ModelProvider.objects.create(name="ops-test-prov", display_name="TSR")
|
||||
self.model = ModelConfig.objects.create(
|
||||
provider=self.provider, name="ops-test-image", capability=ModelConfig.Capability.IMAGE
|
||||
)
|
||||
self.product = Product.objects.create(team=self.team, created_by=self.user, title="补水面膜")
|
||||
self.project = Project.objects.create(
|
||||
team=self.team, name="补水面膜·短视频", product=self.product, created_by=self.user
|
||||
)
|
||||
|
||||
def _charge(self, task_type, amount, balance_after):
|
||||
task = AITask.objects.create(
|
||||
team=self.team,
|
||||
project=self.project,
|
||||
task_type=task_type,
|
||||
model_config=self.model,
|
||||
idempotency_key=f"k-{task_type}-{amount}",
|
||||
actual_cost=Decimal(amount),
|
||||
)
|
||||
CreditLedger.objects.create(
|
||||
team=self.team,
|
||||
user=self.user,
|
||||
project=self.project,
|
||||
task=task,
|
||||
ledger_type=CreditLedger.Type.CHARGE,
|
||||
amount=Decimal(amount),
|
||||
balance_after=Decimal(balance_after),
|
||||
reason="AI 任务扣费",
|
||||
)
|
||||
return task
|
||||
|
||||
def test_charge_becomes_billing_notification_with_real_amount(self):
|
||||
self._charge("product_image", "0.32", "499.68")
|
||||
self._charge("video_segment", "18.40", "481.28")
|
||||
|
||||
ensure_team_notifications(self.team, self.user)
|
||||
|
||||
billing = Notification.objects.filter(team=self.team, notification_type=Notification.Type.BILLING)
|
||||
# 每笔扣费一条计费消息,且费用不为空、为真实金额
|
||||
costs = sorted(n.cost_label for n in billing)
|
||||
self.assertIn("¥0.32", costs)
|
||||
self.assertIn("¥18.40", costs)
|
||||
self.assertFalse(billing.filter(cost_label__in=["", "-"]).exists())
|
||||
self.assertTrue(billing.filter(title__contains="图生成").exists())
|
||||
self.assertTrue(billing.filter(title__contains="视频生成").exists())
|
||||
|
||||
def test_project_notification_shows_aggregated_spend(self):
|
||||
self._charge("product_image", "0.32", "499.68")
|
||||
self._charge("video_segment", "18.40", "481.28")
|
||||
|
||||
ensure_team_notifications(self.team, self.user)
|
||||
|
||||
proj_note = Notification.objects.get(
|
||||
team=self.team, notification_type=Notification.Type.TASK, title__contains="补水面膜"
|
||||
)
|
||||
self.assertEqual(proj_note.cost_label, "¥18.72") # 0.32 + 18.40
|
||||
|
||||
def test_existing_dash_cost_is_backfilled(self):
|
||||
# 早期建的项目通知费用是占位「-」;补上扣费后再跑 ensure 应回填真实金额
|
||||
ensure_team_notifications(self.team, self.user)
|
||||
note = Notification.objects.get(team=self.team, title__contains="补水面膜")
|
||||
self.assertEqual(note.cost_label, "-")
|
||||
|
||||
self._charge("product_image", "0.32", "499.68")
|
||||
ensure_team_notifications(self.team, self.user)
|
||||
note.refresh_from_db()
|
||||
self.assertEqual(note.cost_label, "¥0.32")
|
||||
@@ -1,4 +1,7 @@
|
||||
from django.db.models import Q
|
||||
from datetime import timedelta
|
||||
|
||||
from django.core.cache import cache
|
||||
from django.db.models import Q, Sum
|
||||
from django.utils import timezone
|
||||
from rest_framework import status
|
||||
from rest_framework.decorators import action
|
||||
@@ -14,7 +17,7 @@ class NotificationPagination(PageNumberPagination):
|
||||
max_page_size = 100
|
||||
|
||||
from apps.assets.models import Asset
|
||||
from apps.billing.models import CreditAccount
|
||||
from apps.billing.models import CreditAccount, CreditLedger
|
||||
from apps.common.api import TeamScopedViewSetMixin
|
||||
from apps.projects.models import Project
|
||||
|
||||
@@ -32,6 +35,20 @@ def project_stage_label(project):
|
||||
}.get(project.current_stage, "Stage 1 · 脚本")
|
||||
|
||||
|
||||
# AI 任务类型 → 计费消息里的中文阶段标签(脚本/图片/视频/配音都会扣费)
|
||||
TASK_TYPE_LABEL = {
|
||||
"script_generation": "脚本生成",
|
||||
"script_optimization": "脚本优化",
|
||||
"product_image": "商品图生成",
|
||||
"person_image": "模特图生成",
|
||||
"scene_image": "场景图生成",
|
||||
"storyboard": "故事板生成",
|
||||
"video_segment": "视频生成",
|
||||
"voiceover": "配音生成",
|
||||
"export": "成片导出",
|
||||
}
|
||||
|
||||
|
||||
def project_priority(project):
|
||||
if project.status == Project.Status.COMPLETED:
|
||||
return Notification.Priority.OK
|
||||
@@ -57,11 +74,23 @@ def ensure_team_notifications(team, user):
|
||||
dedupe_key=dedupe_key,
|
||||
defaults=payload,
|
||||
)
|
||||
# 已存在的旧通知补齐「处理记录」timeline(早期建的没存),让历史消息也能显示处理记录
|
||||
if created:
|
||||
return
|
||||
# 已存在的旧通知:补齐早期没存的字段(历史消息也能显示),不重建
|
||||
updates = []
|
||||
# 1) 处理记录 timeline
|
||||
timeline = (payload.get("metadata") or {}).get("timeline")
|
||||
if not created and timeline and not (obj.metadata or {}).get("timeline"):
|
||||
if timeline and not (obj.metadata or {}).get("timeline"):
|
||||
obj.metadata = {**(obj.metadata or {}), "timeline": timeline}
|
||||
obj.save(update_fields=["metadata", "updated_at"])
|
||||
updates.append("metadata")
|
||||
# 2) 费用:早期建的是占位「-」,现在能从计费账本算出真实金额了就回填
|
||||
new_cost = payload.get("cost_label")
|
||||
if new_cost and new_cost != "-" and obj.cost_label in ("", "-"):
|
||||
obj.cost_label = new_cost
|
||||
updates.append("cost_label")
|
||||
if updates:
|
||||
updates.append("updated_at")
|
||||
obj.save(update_fields=updates)
|
||||
|
||||
create_once(
|
||||
"system:welcome",
|
||||
@@ -80,6 +109,11 @@ def ensure_team_notifications(team, user):
|
||||
|
||||
for project in Project.objects.filter(team=team).select_related("product", "created_by").order_by("-updated_at")[:5]:
|
||||
product_title = project.product.title if project.product_id else "未绑定商品"
|
||||
# 项目累计花费 = 该项目所有 AI 扣费流水之和(脚本/图片/视频/导出),无扣费则显示「-」
|
||||
project_spend = project.credit_ledgers.filter(
|
||||
ledger_type=CreditLedger.Type.CHARGE
|
||||
).aggregate(total=Sum("amount"))["total"]
|
||||
project_cost = f"¥{project_spend:.2f}" if project_spend else "-"
|
||||
create_once(
|
||||
f"project:{project.id}:status:{project.status}:{project.current_stage}",
|
||||
notification_type=Notification.Type.TASK,
|
||||
@@ -91,7 +125,7 @@ def ensure_team_notifications(team, user):
|
||||
project=project,
|
||||
stage=project_stage_label(project),
|
||||
owner_label=project.created_by.username if project.created_by_id else "成员",
|
||||
cost_label="-",
|
||||
cost_label=project_cost,
|
||||
related_url=f"pipeline.html?project_id={project.id}",
|
||||
metadata={
|
||||
"status": project.status,
|
||||
@@ -104,7 +138,10 @@ def ensure_team_notifications(team, user):
|
||||
},
|
||||
)
|
||||
|
||||
for asset in Asset.objects.filter(team=team).select_related("created_by").order_by("-updated_at")[:3]:
|
||||
for asset in Asset.objects.filter(team=team).select_related("created_by", "origin_task").order_by("-updated_at")[:3]:
|
||||
# AI 生成的资产带 origin_task,可取其实际扣费;手动上传的资产无扣费 → 「-」
|
||||
origin = asset.origin_task
|
||||
asset_cost = f"¥{origin.actual_cost:.2f}" if origin and origin.actual_cost else "-"
|
||||
create_once(
|
||||
f"asset:{asset.id}:created",
|
||||
notification_type=Notification.Type.TASK,
|
||||
@@ -115,7 +152,7 @@ def ensure_team_notifications(team, user):
|
||||
source="资产库",
|
||||
stage="资产入库",
|
||||
owner_label=asset.created_by.username if asset.created_by_id else "成员",
|
||||
cost_label="-",
|
||||
cost_label=asset_cost,
|
||||
related_url="library.html",
|
||||
metadata={
|
||||
"asset_id": str(asset.id),
|
||||
@@ -128,6 +165,53 @@ def ensure_team_notifications(team, user):
|
||||
},
|
||||
)
|
||||
|
||||
# 计费消息:把真实扣费流水(图片/视频/脚本/配音生成)写成「计费」类通知,带真实金额。
|
||||
# 原先 ensure_team_notifications 完全没读账本,所以计费消息的费用都是空的。
|
||||
# 计费通知覆盖「消息保留期(90 天)」内的全部扣费流水,而不是固定最近 10 条 ——
|
||||
# 否则计费 tab 计数会被卡在 10,与实际扣费条数严重不符。上限 500 仅作暴走兜底。
|
||||
charge_window_start = timezone.now() - timedelta(days=90)
|
||||
recent_charges = (
|
||||
CreditLedger.objects.filter(
|
||||
team=team,
|
||||
ledger_type=CreditLedger.Type.CHARGE,
|
||||
created_at__gte=charge_window_start,
|
||||
)
|
||||
.select_related("task", "project", "user")
|
||||
.order_by("-created_at")[:500]
|
||||
)
|
||||
for ledger in recent_charges:
|
||||
task = ledger.task
|
||||
type_label = TASK_TYPE_LABEL.get(task.task_type, "AI 生成") if task else "AI 生成"
|
||||
project_name = ledger.project.name if ledger.project_id else ""
|
||||
amount_text = f"¥{ledger.amount:.2f}"
|
||||
scope = f" · {project_name}" if project_name else ""
|
||||
create_once(
|
||||
f"billing:charge:{ledger.id}",
|
||||
notification_type=Notification.Type.BILLING,
|
||||
priority=Notification.Priority.INFO,
|
||||
title=f"{type_label}已扣费 {amount_text}",
|
||||
brief=f"{ledger.reason or 'AI 任务扣费'}{scope} · 扣费后余额 ¥{ledger.balance_after:.2f}",
|
||||
body=(
|
||||
f"本次{type_label}消耗 {amount_text},来自真实计费账本(CreditLedger)。"
|
||||
f"扣费后团队余额为 ¥{ledger.balance_after:.2f}。明细可在消费页查看完整账本。"
|
||||
),
|
||||
source="计费中心",
|
||||
project=ledger.project,
|
||||
stage=type_label,
|
||||
owner_label=ledger.user.username if ledger.user_id else "系统",
|
||||
cost_label=amount_text,
|
||||
related_url="account.html",
|
||||
metadata={
|
||||
"ledger_id": str(ledger.id),
|
||||
"amount": str(ledger.amount),
|
||||
"timeline": [
|
||||
[_step_time(ledger.created_at), f"{type_label}提交并预留额度"],
|
||||
[_step_time(ledger.created_at), f"实际扣费 {amount_text}"],
|
||||
[_step_time(ledger.created_at), f"扣费后余额 ¥{ledger.balance_after:.2f}"],
|
||||
],
|
||||
},
|
||||
)
|
||||
|
||||
account, _ = CreditAccount.objects.get_or_create(team=team)
|
||||
if account.balance <= 100:
|
||||
create_once(
|
||||
@@ -174,8 +258,23 @@ class NotificationViewSet(TeamScopedViewSetMixin, ModelViewSet):
|
||||
queryset = queryset.filter(is_read=False)
|
||||
return queryset
|
||||
|
||||
def _refresh_notifications(self, request):
|
||||
"""生成/补齐通知:搬到 Celery 后台执行,不再阻塞请求。
|
||||
- 首次(团队还没有任何通知)同步跑一次,避免初次打开面板是空的;
|
||||
- 之后每团队最多每 60s 触发一次后台刷新,其余请求只读现有数据 → 接口秒回。
|
||||
"""
|
||||
team = self.get_team()
|
||||
if not Notification.objects.filter(team=team).exists():
|
||||
ensure_team_notifications(team, request.user)
|
||||
return
|
||||
if cache.add(f"ops:notif-refresh:{team.id}", 1, timeout=60):
|
||||
from .tasks import ensure_team_notifications_task
|
||||
|
||||
user_id = str(request.user.id) if getattr(request.user, "id", None) else None
|
||||
ensure_team_notifications_task.delay(str(team.id), user_id)
|
||||
|
||||
def list(self, request, *args, **kwargs):
|
||||
ensure_team_notifications(self.get_team(), request.user)
|
||||
self._refresh_notifications(request)
|
||||
response = super().list(request, *args, **kwargs)
|
||||
data = response.data
|
||||
if isinstance(data, dict):
|
||||
|
||||
@@ -732,8 +732,9 @@
|
||||
/* ════════════════════════════════════════════════
|
||||
行31/32/33 · 批次卡 + 气泡菜单
|
||||
════════════════════════════════════════════════ */
|
||||
/* 批次卡:复用 .gen-card,批次间拉开间距 */
|
||||
.image-workbench .gen-batch-card + .gen-batch-card { margin-top: 18px; }
|
||||
/* 批次卡:复用 .gen-card。每批各带一个导航头(.iw-pv-h),批次间靠「卡片→下一个头」拉开间距;
|
||||
首个头紧跟 iw-preview 顶 padding,无需额外上间距(故只给跟在卡片后的头加 margin)。 */
|
||||
.image-workbench .gen-batch-card + .iw-pv-h { margin-top: 22px; }
|
||||
/* 批次头(数量 + 状态) */
|
||||
.image-workbench .gen-batch-h {
|
||||
display: flex; align-items: center; gap: 10px;
|
||||
|
||||
@@ -446,7 +446,7 @@ export const api = {
|
||||
return request<Paginated<AITask>>("/api/ai/tasks/");
|
||||
},
|
||||
// 异步生图:提交后秒级返回任务列表(慢出图在 worker 跑),再用 generateImageStatus 轮询取结果
|
||||
submitGenerateImage(payload: { prompt: string; mode?: "image" | "model" | "cover"; count?: number; product_id?: string; reference_product?: boolean }) {
|
||||
submitGenerateImage(payload: { prompt: string; mode?: "image" | "model" | "cover"; count?: number; product_id?: string; reference_product?: boolean; model_id?: string; ratio?: string }) {
|
||||
return request<{ tasks: { id: string; status: string }[] }>("/api/ai/generate-image/", { method: "POST", body: JSON.stringify(payload) });
|
||||
},
|
||||
generateImageStatus(ids: string[]) {
|
||||
|
||||
@@ -36,6 +36,9 @@
|
||||
align-items: stretch;
|
||||
}
|
||||
.pd-overview .ov-card { height: 100%; box-sizing: border-box; }
|
||||
/* 编辑商品信息时左栏变高:右侧快速操作不跟随拉伸,而是锁定为进编辑前(查看态)的高度,
|
||||
整体大小保持不变。高度值由组件用 ref 实测后内联到 .ov-actions(见 products.tsx)。 */
|
||||
.pd-overview:has(.ov-main.editing) { align-items: start; }
|
||||
.pd-overview .ov-card {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border-faint);
|
||||
|
||||
@@ -779,22 +779,6 @@ export function ImageWorkbenchPage({
|
||||
|
||||
const hasResults = batches.length > 0;
|
||||
|
||||
/* 按商品分组(行34):各商品各自一个导航头,避免多商品批次被并到同一个头里。
|
||||
保持首次出现顺序;productId 缺失的旧批次归到当前商品组。 */
|
||||
const batchGroups = (() => {
|
||||
const order: string[] = [];
|
||||
const map = new Map<string, { key: string; title: string; items: GenBatch[] }>();
|
||||
for (const b of batches) {
|
||||
const key = b.productId || product?.id || "—";
|
||||
if (!map.has(key)) {
|
||||
order.push(key);
|
||||
map.set(key, { key, title: b.productTitle || product?.title || "未选择", items: [] });
|
||||
}
|
||||
map.get(key)!.items.push(b);
|
||||
}
|
||||
return order.map((k) => map.get(k)!);
|
||||
})();
|
||||
|
||||
/* ── 单个批次的结果网格(行30:生成中转圈 icon;行32:单图 hover「更多」气泡菜单)── */
|
||||
function renderBatchGrid(batch: GenBatch) {
|
||||
const generating = batch.status === "generating";
|
||||
@@ -854,7 +838,15 @@ export function ImageWorkbenchPage({
|
||||
);
|
||||
}
|
||||
|
||||
/* ── 批次列表(行31/32/33):每个批次一行卡片,底部一组操作 + 底部「更多」气泡 ── */
|
||||
/* 批次所属商品名:优先生成时记下的 productTitle;旧批次缺失则用 productId 或成图的 asset.product
|
||||
(后端已解析归属商品)反查 products——绝不回退到「当前选中商品」(那正是会显示错名的根因)。 */
|
||||
function batchProductTitle(batch: GenBatch): string {
|
||||
if (batch.productTitle) return batch.productTitle;
|
||||
const pid = batch.productId || batch.results.find((a) => a.product)?.product || "";
|
||||
return products.find((p) => p.id === pid)?.title || "未选择";
|
||||
}
|
||||
|
||||
/* ── 批次列表(行31/32/33):每批各一个商品导航头 + 一张卡片 + 底部操作/气泡 ── */
|
||||
function renderBatchCards(list: GenBatch[]) {
|
||||
return (
|
||||
<>
|
||||
@@ -862,7 +854,32 @@ export function ImageWorkbenchPage({
|
||||
const generating = batch.status === "generating";
|
||||
const failed = batch.status === "failed";
|
||||
return (
|
||||
<div className="gen-card gen-batch-card" key={batch.id}>
|
||||
<Fragment key={batch.id}>
|
||||
{/* 行34:每批一个独立导航头——显示该批所属商品(多商品/多批不再合并到一个头) */}
|
||||
<div className="iw-pv-h">
|
||||
<Quote className="quote-icon" />
|
||||
<div className="pv-meta">
|
||||
<b>1 批</b>
|
||||
{mode === "model" ? ` · ${batch.ratio}` : ""}
|
||||
</div>
|
||||
<div className="pv-line">
|
||||
<span className="k">商品</span>
|
||||
<span className="v">{batchProductTitle(batch)}</span>
|
||||
</div>
|
||||
{mode === "cover" && (
|
||||
<div className="pv-line">
|
||||
<span className="k">平台</span>
|
||||
<span className="v">
|
||||
{pickedIds.length
|
||||
? PLATFORM_OPTIONS.filter((p) => pickedIds.includes(p.id))
|
||||
.map((p) => p.name)
|
||||
.join("、")
|
||||
: "未选择"}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="gen-card gen-batch-card">
|
||||
<div className="gen-batch-h">
|
||||
<span className="b-pic">{batch.count}×</span>
|
||||
<div className="b-meta">
|
||||
@@ -902,6 +919,7 @@ export function ImageWorkbenchPage({
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Fragment>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
@@ -1343,35 +1361,8 @@ export function ImageWorkbenchPage({
|
||||
) : (
|
||||
<>
|
||||
<MediaLightbox open={!!preview} src={preview?.src || ""} kind="image" name={preview?.name} close={() => setPreview(null)} />
|
||||
{/* 行34:按商品分组——每个商品一个独立导航头 + 自己的批次列表 */}
|
||||
{batchGroups.map((group) => (
|
||||
<Fragment key={group.key}>
|
||||
<div className="iw-pv-h">
|
||||
<Quote className="quote-icon" />
|
||||
<div className="pv-meta">
|
||||
<b>{group.items.length} 批</b>
|
||||
{mode === "model" ? ` · ${ratio}` : ""}
|
||||
</div>
|
||||
<div className="pv-line">
|
||||
<span className="k">商品</span>
|
||||
<span className="v">{group.title}</span>
|
||||
</div>
|
||||
{mode === "cover" && (
|
||||
<div className="pv-line">
|
||||
<span className="k">平台</span>
|
||||
<span className="v">
|
||||
{pickedIds.length
|
||||
? PLATFORM_OPTIONS.filter((p) => pickedIds.includes(p.id))
|
||||
.map((p) => p.name)
|
||||
.join("、")
|
||||
: "未选择"}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{renderBatchCards(group.items)}
|
||||
</Fragment>
|
||||
))}
|
||||
{/* 行34:每批各自一个导航头(含所属商品)——见 renderBatchCards */}
|
||||
{renderBatchCards(batches)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1330,42 +1330,8 @@ nav button svg { width: 14px; height: 14px; color: var(--ink-3); }
|
||||
.modal-x { margin-left: auto; width: 28px; height: 28px; display: grid; place-items: center; border-radius: 7px; }
|
||||
.modal-x:hover { background: var(--bg-soft); }
|
||||
|
||||
.msg-workbench { display: grid; grid-template-columns: 380px minmax(0, 1fr); gap: 18px; min-height: 640px; }
|
||||
.msg-panel { background: var(--card); border: 1px solid var(--border); min-width: 0; }
|
||||
.msg-panel-h { display: flex; justify-content: space-between; align-items: center; padding: 14px 16px; border-bottom: 1px solid var(--border); }
|
||||
.msg-panel-h .ti { font-weight: 600; }
|
||||
.msg-filters { display: flex; gap: 6px; padding: 12px; flex-wrap: wrap; }
|
||||
.msg-filter { border: 1px solid var(--border); background: var(--card); padding: 5px 9px; font-size: 12px; color: var(--ink-2); }
|
||||
.msg-filter.active { background: var(--orange-tint); border-color: var(--orange-soft); color: var(--orange); font-weight: 600; }
|
||||
.msg-filter .ct { margin-left: 6px; color: var(--ink-3); font-family: 'JetBrains Mono', monospace; }
|
||||
.msg-search { display: flex; align-items: center; gap: 8px; margin: 0 12px 12px; border: 1px solid var(--border); background: var(--bg-soft); padding: 8px 10px; color: var(--ink-3); }
|
||||
.msg-search input { border: 0; background: transparent; flex: 1; }
|
||||
.msg-list { border-top: 1px solid var(--border); }
|
||||
.msg-item { width: 100%; display: grid; grid-template-columns: 28px 1fr; gap: 10px; padding: 12px; border-bottom: 1px solid var(--border); text-align: left; background: var(--card); }
|
||||
.msg-item:hover, .msg-item.active { background: var(--orange-tint); }
|
||||
.msg-type-ic { width: 28px; height: 28px; border: 1px solid var(--border); background: var(--bg-soft); display: grid; place-items: center; color: var(--orange); }
|
||||
.msg-item-row, .msg-item-foot { display: flex; align-items: center; gap: 8px; }
|
||||
.msg-dot { width: 6px; height: 6px; border-radius: 999px; background: var(--orange); }
|
||||
.msg-item-title { font-weight: 600; flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.msg-time, .msg-priority { color: var(--ink-3); font-family: 'JetBrains Mono', monospace; font-size: 12px; }
|
||||
.msg-brief { display: block; color: var(--ink-2); font-size: 12px; line-height: 1.5; margin: 4px 0 7px; }
|
||||
.msg-detail { display: flex; flex-direction: column; }
|
||||
.msg-detail-body { padding: 20px; flex: 1; }
|
||||
.msg-detail-top { display: flex; align-items: flex-start; gap: 12px; margin-bottom: 16px; }
|
||||
.msg-detail-title h2 { font-size: 18px; line-height: 1.35; }
|
||||
.msg-detail-title .meta { color: var(--ink-3); font-family: 'JetBrains Mono', monospace; font-size: 12px; margin-top: 4px; display: flex; gap: 8px; flex-wrap: wrap; }
|
||||
.msg-body-text { color: var(--ink-2); line-height: 1.7; margin-bottom: 16px; }
|
||||
.msg-props { display: grid; grid-template-columns: 90px 1fr; gap: 0; border: 1px solid var(--border); margin-bottom: 16px; }
|
||||
.msg-props .k, .msg-props .v { padding: 9px 12px; border-bottom: 1px solid var(--border); font-size: 12px; }
|
||||
.msg-props .k { background: var(--bg-soft); color: var(--ink-3); font-family: 'JetBrains Mono', monospace; }
|
||||
.msg-timeline { border: 1px solid var(--border); }
|
||||
.msg-timeline-h { padding: 10px 12px; border-bottom: 1px solid var(--border); font-weight: 600; }
|
||||
.msg-step { display: grid; grid-template-columns: 70px 1fr; gap: 10px; padding: 8px 12px; border-bottom: 1px solid var(--border); }
|
||||
.msg-step:last-child { border-bottom: 0; }
|
||||
.msg-step .t { color: var(--orange); font-family: 'JetBrains Mono', monospace; font-size: 12px; }
|
||||
.msg-detail-f { display: flex; gap: 8px; align-items: center; padding: 14px 16px; border-top: 1px solid var(--border); background: var(--bg-soft); }
|
||||
.msg-foot-note { display: flex; justify-content: space-between; margin-top: 12px; color: var(--ink-3); font-family: 'JetBrains Mono', monospace; font-size: 12px; }
|
||||
.msg-foot-note button { color: var(--orange); }
|
||||
/* 消息中心样式统一由 messages-page.css(对齐 design 稿)提供;此处旧版 V1 .msg-* 规则
|
||||
用的是 --ink/--orange/--bg-soft 旧 token,会漏样式(灰底标签、搜索框描边、记录分隔线),已移除。 */
|
||||
|
||||
.factory-hero { display: grid; gap: 18px; margin-bottom: 26px; }
|
||||
.factory-card { background: var(--card); border: 1px solid var(--border); position: relative; padding: 24px; }
|
||||
@@ -1429,7 +1395,7 @@ nav button svg { width: 14px; height: 14px; color: var(--ink-3); }
|
||||
.switch input:checked + .slider::before { transform: translateX(18px); }
|
||||
|
||||
@media (max-width: 1180px) {
|
||||
.wizard, .create-product-layout, .product-detail-layout, .top-grid, .team-top, .msg-workbench, .factory-body, .tool-layout, .demo-layout, .settings-layout, .overview-grid {
|
||||
.wizard, .create-product-layout, .product-detail-layout, .top-grid, .team-top, .factory-body, .tool-layout, .demo-layout, .settings-layout, .overview-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.wiz-preview, .steps, .create-upload-zone, .settings-side { position: static; }
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
{
|
||||
"ts": "2026-06-17T11:26:06.782Z",
|
||||
"ts": "2026-06-17T13:32:23.263Z",
|
||||
"base": "http://127.0.0.1:5174",
|
||||
"apiChecks": [
|
||||
{
|
||||
"name": "page_size 生效 /api/assets/",
|
||||
"pass": true,
|
||||
"detail": "count=248 返回=200 next=有"
|
||||
"detail": "count=261 返回=200 next=有"
|
||||
},
|
||||
{
|
||||
"name": "page_size 生效 /api/products/",
|
||||
"pass": true,
|
||||
"detail": "count=10 返回=10 next=无"
|
||||
"detail": "count=11 返回=11 next=无"
|
||||
},
|
||||
{
|
||||
"name": "page_size 生效 /api/projects/",
|
||||
@@ -21,25 +21,25 @@
|
||||
"name": "延迟 /api/assets/?page_size=200",
|
||||
"pass": true,
|
||||
"soft": true,
|
||||
"detail": "739ms / 参考 1200ms (HTTP 200)"
|
||||
"detail": "808ms / 参考 1200ms (HTTP 200)"
|
||||
},
|
||||
{
|
||||
"name": "延迟 /api/products/?page_size=200",
|
||||
"pass": true,
|
||||
"soft": true,
|
||||
"detail": "573ms / 参考 1200ms (HTTP 200)"
|
||||
"detail": "585ms / 参考 1200ms (HTTP 200)"
|
||||
},
|
||||
{
|
||||
"name": "延迟 /api/projects/?page_size=200",
|
||||
"pass": true,
|
||||
"soft": true,
|
||||
"detail": "482ms / 参考 1200ms (HTTP 200)"
|
||||
"detail": "488ms / 参考 1200ms (HTTP 200)"
|
||||
},
|
||||
{
|
||||
"name": "延迟 /api/ops/notifications/?page_size=100",
|
||||
"pass": false,
|
||||
"soft": true,
|
||||
"detail": "1389ms / 参考 1200ms (HTTP 200)"
|
||||
"detail": "1233ms / 参考 1200ms (HTTP 200)"
|
||||
}
|
||||
],
|
||||
"pageFailed": 0,
|
||||
@@ -62,7 +62,7 @@
|
||||
"GET /api/ops/notifications/?page_size=1",
|
||||
"GET /api/assets/summary/"
|
||||
],
|
||||
"elapsedMs": 5108,
|
||||
"elapsedMs": 4223,
|
||||
"problems": [],
|
||||
"warnings": [],
|
||||
"pass": true
|
||||
@@ -84,7 +84,7 @@
|
||||
"GET /api/ai/models/",
|
||||
"GET /api/ops/notifications/?page_size=1"
|
||||
],
|
||||
"elapsedMs": 3716,
|
||||
"elapsedMs": 3760,
|
||||
"problems": [],
|
||||
"warnings": [],
|
||||
"pass": true
|
||||
@@ -106,7 +106,7 @@
|
||||
"GET /api/ai/models/",
|
||||
"GET /api/ops/notifications/?page_size=1"
|
||||
],
|
||||
"elapsedMs": 3704,
|
||||
"elapsedMs": 3509,
|
||||
"problems": [],
|
||||
"warnings": [],
|
||||
"pass": true
|
||||
@@ -136,7 +136,7 @@
|
||||
"GET /api/assets/summary/",
|
||||
"GET /api/assets/facets/?meta_keys=gender,age,role&tab=people"
|
||||
],
|
||||
"elapsedMs": 4733,
|
||||
"elapsedMs": 3494,
|
||||
"problems": [],
|
||||
"warnings": [],
|
||||
"pass": true
|
||||
@@ -161,7 +161,7 @@
|
||||
"GET /api/auth/team/members/",
|
||||
"GET /api/billing/ledgers/?page=1&page_size=10"
|
||||
],
|
||||
"elapsedMs": 4773,
|
||||
"elapsedMs": 3728,
|
||||
"problems": [],
|
||||
"warnings": [],
|
||||
"pass": true
|
||||
@@ -185,7 +185,7 @@
|
||||
"GET /api/auth/team/members/",
|
||||
"GET /api/ops/notifications/?page_size=100"
|
||||
],
|
||||
"elapsedMs": 5399,
|
||||
"elapsedMs": 3729,
|
||||
"problems": [],
|
||||
"warnings": [],
|
||||
"pass": true
|
||||
@@ -208,7 +208,7 @@
|
||||
"GET /api/ops/notifications/?page_size=1",
|
||||
"GET /api/ops/notifications/?page=1&page_size=10"
|
||||
],
|
||||
"elapsedMs": 3717,
|
||||
"elapsedMs": 3693,
|
||||
"problems": [],
|
||||
"warnings": [],
|
||||
"pass": true
|
||||
@@ -231,7 +231,7 @@
|
||||
"GET /api/ops/notifications/?page_size=1",
|
||||
"GET /api/assets/?page_size=200&product=045ad8d6-8b15-486b-a4a9-f6968eb1555f"
|
||||
],
|
||||
"elapsedMs": 4757,
|
||||
"elapsedMs": 4730,
|
||||
"problems": [],
|
||||
"warnings": [],
|
||||
"pass": true
|
||||
@@ -254,7 +254,7 @@
|
||||
"GET /api/ai/models/",
|
||||
"GET /api/ops/notifications/?page_size=1"
|
||||
],
|
||||
"elapsedMs": 3725,
|
||||
"elapsedMs": 3737,
|
||||
"problems": [],
|
||||
"warnings": [],
|
||||
"pass": true
|
||||
|
||||
Reference in New Issue
Block a user