geo/backend/app/api/clients.py

332 lines
10 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.

"""客户管理 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
]