上线版: 产品表单统一+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

@@ -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 KeyFernet 加密存储只保存后4位明文用于展示"""
from app.core.response import raise_business
"""录入/更新 API KeyFernet 加密存储只保存后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,

View File

@@ -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,

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

View File

@@ -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,

View File

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

View File

@@ -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,
}

View File

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

View File

@@ -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"})

View 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,
))

View File

@@ -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,
})