451 lines
14 KiB
Python
451 lines
14 KiB
Python
"""内容管理 CRUD API — /api/v1/contents/"""
|
||
import uuid
|
||
from typing import Optional
|
||
|
||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||
from pydantic import BaseModel, Field
|
||
from sqlalchemy import select, func, update
|
||
from sqlalchemy.ext.asyncio import AsyncSession
|
||
|
||
from app.api.deps import get_current_user
|
||
from app.database import get_db
|
||
from app.models.content import Content, ContentVersion
|
||
from app.models.brand_knowledge import BrandKnowledge
|
||
from app.models.user import User
|
||
|
||
router = APIRouter()
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Pydantic Schemas
|
||
# ---------------------------------------------------------------------------
|
||
|
||
class ContentCreateRequest(BaseModel):
|
||
title: str = Field(..., max_length=500)
|
||
body: str
|
||
content_type: str = Field(default="article", max_length=50)
|
||
project_id: Optional[str] = None
|
||
tags: Optional[list[str]] = None
|
||
|
||
|
||
class ContentUpdateRequest(BaseModel):
|
||
title: Optional[str] = Field(None, max_length=500)
|
||
body: Optional[str] = None
|
||
content_type: Optional[str] = Field(None, max_length=50)
|
||
status: Optional[str] = Field(None, max_length=20)
|
||
tags: Optional[list[str]] = None
|
||
|
||
|
||
class ContentResponse(BaseModel):
|
||
id: str
|
||
title: str
|
||
body: Optional[str]
|
||
content_type: str
|
||
status: str
|
||
project_id: Optional[str]
|
||
author_id: Optional[str]
|
||
tags: Optional[list[str]]
|
||
created_at: str
|
||
updated_at: str
|
||
published_at: Optional[str] = None
|
||
|
||
model_config = {"from_attributes": True}
|
||
|
||
|
||
class BrandKnowledgeCreateRequest(BaseModel):
|
||
category: str = Field(..., max_length=50)
|
||
title: str = Field(..., max_length=200)
|
||
body: str
|
||
source: Optional[str] = None
|
||
|
||
|
||
class BrandKnowledgeUpdateRequest(BaseModel):
|
||
category: Optional[str] = Field(None, max_length=50)
|
||
title: Optional[str] = Field(None, max_length=200)
|
||
body: Optional[str] = None
|
||
source: Optional[str] = None
|
||
|
||
|
||
class BrandKnowledgeResponse(BaseModel):
|
||
id: str
|
||
brand_id: str
|
||
category: str
|
||
title: str
|
||
body: str
|
||
source: Optional[str]
|
||
created_at: str
|
||
updated_at: str
|
||
|
||
model_config = {"from_attributes": True}
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Helpers
|
||
# ---------------------------------------------------------------------------
|
||
|
||
def _content_to_response(c: Content) -> ContentResponse:
|
||
"""Convert Content model to response schema."""
|
||
tags = c.keywords if c.keywords else []
|
||
published_at = None
|
||
if c.status == "published" and c.updated_at:
|
||
published_at = c.updated_at.isoformat()
|
||
|
||
return ContentResponse(
|
||
id=str(c.id),
|
||
title=c.title,
|
||
body=c.body,
|
||
content_type=c.content_type,
|
||
status=c.status,
|
||
project_id=str(c.project_id) if c.project_id else None,
|
||
author_id=str(c.created_by) if c.created_by else None,
|
||
tags=tags,
|
||
created_at=c.created_at.isoformat() if c.created_at else "",
|
||
updated_at=c.updated_at.isoformat() if c.updated_at else "",
|
||
published_at=published_at,
|
||
)
|
||
|
||
|
||
def _brand_knowledge_to_response(bk: BrandKnowledge) -> BrandKnowledgeResponse:
|
||
"""Convert BrandKnowledge model to response schema."""
|
||
source = None
|
||
if bk.extra_metadata and isinstance(bk.extra_metadata, dict):
|
||
source = bk.extra_metadata.get("source")
|
||
|
||
return BrandKnowledgeResponse(
|
||
id=str(bk.id),
|
||
brand_id=str(bk.organization_id),
|
||
category=bk.category,
|
||
title=bk.title,
|
||
body=bk.content,
|
||
source=source,
|
||
created_at=bk.created_at.isoformat() if bk.created_at else "",
|
||
updated_at=bk.updated_at.isoformat() if bk.updated_at else "",
|
||
)
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Content CRUD
|
||
# ---------------------------------------------------------------------------
|
||
|
||
@router.get("/", response_model=list[ContentResponse])
|
||
async def list_contents(
|
||
status: Optional[str] = Query(None),
|
||
content_type: Optional[str] = Query(None),
|
||
skip: int = Query(0, ge=0),
|
||
limit: int = Query(20, ge=1, le=100),
|
||
db: AsyncSession = Depends(get_db),
|
||
current_user: User = Depends(get_current_user),
|
||
):
|
||
"""列出当前组织的内容列表"""
|
||
org_id = current_user.organization_id
|
||
if not org_id:
|
||
return []
|
||
|
||
stmt = select(Content).where(Content.organization_id == org_id)
|
||
if status:
|
||
stmt = stmt.where(Content.status == status)
|
||
if content_type:
|
||
stmt = stmt.where(Content.content_type == content_type)
|
||
stmt = stmt.order_by(Content.created_at.desc()).offset(skip).limit(limit)
|
||
|
||
result = await db.execute(stmt)
|
||
contents = result.scalars().all()
|
||
return [_content_to_response(c) for c in contents]
|
||
|
||
|
||
@router.post("/", response_model=ContentResponse, status_code=status.HTTP_201_CREATED)
|
||
async def create_content(
|
||
body: ContentCreateRequest,
|
||
db: AsyncSession = Depends(get_db),
|
||
current_user: User = Depends(get_current_user),
|
||
):
|
||
"""手动创建内容"""
|
||
org_id = current_user.organization_id
|
||
if not org_id:
|
||
raise HTTPException(status_code=403, detail="用户未关联组织")
|
||
|
||
project_uuid = None
|
||
if body.project_id:
|
||
try:
|
||
project_uuid = uuid.UUID(body.project_id)
|
||
except ValueError:
|
||
raise HTTPException(status_code=400, detail="无效的 project_id")
|
||
|
||
content = Content(
|
||
organization_id=org_id,
|
||
title=body.title,
|
||
content_type=body.content_type,
|
||
body=body.body,
|
||
status="draft",
|
||
project_id=project_uuid,
|
||
keywords=body.tags or [],
|
||
created_by=current_user.id,
|
||
current_version=1,
|
||
)
|
||
db.add(content)
|
||
await db.flush()
|
||
|
||
# Create initial version
|
||
version = ContentVersion(
|
||
content_id=content.id,
|
||
version_number=1,
|
||
title=body.title,
|
||
body=body.body,
|
||
change_summary="初始创建",
|
||
created_by=current_user.id,
|
||
)
|
||
db.add(version)
|
||
await db.commit()
|
||
await db.refresh(content)
|
||
|
||
return _content_to_response(content)
|
||
|
||
|
||
@router.get("/{content_id}", response_model=ContentResponse)
|
||
async def get_content(
|
||
content_id: uuid.UUID,
|
||
db: AsyncSession = Depends(get_db),
|
||
current_user: User = Depends(get_current_user),
|
||
):
|
||
"""获取单条内容详情"""
|
||
org_id = current_user.organization_id
|
||
if not org_id:
|
||
raise HTTPException(status_code=404, detail="内容不存在")
|
||
|
||
stmt = select(Content).where(
|
||
Content.id == content_id,
|
||
Content.organization_id == org_id,
|
||
)
|
||
result = await db.execute(stmt)
|
||
content = result.scalar_one_or_none()
|
||
if not content:
|
||
raise HTTPException(status_code=404, detail="内容不存在")
|
||
|
||
return _content_to_response(content)
|
||
|
||
|
||
@router.put("/{content_id}", response_model=ContentResponse)
|
||
async def update_content(
|
||
content_id: uuid.UUID,
|
||
body: ContentUpdateRequest,
|
||
db: AsyncSession = Depends(get_db),
|
||
current_user: User = Depends(get_current_user),
|
||
):
|
||
"""更新内容"""
|
||
org_id = current_user.organization_id
|
||
if not org_id:
|
||
raise HTTPException(status_code=404, detail="内容不存在")
|
||
|
||
stmt = select(Content).where(
|
||
Content.id == content_id,
|
||
Content.organization_id == org_id,
|
||
)
|
||
result = await db.execute(stmt)
|
||
content = result.scalar_one_or_none()
|
||
if not content:
|
||
raise HTTPException(status_code=404, detail="内容不存在")
|
||
|
||
update_dict = body.model_dump(exclude_unset=True)
|
||
|
||
# Map tags -> keywords
|
||
if "tags" in update_dict:
|
||
content.keywords = update_dict.pop("tags")
|
||
|
||
needs_version = False
|
||
if "body" in update_dict and update_dict["body"] != content.body:
|
||
needs_version = True
|
||
if "title" in update_dict and update_dict["title"] != content.title:
|
||
needs_version = True
|
||
|
||
for field, value in update_dict.items():
|
||
setattr(content, field, value)
|
||
|
||
if needs_version:
|
||
content.current_version += 1
|
||
version = ContentVersion(
|
||
content_id=content.id,
|
||
version_number=content.current_version,
|
||
title=content.title,
|
||
body=content.body,
|
||
change_summary=f"版本 {content.current_version}",
|
||
created_by=current_user.id,
|
||
)
|
||
db.add(version)
|
||
|
||
await db.commit()
|
||
await db.refresh(content)
|
||
return _content_to_response(content)
|
||
|
||
|
||
@router.delete("/{content_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||
async def delete_content(
|
||
content_id: uuid.UUID,
|
||
db: AsyncSession = Depends(get_db),
|
||
current_user: User = Depends(get_current_user),
|
||
):
|
||
"""删除内容"""
|
||
org_id = current_user.organization_id
|
||
if not org_id:
|
||
raise HTTPException(status_code=404, detail="内容不存在")
|
||
|
||
stmt = select(Content).where(
|
||
Content.id == content_id,
|
||
Content.organization_id == org_id,
|
||
)
|
||
result = await db.execute(stmt)
|
||
content = result.scalar_one_or_none()
|
||
if not content:
|
||
raise HTTPException(status_code=404, detail="内容不存在")
|
||
|
||
await db.delete(content)
|
||
await db.commit()
|
||
|
||
|
||
@router.post("/{content_id}/publish", response_model=ContentResponse)
|
||
async def publish_content(
|
||
content_id: uuid.UUID,
|
||
db: AsyncSession = Depends(get_db),
|
||
current_user: User = Depends(get_current_user),
|
||
):
|
||
"""发布内容(将状态改为 published)"""
|
||
org_id = current_user.organization_id
|
||
if not org_id:
|
||
raise HTTPException(status_code=404, detail="内容不存在")
|
||
|
||
stmt = select(Content).where(
|
||
Content.id == content_id,
|
||
Content.organization_id == org_id,
|
||
)
|
||
result = await db.execute(stmt)
|
||
content = result.scalar_one_or_none()
|
||
if not content:
|
||
raise HTTPException(status_code=404, detail="内容不存在")
|
||
|
||
content.status = "published"
|
||
await db.commit()
|
||
await db.refresh(content)
|
||
return _content_to_response(content)
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Brand Knowledge CRUD (under /api/v1/contents/knowledge/)
|
||
# ---------------------------------------------------------------------------
|
||
|
||
@router.get("/knowledge/", response_model=list[BrandKnowledgeResponse])
|
||
async def list_brand_knowledge(
|
||
brand_id: Optional[str] = Query(None),
|
||
category: Optional[str] = Query(None),
|
||
db: AsyncSession = Depends(get_db),
|
||
current_user: User = Depends(get_current_user),
|
||
):
|
||
"""列出品牌知识库条目"""
|
||
org_id = current_user.organization_id
|
||
if not org_id:
|
||
return []
|
||
|
||
stmt = select(BrandKnowledge).where(
|
||
BrandKnowledge.organization_id == org_id,
|
||
BrandKnowledge.is_active.is_(True),
|
||
)
|
||
# brand_id maps to organization_id; filter only if different org requested
|
||
if brand_id:
|
||
try:
|
||
brand_uuid = uuid.UUID(brand_id)
|
||
stmt = stmt.where(BrandKnowledge.organization_id == brand_uuid)
|
||
except ValueError:
|
||
pass
|
||
if category:
|
||
stmt = stmt.where(BrandKnowledge.category == category)
|
||
stmt = stmt.order_by(BrandKnowledge.created_at.desc())
|
||
|
||
result = await db.execute(stmt)
|
||
items = result.scalars().all()
|
||
return [_brand_knowledge_to_response(bk) for bk in items]
|
||
|
||
|
||
@router.post("/knowledge/", response_model=BrandKnowledgeResponse, status_code=status.HTTP_201_CREATED)
|
||
async def create_brand_knowledge(
|
||
body: BrandKnowledgeCreateRequest,
|
||
db: AsyncSession = Depends(get_db),
|
||
current_user: User = Depends(get_current_user),
|
||
):
|
||
"""创建品牌知识库条目"""
|
||
org_id = current_user.organization_id
|
||
if not org_id:
|
||
raise HTTPException(status_code=403, detail="用户未关联组织")
|
||
|
||
bk = BrandKnowledge(
|
||
organization_id=org_id,
|
||
category=body.category,
|
||
title=body.title,
|
||
content=body.body,
|
||
extra_metadata={"source": body.source} if body.source else None,
|
||
created_by=current_user.id,
|
||
)
|
||
db.add(bk)
|
||
await db.commit()
|
||
await db.refresh(bk)
|
||
return _brand_knowledge_to_response(bk)
|
||
|
||
|
||
@router.put("/knowledge/{knowledge_id}", response_model=BrandKnowledgeResponse)
|
||
async def update_brand_knowledge(
|
||
knowledge_id: uuid.UUID,
|
||
body: BrandKnowledgeUpdateRequest,
|
||
db: AsyncSession = Depends(get_db),
|
||
current_user: User = Depends(get_current_user),
|
||
):
|
||
"""更新品牌知识库条目"""
|
||
org_id = current_user.organization_id
|
||
if not org_id:
|
||
raise HTTPException(status_code=404, detail="知识条目不存在")
|
||
|
||
stmt = select(BrandKnowledge).where(
|
||
BrandKnowledge.id == knowledge_id,
|
||
BrandKnowledge.organization_id == org_id,
|
||
)
|
||
result = await db.execute(stmt)
|
||
bk = result.scalar_one_or_none()
|
||
if not bk:
|
||
raise HTTPException(status_code=404, detail="知识条目不存在")
|
||
|
||
update_dict = body.model_dump(exclude_unset=True)
|
||
if "body" in update_dict:
|
||
bk.content = update_dict.pop("body")
|
||
if "source" in update_dict:
|
||
source = update_dict.pop("source")
|
||
if bk.extra_metadata is None:
|
||
bk.extra_metadata = {}
|
||
bk.extra_metadata["source"] = source
|
||
|
||
for field, value in update_dict.items():
|
||
setattr(bk, field, value)
|
||
|
||
await db.commit()
|
||
await db.refresh(bk)
|
||
return _brand_knowledge_to_response(bk)
|
||
|
||
|
||
@router.delete("/knowledge/{knowledge_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||
async def delete_brand_knowledge(
|
||
knowledge_id: uuid.UUID,
|
||
db: AsyncSession = Depends(get_db),
|
||
current_user: User = Depends(get_current_user),
|
||
):
|
||
"""删除品牌知识库条目"""
|
||
org_id = current_user.organization_id
|
||
if not org_id:
|
||
raise HTTPException(status_code=404, detail="知识条目不存在")
|
||
|
||
stmt = select(BrandKnowledge).where(
|
||
BrandKnowledge.id == knowledge_id,
|
||
BrandKnowledge.organization_id == org_id,
|
||
)
|
||
result = await db.execute(stmt)
|
||
bk = result.scalar_one_or_none()
|
||
if not bk:
|
||
raise HTTPException(status_code=404, detail="知识条目不存在")
|
||
|
||
await db.delete(bk)
|
||
await db.commit()
|