diff --git a/apps/api/app/api/router.py b/apps/api/app/api/router.py index 712580c93..aa3505b5f 100644 --- a/apps/api/app/api/router.py +++ b/apps/api/app/api/router.py @@ -1,3 +1,4 @@ +from app.api.routes.dashboard import router as dashboard_router from app.api.routes.asset_diagnosis import router as asset_diagnosis_router from app.api.routes.asset_libraries import router as asset_libraries_router from app.api.routes.assets import router as assets_router @@ -110,3 +111,8 @@ api_router.include_router( prefix="/templates", tags=["Template"], ) +api_router.include_router( + dashboard_router, + prefix="/dashboard", + tags=["Dashboard"], +) diff --git a/apps/api/app/api/routes/dashboard.py b/apps/api/app/api/routes/dashboard.py new file mode 100644 index 000000000..1cdc838cc --- /dev/null +++ b/apps/api/app/api/routes/dashboard.py @@ -0,0 +1,92 @@ +from typing import Any + +from app.auth import AuthenticatedUser, get_current_user +from app.dependencies import ( + get_asset_repository, + get_generation_task_repository, + get_project_repository, + get_title_library_repository, + get_voice_library_repository, +) +from app.schemas.dashboard import DashboardOverviewResponse, RecentTaskItem, SubscriptionInfo +from fastapi import APIRouter, Depends + +router = APIRouter() + + +def _status_value(status) -> str: + return status.value if hasattr(status, "value") else str(status) + + +def _generation_step(status: str) -> str: + if status == "pending": + return "等待 Worker 执行" + if status == "running": + return "正在生成成片" + if status == "completed": + return "生成完成" + if status == "failed": + return "生成失败" + return status + + +@router.get("/overview", response_model=DashboardOverviewResponse) +def get_dashboard_overview( + authenticated_user: AuthenticatedUser = Depends(get_current_user), + project_repository: Any = Depends(get_project_repository), + asset_repository: Any = Depends(get_asset_repository), + generation_task_repository: Any = Depends(get_generation_task_repository), + title_library_repository: Any = Depends(get_title_library_repository), + voice_library_repository: Any = Depends(get_voice_library_repository), +) -> DashboardOverviewResponse: + """Dashboard 概览:用户级汇总数据。""" + user_id = authenticated_user.user.id + + # 获取用户可访问的所有 project + projects = project_repository.find_accessible_projects(user_id) + project_ids = [p.id for p in projects] + + # 素材统计 + total_assets = asset_repository.count_by_project_ids(project_ids) + used_storage_bytes = asset_repository.sum_storage_by_project_ids(project_ids) + + # 标题库 / 配音库统计 + total_titles = title_library_repository.count_by_user(user_id) + total_voices = voice_library_repository.count_by_user(user_id) + + # 生成任务统计 + total_tasks = generation_task_repository.count_by_user(user_id) + + # 最近任务(SQL 层 LIMIT 5) + recent = generation_task_repository.list_recent_by_user(user_id, limit=5) + recent_tasks = [] + for task in recent: + s = _status_value(task.status) + recent_tasks.append( + RecentTaskItem( + id=task.id, + task_type="generation", + status=s, + current_step=_generation_step(s), + error_message=task.error_message or "", + updated_at=task.completed_at or task.started_at or task.created_at, + ) + ) + + # 订阅信息 + user = authenticated_user.user + subscription = SubscriptionInfo( + plan=getattr(user, "subscription_plan", "free") or "free", + is_active=getattr(user, "subscription_status", "") == "active", + ) + + return DashboardOverviewResponse( + total_assets=total_assets, + used_storage_bytes=used_storage_bytes, + total_titles=total_titles, + total_voices=total_voices, + total_tasks=total_tasks, + total_products=len(projects), + subscription=subscription, + recent_tasks=recent_tasks, + ) diff --git a/apps/api/app/api/routes/projects.py b/apps/api/app/api/routes/projects.py index cf352a909..7e8e502b0 100644 --- a/apps/api/app/api/routes/projects.py +++ b/apps/api/app/api/routes/projects.py @@ -22,8 +22,10 @@ router = APIRouter() def _to_project_response(item) -> ProjectResponse: return ProjectResponse( id=item.id, + owner_user_id=item.owner_user_id, name=item.name, description=item.description, + shared_users=item.shared_users, ) diff --git a/apps/api/app/schemas/dashboard.py b/apps/api/app/schemas/dashboard.py new file mode 100644 index 000000000..d1732b0a5 --- /dev/null +++ b/apps/api/app/schemas/dashboard.py @@ -0,0 +1,30 @@ +from datetime import datetime + +from pydantic import BaseModel, Field + + +class RecentTaskItem(BaseModel): + id: str + task_type: str = "generation" + status: str + current_step: str = "" + error_message: str = "" + updated_at: datetime | None = None + + +class SubscriptionInfo(BaseModel): + """用户订阅信息。""" + plan: str = "free" + is_active: bool = False + + +class DashboardOverviewResponse(BaseModel): + """Dashboard 概览数据。""" + total_assets: int = 0 + used_storage_bytes: int = 0 + total_titles: int = 0 + total_voices: int = 0 + total_tasks: int = 0 + total_products: int = 0 + subscription: SubscriptionInfo = Field(default_factory=SubscriptionInfo) + recent_tasks: list[RecentTaskItem] = Field(default_factory=list) diff --git a/packages/adapters/sqlalchemy_impl/asset_repository.py b/packages/adapters/sqlalchemy_impl/asset_repository.py index 063edf77e..00dcf0860 100644 --- a/packages/adapters/sqlalchemy_impl/asset_repository.py +++ b/packages/adapters/sqlalchemy_impl/asset_repository.py @@ -105,6 +105,22 @@ class SQLAlchemyAssetRepository: AssetModel.project_id == project_id ).count() + def count_by_project_ids(self, project_ids: list[str]) -> int: + if not project_ids: + return 0 + return self.session.query(AssetModel).filter( + AssetModel.project_id.in_(project_ids) + ).count() + + def sum_storage_by_project_ids(self, project_ids: list[str]) -> int: + if not project_ids: + return 0 + from sqlalchemy import func + result = self.session.query(func.coalesce(func.sum(AssetModel.file_size), 0)).filter( + AssetModel.project_id.in_(project_ids) + ).scalar() + return int(result or 0) + def _to_domain(self, model: AssetModel) -> Asset: metadata = {} if model.classification_result: diff --git a/packages/adapters/sqlalchemy_impl/generation_task_repository.py b/packages/adapters/sqlalchemy_impl/generation_task_repository.py index b2c635f77..f7511e3a8 100644 --- a/packages/adapters/sqlalchemy_impl/generation_task_repository.py +++ b/packages/adapters/sqlalchemy_impl/generation_task_repository.py @@ -81,6 +81,23 @@ class SQLAlchemyGenerationTaskRepository: ) return [_to_domain(m) for m in models] + def count_by_user(self, user_id: str) -> int: + return ( + self.session.query(GenerationTaskModel) + .filter(GenerationTaskModel.created_by_user_id == user_id) + .count() + ) + + def list_recent_by_user(self, user_id: str, limit: int = 5) -> list[GenerationTask]: + models = ( + self.session.query(GenerationTaskModel) + .filter(GenerationTaskModel.created_by_user_id == user_id) + .order_by(GenerationTaskModel.created_at.desc()) + .limit(limit) + .all() + ) + return [_to_domain(m) for m in models] + def update(self, task: GenerationTask) -> GenerationTask: model = self.session.query(GenerationTaskModel).filter(GenerationTaskModel.id == task.id).first() if model is None: diff --git a/packages/ports/asset_repository.py b/packages/ports/asset_repository.py index 14616936f..e2c0beeed 100644 --- a/packages/ports/asset_repository.py +++ b/packages/ports/asset_repository.py @@ -43,3 +43,11 @@ class AssetRepository(ABC): @abstractmethod async def count_by_project(self, project_id: str) -> int: pass + + @abstractmethod + def count_by_project_ids(self, project_ids: list[str]) -> int: + pass + + @abstractmethod + def sum_storage_by_project_ids(self, project_ids: list[str]) -> int: + pass diff --git a/packages/ports/generation_task_repository.py b/packages/ports/generation_task_repository.py index a68f059f7..329d8a39d 100644 --- a/packages/ports/generation_task_repository.py +++ b/packages/ports/generation_task_repository.py @@ -14,4 +14,8 @@ class GenerationTaskRepository(Protocol): def list_by_user(self, user_id: str) -> list[GenerationTask]: ... + def count_by_user(self, user_id: str) -> int: ... + + def list_recent_by_user(self, user_id: str, limit: int = 5) -> list[GenerationTask]: ... + def update(self, task: GenerationTask) -> GenerationTask: ...