""" 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, 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, ProductImage 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 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, "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, "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(), } # ── 产品档案 ──────────────────────────────────────────────── @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_write_permission)] = 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_write_permission)] = 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 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), ): """ 上传产品参考图(铁律:生图必须带产品图)。 文件类型:JPEG/PNG/WebP;大小上限 10 MB。 存储路径:uploads/products/{workspace_id}/{product_id}/{filename} 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( Product.id == product_id, Product.workspace_id == current_user.workspace_id, ).first() 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" # 存绝对路径:锚定 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) # 是否首张:决定要不要兜底设主图 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 scene=%s primary=%s path=%s", product_id, scene, is_primary, 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 限制") 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( 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)