上线版: 产品表单统一+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:
yangqianqian
2026-06-30 18:08:13 +08:00
parent a77212781c
commit df1856d793
150 changed files with 8616 additions and 1765 deletions

View File

@@ -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()