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,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
from typing import Annotated
from fastapi import APIRouter, Depends, UploadFile, File
from fastapi import APIRouter, Depends, UploadFile, File, Form
from pydantic import BaseModel
from sqlalchemy.orm import Session
from app.core.database import get_db
from app.core.response import ok, paginate, raise_not_found
from app.middleware.workspace_guard import CurrentUser, require_admin, require_write_permission
from app.models.product import BannedWord, BenchmarkNote, Product
from app.models.product import BannedWord, BenchmarkNote, Product, ProductImage
logger = logging.getLogger(__name__)
router = APIRouter(tags=["products"])
@@ -50,6 +50,7 @@ class BannedWordCreate(BaseModel):
def _fmt_product(p: Product) -> dict:
import json
imgs = sorted(getattr(p, "images", None) or [], key=lambda im: im.sort_order)
return {
"id": p.id, "name": p.name, "category": p.category,
"source": p.source, "is_active": p.is_active,
@@ -58,6 +59,11 @@ def _fmt_product(p: Product) -> dict:
"text_angles": json.loads(p.text_angles) if p.text_angles else [],
"custom_prompt": p.custom_prompt,
"image_path": p.image_path,
"images": [
{"id": im.id, "path": im.path, "scene": im.scene,
"is_primary": im.is_primary, "sort_order": im.sort_order}
for im in imgs
],
"brand_keyword": p.brand_keyword, # 012: 套2字段暴露
"target_audience": p.target_audience, # 012: 套2字段暴露
"created_at": p.created_at.isoformat(),
@@ -82,7 +88,7 @@ def list_products(
@router.post("/products")
def create_product(
body: ProductCreate,
current_user: Annotated[CurrentUser, Depends(require_admin)] = None,
current_user: Annotated[CurrentUser, Depends(require_write_permission)] = None,
db: Session = Depends(get_db),
):
import json
@@ -118,7 +124,7 @@ def get_product(
@router.put("/products/{product_id}")
def update_product(
product_id: int, body: ProductCreate,
current_user: Annotated[CurrentUser, Depends(require_admin)] = None,
current_user: Annotated[CurrentUser, Depends(require_write_permission)] = None,
db: Session = Depends(get_db),
):
import json
@@ -155,10 +161,28 @@ _ALLOWED_CONTENT_TYPES = {"image/jpeg", "image/png", "image/webp"}
_MAX_SIZE_BYTES = 10 * 1024 * 1024 # 10 MB
def _check_image_magic(data: bytes) -> bool:
"""
校验文件头 magic number防 .exe 改名 .jpg 伪造 content_type 绕过。
content_type 来自客户端可伪造magic number 是真实字节,二者都要过。
JPEG: FF D8 FF / PNG: 89 50 4E 47 0D 0A 1A 0A / WebP: RIFF....WEBP
"""
if len(data) < 12:
return False
if data[:3] == b"\xff\xd8\xff": # JPEG
return True
if data[:8] == b"\x89PNG\r\n\x1a\n": # PNG
return True
if data[:4] == b"RIFF" and data[8:12] == b"WEBP": # WebP
return True
return False
@router.post("/products/{product_id}/upload-image")
async def upload_product_image(
product_id: int,
file: UploadFile = File(...),
scene: str = Form("primary"),
current_user: Annotated[CurrentUser, Depends(require_write_permission)] = None,
db: Session = Depends(get_db),
):
@@ -166,10 +190,12 @@ async def upload_product_image(
上传产品参考图(铁律:生图必须带产品图)。
文件类型JPEG/PNG/WebP大小上限 10 MB。
存储路径uploads/products/{workspace_id}/{product_id}/{filename}
写回 product.image_path生图管道读此字段构建 reference_images
R5多图每次上传落一条 product_images带 scene 场景类型)
首张或 scene=primary 时同步写 product.image_path 当主图(向后兼容)。
"""
from app.core.response import raise_business
from app.core.config import get_settings
from app.constants.enums import ProductImageScene
import os, uuid
p = db.query(Product).filter(
@@ -179,12 +205,18 @@ async def upload_product_image(
if not p:
raise_not_found("产品不存在")
valid_scenes = {s.value for s in ProductImageScene}
if scene not in valid_scenes:
raise_business(f"非法场景类型 {scene},仅支持 {sorted(valid_scenes)}")
if file.content_type not in _ALLOWED_CONTENT_TYPES:
raise_business(f"不支持的文件类型 {file.content_type},仅支持 JPEG/PNG/WebP")
data = await file.read()
if len(data) > _MAX_SIZE_BYTES:
raise_business("文件超过 10 MB 限制")
if not _check_image_magic(data):
raise_business("文件内容与扩展名不符(非真实 JPEG/PNG/WebP 图片)")
settings = get_settings()
ext = os.path.splitext(file.filename or "img.jpg")[1] or ".jpg"
@@ -197,10 +229,29 @@ async def upload_product_image(
with open(save_path, "wb") as f:
f.write(data)
p.image_path = save_path # 绝对路径worker 直接 open 可读
# 是否首张:决定要不要兜底设主图
existing = db.query(ProductImage).filter(
ProductImage.product_id == product_id
).count()
is_primary = (existing == 0) or (scene == ProductImageScene.PRIMARY.value)
if is_primary:
# 同产品其它主图标记降级,保证唯一主图
db.query(ProductImage).filter(
ProductImage.product_id == product_id,
ProductImage.is_primary == True,
).update({"is_primary": False})
img = ProductImage(
product_id=product_id, path=save_path, scene=scene,
is_primary=is_primary, sort_order=existing,
)
db.add(img)
if is_primary:
p.image_path = save_path # 向后兼容:管道/校验仍读此字段当主图
db.commit()
db.refresh(p)
logger.info("product image uploaded: product_id=%s path=%s", product_id, save_path)
logger.info("product image uploaded: product_id=%s scene=%s primary=%s path=%s",
product_id, scene, is_primary, save_path)
return ok(_fmt_product(p))
@@ -273,6 +324,8 @@ async def analyze_product_image(
data = await file.read()
if len(data) > _MAX_SIZE_BYTES:
raise_business("文件超过 10 MB 限制")
if not _check_image_magic(data):
raise_business("文件内容与扩展名不符(非真实 JPEG/PNG/WebP 图片)")
# key 解密(照抄 pipeline_steps.decrypt_user_key基石B
api_key_row = db.query(UserApiKey).filter(

View File

@@ -58,7 +58,9 @@ def select_image(
db: Session = Depends(get_db),
):
"""选图(飞轮信号 image_select +3"""
import json
from app.services.flywheel_service import record_signal
from app.constants.enums import IMAGE_STRATEGY_ANGLE
task = _check_task_ownership(
db.query(GenerationTask).filter(GenerationTask.id == task_id).first(),
current_user.workspace_id,
@@ -70,7 +72,14 @@ def select_image(
raise_not_found("图片候选不存在")
ic.is_selected = True
db.commit()
record_signal(db, current_user, task, "image_select", candidate_id=ic.id)
# R7 断点2选图带 strategy → 映射叙事角度标签进飞轮,与文案 angle_label 并轨;
# strategy 原值另存 signal_meta便于后续按套别复盘。angle_label=None 不再空转。
angle = IMAGE_STRATEGY_ANGLE.get(ic.strategy or "")
meta = json.dumps({"strategy": ic.strategy, "role": ic.role}, ensure_ascii=False) if ic.strategy else None
record_signal(
db, current_user, task, "image_select",
candidate_id=ic.id, angle_label=angle, signal_meta=meta,
)
return ok({"selected": body.candidate_id})
@@ -159,7 +168,14 @@ def get_preference_context(
current_user.workspace_id,
)
from app.services.flywheel_service import get_preference_context
ctx = get_preference_context(db, current_user.workspace_id, task.product_id)
# 取产品档案供冷启动基线(与生产链同口径)
from app.models.product import Product
_p = db.query(Product).filter(Product.id == task.product_id).first()
product_dict = {
"custom_prompt": getattr(_p, "custom_prompt", "") or "",
"style_tone": getattr(_p, "style_tone", "") or "",
} if _p else {}
ctx = get_preference_context(db, current_user.workspace_id, task.product_id, product_dict)
return ok(ctx)

View File

@@ -170,16 +170,45 @@ def create_task(
def list_tasks(
page: int = 1, page_size: int = 20,
status: list[str] | None = Query(default=None),
date_from: str | None = Query(default=None, description="起始日(YYYY-MM-DD)"),
date_to: str | None = Query(default=None, description="结束日(YYYY-MM-DD,含当日)"),
product_id: int | None = Query(default=None),
current_user: Annotated[CurrentUser, Depends(require_write_permission)] = None,
db: Session = Depends(get_db),
):
from datetime import datetime, timedelta
from app.models.product import Product
q = db.query(GenerationTask).filter(GenerationTask.workspace_id == current_user.workspace_id)
if status:
# 支持多状态(?status=approved&status=rejected),单值也兼容
q = q.filter(GenerationTask.status.in_(status))
if product_id is not None:
q = q.filter(GenerationTask.product_id == product_id)
# 日期筛选date 形参非法 → 40001(不静默吞成全量,防运营误判)
try:
if date_from:
q = q.filter(GenerationTask.created_at >= datetime.fromisoformat(date_from))
if date_to:
# date_to 含当日:取次日 0 点为开区间上界,覆盖当日全部时刻
_end = datetime.fromisoformat(date_to) + timedelta(days=1)
q = q.filter(GenerationTask.created_at < _end)
except ValueError:
from app.core.response import raise_param_error
raise_param_error("date_from/date_to 需为 YYYY-MM-DD 格式")
total = q.count()
items = q.order_by(GenerationTask.created_at.desc()).offset((page - 1) * page_size).limit(page_size).all()
return ok(paginate([_fmt_task(t) for t in items], total, page, page_size))
# product_name 一次性批量查(防 N+1):收集本页 product_id → id→name dict
pids = {t.product_id for t in items if t.product_id}
name_map: dict[int, str] = {}
if pids:
for p in db.query(Product.id, Product.name).filter(Product.id.in_(pids)).all():
name_map[p.id] = p.name
rows = []
for t in items:
d = _fmt_task(t)
d["product_name"] = name_map.get(t.product_id)
rows.append(d)
return ok(paginate(rows, total, page, page_size))
@router.get("/{task_id}")