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