baseline: Clover 独立仓库首次基线提交
将 Clover 从上层产品包旧仓库中独立出来,建立专属版本控制。 当前状态=纵切片端到端已打通(登录→选品→出文出图→审核→下载包), M1文案质量去套路化已验收。此提交作为后续按核销清单逐条修复的基线。 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
0
backend/app/api/__init__.py
Normal file
0
backend/app/api/__init__.py
Normal file
11
backend/app/api/v1/README.md
Normal file
11
backend/app/api/v1/README.md
Normal file
@@ -0,0 +1,11 @@
|
||||
# app/api/v1/
|
||||
|
||||
路由层占位,BE agent 按模块创建:
|
||||
- auth.py # 登录/me/切workspace
|
||||
- api_keys.py # API Key录入/列表/删除
|
||||
- products.py # 产品档案CRUD
|
||||
- benchmarks.py # 标杆笔记CRUD(挂在products下)
|
||||
- tasks.py # 生产任务(发起/列表/详情/SSE/选候选)
|
||||
- review.py # 组长审核(队列/通过/打回)
|
||||
- delivery.py # 交付包生成+下载
|
||||
- banned_words.py # 违禁词库CRUD
|
||||
0
backend/app/api/v1/__init__.py
Normal file
0
backend/app/api/v1/__init__.py
Normal file
130
backend/app/api/v1/api_keys.py
Normal file
130
backend/app/api/v1/api_keys.py
Normal file
@@ -0,0 +1,130 @@
|
||||
"""
|
||||
app/api/v1/api_keys.py — API Key 路由
|
||||
只录不显余额(CLAUDE.md 红线)。
|
||||
只返回 provider + key_last4,不返回 encrypted_key。
|
||||
url 字段不接收,token站固定自家站(架构方案§二)。
|
||||
"""
|
||||
|
||||
import logging
|
||||
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, raise_not_found
|
||||
from app.core.security import encrypt_api_key, mask_api_key
|
||||
from app.middleware.workspace_guard import CurrentUser, require_write_permission
|
||||
from app.models.workspace import UserApiKey
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(prefix="/api-keys", tags=["api-keys"])
|
||||
|
||||
|
||||
# ── DTO ────────────────────────────────────────────────────
|
||||
class CreateApiKeyRequest(BaseModel):
|
||||
provider: str
|
||||
api_key: str
|
||||
|
||||
@field_validator("provider")
|
||||
@classmethod
|
||||
def provider_not_empty(cls, v: str) -> str:
|
||||
if not v or not v.strip():
|
||||
raise ValueError("provider 不能为空")
|
||||
return v.strip().lower()
|
||||
|
||||
@field_validator("api_key")
|
||||
@classmethod
|
||||
def key_not_empty(cls, v: str) -> str:
|
||||
if not v or len(v) < 8:
|
||||
raise ValueError("api_key 无效")
|
||||
return v
|
||||
# 注:不接收 url 字段(token站固定自家站,基石B)
|
||||
|
||||
|
||||
def _format_key(k: UserApiKey) -> dict:
|
||||
"""只显 provider + key_last4,不暴露余额/用量/encrypted_key(红线)。"""
|
||||
return {
|
||||
"id": k.id,
|
||||
"provider": k.provider,
|
||||
"key_last4": k.key_last4,
|
||||
"created_at": k.created_at.isoformat(),
|
||||
}
|
||||
|
||||
|
||||
# ── 路由 ───────────────────────────────────────────────────
|
||||
@router.get("")
|
||||
def list_api_keys(
|
||||
current_user: Annotated[CurrentUser, Depends(require_write_permission)],
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""列出当前用户在此 workspace 的 API Key(只显后4位)。"""
|
||||
keys = (
|
||||
db.query(UserApiKey)
|
||||
.filter(
|
||||
UserApiKey.user_id == current_user.user_id,
|
||||
UserApiKey.workspace_id == current_user.workspace_id,
|
||||
)
|
||||
.all()
|
||||
)
|
||||
return ok({"items": [_format_key(k) for k in keys]})
|
||||
|
||||
|
||||
@router.post("")
|
||||
def create_api_key(
|
||||
body: CreateApiKeyRequest,
|
||||
current_user: Annotated[CurrentUser, Depends(require_write_permission)],
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""录入 API Key(Fernet 加密存储,只保存后4位明文用于展示)。"""
|
||||
from app.core.response import raise_business
|
||||
# 检查同 user+workspace+provider 是否已有
|
||||
existing = (
|
||||
db.query(UserApiKey)
|
||||
.filter(
|
||||
UserApiKey.user_id == current_user.user_id,
|
||||
UserApiKey.workspace_id == current_user.workspace_id,
|
||||
UserApiKey.provider == body.provider,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
if existing:
|
||||
raise_business(f"已存在 {body.provider} 的 API Key,请先删除再录入")
|
||||
|
||||
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),
|
||||
)
|
||||
db.add(key_obj)
|
||||
db.commit()
|
||||
db.refresh(key_obj)
|
||||
logger.info("API key created: user=%s provider=%s", current_user.user_id, body.provider)
|
||||
return ok(_format_key(key_obj))
|
||||
|
||||
|
||||
@router.delete("/{key_id}")
|
||||
def delete_api_key(
|
||||
key_id: int,
|
||||
current_user: Annotated[CurrentUser, Depends(require_write_permission)],
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""删除 API Key(只能删自己的)。"""
|
||||
key_obj = (
|
||||
db.query(UserApiKey)
|
||||
.filter(
|
||||
UserApiKey.id == key_id,
|
||||
UserApiKey.user_id == current_user.user_id,
|
||||
UserApiKey.workspace_id == current_user.workspace_id,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
if not key_obj:
|
||||
raise_not_found("API Key 不存在")
|
||||
db.delete(key_obj)
|
||||
db.commit()
|
||||
return ok({"deleted": key_id})
|
||||
69
backend/app/api/v1/auth.py
Normal file
69
backend/app/api/v1/auth.py
Normal file
@@ -0,0 +1,69 @@
|
||||
"""
|
||||
app/api/v1/auth.py — 认证路由
|
||||
路由层只做:参数校验 → 调 service → 格式化响应(不含业务逻辑)
|
||||
"""
|
||||
|
||||
import logging
|
||||
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, raise_unauthorized
|
||||
from app.core.security import create_access_token, decode_access_token
|
||||
from app.middleware.workspace_guard import CurrentUser, get_current_user
|
||||
from app.models.user import User
|
||||
from app.models.workspace import Workspace
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(prefix="/auth", tags=["auth"])
|
||||
|
||||
|
||||
# ── DTO ────────────────────────────────────────────────────
|
||||
class LoginRequest(BaseModel):
|
||||
username: str
|
||||
password: str
|
||||
|
||||
@field_validator("username", "password")
|
||||
@classmethod
|
||||
def not_empty(cls, v: str) -> str:
|
||||
if not v or not v.strip():
|
||||
raise ValueError("不能为空")
|
||||
return v
|
||||
|
||||
|
||||
# ── 路由 ───────────────────────────────────────────────────
|
||||
@router.post("/login")
|
||||
def login(body: LoginRequest, db: Session = Depends(get_db)):
|
||||
"""登录,返回 JWT。密码校验在 service 层(此处调用)。"""
|
||||
from app.services.auth_service import authenticate_user, build_user_response
|
||||
user, workspace_id, role = authenticate_user(db, body.username, body.password)
|
||||
token = create_access_token(user.id, workspace_id, role)
|
||||
return ok({
|
||||
"token": token,
|
||||
"user": build_user_response(user, workspace_id, role),
|
||||
})
|
||||
|
||||
|
||||
@router.get("/me")
|
||||
def get_me(
|
||||
current_user: Annotated[CurrentUser, Depends(get_current_user)],
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""当前用户信息 + workspace + role。"""
|
||||
user = db.query(User).filter(User.id == current_user.user_id).first()
|
||||
if not user:
|
||||
raise_unauthorized("用户不存在")
|
||||
ws = db.query(Workspace).filter(Workspace.id == current_user.workspace_id).first()
|
||||
return ok({
|
||||
"id": user.id,
|
||||
"username": user.username,
|
||||
"email": user.email,
|
||||
"current_workspace_id": current_user.workspace_id,
|
||||
"role": current_user.role,
|
||||
"workspace": {
|
||||
"id": ws.id, "name": ws.name, "slug": ws.slug,
|
||||
} if ws else None,
|
||||
})
|
||||
143
backend/app/api/v1/benchmarks.py
Normal file
143
backend/app/api/v1/benchmarks.py
Normal file
@@ -0,0 +1,143 @@
|
||||
"""
|
||||
app/api/v1/benchmarks.py — 标杆笔记 + 违禁词路由(管理员)
|
||||
主通道:截图+手填亮点(不做原始抓取)。
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
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
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(tags=["products"])
|
||||
|
||||
|
||||
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_benchmark(b: BenchmarkNote) -> dict:
|
||||
return {
|
||||
"id": b.id, "product_id": b.product_id,
|
||||
"screenshot_url": b.screenshot_url,
|
||||
"highlights": b.highlights, "link_url": b.link_url,
|
||||
"created_at": b.created_at.isoformat(),
|
||||
}
|
||||
|
||||
|
||||
def _fmt_banned(bw: BannedWord) -> dict:
|
||||
return {
|
||||
"id": bw.id, "word": bw.word, "level": bw.level,
|
||||
"replacement": bw.replacement, "updatable": bw.updatable,
|
||||
"workspace_id": bw.workspace_id,
|
||||
}
|
||||
|
||||
|
||||
# ── 标杆笔记 ────────────────────────────────────────────────
|
||||
@router.get("/products/{product_id}/benchmarks")
|
||||
def list_benchmarks(
|
||||
product_id: int,
|
||||
current_user: Annotated[CurrentUser, Depends(require_write_permission)] = None,
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
items = (
|
||||
db.query(BenchmarkNote)
|
||||
.filter(
|
||||
BenchmarkNote.product_id == product_id,
|
||||
BenchmarkNote.workspace_id == current_user.workspace_id,
|
||||
)
|
||||
.all()
|
||||
)
|
||||
return ok({"items": [_fmt_benchmark(b) for b in items]})
|
||||
|
||||
|
||||
@router.post("/products/{product_id}/benchmarks")
|
||||
def create_benchmark(
|
||||
product_id: int, body: BenchmarkCreate,
|
||||
current_user: Annotated[CurrentUser, Depends(require_admin)] = None,
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
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,
|
||||
)
|
||||
db.add(b); db.commit(); db.refresh(b)
|
||||
return ok(_fmt_benchmark(b))
|
||||
|
||||
|
||||
# ── 违禁词库 ────────────────────────────────────────────────
|
||||
@router.get("/banned-words")
|
||||
def list_banned_words(
|
||||
page: int = 1, page_size: int = 50,
|
||||
current_user: Annotated[CurrentUser, Depends(require_write_permission)] = None,
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
q = db.query(BannedWord).filter(BannedWord.workspace_id == current_user.workspace_id)
|
||||
total = q.count()
|
||||
items = q.offset((page - 1) * page_size).limit(page_size).all()
|
||||
return ok(paginate([_fmt_banned(bw) for bw in items], total, page, page_size))
|
||||
|
||||
|
||||
@router.post("/banned-words")
|
||||
def create_banned_word(
|
||||
body: BannedWordCreate,
|
||||
current_user: Annotated[CurrentUser, Depends(require_admin)] = None,
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
bw = BannedWord(
|
||||
workspace_id=current_user.workspace_id,
|
||||
word=body.word, level=body.level,
|
||||
replacement=body.replacement, updatable=body.updatable,
|
||||
)
|
||||
db.add(bw); db.commit(); db.refresh(bw)
|
||||
return ok(_fmt_banned(bw))
|
||||
|
||||
|
||||
@router.put("/banned-words/{word_id}")
|
||||
def update_banned_word(
|
||||
word_id: int, body: BannedWordCreate,
|
||||
current_user: Annotated[CurrentUser, Depends(require_admin)] = None,
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
bw = db.query(BannedWord).filter(BannedWord.id == word_id, BannedWord.workspace_id == current_user.workspace_id).first()
|
||||
if not bw:
|
||||
raise_not_found("违禁词不存在")
|
||||
if not bw.updatable:
|
||||
from app.core.response import raise_forbidden
|
||||
raise_forbidden("该违禁词不可修改")
|
||||
bw.word = body.word; bw.level = body.level
|
||||
bw.replacement = body.replacement; bw.updatable = body.updatable
|
||||
db.commit(); db.refresh(bw)
|
||||
return ok(_fmt_banned(bw))
|
||||
|
||||
|
||||
@router.delete("/banned-words/{word_id}")
|
||||
def delete_banned_word(
|
||||
word_id: int,
|
||||
current_user: Annotated[CurrentUser, Depends(require_admin)] = None,
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
bw = db.query(BannedWord).filter(BannedWord.id == word_id, BannedWord.workspace_id == current_user.workspace_id).first()
|
||||
if not bw:
|
||||
raise_not_found("违禁词不存在")
|
||||
db.delete(bw); db.commit()
|
||||
return ok({"deleted": word_id})
|
||||
184
backend/app/api/v1/delivery.py
Normal file
184
backend/app/api/v1/delivery.py
Normal file
@@ -0,0 +1,184 @@
|
||||
"""
|
||||
app/api/v1/delivery.py — 交付包路由
|
||||
POST /tasks/{id}/package — 生成达人素材交付包
|
||||
GET /delivery-packages/{id}/download — 下载(status: pending/ready/downloaded)
|
||||
POST /delivery-packages/{id}/download-token — 签发60s一次性下载令牌(C1坑修复)
|
||||
GET /delivery-packages/{id}/download-file?token= — 带令牌下载,前端可用 window.open
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends, Header, Query
|
||||
from fastapi.responses import FileResponse
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.database import get_db
|
||||
from app.core.response import ok, raise_not_found
|
||||
from app.middleware.workspace_guard import CurrentUser, require_write_permission
|
||||
from app.models.task import DeliveryPackage, GenerationTask
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(tags=["delivery"])
|
||||
|
||||
|
||||
class PackageRequest(BaseModel):
|
||||
note_ids: list[int] = [] # 可选,指定要打包的内容
|
||||
|
||||
|
||||
def _fmt_package(pkg: DeliveryPackage) -> dict:
|
||||
return {
|
||||
"id": pkg.id,
|
||||
"status": pkg.status,
|
||||
"download_url": pkg.download_url,
|
||||
"expires_at": pkg.expires_at.isoformat() if pkg.expires_at else None,
|
||||
}
|
||||
|
||||
|
||||
@router.post("/tasks/{task_id}/package")
|
||||
def create_package(
|
||||
task_id: int, body: PackageRequest,
|
||||
current_user: Annotated[CurrentUser, Depends(require_write_permission)] = None,
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
生成达人素材交付包。
|
||||
任务推入 Celery build_delivery_package 队列(只传 package_id)。
|
||||
"""
|
||||
task = db.query(GenerationTask).filter(
|
||||
GenerationTask.id == task_id,
|
||||
GenerationTask.workspace_id == current_user.workspace_id,
|
||||
).first()
|
||||
if not task:
|
||||
raise_not_found("任务不存在")
|
||||
if task.status not in ("approved", "pending_selection"):
|
||||
from app.core.response import raise_business
|
||||
raise_business("任务尚未生成完成,无法打包")
|
||||
|
||||
pkg = DeliveryPackage(
|
||||
workspace_id=current_user.workspace_id,
|
||||
task_id=task_id,
|
||||
status="pending",
|
||||
)
|
||||
db.add(pkg)
|
||||
db.commit()
|
||||
db.refresh(pkg)
|
||||
|
||||
# 推 Celery 队列,只传 package_id(基石B思路:不传任何敏感信息)
|
||||
from app.workers.tasks import build_delivery_package
|
||||
build_delivery_package.delay(pkg.id)
|
||||
|
||||
return ok({"delivery_package_id": pkg.id, "status": "pending"})
|
||||
|
||||
|
||||
@router.get("/delivery-packages/{package_id}/download")
|
||||
def get_package_download(
|
||||
package_id: int,
|
||||
current_user: Annotated[CurrentUser, Depends(require_write_permission)] = None,
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""下载交付包(status: pending/ready/downloaded)。"""
|
||||
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" and pkg.download_url:
|
||||
# 标记为 downloaded(只能下一次,防重复公开 URL)
|
||||
pkg.status = "downloaded"
|
||||
db.commit()
|
||||
|
||||
return ok(_fmt_package(pkg))
|
||||
|
||||
|
||||
@router.post("/delivery-packages/{package_id}/download-token")
|
||||
def create_download_token(
|
||||
package_id: int,
|
||||
current_user: Annotated[CurrentUser, Depends(require_write_permission)] = None,
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
C1坑修复:签发一次性下载令牌(60s有效)。
|
||||
前端先用 JWT 调此接口,再用 token 直接 window.open/fetch,无需在URL里传JWT。
|
||||
"""
|
||||
import secrets
|
||||
import redis as sync_redis
|
||||
from app.core.config import get_settings
|
||||
|
||||
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 not in ("ready", "downloaded"):
|
||||
from app.core.response import raise_business
|
||||
raise_business("交付包尚未准备好")
|
||||
|
||||
token = secrets.token_hex(32)
|
||||
r = sync_redis.from_url(get_settings().REDIS_URL, decode_responses=True)
|
||||
r.setex(f"dl_token:{token}", 60, str(package_id))
|
||||
return ok({"token": token, "expires_in": 60})
|
||||
|
||||
|
||||
@router.get("/delivery-packages/{package_id}/download-file")
|
||||
def download_package_file(
|
||||
package_id: int,
|
||||
token: str = Query(default=""),
|
||||
authorization: str = Header(default=""),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
直接下载交付包 zip 文件(FileResponse)。
|
||||
支持两种认证:
|
||||
1. ?token=<download-token>(前端 window.open 用,C1坑修复)
|
||||
2. Authorization: Bearer <JWT>(API 调用用)
|
||||
"""
|
||||
import os
|
||||
import redis as sync_redis
|
||||
from app.core.config import get_settings
|
||||
from app.core.security import decode_access_token
|
||||
import jwt as pyjwt
|
||||
|
||||
# token 认证(一次性,60s有效,跳过JWT)
|
||||
if token:
|
||||
r = sync_redis.from_url(get_settings().REDIS_URL, decode_responses=True)
|
||||
stored = r.getdel(f"dl_token:{token}")
|
||||
if not stored or int(stored) != package_id:
|
||||
from app.core.response import raise_business
|
||||
raise_business("下载令牌无效或已过期")
|
||||
pkg = db.query(DeliveryPackage).filter(DeliveryPackage.id == package_id).first()
|
||||
else:
|
||||
# JWT 认证
|
||||
if not authorization or not authorization.startswith("Bearer "):
|
||||
from app.core.response import raise_unauthorized
|
||||
raise_unauthorized("缺少认证信息")
|
||||
try:
|
||||
payload = decode_access_token(authorization.split(" ", 1)[1])
|
||||
except (pyjwt.PyJWTError, Exception):
|
||||
from app.core.response import raise_unauthorized
|
||||
raise_unauthorized("Token 无效")
|
||||
workspace_id = int(payload["current_workspace_id"])
|
||||
pkg = db.query(DeliveryPackage).filter(
|
||||
DeliveryPackage.id == package_id,
|
||||
DeliveryPackage.workspace_id == workspace_id,
|
||||
).first()
|
||||
|
||||
if not pkg:
|
||||
raise_not_found("交付包不存在")
|
||||
if pkg.status not in ("ready", "downloaded"):
|
||||
from app.core.response import raise_business
|
||||
raise_business("交付包尚未准备好,请稍后重试")
|
||||
if not pkg.package_path or not os.path.exists(pkg.package_path):
|
||||
from app.core.response import raise_business
|
||||
raise_business("交付包文件不存在,请重新打包")
|
||||
|
||||
filename = os.path.basename(pkg.package_path)
|
||||
return FileResponse(
|
||||
path=pkg.package_path,
|
||||
media_type="application/zip",
|
||||
filename=filename,
|
||||
)
|
||||
308
backend/app/api/v1/products.py
Normal file
308
backend/app/api/v1/products.py
Normal file
@@ -0,0 +1,308 @@
|
||||
"""
|
||||
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
|
||||
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
|
||||
|
||||
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
|
||||
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,
|
||||
"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_admin)] = 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.put("/products/{product_id}")
|
||||
def update_product(
|
||||
product_id: int, body: ProductCreate,
|
||||
current_user: Annotated[CurrentUser, Depends(require_admin)] = 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),
|
||||
):
|
||||
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 # 软删
|
||||
db.commit()
|
||||
return ok({"deleted": product_id})
|
||||
|
||||
|
||||
# ── 产品参考图上传 ──────────────────────────────────────────
|
||||
_ALLOWED_CONTENT_TYPES = {"image/jpeg", "image/png", "image/webp"}
|
||||
_MAX_SIZE_BYTES = 10 * 1024 * 1024 # 10 MB
|
||||
|
||||
|
||||
@router.post("/products/{product_id}/upload-image")
|
||||
async def upload_product_image(
|
||||
product_id: int,
|
||||
file: UploadFile = File(...),
|
||||
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}
|
||||
写回 product.image_path,生图管道读此字段构建 reference_images。
|
||||
"""
|
||||
from app.core.response import raise_business
|
||||
from app.core.config import get_settings
|
||||
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("产品不存在")
|
||||
|
||||
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 限制")
|
||||
|
||||
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)
|
||||
|
||||
p.image_path = save_path # 绝对路径,worker 直接 open 可读
|
||||
db.commit()
|
||||
db.refresh(p)
|
||||
logger.info("product image uploaded: product_id=%s path=%s", product_id, 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 解密(基石B);plain_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 限制")
|
||||
|
||||
# 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)
|
||||
|
||||
clients = build_ai_clients(plain_key)
|
||||
plain_key = None # 立即清零,不传出(基石B)
|
||||
|
||||
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)
|
||||
140
backend/app/api/v1/review.py
Normal file
140
backend/app/api/v1/review.py
Normal file
@@ -0,0 +1,140 @@
|
||||
"""
|
||||
app/api/v1/review.py — 审核路由(组长)
|
||||
通过(+5,最重) / 打回(写原因,-3,回 pending_selection)
|
||||
打回原因存 preference_events.reason,不做 AI 归纳。
|
||||
"""
|
||||
|
||||
import logging
|
||||
from datetime import datetime, timezone
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
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, raise_state_invalid
|
||||
from app.middleware.workspace_guard import CurrentUser, require_supervisor_or_above
|
||||
from app.models.task import GenerationTask, ImageCandidate, TextCandidate
|
||||
from app.models.user import User
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(prefix="/review", tags=["review"])
|
||||
|
||||
|
||||
class RejectRequest(BaseModel):
|
||||
reason: str # 原文存入 preference_events,不做 AI 归纳(契约§3)
|
||||
|
||||
|
||||
@router.get("/queue")
|
||||
def review_queue(
|
||||
page: int = 1, page_size: int = 20,
|
||||
current_user: Annotated[CurrentUser, Depends(require_supervisor_or_above)] = None,
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""待审队列(pending_review 状态)。"""
|
||||
q = (
|
||||
db.query(GenerationTask)
|
||||
.filter(
|
||||
GenerationTask.workspace_id == current_user.workspace_id,
|
||||
GenerationTask.status == "pending_review",
|
||||
)
|
||||
.order_by(GenerationTask.updated_at.asc())
|
||||
)
|
||||
total = q.count()
|
||||
tasks = q.offset((page - 1) * page_size).limit(page_size).all()
|
||||
items = [_fmt_queue_item(t, db) for t in tasks]
|
||||
return ok(paginate(items, total, page, page_size))
|
||||
|
||||
|
||||
def _fmt_queue_item(t: GenerationTask, db: Session) -> dict:
|
||||
import json
|
||||
from app.models.product import Product
|
||||
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(
|
||||
TextCandidate.task_id == t.id, TextCandidate.is_selected == True
|
||||
).first()
|
||||
selected_image = db.query(ImageCandidate).filter(
|
||||
ImageCandidate.task_id == t.id, ImageCandidate.is_selected == True
|
||||
).first()
|
||||
|
||||
# content 列存 JSON dict,解包取 title/content 分字段返回
|
||||
text_parsed: dict = {}
|
||||
if selected_text and selected_text.content:
|
||||
try:
|
||||
text_parsed = json.loads(selected_text.content)
|
||||
except (json.JSONDecodeError, ValueError):
|
||||
text_parsed = {"content": selected_text.content}
|
||||
|
||||
return {
|
||||
"task_id": t.id,
|
||||
"product_name": product.name if product else None,
|
||||
"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", []),
|
||||
} 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,
|
||||
}
|
||||
|
||||
|
||||
@router.post("/{task_id}/approve")
|
||||
def approve_task(
|
||||
task_id: int,
|
||||
current_user: Annotated[CurrentUser, Depends(require_supervisor_or_above)] = None,
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""通过(飞轮 +5,最重,状态 → approved)。"""
|
||||
from app.services.flywheel_service import record_signal
|
||||
task = db.query(GenerationTask).filter(
|
||||
GenerationTask.id == task_id,
|
||||
GenerationTask.workspace_id == current_user.workspace_id,
|
||||
).first()
|
||||
if not task:
|
||||
raise_not_found("任务不存在")
|
||||
if task.status != "pending_review":
|
||||
raise_state_invalid("只有 pending_review 状态可审核")
|
||||
task.status = "approved"
|
||||
task.review_status = "approved"
|
||||
task.reviewer_id = current_user.user_id
|
||||
task.reviewed_at = datetime.now(timezone.utc)
|
||||
task.approved_at = datetime.now(timezone.utc)
|
||||
db.commit()
|
||||
record_signal(db, current_user, task, "approve")
|
||||
return ok({"task_id": task_id, "status": "approved"})
|
||||
|
||||
|
||||
@router.post("/{task_id}/reject")
|
||||
def reject_task(
|
||||
task_id: int, body: RejectRequest,
|
||||
current_user: Annotated[CurrentUser, Depends(require_supervisor_or_above)] = None,
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""打回(飞轮 -3,回 pending_selection,原因下次注入 prompt)。"""
|
||||
from app.services.flywheel_service import record_signal
|
||||
task = db.query(GenerationTask).filter(
|
||||
GenerationTask.id == task_id,
|
||||
GenerationTask.workspace_id == current_user.workspace_id,
|
||||
).first()
|
||||
if not task:
|
||||
raise_not_found("任务不存在")
|
||||
if task.status != "pending_review":
|
||||
raise_state_invalid("只有 pending_review 状态可打回")
|
||||
task.status = "pending_selection" # 打回回 pending_selection
|
||||
task.review_status = "rejected"
|
||||
task.reviewer_id = current_user.user_id
|
||||
task.reviewed_at = datetime.now(timezone.utc)
|
||||
task.reject_reason = body.reason
|
||||
db.commit()
|
||||
record_signal(db, current_user, task, "reject_with_reason", reason=body.reason)
|
||||
return ok({"task_id": task_id, "status": "pending_selection"})
|
||||
97
backend/app/api/v1/stream.py
Normal file
97
backend/app/api/v1/stream.py
Normal file
@@ -0,0 +1,97 @@
|
||||
"""
|
||||
app/api/v1/stream.py — SSE ticket 签发 + SSE 流路由
|
||||
安全红线(倩倩姐2026-06-08拍板):SSE 认证用一次性短票 ticket,禁止 ?token= 直传 JWT。
|
||||
|
||||
流程:
|
||||
1. FE 带 JWT 调 POST /tasks/{id}/sse-ticket → 拿 ticket(60s有效,一次性)
|
||||
2. FE 用 ticket 建 EventSource: GET /tasks/{id}/stream?ticket=<ticket>
|
||||
3. BE 验 ticket(原子 getdel),用后即失效,换不了其他接口
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import Annotated
|
||||
|
||||
import redis as sync_redis
|
||||
import redis.asyncio as aioredis
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from fastapi.responses import StreamingResponse
|
||||
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, raise_not_found, raise_unauthorized
|
||||
from app.core.sse_ticket import consume_ticket, issue_ticket
|
||||
from app.middleware.workspace_guard import CurrentUser, get_current_user
|
||||
from app.models.task import GenerationTask
|
||||
from app.utils.sse_utils import stream_events
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter()
|
||||
settings = get_settings()
|
||||
|
||||
|
||||
# ── 签发接口(用 JWT 换 ticket)────────────────────────────
|
||||
@router.post("/tasks/{task_id}/sse-ticket")
|
||||
def create_sse_ticket(
|
||||
task_id: int,
|
||||
current_user: Annotated[CurrentUser, Depends(get_current_user)],
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
签发一次性 SSE ticket(60s 有效,仅对该 task 有效)。
|
||||
FE 拿到 ticket 后立即建 EventSource,不要存起来复用。
|
||||
"""
|
||||
task = (
|
||||
db.query(GenerationTask)
|
||||
.filter(
|
||||
GenerationTask.id == task_id,
|
||||
GenerationTask.workspace_id == current_user.workspace_id,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
if not task:
|
||||
raise_not_found("任务不存在")
|
||||
|
||||
r = sync_redis.from_url(settings.REDIS_URL, decode_responses=True)
|
||||
ticket = issue_ticket(r, task_id, current_user.workspace_id)
|
||||
return ok({"ticket": ticket, "expires_in": 60})
|
||||
|
||||
|
||||
# ── SSE 流(ticket 认证,绝不传 JWT)─────────────────────
|
||||
@router.get("/tasks/{task_id}/stream")
|
||||
async def task_stream(
|
||||
task_id: int,
|
||||
ticket: str = Query(...),
|
||||
last_seq: int = Query(default=0),
|
||||
):
|
||||
"""
|
||||
SSE 实时进度推送。
|
||||
ticket 验证:原子 getdel,用后即失效,无法重放、无法访问其他接口。
|
||||
支持断线重连补发(?last_seq=<上次收到的seq>)。
|
||||
断线重连时 FE 须重新调 sse-ticket 换新 ticket。
|
||||
"""
|
||||
r_sync = sync_redis.from_url(settings.REDIS_URL, decode_responses=True)
|
||||
result = consume_ticket(r_sync, ticket)
|
||||
if not result:
|
||||
raise_unauthorized("ticket 无效或已过期")
|
||||
|
||||
verified_task_id, workspace_id = result
|
||||
if verified_task_id != task_id:
|
||||
raise_unauthorized("ticket 与任务不匹配")
|
||||
|
||||
redis_async = aioredis.from_url(settings.REDIS_URL, decode_responses=True)
|
||||
|
||||
return StreamingResponse(
|
||||
stream_events(
|
||||
redis_client=redis_async,
|
||||
task_id=task_id,
|
||||
workspace_id=workspace_id,
|
||||
last_seq=last_seq,
|
||||
),
|
||||
media_type="text/event-stream",
|
||||
headers={
|
||||
"Cache-Control": "no-cache",
|
||||
"X-Accel-Buffering": "no",
|
||||
"Connection": "keep-alive",
|
||||
},
|
||||
)
|
||||
146
backend/app/api/v1/task_actions.py
Normal file
146
backend/app/api/v1/task_actions.py
Normal file
@@ -0,0 +1,146 @@
|
||||
"""
|
||||
app/api/v1/task_actions.py — 任务操作端点
|
||||
选文案 / 选图 / 导入文案 / 重新生成 / 提交审核 / 偏好上下文
|
||||
DTOs 和辅助函数从 tasks.py 导入。
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
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.models.task import GenerationTask, ImageCandidate, TextCandidate
|
||||
|
||||
from app.api.v1.tasks import (
|
||||
SelectCandidateRequest,
|
||||
ImportTextRequest,
|
||||
_fmt_text,
|
||||
_fmt_image,
|
||||
_check_task_ownership,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(prefix="/tasks", tags=["tasks"])
|
||||
|
||||
|
||||
@router.post("/{task_id}/text-candidates/select")
|
||||
def select_text(
|
||||
task_id: int, body: SelectCandidateRequest,
|
||||
current_user: Annotated[CurrentUser, Depends(require_write_permission)] = None,
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""选文案(飞轮信号 text_select +3)。"""
|
||||
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,
|
||||
)
|
||||
tc = db.query(TextCandidate).filter(
|
||||
TextCandidate.id == body.candidate_id, TextCandidate.task_id == task_id
|
||||
).first()
|
||||
if not tc:
|
||||
raise_not_found("文案候选不存在")
|
||||
tc.is_selected = True
|
||||
db.commit()
|
||||
record_signal(db, current_user, task, "text_select", candidate_id=tc.id, angle_label=tc.angle_label)
|
||||
return ok({"selected": body.candidate_id})
|
||||
|
||||
|
||||
@router.post("/{task_id}/image-candidates/select")
|
||||
def select_image(
|
||||
task_id: int, body: SelectCandidateRequest,
|
||||
current_user: Annotated[CurrentUser, Depends(require_write_permission)] = None,
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""选图(飞轮信号 image_select +3)。"""
|
||||
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,
|
||||
)
|
||||
ic = db.query(ImageCandidate).filter(
|
||||
ImageCandidate.id == body.candidate_id, ImageCandidate.task_id == task_id
|
||||
).first()
|
||||
if not ic:
|
||||
raise_not_found("图片候选不存在")
|
||||
ic.is_selected = True
|
||||
db.commit()
|
||||
record_signal(db, current_user, task, "image_select", candidate_id=ic.id)
|
||||
return ok({"selected": body.candidate_id})
|
||||
|
||||
|
||||
@router.post("/{task_id}/import-text")
|
||||
def import_text(
|
||||
task_id: int, body: ImportTextRequest,
|
||||
current_user: Annotated[CurrentUser, Depends(require_write_permission)] = None,
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""轨B:导入外部文案直接进候选池,跳过 AI 生成(洞2)。"""
|
||||
_check_task_ownership(
|
||||
db.query(GenerationTask).filter(GenerationTask.id == task_id).first(),
|
||||
current_user.workspace_id,
|
||||
)
|
||||
tc = TextCandidate(
|
||||
workspace_id=current_user.workspace_id, task_id=task_id,
|
||||
source="import", content=body.content, angle_label=body.angle_label,
|
||||
)
|
||||
db.add(tc)
|
||||
db.commit()
|
||||
db.refresh(tc)
|
||||
return ok(_fmt_text(tc))
|
||||
|
||||
|
||||
@router.post("/{task_id}/regenerate")
|
||||
def regenerate(
|
||||
task_id: int,
|
||||
current_user: Annotated[CurrentUser, Depends(require_write_permission)] = None,
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""重新生成(飞轮信号 regenerate -1)。"""
|
||||
from app.services.flywheel_service import record_signal
|
||||
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,
|
||||
)
|
||||
record_signal(db, current_user, task, "regenerate")
|
||||
enqueue_generation(task.id)
|
||||
return ok({"task_id": task_id, "status": "regenerating"})
|
||||
|
||||
|
||||
@router.post("/{task_id}/submit-review")
|
||||
def submit_review(
|
||||
task_id: int,
|
||||
current_user: Annotated[CurrentUser, Depends(require_write_permission)] = None,
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""提交审核(状态 pending_selection → pending_review)。"""
|
||||
task = _check_task_ownership(
|
||||
db.query(GenerationTask).filter(GenerationTask.id == task_id).first(),
|
||||
current_user.workspace_id,
|
||||
)
|
||||
if task.status != "pending_selection":
|
||||
raise_state_invalid("只有 pending_selection 状态可提审")
|
||||
task.status = "pending_review"
|
||||
db.commit()
|
||||
return ok({"task_id": task_id, "status": "pending_review"})
|
||||
|
||||
|
||||
@router.get("/{task_id}/preference/context")
|
||||
def get_preference_context(
|
||||
task_id: int,
|
||||
current_user: Annotated[CurrentUser, Depends(require_write_permission)] = None,
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""取本任务飞轮偏好上下文(最近选中样本+打回原因)。"""
|
||||
task = _check_task_ownership(
|
||||
db.query(GenerationTask).filter(GenerationTask.id == task_id).first(),
|
||||
current_user.workspace_id,
|
||||
)
|
||||
from app.services.flywheel_service import get_preference_context
|
||||
ctx = get_preference_context(db, current_user.workspace_id, task.product_id)
|
||||
return ok(ctx)
|
||||
191
backend/app/api/v1/tasks.py
Normal file
191
backend/app/api/v1/tasks.py
Normal file
@@ -0,0 +1,191 @@
|
||||
"""
|
||||
app/api/v1/tasks.py — 任务路由(查询层)
|
||||
发起任务 / 任务列表 / 任务详情
|
||||
操作类端点(选文案/选图/导入/重生成/提审/偏好上下文)在 task_actions.py。
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from pydantic import BaseModel, field_validator
|
||||
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.models.task import GenerationTask, ImageCandidate, TextCandidate
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(prefix="/tasks", tags=["tasks"])
|
||||
|
||||
|
||||
# ── DTO ────────────────────────────────────────────────────
|
||||
class CreateTaskRequest(BaseModel):
|
||||
product_id: int
|
||||
benchmark_ids: list[int] = []
|
||||
theme: str | None = None
|
||||
text_count: int = 5 # 不写死(基石A),用户设定
|
||||
image_count: int = 3 # 不写死(基石A),用户设定
|
||||
track: str = "ai" # ai | import
|
||||
need_product_image: bool = True # 本次产品是否入镜:True=无图禁生成不降级
|
||||
|
||||
@field_validator("text_count", "image_count")
|
||||
@classmethod
|
||||
def positive(cls, v: int) -> int:
|
||||
if v < 1 or v > 20:
|
||||
raise ValueError("数量范围 1-20")
|
||||
return v
|
||||
|
||||
@field_validator("track")
|
||||
@classmethod
|
||||
def valid_track(cls, v: str) -> str:
|
||||
if v not in ("ai", "import"):
|
||||
raise ValueError("track 必须是 ai 或 import")
|
||||
return v
|
||||
|
||||
|
||||
class SelectCandidateRequest(BaseModel):
|
||||
candidate_id: int
|
||||
|
||||
|
||||
class ImportTextRequest(BaseModel):
|
||||
content: str
|
||||
angle_label: str | None = None
|
||||
|
||||
|
||||
# ── 格式化辅助 ──────────────────────────────────────────────
|
||||
def _fmt_task(t: GenerationTask) -> dict:
|
||||
return {
|
||||
"id": t.id, "product_id": t.product_id, "theme": t.theme,
|
||||
"status": t.status, "text_count": t.text_count, "image_count": t.image_count,
|
||||
"track": t.track, "need_product_image": t.need_product_image,
|
||||
"created_at": t.created_at.isoformat(),
|
||||
}
|
||||
|
||||
|
||||
def _score_array_to_obj(arr) -> dict:
|
||||
"""score_json 是评分明细数组 [{item,score,max,note}]。
|
||||
AI评委7维(2026-06-15新版:痛点18/情绪18/买点18/钩子15/标题13/真实感13+合规5)
|
||||
或旧机械5维均可处理。
|
||||
total 直接对明细求和(不依赖固定key),透传 dims 给前端 ScoreDimBars 数据驱动渲染。
|
||||
同时填旧5维key保持兼容(能映射的填,映射不上的为0)。"""
|
||||
# 旧5维 + 新7维维度名 → 旧key 的映射表(兼容新旧两套维度名)
|
||||
legacy = {
|
||||
# 旧机械维度
|
||||
"标题吸引力": "title", "标题点击力": "title",
|
||||
"情绪共鸣": "emotion", "情绪张力": "emotion",
|
||||
"买点表达": "selling", "买点转化": "selling",
|
||||
"关键词覆盖": "keyword", "合规性": "compliance",
|
||||
# 新AI评委维度(2026-06-15)→ 最近似旧key
|
||||
"痛点人群精准": "keyword", # 痛点人群精准语义最接近旧关键词(均为"内容精准度")
|
||||
"开头钩子": "emotion", # 钩子即情绪抓点,归入 emotion
|
||||
"真实感": "selling", # 真实感/买点转化同属"用户感知"维度
|
||||
# "产品聚焦一件事" 已被"真实感"替换,保留映射避免旧存量数据报 KeyError
|
||||
"产品聚焦一件事": "selling",
|
||||
}
|
||||
obj = {"title": 0, "emotion": 0, "selling": 0, "keyword": 0, "compliance": 0,
|
||||
"total": 0, "dims": []}
|
||||
if isinstance(arr, list):
|
||||
total = 0
|
||||
for d in arr:
|
||||
if not isinstance(d, dict):
|
||||
continue
|
||||
item = d.get("item", "")
|
||||
sc = d.get("score", 0) or 0
|
||||
total += sc
|
||||
obj["dims"].append({"item": item, "score": sc,
|
||||
"max": d.get("max", 0), "note": d.get("note", "")})
|
||||
key = legacy.get(item)
|
||||
if key:
|
||||
obj[key] = sc
|
||||
obj["total"] = max(0, min(100, total))
|
||||
return obj
|
||||
|
||||
|
||||
def _fmt_text(tc: TextCandidate) -> dict:
|
||||
import json
|
||||
# content 列存整条 JSON dict(含 title/content/tags/angle 等),解包后分字段返回
|
||||
parsed: dict = {}
|
||||
if tc.content:
|
||||
try:
|
||||
parsed = json.loads(tc.content)
|
||||
except (json.JSONDecodeError, ValueError):
|
||||
# 兼容旧数据:如果 content 已经是纯正文则原样返回
|
||||
parsed = {"content": tc.content}
|
||||
score_raw = json.loads(tc.score_json) if tc.score_json else None
|
||||
return {
|
||||
"candidate_id": tc.id,
|
||||
"angle_label": tc.angle_label,
|
||||
"title": parsed.get("title", ""),
|
||||
"content": parsed.get("content", ""), # 纯正文,前端直接展示
|
||||
"tags": parsed.get("tags", []),
|
||||
"cover_title": parsed.get("coverTitle", ""),
|
||||
"image_brief": parsed.get("imageBrief", ""),
|
||||
"source": tc.source,
|
||||
"score": _score_array_to_obj(score_raw), # 转成 {title,emotion,...,total,dims} 对象
|
||||
"banned_word_status": tc.banned_word_status,
|
||||
"is_selected": tc.is_selected,
|
||||
# AI 评委总评(verdict/summary 存在 content 列,新数据有,旧数据为空字符串降级)
|
||||
"verdict": parsed.get("verdict", ""), # "优秀"|"合格"|"不合格"
|
||||
"summary": parsed.get("summary", ""), # 一句话总评含改进点
|
||||
}
|
||||
|
||||
|
||||
def _fmt_image(ic: ImageCandidate) -> dict:
|
||||
return {
|
||||
"candidate_id": ic.id, "role": ic.role, "url": ic.url,
|
||||
"strategy": ic.strategy, "seq": ic.seq, "is_selected": ic.is_selected,
|
||||
}
|
||||
|
||||
|
||||
def _check_task_ownership(task: GenerationTask | None, workspace_id: int) -> GenerationTask:
|
||||
if not task or task.workspace_id != workspace_id:
|
||||
raise_not_found("任务不存在")
|
||||
return task
|
||||
|
||||
|
||||
# ── 路由 ───────────────────────────────────────────────────
|
||||
@router.post("")
|
||||
def create_task(
|
||||
body: CreateTaskRequest,
|
||||
current_user: Annotated[CurrentUser, Depends(require_write_permission)],
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""发起任务:校验有无 key → 只推 task_id 入队,绝不传 key。"""
|
||||
from app.services.task_service import create_generation_task
|
||||
task = create_generation_task(db, current_user, body)
|
||||
return ok(_fmt_task(task))
|
||||
|
||||
|
||||
@router.get("")
|
||||
def list_tasks(
|
||||
page: int = 1, page_size: int = 20,
|
||||
status: list[str] | None = Query(default=None),
|
||||
current_user: Annotated[CurrentUser, Depends(require_write_permission)] = None,
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
q = db.query(GenerationTask).filter(GenerationTask.workspace_id == current_user.workspace_id)
|
||||
if status:
|
||||
# 支持多状态(?status=approved&status=rejected),单值也兼容
|
||||
q = q.filter(GenerationTask.status.in_(status))
|
||||
total = q.count()
|
||||
items = q.order_by(GenerationTask.created_at.desc()).offset((page - 1) * page_size).limit(page_size).all()
|
||||
return ok(paginate([_fmt_task(t) for t in items], total, page, page_size))
|
||||
|
||||
|
||||
@router.get("/{task_id}")
|
||||
def get_task(
|
||||
task_id: int,
|
||||
current_user: Annotated[CurrentUser, Depends(require_write_permission)] = None,
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
task = db.query(GenerationTask).filter(GenerationTask.id == task_id).first()
|
||||
task = _check_task_ownership(task, current_user.workspace_id)
|
||||
texts = db.query(TextCandidate).filter(TextCandidate.task_id == task_id).all()
|
||||
images = db.query(ImageCandidate).filter(ImageCandidate.task_id == task_id).all()
|
||||
return ok({
|
||||
**_fmt_task(task),
|
||||
"text_candidates": [_fmt_text(tc) for tc in texts],
|
||||
"image_candidates": [_fmt_image(ic) for ic in images],
|
||||
})
|
||||
43
backend/app/api/v1/workspaces.py
Normal file
43
backend/app/api/v1/workspaces.py
Normal file
@@ -0,0 +1,43 @@
|
||||
"""
|
||||
app/api/v1/workspaces.py — workspace 切换路由
|
||||
POST /workspaces/switch → /api/v1/workspaces/switch(契约路径)
|
||||
从 auth.py 独立出来,避免 /auth 前缀错误。
|
||||
"""
|
||||
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.security import create_access_token
|
||||
from app.core.database import get_db
|
||||
from app.core.response import ok, raise_forbidden
|
||||
from app.middleware.workspace_guard import CurrentUser, get_current_user
|
||||
from app.models.workspace import WorkspaceMember
|
||||
|
||||
router = APIRouter(tags=["workspaces"])
|
||||
|
||||
|
||||
class SwitchWorkspaceRequest(BaseModel):
|
||||
workspace_id: int
|
||||
|
||||
|
||||
@router.post("/workspaces/switch")
|
||||
def switch_workspace(
|
||||
body: SwitchWorkspaceRequest,
|
||||
current_user: Annotated[CurrentUser, Depends(get_current_user)],
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""切换当前 workspace(必须查 membership 校验)。"""
|
||||
member = db.query(WorkspaceMember).filter(
|
||||
WorkspaceMember.user_id == current_user.user_id,
|
||||
WorkspaceMember.workspace_id == body.workspace_id,
|
||||
).first()
|
||||
if not member:
|
||||
raise_forbidden("无权访问目标 workspace")
|
||||
token = create_access_token(current_user.user_id, body.workspace_id, member.role)
|
||||
return ok({
|
||||
"current_workspace_id": body.workspace_id,
|
||||
"token": token,
|
||||
})
|
||||
Reference in New Issue
Block a user