baseline: Clover 独立仓库首次基线提交

将 Clover 从上层产品包旧仓库中独立出来,建立专属版本控制。
当前状态=纵切片端到端已打通(登录→选品→出文出图→审核→下载包),
M1文案质量去套路化已验收。此提交作为后续按核销清单逐条修复的基线。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
yangqianqian
2026-06-16 11:30:22 +08:00
commit 6a2632da70
253 changed files with 27467 additions and 0 deletions

View 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

View File

View 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 KeyFernet 加密存储只保存后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})

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

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

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

View 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 解密基石Bplain_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)

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

View 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 → 拿 ticket60s有效一次性
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 ticket60s 有效,仅对该 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",
},
)

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

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