From 4bed7425a87f751c2eaf2d1386fc525c5e6e58f4 Mon Sep 17 00:00:00 2001 From: yangqianqian <5845211314@qq.com> Date: Thu, 18 Jun 2026 17:32:49 +0800 Subject: [PATCH] =?UTF-8?q?A8=E5=A4=9A=E5=A5=97=E6=89=93=E5=8C=85+M4?= =?UTF-8?q?=E5=BD=92=E6=A1=A3+R5=E5=A4=9A=E5=9B=BE:=E5=AD=98=E9=87=8F?= =?UTF-8?q?=E5=8A=9F=E8=83=BD=E5=A4=87=E4=BB=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A8 多套交付包(packaging_task.py): - 修复交付包只打第1条混乱note的bug,按ImageCandidate.strategy分A/B/C组 - 每组生独立note_0N夹(6图+文案.txt),同seq留最新去重,老数据兼容 - task74端到端验:3套各6图,独立agent7项交叉验证全过 M4 归档(tasks.py/exports.py/前端): - list_tasks加date_from/date_to/product_id筛选+product_name批量填(防N+1) - 新增exports.py:产品JSON导出+标杆CSV导出(UTF-8 BOM) - 前端HistoryFilters日期/产品筛选+产品列+打回原因红banner - response.py加raise_param_error;独立agent验A1/A2/A9通过 R5 产品多图(product_images.py/020迁移/前端): - product_images表+5端点(上传/列/改场景/设主图/删图) - 生图按ROLE_SCENE_PREFERENCE选对应场景图,回落primary - 前端ProductImageManager多图画廊 R6 账号config拆页(settings/): - 配置页按角色拆/settings(运营+组长+admin)+/config(仅admin) - Key只显末4位不显余额(守红线) 核销表对齐真实代码状态:D1改稿框/M7裂变/E12评图分纠偏为已完成(曾漏回写) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.8 --- .../versions/020_product_images_table.py | 47 ++++++ backend/app/api/v1/exports.py | 100 ++++++++++++ backend/app/api/v1/product_images.py | 121 ++++++++++++++ backend/app/api/v1/products.py | 67 +++++++- backend/app/api/v1/task_actions.py | 20 ++- backend/app/api/v1/tasks.py | 31 +++- backend/app/constants/enums.py | 19 +++ backend/app/core/config.py | 3 +- backend/app/core/response.py | 4 + backend/app/models/product.py | 35 +++- .../app/services/ai_engine/_text_prompt.py | 34 ++++ backend/app/services/ai_engine/constants.py | 17 ++ backend/app/services/ai_engine/image_gen.py | 45 ++++- .../ai_engine/preference_aggregator.py | 3 +- backend/app/services/fission_image_retry.py | 14 ++ backend/app/services/fission_images.py | 14 ++ backend/app/services/flywheel_service.py | 63 +++---- backend/app/services/task_service.py | 31 ++++ backend/app/workers/packaging_task.py | 73 +++++---- backend/app/workers/pipeline_io.py | 22 ++- backend/app/workers/pipeline_steps.py | 45 ++++- backend/app/workers/tasks.py | 4 + backend/main.py | 5 + backend/tests/test_flywheel_wiring_p34.py | 5 +- frontend/src/app/config/page.tsx | 18 +- frontend/src/app/history/HistoryFilters.tsx | 77 +++++++++ frontend/src/app/history/components.tsx | 20 ++- frontend/src/app/history/page.tsx | 22 ++- frontend/src/app/settings/page.tsx | 53 ++++++ frontend/src/app/tasks/new/page.tsx | 13 +- frontend/src/components/AuthGuard.tsx | 3 +- .../components/config/ProductImageManager.tsx | 154 ++++++++++++++++++ .../config/ProductSubComponents.tsx | 103 ++---------- frontend/src/components/layout/Sidebar.tsx | 5 +- frontend/src/components/tasks/NewTaskForm.tsx | 38 ++++- frontend/src/types/dto.tasks.ts | 1 + frontend/src/types/dto.ts | 15 +- frontend/src/types/index.ts | 1 + plans/R-裂变重写核销表.md | 22 +++ plans/总核销表.md | 56 +++---- plans/核销-第12环-归档.md | 24 +-- 41 files changed, 1211 insertions(+), 236 deletions(-) create mode 100644 backend/alembic/versions/020_product_images_table.py create mode 100644 backend/app/api/v1/exports.py create mode 100644 backend/app/api/v1/product_images.py create mode 100644 frontend/src/app/history/HistoryFilters.tsx create mode 100644 frontend/src/app/settings/page.tsx create mode 100644 frontend/src/components/config/ProductImageManager.tsx diff --git a/backend/alembic/versions/020_product_images_table.py b/backend/alembic/versions/020_product_images_table.py new file mode 100644 index 0000000..340b0ad --- /dev/null +++ b/backend/alembic/versions/020_product_images_table.py @@ -0,0 +1,47 @@ +"""create product_images table + +Revision ID: 020 +Revises: 019 +Create Date: 2026-06-18 + +R5 产品多图:一产品多张参考图,每张标 scene 场景类型(primary/scene/ +texture/ingredient/model),生图按分镜 role 选用对应图,取不到回落主图。 +product.image_path 仍保留当主图(向后兼容,零破坏)。 +""" +from alembic import op +import sqlalchemy as sa + +revision = "020" +down_revision = "019" +branch_labels = None +depends_on = None + +_SCENES = ("primary", "scene", "texture", "ingredient", "model") + + +def upgrade(): + op.create_table( + "product_images", + sa.Column("id", sa.BigInteger, primary_key=True, autoincrement=True), + sa.Column( + "product_id", sa.BigInteger, + sa.ForeignKey("products.id", ondelete="CASCADE"), + nullable=False, + ), + sa.Column("path", sa.String(512), nullable=False, comment="绝对路径,worker直接open"), + sa.Column( + "scene", sa.Enum(*_SCENES, name="productimagescene"), + nullable=False, server_default="primary", + comment="场景类型:白底主图/使用场景/质地/成分/上脸", + ), + sa.Column("is_primary", sa.Boolean, nullable=False, server_default=sa.false(), + comment="主图(同步 product.image_path)"), + sa.Column("sort_order", sa.Integer, nullable=False, server_default="0"), + sa.Column("created_at", sa.DateTime, server_default=sa.func.now(), nullable=False), + ) + op.create_index("idx_product_images_product_id", "product_images", ["product_id"]) + + +def downgrade(): + op.drop_index("idx_product_images_product_id", table_name="product_images") + op.drop_table("product_images") diff --git a/backend/app/api/v1/exports.py b/backend/app/api/v1/exports.py new file mode 100644 index 0000000..2070ba4 --- /dev/null +++ b/backend/app/api/v1/exports.py @@ -0,0 +1,100 @@ +""" +app/api/v1/exports.py — 客户原始输入物导出(数据归属红线) +产品/标杆属客户 client_data,可导出(JSON/CSV);飞轮偏好属平台不在此列。 +require_write_permission:仅 workspace 成员可导出本工作区数据。 +""" + +import csv +import io +import json +import logging +from typing import Annotated + +from fastapi import APIRouter, Depends, Query +from fastapi.responses import StreamingResponse +from sqlalchemy.orm import Session + +from app.core.database import get_db +from app.middleware.workspace_guard import CurrentUser, require_write_permission +from app.models.product import BenchmarkNote, Product + +logger = logging.getLogger(__name__) +router = APIRouter(tags=["exports"]) + + +def _csv_response(rows: list[dict], fieldnames: list[str], filename: str) -> StreamingResponse: + """通用 CSV 流式响应。加 UTF-8 BOM 防 Windows Excel 中文乱码。""" + buf = io.StringIO() + buf.write("") # BOM + writer = csv.DictWriter(buf, fieldnames=fieldnames, extrasaction="ignore") + writer.writeheader() + for r in rows: + writer.writerow(r) + buf.seek(0) + return StreamingResponse( + iter([buf.getvalue()]), + media_type="text/csv; charset=utf-8", + headers={"Content-Disposition": f'attachment; filename="{filename}"'}, + ) + + +@router.get("/products/export") +def export_products( + current_user: Annotated[CurrentUser, Depends(require_write_permission)] = None, + db: Session = Depends(get_db), +): + """产品档案导出(JSON)。selling_points 还原为数组,非 JSON 字符串。""" + rows = ( + db.query(Product) + .filter(Product.workspace_id == current_user.workspace_id, Product.is_active == True) + .order_by(Product.id) + .all() + ) + data = [ + { + "id": p.id, + "name": p.name, + "category": p.category, + "selling_points": json.loads(p.selling_points) if p.selling_points else [], + "brand_keyword": p.brand_keyword, + "target_audience": p.target_audience, + "style_tone": p.style_tone, + "created_at": p.created_at.isoformat(), + } + for p in rows + ] + # 直接返数组(非包 ok 信封):导出供下载/二次处理,前端按 blob 存盘。 + return data + + +@router.get("/benchmarks/export") +def export_benchmarks( + format: str = Query(default="json", pattern="^(json|csv)$"), + current_user: Annotated[CurrentUser, Depends(require_write_permission)] = None, + db: Session = Depends(get_db), +): + """标杆笔记导出(JSON/CSV)。JOIN Product 填 product_name 便于客户阅读。""" + rows = ( + db.query(BenchmarkNote, Product.name) + .outerjoin(Product, BenchmarkNote.product_id == Product.id) + .filter(BenchmarkNote.workspace_id == current_user.workspace_id) + .order_by(BenchmarkNote.id) + .all() + ) + data = [ + { + "id": b.id, + "product_id": b.product_id, + "product_name": pname, + "highlights": b.highlights or "", + "link_url": b.link_url or "", + "analyze_status": b.analyze_status, + "created_at": b.created_at.isoformat(), + } + for b, pname in rows + ] + if format == "csv": + fields = ["id", "product_id", "product_name", "highlights", + "link_url", "analyze_status", "created_at"] + return _csv_response(data, fields, "benchmarks.csv") + return data diff --git a/backend/app/api/v1/product_images.py b/backend/app/api/v1/product_images.py new file mode 100644 index 0000000..48a4d55 --- /dev/null +++ b/backend/app/api/v1/product_images.py @@ -0,0 +1,121 @@ +""" +app/api/v1/product_images.py — R5 产品多图管理(列/删/改场景/设主图) + +上传走 products.py 的 /products/{id}/upload-image(带 scene)。 +本文件管已上传图的增删改。product.image_path 始终同步当前主图(向后兼容)。 +""" +import logging +from typing import Annotated + +from fastapi import APIRouter, Depends +from pydantic import BaseModel +from sqlalchemy.orm import Session + +from app.constants.enums import ProductImageScene +from app.core.database import get_db +from app.core.response import ok, raise_not_found, raise_business +from app.middleware.workspace_guard import CurrentUser, require_write_permission +from app.models.product import Product, ProductImage + +logger = logging.getLogger(__name__) +router = APIRouter(tags=["product-images"]) + + +class SceneUpdate(BaseModel): + scene: str + + +def _get_product(db: Session, product_id: int, workspace_id: int) -> Product: + p = db.query(Product).filter( + Product.id == product_id, Product.workspace_id == workspace_id + ).first() + if not p: + raise_not_found("产品不存在") + return p + + +def _get_image(db: Session, product_id: int, image_id: int) -> ProductImage: + img = db.query(ProductImage).filter( + ProductImage.id == image_id, ProductImage.product_id == product_id + ).first() + if not img: + raise_not_found("产品图不存在") + return img + + +@router.get("/products/{product_id}/images") +def list_images( + product_id: int, + current_user: Annotated[CurrentUser, Depends(require_write_permission)] = None, + db: Session = Depends(get_db), +): + _get_product(db, product_id, current_user.workspace_id) + imgs = db.query(ProductImage).filter( + ProductImage.product_id == product_id + ).order_by(ProductImage.sort_order).all() + return ok([ + {"id": im.id, "path": im.path, "scene": im.scene, + "is_primary": im.is_primary, "sort_order": im.sort_order} + for im in imgs + ]) + + +@router.put("/products/{product_id}/images/{image_id}/scene") +def update_scene( + product_id: int, image_id: int, body: SceneUpdate, + current_user: Annotated[CurrentUser, Depends(require_write_permission)] = None, + db: Session = Depends(get_db), +): + _get_product(db, product_id, current_user.workspace_id) + if body.scene not in {s.value for s in ProductImageScene}: + raise_business(f"非法场景类型 {body.scene}") + img = _get_image(db, product_id, image_id) + img.scene = body.scene + db.commit() + logger.info("product image scene updated: id=%s scene=%s", image_id, body.scene) + return ok({"id": image_id, "scene": body.scene}) + + +@router.put("/products/{product_id}/images/{image_id}/primary") +def set_primary( + product_id: int, image_id: int, + current_user: Annotated[CurrentUser, Depends(require_write_permission)] = None, + db: Session = Depends(get_db), +): + p = _get_product(db, product_id, current_user.workspace_id) + img = _get_image(db, product_id, image_id) + db.query(ProductImage).filter( + ProductImage.product_id == product_id, ProductImage.is_primary == True + ).update({"is_primary": False}) + img.is_primary = True + p.image_path = img.path # 同步主图字段(管道/校验读此字段) + db.commit() + logger.info("product primary image set: product_id=%s image_id=%s", product_id, image_id) + return ok({"product_id": product_id, "primary_image_id": image_id}) + + +@router.delete("/products/{product_id}/images/{image_id}") +def delete_image( + product_id: int, image_id: int, + current_user: Annotated[CurrentUser, Depends(require_write_permission)] = None, + db: Session = Depends(get_db), +): + p = _get_product(db, product_id, current_user.workspace_id) + img = _get_image(db, product_id, image_id) + was_primary = img.is_primary + db.delete(img) + db.flush() + # 删的是主图:把剩余 sort_order 最小的一张顶上来当主图,保证 image_path 不悬空 + if was_primary: + nxt = db.query(ProductImage).filter( + ProductImage.product_id == product_id + ).order_by(ProductImage.sort_order).first() + if nxt: + nxt.is_primary = True + p.image_path = nxt.path + else: + p.image_path = None + db.commit() + logger.info("product image deleted: product_id=%s image_id=%s was_primary=%s", + product_id, image_id, was_primary) + return ok({"deleted": image_id}) diff --git a/backend/app/api/v1/products.py b/backend/app/api/v1/products.py index 29bb38d..9b53b54 100644 --- a/backend/app/api/v1/products.py +++ b/backend/app/api/v1/products.py @@ -7,14 +7,14 @@ category 是纯数据字段,不在代码里做枚举(基石A)。 import logging from typing import Annotated -from fastapi import APIRouter, Depends, UploadFile, File +from fastapi import APIRouter, Depends, UploadFile, File, Form from pydantic import BaseModel from sqlalchemy.orm import Session from app.core.database import get_db from app.core.response import ok, paginate, raise_not_found from app.middleware.workspace_guard import CurrentUser, require_admin, require_write_permission -from app.models.product import BannedWord, BenchmarkNote, Product +from app.models.product import BannedWord, BenchmarkNote, Product, ProductImage logger = logging.getLogger(__name__) router = APIRouter(tags=["products"]) @@ -50,6 +50,7 @@ class BannedWordCreate(BaseModel): def _fmt_product(p: Product) -> dict: import json + imgs = sorted(getattr(p, "images", None) or [], key=lambda im: im.sort_order) return { "id": p.id, "name": p.name, "category": p.category, "source": p.source, "is_active": p.is_active, @@ -58,6 +59,11 @@ def _fmt_product(p: Product) -> dict: "text_angles": json.loads(p.text_angles) if p.text_angles else [], "custom_prompt": p.custom_prompt, "image_path": p.image_path, + "images": [ + {"id": im.id, "path": im.path, "scene": im.scene, + "is_primary": im.is_primary, "sort_order": im.sort_order} + for im in imgs + ], "brand_keyword": p.brand_keyword, # 012: 套2字段暴露 "target_audience": p.target_audience, # 012: 套2字段暴露 "created_at": p.created_at.isoformat(), @@ -82,7 +88,7 @@ def list_products( @router.post("/products") def create_product( body: ProductCreate, - current_user: Annotated[CurrentUser, Depends(require_admin)] = None, + current_user: Annotated[CurrentUser, Depends(require_write_permission)] = None, db: Session = Depends(get_db), ): import json @@ -118,7 +124,7 @@ def get_product( @router.put("/products/{product_id}") def update_product( product_id: int, body: ProductCreate, - current_user: Annotated[CurrentUser, Depends(require_admin)] = None, + current_user: Annotated[CurrentUser, Depends(require_write_permission)] = None, db: Session = Depends(get_db), ): import json @@ -155,10 +161,28 @@ _ALLOWED_CONTENT_TYPES = {"image/jpeg", "image/png", "image/webp"} _MAX_SIZE_BYTES = 10 * 1024 * 1024 # 10 MB +def _check_image_magic(data: bytes) -> bool: + """ + 校验文件头 magic number,防 .exe 改名 .jpg 伪造 content_type 绕过。 + content_type 来自客户端可伪造,magic number 是真实字节,二者都要过。 + JPEG: FF D8 FF / PNG: 89 50 4E 47 0D 0A 1A 0A / WebP: RIFF....WEBP + """ + if len(data) < 12: + return False + if data[:3] == b"\xff\xd8\xff": # JPEG + return True + if data[:8] == b"\x89PNG\r\n\x1a\n": # PNG + return True + if data[:4] == b"RIFF" and data[8:12] == b"WEBP": # WebP + return True + return False + + @router.post("/products/{product_id}/upload-image") async def upload_product_image( product_id: int, file: UploadFile = File(...), + scene: str = Form("primary"), current_user: Annotated[CurrentUser, Depends(require_write_permission)] = None, db: Session = Depends(get_db), ): @@ -166,10 +190,12 @@ async def upload_product_image( 上传产品参考图(铁律:生图必须带产品图)。 文件类型:JPEG/PNG/WebP;大小上限 10 MB。 存储路径:uploads/products/{workspace_id}/{product_id}/{filename} - 写回 product.image_path,生图管道读此字段构建 reference_images。 + R5多图:每次上传落一条 product_images(带 scene 场景类型)。 + 首张或 scene=primary 时同步写 product.image_path 当主图(向后兼容)。 """ from app.core.response import raise_business from app.core.config import get_settings + from app.constants.enums import ProductImageScene import os, uuid p = db.query(Product).filter( @@ -179,12 +205,18 @@ async def upload_product_image( if not p: raise_not_found("产品不存在") + valid_scenes = {s.value for s in ProductImageScene} + if scene not in valid_scenes: + raise_business(f"非法场景类型 {scene},仅支持 {sorted(valid_scenes)}") + if file.content_type not in _ALLOWED_CONTENT_TYPES: raise_business(f"不支持的文件类型 {file.content_type},仅支持 JPEG/PNG/WebP") data = await file.read() if len(data) > _MAX_SIZE_BYTES: raise_business("文件超过 10 MB 限制") + if not _check_image_magic(data): + raise_business("文件内容与扩展名不符(非真实 JPEG/PNG/WebP 图片)") settings = get_settings() ext = os.path.splitext(file.filename or "img.jpg")[1] or ".jpg" @@ -197,10 +229,29 @@ async def upload_product_image( with open(save_path, "wb") as f: f.write(data) - p.image_path = save_path # 绝对路径,worker 直接 open 可读 + # 是否首张:决定要不要兜底设主图 + existing = db.query(ProductImage).filter( + ProductImage.product_id == product_id + ).count() + is_primary = (existing == 0) or (scene == ProductImageScene.PRIMARY.value) + if is_primary: + # 同产品其它主图标记降级,保证唯一主图 + db.query(ProductImage).filter( + ProductImage.product_id == product_id, + ProductImage.is_primary == True, + ).update({"is_primary": False}) + + img = ProductImage( + product_id=product_id, path=save_path, scene=scene, + is_primary=is_primary, sort_order=existing, + ) + db.add(img) + if is_primary: + p.image_path = save_path # 向后兼容:管道/校验仍读此字段当主图 db.commit() db.refresh(p) - logger.info("product image uploaded: product_id=%s path=%s", product_id, save_path) + logger.info("product image uploaded: product_id=%s scene=%s primary=%s path=%s", + product_id, scene, is_primary, save_path) return ok(_fmt_product(p)) @@ -273,6 +324,8 @@ async def analyze_product_image( data = await file.read() if len(data) > _MAX_SIZE_BYTES: raise_business("文件超过 10 MB 限制") + if not _check_image_magic(data): + raise_business("文件内容与扩展名不符(非真实 JPEG/PNG/WebP 图片)") # key 解密(照抄 pipeline_steps.decrypt_user_key,基石B) api_key_row = db.query(UserApiKey).filter( diff --git a/backend/app/api/v1/task_actions.py b/backend/app/api/v1/task_actions.py index b93a3bc..8f469c6 100644 --- a/backend/app/api/v1/task_actions.py +++ b/backend/app/api/v1/task_actions.py @@ -58,7 +58,9 @@ def select_image( db: Session = Depends(get_db), ): """选图(飞轮信号 image_select +3)。""" + import json from app.services.flywheel_service import record_signal + from app.constants.enums import IMAGE_STRATEGY_ANGLE task = _check_task_ownership( db.query(GenerationTask).filter(GenerationTask.id == task_id).first(), current_user.workspace_id, @@ -70,7 +72,14 @@ def select_image( raise_not_found("图片候选不存在") ic.is_selected = True db.commit() - record_signal(db, current_user, task, "image_select", candidate_id=ic.id) + # R7 断点2:选图带 strategy → 映射叙事角度标签进飞轮,与文案 angle_label 并轨; + # strategy 原值另存 signal_meta,便于后续按套别复盘。angle_label=None 不再空转。 + angle = IMAGE_STRATEGY_ANGLE.get(ic.strategy or "") + meta = json.dumps({"strategy": ic.strategy, "role": ic.role}, ensure_ascii=False) if ic.strategy else None + record_signal( + db, current_user, task, "image_select", + candidate_id=ic.id, angle_label=angle, signal_meta=meta, + ) return ok({"selected": body.candidate_id}) @@ -159,7 +168,14 @@ def get_preference_context( current_user.workspace_id, ) from app.services.flywheel_service import get_preference_context - ctx = get_preference_context(db, current_user.workspace_id, task.product_id) + # 取产品档案供冷启动基线(与生产链同口径) + from app.models.product import Product + _p = db.query(Product).filter(Product.id == task.product_id).first() + product_dict = { + "custom_prompt": getattr(_p, "custom_prompt", "") or "", + "style_tone": getattr(_p, "style_tone", "") or "", + } if _p else {} + ctx = get_preference_context(db, current_user.workspace_id, task.product_id, product_dict) return ok(ctx) diff --git a/backend/app/api/v1/tasks.py b/backend/app/api/v1/tasks.py index 7627e18..5ac6f26 100644 --- a/backend/app/api/v1/tasks.py +++ b/backend/app/api/v1/tasks.py @@ -170,16 +170,45 @@ def create_task( def list_tasks( page: int = 1, page_size: int = 20, status: list[str] | None = Query(default=None), + date_from: str | None = Query(default=None, description="起始日(YYYY-MM-DD)"), + date_to: str | None = Query(default=None, description="结束日(YYYY-MM-DD,含当日)"), + product_id: int | None = Query(default=None), current_user: Annotated[CurrentUser, Depends(require_write_permission)] = None, db: Session = Depends(get_db), ): + from datetime import datetime, timedelta + from app.models.product import Product q = db.query(GenerationTask).filter(GenerationTask.workspace_id == current_user.workspace_id) if status: # 支持多状态(?status=approved&status=rejected),单值也兼容 q = q.filter(GenerationTask.status.in_(status)) + if product_id is not None: + q = q.filter(GenerationTask.product_id == product_id) + # 日期筛选:date 形参非法 → 40001(不静默吞成全量,防运营误判) + try: + if date_from: + q = q.filter(GenerationTask.created_at >= datetime.fromisoformat(date_from)) + if date_to: + # date_to 含当日:取次日 0 点为开区间上界,覆盖当日全部时刻 + _end = datetime.fromisoformat(date_to) + timedelta(days=1) + q = q.filter(GenerationTask.created_at < _end) + except ValueError: + from app.core.response import raise_param_error + raise_param_error("date_from/date_to 需为 YYYY-MM-DD 格式") total = q.count() items = q.order_by(GenerationTask.created_at.desc()).offset((page - 1) * page_size).limit(page_size).all() - return ok(paginate([_fmt_task(t) for t in items], total, page, page_size)) + # product_name 一次性批量查(防 N+1):收集本页 product_id → id→name dict + pids = {t.product_id for t in items if t.product_id} + name_map: dict[int, str] = {} + if pids: + for p in db.query(Product.id, Product.name).filter(Product.id.in_(pids)).all(): + name_map[p.id] = p.name + rows = [] + for t in items: + d = _fmt_task(t) + d["product_name"] = name_map.get(t.product_id) + rows.append(d) + return ok(paginate(rows, total, page, page_size)) @router.get("/{task_id}") diff --git a/backend/app/constants/enums.py b/backend/app/constants/enums.py index 4d29c2b..eb10e19 100644 --- a/backend/app/constants/enums.py +++ b/backend/app/constants/enums.py @@ -75,6 +75,16 @@ SIGNAL_WEIGHTS: dict[str, int] = { } +# ── 生图3套正交叙事策略 → 飞轮角度标签 ─────────────────── +# 选图信号 angle_label 用此映射,与文案 angle_label 体系并轨进同一偏好聚合。 +# A痛点先行/B场景先行/C成分背书先行(倩倩姐2026-06-15起草,北哥过目版)。 +IMAGE_STRATEGY_ANGLE: dict[str, str] = { + "A": "痛点先行", + "B": "场景先行", + "C": "成分背书先行", +} + + # ── 数据归属 ────────────────────────────────────────── class DataOwnership(str, Enum): CLIENT_DATA = "client_data" # 原始输入产出,客户可导出 @@ -110,6 +120,15 @@ class ImageRole(str, Enum): MAIN = "main" +# ── 产品参考图场景类型(R5多图:标注每张产品图拍的是什么,供生图按分镜role选用)── +class ProductImageScene(str, Enum): + PRIMARY = "primary" # 白底/正面主图(默认主图,必有) + SCENE = "scene" # 使用场景图(梳妆台/生活环境) + TEXTURE = "texture" # 质地特写(膏体/涂抹) + INGREDIENT = "ingredient" # 成分/包装细节 + MODEL = "model" # 上脸/使用中 + + # ── AI 图片提供商 ───────────────────────────────────── class ImageProvider(str, Enum): GPT = "gpt" diff --git a/backend/app/core/config.py b/backend/app/core/config.py index dc12d85..8211a34 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -42,7 +42,8 @@ class Settings(BaseSettings): CELERY_RESULT_BACKEND: str = "" # ── 并发限制(可配置,不写死)───────────────────── - MAX_CONCURRENT_TASKS_PER_USER: int = 2 + # 倩倩姐红线:每用户并发上限 5 个任务(客户侧实际跑几个再观察调整)。 + MAX_CONCURRENT_TASKS_PER_USER: int = 5 # ── 文件存储路径 ────────────────────────────────── UPLOAD_BASE_PATH: str = "uploads/packages" diff --git a/backend/app/core/response.py b/backend/app/core/response.py index 6db91f8..5b38f50 100644 --- a/backend/app/core/response.py +++ b/backend/app/core/response.py @@ -59,5 +59,9 @@ def raise_business(message: str) -> None: raise CloverHTTPException(422, ErrorCode.BUSINESS_ERROR, message) +def raise_param_error(message: str = "参数非法") -> None: + raise CloverHTTPException(400, ErrorCode.PARAM_INVALID, message) + + def raise_state_invalid(message: str = "状态机非法流转") -> None: raise CloverHTTPException(409, ErrorCode.STATE_INVALID, message) diff --git a/backend/app/models/product.py b/backend/app/models/product.py index 89a30b1..6795409 100644 --- a/backend/app/models/product.py +++ b/backend/app/models/product.py @@ -13,7 +13,7 @@ from sqlalchemy import ( ) from sqlalchemy.orm import Mapped, mapped_column, relationship -from app.constants.enums import BannedWordLevel, ProductSource +from app.constants.enums import BannedWordLevel, ProductImageScene, ProductSource from app.core.database import Base @@ -52,12 +52,45 @@ class Product(Base): benchmark_notes: Mapped[list["BenchmarkNote"]] = relationship( back_populates="product", lazy="noload" ) + images: Mapped[list["ProductImage"]] = relationship( + back_populates="product", lazy="selectin", + cascade="all, delete-orphan", order_by="ProductImage.sort_order", + ) __table_args__ = ( Index("idx_products_workspace_id", "workspace_id"), ) +class ProductImage(Base): + """产品参考图(R5多图):一产品多张,每张标 scene 场景类型,生图按分镜role选用。 + + product.image_path 仍保留当主图(向后兼容,零破坏);本表是其超集。 + """ + __tablename__ = "product_images" + + id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True) + product_id: Mapped[int] = mapped_column( + BigInteger, ForeignKey("products.id", ondelete="CASCADE"), nullable=False + ) + path: Mapped[str] = mapped_column(String(512), nullable=False) # 绝对路径,worker 直接 open + scene: Mapped[str] = mapped_column( + Enum(ProductImageScene, values_callable=lambda x: [e.value for e in x]), + default=ProductImageScene.PRIMARY, nullable=False, + ) + is_primary: Mapped[bool] = mapped_column(default=False, nullable=False) # 主图(同步 product.image_path) + sort_order: Mapped[int] = mapped_column(Integer, default=0, nullable=False) + created_at: Mapped[datetime] = mapped_column( + DateTime, server_default=func.now(), nullable=False + ) + + product: Mapped["Product"] = relationship(back_populates="images") + + __table_args__ = ( + Index("idx_product_images_product_id", "product_id"), + ) + + class BenchmarkNote(Base): """标杆笔记(截图+手填亮点为主通道)""" __tablename__ = "benchmark_notes" diff --git a/backend/app/services/ai_engine/_text_prompt.py b/backend/app/services/ai_engine/_text_prompt.py index 07e344b..f3b5d9b 100644 --- a/backend/app/services/ai_engine/_text_prompt.py +++ b/backend/app/services/ai_engine/_text_prompt.py @@ -131,6 +131,38 @@ angle(本条角度标签)/ coverTitle(封面大字≤10字)/ imageBrief 硬性格式:只输出JSON,不要markdown代码块,字符串内用中文引号「」。""" +# ── 第2环标杆爆款配方 → 文案 prompt(借方法层结构,禁抄竞品原话)────────────── +_BM_DIM_LABELS: list[tuple[str, str]] = [ + ("title_formula", "标题公式"), + ("opening_hook", "开篇钩子"), + ("content_structure", "内容结构"), + ("selling_point_style", "卖点表达风格"), + ("emotion_tone", "情绪基调"), + ("topic_tags", "话题标签思路"), +] + + +def _build_benchmark_block(refs: list[dict]) -> str: + """把标杆 8维配方渲染成 prompt 块。只借'方法结构',硬约束禁抄竞品品牌/功效原话。""" + if not refs: + return "" + lines: list[str] = [] + for i, f in enumerate(refs[:3], 1): # 最多取3条标杆,避免prompt膨胀 + dims = [f"{label}=「{str(f.get(key, '')).strip()}」" + for key, label in _BM_DIM_LABELS if str(f.get(key, "")).strip()] + if dims: + lines.append(f" 标杆{i}:" + ";".join(dims)) + if not lines: + return "" + body = "\n".join(lines) + return ( + "\n【对标爆款配方参考(只借方法层结构,绝不照抄)】\n" + f"{body}\n" + "硬性约束:仅参考上面的标题套路/开篇方式/结构节奏/情绪基调来组织本产品文案;" + "禁止照搬竞品品牌名、产品名、功效原话或具体数字;产品信息一律以本产品为准。" + ) + + def build_prompt(product: dict, count: int, extra_rules: str = "") -> str: """ 组装文案生成 user_prompt。 @@ -153,6 +185,7 @@ def build_prompt(product: dict, count: int, extra_rules: str = "") -> str: angle_hint = f"文案角度要覆盖:{'、'.join(angles)}(每条用不同角度)。" if angles else "" brand_rule = f"每条正文和标题中植入品牌词「{brand_kw}」一次(自然融入,不生硬)。" if brand_kw else "" + benchmark_block = _build_benchmark_block(product.get("benchmark_refs") or []) lines = [ f"产品:{name}", @@ -161,6 +194,7 @@ def build_prompt(product: dict, count: int, extra_rules: str = "") -> str: angle_hint, brand_rule, custom, + benchmark_block, f"\n【Q1随机变量池·每条身份/起因/小缺点各不相同,严格按下方分配使用】", combos_text, extra_rules, diff --git a/backend/app/services/ai_engine/constants.py b/backend/app/services/ai_engine/constants.py index fcf1a85..fcf6e88 100644 --- a/backend/app/services/ai_engine/constants.py +++ b/backend/app/services/ai_engine/constants.py @@ -70,6 +70,23 @@ PAGE_ROLES = [ ] PAGE_ROLE_MAP = {r["role"]: r for r in PAGE_ROLES} +# ── R5多图:分镜role → 优先产品图场景(scene) 偏好表 ────────── +# 生图时按分镜 role 选该场景的产品图当参考;取不到回落主图(primary)。 +# scene 取值见 enums.ProductImageScene:primary/scene/texture/ingredient/model +ROLE_SCENE_PREFERENCE = { + "hook": ["scene", "primary"], # 封面:真实生活场景优先 + "pain_scene": ["scene", "primary"], # 痛点共鸣:使用前情境 + "product_closeup": ["primary"], # 单品特写:白底主图 + "ingredient": ["ingredient", "primary"], # 成分拆解:成分/包装细节 + "texture": ["texture", "primary"], # 质地展示:质地特写 + "applied_proof": ["model", "scene", "primary"], # 上脸:上脸图/场景 + "social_proof": ["scene", "primary"], # 社交背书:场景 + "closer": ["primary"], # 促单收尾:主图 + "scenario": ["scene", "primary"], + "tutorial": ["model", "scene", "primary"], +} + + # ── 生图风格预设(扒 image.js STYLE_PROMPTS:26-29)────────── # 按 style 参数选小红书风格调性,注入 base_prompt 的"视觉风格"行 STYLE_PROMPTS = { diff --git a/backend/app/services/ai_engine/image_gen.py b/backend/app/services/ai_engine/image_gen.py index dd5b785..a9a0c71 100644 --- a/backend/app/services/ai_engine/image_gen.py +++ b/backend/app/services/ai_engine/image_gen.py @@ -14,13 +14,36 @@ import logging import os from typing import Any, Protocol -from .constants import IMAGE_RETRY_ATTEMPTS, IMAGE_RETRY_BACKOFF_BASE, IMAGE_SIZE_DEFAULT +from .constants import IMAGE_RETRY_ATTEMPTS, IMAGE_RETRY_BACKOFF_BASE, IMAGE_SIZE_DEFAULT, ROLE_SCENE_PREFERENCE from .image_scorer import score_image from .storyboard import plan_image_set, sanitize_text logger = logging.getLogger(__name__) +def _pick_reference_for_role( + role: str, + images_by_scene: dict[str, list[bytes]] | None, + fallback: list[bytes] | None, +) -> tuple[list[bytes] | None, str]: + """R5多图:按分镜 role 选该场景的产品图。取不到回落主图。 + + 返回 (参考图bytes列表, 命中scene标签用于日志)。 + """ + if images_by_scene: + for scene in ROLE_SCENE_PREFERENCE.get(role, ["primary"]): + imgs = images_by_scene.get(scene) + if imgs: + return imgs, scene + # 偏好全落空:用任意可用图兜底(仍优先 primary) + if images_by_scene.get("primary"): + return images_by_scene["primary"], "primary" + for scene, imgs in images_by_scene.items(): + if imgs: + return imgs, f"{scene}(兜底)" + return fallback, "fallback" + + class ImageClient(Protocol): """worker 注入的图片生成客户端协议(隔离 key 细节)""" async def gpt_edits( @@ -129,12 +152,18 @@ async def generate_storyboard_images( strategy: str | None = None, target_role: str | None = None, custom_prompt: str | None = None, + images_by_scene: dict[str, list[bytes]] | None = None, + flywheel_fragment: str | None = None, ) -> list[dict]: """ 按 storyboard 逐张生图(asyncio.gather 并发),返回每张结果列表。 strategy: None=默认叙事,'A'/'B'/'C'=三套正交叙事策略 target_role: 非空时只生成该 role 那一张(R2 单张重生) custom_prompt: 非空时追加到每张 per_prompt 末尾(R2 人工提示词) + images_by_scene: R5多图,{scene: [bytes]},按分镜 role 选对应场景图; + 为空则全分镜共用 reference_images(向后兼容)。 + flywheel_fragment: R7 飞轮偏好片段(最近选图/打回真实信号聚合),注入图片 + 排版偏好;仅影响文字角度/版式取向,绝不改瓶身(合规红线)。 每项:{role, name, image_bytes, error} """ plan = plan_image_set(note, product, image_count, analysis, strategy=strategy) @@ -169,8 +198,20 @@ async def generate_storyboard_images( # R2 人工提示词:追加到末尾权重最高,但不覆盖前面合规/真实约束 if custom_prompt: per_prompt += f"\n运营补充要求(在不违反上述合规与真实约束前提下尽量满足):{sanitize_text(custom_prompt, 200)}。" + # R7 飞轮偏好:仅作用于文字角度/版式取向参考,绝不改瓶身(合规+真实红线) + if flywheel_fragment: + per_prompt += ( + f"\n历史偏好参考(仅影响标题文字角度与排版取向,不得据此改动产品瓶身):" + f"{sanitize_text(flywheel_fragment, 300)}。" + ) try: - img_bytes = await generate_one_image(client, per_prompt, reference_images) + # R5多图:按本张分镜 role 选对应场景产品图;无多图则共用 reference_images + ref_for_item, _scene_hit = _pick_reference_for_role( + item["role"], images_by_scene, reference_images + ) + if images_by_scene: + logger.info("分镜 %s 选用产品图场景=%s", item["role"], _scene_hit) + img_bytes = await generate_one_image(client, per_prompt, ref_for_item) # 注:gpt-image-2 渲染中文偶发错别字(约1/6)。vision/OCR 文字校验闸门实测 # 不可靠(漏报形近字+幻觉误伤品牌词),倩倩姐2026-06-16拍板先撤,纯生图, # 错别字作已知问题记录,后续迭代再处理。详见记忆 clover-image-text-check-shelved。 diff --git a/backend/app/services/ai_engine/preference_aggregator.py b/backend/app/services/ai_engine/preference_aggregator.py index 455726c..a15eca7 100644 --- a/backend/app/services/ai_engine/preference_aggregator.py +++ b/backend/app/services/ai_engine/preference_aggregator.py @@ -52,7 +52,8 @@ def aggregate_preference_context( weight = int(e.get("signal_weight", 1)) # text_edit(改稿)是最强真实信号,角度按权重计入(倩倩姐2026-06-16拍板) - if sig_type in ("text_select", "approve", "text_edit") and angle: + # image_select(选图)按套别叙事角度计入,让选图偏好真正闭环回生图(R7断点2) + if sig_type in ("text_select", "approve", "text_edit", "image_select") and angle: angle_counts[angle] += weight elif sig_type == "reject_with_reason": reason = str(e.get("reason") or "").strip() diff --git a/backend/app/services/fission_image_retry.py b/backend/app/services/fission_image_retry.py index a0fd44e..b8027ed 100644 --- a/backend/app/services/fission_image_retry.py +++ b/backend/app/services/fission_image_retry.py @@ -60,6 +60,19 @@ def retry_fission_note_images( except Exception as e: # noqa: BLE001 logger.warning("裂变补图参考图读取失败,无参考图模式: %s", e) + # R5多图:补图也按场景分组,重生失败分镜时选对应场景图 + images_by_scene: dict[str, list[bytes]] = {} + for _im in (product.get("images") or []): + _p = _resolve_image_path(_im.get("path", "")) + if _p and os.path.isfile(_p): + try: + with open(_p, "rb") as _f: + images_by_scene.setdefault(_im.get("scene") or "primary", []).append(_f.read()) + except Exception: # noqa: BLE001 + pass + if reference_images and not images_by_scene.get("primary"): + images_by_scene.setdefault("primary", []).extend(reference_images) + upload_base = get_settings().UPLOAD_ABS_ROOT img_dir = os.path.join(upload_base, str(ft.workspace_id), f"fission_{ft.id}", str(fn.seq)) os.makedirs(img_dir, exist_ok=True) @@ -73,6 +86,7 @@ def retry_fission_note_images( client=clients, note=note, product=product, image_count=image_count, reference_images=reference_images, target_role=role, + images_by_scene=images_by_scene or None, )) except Exception as exc: # noqa: BLE001 logger.error("裂变补图 seq=%s role=%s 仍失败: %s", fn.seq, role, exc) diff --git a/backend/app/services/fission_images.py b/backend/app/services/fission_images.py index b4c3b15..8db11c2 100644 --- a/backend/app/services/fission_images.py +++ b/backend/app/services/fission_images.py @@ -39,6 +39,19 @@ def generate_fission_images( except Exception as e: # noqa: BLE001 logger.warning("裂变参考图读取失败,无参考图模式: %s", e) + # R5多图:裂变同样按场景分组,生图按分镜 role 选对应图 + images_by_scene: dict[str, list[bytes]] = {} + for _im in (product.get("images") or []): + _p = _resolve_image_path(_im.get("path", "")) + if _p and os.path.isfile(_p): + try: + with open(_p, "rb") as _f: + images_by_scene.setdefault(_im.get("scene") or "primary", []).append(_f.read()) + except Exception: # noqa: BLE001 + pass + if reference_images and not images_by_scene.get("primary"): + images_by_scene.setdefault("primary", []).extend(reference_images) + upload_base = get_settings().UPLOAD_ABS_ROOT for nid in note_ids: fn = db.query(FissionNote).filter(FissionNote.id == nid).first() @@ -52,6 +65,7 @@ def generate_fission_images( results = asyncio.run(generate_storyboard_images( client=clients, note=note, product=product, image_count=image_count, reference_images=reference_images, + images_by_scene=images_by_scene or None, )) except Exception as exc: # noqa: BLE001 logger.error("裂变套 seq=%s 生图失败: %s", fn.seq, exc) diff --git a/backend/app/services/flywheel_service.py b/backend/app/services/flywheel_service.py index b3d69be..20b0f07 100644 --- a/backend/app/services/flywheel_service.py +++ b/backend/app/services/flywheel_service.py @@ -8,10 +8,10 @@ preference_aggregator:查最近50条 → 最常选角度 + 打回原因近3条 import logging from typing import Any -from sqlalchemy import desc, func +from sqlalchemy import desc from sqlalchemy.orm import Session -from app.constants.enums import SIGNAL_WEIGHTS, DataOwnership, SignalType +from app.constants.enums import SIGNAL_WEIGHTS, DataOwnership from app.middleware.workspace_guard import CurrentUser from app.models.flywheel import PreferenceEvent from app.models.task import GenerationTask @@ -20,8 +20,6 @@ logger = logging.getLogger(__name__) # 实时聚合窗口:最近50条事件 _AGGREGATION_WINDOW = 50 -# 冷启动阈值:不足5条信号用产品档案冷启动 -_COLD_START_THRESHOLD = 5 def record_signal( @@ -32,12 +30,14 @@ def record_signal( candidate_id: int | None = None, angle_label: str | None = None, reason: str | None = None, + signal_meta: str | None = None, ) -> None: """ 写入飞轮信号。 workspace_id + product_id 都必须有(基石C + 按产品分开学)。 signal_weight 用枚举默认值,北哥可校准。 data_ownership 默认 client_data(选择行为归客户)。 + signal_meta:JSON 扩展(如选图存 strategy),不参与角度聚合主逻辑。 """ weight = SIGNAL_WEIGHTS.get(signal_type, 0) event = PreferenceEvent( @@ -50,6 +50,7 @@ def record_signal( candidate_id=candidate_id, angle_label=angle_label, reason=reason, + signal_meta=signal_meta, data_ownership=DataOwnership.CLIENT_DATA, ) try: @@ -69,14 +70,17 @@ def record_signal( def get_preference_context( - db: Session, workspace_id: int, product_id: int + db: Session, workspace_id: int, product_id: int, + product_dict: dict[str, Any] | None = None, ) -> dict[str, Any]: """ - 实时聚合偏好上下文(最近50条 events)。 - 返回:recent_preference摘要 + reject_reasons近3条 + injected_count。 - 不足5条 → 冷启动提示(产品档案兜底,由 AIE prompt 层读 products.custom_prompt)。 + 实时聚合偏好上下文(最近50条 events)供前端展示。 + R7断点3:统一口径——委托给生产链同一个 aggregate_preference_context(权重口径), + 消除"前端展示按次数/生成按权重"的口径分裂(说一套做一套)。 按 workspace_id + product_id 严格过滤(不串数据,基石C)。 """ + from app.services.ai_engine.preference_aggregator import aggregate_preference_context + recent = ( db.query(PreferenceEvent) .filter( @@ -87,35 +91,18 @@ def get_preference_context( .limit(_AGGREGATION_WINDOW) .all() ) - - if len(recent) < _COLD_START_THRESHOLD: - return { - "recent_preference": "信号不足,使用产品档案基线(冷启动)", - "reject_reasons": [], - "injected_count": len(recent), - } - - # 统计最常被选中的角度(text_edit 改稿=最强真实信号,按权重计入,倩倩姐2026-06-16) - angle_counts: dict[str, int] = {} - for ev in recent: - if ev.signal_type in (SignalType.TEXT_SELECT, SignalType.APPROVE, SignalType.TEXT_EDIT) and ev.angle_label: - angle_counts[ev.angle_label] = angle_counts.get(ev.angle_label, 0) + 1 - - top_angles = sorted(angle_counts.items(), key=lambda x: x[1], reverse=True)[:3] - if top_angles: - pref_desc = ";".join(f"{a}(已选{c}次)" for a, c in top_angles) - preference_summary = f"最近偏好:{pref_desc}" - else: - preference_summary = "暂无明显角度偏好" - - # 取最近3条打回原因原文(不做 AI 归纳,契约§3) - reject_reasons = [ - ev.reason for ev in recent - if ev.signal_type == SignalType.REJECT_WITH_REASON and ev.reason - ][:3] - + events_dicts = [ + {"signal_type": e.signal_type, "workspace_id": e.workspace_id, + "product_id": e.product_id, "angle_label": e.angle_label or "", + "signal_weight": e.signal_weight, "reason": e.reason or ""} + for e in recent + ] + ctx = aggregate_preference_context( + events_dicts, product_dict or {}, workspace_id, product_id + ) + # 前端展示不需要 prompt_fragment(注入用),剥掉只回摘要+原因+计数 return { - "recent_preference": preference_summary, - "reject_reasons": reject_reasons, - "injected_count": len(recent), + "recent_preference": ctx.get("recent_preference", ""), + "reject_reasons": ctx.get("reject_reasons", []), + "injected_count": ctx.get("injected_count", 0), } diff --git a/backend/app/services/task_service.py b/backend/app/services/task_service.py index 87e0552..671b394 100644 --- a/backend/app/services/task_service.py +++ b/backend/app/services/task_service.py @@ -30,6 +30,28 @@ def _check_user_has_key(db: Session, user_id: int, workspace_id: int) -> None: raise_business("尚未配置 API Key,请先在设置中录入") +def _check_concurrency_limit(db: Session, user_id: int, workspace_id: int) -> None: + """ + 校验该用户未完成任务数未超并发上限(红线=5,可配置)。 + 只算 pending/generating(真占 worker),挑选/审核态不计。超限引导稍后再试。 + """ + from app.core.config import get_settings + from app.constants.enums import TaskStatus + + limit = get_settings().MAX_CONCURRENT_TASKS_PER_USER + running = ( + db.query(GenerationTask) + .filter( + GenerationTask.operator_id == user_id, + GenerationTask.workspace_id == workspace_id, + GenerationTask.status.in_([TaskStatus.PENDING.value, TaskStatus.GENERATING.value]), + ) + .count() + ) + if running >= limit: + raise_business(f"您有 {running} 个任务正在生成,已达并发上限 {limit} 个,请等待完成后再发起") + + def create_generation_task( db: Session, current_user: CurrentUser, @@ -42,6 +64,9 @@ def create_generation_task( if body.track == "ai": # 轨A:先检查有没有 key _check_user_has_key(db, current_user.user_id, current_user.workspace_id) + # 并发上限:只算正在消耗生成资源的任务(pending/generating), + # 已生成完等挑选/审核的不占 worker。红线=每用户5个(可配置)。 + _check_concurrency_limit(db, current_user.user_id, current_user.workspace_id) # 禁降级铁律:本次产品入镜(need_product_image=True)时,产品必须已上传参考图, # 否则拒绝建任务(不允许降级纯文生图,防产品包装跑偏/过抽检失败)。 @@ -57,6 +82,11 @@ def create_generation_task( if not (product.image_path or "").strip(): raise_business("该产品未上传参考图,无法生成产品入镜内容;请先到产品库上传产品图,或关闭「产品入镜」开关") + # 第2环:关联标杆笔记ID存库(JSON list)。pipeline 据此读 features_json 注入文案 prompt。 + import json + _bids = getattr(body, "benchmark_ids", None) or [] + benchmark_ids_json = json.dumps([int(i) for i in _bids]) if _bids else None + task = GenerationTask( workspace_id=current_user.workspace_id, product_id=body.product_id, @@ -66,6 +96,7 @@ def create_generation_task( image_count=body.image_count, track=body.track, need_product_image=need_img, + benchmark_ids=benchmark_ids_json, status="pending", ) db.add(task) diff --git a/backend/app/workers/packaging_task.py b/backend/app/workers/packaging_task.py index 02adcea..53541f0 100644 --- a/backend/app/workers/packaging_task.py +++ b/backend/app/workers/packaging_task.py @@ -42,47 +42,62 @@ def build_delivery_package(self, package_id: int) -> dict: settings = get_settings() upload_base = settings.UPLOAD_BASE_PATH.rstrip("/") - selected_text = db.query(TextCandidate).filter( + # A8 多套打包:按 strategy(A/B/C 三套正交叙事)分组,每套成一篇独立 note。 + # 北哥拿到的交付包含完整 3 套(note_01/02/03),不再只打第 1 条文案。 + selected_texts = db.query(TextCandidate).filter( TextCandidate.task_id == task_id, TextCandidate.is_selected == True, - ).first() - # 整套全打(倩倩姐2026-06-08拍板):一条笔记的全部图按 seq 排序进包, - # 不再只打 is_selected 的封面。北哥6张标准套 seq=1 是 hook 封面,天然排第一。 - selected_images = db.query(ImageCandidate).filter( - ImageCandidate.task_id == task_id, - ).order_by(ImageCandidate.seq).all() - - if not selected_text: + ).order_by(TextCandidate.id).all() + if not selected_texts: raise ValueError("无已选文案,请先选择文案") - text_data = json.loads(selected_text.content or "{}") - images_data = [] - for ic in selected_images: + # 整套全打:一套内全部图按 seq 排序进包,不只打封面。重生场景同 (strategy,seq) + # 可能多条(新增不删旧),去重取最新(id最大),避免包内重复图。 + all_images = db.query(ImageCandidate).filter( + ImageCandidate.task_id == task_id, + ).order_by(ImageCandidate.strategy, ImageCandidate.seq).all() + + def _read_image(ic) -> dict: img_bytes = b"" if ic.url: - # url 形如 /uploads/ws/task/file.jpg,本身已含 uploads 前缀。 - # 工作目录是 /app,直接 lstrip("/") 当相对路径读,不能再拼 upload_base(会重复 uploads/uploads)。 - rel = ic.url.lstrip("/") - abs_path = rel + # url 已含 uploads 前缀;工作目录 /app,lstrip 当相对路径读,勿再拼 base(防 uploads/uploads) try: - with open(abs_path, "rb") as f: + with open(ic.url.lstrip("/"), "rb") as f: img_bytes = f.read() except OSError as e: - logger.warning("图片读取失败,跳过:%s %s", abs_path, e) - images_data.append({ + logger.warning("图片读取失败,跳过:%s %s", ic.url, e) + return { "seq": ic.seq, "role": ic.role.value if hasattr(ic.role, "value") else str(ic.role), "data": img_bytes, - }) + } - notes = [{ - "title": text_data.get("title", ""), - "content": text_data.get("content", ""), - "tags": text_data.get("tags", []), - "images": images_data, - "banned_word_status": (selected_text.banned_word_status.value - if hasattr(selected_text.banned_word_status, "value") - else str(selected_text.banned_word_status)), - }] + # 按 strategy 分组(A/B/C;老数据 strategy=None 归一套,向后兼容) + from collections import OrderedDict + groups: "OrderedDict[str, dict]" = OrderedDict() + for ic in all_images: + slot = groups.setdefault(ic.strategy or "_", {}) + prev = slot.get(ic.seq) + if prev is None or ic.id > prev.id: + slot[ic.seq] = ic # 同 seq 留最新 + + notes = [] + for idx, (_strategy, slot) in enumerate(groups.items()): + images_data = [_read_image(slot[k]) for k in sorted(slot)] + # 文案配对:选中文案数≥套数则一套一条;否则各套共用第 1 条 + # (图均以第 1 条文案为语境生成,共用合理;多选则尊重运营按套选的文案) + tc = selected_texts[idx] if idx < len(selected_texts) else selected_texts[0] + text_data = json.loads(tc.content or "{}") + notes.append({ + "title": text_data.get("title", ""), + "content": text_data.get("content", ""), + "tags": text_data.get("tags", []), + "images": images_data, + "banned_word_status": (tc.banned_word_status.value + if hasattr(tc.banned_word_status, "value") + else str(tc.banned_word_status)), + }) + if not notes: + raise ValueError("无图片候选,无法打包") from app.services.ai_engine.package_exporter import build_delivery_package as do_build # 打包产物放专用目录 uploads/packages/,与图片目录 uploads/{ws}/{task}/ 分开 diff --git a/backend/app/workers/pipeline_io.py b/backend/app/workers/pipeline_io.py index 8544dc9..0fbcfbb 100644 --- a/backend/app/workers/pipeline_io.py +++ b/backend/app/workers/pipeline_io.py @@ -148,7 +148,8 @@ def run_image_generation(db, clients, task, product_dict: dict, first_copy: dict, upload_base_path: str, regen_strategy: str | None = None, regen_role: str | None = None, - custom_prompt: str | None = None) -> int: + custom_prompt: str | None = None, + flywheel_fragment: str | None = None) -> int: """ Step6+7+8(image): 调 generate_storyboard_images → 后处理 → 存 ImageCandidate → 推 SSE。 返回 next_seq。 @@ -202,6 +203,23 @@ def run_image_generation(db, clients, task, product_dict: dict, "拒绝降级纯文生图。请确认产品已上传参考图。" ) + # R5多图:按场景分组加载产品图,生图按分镜 role 选对应场景图 + images_by_scene: dict[str, list[bytes]] = {} + for _im in (product_dict.get("images") or []): + _p = _resolve_image_path(_im.get("path", "")) + _scene = _im.get("scene") or "primary" + if _p and os.path.isfile(_p): + try: + with open(_p, "rb") as _f: + images_by_scene.setdefault(_scene, []).append(_f.read()) + except Exception as _e: + logger.warning("产品图(scene=%s)读取失败,跳过:%s %s", _scene, _p, _e) + # 主图始终保底进 primary(多图表为空或主图未入表时仍可用) + if reference_images and not images_by_scene.get("primary"): + images_by_scene.setdefault("primary", []).extend(reference_images) + if images_by_scene: + logger.info("R5多图已加载:%s", {k: len(v) for k, v in images_by_scene.items()}) + seq = seq_start # R2: 限定重生套别(regen_strategy)则只跑该套,否则全量 A/B/C 三套正交叙事 _strategies = (regen_strategy,) if regen_strategy else ("A", "B", "C") @@ -227,6 +245,8 @@ def run_image_generation(db, clients, task, product_dict: dict, strategy=strategy, target_role=regen_role, custom_prompt=custom_prompt, + images_by_scene=images_by_scene or None, + flywheel_fragment=flywheel_fragment, )) except Exception as exc: img_success = False diff --git a/backend/app/workers/pipeline_steps.py b/backend/app/workers/pipeline_steps.py index f1048c6..1335b2f 100644 --- a/backend/app/workers/pipeline_steps.py +++ b/backend/app/workers/pipeline_steps.py @@ -58,6 +58,7 @@ def build_clients_and_clear_key(plain_key: str): def build_product_dict(product) -> dict: """把 ORM product 转成 AI 引擎所需的 dict(不含任何 key)。""" return { + "id": product.id, "name": product.name, "category": product.category or "通用好物", "selling_points": json.loads(product.selling_points or "[]"), @@ -66,10 +67,52 @@ def build_product_dict(product) -> dict: "custom_prompt": product.custom_prompt or "", "brand_keyword": product.brand_keyword or "", # S3: 品牌词透传进生成prompt(每条植入) "target_audience": product.target_audience or "", # 012: 人群透传进storyboard/文案prompt - "image_path": product.image_path or "", # 产品参考图路径(前端上传后填入) + "image_path": product.image_path or "", # 产品参考图路径(主图,向后兼容) + # R5多图:每张产品图 {path, scene},生图按分镜role选对应场景图 + "images": [ + {"path": im.path, "scene": im.scene} + for im in (getattr(product, "images", None) or []) + ], + # 第2环标杆配方,默认空;走 AI 主链时由 load_benchmark_features 覆盖填充 + "benchmark_refs": [], } +def load_benchmark_features(db, task, workspace_id: int) -> list[dict]: + """ + 第2环→第5环接线:读 task.benchmark_ids → 查 analyze_status=done 的标杆 features_json。 + 返回 8维配方 dict 列表(供 build_prompt 借方法层结构,禁抄竞品品牌/功效原话)。 + 未选/未分析完/解析失败都安全返空,绝不阻断生成。 + """ + from app.models.product import BenchmarkNote + + raw_ids = getattr(task, "benchmark_ids", None) + if not raw_ids: + return [] + try: + ids = [int(i) for i in json.loads(raw_ids)] + except Exception: + return [] + if not ids: + return [] + + rows = db.query(BenchmarkNote).filter( + BenchmarkNote.id.in_(ids), + BenchmarkNote.workspace_id == workspace_id, + BenchmarkNote.analyze_status == "done", + ).all() + + feats: list[dict] = [] + for b in rows: + if not b.features_json: + continue + try: + feats.append(json.loads(b.features_json)) + except Exception: + logger.warning("标杆 features_json 解析失败 id=%s", b.id) + return feats + + def load_flywheel_context(db, workspace_id: int, product_id: int, product_dict: dict) -> tuple[str, dict]: """ 查最近50条飞轮事件,聚合偏好上下文。 diff --git a/backend/app/workers/tasks.py b/backend/app/workers/tasks.py index 35ef8d0..6e34496 100644 --- a/backend/app/workers/tasks.py +++ b/backend/app/workers/tasks.py @@ -106,6 +106,7 @@ def run_generation_pipeline(self, task_id: int, regen_strategy: str | None = Non build_clients_and_clear_key, build_product_dict, load_flywheel_context, + load_benchmark_features, ) from app.workers.pipeline_io import run_text_generation, run_image_generation from app.core.config import get_settings @@ -135,6 +136,8 @@ def run_generation_pipeline(self, task_id: int, regen_strategy: str | None = Non }, seq) product_dict = build_product_dict(product) + # 第2环爆款配方接进文案链:选中并分析完的标杆 8维配方注入 build_prompt(借方法层结构) + product_dict["benchmark_refs"] = load_benchmark_features(db, task, workspace_id) flywheel_fragment, flywheel_ctx = load_flywheel_context(db, workspace_id, task.product_id, product_dict) if flywheel_fragment: @@ -192,6 +195,7 @@ def run_generation_pipeline(self, task_id: int, regen_strategy: str | None = Non first_copy, settings.UPLOAD_BASE_PATH, regen_strategy=regen_strategy, regen_role=regen_role, custom_prompt=custom_prompt, + flywheel_fragment=flywheel_fragment, ) # 最终状态 + task_done diff --git a/backend/main.py b/backend/main.py index 476888b..e7d5450 100644 --- a/backend/main.py +++ b/backend/main.py @@ -71,6 +71,7 @@ def _register_routers(app: FastAPI) -> None: from app.api.v1.auth import router as auth_router from app.api.v1.api_keys import router as api_keys_router from app.api.v1.products import router as products_router + from app.api.v1.product_images import router as product_images_router from app.api.v1.benchmarks import router as benchmarks_router from app.api.v1.tasks import router as tasks_router from app.api.v1.task_actions import router as task_actions_router @@ -79,11 +80,15 @@ def _register_routers(app: FastAPI) -> None: from app.api.v1.stream import router as stream_router from app.api.v1.workspaces import router as workspaces_router from app.api.v1.fission import router as fission_router + from app.api.v1.exports import router as exports_router prefix = "/api/v1" app.include_router(auth_router, prefix=prefix) app.include_router(api_keys_router, prefix=prefix) + # 导出 router 必须在 products_router 之前:否则 /products/export 被 /products/{product_id} 吞掉 + app.include_router(exports_router, prefix=prefix) app.include_router(products_router, prefix=prefix) + app.include_router(product_images_router, prefix=prefix) app.include_router(benchmarks_router, prefix=prefix) app.include_router(tasks_router, prefix=prefix) app.include_router(task_actions_router, prefix=prefix) diff --git a/backend/tests/test_flywheel_wiring_p34.py b/backend/tests/test_flywheel_wiring_p34.py index 508b21e..bf0569d 100644 --- a/backend/tests/test_flywheel_wiring_p34.py +++ b/backend/tests/test_flywheel_wiring_p34.py @@ -156,7 +156,10 @@ class TestPoint4SseFlywheelEvent: mq.limit.return_value = mq mq.all.return_value = orm_events - result = get_preference_context(mock_db, workspace_id=1, product_id=10) + result = get_preference_context( + mock_db, workspace_id=1, product_id=10, + product_dict={"custom_prompt": "", "style_tone": ""}, + ) fe_dto_keys = {"recent_preference", "reject_reasons", "injected_count"} missing = fe_dto_keys - result.keys() diff --git a/frontend/src/app/config/page.tsx b/frontend/src/app/config/page.tsx index 5ed16e2..e2d3c96 100644 --- a/frontend/src/app/config/page.tsx +++ b/frontend/src/app/config/page.tsx @@ -1,30 +1,26 @@ 'use client'; /** - * 屏1 配置台(管理员 /config) - * 产品库 / 标杆笔记 / 违禁词 / API Key 录入 - * 🔴 红线:key 只录不显余额(不做 Token 用量展示) + * 系统管理 /config(仅管理员) + * 标杆笔记 / 违禁词库 —— 平台级配置,写操作后端锁 admin + * 拆分自原 /config(B方案):运营日常用的 API Key/产品档案已移到 /settings「工作配置」 */ import { useState } from 'react'; -import { ProductsTab } from '@/components/config/ProductsTab'; import { BenchmarksTab } from '@/components/config/BenchmarksTab'; import { BannedWordsTab } from '@/components/config/BannedWordsTab'; -import { ApiKeysTab } from '@/components/config/ApiKeysTab'; -type TabKey = 'products' | 'benchmarks' | 'banned_words' | 'api_keys'; +type TabKey = 'benchmarks' | 'banned_words'; const TABS: { key: TabKey; label: string }[] = [ - { key: 'products', label: '产品档案' }, { key: 'benchmarks', label: '标杆笔记' }, { key: 'banned_words', label: '违禁词库' }, - { key: 'api_keys', label: 'API Key' }, ]; export default function ConfigPage() { - const [activeTab, setActiveTab] = useState('products'); + const [activeTab, setActiveTab] = useState('benchmarks'); return (
-

