From 9bdd0683f8b079b4b6141e869e931462599ffef9 Mon Sep 17 00:00:00 2001 From: zyc <1439655764@qq.com> Date: Wed, 17 Jun 2026 15:54:04 +0800 Subject: [PATCH] =?UTF-8?q?fix(core):=20=E5=95=86=E5=93=81=E8=AF=A6?= =?UTF-8?q?=E6=83=85=E9=A1=B5=E4=B8=89=E8=A7=86=E5=9B=BE=E6=94=B9=E8=B5=B0?= =?UTF-8?q?=20image=5Fedit=20=E5=8F=82=E8=80=83=E7=9C=9F=E5=AE=9E=E4=B8=BB?= =?UTF-8?q?=E5=9B=BE=20+=20=E8=AF=A5=E5=95=86=E5=93=81=20AI=20=E7=B4=A0?= =?UTF-8?q?=E6=9D=90=E5=BD=92=E5=B1=9E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 商品详情页「AI 生成三视图」原走独立生图老链路(纯文生图),只凭商品名脑补, 出来通用图不像真品。改:前端传 reference_product;后端 run_standalone_image_task 据此取商品主图走 image_edit(复用 build_product_triview_prompt_refs 锁包装), 取不到主图优雅回落文生图。图片创作页(自由创作)不带标记,不受影响。 - 商品详情页素材区改为只显示「该商品」AI 素材(asset.product 归属)而非全团队。 - 首登水合带重试,失败不再静默(否则页面卡在全 0,要刷新才好)。 Co-Authored-By: Claude Opus 4.8 --- core/backend/apps/ai/services.py | 24 +++++++++++++++++++++--- core/backend/apps/ai/views.py | 4 +++- core/backend/apps/assets/serializers.py | 16 ++++++++++++++++ core/backend/apps/assets/views.py | 3 ++- core/frontend/src/App.tsx | 25 +++++++++++++++++++++---- core/frontend/src/api.ts | 2 +- core/frontend/src/routes/ai-tools.tsx | 6 +++--- core/frontend/src/routes/products.tsx | 21 +++++++++++++++------ core/frontend/src/types.ts | 2 ++ 9 files changed, 84 insertions(+), 19 deletions(-) diff --git a/core/backend/apps/ai/services.py b/core/backend/apps/ai/services.py index d30afdd..eb8eeb3 100644 --- a/core/backend/apps/ai/services.py +++ b/core/backend/apps/ai/services.py @@ -1334,7 +1334,7 @@ def _reap_stale_standalone_image_tasks(*, team) -> None: continue -def enqueue_standalone_images(*, team, user, prompt: str, mode: str = "image", count: int = 1) -> 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) -> list[AITask]: """独立生图(图片创作 / 模特上身图 / 平台套图)改为**异步**:本函数在 Web 请求里只做「建任务 + 预留额度」这种秒级的活,真正 ~30s 的 ARK 出图交给 Celery worker(generate_standalone_image_task)。 @@ -1360,7 +1360,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}, + 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)}, estimated_cost=cost, ) # 预留额度若余额不足会抛 ValueError,在同步的 Web 请求里立刻反馈给前端(不会先建半套任务) @@ -1386,12 +1386,28 @@ def run_standalone_image_task(*, task_id: str) -> None: prompt = str(payload.get("prompt") or "") 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) model_config = task.model_config provider = get_image_provider(model_config) reservation = task.credit_reservation + # 商品三视图(商品详情页按 reference_product 提交):有真实商品主图 + 模型支持 image_edit → + # 以主图为参考锁包装一致性,否则回落纯文生图(仅凭商品名脑补,不保证还原真实包装)。 + ref_url = "" + product = None + if bool(payload.get("reference_product")) and 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") try: - response = provider.image_generation(model=model_config.name, endpoint=model_config.endpoint, prompt=prompt) + 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") + else: + response = provider.image_generation(model=model_config.name, endpoint=model_config.endpoint, prompt=prompt) media = provider.extract_first_media_url(response) with transaction.atomic(): task.status = AITask.Status.SUCCEEDED @@ -1408,6 +1424,8 @@ def run_standalone_image_task(*, task_id: str) -> None: asset = Asset.objects.create( id=asset_id, team=team, created_by=user, name=f"AI 生成 · {mode} · {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 {}, ) AssetFile.objects.create(asset=asset, object_key=stored.object_key, bucket=stored.bucket, content_type=stored.content_type, size_bytes=stored.size_bytes, is_primary=True) except Exception as exc: # noqa: BLE001 — 失败要退费并把错误记进 AITask 供前端轮询读取;不向上抛(避免 celery 重试二次扣费) diff --git a/core/backend/apps/ai/views.py b/core/backend/apps/ai/views.py index da1693a..20bf6b4 100644 --- a/core/backend/apps/ai/views.py +++ b/core/backend/apps/ai/views.py @@ -29,9 +29,11 @@ class GenerateImageView(APIView): count = int(request.data.get("count") or 1) except (TypeError, ValueError): count = 1 + product_id = str(request.data.get("product_id") or "").strip() or None + reference_product = bool(request.data.get("reference_product")) team = get_current_team(request.user) try: - tasks = enqueue_standalone_images(team=team, user=request.user, prompt=prompt, mode=mode, count=count) + tasks = enqueue_standalone_images(team=team, user=request.user, prompt=prompt, mode=mode, count=count, product_id=product_id, reference_product=reference_product) except ValueError as exc: # 无可用模型 / 余额不足等,立即反馈 return Response({"detail": str(exc)}, status=status.HTTP_400_BAD_REQUEST) return Response( diff --git a/core/backend/apps/assets/serializers.py b/core/backend/apps/assets/serializers.py index 189fc0e..2b94117 100644 --- a/core/backend/apps/assets/serializers.py +++ b/core/backend/apps/assets/serializers.py @@ -58,6 +58,21 @@ class AssetFileSerializer(serializers.ModelSerializer): class AssetSerializer(serializers.ModelSerializer): files = AssetFileSerializer(many=True, read_only=True) + # 资产归属商品(只读):商品详情页据此只展示「该商品」的 AI 素材,而非全团队 + product = serializers.SerializerMethodField() + + def get_product(self, obj): + """解析资产所属商品: + 1) 独立生图(图片创作/模特图/平台套图):生图时已写入 metadata.product_id; + 2) 项目内生成(基础资产/分镜图等):回溯 origin_task → project → product。 + 两条路都拿不到则归属 None(如纯手动上传到素材库、与任何商品无关的图)。""" + pid = (obj.metadata or {}).get("product_id") + if pid: + return str(pid) + task = obj.origin_task + if task and task.project_id and task.project.product_id: + return str(task.project.product_id) + return None class Meta: model = Asset @@ -71,6 +86,7 @@ class AssetSerializer(serializers.ModelSerializer): "metadata", "is_deleted", "origin_task", + "product", "files", "review_status", "review_error", diff --git a/core/backend/apps/assets/views.py b/core/backend/apps/assets/views.py index 6f45634..a0f7bb9 100644 --- a/core/backend/apps/assets/views.py +++ b/core/backend/apps/assets/views.py @@ -28,7 +28,8 @@ class AssetPagination(PageNumberPagination): class AssetViewSet(TeamScopedViewSetMixin, ModelViewSet): - queryset = Asset.objects.prefetch_related("files").all() + # select_related 回溯链:asset → origin_task → project,供序列化器解析资产归属商品(避免 N+1) + queryset = Asset.objects.prefetch_related("files").select_related("origin_task__project").all() serializer_class = AssetSerializer pagination_class = AssetPagination search_fields = ["name", "description"] diff --git a/core/frontend/src/App.tsx b/core/frontend/src/App.tsx index c2a155f..1ec9a78 100644 --- a/core/frontend/src/App.tsx +++ b/core/frontend/src/App.tsx @@ -168,6 +168,21 @@ export function App() { setActiveProductId((current) => current || productData.results[0]?.id || ""); }, []); + // 首登水合带重试:loadData 里 products/projects/allAssets 没有 .catch,任一瞬时失败会让整个 + // Promise.all 直接 reject、一个 setter 都不跑 → 页面卡在全 0。boot 路径有 api.me 重试兜底, + // 故刷新就好;首登(onAuthed)以前是 void loadData().catch(log) 静默吞掉 → 数据全错。这里统一重试。 + const loadDataWithRetry = useCallback(async (attempts = 3) => { + for (let attempt = 0; attempt < attempts; attempt += 1) { + try { + await loadData(); + return; + } catch (error) { + if (attempt === attempts - 1) throw error; + await new Promise((resolve) => setTimeout(resolve, 1000)); + } + } + }, [loadData]); + // 设置页数据:偏好 + 登录会话(进入设置页时按需加载) const loadSettingsData = useCallback(async () => { const [pref, sess] = await Promise.all([ @@ -227,7 +242,7 @@ export function App() { if (cancelled || !identity) return; setUser(identity.user); setTeam(identity.team); - await loadData(); + await loadDataWithRetry(); } catch (bootError) { console.error("[boot] failed:", bootError); setToken(null); @@ -239,7 +254,7 @@ export function App() { return () => { cancelled = true; }; - }, [loadData]); + }, [loadDataWithRetry]); // Keep route in sync with browser navigation. useEffect(() => { @@ -393,7 +408,7 @@ export function App() { if (res) setUser(res); } - function generateImages(payload: { prompt: string; mode?: "image" | "model" | "cover"; count?: number }) { + function generateImages(payload: { prompt: string; mode?: "image" | "model" | "cover"; count?: number; product_id?: string; reference_product?: boolean }) { // 异步生图:提交后立刻拿到任务列表,前端轮询直到出图。慢的 ARK 出图在 Celery worker 里跑—— // Web 层不被 ~30s 请求占住 → 健康探针不饿死 → 根治"几张图整站 502";且提交成功后浏览器关掉/断网, // worker 仍会把图生成并落库(扣费/退费在 worker 内闭环),重开素材库即可见。 @@ -487,8 +502,10 @@ export function App() { setBooting(false); setAuthed(true); navigate("dashboard", { replace: true }); - void loadData().catch((error) => { + // 首登水合:与 boot 路径一致地重试,失败不再静默(否则页面卡在全 0,要刷新才好) + loadDataWithRetry().catch((error) => { console.error("[login] data hydrate failed:", error); + setNotice({ type: "error", text: "数据加载失败,请刷新页面重试" }); }); } diff --git a/core/frontend/src/api.ts b/core/frontend/src/api.ts index 13afa54..656c8b2 100644 --- a/core/frontend/src/api.ts +++ b/core/frontend/src/api.ts @@ -417,7 +417,7 @@ export const api = { return request>("/api/ai/tasks/"); }, // 异步生图:提交后秒级返回任务列表(慢出图在 worker 跑),再用 generateImageStatus 轮询取结果 - submitGenerateImage(payload: { prompt: string; mode?: "image" | "model" | "cover"; count?: number }) { + submitGenerateImage(payload: { prompt: string; mode?: "image" | "model" | "cover"; count?: number; product_id?: string; reference_product?: boolean }) { 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/routes/ai-tools.tsx b/core/frontend/src/routes/ai-tools.tsx index f0fb41f..62f3af1 100644 --- a/core/frontend/src/routes/ai-tools.tsx +++ b/core/frontend/src/routes/ai-tools.tsx @@ -485,7 +485,7 @@ export function ImageWorkbenchPage({ modelConfigs: ModelConfig[]; onBack: () => void; navigate?: (page: Page) => void; - onGenerate: (payload: { prompt: string; mode?: "image" | "model" | "cover"; count?: number }) => Promise<{ assets: Asset[] } | null>; + onGenerate: (payload: { prompt: string; mode?: "image" | "model" | "cover"; count?: number; product_id?: string }) => Promise<{ assets: Asset[] } | null>; onResume?: (mode: "image" | "model" | "cover", ids: string[]) => Promise<{ assets: Asset[] } | null>; /** 行19:从商品页带入的初始商品 id(可选);未传则回退到第一个商品 */ initialProductId?: string; @@ -574,7 +574,7 @@ export function ImageWorkbenchPage({ // 追加新批次(行32②:不覆盖,在下面新增一行) setBatches((prev) => [...prev, newBatch]); try { - const result = await onGenerate({ prompt: prompt.trim(), mode, count: candidateCount }); + const result = await onGenerate({ prompt: prompt.trim(), mode, count: candidateCount, product_id: product?.id }); setBatches((prev) => { const next = prev.map((b) => b.id === batchId @@ -608,7 +608,7 @@ export function ImageWorkbenchPage({ }; setBatches((prev) => [...prev, newBatch]); try { - const result = await onGenerate({ prompt: src.prompt, mode, count: src.count }); + const result = await onGenerate({ prompt: src.prompt, mode, count: src.count, product_id: product?.id }); setBatches((prev) => { const next = prev.map((b) => b.id === batchId diff --git a/core/frontend/src/routes/products.tsx b/core/frontend/src/routes/products.tsx index 7c802ca..fbc89a5 100644 --- a/core/frontend/src/routes/products.tsx +++ b/core/frontend/src/routes/products.tsx @@ -668,7 +668,7 @@ export function ProductDetailPage({ product, projects, assets, initialTab = "ass onUpdate: (payload: Partial) => Promise | void; onUploadImage?: (formData: FormData) => Promise | void; onDeleteImage?: (imageId: string) => Promise | void; - onGenerateImages?: (payload: { prompt: string; mode?: "image" | "model" | "cover"; count?: number }) => Promise<{ assets: Asset[] } | null>; + onGenerateImages?: (payload: { prompt: string; mode?: "image" | "model" | "cover"; count?: number; product_id?: string; reference_product?: boolean }) => Promise<{ assets: Asset[] } | null>; // 采用三视图版本:把该版本的 asset 作为商品图持久化进 AI 素材库(由 App.tsx 接线;不传则仅本地标记) onAdoptTriView?: (asset: Asset) => Promise | void; }) { @@ -727,7 +727,10 @@ export function ProductDetailPage({ product, projects, assets, initialTab = "ass const res = await onGenerateImages({ prompt: `${product.title || "商品"} 白底商品三视图,正面 / 侧面 / 背面三视图并排于同一张 16:9 横图,电商主图,干净白底,高清`, mode: "image", - count: 1 + count: 1, + product_id: product.id, + // 锁真实商品主图:后端据此走 image_edit 以主图为参考,而非纯文生图脑补 + reference_product: true }); const asset = res?.assets?.[0]; const url = asset?.files?.[0]?.preview_url; @@ -756,10 +759,16 @@ export function ProductDetailPage({ product, projects, assets, initialTab = "ass const productImages = imageIds .map((id) => ({ id, url: pdAssetPreview(assetById.get(id)) })); - // AI 生成素材 · 团队资产中筛与该商品相关的类别(模特/场景/三视图/商品图/背景),取真图;无则回退到全部图片资产 + // AI 生成素材 · 只取「该商品」的 AI 素材,而非全团队资产。归属由后端解析的 asset.product 给出 + // (独立生图记 metadata.product_id;项目内生成的图回溯 origin_task→project→product,历史图也能归位); + // 再纳入该商品上传的商品图(product.images 的 asset)。 const AI_CATS = new Set(["product_image", "person", "scene", "tri_view", "background"]); - const aiSource = assets.filter((asset) => AI_CATS.has(asset.category) || AI_CATS.has(asset.asset_type)); - const allImageAssets = aiSource.length ? aiSource : assets.filter((asset) => asset.asset_type === "image"); + const productImageIds = new Set(imageIds); + const belongsToProduct = (asset: Asset) => + asset.product === product.id || productImageIds.has(asset.id); + const allImageAssets = assets.filter( + (asset) => belongsToProduct(asset) && (AI_CATS.has(asset.category) || AI_CATS.has(asset.asset_type) || asset.asset_type === "image") + ); // 类型筛选选项(当前素材里真实存在的 category) const typeOptions = Array.from(new Set(allImageAssets.map((a) => a.category).filter(Boolean))); const filteredAssets = allImageAssets @@ -1008,7 +1017,7 @@ export function ProductDetailPage({ product, projects, assets, initialTab = "ass
-
全部 AI 素材 ({assetCount})
+
该商品 AI 素材 ({assetCount})