Files
beige/backend/app/api/v1/products.py
yangqianqian df1856d793 上线版: 产品表单统一+form嵌套修复+用户管理+部署+三套叙事
- 产品编辑入口统一走 ProductFormFull(卖点/风格/人群/品牌词全字段);
  修复开任务页 <form> 套 <form> 致"编辑产品"报错、改不了、跳回首个产品
- dashboard 入口卡片对齐实际路由: 系统管理(/config) 与 工作配置(/settings) 分开;
  settings ?tab=products 直达改用挂载后读 URL, 消除 hydration mismatch
- 新增用户管理(users API/admin service/改密页) + alembic 022/023/024
- 上线部署: Dockerfile / docker-compose.prod+https / nginx https / .env.example
- A8 三套正交叙事(痛点/场景/成分背书) + beige 调色去AI化 + 飞轮 text_import 高权重信号

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 18:08:13 +08:00

403 lines
17 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
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
from app.models.task import GenerationTask
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.get("/products/{product_id}/preference/context")
def get_product_preference_context(
product_id: int,
current_user: Annotated[CurrentUser, Depends(require_write_permission)] = None,
db: Session = Depends(get_db),
):
"""产品维度飞轮偏好上下文(新建任务页用,此时还没有 task_id
与 task 维度端点同口径,复用 flywheel_service.get_preference_context。"""
p = db.query(Product).filter(
Product.id == product_id, Product.workspace_id == current_user.workspace_id
).first()
if not p:
raise_not_found("产品不存在")
from app.services.flywheel_service import get_preference_context
product_dict = {
"custom_prompt": p.custom_prompt or "",
"style_tone": p.style_tone or "",
}
ctx = get_preference_context(db, current_user.workspace_id, product_id, product_dict)
return ok(ctx)
@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),
):
"""智能删除(仅管理员):没跑过任务的产品物理删干净(级联清图片/标杆),
已有历史任务的产品改软删保留历史,避免 generation_tasks 外键报错。"""
p = db.query(Product).filter(Product.id == product_id, Product.workspace_id == current_user.workspace_id).first()
if not p:
raise_not_found("产品不存在")
has_task = db.query(GenerationTask.id).filter(GenerationTask.product_id == product_id).first() is not None
if has_task:
p.is_active = False # 有历史任务→软删保留
db.commit()
return ok({"deleted": product_id, "mode": "soft", "reason": "产品已有历史任务,已停用并保留记录"})
db.query(ProductImage).filter(ProductImage.product_id == product_id).delete()
db.query(BenchmarkNote).filter(BenchmarkNote.product_id == product_id).delete()
db.delete(p) # 无任务→物理删(已先显式清图片/标杆,不赌数据库级联)
db.commit()
return ok({"deleted": product_id, "mode": "hard"})
# ── 产品参考图上传 ──────────────────────────────────────────
_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 解密基石Bplain_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)
# codeproxy 备用 key可选用户录入则用没录回落 env不抛错
alt_row = db.query(UserApiKey).filter(
UserApiKey.user_id == current_user.user_id,
UserApiKey.workspace_id == current_user.workspace_id,
UserApiKey.provider == "codeproxy",
).first()
alt_key = decrypt_key(alt_row.encrypted_key) if alt_row else None
clients = build_ai_clients(plain_key, alt_key=alt_key)
plain_key = None # 立即清零不传出基石B
alt_key = None
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)