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