将 Clover 从上层产品包旧仓库中独立出来,建立专属版本控制。 当前状态=纵切片端到端已打通(登录→选品→出文出图→审核→下载包), M1文案质量去套路化已验收。此提交作为后续按核销清单逐条修复的基线。 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
309 lines
12 KiB
Python
309 lines
12 KiB
Python
"""
|
||
app/api/v1/products.py — 品牌库路由(管理员)
|
||
products / benchmark_notes / banned_words
|
||
category 是纯数据字段,不在代码里做枚举(基石A)。
|
||
"""
|
||
|
||
import logging
|
||
from typing import Annotated
|
||
|
||
from fastapi import APIRouter, Depends, UploadFile, File
|
||
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
|
||
|
||
logger = logging.getLogger(__name__)
|
||
router = APIRouter(tags=["products"])
|
||
|
||
|
||
# ── DTO ────────────────────────────────────────────────────
|
||
class ProductCreate(BaseModel):
|
||
name: str
|
||
category: str | None = None # 纯数据字段,不做枚举(基石A)
|
||
source: str = "custom" # preset | custom
|
||
selling_points: list[str] = []
|
||
style_tone: str | None = None
|
||
text_angles: list[str] = [] # 用户设定,不写死(基石A)
|
||
custom_prompt: str | None = None # 等北哥方案注入
|
||
banned_word_ids: list[int] = []
|
||
image_path: str | None = None # 产品参考图(可建档即带;通常走 upload-image 接口)
|
||
brand_keyword: str | None = None # 品牌词,客户录入,012:套2字段暴露
|
||
target_audience: str | None = None # 目标人群,客户录入,012:套2字段暴露
|
||
|
||
|
||
class BenchmarkCreate(BaseModel):
|
||
screenshot_url: str | None = None
|
||
highlights: str | None = None
|
||
link_url: str | None = None
|
||
|
||
|
||
class BannedWordCreate(BaseModel):
|
||
word: str
|
||
level: str # auto_fix | soft_warn | hard_block
|
||
replacement: str | None = None
|
||
updatable: bool = True
|
||
|
||
|
||
def _fmt_product(p: Product) -> dict:
|
||
import json
|
||
return {
|
||
"id": p.id, "name": p.name, "category": p.category,
|
||
"source": p.source, "is_active": p.is_active,
|
||
"selling_points": json.loads(p.selling_points) if p.selling_points else [],
|
||
"style_tone": p.style_tone,
|
||
"text_angles": json.loads(p.text_angles) if p.text_angles else [],
|
||
"custom_prompt": p.custom_prompt,
|
||
"image_path": p.image_path,
|
||
"brand_keyword": p.brand_keyword, # 012: 套2字段暴露
|
||
"target_audience": p.target_audience, # 012: 套2字段暴露
|
||
"created_at": p.created_at.isoformat(),
|
||
}
|
||
|
||
|
||
# ── 产品档案 ────────────────────────────────────────────────
|
||
@router.get("/products")
|
||
def list_products(
|
||
page: int = 1, page_size: int = 20, source: str | None = None,
|
||
current_user: Annotated[CurrentUser, Depends(require_write_permission)] = None,
|
||
db: Session = Depends(get_db),
|
||
):
|
||
q = db.query(Product).filter(Product.workspace_id == current_user.workspace_id, Product.is_active == True)
|
||
if source in ("preset", "custom"):
|
||
q = q.filter(Product.source == source)
|
||
total = q.count()
|
||
items = q.offset((page - 1) * page_size).limit(page_size).all()
|
||
return ok(paginate([_fmt_product(p) for p in items], total, page, page_size))
|
||
|
||
|
||
@router.post("/products")
|
||
def create_product(
|
||
body: ProductCreate,
|
||
current_user: Annotated[CurrentUser, Depends(require_admin)] = None,
|
||
db: Session = Depends(get_db),
|
||
):
|
||
import json
|
||
p = Product(
|
||
workspace_id=current_user.workspace_id,
|
||
name=body.name, category=body.category, source=body.source,
|
||
selling_points=json.dumps(body.selling_points, ensure_ascii=False),
|
||
style_tone=body.style_tone,
|
||
text_angles=json.dumps(body.text_angles, ensure_ascii=False),
|
||
custom_prompt=body.custom_prompt,
|
||
image_path=body.image_path or None,
|
||
brand_keyword=body.brand_keyword or None, # 012: 套2字段
|
||
target_audience=body.target_audience or None, # 012: 套2字段
|
||
)
|
||
db.add(p)
|
||
db.commit()
|
||
db.refresh(p)
|
||
return ok(_fmt_product(p))
|
||
|
||
|
||
@router.get("/products/{product_id}")
|
||
def get_product(
|
||
product_id: int,
|
||
current_user: Annotated[CurrentUser, Depends(require_write_permission)] = None,
|
||
db: Session = Depends(get_db),
|
||
):
|
||
p = db.query(Product).filter(Product.id == product_id, Product.workspace_id == current_user.workspace_id).first()
|
||
if not p:
|
||
raise_not_found("产品不存在")
|
||
return ok(_fmt_product(p))
|
||
|
||
|
||
@router.put("/products/{product_id}")
|
||
def update_product(
|
||
product_id: int, body: ProductCreate,
|
||
current_user: Annotated[CurrentUser, Depends(require_admin)] = None,
|
||
db: Session = Depends(get_db),
|
||
):
|
||
import json
|
||
p = db.query(Product).filter(Product.id == product_id, Product.workspace_id == current_user.workspace_id).first()
|
||
if not p:
|
||
raise_not_found("产品不存在")
|
||
p.name = body.name; p.category = body.category; p.source = body.source
|
||
p.selling_points = json.dumps(body.selling_points, ensure_ascii=False)
|
||
p.style_tone = body.style_tone
|
||
p.text_angles = json.dumps(body.text_angles, ensure_ascii=False)
|
||
p.custom_prompt = body.custom_prompt
|
||
p.brand_keyword = body.brand_keyword or None # 012: 套2字段
|
||
p.target_audience = body.target_audience or None # 012: 套2字段
|
||
db.commit(); db.refresh(p)
|
||
return ok(_fmt_product(p))
|
||
|
||
|
||
@router.delete("/products/{product_id}")
|
||
def delete_product(
|
||
product_id: int,
|
||
current_user: Annotated[CurrentUser, Depends(require_admin)] = None,
|
||
db: Session = Depends(get_db),
|
||
):
|
||
p = db.query(Product).filter(Product.id == product_id, Product.workspace_id == current_user.workspace_id).first()
|
||
if not p:
|
||
raise_not_found("产品不存在")
|
||
p.is_active = False # 软删
|
||
db.commit()
|
||
return ok({"deleted": product_id})
|
||
|
||
|
||
# ── 产品参考图上传 ──────────────────────────────────────────
|
||
_ALLOWED_CONTENT_TYPES = {"image/jpeg", "image/png", "image/webp"}
|
||
_MAX_SIZE_BYTES = 10 * 1024 * 1024 # 10 MB
|
||
|
||
|
||
@router.post("/products/{product_id}/upload-image")
|
||
async def upload_product_image(
|
||
product_id: int,
|
||
file: UploadFile = File(...),
|
||
current_user: Annotated[CurrentUser, Depends(require_write_permission)] = None,
|
||
db: Session = Depends(get_db),
|
||
):
|
||
"""
|
||
上传产品参考图(铁律:生图必须带产品图)。
|
||
文件类型:JPEG/PNG/WebP;大小上限 10 MB。
|
||
存储路径:uploads/products/{workspace_id}/{product_id}/{filename}
|
||
写回 product.image_path,生图管道读此字段构建 reference_images。
|
||
"""
|
||
from app.core.response import raise_business
|
||
from app.core.config import get_settings
|
||
import os, uuid
|
||
|
||
p = db.query(Product).filter(
|
||
Product.id == product_id,
|
||
Product.workspace_id == current_user.workspace_id,
|
||
).first()
|
||
if not p:
|
||
raise_not_found("产品不存在")
|
||
|
||
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 限制")
|
||
|
||
settings = get_settings()
|
||
ext = os.path.splitext(file.filename or "img.jpg")[1] or ".jpg"
|
||
# 存绝对路径:锚定 StaticFiles 根 /app/uploads,避免 worker(cwd=/) 读不到。
|
||
abs_dir = os.path.join(settings.UPLOAD_ABS_ROOT, "products",
|
||
str(current_user.workspace_id), str(product_id))
|
||
os.makedirs(abs_dir, exist_ok=True)
|
||
filename = f"{uuid.uuid4().hex}{ext}"
|
||
save_path = os.path.join(abs_dir, filename)
|
||
with open(save_path, "wb") as f:
|
||
f.write(data)
|
||
|
||
p.image_path = save_path # 绝对路径,worker 直接 open 可读
|
||
db.commit()
|
||
db.refresh(p)
|
||
logger.info("product image uploaded: product_id=%s path=%s", product_id, save_path)
|
||
return ok(_fmt_product(p))
|
||
|
||
|
||
# ── 套1 产品图视觉分析 ────────────────────────────────────────
|
||
_VISION_PROMPT = """你是小红书产品种草策划。请根据产品图分析卖点与人群。
|
||
硬性规则:只分析本次上传图片,不沿用历史案例,不默认护肤品。
|
||
返回JSON(仅JSON,无其他文字):
|
||
{
|
||
"productName": "从包装识别,识别不到填空",
|
||
"category": "美妆护肤/个护护理/食品饮品/营养健康/家居生活/服饰穿搭/电商产品之一",
|
||
"sellingPoints": ["转成用户买点,不要品牌空话,3-6个"],
|
||
"targetAudience": "一句话描述核心人群",
|
||
"scenarios": ["使用场景,2-4个"],
|
||
"keywords": ["小红书搜索关键词,3-5个"],
|
||
"bannedWords": ["合规禁用词"],
|
||
"imageDirection": "这组图如何拍/排版,一句话",
|
||
"confidence": 0.9,
|
||
"source": "vision"
|
||
}"""
|
||
|
||
|
||
def _parse_vision_json(raw: str) -> dict:
|
||
"""从模型返回文本中提取JSON(容错 markdown ```json 代码块)"""
|
||
import json, re
|
||
# 去掉 markdown 代码块包裹
|
||
cleaned = re.sub(r"```json\s*", "", raw)
|
||
cleaned = re.sub(r"```\s*", "", cleaned)
|
||
# 提取第一个 {...} 块
|
||
m = re.search(r"\{[\s\S]*\}", cleaned)
|
||
if not m:
|
||
raise ValueError("vision 返回内容中未找到 JSON")
|
||
return json.loads(m.group())
|
||
|
||
|
||
def _fallback_analysis(product_name: str = "") -> dict:
|
||
"""vision 失败时的文字兜底,保证接口不空手返回"""
|
||
return {
|
||
"productName": product_name or "产品",
|
||
"category": "电商产品",
|
||
"sellingPoints": ["使用方便", "适合日常场景"],
|
||
"targetAudience": "有明确使用场景和效率诉求的人群",
|
||
"scenarios": ["日常使用", "通勤出门"],
|
||
"keywords": [product_name or "好物", "真实测评", "种草分享"],
|
||
"bannedWords": ["美白", "祛斑", "速效", "医用", "药妆"],
|
||
"imageDirection": "产品白底图保证准确,真实场景图突出使用体验",
|
||
"confidence": 0.45,
|
||
"source": "fallback",
|
||
}
|
||
|
||
|
||
@router.post("/products/{product_id}/analyze-image")
|
||
async def analyze_product_image(
|
||
product_id: int,
|
||
file: UploadFile = File(...),
|
||
current_user: Annotated[CurrentUser, Depends(require_write_permission)] = None,
|
||
db: Session = Depends(get_db),
|
||
):
|
||
"""
|
||
套1:上传产品图 → GPT vision 读图 → 返回结构化卖点/人群分析。
|
||
key 链路:从当前用户 API key 解密(基石B);plain_key 只活在局部变量。
|
||
"""
|
||
from app.core.response import raise_business
|
||
from app.models.workspace import UserApiKey
|
||
from app.utils.fernet_utils import decrypt_key
|
||
from app.services.ai_engine.gemini_factory import build_ai_clients
|
||
|
||
# 文件校验(复用 upload_product_image 同款规则)
|
||
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 限制")
|
||
|
||
# key 解密(照抄 pipeline_steps.decrypt_user_key,基石B)
|
||
api_key_row = db.query(UserApiKey).filter(
|
||
UserApiKey.user_id == current_user.user_id,
|
||
UserApiKey.workspace_id == current_user.workspace_id,
|
||
UserApiKey.provider.in_(["openai", "apiports"]),
|
||
).first()
|
||
if not api_key_row:
|
||
raise_business("未配置 API Key,请先在设置中录入")
|
||
plain_key = decrypt_key(api_key_row.encrypted_key)
|
||
|
||
clients = build_ai_clients(plain_key)
|
||
plain_key = None # 立即清零,不传出(基石B)
|
||
|
||
try:
|
||
raw = await clients.gpt_vision_analyze(_VISION_PROMPT, [data])
|
||
try:
|
||
result = _parse_vision_json(raw)
|
||
result["source"] = "vision"
|
||
except Exception as parse_err:
|
||
logger.warning("vision JSON parse failed, fallback: %s", parse_err)
|
||
result = _fallback_analysis()
|
||
result["warning"] = f"视觉分析解析失败,已使用文字兜底:{parse_err}"
|
||
except Exception as e:
|
||
logger.warning("vision_analyze failed, fallback: %s", e)
|
||
result = _fallback_analysis()
|
||
result["warning"] = f"视觉分析失败,已使用文字兜底:{e}"
|
||
finally:
|
||
await clients.aclose()
|
||
|
||
logger.info("analyze_product_image done: product_id=%s source=%s conf=%.2f",
|
||
product_id, result.get("source"), result.get("confidence", 0))
|
||
return ok(result)
|