geo/backend/app/api/contents.py

451 lines
14 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""内容管理 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()