Your Name 4c9b2f1263 feat: Brief附件/项目平台/规则AI解析/消息中心修复 + 项目创建通知
- Brief 支持代理商附件上传 (迁移 007)
- 项目新增 platform 字段 (迁移 008),前端创建/展示平台信息
- 修复 AI 规则解析:处理中文引号导致 JSON 解析失败的问题
- 修复消息中心崩溃:补全后端消息类型映射 + fallback 保护
- 项目创建时自动发送消息通知
- .gitignore 排除 backend/data/ 数据库文件

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-10 19:00:03 +08:00

354 lines
12 KiB
Python

"""
项目 API
品牌方创建和管理项目,分配代理商
"""
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, func
from sqlalchemy.orm import selectinload
from app.database import get_db
from app.models.user import User, UserRole
from app.models.project import Project, project_agency_association
from app.models.task import Task
from app.models.organization import Brand, Agency
from app.api.deps import get_current_user, get_current_brand, get_current_agency
from app.schemas.project import (
ProjectCreateRequest,
ProjectUpdateRequest,
ProjectAssignAgencyRequest,
ProjectResponse,
ProjectListResponse,
AgencySummary,
)
from app.services.auth import generate_id
from app.services.message_service import create_message
router = APIRouter(prefix="/projects", tags=["项目"])
async def _project_to_response(project: Project, db: AsyncSession) -> ProjectResponse:
"""将项目模型转换为响应"""
# 获取任务数量
count_result = await db.execute(
select(func.count(Task.id)).where(Task.project_id == project.id)
)
task_count = count_result.scalar() or 0
agencies = []
if project.agencies:
agencies = [
AgencySummary(id=a.id, name=a.name, logo=a.logo)
for a in project.agencies
]
return ProjectResponse(
id=project.id,
name=project.name,
description=project.description,
platform=project.platform,
brand_id=project.brand_id,
brand_name=project.brand.name if project.brand else None,
status=project.status,
start_date=project.start_date,
deadline=project.deadline,
agencies=agencies,
task_count=task_count,
created_at=project.created_at,
updated_at=project.updated_at,
)
@router.post("", response_model=ProjectResponse, status_code=status.HTTP_201_CREATED)
async def create_project(
request: ProjectCreateRequest,
brand: Brand = Depends(get_current_brand),
db: AsyncSession = Depends(get_db),
):
"""
创建项目(品牌方操作)
"""
project = Project(
id=generate_id("PJ"),
brand_id=brand.id,
name=request.name,
description=request.description,
platform=request.platform,
start_date=request.start_date,
deadline=request.deadline,
status="active",
)
db.add(project)
await db.flush()
# 分配代理商(直接 INSERT 关联表,避免 async 懒加载问题)
if request.agency_ids:
for agency_id in request.agency_ids:
result = await db.execute(
select(Agency).where(Agency.id == agency_id)
)
agency = result.scalar_one_or_none()
if agency:
await db.execute(
project_agency_association.insert().values(
project_id=project.id,
agency_id=agency.id,
)
)
await db.flush()
await db.refresh(project)
# 重新加载关联
result = await db.execute(
select(Project)
.options(selectinload(Project.brand), selectinload(Project.agencies))
.where(Project.id == project.id)
)
project = result.scalar_one()
# 给品牌方用户发送项目创建成功消息
brand_user_result = await db.execute(
select(User).where(User.id == brand.user_id)
)
brand_user = brand_user_result.scalar_one_or_none()
if brand_user:
await create_message(
db=db,
user_id=brand_user.id,
type="system_notice",
title="项目创建成功",
content=f"您的项目「{project.name}」已创建成功",
related_project_id=project.id,
)
return await _project_to_response(project, db)
@router.get("", response_model=ProjectListResponse)
async def list_projects(
page: int = Query(1, ge=1),
page_size: int = Query(20, ge=1, le=100),
status_filter: Optional[str] = Query(None, alias="status"),
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""
查询项目列表
- 品牌方: 查看自己创建的项目
- 代理商: 查看被分配的项目
"""
if current_user.role == UserRole.BRAND:
result = await db.execute(
select(Brand).where(Brand.user_id == current_user.id)
)
brand = result.scalar_one_or_none()
if not brand:
raise HTTPException(status_code=404, detail="品牌方信息不存在")
query = (
select(Project)
.options(selectinload(Project.brand), selectinload(Project.agencies))
.where(Project.brand_id == brand.id)
)
count_query = select(func.count(Project.id)).where(Project.brand_id == brand.id)
if status_filter:
query = query.where(Project.status == status_filter)
count_query = count_query.where(Project.status == status_filter)
elif current_user.role == UserRole.AGENCY:
result = await db.execute(
select(Agency).where(Agency.user_id == current_user.id)
)
agency = result.scalar_one_or_none()
if not agency:
raise HTTPException(status_code=404, detail="代理商信息不存在")
# 通过关联表查询
project_ids_query = (
select(project_agency_association.c.project_id)
.where(project_agency_association.c.agency_id == agency.id)
)
project_ids_result = await db.execute(project_ids_query)
project_ids = [row[0] for row in project_ids_result.all()]
if not project_ids:
return ProjectListResponse(items=[], total=0, page=page, page_size=page_size)
query = (
select(Project)
.options(selectinload(Project.brand), selectinload(Project.agencies))
.where(Project.id.in_(project_ids))
)
count_query = select(func.count(Project.id)).where(Project.id.in_(project_ids))
if status_filter:
query = query.where(Project.status == status_filter)
count_query = count_query.where(Project.status == status_filter)
else:
raise HTTPException(status_code=403, detail="达人无权查看项目列表")
query = query.order_by(Project.created_at.desc())
# 总数
count_result = await db.execute(count_query)
total = count_result.scalar() or 0
# 分页
query = query.offset((page - 1) * page_size).limit(page_size)
result = await db.execute(query)
projects = list(result.scalars().all())
items = []
for p in projects:
items.append(await _project_to_response(p, db))
return ProjectListResponse(items=items, total=total, page=page, page_size=page_size)
@router.get("/{project_id}", response_model=ProjectResponse)
async def get_project(
project_id: str,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""查询项目详情"""
result = await db.execute(
select(Project)
.options(selectinload(Project.brand), selectinload(Project.agencies))
.where(Project.id == project_id)
)
project = result.scalar_one_or_none()
if not project:
raise HTTPException(status_code=404, detail="项目不存在")
# 权限检查
if current_user.role == UserRole.BRAND:
brand_result = await db.execute(
select(Brand).where(Brand.user_id == current_user.id)
)
brand = brand_result.scalar_one_or_none()
if not brand or project.brand_id != brand.id:
raise HTTPException(status_code=403, detail="无权访问此项目")
elif current_user.role == UserRole.AGENCY:
agency_result = await db.execute(
select(Agency).where(Agency.user_id == current_user.id)
)
agency = agency_result.scalar_one_or_none()
if not agency or agency not in project.agencies:
raise HTTPException(status_code=403, detail="无权访问此项目")
else:
raise HTTPException(status_code=403, detail="无权访问此项目")
return await _project_to_response(project, db)
@router.put("/{project_id}", response_model=ProjectResponse)
async def update_project(
project_id: str,
request: ProjectUpdateRequest,
brand: Brand = Depends(get_current_brand),
db: AsyncSession = Depends(get_db),
):
"""更新项目(品牌方操作)"""
result = await db.execute(
select(Project)
.options(selectinload(Project.brand), selectinload(Project.agencies))
.where(Project.id == project_id)
)
project = result.scalar_one_or_none()
if not project:
raise HTTPException(status_code=404, detail="项目不存在")
if project.brand_id != brand.id:
raise HTTPException(status_code=403, detail="无权修改此项目")
if request.name is not None:
project.name = request.name
if request.description is not None:
project.description = request.description
if request.platform is not None:
project.platform = request.platform
if request.start_date is not None:
project.start_date = request.start_date
if request.deadline is not None:
project.deadline = request.deadline
if request.status is not None:
project.status = request.status
await db.flush()
await db.refresh(project)
return await _project_to_response(project, db)
@router.post("/{project_id}/agencies", response_model=ProjectResponse)
async def assign_agencies(
project_id: str,
request: ProjectAssignAgencyRequest,
brand: Brand = Depends(get_current_brand),
db: AsyncSession = Depends(get_db),
):
"""分配代理商到项目(品牌方操作)"""
result = await db.execute(
select(Project)
.options(selectinload(Project.brand), selectinload(Project.agencies))
.where(Project.id == project_id)
)
project = result.scalar_one_or_none()
if not project:
raise HTTPException(status_code=404, detail="项目不存在")
if project.brand_id != brand.id:
raise HTTPException(status_code=403, detail="无权操作此项目")
for agency_id in request.agency_ids:
agency_result = await db.execute(
select(Agency).where(Agency.id == agency_id)
)
agency = agency_result.scalar_one_or_none()
if agency and agency not in project.agencies:
project.agencies.append(agency)
await db.flush()
await db.refresh(project)
return await _project_to_response(project, db)
@router.delete("/{project_id}/agencies/{agency_id}", response_model=ProjectResponse)
async def remove_agency_from_project(
project_id: str,
agency_id: str,
brand: Brand = Depends(get_current_brand),
db: AsyncSession = Depends(get_db),
):
"""从项目移除代理商(品牌方操作)"""
result = await db.execute(
select(Project)
.options(selectinload(Project.brand), selectinload(Project.agencies))
.where(Project.id == project_id)
)
project = result.scalar_one_or_none()
if not project:
raise HTTPException(status_code=404, detail="项目不存在")
if project.brand_id != brand.id:
raise HTTPException(status_code=403, detail="无权操作此项目")
agency_result = await db.execute(
select(Agency).where(Agency.id == agency_id)
)
agency = agency_result.scalar_one_or_none()
if agency and agency in project.agencies:
project.agencies.remove(agency)
await db.flush()
await db.refresh(project)
return await _project_to_response(project, db)