A8多套打包+M4归档+R5多图:存量功能备份

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 <noreply@anthropic.com>
This commit is contained in:
yangqianqian
2026-06-18 17:32:49 +08:00
parent 285791c12f
commit 4bed7425a8
41 changed files with 1211 additions and 236 deletions

View File

@@ -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")

View File

@@ -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

View File

@@ -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})

View File

@@ -7,14 +7,14 @@ category 是纯数据字段不在代码里做枚举基石A
import logging import logging
from typing import Annotated from typing import Annotated
from fastapi import APIRouter, Depends, UploadFile, File from fastapi import APIRouter, Depends, UploadFile, File, Form
from pydantic import BaseModel from pydantic import BaseModel
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.core.database import get_db from app.core.database import get_db
from app.core.response import ok, paginate, raise_not_found from app.core.response import ok, paginate, raise_not_found
from app.middleware.workspace_guard import CurrentUser, require_admin, require_write_permission 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__) logger = logging.getLogger(__name__)
router = APIRouter(tags=["products"]) router = APIRouter(tags=["products"])
@@ -50,6 +50,7 @@ class BannedWordCreate(BaseModel):
def _fmt_product(p: Product) -> dict: def _fmt_product(p: Product) -> dict:
import json import json
imgs = sorted(getattr(p, "images", None) or [], key=lambda im: im.sort_order)
return { return {
"id": p.id, "name": p.name, "category": p.category, "id": p.id, "name": p.name, "category": p.category,
"source": p.source, "is_active": p.is_active, "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 [], "text_angles": json.loads(p.text_angles) if p.text_angles else [],
"custom_prompt": p.custom_prompt, "custom_prompt": p.custom_prompt,
"image_path": p.image_path, "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字段暴露 "brand_keyword": p.brand_keyword, # 012: 套2字段暴露
"target_audience": p.target_audience, # 012: 套2字段暴露 "target_audience": p.target_audience, # 012: 套2字段暴露
"created_at": p.created_at.isoformat(), "created_at": p.created_at.isoformat(),
@@ -82,7 +88,7 @@ def list_products(
@router.post("/products") @router.post("/products")
def create_product( def create_product(
body: ProductCreate, body: ProductCreate,
current_user: Annotated[CurrentUser, Depends(require_admin)] = None, current_user: Annotated[CurrentUser, Depends(require_write_permission)] = None,
db: Session = Depends(get_db), db: Session = Depends(get_db),
): ):
import json import json
@@ -118,7 +124,7 @@ def get_product(
@router.put("/products/{product_id}") @router.put("/products/{product_id}")
def update_product( def update_product(
product_id: int, body: ProductCreate, 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), db: Session = Depends(get_db),
): ):
import json import json
@@ -155,10 +161,28 @@ _ALLOWED_CONTENT_TYPES = {"image/jpeg", "image/png", "image/webp"}
_MAX_SIZE_BYTES = 10 * 1024 * 1024 # 10 MB _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") @router.post("/products/{product_id}/upload-image")
async def upload_product_image( async def upload_product_image(
product_id: int, product_id: int,
file: UploadFile = File(...), file: UploadFile = File(...),
scene: str = Form("primary"),
current_user: Annotated[CurrentUser, Depends(require_write_permission)] = None, current_user: Annotated[CurrentUser, Depends(require_write_permission)] = None,
db: Session = Depends(get_db), db: Session = Depends(get_db),
): ):
@@ -166,10 +190,12 @@ async def upload_product_image(
上传产品参考图(铁律:生图必须带产品图)。 上传产品参考图(铁律:生图必须带产品图)。
文件类型JPEG/PNG/WebP大小上限 10 MB。 文件类型JPEG/PNG/WebP大小上限 10 MB。
存储路径uploads/products/{workspace_id}/{product_id}/{filename} 存储路径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.response import raise_business
from app.core.config import get_settings from app.core.config import get_settings
from app.constants.enums import ProductImageScene
import os, uuid import os, uuid
p = db.query(Product).filter( p = db.query(Product).filter(
@@ -179,12 +205,18 @@ async def upload_product_image(
if not p: if not p:
raise_not_found("产品不存在") 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: if file.content_type not in _ALLOWED_CONTENT_TYPES:
raise_business(f"不支持的文件类型 {file.content_type},仅支持 JPEG/PNG/WebP") raise_business(f"不支持的文件类型 {file.content_type},仅支持 JPEG/PNG/WebP")
data = await file.read() data = await file.read()
if len(data) > _MAX_SIZE_BYTES: if len(data) > _MAX_SIZE_BYTES:
raise_business("文件超过 10 MB 限制") raise_business("文件超过 10 MB 限制")
if not _check_image_magic(data):
raise_business("文件内容与扩展名不符(非真实 JPEG/PNG/WebP 图片)")
settings = get_settings() settings = get_settings()
ext = os.path.splitext(file.filename or "img.jpg")[1] or ".jpg" 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: with open(save_path, "wb") as f:
f.write(data) 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.commit()
db.refresh(p) 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)) return ok(_fmt_product(p))
@@ -273,6 +324,8 @@ async def analyze_product_image(
data = await file.read() data = await file.read()
if len(data) > _MAX_SIZE_BYTES: if len(data) > _MAX_SIZE_BYTES:
raise_business("文件超过 10 MB 限制") raise_business("文件超过 10 MB 限制")
if not _check_image_magic(data):
raise_business("文件内容与扩展名不符(非真实 JPEG/PNG/WebP 图片)")
# key 解密(照抄 pipeline_steps.decrypt_user_key基石B # key 解密(照抄 pipeline_steps.decrypt_user_key基石B
api_key_row = db.query(UserApiKey).filter( api_key_row = db.query(UserApiKey).filter(

View File

@@ -58,7 +58,9 @@ def select_image(
db: Session = Depends(get_db), db: Session = Depends(get_db),
): ):
"""选图(飞轮信号 image_select +3""" """选图(飞轮信号 image_select +3"""
import json
from app.services.flywheel_service import record_signal from app.services.flywheel_service import record_signal
from app.constants.enums import IMAGE_STRATEGY_ANGLE
task = _check_task_ownership( task = _check_task_ownership(
db.query(GenerationTask).filter(GenerationTask.id == task_id).first(), db.query(GenerationTask).filter(GenerationTask.id == task_id).first(),
current_user.workspace_id, current_user.workspace_id,
@@ -70,7 +72,14 @@ def select_image(
raise_not_found("图片候选不存在") raise_not_found("图片候选不存在")
ic.is_selected = True ic.is_selected = True
db.commit() 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}) return ok({"selected": body.candidate_id})
@@ -159,7 +168,14 @@ def get_preference_context(
current_user.workspace_id, current_user.workspace_id,
) )
from app.services.flywheel_service import get_preference_context 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) return ok(ctx)

View File

@@ -170,16 +170,45 @@ def create_task(
def list_tasks( def list_tasks(
page: int = 1, page_size: int = 20, page: int = 1, page_size: int = 20,
status: list[str] | None = Query(default=None), 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, current_user: Annotated[CurrentUser, Depends(require_write_permission)] = None,
db: Session = Depends(get_db), 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) q = db.query(GenerationTask).filter(GenerationTask.workspace_id == current_user.workspace_id)
if status: if status:
# 支持多状态(?status=approved&status=rejected),单值也兼容 # 支持多状态(?status=approved&status=rejected),单值也兼容
q = q.filter(GenerationTask.status.in_(status)) 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() total = q.count()
items = q.order_by(GenerationTask.created_at.desc()).offset((page - 1) * page_size).limit(page_size).all() 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}") @router.get("/{task_id}")

View File

@@ -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): class DataOwnership(str, Enum):
CLIENT_DATA = "client_data" # 原始输入产出,客户可导出 CLIENT_DATA = "client_data" # 原始输入产出,客户可导出
@@ -110,6 +120,15 @@ class ImageRole(str, Enum):
MAIN = "main" MAIN = "main"
# ── 产品参考图场景类型R5多图标注每张产品图拍的是什么供生图按分镜role选用──
class ProductImageScene(str, Enum):
PRIMARY = "primary" # 白底/正面主图(默认主图,必有)
SCENE = "scene" # 使用场景图(梳妆台/生活环境)
TEXTURE = "texture" # 质地特写(膏体/涂抹)
INGREDIENT = "ingredient" # 成分/包装细节
MODEL = "model" # 上脸/使用中
# ── AI 图片提供商 ───────────────────────────────────── # ── AI 图片提供商 ─────────────────────────────────────
class ImageProvider(str, Enum): class ImageProvider(str, Enum):
GPT = "gpt" GPT = "gpt"

View File

@@ -42,7 +42,8 @@ class Settings(BaseSettings):
CELERY_RESULT_BACKEND: str = "" 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" UPLOAD_BASE_PATH: str = "uploads/packages"

View File

@@ -59,5 +59,9 @@ def raise_business(message: str) -> None:
raise CloverHTTPException(422, ErrorCode.BUSINESS_ERROR, message) 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: def raise_state_invalid(message: str = "状态机非法流转") -> None:
raise CloverHTTPException(409, ErrorCode.STATE_INVALID, message) raise CloverHTTPException(409, ErrorCode.STATE_INVALID, message)

View File

@@ -13,7 +13,7 @@ from sqlalchemy import (
) )
from sqlalchemy.orm import Mapped, mapped_column, relationship 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 from app.core.database import Base
@@ -52,12 +52,45 @@ class Product(Base):
benchmark_notes: Mapped[list["BenchmarkNote"]] = relationship( benchmark_notes: Mapped[list["BenchmarkNote"]] = relationship(
back_populates="product", lazy="noload" 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__ = ( __table_args__ = (
Index("idx_products_workspace_id", "workspace_id"), 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): class BenchmarkNote(Base):
"""标杆笔记(截图+手填亮点为主通道)""" """标杆笔记(截图+手填亮点为主通道)"""
__tablename__ = "benchmark_notes" __tablename__ = "benchmark_notes"

View File

@@ -131,6 +131,38 @@ angle本条角度标签/ coverTitle封面大字≤10字/ imageBrief
硬性格式只输出JSON不要markdown代码块字符串内用中文引号「」。""" 硬性格式只输出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: def build_prompt(product: dict, count: int, extra_rules: str = "") -> str:
""" """
组装文案生成 user_prompt。 组装文案生成 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 "" angle_hint = f"文案角度要覆盖:{''.join(angles)}(每条用不同角度)。" if angles else ""
brand_rule = f"每条正文和标题中植入品牌词「{brand_kw}」一次(自然融入,不生硬)。" if brand_kw else "" brand_rule = f"每条正文和标题中植入品牌词「{brand_kw}」一次(自然融入,不生硬)。" if brand_kw else ""
benchmark_block = _build_benchmark_block(product.get("benchmark_refs") or [])
lines = [ lines = [
f"产品:{name}", f"产品:{name}",
@@ -161,6 +194,7 @@ def build_prompt(product: dict, count: int, extra_rules: str = "") -> str:
angle_hint, angle_hint,
brand_rule, brand_rule,
custom, custom,
benchmark_block,
f"\n【Q1随机变量池·每条身份/起因/小缺点各不相同,严格按下方分配使用】", f"\n【Q1随机变量池·每条身份/起因/小缺点各不相同,严格按下方分配使用】",
combos_text, combos_text,
extra_rules, extra_rules,

View File

@@ -70,6 +70,23 @@ PAGE_ROLES = [
] ]
PAGE_ROLE_MAP = {r["role"]: r for r in PAGE_ROLES} PAGE_ROLE_MAP = {r["role"]: r for r in PAGE_ROLES}
# ── R5多图分镜role → 优先产品图场景(scene) 偏好表 ──────────
# 生图时按分镜 role 选该场景的产品图当参考;取不到回落主图(primary)。
# scene 取值见 enums.ProductImageSceneprimary/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────────── # ── 生图风格预设(扒 image.js STYLE_PROMPTS:26-29──────────
# 按 style 参数选小红书风格调性,注入 base_prompt 的"视觉风格"行 # 按 style 参数选小红书风格调性,注入 base_prompt 的"视觉风格"行
STYLE_PROMPTS = { STYLE_PROMPTS = {

View File

@@ -14,13 +14,36 @@ import logging
import os import os
from typing import Any, Protocol 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 .image_scorer import score_image
from .storyboard import plan_image_set, sanitize_text from .storyboard import plan_image_set, sanitize_text
logger = logging.getLogger(__name__) 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): class ImageClient(Protocol):
"""worker 注入的图片生成客户端协议(隔离 key 细节)""" """worker 注入的图片生成客户端协议(隔离 key 细节)"""
async def gpt_edits( async def gpt_edits(
@@ -129,12 +152,18 @@ async def generate_storyboard_images(
strategy: str | None = None, strategy: str | None = None,
target_role: str | None = None, target_role: str | None = None,
custom_prompt: str | None = None, custom_prompt: str | None = None,
images_by_scene: dict[str, list[bytes]] | None = None,
flywheel_fragment: str | None = None,
) -> list[dict]: ) -> list[dict]:
""" """
按 storyboard 逐张生图asyncio.gather 并发),返回每张结果列表。 按 storyboard 逐张生图asyncio.gather 并发),返回每张结果列表。
strategy: None=默认叙事,'A'/'B'/'C'=三套正交叙事策略 strategy: None=默认叙事,'A'/'B'/'C'=三套正交叙事策略
target_role: 非空时只生成该 role 那一张R2 单张重生) target_role: 非空时只生成该 role 那一张R2 单张重生)
custom_prompt: 非空时追加到每张 per_prompt 末尾R2 人工提示词) custom_prompt: 非空时追加到每张 per_prompt 末尾R2 人工提示词)
images_by_scene: R5多图{scene: [bytes]},按分镜 role 选对应场景图;
为空则全分镜共用 reference_images向后兼容
flywheel_fragment: R7 飞轮偏好片段(最近选图/打回真实信号聚合),注入图片
排版偏好;仅影响文字角度/版式取向,绝不改瓶身(合规红线)。
每项:{role, name, image_bytes, error} 每项:{role, name, image_bytes, error}
""" """
plan = plan_image_set(note, product, image_count, analysis, strategy=strategy) plan = plan_image_set(note, product, image_count, analysis, strategy=strategy)
@@ -169,8 +198,20 @@ async def generate_storyboard_images(
# R2 人工提示词:追加到末尾权重最高,但不覆盖前面合规/真实约束 # R2 人工提示词:追加到末尾权重最高,但不覆盖前面合规/真实约束
if custom_prompt: if custom_prompt:
per_prompt += f"\n运营补充要求(在不违反上述合规与真实约束前提下尽量满足):{sanitize_text(custom_prompt, 200)}" per_prompt += f"\n运营补充要求(在不违反上述合规与真实约束前提下尽量满足):{sanitize_text(custom_prompt, 200)}"
# R7 飞轮偏好:仅作用于文字角度/版式取向参考,绝不改瓶身(合规+真实红线)
if flywheel_fragment:
per_prompt += (
f"\n历史偏好参考(仅影响标题文字角度与排版取向,不得据此改动产品瓶身):"
f"{sanitize_text(flywheel_fragment, 300)}"
)
try: 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 文字校验闸门实测 # 注gpt-image-2 渲染中文偶发错别字(约1/6)。vision/OCR 文字校验闸门实测
# 不可靠(漏报形近字+幻觉误伤品牌词),倩倩姐2026-06-16拍板先撤,纯生图, # 不可靠(漏报形近字+幻觉误伤品牌词),倩倩姐2026-06-16拍板先撤,纯生图,
# 错别字作已知问题记录,后续迭代再处理。详见记忆 clover-image-text-check-shelved。 # 错别字作已知问题记录,后续迭代再处理。详见记忆 clover-image-text-check-shelved。

View File

@@ -52,7 +52,8 @@ def aggregate_preference_context(
weight = int(e.get("signal_weight", 1)) weight = int(e.get("signal_weight", 1))
# text_edit(改稿)是最强真实信号,角度按权重计入(倩倩姐2026-06-16拍板) # 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 angle_counts[angle] += weight
elif sig_type == "reject_with_reason": elif sig_type == "reject_with_reason":
reason = str(e.get("reason") or "").strip() reason = str(e.get("reason") or "").strip()

View File

@@ -60,6 +60,19 @@ def retry_fission_note_images(
except Exception as e: # noqa: BLE001 except Exception as e: # noqa: BLE001
logger.warning("裂变补图参考图读取失败,无参考图模式: %s", e) 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 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)) 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) os.makedirs(img_dir, exist_ok=True)
@@ -73,6 +86,7 @@ def retry_fission_note_images(
client=clients, note=note, product=product, client=clients, note=note, product=product,
image_count=image_count, reference_images=reference_images, image_count=image_count, reference_images=reference_images,
target_role=role, target_role=role,
images_by_scene=images_by_scene or None,
)) ))
except Exception as exc: # noqa: BLE001 except Exception as exc: # noqa: BLE001
logger.error("裂变补图 seq=%s role=%s 仍失败: %s", fn.seq, role, exc) logger.error("裂变补图 seq=%s role=%s 仍失败: %s", fn.seq, role, exc)

View File

@@ -39,6 +39,19 @@ def generate_fission_images(
except Exception as e: # noqa: BLE001 except Exception as e: # noqa: BLE001
logger.warning("裂变参考图读取失败,无参考图模式: %s", e) 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 upload_base = get_settings().UPLOAD_ABS_ROOT
for nid in note_ids: for nid in note_ids:
fn = db.query(FissionNote).filter(FissionNote.id == nid).first() fn = db.query(FissionNote).filter(FissionNote.id == nid).first()
@@ -52,6 +65,7 @@ def generate_fission_images(
results = asyncio.run(generate_storyboard_images( results = asyncio.run(generate_storyboard_images(
client=clients, note=note, product=product, client=clients, note=note, product=product,
image_count=image_count, reference_images=reference_images, image_count=image_count, reference_images=reference_images,
images_by_scene=images_by_scene or None,
)) ))
except Exception as exc: # noqa: BLE001 except Exception as exc: # noqa: BLE001
logger.error("裂变套 seq=%s 生图失败: %s", fn.seq, exc) logger.error("裂变套 seq=%s 生图失败: %s", fn.seq, exc)

View File

@@ -8,10 +8,10 @@ preference_aggregator查最近50条 → 最常选角度 + 打回原因近3条
import logging import logging
from typing import Any from typing import Any
from sqlalchemy import desc, func from sqlalchemy import desc
from sqlalchemy.orm import Session 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.middleware.workspace_guard import CurrentUser
from app.models.flywheel import PreferenceEvent from app.models.flywheel import PreferenceEvent
from app.models.task import GenerationTask from app.models.task import GenerationTask
@@ -20,8 +20,6 @@ logger = logging.getLogger(__name__)
# 实时聚合窗口最近50条事件 # 实时聚合窗口最近50条事件
_AGGREGATION_WINDOW = 50 _AGGREGATION_WINDOW = 50
# 冷启动阈值不足5条信号用产品档案冷启动
_COLD_START_THRESHOLD = 5
def record_signal( def record_signal(
@@ -32,12 +30,14 @@ def record_signal(
candidate_id: int | None = None, candidate_id: int | None = None,
angle_label: str | None = None, angle_label: str | None = None,
reason: str | None = None, reason: str | None = None,
signal_meta: str | None = None,
) -> None: ) -> None:
""" """
写入飞轮信号。 写入飞轮信号。
workspace_id + product_id 都必须有基石C + 按产品分开学)。 workspace_id + product_id 都必须有基石C + 按产品分开学)。
signal_weight 用枚举默认值,北哥可校准。 signal_weight 用枚举默认值,北哥可校准。
data_ownership 默认 client_data选择行为归客户 data_ownership 默认 client_data选择行为归客户
signal_metaJSON 扩展(如选图存 strategy不参与角度聚合主逻辑。
""" """
weight = SIGNAL_WEIGHTS.get(signal_type, 0) weight = SIGNAL_WEIGHTS.get(signal_type, 0)
event = PreferenceEvent( event = PreferenceEvent(
@@ -50,6 +50,7 @@ def record_signal(
candidate_id=candidate_id, candidate_id=candidate_id,
angle_label=angle_label, angle_label=angle_label,
reason=reason, reason=reason,
signal_meta=signal_meta,
data_ownership=DataOwnership.CLIENT_DATA, data_ownership=DataOwnership.CLIENT_DATA,
) )
try: try:
@@ -69,14 +70,17 @@ def record_signal(
def get_preference_context( 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]: ) -> dict[str, Any]:
""" """
实时聚合偏好上下文最近50条 events 实时聚合偏好上下文最近50条 events供前端展示
返回recent_preference摘要 + reject_reasons近3条 + injected_count。 R7断点3统一口径——委托给生产链同一个 aggregate_preference_context权重口径
不足5条 → 冷启动提示(产品档案兜底,由 AIE prompt 层读 products.custom_prompt)。 消除"前端展示按次数/生成按权重"的口径分裂(说一套做一套)。
按 workspace_id + product_id 严格过滤不串数据基石C 按 workspace_id + product_id 严格过滤不串数据基石C
""" """
from app.services.ai_engine.preference_aggregator import aggregate_preference_context
recent = ( recent = (
db.query(PreferenceEvent) db.query(PreferenceEvent)
.filter( .filter(
@@ -87,35 +91,18 @@ def get_preference_context(
.limit(_AGGREGATION_WINDOW) .limit(_AGGREGATION_WINDOW)
.all() .all()
) )
events_dicts = [
if len(recent) < _COLD_START_THRESHOLD: {"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 { return {
"recent_preference": "信号不足,使用产品档案基线(冷启动)", "recent_preference": ctx.get("recent_preference", ""),
"reject_reasons": [], "reject_reasons": ctx.get("reject_reasons", []),
"injected_count": len(recent), "injected_count": ctx.get("injected_count", 0),
}
# 统计最常被选中的角度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]
return {
"recent_preference": preference_summary,
"reject_reasons": reject_reasons,
"injected_count": len(recent),
} }

View File

@@ -30,6 +30,28 @@ def _check_user_has_key(db: Session, user_id: int, workspace_id: int) -> None:
raise_business("尚未配置 API Key请先在设置中录入") 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( def create_generation_task(
db: Session, db: Session,
current_user: CurrentUser, current_user: CurrentUser,
@@ -42,6 +64,9 @@ def create_generation_task(
if body.track == "ai": if body.track == "ai":
# 轨A先检查有没有 key # 轨A先检查有没有 key
_check_user_has_key(db, current_user.user_id, current_user.workspace_id) _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)时,产品必须已上传参考图, # 禁降级铁律:本次产品入镜(need_product_image=True)时,产品必须已上传参考图,
# 否则拒绝建任务(不允许降级纯文生图,防产品包装跑偏/过抽检失败)。 # 否则拒绝建任务(不允许降级纯文生图,防产品包装跑偏/过抽检失败)。
@@ -57,6 +82,11 @@ def create_generation_task(
if not (product.image_path or "").strip(): if not (product.image_path or "").strip():
raise_business("该产品未上传参考图,无法生成产品入镜内容;请先到产品库上传产品图,或关闭「产品入镜」开关") 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( task = GenerationTask(
workspace_id=current_user.workspace_id, workspace_id=current_user.workspace_id,
product_id=body.product_id, product_id=body.product_id,
@@ -66,6 +96,7 @@ def create_generation_task(
image_count=body.image_count, image_count=body.image_count,
track=body.track, track=body.track,
need_product_image=need_img, need_product_image=need_img,
benchmark_ids=benchmark_ids_json,
status="pending", status="pending",
) )
db.add(task) db.add(task)

View File

@@ -42,47 +42,62 @@ def build_delivery_package(self, package_id: int) -> dict:
settings = get_settings() settings = get_settings()
upload_base = settings.UPLOAD_BASE_PATH.rstrip("/") 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, TextCandidate.task_id == task_id, TextCandidate.is_selected == True,
).first() ).order_by(TextCandidate.id).all()
# 整套全打倩倩姐2026-06-08拍板一条笔记的全部图按 seq 排序进包, if not selected_texts:
# 不再只打 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:
raise ValueError("无已选文案,请先选择文案") raise ValueError("无已选文案,请先选择文案")
text_data = json.loads(selected_text.content or "{}") # 整套全打:一套内全部图按 seq 排序进包,不只打封面。重生场景同 (strategy,seq)
images_data = [] # 可能多条(新增不删旧),去重取最新(id最大),避免包内重复图。
for ic in selected_images: 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"" img_bytes = b""
if ic.url: if ic.url:
# url 形如 /uploads/ws/task/file.jpg本身已含 uploads 前缀。 # url 已含 uploads 前缀;工作目录 /applstrip 当相对路径读,勿再拼 base(防 uploads/uploads)
# 工作目录是 /app直接 lstrip("/") 当相对路径读,不能再拼 upload_base(会重复 uploads/uploads)。
rel = ic.url.lstrip("/")
abs_path = rel
try: try:
with open(abs_path, "rb") as f: with open(ic.url.lstrip("/"), "rb") as f:
img_bytes = f.read() img_bytes = f.read()
except OSError as e: except OSError as e:
logger.warning("图片读取失败,跳过:%s %s", abs_path, e) logger.warning("图片读取失败,跳过:%s %s", ic.url, e)
images_data.append({ return {
"seq": ic.seq, "seq": ic.seq,
"role": ic.role.value if hasattr(ic.role, "value") else str(ic.role), "role": ic.role.value if hasattr(ic.role, "value") else str(ic.role),
"data": img_bytes, "data": img_bytes,
}) }
notes = [{ # 按 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", ""), "title": text_data.get("title", ""),
"content": text_data.get("content", ""), "content": text_data.get("content", ""),
"tags": text_data.get("tags", []), "tags": text_data.get("tags", []),
"images": images_data, "images": images_data,
"banned_word_status": (selected_text.banned_word_status.value "banned_word_status": (tc.banned_word_status.value
if hasattr(selected_text.banned_word_status, "value") if hasattr(tc.banned_word_status, "value")
else str(selected_text.banned_word_status)), 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 from app.services.ai_engine.package_exporter import build_delivery_package as do_build
# 打包产物放专用目录 uploads/packages/,与图片目录 uploads/{ws}/{task}/ 分开 # 打包产物放专用目录 uploads/packages/,与图片目录 uploads/{ws}/{task}/ 分开

View File

@@ -148,7 +148,8 @@ def run_image_generation(db, clients, task, product_dict: dict,
first_copy: dict, upload_base_path: str, first_copy: dict, upload_base_path: str,
regen_strategy: str | None = None, regen_strategy: str | None = None,
regen_role: 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。 Step6+7+8(image): 调 generate_storyboard_images → 后处理 → 存 ImageCandidate → 推 SSE。
返回 next_seq。 返回 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 seq = seq_start
# R2: 限定重生套别(regen_strategy)则只跑该套,否则全量 A/B/C 三套正交叙事 # R2: 限定重生套别(regen_strategy)则只跑该套,否则全量 A/B/C 三套正交叙事
_strategies = (regen_strategy,) if regen_strategy else ("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, strategy=strategy,
target_role=regen_role, target_role=regen_role,
custom_prompt=custom_prompt, custom_prompt=custom_prompt,
images_by_scene=images_by_scene or None,
flywheel_fragment=flywheel_fragment,
)) ))
except Exception as exc: except Exception as exc:
img_success = False img_success = False

View File

@@ -58,6 +58,7 @@ def build_clients_and_clear_key(plain_key: str):
def build_product_dict(product) -> dict: def build_product_dict(product) -> dict:
"""把 ORM product 转成 AI 引擎所需的 dict不含任何 key""" """把 ORM product 转成 AI 引擎所需的 dict不含任何 key"""
return { return {
"id": product.id,
"name": product.name, "name": product.name,
"category": product.category or "通用好物", "category": product.category or "通用好物",
"selling_points": json.loads(product.selling_points 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 "", "custom_prompt": product.custom_prompt or "",
"brand_keyword": product.brand_keyword or "", # S3: 品牌词透传进生成prompt(每条植入) "brand_keyword": product.brand_keyword or "", # S3: 品牌词透传进生成prompt(每条植入)
"target_audience": product.target_audience or "", # 012: 人群透传进storyboard/文案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]: def load_flywheel_context(db, workspace_id: int, product_id: int, product_dict: dict) -> tuple[str, dict]:
""" """
查最近50条飞轮事件聚合偏好上下文。 查最近50条飞轮事件聚合偏好上下文。

View File

@@ -106,6 +106,7 @@ def run_generation_pipeline(self, task_id: int, regen_strategy: str | None = Non
build_clients_and_clear_key, build_clients_and_clear_key,
build_product_dict, build_product_dict,
load_flywheel_context, load_flywheel_context,
load_benchmark_features,
) )
from app.workers.pipeline_io import run_text_generation, run_image_generation from app.workers.pipeline_io import run_text_generation, run_image_generation
from app.core.config import get_settings 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) }, seq)
product_dict = build_product_dict(product) 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) flywheel_fragment, flywheel_ctx = load_flywheel_context(db, workspace_id, task.product_id, product_dict)
if flywheel_fragment: 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, first_copy, settings.UPLOAD_BASE_PATH,
regen_strategy=regen_strategy, regen_role=regen_role, regen_strategy=regen_strategy, regen_role=regen_role,
custom_prompt=custom_prompt, custom_prompt=custom_prompt,
flywheel_fragment=flywheel_fragment,
) )
# 最终状态 + task_done # 最终状态 + task_done

View File

@@ -71,6 +71,7 @@ def _register_routers(app: FastAPI) -> None:
from app.api.v1.auth import router as auth_router 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.api_keys import router as api_keys_router
from app.api.v1.products import router as products_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.benchmarks import router as benchmarks_router
from app.api.v1.tasks import router as tasks_router from app.api.v1.tasks import router as tasks_router
from app.api.v1.task_actions import router as task_actions_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.stream import router as stream_router
from app.api.v1.workspaces import router as workspaces_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.fission import router as fission_router
from app.api.v1.exports import router as exports_router
prefix = "/api/v1" prefix = "/api/v1"
app.include_router(auth_router, prefix=prefix) app.include_router(auth_router, prefix=prefix)
app.include_router(api_keys_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(products_router, prefix=prefix)
app.include_router(product_images_router, prefix=prefix)
app.include_router(benchmarks_router, prefix=prefix) app.include_router(benchmarks_router, prefix=prefix)
app.include_router(tasks_router, prefix=prefix) app.include_router(tasks_router, prefix=prefix)
app.include_router(task_actions_router, prefix=prefix) app.include_router(task_actions_router, prefix=prefix)

View File

@@ -156,7 +156,10 @@ class TestPoint4SseFlywheelEvent:
mq.limit.return_value = mq mq.limit.return_value = mq
mq.all.return_value = orm_events 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"} fe_dto_keys = {"recent_preference", "reject_reasons", "injected_count"}
missing = fe_dto_keys - result.keys() missing = fe_dto_keys - result.keys()

View File

@@ -1,30 +1,26 @@
'use client'; 'use client';
/** /**
* 屏1 配置台(管理 /config * 系统管理 /config(仅管理员
* 产品库 / 标杆笔记 / 违禁词 / API Key 录入 * 标杆笔记 / 违禁词库 —— 平台级配置,写操作后端锁 admin
* 🔴 红线key 只录不显余额(不做 Token 用量展示) * 拆分自原 /configB方案运营日常用的 API Key/产品档案已移到 /settings「工作配置」
*/ */
import { useState } from 'react'; import { useState } from 'react';
import { ProductsTab } from '@/components/config/ProductsTab';
import { BenchmarksTab } from '@/components/config/BenchmarksTab'; import { BenchmarksTab } from '@/components/config/BenchmarksTab';
import { BannedWordsTab } from '@/components/config/BannedWordsTab'; 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 }[] = [ const TABS: { key: TabKey; label: string }[] = [
{ key: 'products', label: '产品档案' },
{ key: 'benchmarks', label: '标杆笔记' }, { key: 'benchmarks', label: '标杆笔记' },
{ key: 'banned_words', label: '违禁词库' }, { key: 'banned_words', label: '违禁词库' },
{ key: 'api_keys', label: 'API Key' },
]; ];
export default function ConfigPage() { export default function ConfigPage() {
const [activeTab, setActiveTab] = useState<TabKey>('products'); const [activeTab, setActiveTab] = useState<TabKey>('benchmarks');
return ( return (
<div className="p-6 max-w-5xl"> <div className="p-6 max-w-5xl">
<h2 className="text-xl font-bold text-text-primary mb-6"></h2> <h2 className="text-xl font-bold text-text-primary mb-6"></h2>
{/* Tab 导航 */} {/* Tab 导航 */}
<div className="flex gap-1 border-b border-border-default mb-6" role="tablist"> <div className="flex gap-1 border-b border-border-default mb-6" role="tablist">
@@ -48,10 +44,8 @@ export default function ConfigPage() {
{/* Tab 内容 */} {/* Tab 内容 */}
<div id={`panel-${activeTab}`} role="tabpanel"> <div id={`panel-${activeTab}`} role="tabpanel">
{activeTab === 'products' && <ProductsTab />}
{activeTab === 'benchmarks' && <BenchmarksTab />} {activeTab === 'benchmarks' && <BenchmarksTab />}
{activeTab === 'banned_words' && <BannedWordsTab />} {activeTab === 'banned_words' && <BannedWordsTab />}
{activeTab === 'api_keys' && <ApiKeysTab />}
</div> </div>
</div> </div>
); );

View File

@@ -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<ProductOpt[]>([]);
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 (
<div className="flex flex-wrap items-center gap-3">
<label className="flex items-center gap-1.5 text-sm text-text-secondary">
<input type="date" value={dateFrom} max={dateTo || undefined}
onChange={(e) => onChange({ dateFrom: e.target.value })} className={inputCls} />
</label>
<label className="flex items-center gap-1.5 text-sm text-text-secondary">
<input type="date" value={dateTo} min={dateFrom || undefined}
onChange={(e) => onChange({ dateTo: e.target.value })} className={inputCls} />
</label>
<select value={productId} onChange={(e) => onChange({ productId: e.target.value })} className={inputCls}>
<option value=""></option>
{products.map((p) => <option key={p.id} value={String(p.id)}>{p.name}</option>)}
</select>
{(dateFrom || dateTo || productId) && (
<button onClick={() => onChange({ dateFrom: '', dateTo: '', productId: '' })}
className="text-xs text-text-tertiary hover:text-brand-orange underline"></button>
)}
<button onClick={handleExport} disabled={exporting}
className="ml-auto rounded-lg border border-border-default px-3 py-1.5 text-sm text-text-secondary hover:border-brand-orange disabled:opacity-50">
{exporting ? '导出中…' : '导出产品数据'}
</button>
</div>
);
}

View File

@@ -7,7 +7,7 @@
* window.open 无法附带 Authorization header会 401。 * window.open 无法附带 Authorization header会 401。
* 改法api.getBlob 拿二进制流 → URL.createObjectURL → a.click → revoke。 * 改法api.getBlob 拿二进制流 → URL.createObjectURL → a.click → revoke。
*/ */
import { useState } from 'react'; import { useState, Fragment } from 'react';
import Link from 'next/link'; import Link from 'next/link';
import { clsx } from 'clsx'; import { clsx } from 'clsx';
import { TaskListItem } from '@/types'; import { TaskListItem } from '@/types';
@@ -76,6 +76,7 @@ export function HistoryTable({ tasks }: { tasks: TaskListItem[] }) {
<thead> <thead>
<tr className="bg-surface-secondary text-text-secondary"> <tr className="bg-surface-secondary text-text-secondary">
<th className="px-4 py-3 text-left font-medium"></th> <th className="px-4 py-3 text-left font-medium"></th>
<th className="px-4 py-3 text-left font-medium"></th>
<th className="px-4 py-3 text-left font-medium"></th> <th className="px-4 py-3 text-left font-medium"></th>
<th className="px-4 py-3 text-left font-medium"></th> <th className="px-4 py-3 text-left font-medium"></th>
<th className="px-4 py-3 text-left font-medium"></th> <th className="px-4 py-3 text-left font-medium"></th>
@@ -85,8 +86,8 @@ export function HistoryTable({ tasks }: { tasks: TaskListItem[] }) {
</thead> </thead>
<tbody> <tbody>
{tasks.map((task, i) => ( {tasks.map((task, i) => (
<Fragment key={task.id}>
<tr <tr
key={task.id}
className={clsx( className={clsx(
'border-t border-border-default hover:bg-surface-tertiary transition-colors', 'border-t border-border-default hover:bg-surface-tertiary transition-colors',
i % 2 === 0 ? 'bg-white' : 'bg-surface-primary' i % 2 === 0 ? 'bg-white' : 'bg-surface-primary'
@@ -95,6 +96,9 @@ export function HistoryTable({ tasks }: { tasks: TaskListItem[] }) {
<td className="px-4 py-3 text-text-primary font-medium max-w-xs truncate"> <td className="px-4 py-3 text-text-primary font-medium max-w-xs truncate">
{task.theme} {task.theme}
</td> </td>
<td className="px-4 py-3 text-text-secondary">
{task.product_name ?? '-'}
</td>
<td className="px-4 py-3"> <td className="px-4 py-3">
<span className={clsx( <span className={clsx(
'px-2 py-0.5 rounded-full text-xs font-medium', 'px-2 py-0.5 rounded-full text-xs font-medium',
@@ -128,6 +132,17 @@ export function HistoryTable({ tasks }: { tasks: TaskListItem[] }) {
</div> </div>
</td> </td>
</tr> </tr>
{/* 被打回任务:原因独占一行展示,运营见"为何被打回"可重做(R8历史复用) */}
{task.status === 'rejected' && task.reject_reason && (
<tr className={i % 2 === 0 ? 'bg-white' : 'bg-surface-primary'}>
<td colSpan={7} className="px-4 pb-3 pt-0">
<div className="rounded-md bg-red-50 border border-red-200 px-3 py-2 text-xs text-red-600">
<span className="font-medium"></span>{task.reject_reason}
</div>
</td>
</tr>
)}
</Fragment>
))} ))}
</tbody> </tbody>
</table> </table>
@@ -142,6 +157,7 @@ export function HistorySkeleton() {
{Array.from({ length: 6 }).map((_, i) => ( {Array.from({ length: 6 }).map((_, i) => (
<div key={i} className="flex gap-4 px-4 py-3 border-t border-border-default first:border-t-0"> <div key={i} className="flex gap-4 px-4 py-3 border-t border-border-default first:border-t-0">
<div className="h-4 bg-gray-200 rounded flex-1" /> <div className="h-4 bg-gray-200 rounded flex-1" />
<div className="h-4 bg-gray-200 rounded w-20" />
<div className="h-4 bg-gray-200 rounded w-16" /> <div className="h-4 bg-gray-200 rounded w-16" />
<div className="h-4 bg-gray-200 rounded w-12" /> <div className="h-4 bg-gray-200 rounded w-12" />
<div className="h-4 bg-gray-200 rounded w-12" /> <div className="h-4 bg-gray-200 rounded w-12" />

View File

@@ -10,6 +10,7 @@ import { TaskListItem, PaginatedResponse } from '@/types';
import { getErrorAction } from '@/types/errors'; import { getErrorAction } from '@/types/errors';
import { clsx } from 'clsx'; import { clsx } from 'clsx';
import { HistoryTable, HistorySkeleton, Pagination } from './components'; import { HistoryTable, HistorySkeleton, Pagination } from './components';
import { HistoryFilters } from './HistoryFilters';
const HISTORY_STATUSES = ['approved', 'archived', 'rejected', 'failed']; const HISTORY_STATUSES = ['approved', 'archived', 'rejected', 'failed'];
const STATUS_LABELS: Record<string, string> = { const STATUS_LABELS: Record<string, string> = {
@@ -24,14 +25,21 @@ export default function HistoryPage() {
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [statusFilter, setStatusFilter] = useState<string>('all'); const [statusFilter, setStatusFilter] = useState<string>('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); setLoading(true);
setError(null); setError(null);
try { try {
const params = new URLSearchParams({ page: String(p), page_size: String(PAGE_SIZE) }); const params = new URLSearchParams({ page: String(p), page_size: String(PAGE_SIZE) });
if (status !== 'all') params.append('status', status); if (status !== 'all') params.append('status', status);
else HISTORY_STATUSES.forEach((s) => params.append('status', s)); 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<PaginatedResponse<TaskListItem>>(`/api/v1/tasks?${params}`); const res = await api.get<PaginatedResponse<TaskListItem>>(`/api/v1/tasks?${params}`);
setTasks(res.items); setTasks(res.items);
setTotal(res.total); 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); const totalPages = Math.ceil(total / PAGE_SIZE);
@@ -74,10 +82,18 @@ export default function HistoryPage() {
))} ))}
</div> </div>
{/* M4: 日期区间 + 产品筛选 + 导出 */}
<HistoryFilters
dateFrom={filters.dateFrom}
dateTo={filters.dateTo}
productId={filters.productId}
onChange={(patch) => { setFilters((f) => ({ ...f, ...patch })); setPage(1); }}
/>
{error && ( {error && (
<div className="rounded-lg bg-red-50 border border-red-200 p-4 text-sm text-red-600"> <div className="rounded-lg bg-red-50 border border-red-200 p-4 text-sm text-red-600">
{error} {error}
<button className="ml-4 underline" onClick={() => fetchHistory(page, statusFilter)}> <button className="ml-4 underline" onClick={() => fetchHistory(page, statusFilter, filters)}>
</button> </button>
</div> </div>

View File

@@ -0,0 +1,53 @@
'use client';
/**
* 工作配置 /settings运营+组长+管理员)
* API Key 录入 / 产品档案 —— 各角色日常自助配置
* 🔴 红线key 各人自录自用各算各的;只录不显余额
* 拆分自原 /configB方案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<TabKey>('api_keys');
return (
<div className="p-6 max-w-5xl">
<h2 className="text-xl font-bold text-text-primary mb-6"></h2>
{/* Tab 导航 */}
<div className="flex gap-1 border-b border-border-default mb-6" role="tablist">
{TABS.map((tab) => (
<button
key={tab.key}
role="tab"
aria-selected={activeTab === tab.key}
aria-controls={`panel-${tab.key}`}
onClick={() => setActiveTab(tab.key)}
className={`px-4 py-2 text-sm font-medium rounded-t-lg transition-colors ${
activeTab === tab.key
? 'bg-surface-primary border border-border-default border-b-white text-brand-orange'
: 'text-text-secondary hover:text-text-primary'
}`}
>
{tab.label}
</button>
))}
</div>
{/* Tab 内容 */}
<div id={`panel-${activeTab}`} role="tabpanel">
{activeTab === 'api_keys' && <ApiKeysTab />}
{activeTab === 'products' && <ProductsTab />}
</div>
</div>
);
}

View File

@@ -12,7 +12,7 @@ import { RecentTasksBar } from '@/components/tasks/RecentTasksBar';
import { NewTaskForm } from '@/components/tasks/NewTaskForm'; import { NewTaskForm } from '@/components/tasks/NewTaskForm';
import { ImportTextModal } from '@/components/tasks/ImportTextModal'; import { ImportTextModal } from '@/components/tasks/ImportTextModal';
import { api } from '@/lib/api'; import { api } from '@/lib/api';
import { Product, CreateTaskRequest } from '@/types/index'; import { Product, CreateTaskRequest, Benchmark } from '@/types/index';
import { PaginatedResponse } from '@/types/index'; import { PaginatedResponse } from '@/types/index';
import { usePreferenceStore } from '@/stores/preferenceStore'; import { usePreferenceStore } from '@/stores/preferenceStore';
import { getErrorAction } from '@/types/errors'; import { getErrorAction } from '@/types/errors';
@@ -23,6 +23,7 @@ export default function NewTaskPage() {
const { context } = usePreferenceStore(); const { context } = usePreferenceStore();
const [products, setProducts] = useState<Product[]>([]); const [products, setProducts] = useState<Product[]>([]);
const [selectedProduct, setSelectedProduct] = useState<Product | null>(null); const [selectedProduct, setSelectedProduct] = useState<Product | null>(null);
const [benchmarks, setBenchmarks] = useState<Benchmark[]>([]);
const [form, setForm] = useState<Omit<CreateTaskRequest, 'product_id'>>({ const [form, setForm] = useState<Omit<CreateTaskRequest, 'product_id'>>({
benchmark_ids: [], benchmark_ids: [],
theme: '', theme: '',
@@ -37,6 +38,15 @@ export default function NewTaskPage() {
useEffect(() => { loadProducts(); }, []); 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() { async function loadProducts() {
try { try {
const data = await api.get<PaginatedResponse<Product>>('/api/v1/products'); const data = await api.get<PaginatedResponse<Product>>('/api/v1/products');
@@ -125,6 +135,7 @@ export default function NewTaskPage() {
products={products} products={products}
selectedProduct={selectedProduct} selectedProduct={selectedProduct}
onSelectProduct={setSelectedProduct} onSelectProduct={setSelectedProduct}
benchmarks={benchmarks}
form={form} form={form}
onFormChange={(patch) => setForm((f) => ({ ...f, ...patch }))} onFormChange={(patch) => setForm((f) => ({ ...f, ...patch }))}
submitting={submitting} submitting={submitting}

View File

@@ -2,7 +2,7 @@
/** /**
* AuthGuard — 路由守卫 * AuthGuard — 路由守卫
* - 未登录 → /login * - 未登录 → /login
* - 越权(组长访问 /config,非管理等)→ /dashboard * - 越权(运营访问 /config 系统管理等)→ /dashboard
* 扒 banana AuthGuard 范式,全新重建 * 扒 banana AuthGuard 范式,全新重建
*/ */
import { useEffect } from 'react'; import { useEffect } from 'react';
@@ -13,6 +13,7 @@ import { UserRole } from '@/types/index';
// 路由 → 最低所需角色 // 路由 → 最低所需角色
const ROUTE_ROLES: Record<string, UserRole[]> = { const ROUTE_ROLES: Record<string, UserRole[]> = {
'/config': ['admin'], '/config': ['admin'],
'/settings': ['admin', 'supervisor', 'operator'],
'/review': ['admin', 'supervisor'], '/review': ['admin', 'supervisor'],
'/history': ['admin', 'supervisor', 'operator'], '/history': ['admin', 'supervisor', 'operator'],
'/tasks/new': ['admin', 'supervisor', 'operator'], '/tasks/new': ['admin', 'supervisor', 'operator'],

View File

@@ -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<HTMLInputElement>(null);
const [scene, setScene] = useState<ProductImageScene>('primary');
const [uploading, setUploading] = useState(false);
const [busyId, setBusyId] = useState<number | null>(null);
const [error, setError] = useState('');
const images = product.images ?? [];
async function refresh() {
const updated = await api.get<Product>(`/api/v1/products/${product.id}`);
onChanged(updated);
}
async function handleFile(e: React.ChangeEvent<HTMLInputElement>) {
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<Product>(`/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 (
<div className="mt-2 space-y-2">
{images.length > 0 && (
<div className="grid grid-cols-2 gap-2">
{images.map((im: ProductImage) => (
<div key={im.id}
className="flex items-center gap-2 border border-border-default rounded-lg p-2">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src={toUploadSrc(im.path)} alt={`${sceneLabel(im.scene)}`}
className="h-12 w-12 object-cover rounded border border-border-default shrink-0" />
<div className="flex-1 min-w-0 space-y-1">
<select value={im.scene} disabled={busyId === im.id}
onChange={(e) => changeScene(im.id, e.target.value)}
aria-label="图片场景类型"
className="w-full text-xs border border-border-default rounded px-1 py-0.5">
{SCENE_OPTIONS.map((o) => (
<option key={o.value} value={o.value}>{o.label}</option>
))}
</select>
<div className="flex items-center gap-2 text-xs">
{im.is_primary ? (
<span className="text-brand-orange font-medium"> </span>
) : (
<button type="button" disabled={busyId === im.id}
onClick={() => setPrimary(im.id)}
className="text-text-secondary hover:text-brand-orange"></button>
)}
<button type="button" disabled={busyId === im.id}
onClick={() => removeImage(im.id)}
className="text-red-500 hover:underline ml-auto"></button>
</div>
</div>
</div>
))}
</div>
)}
<div className="flex items-center gap-2">
<select value={scene} onChange={(e) => setScene(e.target.value as ProductImageScene)}
aria-label="新图场景类型"
className="text-xs border border-border-default rounded px-2 py-1">
{SCENE_OPTIONS.map((o) => (
<option key={o.value} value={o.value}>{o.label}</option>
))}
</select>
<button type="button" onClick={() => inputRef.current?.click()} disabled={uploading}
className="text-xs border border-dashed border-border-default rounded px-3 py-1
text-text-secondary hover:border-brand-orange hover:text-brand-orange
disabled:opacity-50 transition-colors">
{uploading ? '上传中…' : '+ 上传产品图'}
</button>
</div>
{images.length === 0 && (
<p className="text-xs text-text-tertiary leading-relaxed">
/
</p>
)}
{error && <p className="text-xs text-red-500">{error}</p>}
<input ref={inputRef} type="file" accept="image/jpeg,image/png,image/webp"
className="hidden" aria-label="选择产品图" onChange={handleFile} />
</div>
);
}

View File

@@ -1,92 +1,16 @@
'use client'; 'use client';
/** /**
* ProductCard + ProductForm + ProductsSkeleton + ProductImageUpload — 产品档案子组件 * ProductCard + ProductForm + ProductsSkeleton — 产品档案子组件
* 产品多图管理拆到 ProductImageManagerR5
*/ */
import { useRef, useState } from 'react'; import { useState } from 'react';
import { api } from '@/lib/api'; import { api } from '@/lib/api';
import { Product, CreateProductRequest } from '@/types/index'; import { Product, CreateProductRequest } from '@/types/index';
import { ProductImageManager } from './ProductImageManager';
// --- ProductImageUpload --- // ProductImageUpload 旧单图组件已被 ProductImageManager多图+场景)取代。
export function ProductImageUpload({ // 保留导出名向后兼容引用方。
product, export { ProductImageManager as ProductImageUpload };
onUploaded,
}: {
product: Product;
onUploaded: (updated: Product) => void;
}) {
const inputRef = useRef<HTMLInputElement>(null);
const [uploading, setUploading] = useState(false);
const [error, setError] = useState('');
async function handleFile(e: React.ChangeEvent<HTMLInputElement>) {
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<Product>(
`/api/v1/products/${product.id}/upload-image`, form
);
onUploaded(updated);
} catch {
setError('上传失败,请重试');
} finally {
setUploading(false);
if (inputRef.current) inputRef.current.value = '';
}
}
return (
<div className="mt-2">
{product.image_path ? (
<div className="flex items-center gap-2">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={`/uploads/${product.image_path.replace(/^.*\/uploads\//, '').replace(/^uploads\//, '')}`}
alt={`${product.name}参考图`}
className="h-12 w-12 object-cover rounded border border-border-default"
/>
<button
type="button"
onClick={() => inputRef.current?.click()}
className="text-xs text-brand-orange hover:underline"
>
</button>
</div>
) : (
<div>
<button
type="button"
onClick={() => inputRef.current?.click()}
disabled={uploading}
className="text-xs border border-dashed border-border-default rounded px-3 py-1
text-text-secondary hover:border-brand-orange hover:text-brand-orange
disabled:opacity-50 transition-colors"
>
{uploading ? '上传中…' : '+ 上传产品参考图'}
</button>
<p className="text-xs text-text-tertiary mt-1 leading-relaxed">
/
</p>
</div>
)}
{error && <p className="text-xs text-red-500 mt-1">{error}</p>}
<input
ref={inputRef}
type="file"
accept="image/jpeg,image/png,image/webp"
className="hidden"
aria-label="选择产品参考图"
onChange={handleFile}
/>
</div>
);
}
export function ProductCard({ product, onUpdated }: { product: Product; onUpdated: () => void }) { export function ProductCard({ product, onUpdated }: { product: Product; onUpdated: () => void }) {
const [current, setCurrent] = useState<Product>(product); const [current, setCurrent] = useState<Product>(product);
@@ -109,9 +33,9 @@ export function ProductCard({ product, onUpdated }: { product: Product; onUpdate
</span> </span>
))} ))}
</div> </div>
<ProductImageUpload <ProductImageManager
product={current} product={current}
onUploaded={(updated) => { setCurrent(updated); onUpdated(); }} onChanged={(updated) => { setCurrent(updated); onUpdated(); }}
/> />
</div> </div>
); );
@@ -124,14 +48,18 @@ export function ProductForm({ onSaved, onCancel }: { onSaved: () => void; onCanc
custom_prompt: null, banned_word_ids: [], custom_prompt: null, banned_word_ids: [],
}); });
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [error, setError] = useState('');
async function handleSave(e: React.FormEvent) { async function handleSave(e: React.FormEvent) {
e.preventDefault(); e.preventDefault();
if (!form.name) return; if (!form.name.trim()) { setError('请填写产品名称'); return; }
setSaving(true); setSaving(true); setError('');
try { try {
await api.post('/api/v1/products', form); await api.post('/api/v1/products', form);
onSaved(); onSaved();
} catch (err) {
const msg = (err as { message?: string })?.message;
setError(msg || '保存失败,请重试');
} finally { } finally {
setSaving(false); setSaving(false);
} }
@@ -158,6 +86,7 @@ export function ProductForm({ onSaved, onCancel }: { onSaved: () => void; onCanc
/> />
</div> </div>
</div> </div>
{error && <p role="alert" className="text-sm text-red-500">{error}</p>}
<div className="flex gap-3 justify-end"> <div className="flex gap-3 justify-end">
<button type="button" onClick={onCancel} <button type="button" onClick={onCancel}
className="px-4 py-2 text-sm text-text-secondary border border-border-default rounded-lg hover:bg-surface-tertiary"> className="px-4 py-2 text-sm text-text-secondary border border-border-default rounded-lg hover:bg-surface-tertiary">

View File

@@ -2,7 +2,7 @@
/** /**
* Sidebar — 左侧8项导航 * Sidebar — 左侧8项导航
* 按 PRD-前端§2 全局布局 * 按 PRD-前端§2 全局布局
* 角色守卫:/review 只组长+管理员;/config 只管理员 * 角色守卫:/review 只组长+管理员;/settings 全员;/config 只管理员
*/ */
import Link from 'next/link'; import Link from 'next/link';
import { usePathname } from 'next/navigation'; import { usePathname } from 'next/navigation';
@@ -26,7 +26,8 @@ const NAV_ITEMS: NavItem[] = [
{ label: '裂变扩散', href: '/fission', icon: '🌱' }, { label: '裂变扩散', href: '/fission', icon: '🌱' },
{ label: '审核台', href: '/review', roles: ['supervisor', 'admin'], icon: '✅' }, { label: '审核台', href: '/review', roles: ['supervisor', 'admin'], icon: '✅' },
{ label: '历史归档', href: '/history', icon: '🗂️' }, { label: '历史归档', href: '/history', icon: '🗂️' },
{ label: '配置中心', href: '/config', roles: ['admin'], icon: '⚙️' }, { label: '工作配置', href: '/settings', icon: '🔧' },
{ label: '系统管理', href: '/config', roles: ['admin'], icon: '⚙️' },
]; ];
export function Sidebar() { export function Sidebar() {

View File

@@ -3,13 +3,14 @@
* NewTaskForm — 开任务表单屏2主体 * NewTaskForm — 开任务表单屏2主体
* 选产品 + 今天主题 + 数量 + 双轨入口 * 选产品 + 今天主题 + 数量 + 双轨入口
*/ */
import { Product, CreateTaskRequest } from '@/types/index'; import { Product, CreateTaskRequest, Benchmark } from '@/types/index';
import { CountStepper } from './CountStepper'; import { CountStepper } from './CountStepper';
interface NewTaskFormProps { interface NewTaskFormProps {
products: Product[]; products: Product[];
selectedProduct: Product | null; selectedProduct: Product | null;
onSelectProduct: (p: Product | null) => void; onSelectProduct: (p: Product | null) => void;
benchmarks: Benchmark[];
form: Omit<CreateTaskRequest, 'product_id'>; form: Omit<CreateTaskRequest, 'product_id'>;
onFormChange: (patch: Partial<Omit<CreateTaskRequest, 'product_id'>>) => void; onFormChange: (patch: Partial<Omit<CreateTaskRequest, 'product_id'>>) => void;
submitting: boolean; submitting: boolean;
@@ -19,7 +20,7 @@ interface NewTaskFormProps {
} }
export function NewTaskForm({ export function NewTaskForm({
products, selectedProduct, onSelectProduct, products, selectedProduct, onSelectProduct, benchmarks,
form, onFormChange, submitting, error, form, onFormChange, submitting, error,
onSubmitAi, onSubmitImport, onSubmitAi, onSubmitImport,
}: NewTaskFormProps) { }: NewTaskFormProps) {
@@ -28,6 +29,15 @@ export function NewTaskForm({
onFormChange({ [field]: Math.max(1, Math.min(max, form[field] + delta)) }); onFormChange({ [field]: Math.max(1, Math.min(max, form[field] + delta)) });
} }
// 只允许选已分析完(analyze_status=done)的标杆——未分析的无 features 注入无意义
const usableBenchmarks = benchmarks.filter((b) => b.analyze_status === 'done');
function toggleBenchmark(id: number) {
const cur = form.benchmark_ids || [];
onFormChange({
benchmark_ids: cur.includes(id) ? cur.filter((x) => x !== id) : [...cur, id],
});
}
// 产品参考图状态image_path 绝对路径(/app/uploads/...)取末段映射成 /uploads/... 供前端访问 // 产品参考图状态image_path 绝对路径(/app/uploads/...)取末段映射成 /uploads/... 供前端访问
const rawPath = selectedProduct?.image_path || ''; const rawPath = selectedProduct?.image_path || '';
const hasProductImage = !!rawPath; const hasProductImage = !!rawPath;
@@ -93,6 +103,30 @@ export function NewTaskForm({
<p className="text-xs text-text-tertiary mt-1">{form.theme.length}/100</p> <p className="text-xs text-text-tertiary mt-1">{form.theme.length}/100</p>
</div> </div>
{/* 对标爆款可选多选选中标杆把其8维配方注入文案生成 */}
{usableBenchmarks.length > 0 && (
<div className="bg-surface-secondary rounded-lg p-4 space-y-2">
<p className="text-sm font-medium text-text-primary">
<span className="text-xs text-text-tertiary ml-1"></span>
</p>
<div className="space-y-1.5 max-h-40 overflow-auto">
{usableBenchmarks.map((b) => (
<label key={b.id} className="flex items-start gap-2 cursor-pointer text-sm">
<input type="checkbox"
checked={(form.benchmark_ids || []).includes(b.id)}
onChange={() => toggleBenchmark(b.id)}
className="w-4 h-4 mt-0.5 accent-brand-orange"
aria-label={`选用标杆 ${b.id}`} />
<span className="text-text-secondary line-clamp-2">
{b.highlights || `标杆 #${b.id}`}
</span>
</label>
))}
</div>
<p className="text-xs text-text-tertiary"> {(form.benchmark_ids || []).length} 3 </p>
</div>
)}
{/* 数量设定(不写死) */} {/* 数量设定(不写死) */}
<div className="flex gap-6"> <div className="flex gap-6">
<CountStepper label="生几条文案" value={form.text_count} <CountStepper label="生几条文案" value={form.text_count}

View File

@@ -8,6 +8,7 @@ export type TaskStatus =
export interface TaskListItem { export interface TaskListItem {
id: number; id: number;
product_id: number; product_id: number;
product_name?: string | null; // M4: 历史页"产品"列,list_tasks 批量 JOIN 填充
theme: string; theme: string;
status: TaskStatus; status: TaskStatus;
created_at: string; created_at: string;

View File

@@ -37,6 +37,16 @@ export interface ApiKey {
export interface CreateApiKeyRequest { provider: string; api_key: string; } export interface CreateApiKeyRequest { provider: string; api_key: string; }
// 产品档案 // 产品档案
export type ProductImageScene = 'primary' | 'scene' | 'texture' | 'ingredient' | 'model';
export interface ProductImage {
id: number;
path: string;
scene: ProductImageScene;
is_primary: boolean;
sort_order: number;
}
export interface Product { export interface Product {
id: number; id: number;
name: string; name: string;
@@ -47,7 +57,8 @@ export interface Product {
text_angles: string[]; text_angles: string[];
custom_prompt: string | null; custom_prompt: string | null;
banned_word_ids: number[]; banned_word_ids: number[];
image_path: string | null; // 产品参考图路径(铁律:生图带产品图) image_path: string | null; // 产品参考图路径(铁律:生图带产品图,向后兼容=主图
images?: ProductImage[]; // R5多图每张带 scene 场景类型
} }
export interface CreateProductRequest { export interface CreateProductRequest {
@@ -67,6 +78,8 @@ export interface Benchmark {
screenshot_url: string; screenshot_url: string;
highlights: string; highlights: string;
link_url: string | null; link_url: string | null;
analyze_status?: 'pending' | 'analyzing' | 'done' | 'failed';
features?: Record<string, unknown> | null;
} }
export interface CreateBenchmarkRequest { export interface CreateBenchmarkRequest {

View File

@@ -9,6 +9,7 @@ export type {
SwitchWorkspaceRequest, SwitchWorkspaceResponse, SwitchWorkspaceRequest, SwitchWorkspaceResponse,
ApiKey, CreateApiKeyRequest, ApiKey, CreateApiKeyRequest,
Product, CreateProductRequest, Product, CreateProductRequest,
ProductImage, ProductImageScene,
Benchmark, CreateBenchmarkRequest, Benchmark, CreateBenchmarkRequest,
BannedWordLevel, BannedWord, CreateBannedWordRequest, BannedWordLevel, BannedWord, CreateBannedWordRequest,
} from './dto'; } from './dto';

View File

@@ -64,3 +64,25 @@
## 搁置/待北哥(攒批) ## 搁置/待北哥(攒批)
- 裂变三档/参考度真实业务措辞 - 裂变三档/参考度真实业务措辞
- N套真差异化质量验收(北哥过目) - 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 裂变补图机制

View File

@@ -24,15 +24,15 @@ KR1链路连通 + KR2质量达标 + KR3自主可验 + KR4引擎补全(第2环+
| ID | 任务 | 模块核销表 | 细化 | 状态 | | ID | 任务 | 模块核销表 | 细化 | 状态 |
|----|------|-----------|------|------| |----|------|-----------|------|------|
| M3 | 第8环 审核(死链/打回回路) | plans/核销-第8环-审核.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 | | | 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 | | | 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环裂变 ## P2 引擎补全第2环标杆 / 第11环裂变
| ID | 任务 | 模块核销表 | 细化 | 状态 | | 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套差异化文案+配图的质量验收(需北哥过目)** | | 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 九文档审核结论·按序逐条修) ## P3 前端打通+飞轮2026-06-16 九文档审核结论·按序逐条修)
@@ -44,7 +44,7 @@ KR1链路连通 + KR2质量达标 + KR3自主可验 + KR4引擎补全(第2环+
| ID | 任务 | 根据 | 状态 | | ID | 任务 | 根据 | 状态 |
|----|------|------|------| |----|------|------|------|
| A1 | Sidebar 4个死链路由(/tasks /images /tasks/build /tasks/list 全不存在) | Sidebar.tsx确证 | ✅ 2026-06-16 删4死链+补回/review审核台(限supervisor+admin),tsc全量EXIT0,commit 77f52e7 | | 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 | | 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全链+轮询兜底,后端事件发布到位,标绿 | | 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 | | 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 飞轮专项(产品最大亮点·做扎实+前端呈现) ### 桶B 飞轮专项(产品最大亮点·做扎实+前端呈现)
| ID | 任务 | 守的底线 | 状态 | | ID | 任务 | 守的底线 | 状态 |
|----|------|---------|------| |----|------|---------|------|
| E12 | AI评图分进飞轮:仅"生成时筛选+前端展示X分已入飞轮" | 权重只认真实信号(选/改/审),eval_score留NULL铁律 | □ | | 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) | □ | | FV | 飞轮可视化=轻量化关键节点反馈(让客户感到确实越用越好) | 不做独立飞轮主视图大页(砍E01) | ✅ 符合决策(2026-06-18核实):FlywheelBanner(new页/text页/images页SSE flywheel_injected事件)+TextCandidateCard"🌿已学习"+AI分徽章,轻量节点反馈嵌现有流程;无独立大看板=砍E01已拍板。曾误标□ |
### 桶C 接线(数据流薄,主链通后接) ### 桶C 接线(数据流薄,主链通后接)
| ID | 任务 | 状态 | | ID | 任务 | 状态 |
|----|------|------| |----|------|------|
| B1 | 图片按strategy分组(generationStore现一维平铺) | □ | | B1 | 图片按strategy分组(generationStore现一维平铺) | ✅ 同R1已做(对账核实):ImageStrategyGroup按strategy分A/B/C三组,images页:45-47分组+:96-108渲染,非空壳。曾重复挂账□ |
| C1 | 任务看板 | □ | | C1 | 任务看板 | □ 真欠账(2026-06-18核实:仅/history列表+状态tab,无kanban按状态分列视图,零代码)。⚠️攒批问倩倩姐:/history已有状态筛选,是否还需独立kanban看板?可能伪需求 |
| B2/B3/B4/B5 | 前端数据流补齐 | | | B2/B3/B4/B5 | 前端数据流补齐 | ✅ 主链完整(2026-06-18核实:generationStore/taskStore/preferenceStore全实现无空壳无TODO,hydrateFromTask历史回填已做);B2-B5核销表本身需求定义模糊,代码层无欠账 |
| C3/C4 | 展示层补薄 | | | C3/C4 | 展示层补薄 | C3✅留口子(OVERLAY_TEXT_RENDER_ENABLED=False拍板缓做);C4□代码无对应实现且需求不明,⚠攒批问倩倩姐C4具体指什么 |
### 桶D 踩刹车(砍/缓,防堆伪需求偏离纵切片) ### 桶D 踩刹车(砍/缓,防堆伪需求偏离纵切片)
| ID | 任务 | 裁决 | | ID | 任务 | 裁决 |
@@ -72,10 +72,10 @@ KR1链路连通 + KR2质量达标 + KR3自主可验 + KR4引擎补全(第2环+
### 桶E 需补充(缺口补全) ### 桶E 需补充(缺口补全)
| ID | 任务 | 状态 | | 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 | | 补2 | status=failed 完整处理(后端+前端) | ✅ 同B6(2026-06-16),后端FAILED终态+前端可见,commit 1b48875/ffc213f |
| 补3 | git版本兜底 | ✅2026-06-16已建Clover独立仓库,基线6a2632d | | 补3 | git版本兜底 | ✅2026-06-16已建Clover独立仓库,基线6a2632d |
| D2 | 数据复盘(飞轮喂料可见) | | | D2 | 数据复盘(飞轮喂料可见) | ◐ 实时注入反馈已做(FlywheelBanner+"已学习"提示,见FV),但历史注入摘要/复盘页未做。⚠️攒批问倩倩姐:轻量节点反馈是否已够,还需独立历史复盘页? |
| D3 | 爆款解析(并入M6标杆) | ⏸随M6 | | D3 | 爆款解析(并入M6标杆) | ⏸随M6 |
--- ---
@@ -131,33 +131,33 @@ KR1链路连通 + KR2质量达标 + KR3自主可验 + KR4引擎补全(第2环+
### R5 产品多图P1 ### R5 产品多图P1
| 子项 | 去重映射 | 根因证据 | 状态 | | 子项 | 去重映射 | 根因证据 | 状态 |
|------|---------|---------|------| |------|---------|---------|------|
| 产品图多张(DB+上传端点+前端multiple) | 问题2/14新 | image_path单值字符串,无multiple,覆盖式 | | | 产品图多张(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 | | | 生图按场景选用对应图 | 问题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不能建 | | | 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 ### R6 账号体系+Key+权限P1
| 子项 | 去重映射 | 根因证据 | 状态 | | 子项 | 去重映射 | 根因证据 | 状态 |
|------|---------|---------|------| |------|---------|---------|------|
| 后端users.py增/删/列用户(删=软删is_active) | 问题15新 | 无users CRUD,is_active登录已校验:37 | | | 后端users.py增/删/列用户(删=软删is_active) | 问题15新 | 无users CRUD,is_active登录已校验:37 | ⏸️暂停(倩倩姐2026-06-18拍板:现有4种子账号够北哥测,加人能力留后;先推R7飞轮) |
| 前端配置台账号管理Tab | 问题15新 | 无账号管理入口 | | | 前端配置台账号管理Tab | 问题15新 | 无账号管理入口 | ⏸️暂停(同上,随后端users CRUD一起做) |
| Key绑定账号显式展示 | 问题15新 | UserApiKey已按user隔离,只缺前端展示 | | | 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 | | | 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无提示 | | | 保存按钮补错误提示(治点不动) | 问题13b新 | ProductForm校验静默return无提示 | ✅ ProductForm加error state:空名提示"请填写产品名称"+catch后端报错展示(role=alert);tsc EXIT0 |
### R7 飞轮闭环+可视化P2 ### R7 飞轮闭环+可视化P2
| 子项 | 去重映射 | 根因证据 | 状态 | | 子项 | 去重映射 | 根因证据 | 状态 |
|------|---------|---------|------| |------|---------|---------|------|
| 飞轮片段注入生图链路(补断点1) | 新 | run_image_generation没传flywheel_fragment | | | 飞轮片段注入生图链路(补断点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空转 | | | 选图信号带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用权重不一致 | | | 统一前端查询与注入的计数口径 | 新 | service用次数/aggregator用权重不一致 | ✅ get_preference_context改委托aggregate_preference_context(权重口径),image_select纳入角度聚合;展示端点与生产链同源,交叉验证输出字符串完全一致;复核"真修好" |
| AI评图分仅展示+生成筛选 | =既有E12 | 守eval_score NULL红线 | | | AI评图分仅展示+生成筛选 | =既有E12 | 守eval_score NULL红线 | ✅ 既有已达标:ai_visual_score只展示+筛选,eval_score全程NULL,本轮改动复核确认未碰;ImageStrategyGroup徽章展示AI分 |
| 飞轮可视化轻量节点反馈 | =既有FV/D2 | 让客户感到越用越好 | | | 飞轮可视化轻量节点反馈 | =既有FV/D2 | 让客户感到越用越好 | ◐ 已有雏形:生成时FlywheelBanner"本次已注入偏好角度"+改稿TextCandidateCard"🌿已学习你的改动"+AI分徽章;无独立大看板(符合砍E01红线) |
### R8 归档P2 ### R8 归档P2
| 子项 | 去重映射 | 根因证据 | 状态 | | 子项 | 去重映射 | 根因证据 | 状态 |
|------|---------|---------|------| |------|---------|---------|------|
| 历史打回显示原因 | =问题10,既有M4沾边 | _fmt_task()没返reject_reason,前端没读 | | | 历史打回显示原因 | =问题10,既有M4沾边 | ✅后端_fmt_task返review_status/reject_reason/reviewed_at(tasks.py:58),前端HistoryTable加rejected行红banner展示打回原因(代码就绪,Z阶段目视) | |
| 日期/品类筛选+导出 | =既有M4 | M4未开工 | | | 日期/品类筛选+导出 | =既有M4 | ✅后端筛选链+导出端点独立agent验过(A1/A2/A9),前端HistoryFilters就绪待目视 | |
--- ---

View File

@@ -14,24 +14,24 @@
| S3 | history/page.tsx 加 dateFrom/dateTo state + 两个 `<input type=date>`(提 DateRangeFilter 子组件防超100行) | history/page.tsx:28-44 + JSX 50-94 | | S3 | history/page.tsx 加 dateFrom/dateTo state + 两个 `<input type=date>`(提 DateRangeFilter 子组件防超100行) | history/page.tsx:28-44 + JSX 50-94 |
| S4 | HistoryTable 表头/行加"产品"列(显 product_name ?? '-') | history/components.tsx:70-127 | | S4 | HistoryTable 表头/行加"产品"列(显 product_name ?? '-') | history/components.tsx:70-127 |
| S5 | TaskListItem 加 `product_name?:string` | dto.tasks.ts:8-16 | | 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_permissionbenchmarks/export 需 Product JOIN 填 product_name(复用 _fmt_benchmark 不够,只有 product_id) | products.py + benchmarks.py | | S7 | 新建 GET /products/export + /benchmarks/export(format=json/csv)require_write_permissionbenchmarks/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 | | 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)。 - [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天开区间)。
- [ ] **A2**autoproduct_id 筛选:传 product_id=1 → 每条 product_id=1 且 product_name 非空 - [x] **A2**autoproduct_id 筛选:✅2026-06-18 ws3实测:product_id=1过滤命中78条,product_name批量查到'素颜霜'(防N+1:set收集→一次in_查询建name_map)
- [ ] **A3**human前端产品列/history 表头有"产品"列,每行显产品名(非数字),与 GET /products/{id}.name 一致。 - [ ] **A3**human前端产品列/history 表头有"产品"列,每行显产品名(非数字),与 GET /products/{id}.name 一致。【代码已就绪:components.tsx加产品列+dto加product_name,typecheck过+独立审核过;待Z阶段浏览器目视】
- [ ] **A4**human日期控件有开始/结束 date input填后表格刷新XHR URL 含 date_from/date_to结果≤全量。 - [ ] **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/发布清单/合规说明。 - [ ] **A5**human下载交付包approved 任务点"下载交付包"≤60s 触发下载 delivery-{id}.zip(>1KB)`unzip -l` 含 note_01/文案.txt/发布清单/合规说明。
- [ ] **A6**autozip 结构完整:`unzip -l` grep 文案.txt/发布清单/合规说明/note_01//.jpg 全非空。⚠️审计补:若 .jpg 空先查 ImageCandidate.url 文件容器内可达(packaging_task.py:64-70 读失败静默跳过)。 - [x] **A6**autozip 结构完整:✅2026-06-18 task74端到端验:zip解出note_01/02/03各7项(6图+文案.txt),发布清单+合规说明在root,18张图全非空真写入(磁盘文件真读到)。
- [ ] **A7**auto文案.txt 格式:`unzip -p note_01/文案.txt`"【标题】"、不以 `{` 开头(JSON 已解析)。 - [x] **A7**auto文案.txt 格式:✅2026-06-18 task74验:note_02/文案.txt"【标题】懒人三分钟搞定,倍分子上脸气色回来了"开头(非{,JSON已解析),品牌词"倍分子"已植入
- [ ] **A8**human+auto多套打包⚠️审计改 human(需先 seed 3条 is_selected TextCandidate)→构造后 `unzip -l|grep 'note_0[1-3]/'|wc -l`≥3三套标题互不相同。 - [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)不同、文案全同,品牌词"倍分子"已正确植入。是否要三套各配不同角度文案?
- [ ] **A9**auto产品 JSON 导出:GET /products/export → 200,application/json,数组,每元素含 id/name/selling_points,且 selling_points 是数组(非JSON字符串)。 - [x] **A9**auto产品 JSON 导出:✅2026-06-18 ws3实测:export_products返3条,selling_points类型=list(['烟酰胺改善暗沉提亮','水解珍珠锁水匀肤']非JSON字符串);端点真注册路由表+顺序在/products/{product_id}之前(idx9<13不被吞)。
- [ ] **A10**auto标杆 CSV 导出:GET /benchmarks/export?format=csv → 200,text/csv,首行表头含 product_id/highlights/created_at,行数≥已录标杆数 - [ ] **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=40101workspace 成员 → 200。 - [ ] **A11**auto导出权限审计注——require_write_permission 当前只查 workspace_members 不分角色(workspace_guard.py:65-88);未登录调 → code=40101workspace 成员 → 200。【两端点均挂require_write_permission,独立审核确认】
- [ ] **A12**human不显 token 余额(红线):⚠️审计改 human(curl 拿不到Next.js水合后DOM)——浏览器 Ctrl+F 搜"余额/用量/tokens/balance"无结果。 - [ ] **A12**human不显 token 余额(红线):⚠️审计改 human(curl 拿不到Next.js水合后DOM)——浏览器 Ctrl+F 搜"余额/用量/tokens/balance"无结果。【HistoryFilters/page/components均无余额字段,独立审核确认】
## 待北哥定义(不阻断代码) ## 待北哥定义(不阻断代码)
- 多套打包图片套间对应ImageCandidate 无 set_index是否加列关联文案还是退化"图共享复制进每套夹"(本期默认退化)。 - 多套打包图片套间对应ImageCandidate 无 set_index是否加列关联文案还是退化"图共享复制进每套夹"(本期默认退化)。