上线版: 产品表单统一+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:
@@ -43,6 +43,10 @@ class CreateApiKeyRequest(BaseModel):
|
||||
# 注:不接收 url 字段(token站固定自家站,基石B)
|
||||
|
||||
|
||||
class TestApiKeyRequest(CreateApiKeyRequest):
|
||||
pass
|
||||
|
||||
|
||||
def _format_key(k: UserApiKey) -> dict:
|
||||
"""只显 provider + key_last4,不暴露余额/用量/encrypted_key(红线)。"""
|
||||
return {
|
||||
@@ -77,8 +81,13 @@ def create_api_key(
|
||||
current_user: Annotated[CurrentUser, Depends(require_write_permission)],
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""录入 API Key(Fernet 加密存储,只保存后4位明文用于展示)。"""
|
||||
from app.core.response import raise_business
|
||||
"""录入/更新 API Key(Fernet 加密存储,只保存后4位明文用于展示)。
|
||||
|
||||
覆盖式(倩倩姐2026-06-23拍板):同 user+workspace+provider 已存在则直接更新,
|
||||
不报错——「切换/修改 key」无需先删再录,前端点「修改」重录即覆盖。
|
||||
"""
|
||||
encrypted = encrypt_api_key(body.api_key)
|
||||
last4 = mask_api_key(body.api_key)
|
||||
# 检查同 user+workspace+provider 是否已有
|
||||
existing = (
|
||||
db.query(UserApiKey)
|
||||
@@ -90,15 +99,19 @@ def create_api_key(
|
||||
.first()
|
||||
)
|
||||
if existing:
|
||||
raise_business(f"已存在 {body.provider} 的 API Key,请先删除再录入")
|
||||
existing.encrypted_key = encrypted
|
||||
existing.key_last4 = last4
|
||||
db.commit()
|
||||
db.refresh(existing)
|
||||
logger.info("API key updated: user=%s provider=%s", current_user.user_id, body.provider)
|
||||
return ok(_format_key(existing))
|
||||
|
||||
encrypted = encrypt_api_key(body.api_key)
|
||||
key_obj = UserApiKey(
|
||||
user_id=current_user.user_id,
|
||||
workspace_id=current_user.workspace_id,
|
||||
provider=body.provider,
|
||||
encrypted_key=encrypted,
|
||||
key_last4=mask_api_key(body.api_key),
|
||||
key_last4=last4,
|
||||
)
|
||||
db.add(key_obj)
|
||||
db.commit()
|
||||
@@ -107,6 +120,48 @@ def create_api_key(
|
||||
return ok(_format_key(key_obj))
|
||||
|
||||
|
||||
@router.post("/test")
|
||||
async def test_api_key(
|
||||
body: TestApiKeyRequest,
|
||||
current_user: Annotated[CurrentUser, Depends(require_write_permission)],
|
||||
):
|
||||
"""用客户输入的明文 key 做一次轻量连通性测试,不落库。"""
|
||||
import httpx
|
||||
from app.core.config import get_settings
|
||||
from app.core.response import raise_business
|
||||
|
||||
settings = get_settings()
|
||||
provider = body.provider
|
||||
if provider in ("openai", "apiports"):
|
||||
base = (settings.APIPORTS_BASE_URL or settings.IMAGE_API_BASE).rstrip("/")
|
||||
model = settings.MODEL_TEXT
|
||||
elif provider == "codeproxy":
|
||||
base = settings.CODEPROXY_BASE_URL.rstrip("/")
|
||||
model = "gpt-5.5"
|
||||
else:
|
||||
raise_business("该 Provider 暂不支持在线测试")
|
||||
if not base:
|
||||
raise_business("服务端未配置该 Provider 的 Base URL")
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=12) as client:
|
||||
resp = await client.post(
|
||||
f"{base}/chat/completions",
|
||||
headers={"Authorization": f"Bearer {body.api_key}"},
|
||||
json={
|
||||
"model": model,
|
||||
"messages": [{"role": "user", "content": "ping"}],
|
||||
"max_tokens": 4,
|
||||
},
|
||||
)
|
||||
resp.raise_for_status()
|
||||
except Exception as exc:
|
||||
raise_business(f"Key 测试失败:{type(exc).__name__}")
|
||||
|
||||
logger.info("API key test ok: user=%s provider=%s", current_user.user_id, provider)
|
||||
return ok({"ok": True, "provider": provider})
|
||||
|
||||
|
||||
@router.delete("/{key_id}")
|
||||
def delete_api_key(
|
||||
key_id: int,
|
||||
|
||||
@@ -34,6 +34,11 @@ class LoginRequest(BaseModel):
|
||||
return v
|
||||
|
||||
|
||||
class ChangePasswordRequest(BaseModel):
|
||||
current_password: str
|
||||
new_password: str
|
||||
|
||||
|
||||
# ── 路由 ───────────────────────────────────────────────────
|
||||
@router.post("/login")
|
||||
def login(body: LoginRequest, db: Session = Depends(get_db)):
|
||||
@@ -47,6 +52,18 @@ def login(body: LoginRequest, db: Session = Depends(get_db)):
|
||||
})
|
||||
|
||||
|
||||
@router.post("/change-password")
|
||||
def change_password(
|
||||
body: ChangePasswordRequest,
|
||||
current_user: Annotated[CurrentUser, Depends(get_current_user)],
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""修改当前登录用户密码;首登强制改密也走此接口。"""
|
||||
from app.services.auth_service import change_password as do_change_password
|
||||
do_change_password(db, current_user.user_id, body.current_password, body.new_password)
|
||||
return ok({"changed": True})
|
||||
|
||||
|
||||
@router.get("/me")
|
||||
def get_me(
|
||||
current_user: Annotated[CurrentUser, Depends(get_current_user)],
|
||||
@@ -63,6 +80,7 @@ def get_me(
|
||||
"email": user.email,
|
||||
"current_workspace_id": current_user.workspace_id,
|
||||
"role": current_user.role,
|
||||
"must_change_password": bool(getattr(user, "must_change_password", False)),
|
||||
"workspace": {
|
||||
"id": ws.id, "name": ws.name, "slug": ws.slug,
|
||||
} if ws else None,
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
"""
|
||||
app/api/v1/delivery.py — 交付包路由
|
||||
POST /tasks/{id}/package — 生成达人素材交付包
|
||||
GET /delivery-packages/{id}/download — 下载(status: pending/ready/downloaded)
|
||||
GET /delivery-packages/{id}/download — 查询下载状态(无副作用)
|
||||
POST /delivery-packages/{id}/download-token — 签发60s一次性下载令牌(C1坑修复)
|
||||
POST /delivery-packages/{id}/mark-downloaded — 显式标记已下载
|
||||
GET /delivery-packages/{id}/download-file?token= — 带令牌下载,前端可用 window.open
|
||||
"""
|
||||
|
||||
@@ -78,7 +79,7 @@ def get_package_download(
|
||||
current_user: Annotated[CurrentUser, Depends(require_write_permission)] = None,
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""下载交付包(status: pending/ready/downloaded)。"""
|
||||
"""查询交付包下载状态。GET 只读,实际下载由 download-file 完成。"""
|
||||
pkg = db.query(DeliveryPackage).filter(
|
||||
DeliveryPackage.id == package_id,
|
||||
DeliveryPackage.workspace_id == current_user.workspace_id,
|
||||
@@ -86,11 +87,6 @@ def get_package_download(
|
||||
if not pkg:
|
||||
raise_not_found("交付包不存在")
|
||||
|
||||
if pkg.status == "ready" and pkg.download_url:
|
||||
# 标记为 downloaded(只能下一次,防重复公开 URL)
|
||||
pkg.status = "downloaded"
|
||||
db.commit()
|
||||
|
||||
return ok(_fmt_package(pkg))
|
||||
|
||||
|
||||
@@ -124,6 +120,26 @@ def create_download_token(
|
||||
return ok({"token": token, "expires_in": 60})
|
||||
|
||||
|
||||
@router.post("/delivery-packages/{package_id}/mark-downloaded")
|
||||
def mark_package_downloaded(
|
||||
package_id: int,
|
||||
current_user: Annotated[CurrentUser, Depends(require_write_permission)] = None,
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""显式标记交付包已下载。所有 GET 下载接口保持只读。"""
|
||||
pkg = db.query(DeliveryPackage).filter(
|
||||
DeliveryPackage.id == package_id,
|
||||
DeliveryPackage.workspace_id == current_user.workspace_id,
|
||||
).first()
|
||||
if not pkg:
|
||||
raise_not_found("交付包不存在")
|
||||
if pkg.status == "ready":
|
||||
pkg.status = "downloaded"
|
||||
db.commit()
|
||||
db.refresh(pkg)
|
||||
return ok(_fmt_package(pkg))
|
||||
|
||||
|
||||
@router.get("/delivery-packages/{package_id}/download-file")
|
||||
def download_package_file(
|
||||
package_id: int,
|
||||
|
||||
@@ -15,6 +15,7 @@ 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"])
|
||||
@@ -121,6 +122,28 @@ def get_product(
|
||||
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,
|
||||
@@ -148,12 +171,21 @@ def delete_product(
|
||||
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("产品不存在")
|
||||
p.is_active = False # 软删
|
||||
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})
|
||||
return ok({"deleted": product_id, "mode": "hard"})
|
||||
|
||||
|
||||
# ── 产品参考图上传 ──────────────────────────────────────────
|
||||
@@ -337,8 +369,17 @@ async def analyze_product_image(
|
||||
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 # 立即清零,不传出(基石B)
|
||||
alt_key = None
|
||||
|
||||
try:
|
||||
raw = await clients.gpt_vision_analyze(_VISION_PROMPT, [data])
|
||||
|
||||
@@ -53,28 +53,78 @@ def _fmt_queue_item(t: GenerationTask, db: Session) -> dict:
|
||||
from app.api.v1.tasks import _score_array_to_obj
|
||||
product = db.query(Product).filter(Product.id == t.product_id).first()
|
||||
operator = db.query(User).filter(User.id == t.operator_id).first()
|
||||
selected_text = db.query(TextCandidate).filter(
|
||||
selected_texts = db.query(TextCandidate).filter(
|
||||
TextCandidate.task_id == t.id, TextCandidate.is_selected == True
|
||||
).first()
|
||||
selected_image = db.query(ImageCandidate).filter(
|
||||
).order_by(TextCandidate.strategy.asc(), TextCandidate.id.asc()).all()
|
||||
selected_images = db.query(ImageCandidate).filter(
|
||||
ImageCandidate.task_id == t.id, ImageCandidate.is_selected == True
|
||||
).first()
|
||||
).order_by(ImageCandidate.strategy.asc(), ImageCandidate.seq.asc()).all()
|
||||
|
||||
# content 列存 JSON dict,解包取 title/content 分字段返回
|
||||
text_parsed: dict = {}
|
||||
if selected_text and selected_text.content:
|
||||
selected_text = selected_texts[0] if selected_texts else None
|
||||
selected_image = selected_images[0] if selected_images else None
|
||||
|
||||
def _fmt_selected_text(selected_text: TextCandidate | None) -> dict | None:
|
||||
if not selected_text:
|
||||
return None
|
||||
text_parsed: dict = {}
|
||||
try:
|
||||
text_parsed = json.loads(selected_text.content)
|
||||
text_parsed = json.loads(selected_text.content or "{}")
|
||||
except (json.JSONDecodeError, ValueError):
|
||||
text_parsed = {"content": selected_text.content}
|
||||
text_score = None
|
||||
if selected_text.score_json:
|
||||
try:
|
||||
text_score = _score_array_to_obj(json.loads(selected_text.score_json))
|
||||
except (json.JSONDecodeError, ValueError):
|
||||
text_score = None
|
||||
return {
|
||||
"candidate_id": selected_text.id,
|
||||
"strategy": selected_text.strategy,
|
||||
"angle_label": selected_text.angle_label,
|
||||
"title": text_parsed.get("title", ""),
|
||||
"content": text_parsed.get("content", ""),
|
||||
"tags": text_parsed.get("tags", []),
|
||||
"score": text_score,
|
||||
}
|
||||
|
||||
def _fmt_selected_image(selected_image: ImageCandidate | None) -> dict | None:
|
||||
if not selected_image:
|
||||
return None
|
||||
return {
|
||||
"candidate_id": selected_image.id,
|
||||
"strategy": selected_image.strategy,
|
||||
"url": selected_image.url,
|
||||
"role": selected_image.role,
|
||||
"seq": selected_image.seq,
|
||||
}
|
||||
|
||||
images_by_strategy: dict[str, list[ImageCandidate]] = {}
|
||||
for img in selected_images:
|
||||
images_by_strategy.setdefault(img.strategy or "_", []).append(img)
|
||||
|
||||
selected_sets = []
|
||||
for strategy in ("A", "B", "C"):
|
||||
text = next((tc for tc in selected_texts if tc.strategy == strategy), None)
|
||||
imgs = images_by_strategy.get(strategy, [])
|
||||
if text or imgs:
|
||||
selected_sets.append({
|
||||
"strategy": strategy,
|
||||
"selected_text": _fmt_selected_text(text),
|
||||
"selected_images": [_fmt_selected_image(img) for img in imgs],
|
||||
})
|
||||
|
||||
# 质量分:解析 score_json 明细数组算总分+透传维度(绝不读 eval_score,设计留NULL)
|
||||
text_score = None
|
||||
if selected_text and selected_text.score_json:
|
||||
try:
|
||||
text_score = _score_array_to_obj(json.loads(selected_text.score_json))
|
||||
except (json.JSONDecodeError, ValueError):
|
||||
text_score = None
|
||||
legacy_text = _fmt_selected_text(selected_text)
|
||||
legacy_image = _fmt_selected_image(selected_image)
|
||||
# 兼容旧前端字段:selected_text/selected_image 仍返回第一套第一张。
|
||||
if legacy_text:
|
||||
legacy_text.pop("strategy", None)
|
||||
if legacy_image:
|
||||
legacy_image = {
|
||||
"candidate_id": legacy_image["candidate_id"],
|
||||
"strategy": legacy_image["strategy"],
|
||||
"url": legacy_image["url"],
|
||||
}
|
||||
|
||||
return {
|
||||
"task_id": t.id,
|
||||
@@ -82,19 +132,9 @@ def _fmt_queue_item(t: GenerationTask, db: Session) -> dict:
|
||||
"theme": t.theme,
|
||||
"submitted_at": t.updated_at.isoformat(),
|
||||
"operator_name": operator.username if operator else None,
|
||||
"selected_text": {
|
||||
"candidate_id": selected_text.id,
|
||||
"angle_label": selected_text.angle_label,
|
||||
"title": text_parsed.get("title", ""),
|
||||
"content": text_parsed.get("content", ""), # 纯正文
|
||||
"tags": text_parsed.get("tags", []),
|
||||
"score": text_score, # 审核显分:总分+7维明细(C2)
|
||||
} if selected_text else None,
|
||||
"selected_image": {
|
||||
"candidate_id": selected_image.id,
|
||||
"strategy": selected_image.strategy,
|
||||
"url": selected_image.url,
|
||||
} if selected_image else None,
|
||||
"selected_text": legacy_text,
|
||||
"selected_image": legacy_image,
|
||||
"selected_sets": selected_sets,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.database import get_db
|
||||
from app.core.response import ok, raise_not_found, raise_state_invalid
|
||||
from app.middleware.workspace_guard import CurrentUser, require_write_permission
|
||||
from app.middleware.workspace_guard import CurrentUser, require_write_permission, require_admin
|
||||
from app.models.task import GenerationTask, ImageCandidate, TextCandidate
|
||||
|
||||
from app.api.v1.tasks import (
|
||||
@@ -45,6 +45,12 @@ def select_text(
|
||||
).first()
|
||||
if not tc:
|
||||
raise_not_found("文案候选不存在")
|
||||
# A/B/C 同套单选:避免旧选择累积导致审核/打包拿错套别。
|
||||
db.query(TextCandidate).filter(
|
||||
TextCandidate.task_id == task_id,
|
||||
TextCandidate.strategy == tc.strategy,
|
||||
TextCandidate.id != tc.id,
|
||||
).update({"is_selected": False})
|
||||
tc.is_selected = True
|
||||
db.commit()
|
||||
record_signal(db, current_user, task, "text_select", candidate_id=tc.id, angle_label=tc.angle_label)
|
||||
@@ -70,6 +76,13 @@ def select_image(
|
||||
).first()
|
||||
if not ic:
|
||||
raise_not_found("图片候选不存在")
|
||||
# 同套同角色只保留一张已选图;重生后选择新图会自动取消旧图。
|
||||
db.query(ImageCandidate).filter(
|
||||
ImageCandidate.task_id == task_id,
|
||||
ImageCandidate.strategy == ic.strategy,
|
||||
ImageCandidate.role == ic.role,
|
||||
ImageCandidate.id != ic.id,
|
||||
).update({"is_selected": False})
|
||||
ic.is_selected = True
|
||||
db.commit()
|
||||
# R7 断点2:选图带 strategy → 映射叙事角度标签进飞轮,与文案 angle_label 并轨;
|
||||
@@ -89,18 +102,40 @@ def import_text(
|
||||
current_user: Annotated[CurrentUser, Depends(require_write_permission)] = None,
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""轨B:导入外部文案直接进候选池,跳过 AI 生成(洞2)。"""
|
||||
_check_task_ownership(
|
||||
"""轨B:导入外部文案直接进候选池,跳过 AI 生成(洞2)。
|
||||
按已导入条数 %3 轮转分配 strategy(A/B/C),让导入轨「去生图」时能出三套正交配图
|
||||
(生图按 strategy 匹配文案,NULL 套永远匹配不上)。
|
||||
导入=客户实跑验证过的范本,记 text_import 飞轮信号(权重高于改稿/AI选稿,倩倩姐2026-06-26)。"""
|
||||
from app.constants.enums import IMAGE_STRATEGY_ANGLE
|
||||
from app.services.flywheel_service import record_signal
|
||||
task = _check_task_ownership(
|
||||
db.query(GenerationTask).filter(GenerationTask.id == task_id).first(),
|
||||
current_user.workspace_id,
|
||||
)
|
||||
# 已导入条数 %3 → A/B/C 轮转:第1条A 第2条B 第3条C 第4条A…,三套均匀分布
|
||||
existing = db.query(TextCandidate).filter(TextCandidate.task_id == task_id).count()
|
||||
strategy = ("A", "B", "C")[existing % 3]
|
||||
tc = TextCandidate(
|
||||
workspace_id=current_user.workspace_id, task_id=task_id,
|
||||
source="import", content=body.content, angle_label=body.angle_label,
|
||||
strategy=strategy,
|
||||
)
|
||||
db.add(tc)
|
||||
db.commit()
|
||||
db.refresh(tc)
|
||||
# 飞轮信号:导入文案=最值得学习的范本。角度优先用户填的,缺则按套别兜底,让偏好聚合学到角度。
|
||||
# 飞轮辅助不阻塞主路径:记信号失败只 log,导入文案(tc 已 commit)照常返回。
|
||||
try:
|
||||
record_signal(
|
||||
db, current_user, task, "text_import",
|
||||
candidate_id=tc.id,
|
||||
angle_label=body.angle_label or IMAGE_STRATEGY_ANGLE.get(strategy),
|
||||
)
|
||||
except Exception:
|
||||
logger.warning(
|
||||
"导入文案飞轮信号记录失败(不阻塞导入): task_id=%s candidate_id=%s",
|
||||
task_id, tc.id,
|
||||
)
|
||||
return ok(_fmt_text(tc))
|
||||
|
||||
|
||||
@@ -128,6 +163,18 @@ def regenerate(
|
||||
current_user.workspace_id,
|
||||
)
|
||||
body = body or RegenerateRequest()
|
||||
if str(task.status) == "generating" or getattr(task.status, "value", None) == "generating":
|
||||
raise_state_invalid("任务正在生成中,请等待本轮完成后再重生")
|
||||
import redis as redis_lib
|
||||
from app.core.config import get_settings
|
||||
|
||||
try:
|
||||
r = redis_lib.from_url(get_settings().REDIS_URL, decode_responses=True)
|
||||
except Exception as exc:
|
||||
logger.warning("检查重生锁失败,继续提交: task_id=%s err=%s", task_id, exc)
|
||||
else:
|
||||
if r.exists(f"pipeline:lock:{task_id}"):
|
||||
raise_state_invalid("任务正在重生中,请等待本轮完成后再操作")
|
||||
# role 必须配 strategy(单张重生需知道是哪套的哪张)
|
||||
if body.role and not body.strategy:
|
||||
raise_state_invalid("指定单张重生(role)时必须同时指定 strategy(套别)")
|
||||
@@ -138,6 +185,39 @@ def regenerate(
|
||||
return ok({"task_id": task_id, "status": "regenerating", "scope": scope})
|
||||
|
||||
|
||||
@router.post("/{task_id}/generate-images")
|
||||
def generate_images(
|
||||
task_id: int,
|
||||
current_user: Annotated[CurrentUser, Depends(require_write_permission)] = None,
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""导入轨「去生图」:复用库内导入文案,跳过文案生成,只生图(首次出图)。
|
||||
复用 regenerate 的 generating 守卫 + redis 锁防重复烧钱。"""
|
||||
from app.services.task_service import enqueue_generation
|
||||
task = _check_task_ownership(
|
||||
db.query(GenerationTask).filter(GenerationTask.id == task_id).first(),
|
||||
current_user.workspace_id,
|
||||
)
|
||||
if str(task.status) == "generating" or getattr(task.status, "value", None) == "generating":
|
||||
raise_state_invalid("任务正在生成中,请等待本轮完成")
|
||||
# 必须库内有导入文案才放行生图,避免空语境生图(质量崩)
|
||||
has_text = db.query(TextCandidate).filter(TextCandidate.task_id == task_id).count()
|
||||
if not has_text:
|
||||
raise_state_invalid("无可用文案,请先导入文案再去生图")
|
||||
import redis as redis_lib
|
||||
from app.core.config import get_settings
|
||||
|
||||
try:
|
||||
r = redis_lib.from_url(get_settings().REDIS_URL, decode_responses=True)
|
||||
except Exception as exc:
|
||||
logger.warning("检查生图锁失败,继续提交: task_id=%s err=%s", task_id, exc)
|
||||
else:
|
||||
if r.exists(f"pipeline:lock:{task_id}"):
|
||||
raise_state_invalid("任务正在生成中,请等待本轮完成")
|
||||
enqueue_generation(task.id, reuse_text=True)
|
||||
return ok({"task_id": task_id, "status": "generating"})
|
||||
|
||||
|
||||
@router.post("/{task_id}/submit-review")
|
||||
def submit_review(
|
||||
task_id: int,
|
||||
@@ -151,11 +231,40 @@ def submit_review(
|
||||
)
|
||||
if task.status != "pending_selection":
|
||||
raise_state_invalid("只有 pending_selection 状态可提审")
|
||||
_validate_strategy_selection(db, task)
|
||||
task.status = "pending_review"
|
||||
db.commit()
|
||||
return ok({"task_id": task_id, "status": "pending_review"})
|
||||
|
||||
|
||||
def _validate_strategy_selection(db: Session, task: GenerationTask) -> None:
|
||||
"""提审前最低门槛:至少 1 条已选文案 + 至少 1 张已选图。
|
||||
|
||||
三套 A/B/C 是「挑着选」——用户可只要其中一套、丢弃某套、或某套重新生成,
|
||||
不强制三套齐全、不强制每套选满(倩倩姐 2026-06-26 拍板)。
|
||||
"""
|
||||
from app.core.response import raise_business
|
||||
|
||||
selected_text_count = (
|
||||
db.query(TextCandidate.id)
|
||||
.filter(TextCandidate.task_id == task.id, TextCandidate.is_selected == True)
|
||||
.count()
|
||||
)
|
||||
selected_image_count = (
|
||||
db.query(ImageCandidate.id)
|
||||
.filter(ImageCandidate.task_id == task.id, ImageCandidate.is_selected == True)
|
||||
.count()
|
||||
)
|
||||
|
||||
parts = []
|
||||
if selected_text_count < 1:
|
||||
parts.append("请至少选择 1 条文案")
|
||||
if selected_image_count < 1:
|
||||
parts.append("请至少选择 1 张图片")
|
||||
if parts:
|
||||
raise_business(";".join(parts))
|
||||
|
||||
|
||||
@router.get("/{task_id}/preference/context")
|
||||
def get_preference_context(
|
||||
task_id: int,
|
||||
@@ -211,3 +320,45 @@ def edit_text(
|
||||
db.commit()
|
||||
record_signal(db, current_user, task, "text_edit", candidate_id=tc.id, angle_label=tc.angle_label)
|
||||
return ok({"candidate_id": candidate_id, "edited": True})
|
||||
|
||||
|
||||
@router.delete("/{task_id}/text-candidates/{candidate_id}")
|
||||
def delete_text_candidate(
|
||||
task_id: int, candidate_id: int,
|
||||
current_user: Annotated[CurrentUser, Depends(require_admin)] = None,
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""删除单条文案候选(仅管理员,物理删;倩倩姐2026-06-30拍板:删除权限只给管理员)。"""
|
||||
_check_task_ownership(
|
||||
db.query(GenerationTask).filter(GenerationTask.id == task_id).first(),
|
||||
current_user.workspace_id,
|
||||
)
|
||||
tc = db.query(TextCandidate).filter(
|
||||
TextCandidate.id == candidate_id, TextCandidate.task_id == task_id
|
||||
).first()
|
||||
if not tc:
|
||||
raise_not_found("文案候选不存在")
|
||||
db.delete(tc)
|
||||
db.commit()
|
||||
return ok({"deleted": candidate_id})
|
||||
|
||||
|
||||
@router.delete("/{task_id}/image-candidates/{candidate_id}")
|
||||
def delete_image_candidate(
|
||||
task_id: int, candidate_id: int,
|
||||
current_user: Annotated[CurrentUser, Depends(require_admin)] = None,
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""删除单张图片候选(仅管理员,物理删;倩倩姐2026-06-30拍板:删除权限只给管理员)。"""
|
||||
_check_task_ownership(
|
||||
db.query(GenerationTask).filter(GenerationTask.id == task_id).first(),
|
||||
current_user.workspace_id,
|
||||
)
|
||||
ic = db.query(ImageCandidate).filter(
|
||||
ImageCandidate.id == candidate_id, ImageCandidate.task_id == task_id
|
||||
).first()
|
||||
if not ic:
|
||||
raise_not_found("图片候选不存在")
|
||||
db.delete(ic)
|
||||
db.commit()
|
||||
return ok({"deleted": candidate_id})
|
||||
|
||||
@@ -13,7 +13,11 @@ 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_write_permission
|
||||
from app.middleware.workspace_guard import (
|
||||
CurrentUser,
|
||||
require_admin,
|
||||
require_write_permission,
|
||||
)
|
||||
from app.models.task import GenerationTask, ImageCandidate, TextCandidate
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -227,3 +231,42 @@ def get_task(
|
||||
"text_candidates": [_fmt_text(tc) for tc in texts],
|
||||
"image_candidates": [_fmt_image(ic) for ic in images],
|
||||
})
|
||||
|
||||
|
||||
@router.delete("/{task_id}")
|
||||
def delete_task(
|
||||
task_id: int,
|
||||
current_user: Annotated[CurrentUser, Depends(require_admin)] = None,
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""删除整个图文任务(仅管理员,倩倩姐2026-06-26拍板)。
|
||||
智能删:有成品(delivery_packages)→转归档保留(status=archived,不进看板);
|
||||
无成品(残次/废稿)→物理删(先清 ai_call_logs/preference_events 外键,候选 CASCADE)。
|
||||
"""
|
||||
from datetime import datetime
|
||||
|
||||
from app.models.flywheel import AiCallLog, PreferenceEvent
|
||||
from app.models.task import DeliveryPackage
|
||||
from app.constants.enums import TaskStatus
|
||||
|
||||
task = db.query(GenerationTask).filter(GenerationTask.id == task_id).first()
|
||||
task = _check_task_ownership(task, current_user.workspace_id)
|
||||
|
||||
has_package = (
|
||||
db.query(DeliveryPackage.id)
|
||||
.filter(DeliveryPackage.task_id == task_id)
|
||||
.first()
|
||||
is not None
|
||||
)
|
||||
if has_package:
|
||||
task.status = TaskStatus.ARCHIVED.value
|
||||
task.archived_at = datetime.utcnow()
|
||||
db.commit()
|
||||
return ok({"task_id": task_id, "action": "archived"})
|
||||
|
||||
# 物理删:先清无 CASCADE 的外键引用(候选表 CASCADE 自动删)
|
||||
db.query(AiCallLog).filter(AiCallLog.task_id == task_id).delete(synchronize_session=False)
|
||||
db.query(PreferenceEvent).filter(PreferenceEvent.task_id == task_id).delete(synchronize_session=False)
|
||||
db.delete(task)
|
||||
db.commit()
|
||||
return ok({"task_id": task_id, "action": "deleted"})
|
||||
|
||||
83
backend/app/api/v1/users.py
Normal file
83
backend/app/api/v1/users.py
Normal file
@@ -0,0 +1,83 @@
|
||||
"""
|
||||
app/api/v1/users.py — 管理员账号管理路由
|
||||
全部挂 require_admin;路由层只做参数校验→调 service→格式化响应。
|
||||
禁用=软删除可恢复(倩倩姐2026-06-30);不物理删除。
|
||||
"""
|
||||
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
from pydantic import BaseModel, field_validator
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.database import get_db
|
||||
from app.core.response import ok
|
||||
from app.middleware.workspace_guard import CurrentUser, require_admin
|
||||
from app.services import user_admin_service as svc
|
||||
|
||||
router = APIRouter(prefix="/users", tags=["users"])
|
||||
|
||||
|
||||
class CreateUserRequest(BaseModel):
|
||||
username: str
|
||||
email: str
|
||||
role: str
|
||||
init_password: str
|
||||
|
||||
@field_validator("username", "email", "role", "init_password")
|
||||
@classmethod
|
||||
def not_empty(cls, v: str) -> str:
|
||||
if not v or not v.strip():
|
||||
raise ValueError("不能为空")
|
||||
return v
|
||||
|
||||
|
||||
class SetActiveRequest(BaseModel):
|
||||
is_active: bool
|
||||
|
||||
|
||||
@router.get("")
|
||||
def list_users(
|
||||
current_user: Annotated[CurrentUser, Depends(require_admin)],
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""列出本 workspace 成员(含已禁用)。"""
|
||||
return ok(svc.list_workspace_users(db, current_user.workspace_id))
|
||||
|
||||
|
||||
@router.post("")
|
||||
def create_user(
|
||||
body: CreateUserRequest,
|
||||
current_user: Annotated[CurrentUser, Depends(require_admin)],
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""建号,强制首登改密。"""
|
||||
return ok(svc.create_workspace_user(
|
||||
db, current_user.workspace_id,
|
||||
body.username, body.email, body.role, body.init_password,
|
||||
))
|
||||
|
||||
|
||||
@router.post("/{user_id}/active")
|
||||
def set_active(
|
||||
user_id: int,
|
||||
body: SetActiveRequest,
|
||||
current_user: Annotated[CurrentUser, Depends(require_admin)],
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""启用/禁用账号(软删除,可随时恢复)。"""
|
||||
return ok(svc.set_user_active(
|
||||
db, current_user.workspace_id, user_id, body.is_active, current_user.user_id,
|
||||
))
|
||||
|
||||
|
||||
@router.delete("/{user_id}")
|
||||
def delete_user(
|
||||
user_id: int,
|
||||
current_user: Annotated[CurrentUser, Depends(require_admin)],
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""物理删除账号(不可恢复,与禁用区分)。"""
|
||||
return ok(svc.delete_workspace_user(
|
||||
db, current_user.workspace_id, user_id, current_user.user_id,
|
||||
))
|
||||
@@ -39,5 +39,6 @@ def switch_workspace(
|
||||
token = create_access_token(current_user.user_id, body.workspace_id, member.role)
|
||||
return ok({
|
||||
"current_workspace_id": body.workspace_id,
|
||||
"role": member.role,
|
||||
"token": token,
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user