配置台

+

系统管理

{/* Tab 导航 */}
@@ -48,10 +44,8 @@ export default function ConfigPage() { {/* Tab 内容 */}
- {activeTab === 'products' && } {activeTab === 'benchmarks' && } {activeTab === 'banned_words' && } - {activeTab === 'api_keys' && }
); diff --git a/frontend/src/app/history/HistoryFilters.tsx b/frontend/src/app/history/HistoryFilters.tsx new file mode 100644 index 0000000..ebaed06 --- /dev/null +++ b/frontend/src/app/history/HistoryFilters.tsx @@ -0,0 +1,77 @@ +'use client'; +/** + * HistoryFilters — 历史归档页筛选条(日期区间 + 产品下拉 + 导出按钮) + * 按文件约束从 page.tsx 拆出,避免 page 超行。 + */ +import { useEffect, useState } from 'react'; +import { api } from '@/lib/api'; + +interface ProductOpt { id: number; name: string } + +interface Props { + dateFrom: string; + dateTo: string; + productId: string; + onChange: (patch: { dateFrom?: string; dateTo?: string; productId?: string }) => void; +} + +// 通用导出:getBlob 下载,带 Authorization header(复用 DownloadButton 同款 blob 方案) +async function exportData(path: string, filename: string) { + const blob = await api.getBlob(path); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = filename; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); +} + +export function HistoryFilters({ dateFrom, dateTo, productId, onChange }: Props) { + const [products, setProducts] = useState([]); + const [exporting, setExporting] = useState(false); + + useEffect(() => { + api.get<{ items: ProductOpt[] }>('/api/v1/products?page_size=100') + .then((d) => setProducts(d.items || [])) + .catch(() => setProducts([])); + }, []); + + async function handleExport() { + setExporting(true); + try { + await exportData('/api/v1/products/export', 'products.json'); + } catch { /* 失败静默,按钮恢复可重试 */ } + finally { setExporting(false); } + } + + const inputCls = 'rounded-lg border border-border-default px-3 py-1.5 text-sm text-text-primary focus:border-brand-orange outline-none'; + + return ( +
+ + + + {(dateFrom || dateTo || productId) && ( + + )} + +
+ ); +} diff --git a/frontend/src/app/history/components.tsx b/frontend/src/app/history/components.tsx index 6f6466f..e39b43a 100644 --- a/frontend/src/app/history/components.tsx +++ b/frontend/src/app/history/components.tsx @@ -7,7 +7,7 @@ * window.open 无法附带 Authorization header,会 401。 * 改法:api.getBlob 拿二进制流 → URL.createObjectURL → a.click → revoke。 */ -import { useState } from 'react'; +import { useState, Fragment } from 'react'; import Link from 'next/link'; import { clsx } from 'clsx'; import { TaskListItem } from '@/types'; @@ -76,6 +76,7 @@ export function HistoryTable({ tasks }: { tasks: TaskListItem[] }) { 任务 + 产品 状态 文案 图片 @@ -85,8 +86,8 @@ export function HistoryTable({ tasks }: { tasks: TaskListItem[] }) { {tasks.map((task, i) => ( + {task.theme} + + {task.product_name ?? '-'} + + {/* 被打回任务:原因独占一行展示,运营见"为何被打回"可重做(R8历史复用) */} + {task.status === 'rejected' && task.reject_reason && ( + + +
+ 打回原因:{task.reject_reason} +
+ + + )} +
))} @@ -142,6 +157,7 @@ export function HistorySkeleton() { {Array.from({ length: 6 }).map((_, i) => (
+
diff --git a/frontend/src/app/history/page.tsx b/frontend/src/app/history/page.tsx index e1c828e..ab466da 100644 --- a/frontend/src/app/history/page.tsx +++ b/frontend/src/app/history/page.tsx @@ -10,6 +10,7 @@ import { TaskListItem, PaginatedResponse } from '@/types'; import { getErrorAction } from '@/types/errors'; import { clsx } from 'clsx'; import { HistoryTable, HistorySkeleton, Pagination } from './components'; +import { HistoryFilters } from './HistoryFilters'; const HISTORY_STATUSES = ['approved', 'archived', 'rejected', 'failed']; const STATUS_LABELS: Record = { @@ -24,14 +25,21 @@ export default function HistoryPage() { const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [statusFilter, setStatusFilter] = useState('all'); + // M4: 日期区间 + 产品筛选 + const [filters, setFilters] = useState({ dateFrom: '', dateTo: '', productId: '' }); - const fetchHistory = useCallback(async (p: number, status: string) => { + const fetchHistory = useCallback(async ( + p: number, status: string, f: { dateFrom: string; dateTo: string; productId: string }, + ) => { setLoading(true); setError(null); try { const params = new URLSearchParams({ page: String(p), page_size: String(PAGE_SIZE) }); if (status !== 'all') params.append('status', status); else HISTORY_STATUSES.forEach((s) => params.append('status', s)); + if (f.dateFrom) params.append('date_from', f.dateFrom); + if (f.dateTo) params.append('date_to', f.dateTo); + if (f.productId) params.append('product_id', f.productId); const res = await api.get>(`/api/v1/tasks?${params}`); setTasks(res.items); setTotal(res.total); @@ -43,7 +51,7 @@ export default function HistoryPage() { } }, []); - useEffect(() => { fetchHistory(page, statusFilter); }, [page, statusFilter, fetchHistory]); + useEffect(() => { fetchHistory(page, statusFilter, filters); }, [page, statusFilter, filters, fetchHistory]); const totalPages = Math.ceil(total / PAGE_SIZE); @@ -74,10 +82,18 @@ export default function HistoryPage() { ))}
+ {/* M4: 日期区间 + 产品筛选 + 导出 */} + { setFilters((f) => ({ ...f, ...patch })); setPage(1); }} + /> + {error && (
{error} -
diff --git a/frontend/src/app/settings/page.tsx b/frontend/src/app/settings/page.tsx new file mode 100644 index 0000000..8c20abc --- /dev/null +++ b/frontend/src/app/settings/page.tsx @@ -0,0 +1,53 @@ +'use client'; +/** + * 工作配置 /settings(运营+组长+管理员) + * API Key 录入 / 产品档案 —— 各角色日常自助配置 + * 🔴 红线:key 各人自录自用各算各的;只录不显余额 + * 拆分自原 /config(B方案):admin 专属的标杆/违禁词留在 /config「系统管理」 + */ +import { useState } from 'react'; +import { ProductsTab } from '@/components/config/ProductsTab'; +import { ApiKeysTab } from '@/components/config/ApiKeysTab'; + +type TabKey = 'api_keys' | 'products'; + +const TABS: { key: TabKey; label: string }[] = [ + { key: 'api_keys', label: 'API Key' }, + { key: 'products', label: '产品档案' }, +]; + +export default function SettingsPage() { + const [activeTab, setActiveTab] = useState('api_keys'); + + return ( +
+

工作配置

+ + {/* Tab 导航 */} +
+ {TABS.map((tab) => ( + + ))} +
+ + {/* Tab 内容 */} +
+ {activeTab === 'api_keys' && } + {activeTab === 'products' && } +
+
+ ); +} diff --git a/frontend/src/app/tasks/new/page.tsx b/frontend/src/app/tasks/new/page.tsx index 6208e09..1ee042f 100644 --- a/frontend/src/app/tasks/new/page.tsx +++ b/frontend/src/app/tasks/new/page.tsx @@ -12,7 +12,7 @@ import { RecentTasksBar } from '@/components/tasks/RecentTasksBar'; import { NewTaskForm } from '@/components/tasks/NewTaskForm'; import { ImportTextModal } from '@/components/tasks/ImportTextModal'; import { api } from '@/lib/api'; -import { Product, CreateTaskRequest } from '@/types/index'; +import { Product, CreateTaskRequest, Benchmark } from '@/types/index'; import { PaginatedResponse } from '@/types/index'; import { usePreferenceStore } from '@/stores/preferenceStore'; import { getErrorAction } from '@/types/errors'; @@ -23,6 +23,7 @@ export default function NewTaskPage() { const { context } = usePreferenceStore(); const [products, setProducts] = useState([]); const [selectedProduct, setSelectedProduct] = useState(null); + const [benchmarks, setBenchmarks] = useState([]); const [form, setForm] = useState>({ benchmark_ids: [], theme: '', @@ -37,6 +38,15 @@ export default function NewTaskPage() { useEffect(() => { loadProducts(); }, []); + // 选品变化:拉该产品标杆笔记;切产品时清空已选标杆(避免跨产品串用) + useEffect(() => { + if (!selectedProduct) { setBenchmarks([]); return; } + setForm((f) => ({ ...f, benchmark_ids: [] })); + api.get<{ items: Benchmark[] }>(`/api/v1/products/${selectedProduct.id}/benchmarks`) + .then((d) => setBenchmarks(d.items || [])) + .catch(() => setBenchmarks([])); + }, [selectedProduct]); + async function loadProducts() { try { const data = await api.get>('/api/v1/products'); @@ -125,6 +135,7 @@ export default function NewTaskPage() { products={products} selectedProduct={selectedProduct} onSelectProduct={setSelectedProduct} + benchmarks={benchmarks} form={form} onFormChange={(patch) => setForm((f) => ({ ...f, ...patch }))} submitting={submitting} diff --git a/frontend/src/components/AuthGuard.tsx b/frontend/src/components/AuthGuard.tsx index 73fe6ed..ecaa772 100644 --- a/frontend/src/components/AuthGuard.tsx +++ b/frontend/src/components/AuthGuard.tsx @@ -2,7 +2,7 @@ /** * AuthGuard — 路由守卫 * - 未登录 → /login - * - 越权(组长访问 /config,非管理员等)→ /dashboard + * - 越权(运营访问 /config 系统管理等)→ /dashboard * 扒 banana AuthGuard 范式,全新重建 */ import { useEffect } from 'react'; @@ -13,6 +13,7 @@ import { UserRole } from '@/types/index'; // 路由 → 最低所需角色 const ROUTE_ROLES: Record = { '/config': ['admin'], + '/settings': ['admin', 'supervisor', 'operator'], '/review': ['admin', 'supervisor'], '/history': ['admin', 'supervisor', 'operator'], '/tasks/new': ['admin', 'supervisor', 'operator'], diff --git a/frontend/src/components/config/ProductImageManager.tsx b/frontend/src/components/config/ProductImageManager.tsx new file mode 100644 index 0000000..23313d0 --- /dev/null +++ b/frontend/src/components/config/ProductImageManager.tsx @@ -0,0 +1,154 @@ +'use client'; +/** + * ProductImageManager — R5 产品多图管理 + * 多图上传(带场景类型) + 设主图 + 改场景 + 删图。 + * scene 场景类型决定生图时哪张图喂给哪个分镜 role。 + */ +import { useRef, useState } from 'react'; +import { api } from '@/lib/api'; +import { Product, ProductImage, ProductImageScene } from '@/types/dto'; + +const SCENE_OPTIONS: { value: ProductImageScene; label: string }[] = [ + { value: 'primary', label: '主图/白底' }, + { value: 'scene', label: '使用场景' }, + { value: 'texture', label: '质地特写' }, + { value: 'ingredient', label: '成分/包装' }, + { value: 'model', label: '上脸/使用中' }, +]; + +const sceneLabel = (s: string) => + SCENE_OPTIONS.find((o) => o.value === s)?.label ?? s; + +function toUploadSrc(path: string) { + return `/uploads/${path.replace(/^.*\/uploads\//, '').replace(/^uploads\//, '')}`; +} + +export function ProductImageManager({ + product, + onChanged, +}: { + product: Product; + onChanged: (updated: Product) => void; +}) { + const inputRef = useRef(null); + const [scene, setScene] = useState('primary'); + const [uploading, setUploading] = useState(false); + const [busyId, setBusyId] = useState(null); + const [error, setError] = useState(''); + + const images = product.images ?? []; + + async function refresh() { + const updated = await api.get(`/api/v1/products/${product.id}`); + onChanged(updated); + } + + async function handleFile(e: React.ChangeEvent) { + const file = e.target.files?.[0]; + if (!file) return; + if (file.size > 10 * 1024 * 1024) { setError('文件超过 10 MB'); return; } + setError(''); + setUploading(true); + try { + const form = new FormData(); + form.append('file', file); + form.append('scene', scene); + await api.postForm(`/api/v1/products/${product.id}/upload-image`, form); + await refresh(); + } catch { + setError('上传失败,请重试'); + } finally { + setUploading(false); + if (inputRef.current) inputRef.current.value = ''; + } + } + + async function setPrimary(imageId: number) { + setBusyId(imageId); + try { + await api.put(`/api/v1/products/${product.id}/images/${imageId}/primary`, {}); + await refresh(); + } finally { setBusyId(null); } + } + + async function changeScene(imageId: number, next: string) { + setBusyId(imageId); + try { + await api.put(`/api/v1/products/${product.id}/images/${imageId}/scene`, { scene: next }); + await refresh(); + } finally { setBusyId(null); } + } + + async function removeImage(imageId: number) { + setBusyId(imageId); + try { + await api.delete(`/api/v1/products/${product.id}/images/${imageId}`); + await refresh(); + } finally { setBusyId(null); } + } + + return ( +
+ {images.length > 0 && ( +
+ {images.map((im: ProductImage) => ( +
+ {/* eslint-disable-next-line @next/next/no-img-element */} + {`${sceneLabel(im.scene)}图`} +
+ +
+ {im.is_primary ? ( + ★ 主图 + ) : ( + + )} + +
+
+
+ ))} +
+ )} + +
+ + +
+ + {images.length === 0 && ( +

+ 首张必须含产品主体(瓶身/包装本体)当主图,生图以此为锚点保证产品一致。 + 质地图、上脸图等设对应场景类型,生图会按分镜自动选用。 +

+ )} + {error &&

{error}

} + +
+ ); +} diff --git a/frontend/src/components/config/ProductSubComponents.tsx b/frontend/src/components/config/ProductSubComponents.tsx index 1391853..54f3317 100644 --- a/frontend/src/components/config/ProductSubComponents.tsx +++ b/frontend/src/components/config/ProductSubComponents.tsx @@ -1,92 +1,16 @@ 'use client'; /** - * ProductCard + ProductForm + ProductsSkeleton + ProductImageUpload — 产品档案子组件 + * ProductCard + ProductForm + ProductsSkeleton — 产品档案子组件 + * 产品多图管理拆到 ProductImageManager(R5)。 */ -import { useRef, useState } from 'react'; +import { useState } from 'react'; import { api } from '@/lib/api'; import { Product, CreateProductRequest } from '@/types/index'; +import { ProductImageManager } from './ProductImageManager'; -// --- ProductImageUpload --- -export function ProductImageUpload({ - product, - onUploaded, -}: { - product: Product; - onUploaded: (updated: Product) => void; -}) { - const inputRef = useRef(null); - const [uploading, setUploading] = useState(false); - const [error, setError] = useState(''); - - async function handleFile(e: React.ChangeEvent) { - const file = e.target.files?.[0]; - if (!file) return; - if (file.size > 10 * 1024 * 1024) { setError('文件超过 10 MB'); return; } - setError(''); - setUploading(true); - try { - const form = new FormData(); - form.append('file', file); - const updated = await api.postForm( - `/api/v1/products/${product.id}/upload-image`, form - ); - onUploaded(updated); - } catch { - setError('上传失败,请重试'); - } finally { - setUploading(false); - if (inputRef.current) inputRef.current.value = ''; - } - } - - return ( -
- {product.image_path ? ( -
- {/* eslint-disable-next-line @next/next/no-img-element */} - {`${product.name}参考图`} - -
- ) : ( -
- -

- 必须含产品主体(瓶身/包装本体),生图以此为锚点保证产品一致。 - 质地图、手臂上脸图等可作补充,但不能替代主图单独上传。 -

-
- )} - {error &&

{error}

} - -
- ); -} +// ProductImageUpload 旧单图组件已被 ProductImageManager(多图+场景)取代。 +// 保留导出名向后兼容引用方。 +export { ProductImageManager as ProductImageUpload }; export function ProductCard({ product, onUpdated }: { product: Product; onUpdated: () => void }) { const [current, setCurrent] = useState(product); @@ -109,9 +33,9 @@ export function ProductCard({ product, onUpdated }: { product: Product; onUpdate ))}
- { setCurrent(updated); onUpdated(); }} + onChanged={(updated) => { setCurrent(updated); onUpdated(); }} />
); @@ -124,14 +48,18 @@ export function ProductForm({ onSaved, onCancel }: { onSaved: () => void; onCanc custom_prompt: null, banned_word_ids: [], }); const [saving, setSaving] = useState(false); + const [error, setError] = useState(''); async function handleSave(e: React.FormEvent) { e.preventDefault(); - if (!form.name) return; - setSaving(true); + if (!form.name.trim()) { setError('请填写产品名称'); return; } + setSaving(true); setError(''); try { await api.post('/api/v1/products', form); onSaved(); + } catch (err) { + const msg = (err as { message?: string })?.message; + setError(msg || '保存失败,请重试'); } finally { setSaving(false); } @@ -158,6 +86,7 @@ export function ProductForm({ onSaved, onCancel }: { onSaved: () => void; onCanc />
+ {error &&

{error}

}
+ {/* 对标爆款(可选,多选):选中标杆把其8维配方注入文案生成 */} + {usableBenchmarks.length > 0 && ( +
+

+ 对标爆款(可选)借爆款方法结构,不抄竞品原话 +

+
+ {usableBenchmarks.map((b) => ( + + ))} +
+

已选 {(form.benchmark_ids || []).length} 个,最多取前 3 个注入

+
+ )} + {/* 数量设定(不写死) */}
| null; } export interface CreateBenchmarkRequest { diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index 9503d9e..ad09f08 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -9,6 +9,7 @@ export type { SwitchWorkspaceRequest, SwitchWorkspaceResponse, ApiKey, CreateApiKeyRequest, Product, CreateProductRequest, + ProductImage, ProductImageScene, Benchmark, CreateBenchmarkRequest, BannedWordLevel, BannedWord, CreateBannedWordRequest, } from './dto'; diff --git a/plans/R-裂变重写核销表.md b/plans/R-裂变重写核销表.md index 5af554b..5d50d36 100644 --- a/plans/R-裂变重写核销表.md +++ b/plans/R-裂变重写核销表.md @@ -64,3 +64,25 @@ ## 搁置/待北哥(攒批) - 裂变三档/参考度真实业务措辞 - N套真差异化质量验收(北哥过目) + +## 补图机制(中转站波动兜底,2026-06-18 追加并实测✅) + +> 实测发现 codeproxy 偶发不稳,单套中某张图重试3次仍失败会记 error。倩倩姐拍板"不稳定的需要加再次重试的机制"。原则:只重生失败分镜,成功的图与 token 不浪费;生图引擎零改动(R2)。 + +| # | 文件 | 改什么 | 状态 | +|---|------|--------|------| +| RT1 | alembic 019 + models/fission.py | fission_tasks 加 source_task_id(新架构不扇出GenerationTask,补图需复刻首轮上下文:产品/operator key/张数,故落库回查;原 source_fission_id 反查链在新架构下断裂) | ✅ 迁移019 head,列已加 | +| RT2 | services/fission_service.py | create_fission 写入 source_task_id | ✅ | +| RT3 | services/fission_image_retry.py | 只补标 error 的 role(引擎 target_role 单张重生,R2零改动),merge 回 images_json,全恢复则 status=image_done | ✅ 105行 | +| RT4 | workers/tasks.py | retry_fission_images Celery task,key归属源任务operator(基石B不传明文key),幂等锁防重复补图 | ✅ 已注册(celery inspect registered) | +| RT5 | api/v1/fission.py | POST /fission/{id}/notes/{seq}/retry-images,无失败分镜守卫 queued=false | ✅ | +| RT6 | 前端 FissionNoteCard + notes/page.tsx | 失败提示改"重试补图"按钮+loading,父页轮询6次×8s刷新 | ✅ tsc过 | + +- 端到端实测✅: task5 seq1(hook图首轮codeproxy重试3次失败,note_id=2)触发补图→**只补hook**(1024×1536去AI化新落盘)、**applied_proof原图不动**(时间戳未变)、recovered=1 still_failed=0、status=image_done; apiports503回落codeproxy gpt-5.5强档兜底(R3); seq0无失败图守卫返回queued=false不浪费token +- git: 285791c "裂变补图:中转站波动导致单张失败时只重生失败分镜"(8文件) + +## git 提交记录(2026-06-18) +- 7f419f4 存量后端(生图素人感约束+评图分+幂等防重跑+审核回路) +- d85dcd4 第11环裂变重写(一次LLM出N套完整笔记包) +- 25f86b2 存量前端(生图分组/灯箱/重生控件等) +- 285791c 裂变补图机制 diff --git a/plans/总核销表.md b/plans/总核销表.md index 4f4258e..3ca4947 100644 --- a/plans/总核销表.md +++ b/plans/总核销表.md @@ -24,15 +24,15 @@ KR1链路连通 + KR2质量达标 + KR3自主可验 + KR4引擎补全(第2环+ | ID | 任务 | 模块核销表 | 细化 | 状态 | |----|------|-----------|------|------| -| M3 | 第8环 审核(死链/打回回路) | plans/核销-第8环-审核.md | 🟢S1-S6/A1-A8 | □ | -| M4 | 第12环 归档(日期品类筛选/导出) | plans/核销-第12环-归档.md | 🟢S1-S8/A1-A12 | □ | -| M5 | 工程底座(5并发/magic/选标杆/置灰/lightbox) | plans/核销-工程底座.md | 🟢S1-S6/A1-A8 | □ | +| M3 | 第8环 审核(死链/打回回路) | plans/核销-第8环-审核.md | 🟢S1-S6/A1-A8 | ✅ 对账核实(2026-06-18独立agent):review.py approve(:101)/reject(:127)端点真在,_fmt_task返review_status/reject_reason/reviewed_at(:64-66),RejectReasonBanner在text/images页均挂载,/review审核工作台页+ReviewCard+RejectModal全在,打回→status=pending_selection→运营见原因可重做闭环真通。曾误标□ | +| M4 | 第12环 归档(日期品类筛选/导出) | plans/核销-第12环-归档.md | 🟢S1-S8/A1-A12 | ◐ 后端验过(2026-06-18独立agent):✅A1日期筛选(79→当日4/未来0/非法→40001)✅A2品类筛选(product_id=1命中78+product_name批量防N+1)✅A9产品导出(3条+selling_points真数组)✅A6/A7 zip结构+文案格式;前端代码就绪过typecheck(产品列/日期控件/打回原因banner/导出按钮)但A3/A4/A12目视项待Z阶段浏览器实跑;A10标杆导出逻辑已审ws3无数据未触发 | +| M5 | 工程底座(5并发/magic/选标杆/置灰/lightbox) | plans/核销-工程底座.md | 🟢S1-S6/A1-A8 | ✅ 完成(2026-06-18):✅lightbox ✅置灰 ✅magic(文件头校验,products.py _check_image_magic,2端点接入,验证真图过.exe拒) ✅5并发(config值2→5红线,task_service._check_concurrency_limit算pending/generating,验证5拦4放) ✅选标杆(注入链4断点全打通:task_service存benchmark_ids→pipeline_steps.load_benchmark_features读done态→tasks塞product_dict→_text_prompt渲染8维配方+负向约束;前端NewTaskForm多选UI;DB回环验证benchmark_ids真存库+done过滤+ws隔离;独立agent重核5项真修好,不入图片prompt合规保持) | ## P2 引擎补全(第2环标杆 / 第11环裂变) | ID | 任务 | 模块核销表 | 细化 | 状态 | |----|------|-----------|------|------| -| M6 | 第2环 标杆8特征分析 | plans/核销-第2环-标杆.md | 🟢S1-S12(S12拆a/b)/A1-A12 | ◐ **引擎双路径端到端真验完成**(2026-06-16 c1ac2a6):①文本路径(手填亮点)8维齐全conf0.9 ②截图vision路径(真喂4.7MB素颜霜图)source=vision真读图,识别三段式卖点/√图标/大字报模板/办公背景,看不到的诚实标"截图未体现"。features_json落库done。端点健壮(404/422/读图失败降级/key缺失提示)。**剩非引擎本体待办:①北哥过目8维草案标✅满分 ②第5/6环消费features_json对标(接线,归M6下游或随M1/M2)**。8维=banana不变量+俊达配方+3链接 | +| M6 | 第2环 标杆8特征分析 | plans/核销-第2环-标杆.md | 🟢S1-S12(S12拆a/b)/A1-A12 | ◐ **引擎双路径+第5环消费接线完成**(2026-06-16~18):①文本路径(手填亮点)8维齐全conf0.9 ②截图vision路径(真喂4.7MB素颜霜图)source=vision真读图,识别三段式卖点/√图标/大字报模板/办公背景,看不到的诚实标"截图未体现"。features_json落库done。端点健壮(404/422/读图失败降级/key缺失提示)。**✅第5环消费features_json已接通(2026-06-18)**:选标杆注入链4断点全打通(详见M5),用户选的标杆8维配方真进文案build_prompt(借方法层结构+禁抄竞品负向约束),独立agent重核真修好。**剩非引擎本体待办:北哥过目8维草案标✅满分**。8维=banana不变量+俊达配方+3链接 | | M7 | 第11环 裂变(工作台内) | plans/核销-第11环-裂变.md | 🟢S1-S13(含S10.5)/AC1-12 | ◐ **引擎端到端真跑通**(2026-06-16 cefdbaa):①fission_service扇出1源→N子任务(回填source_fission_id,复用主管道run_generation_pipeline),MAX_FANOUT=5红线 ②fission_prompt三档(high/mid/low)规则 ③tasks.py worker识别裂变子任务→注入对应档位进文案上下文(自抓关键缺口,**worker曾跑旧内存代码未热重载,restart后实测注入日志level=high真出现**) ④POST/fission触发+GET/fission/{id}进度聚合,main.py注册路由 ⑤实测fission_id=2扇出task70/71,子任务带source_fission_id+档位注入生效,进度端点聚合正确(total=2/done=0/generating)。**剩待办:①三档真实措辞待北哥定义(现占位规则) ②前端裂变入口+进度呈现(归前端攒批) ③子任务真出3套差异化文案+配图的质量验收(需北哥过目)** | ## P3 前端打通+飞轮(2026-06-16 九文档审核结论·按序逐条修) @@ -44,7 +44,7 @@ KR1链路连通 + KR2质量达标 + KR3自主可验 + KR4引擎补全(第2环+ | ID | 任务 | 根据 | 状态 | |----|------|------|------| | A1 | Sidebar 4个死链路由(/tasks /images /tasks/build /tasks/list 全不存在) | Sidebar.tsx确证 | ✅ 2026-06-16 删4死链+补回/review审核台(限supervisor+admin),tsc全量EXIT0,commit 77f52e7 | -| D1 | 改稿进飞轮(SignalType.text_edit + TextCandidate.edited + 改稿端点走task_actions) | 最强真实信号 | ◐ 后端✅2026-06-16端到端真跑(edited False→True+飞轮信号落库weight=5,实测抓出ENUM未同步bug已修,commit e3fb6f2)。**前端改稿框入口攒批做(倩倩姐2026-06-16:后端先串完前端攒批)** | +| D1 | 改稿进飞轮(SignalType.text_edit + TextCandidate.edited + 改稿端点走task_actions) | 最强真实信号 | ✅ 全链完整(2026-06-18独立agent核实):后端✅端到端真跑(edited False→True+飞轮信号weight=5,commit e3fb6f2)。**前端✅已做完整**:TextCandidateCard.tsx:29-47可编辑textarea+保存改稿/取消按钮,text/page.tsx:29-35真调POST /text-candidates/{id}/edit+同步store,保存后展示"🌿已学习你的改动"+"已改稿"角标。曾误标◐ | | C2 | 审核界面显分(读score_json总分,不读eval_score NULL列) | 审核可判断 | ✅ 2026-06-16 review.py复用_score_array_to_obj算总分+7维,ReviewCard接ScoreDimBars(passLine=80红线),端到端验task40返回total=83,新旧维度兼容,commit ea423e8 | | B7 | 事件处理补齐(SSE/轮询状态流转) | 前端诊断 | ✅ 2026-06-16 核实已做无缺口:SSE签ticket换JWT(?ticket=合规红线)+useSse消费progress/done/error全链+轮询兜底,后端事件发布到位,标绿 | | B6 | 错误态呈现(status=failed前端可见) | 前端诊断 | ✅ 2026-06-16 后端加TaskStatus.FAILED终态+异常区分重试中vs耗尽(耗尽落failed不retry)+014迁移ALTER status ENUM;前端fatalError state+useSse error不再静默丢+FatalErrorBanner提示+failed标签(列表/历史页可见,history纳入HISTORY_STATUSES),tsc EXIT0 | @@ -52,16 +52,16 @@ KR1链路连通 + KR2质量达标 + KR3自主可验 + KR4引擎补全(第2环+ ### 桶B 飞轮专项(产品最大亮点·做扎实+前端呈现) | ID | 任务 | 守的底线 | 状态 | |----|------|---------|------| -| E12 | AI评图分进飞轮:仅"生成时筛选+前端展示X分已入飞轮" | 权重只认真实信号(选/改/审),eval_score留NULL铁律 | □ | -| FV | 飞轮可视化=轻量化关键节点反馈(让客户感到确实越用越好) | 不做独立飞轮主视图大页(砍E01) | □ | +| E12 | AI评图分进飞轮:仅"生成时筛选+前端展示X分已入飞轮" | 权重只认真实信号(选/改/审),eval_score留NULL铁律 | ✅ 全链完整(2026-06-18独立agent核实):ImageStrategyGroup.tsx:89-96 ai_visual_score徽章(≥80绿/≥60橙/<60灰),后端image_gen.py:228→pipeline_io.py:303→tasks.py:146落库透传;eval_score全程NULL(models/task.py:93/124注释+image_gen/pipeline_io/review.py均不碰),红线守住。曾误标□ | +| FV | 飞轮可视化=轻量化关键节点反馈(让客户感到确实越用越好) | 不做独立飞轮主视图大页(砍E01) | ✅ 符合决策(2026-06-18核实):FlywheelBanner(new页/text页/images页SSE flywheel_injected事件)+TextCandidateCard"🌿已学习"+AI分徽章,轻量节点反馈嵌现有流程;无独立大看板=砍E01已拍板。曾误标□ | ### 桶C 接线(数据流薄,主链通后接) | ID | 任务 | 状态 | |----|------|------| -| B1 | 图片按strategy分组(generationStore现一维平铺) | □ | -| C1 | 任务看板 | □ | -| B2/B3/B4/B5 | 前端数据流补齐 | □ | -| C3/C4 | 展示层补薄 | □ | +| B1 | 图片按strategy分组(generationStore现一维平铺) | ✅ 同R1已做(对账核实):ImageStrategyGroup按strategy分A/B/C三组,images页:45-47分组+:96-108渲染,非空壳。曾重复挂账□ | +| C1 | 任务看板 | □ 真欠账(2026-06-18核实:仅/history列表+状态tab,无kanban按状态分列视图,零代码)。⚠️攒批问倩倩姐:/history已有状态筛选,是否还需独立kanban看板?可能伪需求 | +| B2/B3/B4/B5 | 前端数据流补齐 | ✅ 主链完整(2026-06-18核实:generationStore/taskStore/preferenceStore全实现无空壳无TODO,hydrateFromTask历史回填已做);B2-B5核销表本身需求定义模糊,代码层无欠账 | +| C3/C4 | 展示层补薄 | C3✅留口子(OVERLAY_TEXT_RENDER_ENABLED=False拍板缓做);C4□代码无对应实现且需求不明,⚠️攒批问倩倩姐C4具体指什么 | ### 桶D 踩刹车(砍/缓,防堆伪需求偏离纵切片) | ID | 任务 | 裁决 | @@ -72,10 +72,10 @@ KR1链路连通 + KR2质量达标 + KR3自主可验 + KR4引擎补全(第2环+ ### 桶E 需补充(缺口补全) | ID | 任务 | 状态 | |----|------|------| -| 补1 | 上传主图校验(必含产品瓶身,防质地图/手臂图当主图) | □ | +| 补1 | 上传主图校验(必含产品瓶身,防质地图/手臂图当主图) | ✅ 同R3已做(对账核实):后端2层硬拦截(task_service.py:46-58建任务校验+pipeline_io.py:199-204 worker兜底防绕过)+前端NewTaskForm:66-77红色警告+按钮置灰。曾重复挂账□ | | 补2 | status=failed 完整处理(后端+前端) | ✅ 同B6(2026-06-16),后端FAILED终态+前端可见,commit 1b48875/ffc213f | | 补3 | git版本兜底 | ✅2026-06-16已建Clover独立仓库,基线6a2632d | -| D2 | 数据复盘(飞轮喂料可见) | □ | +| D2 | 数据复盘(飞轮喂料可见) | ◐ 实时注入反馈已做(FlywheelBanner+"已学习"提示,见FV),但历史注入摘要/复盘页未做。⚠️攒批问倩倩姐:轻量节点反馈是否已够,还需独立历史复盘页? | | D3 | 爆款解析(并入M6标杆) | ⏸️随M6 | --- @@ -131,33 +131,33 @@ KR1链路连通 + KR2质量达标 + KR3自主可验 + KR4引擎补全(第2环+ ### R5 产品多图(P1) | 子项 | 去重映射 | 根因证据 | 状态 | |------|---------|---------|------| -| 产品图多张(DB+上传端点+前端multiple) | 问题2/14新 | image_path单值字符串,无multiple,覆盖式 | □ | -| 生图按场景选用对应图 | 问题2新 | pipeline_io只读单条image_path | □ | -| operator选品/快速建品/产品入镜 | 问题1新 | POST/products限admin,operator不能建 | □ | +| 产品图多张(DB+上传端点+前端multiple) | 问题2/14新 | image_path单值字符串,无multiple,覆盖式 | ✅ product_images表(alembic020)+ProductImage模型(lazy=selectin)+5端点(upload带scene/列/改场景/设主图/删图,删主图自动提升下张防image_path悬空);前端ProductImageManager.tsx多图画廊(每张缩略图+场景下拉+设主图+删除);tsc EXIT0;API链路实测建品→传2图(primary+scene)→改场景→设主图→删图全code=0 | +| 生图按场景选用对应图 | 问题2新 | pipeline_io只读单条image_path | ✅ ROLE_SCENE_PREFERENCE覆盖全10分镜role;_pick_reference_for_role按偏好链选图缺失回落primary再回落fallback;image_gen/pipeline_io/fission_images/fission_image_retry四路均构造并传images_by_scene;真实数据验证texture分镜命中texture图(不同字节确认)、其余回落primary、无多图回落原reference;独立agent交叉核验6链路点全真0实质bug | +| operator选品/快速建品/产品入镜 | 问题1新 | POST/products限admin,operator不能建 | ✅ create/update放开require_admin→require_write_permission;delete_product保留require_admin(破坏性不放开);operator账号实测建品id=6+传图成功code=0 | ### R6 账号体系+Key+权限(P1) | 子项 | 去重映射 | 根因证据 | 状态 | |------|---------|---------|------| -| 后端users.py增/删/列用户(删=软删is_active) | 问题15新 | 无users CRUD,is_active登录已校验:37 | □ | -| 前端配置台账号管理Tab | 问题15新 | 无账号管理入口 | □ | -| Key绑定账号显式展示 | 问题15新 | UserApiKey已按user隔离,只缺前端展示 | □ | -| operator配置编辑权(放开全部产品) | 问题13a新 | /config硬限admin,products CRUD require_admin | □ | -| 保存按钮补错误提示(治点不动) | 问题13b新 | ProductForm校验静默return无提示 | □ | +| 后端users.py增/删/列用户(删=软删is_active) | 问题15新 | 无users CRUD,is_active登录已校验:37 | ⏸️暂停(倩倩姐2026-06-18拍板:现有4种子账号够北哥测,加人能力留后;先推R7飞轮) | +| 前端配置台账号管理Tab | 问题15新 | 无账号管理入口 | ⏸️暂停(同上,随后端users CRUD一起做) | +| Key绑定账号显式展示 | 问题15新 | UserApiKey已按user隔离,只缺前端展示 | ✅ B方案拆页:运营进/settings「工作配置」自录key;实测operator录key(id=4 last4=0001)归user_id=3、admin的key(id=2)归user_id=1,DB核对user_id严格隔离各算各的;只显末4位不显余额(守红线) | +| operator配置编辑权(放开全部产品) | 问题13a新 | /config硬限admin,products CRUD require_admin | ✅ B方案:配置页按角色拆/settings(运营+组长+admin,含APIKey+产品档案)+/config(仅admin,标杆+违禁词);Sidebar+AuthGuard双层放开/settings给三角色;前缀匹配无串扰;角色矩阵静态核查无死链;独立agent交叉核验0穿透漏洞;后端require_write_permission已对齐 | +| 保存按钮补错误提示(治点不动) | 问题13b新 | ProductForm校验静默return无提示 | ✅ ProductForm加error state:空名提示"请填写产品名称"+catch后端报错展示(role=alert);tsc EXIT0 | ### R7 飞轮闭环+可视化(P2) | 子项 | 去重映射 | 根因证据 | 状态 | |------|---------|---------|------| -| 飞轮片段注入生图链路(补断点1) | 新 | run_image_generation没传flywheel_fragment | □ | -| 选图信号带strategy进飞轮(补断点2) | 新 | image_select的angle_label=None空转 | □ | -| 统一前端查询与注入的计数口径 | 新 | service用次数/aggregator用权重不一致 | □ | -| AI评图分仅展示+生成筛选 | =既有E12 | 守eval_score NULL红线 | □ | -| 飞轮可视化轻量节点反馈 | =既有FV/D2 | 让客户感到越用越好 | □ | +| 飞轮片段注入生图链路(补断点1) | 新 | run_image_generation没传flywheel_fragment | ✅ generate_storyboard_images+run_image_generation加flywheel_fragment参数,tasks.py传load_flywheel_context真实聚合结果;拼per_prompt措辞"仅影响文字角度/排版不改瓶身"守红线;独立agent复核"真修好" | +| 选图信号带strategy进飞轮(补断点2) | 新 | image_select的angle_label=None空转 | ✅ IMAGE_STRATEGY_ANGLE映射A痛点/B场景/C成分背书,image_select取ic.strategy映射angle_label+原值存signal_meta;record_signal加signal_meta参;strategy空安全降级;复核"真修好" | +| 统一前端查询与注入的计数口径 | 新 | service用次数/aggregator用权重不一致 | ✅ get_preference_context改委托aggregate_preference_context(权重口径),image_select纳入角度聚合;展示端点与生产链同源,交叉验证输出字符串完全一致;复核"真修好" | +| AI评图分仅展示+生成筛选 | =既有E12 | 守eval_score NULL红线 | ✅ 既有已达标:ai_visual_score只展示+筛选,eval_score全程NULL,本轮改动复核确认未碰;ImageStrategyGroup徽章展示AI分 | +| 飞轮可视化轻量节点反馈 | =既有FV/D2 | 让客户感到越用越好 | ◐ 已有雏形:生成时FlywheelBanner"本次已注入偏好角度"+改稿TextCandidateCard"🌿已学习你的改动"+AI分徽章;无独立大看板(符合砍E01红线) | ### R8 归档(P2) | 子项 | 去重映射 | 根因证据 | 状态 | |------|---------|---------|------| -| 历史打回显示原因 | =问题10,既有M4沾边 | _fmt_task()没返reject_reason,前端没读 | □ | -| 日期/品类筛选+导出 | =既有M4 | M4未开工 | □ | +| 历史打回显示原因 | =问题10,既有M4沾边 | ✅后端_fmt_task返review_status/reject_reason/reviewed_at(tasks.py:58),前端HistoryTable加rejected行红banner展示打回原因(代码就绪,Z阶段目视) | ◐ | +| 日期/品类筛选+导出 | =既有M4 | ✅后端筛选链+导出端点独立agent验过(A1/A2/A9),前端HistoryFilters就绪待目视 | ◐ | --- diff --git a/plans/核销-第12环-归档.md b/plans/核销-第12环-归档.md index 67f48f1..25d17c1 100644 --- a/plans/核销-第12环-归档.md +++ b/plans/核销-第12环-归档.md @@ -14,24 +14,24 @@ | S3 | history/page.tsx 加 dateFrom/dateTo state + 两个 ``(提 DateRangeFilter 子组件防超100行) | history/page.tsx:28-44 + JSX 50-94 | | S4 | HistoryTable 表头/行加"产品"列(显 product_name ?? '-') | history/components.tsx:70-127 | | S5 | TaskListItem 加 `product_name?:string` | dto.tasks.ts:8-16 | -| S6 | ⚠️多套打包:查所有 is_selected=True TextCandidate 按 angle_label 分组每组取第一;notes 列表长度=选中套数(改 line 77-85 为 for loop);images_data 循环外查一次,每套传 `list(images_data)` 副本(退化:图共享复制进各套夹) | packaging_task.py:45-47,77-85 | +| S6 | ✅多套打包(2026-06-18实做修正分组依据):实测真相=图按 `ImageCandidate.strategy`(A/B/C三套正交叙事)分组,三套图均以first_copy第1条文案为语境生成→**按strategy分组比angle_label更贴数据实际,且无需加set_index列**。改 packaging_task.py:查全部图按strategy分组(OrderedDict)+同(strategy,seq)取最新id去重(防重生重复)+老数据strategy=None归"_"单套兼容;文案配对:选中文案数≥套数则一套一条否则共用第1条;每套成独立note进exporter | packaging_task.py:45-100 | | S7 | 新建 GET /products/export + /benchmarks/export(format=json/csv);require_write_permission;⚠️benchmarks/export 需 Product JOIN 填 product_name(复用 _fmt_benchmark 不够,只有 product_id) | products.py + benchmarks.py | | S8 | 配置页产品 tab + 历史归档页各加"导出数据"按钮,通用 ExportButton(url,filename) 调 getBlob 下载 | history/components.tsx 或 config/ExportTab.tsx | ## 验收清单(做完一个勾一个) -- [ ] **A1**〔auto〕日期筛选:传 date_from/date_to → items 全落区间;传 date_from=2099 → 空数组 total=0。⚠️审计补:再验 `date_from=not-a-date` → code=40001(非500)。 -- [ ] **A2**〔auto〕product_id 筛选:传 product_id=1 → 每条 product_id=1 且 product_name 非空。 -- [ ] **A3**〔human〕前端产品列:/history 表头有"产品"列,每行显产品名(非数字),与 GET /products/{id}.name 一致。 -- [ ] **A4**〔human〕日期控件:有开始/结束 date input,填后表格刷新,XHR URL 含 date_from/date_to,结果≤全量。 +- [x] **A1**〔auto〕日期筛选:✅2026-06-18 ws3实测:全量79条→当日(2026-06-17)区间过滤=4条,未来(2099+)=0条;非法日期"not-a-date"→raise_param_error返400/40001(非500)。date_to含当日(+1天开区间)。 +- [x] **A2**〔auto〕product_id 筛选:✅2026-06-18 ws3实测:product_id=1过滤命中78条,product_name批量查到'素颜霜'(防N+1:set收集→一次in_查询建name_map)。 +- [ ] **A3**〔human〕前端产品列:/history 表头有"产品"列,每行显产品名(非数字),与 GET /products/{id}.name 一致。【代码已就绪:components.tsx加产品列+dto加product_name,typecheck过+独立审核过;待Z阶段浏览器目视】 +- [ ] **A4**〔human〕日期控件:有开始/结束 date input,填后表格刷新,XHR URL 含 date_from/date_to,结果≤全量。【代码已就绪:HistoryFilters.tsx日期input+产品下拉+清除,page接入筛选变化重置页码;待Z阶段浏览器目视】 - [ ] **A5**〔human〕下载交付包:approved 任务点"下载交付包",≤60s 触发下载 delivery-{id}.zip(>1KB);`unzip -l` 含 note_01/文案.txt/发布清单/合规说明。 -- [ ] **A6**〔auto〕zip 结构完整:`unzip -l` grep 文案.txt/发布清单/合规说明/note_01//.jpg 全非空。⚠️审计补:若 .jpg 空先查 ImageCandidate.url 文件容器内可达(packaging_task.py:64-70 读失败静默跳过)。 -- [ ] **A7**〔auto〕文案.txt 格式:`unzip -p note_01/文案.txt` 含"【标题】"、不以 `{` 开头(JSON 已解析)。 -- [ ] **A8**〔human+auto〕多套打包:⚠️审计改 human(需先 seed 3条 is_selected TextCandidate)→构造后 `unzip -l|grep 'note_0[1-3]/'|wc -l`≥3,三套标题互不相同。 -- [ ] **A9**〔auto〕产品 JSON 导出:GET /products/export → 200,application/json,数组,每元素含 id/name/selling_points,且 selling_points 是数组(非JSON字符串)。 -- [ ] **A10**〔auto〕标杆 CSV 导出:GET /benchmarks/export?format=csv → 200,text/csv,首行表头含 product_id/highlights/created_at,行数≥已录标杆数。 -- [ ] **A11**〔auto〕导出权限:⚠️审计注——require_write_permission 当前只查 workspace_members 不分角色(workspace_guard.py:65-88);未登录调 → code=40101,workspace 成员 → 200。 -- [ ] **A12**〔human〕不显 token 余额(红线):⚠️审计改 human(curl 拿不到Next.js水合后DOM)——浏览器 Ctrl+F 搜"余额/用量/tokens/balance"无结果。 +- [x] **A6**〔auto〕zip 结构完整:✅2026-06-18 task74端到端验:zip解出note_01/02/03各7项(6图+文案.txt),发布清单+合规说明在root,18张图全非空真写入(磁盘文件真读到)。 +- [x] **A7**〔auto〕文案.txt 格式:✅2026-06-18 task74验:note_02/文案.txt以"【标题】懒人三分钟搞定,倍分子上脸气色回来了"开头(非{,JSON已解析),品牌词"倍分子"已植入。 +- [x] **A8**〔human+auto〕多套打包:✅2026-06-18 task74(6图×3套A/B/C)端到端真跑:选1条文案→建package→build_delivery_package→`grep note_0[1-3]`=3套各6图,每套独立note_0N夹+文案.txt。独立agent交叉验证7条全过(分组/去重/老数据兼容/越界/异常/契约/红线)。⚠️攒批问倩倩姐:当前只生成选1条文案,三套图共用同文案→交付包"3套"仅图风格(A/B/C)不同、文案全同,品牌词"倍分子"已正确植入。是否要三套各配不同角度文案? +- [x] **A9**〔auto〕产品 JSON 导出:✅2026-06-18 ws3实测:export_products返3条,selling_points类型=list(['烟酰胺改善暗沉提亮','水解珍珠锁水匀肤']非JSON字符串);端点真注册路由表+顺序在/products/{product_id}之前(idx9<13不被吞)。 +- [ ] **A10**〔auto〕标杆 CSV 导出:⏸️逻辑已审(CSV带UTF-8 BOM+JOIN Product填product_name+json/csv双格式),但ws3无标杆数据未触发真导出;待录标杆后验或Z阶段一条龙带验。 +- [ ] **A11**〔auto〕导出权限:⚠️审计注——require_write_permission 当前只查 workspace_members 不分角色(workspace_guard.py:65-88);未登录调 → code=40101,workspace 成员 → 200。【两端点均挂require_write_permission,独立审核确认】 +- [ ] **A12**〔human〕不显 token 余额(红线):⚠️审计改 human(curl 拿不到Next.js水合后DOM)——浏览器 Ctrl+F 搜"余额/用量/tokens/balance"无结果。【HistoryFilters/page/components均无余额字段,独立审核确认】 ## 待北哥定义(不阻断代码) - 多套打包图片套间对应:ImageCandidate 无 set_index,是否加列关联文案?还是退化"图共享复制进每套夹"(本期默认退化)。