"""客户管理 API — /api/v1/clients/ 前端 clients 模块在 GEO 平台中映射到 Organization 模型。 一个 Organization 代表一个客户/品牌方。 """ import uuid from typing import Optional from fastapi import APIRouter, Depends, HTTPException, Query, status from pydantic import BaseModel, Field from sqlalchemy import select, func from sqlalchemy.ext.asyncio import AsyncSession from app.api.deps import get_current_user from app.database import get_db from app.models.organization import Organization, OrgMember from app.models.lifecycle import LifecycleProject from app.models.user import User router = APIRouter() # --------------------------------------------------------------------------- # Pydantic Schemas # --------------------------------------------------------------------------- class ClientCreateRequest(BaseModel): name: str = Field(..., max_length=100) company: Optional[str] = None contact_email: Optional[str] = None contact_phone: Optional[str] = None class ClientUpdateRequest(BaseModel): name: Optional[str] = Field(None, max_length=100) company: Optional[str] = None contact_email: Optional[str] = None contact_phone: Optional[str] = None status: Optional[str] = Field(None, max_length=20) class ClientResponse(BaseModel): id: str name: str company: Optional[str] = None contact_email: Optional[str] = None contact_phone: Optional[str] = None status: str manager_id: str created_at: str updated_at: str model_config = {"from_attributes": True} class ClientProjectResponse(BaseModel): id: str brand_name: str current_stage: int status: str created_at: str # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- def _org_to_client(org: Organization, membership: OrgMember) -> ClientResponse: """Convert Organization model to ClientResponse.""" metadata = {} if org.description: import json as _json try: metadata = _json.loads(org.description) except (ValueError, TypeError): metadata = {"notes": org.description} return ClientResponse( id=str(org.id), name=org.name, company=metadata.get("company"), contact_email=metadata.get("contact_email"), contact_phone=metadata.get("contact_phone"), status="active" if org.plan != "suspended" else "inactive", manager_id=str(membership.user_id) if membership else "", created_at=org.created_at.isoformat() if org.created_at else "", updated_at=org.updated_at.isoformat() if org.updated_at else "", ) def _serialize_org_metadata(data: dict) -> str: """Serialize client metadata into org description field.""" import json as _json return _json.dumps(data, ensure_ascii=False) # --------------------------------------------------------------------------- # Client CRUD # --------------------------------------------------------------------------- @router.get("/", response_model=list[ClientResponse]) async def list_clients( status: 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), ): """列出当前用户所属的客户/组织""" # Find all orgs this user is a member of stmt = ( select(Organization, OrgMember) .join(OrgMember, OrgMember.organization_id == Organization.id) .where(OrgMember.user_id == current_user.id) .order_by(Organization.created_at.desc()) .offset(skip) .limit(limit) ) result = await db.execute(stmt) rows = result.all() clients = [] for org, membership in rows: client = _org_to_client(org, membership) if status and client.status != status: continue clients.append(client) return clients @router.post("/", response_model=ClientResponse, status_code=status.HTTP_201_CREATED) async def create_client( body: ClientCreateRequest, db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user), ): """创建新客户/组织""" import re slug = re.sub(r'[^a-zA-Z0-9\u4e00-\u9fff]', '-', body.name)[:50] slug = f"{slug}-{uuid.uuid4().hex[:8]}" metadata = { "company": body.company, "contact_email": body.contact_email, "contact_phone": body.contact_phone, } org = Organization( name=body.name, slug=slug, description=_serialize_org_metadata(metadata), plan="free", ) db.add(org) await db.flush() # Add current user as owner membership = OrgMember( organization_id=org.id, user_id=current_user.id, role="owner", ) db.add(membership) await db.commit() await db.refresh(org) # Refresh membership mem_stmt = select(OrgMember).where( OrgMember.organization_id == org.id, OrgMember.user_id == current_user.id, ) mem_result = await db.execute(mem_stmt) membership = mem_result.scalar_one_or_none() return _org_to_client(org, membership) @router.get("/{client_id}", response_model=ClientResponse) async def get_client( client_id: uuid.UUID, db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user), ): """获取客户详情""" # Verify the user is a member of this org mem_stmt = select(OrgMember).where( OrgMember.organization_id == client_id, OrgMember.user_id == current_user.id, ) mem_result = await db.execute(mem_stmt) membership = mem_result.scalar_one_or_none() if not membership: raise HTTPException(status_code=404, detail="客户不存在或无权限") org_stmt = select(Organization).where(Organization.id == client_id) org_result = await db.execute(org_stmt) org = org_result.scalar_one_or_none() if not org: raise HTTPException(status_code=404, detail="客户不存在") return _org_to_client(org, membership) @router.put("/{client_id}", response_model=ClientResponse) async def update_client( client_id: uuid.UUID, body: ClientUpdateRequest, db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user), ): """更新客户信息""" # Verify ownership mem_stmt = select(OrgMember).where( OrgMember.organization_id == client_id, OrgMember.user_id == current_user.id, OrgMember.role == "owner", ) mem_result = await db.execute(mem_stmt) membership = mem_result.scalar_one_or_none() if not membership: raise HTTPException(status_code=404, detail="客户不存在或无权限") org_stmt = select(Organization).where(Organization.id == client_id) org_result = await db.execute(org_stmt) org = org_result.scalar_one_or_none() if not org: raise HTTPException(status_code=404, detail="客户不存在") # Update fields if body.name is not None: org.name = body.name # Update metadata in description import json as _json metadata = {} if org.description: try: metadata = _json.loads(org.description) except (ValueError, TypeError): metadata = {"notes": org.description} if body.company is not None: metadata["company"] = body.company if body.contact_email is not None: metadata["contact_email"] = body.contact_email if body.contact_phone is not None: metadata["contact_phone"] = body.contact_phone if body.status is not None: if body.status == "inactive": org.plan = "suspended" else: org.plan = metadata.get("original_plan", "free") org.description = _json.dumps(metadata, ensure_ascii=False) await db.commit() await db.refresh(org) # Re-fetch membership for response mem_stmt2 = select(OrgMember).where( OrgMember.organization_id == org.id, OrgMember.user_id == current_user.id, ) mem_result2 = await db.execute(mem_stmt2) membership = mem_result2.scalar_one_or_none() return _org_to_client(org, membership) @router.delete("/{client_id}", status_code=status.HTTP_204_NO_CONTENT) async def delete_client( client_id: uuid.UUID, db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user), ): """删除客户(仅owner可操作)""" mem_stmt = select(OrgMember).where( OrgMember.organization_id == client_id, OrgMember.user_id == current_user.id, OrgMember.role == "owner", ) mem_result = await db.execute(mem_stmt) membership = mem_result.scalar_one_or_none() if not membership: raise HTTPException(status_code=404, detail="客户不存在或无权限") org_stmt = select(Organization).where(Organization.id == client_id) org_result = await db.execute(org_stmt) org = org_result.scalar_one_or_none() if not org: raise HTTPException(status_code=404, detail="客户不存在") await db.delete(org) await db.commit() @router.get("/{client_id}/projects") async def get_client_projects( client_id: uuid.UUID, db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user), ): """获取客户下的所有项目""" # Verify membership mem_stmt = select(OrgMember).where( OrgMember.organization_id == client_id, OrgMember.user_id == current_user.id, ) mem_result = await db.execute(mem_stmt) if not mem_result.scalar_one_or_none(): raise HTTPException(status_code=404, detail="客户不存在或无权限") proj_stmt = ( select(LifecycleProject) .where(LifecycleProject.organization_id == client_id) .order_by(LifecycleProject.created_at.desc()) ) proj_result = await db.execute(proj_stmt) projects = proj_result.scalars().all() return [ ClientProjectResponse( id=str(p.id), brand_name=p.brand_name, current_stage=p.current_stage, status=p.status, created_at=p.created_at.isoformat() if p.created_at else "", ) for p in projects ]