上线版: 产品表单统一+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>
This commit is contained in:
@@ -4,16 +4,19 @@ app/api/v1/benchmarks.py — 标杆笔记 + 违禁词路由(管理员)
|
||||
"""
|
||||
|
||||
import logging
|
||||
import os
|
||||
import uuid
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
from fastapi import APIRouter, Depends, File, Form, UploadFile
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.config import get_settings
|
||||
from app.core.database import get_db
|
||||
from app.core.response import ok, paginate, raise_not_found
|
||||
from app.core.response import ok, paginate, raise_business, raise_not_found
|
||||
from app.middleware.workspace_guard import CurrentUser, require_admin, require_write_permission
|
||||
from app.models.product import BannedWord, BenchmarkNote
|
||||
from app.models.product import BannedWord, BenchmarkNote, Product
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(tags=["products"])
|
||||
@@ -45,6 +48,8 @@ def _fmt_benchmark(b: BenchmarkNote) -> dict:
|
||||
"screenshot_url": b.screenshot_url,
|
||||
"highlights": b.highlights, "link_url": b.link_url,
|
||||
"features": features, "analyze_status": b.analyze_status,
|
||||
"analysis_source": features.get("source") if isinstance(features, dict) else None,
|
||||
"analysis_warning": features.get("warning") if isinstance(features, dict) else None,
|
||||
"created_at": b.created_at.isoformat(),
|
||||
}
|
||||
|
||||
@@ -57,6 +62,70 @@ def _fmt_banned(bw: BannedWord) -> dict:
|
||||
}
|
||||
|
||||
|
||||
_ALLOWED_IMAGE_CONTENT_TYPES = {"image/jpeg", "image/png", "image/webp"}
|
||||
_MAX_IMAGE_SIZE_BYTES = 10 * 1024 * 1024
|
||||
|
||||
|
||||
def _check_image_magic(data: bytes) -> bool:
|
||||
if len(data) < 12:
|
||||
return False
|
||||
if data[:3] == b"\xff\xd8\xff":
|
||||
return True
|
||||
if data[:8] == b"\x89PNG\r\n\x1a\n":
|
||||
return True
|
||||
if data[:4] == b"RIFF" and data[8:12] == b"WEBP":
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _clean_link_url(link_url: str | None) -> str | None:
|
||||
value = (link_url or "").strip()
|
||||
if not value:
|
||||
return None
|
||||
if not value.startswith(("http://", "https://")):
|
||||
raise_business("原始链接必须以 http:// 或 https:// 开头")
|
||||
return value
|
||||
|
||||
|
||||
def _is_benchmark_upload_path(path: str) -> bool:
|
||||
upload_root = os.path.abspath(get_settings().UPLOAD_ABS_ROOT)
|
||||
benchmark_root = os.path.join(upload_root, "benchmarks")
|
||||
candidate = os.path.abspath(path)
|
||||
try:
|
||||
return os.path.commonpath([benchmark_root, candidate]) == benchmark_root
|
||||
except ValueError:
|
||||
return False
|
||||
|
||||
|
||||
async def _save_benchmark_screenshot(
|
||||
file: UploadFile,
|
||||
workspace_id: int,
|
||||
product_id: int,
|
||||
) -> str:
|
||||
if file.content_type not in _ALLOWED_IMAGE_CONTENT_TYPES:
|
||||
raise_business(f"不支持的截图类型 {file.content_type},仅支持 JPEG/PNG/WebP")
|
||||
|
||||
data = await file.read()
|
||||
if len(data) > _MAX_IMAGE_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 "benchmark.jpg")[1] or ".jpg"
|
||||
abs_dir = os.path.join(
|
||||
settings.UPLOAD_ABS_ROOT,
|
||||
"benchmarks",
|
||||
str(workspace_id),
|
||||
str(product_id),
|
||||
)
|
||||
os.makedirs(abs_dir, exist_ok=True)
|
||||
save_path = os.path.join(abs_dir, f"{uuid.uuid4().hex}{ext}")
|
||||
with open(save_path, "wb") as f:
|
||||
f.write(data)
|
||||
return save_path
|
||||
|
||||
|
||||
# ── 标杆笔记 ────────────────────────────────────────────────
|
||||
@router.get("/products/{product_id}/benchmarks")
|
||||
def list_benchmarks(
|
||||
@@ -81,12 +150,64 @@ def create_benchmark(
|
||||
current_user: Annotated[CurrentUser, Depends(require_admin)] = None,
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
product = db.query(Product).filter(
|
||||
Product.id == product_id,
|
||||
Product.workspace_id == current_user.workspace_id,
|
||||
).first()
|
||||
if not product:
|
||||
raise_not_found("产品不存在")
|
||||
# 兼容旧前端:旧字段 screenshot_url 实际可能传的是外部原帖链接。
|
||||
legacy_link = (body.screenshot_url or "").strip()
|
||||
highlights = (body.highlights or "").strip()
|
||||
link_url = _clean_link_url(body.link_url or legacy_link)
|
||||
if not highlights and not link_url:
|
||||
raise_business("请至少填写原始链接或亮点")
|
||||
b = BenchmarkNote(
|
||||
workspace_id=current_user.workspace_id,
|
||||
product_id=product_id,
|
||||
screenshot_url=body.screenshot_url,
|
||||
highlights=body.highlights,
|
||||
link_url=body.link_url,
|
||||
screenshot_url=None,
|
||||
highlights=highlights,
|
||||
link_url=link_url,
|
||||
)
|
||||
db.add(b); db.commit(); db.refresh(b)
|
||||
return ok(_fmt_benchmark(b))
|
||||
|
||||
|
||||
@router.post("/products/{product_id}/benchmarks/upload")
|
||||
async def upload_benchmark(
|
||||
product_id: int,
|
||||
screenshot: UploadFile | None = File(None),
|
||||
highlights: str | None = Form(None),
|
||||
link_url: str | None = Form(None),
|
||||
current_user: Annotated[CurrentUser, Depends(require_admin)] = None,
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
product = db.query(Product).filter(
|
||||
Product.id == product_id,
|
||||
Product.workspace_id == current_user.workspace_id,
|
||||
).first()
|
||||
if not product:
|
||||
raise_not_found("产品不存在")
|
||||
|
||||
clean_highlights = (highlights or "").strip()
|
||||
clean_link_url = _clean_link_url(link_url)
|
||||
if not screenshot and not clean_highlights and not clean_link_url:
|
||||
raise_business("请至少上传截图、填写原始链接或填写亮点")
|
||||
|
||||
screenshot_path = None
|
||||
if screenshot:
|
||||
screenshot_path = await _save_benchmark_screenshot(
|
||||
screenshot,
|
||||
current_user.workspace_id,
|
||||
product_id,
|
||||
)
|
||||
|
||||
b = BenchmarkNote(
|
||||
workspace_id=current_user.workspace_id,
|
||||
product_id=product_id,
|
||||
screenshot_url=screenshot_path,
|
||||
highlights=clean_highlights,
|
||||
link_url=clean_link_url,
|
||||
)
|
||||
db.add(b); db.commit(); db.refresh(b)
|
||||
return ok(_fmt_benchmark(b))
|
||||
@@ -117,17 +238,24 @@ async def analyze_benchmark_note(
|
||||
).first()
|
||||
if not b:
|
||||
raise_not_found("标杆笔记不存在")
|
||||
if not b.screenshot_url and not b.highlights:
|
||||
raise_business("该标杆无截图也无手填亮点,无法分析")
|
||||
if not b.screenshot_url and not b.highlights and not b.link_url:
|
||||
raise_business("该标杆无截图、原始链接和手填亮点,无法分析")
|
||||
|
||||
# 读截图字节(screenshot_url 存的是上传后的绝对/相对路径)
|
||||
# 读截图字节:新数据存本地上传路径;旧数据可能误存外部 URL,不能直接 open。
|
||||
screenshot = None
|
||||
if b.screenshot_url:
|
||||
try:
|
||||
with open(b.screenshot_url, "rb") as f:
|
||||
screenshot = f.read()
|
||||
except Exception as e:
|
||||
logger.warning("标杆截图读取失败 id=%s: %s", benchmark_id, e)
|
||||
if b.screenshot_url.startswith(("http://", "https://")):
|
||||
logger.info("标杆截图字段为外部 URL,跳过本地读取 id=%s", benchmark_id)
|
||||
elif _is_benchmark_upload_path(b.screenshot_url) and os.path.exists(b.screenshot_url):
|
||||
try:
|
||||
with open(b.screenshot_url, "rb") as f:
|
||||
screenshot = f.read()
|
||||
except Exception as e:
|
||||
logger.warning("标杆截图读取失败 id=%s: %s", benchmark_id, e)
|
||||
elif os.path.exists(b.screenshot_url):
|
||||
logger.warning("标杆截图路径不在上传目录,跳过读取 id=%s path=%s", benchmark_id, b.screenshot_url)
|
||||
else:
|
||||
logger.warning("标杆截图路径不存在 id=%s path=%s", benchmark_id, b.screenshot_url)
|
||||
|
||||
api_key_row = db.query(UserApiKey).filter(
|
||||
UserApiKey.user_id == current_user.user_id,
|
||||
@@ -137,12 +265,26 @@ async def analyze_benchmark_note(
|
||||
if not api_key_row:
|
||||
raise_business("未配置 API Key,请先在设置中录入")
|
||||
plain_key = decrypt_key(api_key_row.encrypted_key)
|
||||
clients = build_ai_clients(plain_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
|
||||
alt_key = None
|
||||
|
||||
b.analyze_status = "analyzing"; db.commit()
|
||||
try:
|
||||
result = await analyze_benchmark(clients, screenshot, b.highlights)
|
||||
analysis_text_parts = []
|
||||
if b.highlights:
|
||||
analysis_text_parts.append(b.highlights)
|
||||
if b.link_url:
|
||||
analysis_text_parts.append(f"原始链接:{b.link_url}")
|
||||
result = await analyze_benchmark(clients, screenshot, "\n".join(analysis_text_parts))
|
||||
finally:
|
||||
await clients.aclose()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user