From 0695c36f4c940e480c5b3c29de2f5d8b52198657 Mon Sep 17 00:00:00 2001 From: zyc <1439655764@qq.com> Date: Wed, 17 Jun 2026 21:58:39 +0800 Subject: [PATCH] =?UTF-8?q?fix(ai):=20=E5=B9=B3=E5=8F=B0=E5=A5=97=E5=9B=BE?= =?UTF-8?q?=E6=94=B9=E8=B5=B0=E5=95=86=E5=93=81=E4=B8=BB=E5=9B=BE=E5=8F=82?= =?UTF-8?q?=E8=80=83=20image=5Fedit=20=C2=B7=20=E6=A8=A1=E7=89=B9=E4=B8=8A?= =?UTF-8?q?=E8=BA=AB=E5=9B=BE=E9=94=81=E5=95=86=E5=93=81+=E6=A8=A1?= =?UTF-8?q?=E7=89=B9=E5=8F=8C=E5=9B=BE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 平台套图(cover)以前纯文生图,不参考真实商品主图 → 出图与商品对不上; 现以商品主图为参考图1走 image_edit,锁包装一致性,有模特则参考图2=模特 - 新增 build_platform_cover_prompt_refs;model_url 解析对 cover 模式同样生效 - 补回归测试:cover 传商品主图、model 传商品主图+模特图、无主图回落文生图 Co-Authored-By: Claude Opus 4.8 --- core/backend/apps/ai/services.py | 111 +++++++++++++++-- core/backend/apps/ai/tests.py | 75 +++++++++++ core/backend/apps/ai/views.py | 4 +- core/backend/apps/ops/tasks.py | 21 ++++ core/backend/apps/ops/tests.py | 86 +++++++++++++ core/backend/apps/ops/views.py | 117 ++++++++++++++++-- core/frontend/src/ai-tools-page.css | 5 +- core/frontend/src/api.ts | 2 +- core/frontend/src/product-detail-page.css | 3 + core/frontend/src/routes/ai-tools.tsx | 85 ++++++------- core/frontend/src/styles.css | 40 +----- .../qa/function-audit/output/perf-report.json | 32 ++--- 12 files changed, 455 insertions(+), 126 deletions(-) create mode 100644 core/backend/apps/ops/tasks.py create mode 100644 core/backend/apps/ops/tests.py diff --git a/core/backend/apps/ai/services.py b/core/backend/apps/ai/services.py index f36742d..14f3ddd 100644 --- a/core/backend/apps/ai/services.py +++ b/core/backend/apps/ai/services.py @@ -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 {}, diff --git a/core/backend/apps/ai/tests.py b/core/backend/apps/ai/tests.py index bbd7f02..35f66ae 100644 --- a/core/backend/apps/ai/tests.py +++ b/core/backend/apps/ai/tests.py @@ -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() diff --git a/core/backend/apps/ai/views.py b/core/backend/apps/ai/views.py index 20bf6b4..b6a4dc1 100644 --- a/core/backend/apps/ai/views.py +++ b/core/backend/apps/ai/views.py @@ -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( diff --git a/core/backend/apps/ops/tasks.py b/core/backend/apps/ops/tasks.py new file mode 100644 index 0000000..c88e5a7 --- /dev/null +++ b/core/backend/apps/ops/tasks.py @@ -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) diff --git a/core/backend/apps/ops/tests.py b/core/backend/apps/ops/tests.py new file mode 100644 index 0000000..92bf300 --- /dev/null +++ b/core/backend/apps/ops/tests.py @@ -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") diff --git a/core/backend/apps/ops/views.py b/core/backend/apps/ops/views.py index e68158f..dabea2a 100644 --- a/core/backend/apps/ops/views.py +++ b/core/backend/apps/ops/views.py @@ -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): diff --git a/core/frontend/src/ai-tools-page.css b/core/frontend/src/ai-tools-page.css index 5b0afa1..f45f8ad 100644 --- a/core/frontend/src/ai-tools-page.css +++ b/core/frontend/src/ai-tools-page.css @@ -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; diff --git a/core/frontend/src/api.ts b/core/frontend/src/api.ts index b2c6e3a..c3affb9 100644 --- a/core/frontend/src/api.ts +++ b/core/frontend/src/api.ts @@ -446,7 +446,7 @@ export const api = { return request>("/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[]) { diff --git a/core/frontend/src/product-detail-page.css b/core/frontend/src/product-detail-page.css index ca3b2b9..6f7067b 100644 --- a/core/frontend/src/product-detail-page.css +++ b/core/frontend/src/product-detail-page.css @@ -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); diff --git a/core/frontend/src/routes/ai-tools.tsx b/core/frontend/src/routes/ai-tools.tsx index 1879520..c515af6 100644 --- a/core/frontend/src/routes/ai-tools.tsx +++ b/core/frontend/src/routes/ai-tools.tsx @@ -779,22 +779,6 @@ export function ImageWorkbenchPage({ const hasResults = batches.length > 0; - /* 按商品分组(行34):各商品各自一个导航头,避免多商品批次被并到同一个头里。 - 保持首次出现顺序;productId 缺失的旧批次归到当前商品组。 */ - const batchGroups = (() => { - const order: string[] = []; - const map = new Map(); - 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 ( -
+ + {/* 行34:每批一个独立导航头——显示该批所属商品(多商品/多批不再合并到一个头) */} +
+ +
+ 1 批 + {mode === "model" ? ` · ${batch.ratio}` : ""} +
+
+ 商品 + {batchProductTitle(batch)} +
+ {mode === "cover" && ( +
+ 平台 + + {pickedIds.length + ? PLATFORM_OPTIONS.filter((p) => pickedIds.includes(p.id)) + .map((p) => p.name) + .join("、") + : "未选择"} + +
+ )} +
+
{batch.count}×
@@ -902,6 +919,7 @@ export function ImageWorkbenchPage({
+
); })} @@ -1343,35 +1361,8 @@ export function ImageWorkbenchPage({ ) : ( <> setPreview(null)} /> - {/* 行34:按商品分组——每个商品一个独立导航头 + 自己的批次列表 */} - {batchGroups.map((group) => ( - -
- -
- {group.items.length} 批 - {mode === "model" ? ` · ${ratio}` : ""} -
-
- 商品 - {group.title} -
- {mode === "cover" && ( -
- 平台 - - {pickedIds.length - ? PLATFORM_OPTIONS.filter((p) => pickedIds.includes(p.id)) - .map((p) => p.name) - .join("、") - : "未选择"} - -
- )} -
- {renderBatchCards(group.items)} -
- ))} + {/* 行34:每批各自一个导航头(含所属商品)——见 renderBatchCards */} + {renderBatchCards(batches)} )}
diff --git a/core/frontend/src/styles.css b/core/frontend/src/styles.css index 5afc305..11f3b0e 100644 --- a/core/frontend/src/styles.css +++ b/core/frontend/src/styles.css @@ -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; } diff --git a/core/qa/function-audit/output/perf-report.json b/core/qa/function-audit/output/perf-report.json index 8e9de8c..05721b6 100644 --- a/core/qa/function-audit/output/perf-report.json +++ b/core/qa/function-audit/output/perf-report.json @@ -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