332 lines
10 KiB
Python
332 lines
10 KiB
Python
"""客户管理 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
|
||
]
|