diff --git a/alembic/versions/007_remove_workspace_concept.py b/alembic/versions/007_remove_workspace_concept.py new file mode 100644 index 000000000..7a8a7845e --- /dev/null +++ b/alembic/versions/007_remove_workspace_concept.py @@ -0,0 +1,203 @@ +"""Remove workspace concept - Projects now directly under User + +Revision ID: 007_remove_workspace_concept +Revises: 006_add_edit_plan_tables +Create Date: 2026-06-26 + +This migration: +1. Moves subscription/quota fields from workspaces to users table +2. Converts projects.workspace_id to projects.owner_user_id +3. Adds shared_users JSON field to projects table +4. Removes workspace_id from all tables that had it +5. Drops workspace-related tables: workspaces, workspace_members, workspace_invitations +""" +from alembic import op +import sqlalchemy as sa +from sqlalchemy import text + +# revision identifiers +revision = "007_remove_workspace_concept" +down_revision = "006_add_edit_plan_tables" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + conn = op.get_bind() + + # Step 1: Add subscription/quota fields to users table + conn.execute(text(""" + ALTER TABLE users + ADD COLUMN IF NOT EXISTS subscription_plan VARCHAR(20) NOT NULL DEFAULT free + """)) + conn.execute(text(""" + ALTER TABLE users + ADD COLUMN IF NOT EXISTS subscription_status VARCHAR(20) NOT NULL DEFAULT active + """)) + conn.execute(text(""" + ALTER TABLE users + ADD COLUMN IF NOT EXISTS subscription_expires_at TIMESTAMP + """)) + conn.execute(text(""" + ALTER TABLE users + ADD COLUMN IF NOT EXISTS max_projects FLOAT NOT NULL DEFAULT 3 + """)) + conn.execute(text(""" + ALTER TABLE users + ADD COLUMN IF NOT EXISTS max_storage_gb FLOAT NOT NULL DEFAULT 10 + """)) + conn.execute(text(""" + ALTER TABLE users + ADD COLUMN IF NOT EXISTS used_storage_gb FLOAT NOT NULL DEFAULT 0 + """)) + + # Step 2: Copy subscription data from workspaces to users + conn.execute(text(""" + UPDATE users SET + subscription_plan = w.subscription_plan, + subscription_status = w.subscription_status, + subscription_expires_at = w.subscription_expires_at, + max_projects = w.max_projects, + max_storage_gb = w.max_storage_gb, + used_storage_gb = w.used_storage_gb + FROM workspaces w + WHERE w.owner_user_id = users.id + """)) + + # Step 3: Add owner_user_id and shared_users to projects table + conn.execute(text(""" + ALTER TABLE projects + ADD COLUMN IF NOT EXISTS owner_user_id VARCHAR(32) + """)) + conn.execute(text(""" + ALTER TABLE projects + ADD COLUMN IF NOT EXISTS shared_users JSON + """)) + + # Step 4: Migrate workspace_id to owner_user_id (from workspace_members where role=owner) + conn.execute(text(""" + UPDATE projects SET + owner_user_id = wm.user_id + FROM workspace_members wm + WHERE wm.workspace_id = projects.workspace_id + AND wm.role = owner + """)) + + # Set shared_users to empty array for all projects + conn.execute(text(""" + UPDATE projects SET shared_users = []::json + WHERE shared_users IS NULL + """)) + + # Step 5: Remove workspace_id from all tables + tables_with_workspace_id = [ + "asset_libraries", + "assets", + "classification_jobs", + "edit_plans", + "edit_templates", + "generation_tasks", + "generated_videos", + "ingest_jobs", + "milestones", + "project_titles", + "tasks", + "task_issues", + ] + + for table in tables_with_workspace_id: + conn.execute(text(f""" + ALTER TABLE {table} DROP COLUMN IF EXISTS workspace_id + """)) + + # Step 6: Drop workspace-related tables + conn.execute(text(""" + DROP TABLE IF EXISTS workspace_invitations + """)) + conn.execute(text(""" + DROP TABLE IF EXISTS workspace_members + """)) + conn.execute(text(""" + DROP TABLE IF EXISTS workspaces + """)) + + # Step 7: Drop workspace_id from projects table + conn.execute(text(""" + ALTER TABLE projects DROP COLUMN IF EXISTS workspace_id + """)) + + +def downgrade() -> None: + conn = op.get_bind() + + # Add back workspace tables (simplified - in real scenario would need full recreation) + conn.execute(text(""" + CREATE TABLE IF NOT EXISTS workspaces ( + id VARCHAR(36) PRIMARY KEY, + name VARCHAR(100) NOT NULL, + owner_user_id VARCHAR(36) NOT NULL, + subscription_plan VARCHAR(20) NOT NULL DEFAULT free, + subscription_status VARCHAR(20) NOT NULL DEFAULT active, + subscription_expires_at TIMESTAMP, + max_projects FLOAT NOT NULL DEFAULT 3, + max_storage_gb FLOAT NOT NULL DEFAULT 10, + used_storage_gb FLOAT NOT NULL DEFAULT 0, + created_at TIMESTAMP NOT NULL DEFAULT NOW() + ) + """)) + + conn.execute(text(""" + CREATE TABLE IF NOT EXISTS workspace_members ( + id VARCHAR(36) PRIMARY KEY, + workspace_id VARCHAR(36) NOT NULL, + user_id VARCHAR(36) NOT NULL, + role VARCHAR(20) NOT NULL, + invited_by VARCHAR(36), + joined_at TIMESTAMP NOT NULL DEFAULT NOW(), + UNIQUE(workspace_id, user_id) + ) + """)) + + conn.execute(text(""" + CREATE TABLE IF NOT EXISTS workspace_invitations ( + id VARCHAR(36) PRIMARY KEY, + workspace_id VARCHAR(36) NOT NULL, + inviter_user_id VARCHAR(36) NOT NULL, + invitee_email VARCHAR(255) NOT NULL, + role VARCHAR(20) NOT NULL, + invitation_token VARCHAR(255) NOT NULL UNIQUE, + status VARCHAR(20) NOT NULL DEFAULT pending, + expires_at TIMESTAMP, + accepted_at TIMESTAMP, + created_at TIMESTAMP NOT NULL DEFAULT NOW() + ) + """)) + + # Add back workspace_id column to projects + conn.execute(text(""" + ALTER TABLE projects ADD COLUMN workspace_id VARCHAR(32) + """)) + + # Add back workspace_id columns to other tables + tables_with_workspace_id = [ + "asset_libraries", + "assets", + "classification_jobs", + "edit_plans", + "edit_templates", + "generation_tasks", + "generated_videos", + "ingest_jobs", + "milestones", + "project_titles", + "tasks", + "task_issues", + ] + + for table in tables_with_workspace_id: + conn.execute(text(f""" + ALTER TABLE {table} ADD COLUMN workspace_id VARCHAR(36) + """)) + + # Note: This downgrade is incomplete - projects.owner_user_id data would need to be + # converted back to workspace_ids, which requires reconstructing workspace records. diff --git a/apps/api/app/api/router.py b/apps/api/app/api/router.py index 17a421028..1d42088eb 100644 --- a/apps/api/app/api/router.py +++ b/apps/api/app/api/router.py @@ -13,7 +13,6 @@ from app.api.routes.project_titles import router as project_titles_router from app.api.routes.projects import router as projects_router from app.api.routes.task_center import router as task_center_router from app.api.routes.upload import router as upload_router -from app.api.routes.workspaces import router as workspaces_router from fastapi import APIRouter api_router = APIRouter(prefix="/api/v1") @@ -24,10 +23,6 @@ api_router.include_router( auth_router, tags=["认证"], ) -api_router.include_router( - workspaces_router, - tags=["工作空间"], -) api_router.include_router( projects_router, prefix="/projects", diff --git a/apps/api/app/api/routes/asset_libraries.py b/apps/api/app/api/routes/asset_libraries.py index c3465c09d..1bd0acaa5 100644 --- a/apps/api/app/api/routes/asset_libraries.py +++ b/apps/api/app/api/routes/asset_libraries.py @@ -1,7 +1,7 @@ from typing import Any from app.auth import AuthenticatedUser, get_current_user -from app.dependencies import get_asset_library_repository, get_project_repository, get_workspace_member_repository +from app.dependencies import get_asset_library_repository, get_project_repository from app.schemas.asset_library import ( AssetLibraryResponse, CreateAssetLibraryRequest, @@ -16,25 +16,22 @@ from packages.application import ( ListAssetLibrariesUseCase, ) from packages.domain import AssetLibraryKind -from packages.ports.workspace_member_repository import WorkspaceMemberRepository router = APIRouter() -def _require_workspace_member( - workspace_id: str, - authenticated_user: AuthenticatedUser, - workspace_member_repository: WorkspaceMemberRepository, -) -> None: - member = workspace_member_repository.find_by_workspace_and_user(workspace_id, authenticated_user.user.id) - if member is None: - raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Workspace access denied") +def _check_project_access(project_id: str, user_id: str, project_repository) -> None: + """检查用户是否有项目访问权限""" + project = project_repository.find_by_id(project_id) + if project is None: + raise HTTPException(status_code=404, detail=f"Project {project_id} not found") + if not project.can_access(user_id): + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Access denied to project") def _to_asset_library_response(item) -> AssetLibraryResponse: return AssetLibraryResponse( id=item.id, - workspace_id=item.workspace_id, project_id=item.project_id, name=item.name, kind=item.kind.value, @@ -46,19 +43,17 @@ def _to_asset_library_response(item) -> AssetLibraryResponse: @router.get("", response_model=ListAssetLibrariesResponse) def list_asset_libraries( project_id: str, - kind: str | None = None, authenticated_user: AuthenticatedUser = Depends(get_current_user), asset_library_repository: Any = Depends(get_asset_library_repository), project_repository: Any = Depends(get_project_repository), - workspace_member_repository: WorkspaceMemberRepository = Depends(get_workspace_member_repository), ) -> ListAssetLibrariesResponse: project = GetProjectUseCase(project_repository).execute(project_id) if project is None: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Project not found") - _require_workspace_member(project.workspace_id, authenticated_user, workspace_member_repository) + if not project.can_access(authenticated_user.id): + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Access denied to project") use_case = ListAssetLibrariesUseCase(asset_library_repository) - parsed_kind = AssetLibraryKind(kind) if kind else None - items = use_case.execute(project_id, kind=parsed_kind) + items = use_case.execute(project_id) return ListAssetLibrariesResponse(items=[_to_asset_library_response(item) for item in items]) @@ -68,16 +63,15 @@ def create_asset_library( authenticated_user: AuthenticatedUser = Depends(get_current_user), asset_library_repository: Any = Depends(get_asset_library_repository), project_repository: Any = Depends(get_project_repository), - workspace_member_repository: WorkspaceMemberRepository = Depends(get_workspace_member_repository), ) -> AssetLibraryResponse: - project = GetProjectUseCase(project_repository).execute(request.project_id) - if project is None or project.workspace_id != request.workspace_id: + project = project_repository.find_by_id(request.project_id) + if project is None: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Project not found") - _require_workspace_member(request.workspace_id, authenticated_user, workspace_member_repository) + if not project.can_access(authenticated_user.id): + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Access denied to project") use_case = CreateAssetLibraryUseCase(asset_library_repository) item = use_case.execute( CreateAssetLibraryCommand( - workspace_id=request.workspace_id, project_id=request.project_id, name=request.name, kind=AssetLibraryKind(request.kind), diff --git a/apps/api/app/api/routes/assets.py b/apps/api/app/api/routes/assets.py index 76d5c050a..7fa7ef839 100644 --- a/apps/api/app/api/routes/assets.py +++ b/apps/api/app/api/routes/assets.py @@ -1,12 +1,10 @@ from typing import Any -from app.api.routes.permissions import require_workspace_member from app.auth import AuthenticatedUser, get_current_user from app.dependencies import ( get_asset_library_repository, get_asset_repository, get_project_repository, - get_workspace_member_repository, ) from app.schemas.asset import AssetResponse, CreateAssetRequest, ListAssetsResponse, UpdateAssetReviewRequest from fastapi import APIRouter, Depends, HTTPException @@ -17,7 +15,6 @@ from packages.application import ( ListAssetsUseCase, ) from packages.domain import AssetStatus, ClassificationStatus -from packages.ports.workspace_member_repository import WorkspaceMemberRepository router = APIRouter() @@ -25,7 +22,6 @@ router = APIRouter() def _to_asset_response(item) -> AssetResponse: return AssetResponse( id=item.id, - workspace_id=item.workspace_id, project_id=item.project_id, library_id=item.library_id, name=item.name, @@ -46,18 +42,27 @@ def _to_asset_response(item) -> AssetResponse: ) +def _check_project_access(project_id: str, user_id: str, project_repository) -> None: + """检查用户是否有项目访问权限""" + project = project_repository.find_by_id(project_id) + if project is None: + raise HTTPException(status_code=404, detail=f"Project {project_id} not found") + if not project.can_access(user_id): + raise HTTPException(status_code=403, detail="Access denied to project") + + @router.get("", response_model=ListAssetsResponse) def list_assets( library_id: str, authenticated_user: AuthenticatedUser = Depends(get_current_user), asset_repository: Any = Depends(get_asset_repository), asset_library_repository: Any = Depends(get_asset_library_repository), - workspace_member_repository: WorkspaceMemberRepository = Depends(get_workspace_member_repository), + project_repository: Any = Depends(get_project_repository), ) -> ListAssetsResponse: library = asset_library_repository.get(library_id) if library is None: raise HTTPException(status_code=404, detail=f"AssetLibrary {library_id} not found") - require_workspace_member(library.workspace_id, authenticated_user, workspace_member_repository) + _check_project_access(library.project_id, authenticated_user.id, project_repository) use_case = ListAssetsUseCase(asset_repository) items = use_case.execute(library_id) return ListAssetsResponse(items=[_to_asset_response(item) for item in items]) @@ -77,12 +82,12 @@ def update_asset_review_status( request: UpdateAssetReviewRequest, authenticated_user: AuthenticatedUser = Depends(get_current_user), asset_repository: Any = Depends(get_asset_repository), - workspace_member_repository: WorkspaceMemberRepository = Depends(get_workspace_member_repository), + project_repository: Any = Depends(get_project_repository), ) -> AssetResponse: item = asset_repository.get(asset_id) if item is None: raise HTTPException(status_code=404, detail=f"Asset {asset_id} not found") - require_workspace_member(item.workspace_id, authenticated_user, workspace_member_repository) + _check_project_access(item.project_id, authenticated_user.id, project_repository) _apply_asset_review_status(item, request.review_status) updated = asset_repository.update(item) return _to_asset_response(updated) @@ -95,20 +100,20 @@ def create_asset( asset_repository: Any = Depends(get_asset_repository), asset_library_repository: Any = Depends(get_asset_library_repository), project_repository: Any = Depends(get_project_repository), - workspace_member_repository: WorkspaceMemberRepository = Depends(get_workspace_member_repository), ) -> AssetResponse: - require_workspace_member(request.workspace_id, authenticated_user, workspace_member_repository) project = project_repository.find_by_id(request.project_id) - if project is None or project.workspace_id != request.workspace_id: + if project is None: raise HTTPException(status_code=404, detail=f"Project {request.project_id} not found") + if not project.can_access(authenticated_user.id): + raise HTTPException(status_code=403, detail="Access denied to project") + library = asset_library_repository.get(request.library_id) - if library is None or library.workspace_id != request.workspace_id or library.project_id != request.project_id: + if library is None or library.project_id != request.project_id: raise HTTPException(status_code=404, detail=f"AssetLibrary {request.library_id} not found") use_case = CreateAssetUseCase(asset_repository) item = use_case.execute( CreateAssetCommand( - workspace_id=request.workspace_id, project_id=request.project_id, library_id=request.library_id, name=request.name, @@ -125,7 +130,7 @@ def create_asset( status=AssetStatus(request.status), classification_status=ClassificationStatus(request.classification_status), quality_score=request.quality_score, - uploaded_by_user_id=authenticated_user.user.id, + uploaded_by_user_id=authenticated_user.id, ) ) return _to_asset_response(item) diff --git a/apps/api/app/api/routes/classification_jobs.py b/apps/api/app/api/routes/classification_jobs.py index e5161bd58..0c713b1f1 100644 --- a/apps/api/app/api/routes/classification_jobs.py +++ b/apps/api/app/api/routes/classification_jobs.py @@ -27,10 +27,9 @@ def get_classification_job( raise HTTPException(status_code=404, detail=f"ClassificationJob {job_id} not found") return ClassificationJobResponse( id=job.id, - workspace_id=job.workspace_id, project_id=job.project_id, asset_id=job.asset_id, - status=job.status.value, + status=job.status, classification=job.classification, confidence=job.confidence, error_message=job.error_message, @@ -45,7 +44,6 @@ def submit_classification_job( use_case = SubmitClassificationJobUseCase(classification_job_repository) job = use_case.execute( SubmitClassificationJobCommand( - workspace_id=request.workspace_id, project_id=request.project_id, asset_id=request.asset_id, ) @@ -53,10 +51,9 @@ def submit_classification_job( celery_app.send_task("worker.classify_asset", args=[job.id]) return ClassificationJobResponse( id=job.id, - workspace_id=job.workspace_id, project_id=job.project_id, asset_id=job.asset_id, - status=job.status.value, + status=job.status, classification=job.classification, confidence=job.confidence, error_message=job.error_message, diff --git a/apps/api/app/api/routes/generated_videos.py b/apps/api/app/api/routes/generated_videos.py index a50b1eb41..5ea7ddc65 100644 --- a/apps/api/app/api/routes/generated_videos.py +++ b/apps/api/app/api/routes/generated_videos.py @@ -1,9 +1,8 @@ from typing import Any -from app.api.routes.permissions import require_workspace_member from app.auth import AuthenticatedUser, get_current_user from app.core.storage import OSSStorageService, get_storage_service -from app.dependencies import get_generated_video_repository, get_project_repository, get_workspace_member_repository +from app.dependencies import get_generated_video_repository, get_project_repository from app.schemas.generated_video import ( GeneratedVideoDownloadUrlResponse, GeneratedVideoResponse, @@ -17,15 +16,22 @@ from packages.application import ( GetGeneratedVideoUseCase, ListGeneratedVideosUseCase, ) -from packages.ports.workspace_member_repository import WorkspaceMemberRepository router = APIRouter() +def _check_project_access(project_id: str, user_id: str, project_repository) -> None: + """检查用户是否有项目访问权限""" + project = project_repository.find_by_id(project_id) + if project is None: + raise HTTPException(status_code=404, detail=f"Project {project_id} not found") + if not project.can_access(user_id): + raise HTTPException(status_code=403, detail="Access denied to project") + + def _to_generated_video_response(item) -> GeneratedVideoResponse: return GeneratedVideoResponse( id=item.id, - workspace_id=item.workspace_id, project_id=item.project_id, generation_task_id=item.generation_task_id, name=item.name, @@ -48,12 +54,12 @@ def list_generated_videos( authenticated_user: AuthenticatedUser = Depends(get_current_user), generated_video_repository: Any = Depends(get_generated_video_repository), project_repository: Any = Depends(get_project_repository), - workspace_member_repository: WorkspaceMemberRepository = Depends(get_workspace_member_repository), ) -> ListGeneratedVideosResponse: project = project_repository.find_by_id(project_id) if project is None: raise HTTPException(status_code=404, detail=f"Project {project_id} not found") - require_workspace_member(project.workspace_id, authenticated_user, workspace_member_repository) + if not project.can_access(authenticated_user.id): + raise HTTPException(status_code=403, detail="Access denied to project") use_case = ListGeneratedVideosUseCase(generated_video_repository) items = use_case.execute(project_id) return ListGeneratedVideosResponse(items=[_to_generated_video_response(item) for item in items]) @@ -64,13 +70,13 @@ def get_generated_video( video_id: str, authenticated_user: AuthenticatedUser = Depends(get_current_user), generated_video_repository: Any = Depends(get_generated_video_repository), - workspace_member_repository: WorkspaceMemberRepository = Depends(get_workspace_member_repository), + project_repository: Any = Depends(get_project_repository), ) -> GeneratedVideoResponse: use_case = GetGeneratedVideoUseCase(generated_video_repository) item = use_case.execute(video_id) if item is None: raise HTTPException(status_code=404, detail=f"GeneratedVideo {video_id} not found") - require_workspace_member(item.workspace_id, authenticated_user, workspace_member_repository) + _check_project_access(item.project_id, authenticated_user.id, project_repository) return _to_generated_video_response(item) @@ -80,29 +86,29 @@ def update_generated_video_review_status( request: UpdateGeneratedVideoReviewRequest, authenticated_user: AuthenticatedUser = Depends(get_current_user), generated_video_repository: Any = Depends(get_generated_video_repository), - workspace_member_repository: WorkspaceMemberRepository = Depends(get_workspace_member_repository), + project_repository: Any = Depends(get_project_repository), ) -> GeneratedVideoResponse: video = generated_video_repository.get(video_id) if video is None: raise HTTPException(status_code=404, detail=f"GeneratedVideo {video_id} not found") - require_workspace_member(video.workspace_id, authenticated_user, workspace_member_repository) + _check_project_access(video.project_id, authenticated_user.id, project_repository) video.review_status = request.review_status updated = generated_video_repository.update(video) return _to_generated_video_response(updated) -@router.get("/{video_id}/download-url", response_model=GeneratedVideoDownloadUrlResponse) +@router.get("/{video_id}/download-url/", response_model=GeneratedVideoDownloadUrlResponse) def get_generated_video_download_url( video_id: str, authenticated_user: AuthenticatedUser = Depends(get_current_user), generated_video_repository: Any = Depends(get_generated_video_repository), - workspace_member_repository: WorkspaceMemberRepository = Depends(get_workspace_member_repository), + project_repository: Any = Depends(get_project_repository), storage_service: OSSStorageService = Depends(get_storage_service), ) -> GeneratedVideoDownloadUrlResponse: video = generated_video_repository.get(video_id) if video is None: raise HTTPException(status_code=404, detail=f"GeneratedVideo {video_id} not found") - require_workspace_member(video.workspace_id, authenticated_user, workspace_member_repository) + _check_project_access(video.project_id, authenticated_user.id, project_repository) use_case = GetGeneratedVideoDownloadUrlUseCase(generated_video_repository) file_url = use_case.execute(video_id) if file_url is None: diff --git a/apps/api/app/api/routes/generation_tasks.py b/apps/api/app/api/routes/generation_tasks.py index 14e9b5e41..ceb3f204c 100644 --- a/apps/api/app/api/routes/generation_tasks.py +++ b/apps/api/app/api/routes/generation_tasks.py @@ -1,6 +1,5 @@ from typing import Any -from app.api.routes.permissions import require_workspace_member from app.auth import AuthenticatedUser, get_current_user from app.core.celery_app import celery_app from app.dependencies import ( @@ -10,7 +9,6 @@ from app.dependencies import ( get_generation_task_repository, get_project_repository, get_project_title_repository, - get_workspace_member_repository, ) from app.schemas.generated_video import ( GeneratedVideoResponse, @@ -28,21 +26,28 @@ from packages.application import ( GetGenerationTaskUseCase, ListGeneratedVideosByTaskUseCase, ) -from packages.ports.workspace_member_repository import WorkspaceMemberRepository router = APIRouter() +def _check_project_access(project_id: str, user_id: str, project_repository) -> None: + """检查用户是否有项目访问权限""" + project = project_repository.find_by_id(project_id) + if project is None: + raise HTTPException(status_code=404, detail=f"Project {project_id} not found") + if not project.can_access(user_id): + raise HTTPException(status_code=403, detail="Access denied to project") + + def _to_generation_task_response(task) -> GenerationTaskResponse: return GenerationTaskResponse( id=task.id, - workspace_id=task.workspace_id, project_id=task.project_id, asset_library_id=task.asset_library_id, strategy_id=task.strategy_id, voice_library_id=task.voice_library_id, edit_plan_id=task.edit_plan_id, - status=task.status.value, + status=task.status, progress=task.progress, result_count=task.result_count, error_message=task.error_message, @@ -52,7 +57,6 @@ def _to_generation_task_response(task) -> GenerationTaskResponse: def _to_generated_video_response(item) -> GeneratedVideoResponse: return GeneratedVideoResponse( id=item.id, - workspace_id=item.workspace_id, project_id=item.project_id, generation_task_id=item.generation_task_id, name=item.name, @@ -92,7 +96,7 @@ def _select_title_id(project_title_repository: Any, project_id: str) -> str: return selected.id -@router.post("/tasks", response_model=GenerationTaskResponse) +@router.post("/tasks/", response_model=GenerationTaskResponse) def create_generation_task( request: CreateGenerationTaskRequest, authenticated_user: AuthenticatedUser = Depends(get_current_user), @@ -101,28 +105,29 @@ def create_generation_task( asset_library_repository: Any = Depends(get_asset_library_repository), asset_repository: Any = Depends(get_asset_repository), project_title_repository: Any = Depends(get_project_title_repository), - workspace_member_repository: WorkspaceMemberRepository = Depends(get_workspace_member_repository), ) -> GenerationTaskResponse: - require_workspace_member(request.workspace_id, authenticated_user, workspace_member_repository) project = project_repository.find_by_id(request.project_id) - if project is None or project.workspace_id != request.workspace_id: + if project is None: raise HTTPException(status_code=404, detail=f"Project {request.project_id} not found") + if not project.can_access(authenticated_user.id): + raise HTTPException(status_code=403, detail="Access denied to project") + library = asset_library_repository.get(request.asset_library_id) - if library is None or library.workspace_id != request.workspace_id or library.project_id != request.project_id: + if library is None or library.project_id != request.project_id: raise HTTPException(status_code=404, detail=f"AssetLibrary {request.asset_library_id} not found") + assets = asset_repository.list_by_library(request.asset_library_id) _ensure_library_has_ready_video_assets(assets) use_case = CreateGenerationTaskUseCase(generation_task_repository) task = use_case.execute( CreateGenerationTaskCommand( - workspace_id=request.workspace_id, project_id=request.project_id, asset_library_id=request.asset_library_id, strategy_id=request.strategy_id or _select_title_id(project_title_repository, request.project_id), voice_library_id=request.voice_library_id, edit_plan_id=request.edit_plan_id, - created_by_user_id=authenticated_user.user.id, + created_by_user_id=authenticated_user.id, ) ) celery_app.send_task("worker.generate_video", args=[task.id]) @@ -134,28 +139,28 @@ def get_generation_task( task_id: str, authenticated_user: AuthenticatedUser = Depends(get_current_user), generation_task_repository: Any = Depends(get_generation_task_repository), - workspace_member_repository: WorkspaceMemberRepository = Depends(get_workspace_member_repository), + project_repository: Any = Depends(get_project_repository), ) -> GenerationTaskResponse: use_case = GetGenerationTaskUseCase(generation_task_repository) task = use_case.execute(task_id) if task is None: raise HTTPException(status_code=404, detail=f"GenerationTask {task_id} not found") - require_workspace_member(task.workspace_id, authenticated_user, workspace_member_repository) + _check_project_access(task.project_id, authenticated_user.id, project_repository) return _to_generation_task_response(task) -@router.get("/tasks/{task_id}/results", response_model=ListGeneratedVideosResponse) +@router.get("/tasks/{task_id}/results/", response_model=ListGeneratedVideosResponse) def list_generation_results( task_id: str, authenticated_user: AuthenticatedUser = Depends(get_current_user), generation_task_repository: Any = Depends(get_generation_task_repository), generated_video_repository: Any = Depends(get_generated_video_repository), - workspace_member_repository: WorkspaceMemberRepository = Depends(get_workspace_member_repository), + project_repository: Any = Depends(get_project_repository), ) -> ListGeneratedVideosResponse: task = generation_task_repository.get(task_id) if task is None: raise HTTPException(status_code=404, detail=f"GenerationTask {task_id} not found") - require_workspace_member(task.workspace_id, authenticated_user, workspace_member_repository) + _check_project_access(task.project_id, authenticated_user.id, project_repository) use_case = ListGeneratedVideosByTaskUseCase(generated_video_repository) items = use_case.execute(task_id) return ListGeneratedVideosResponse(items=[_to_generated_video_response(item) for item in items]) diff --git a/apps/api/app/api/routes/ingest_jobs.py b/apps/api/app/api/routes/ingest_jobs.py index 1c6421055..b7148ae93 100644 --- a/apps/api/app/api/routes/ingest_jobs.py +++ b/apps/api/app/api/routes/ingest_jobs.py @@ -20,7 +20,6 @@ def get_ingest_job( raise ValueError(f"IngestJob {job_id} not found") return IngestJobResponse( id=job.id, - workspace_id=job.workspace_id, project_id=job.project_id, library_id=job.library_id, storage_key=job.storage_key, @@ -38,7 +37,6 @@ def submit_ingest_job( use_case = SubmitIngestJobUseCase(ingest_job_repository) job = use_case.execute( SubmitIngestJobCommand( - workspace_id=request.workspace_id, project_id=request.project_id, library_id=request.library_id, storage_key=request.storage_key, @@ -49,7 +47,6 @@ def submit_ingest_job( return IngestJobResponse( id=job.id, - workspace_id=job.workspace_id, project_id=job.project_id, library_id=job.library_id, storage_key=job.storage_key, diff --git a/apps/api/app/api/routes/permissions.py b/apps/api/app/api/routes/permissions.py deleted file mode 100644 index 4a16d4d95..000000000 --- a/apps/api/app/api/routes/permissions.py +++ /dev/null @@ -1,16 +0,0 @@ -from __future__ import annotations - -from app.auth import AuthenticatedUser -from fastapi import HTTPException - -from packages.ports.workspace_member_repository import WorkspaceMemberRepository - - -def require_workspace_member( - workspace_id: str, - authenticated_user: AuthenticatedUser, - workspace_member_repository: WorkspaceMemberRepository, -) -> None: - member = workspace_member_repository.find_by_workspace_and_user(workspace_id, authenticated_user.user.id) - if member is None: - raise HTTPException(status_code=403, detail="Workspace access denied") diff --git a/apps/api/app/api/routes/projects.py b/apps/api/app/api/routes/projects.py index e77c9ab36..1d51ff9e5 100644 --- a/apps/api/app/api/routes/projects.py +++ b/apps/api/app/api/routes/projects.py @@ -1,13 +1,12 @@ from typing import Any from app.auth import AuthenticatedUser, get_current_user -from app.dependencies import get_project_repository, get_workspace_member_repository +from app.dependencies import get_project_repository from app.schemas.project import ( CreateProjectRequest, ListProjectsResponse, ProjectResponse, ) -from app.api.routes.permissions import require_workspace_member from fastapi import APIRouter, Depends, HTTPException, status from packages.application import ( @@ -15,8 +14,9 @@ from packages.application import ( CreateProjectUseCase, GetProjectUseCase, ListProjectsUseCase, + ShareProjectUseCase, + UnshareProjectUseCase, ) -from packages.ports.workspace_member_repository import WorkspaceMemberRepository router = APIRouter() @@ -24,9 +24,10 @@ router = APIRouter() def _to_project_response(item) -> ProjectResponse: return ProjectResponse( id=item.id, - workspace_id=item.workspace_id, + owner_user_id=item.owner_user_id, name=item.name, description=item.description, + shared_users=item.shared_users, ) @@ -35,26 +36,24 @@ def get_project( project_id: str, authenticated_user: AuthenticatedUser = Depends(get_current_user), project_repository: Any = Depends(get_project_repository), - workspace_member_repository: WorkspaceMemberRepository = Depends(get_workspace_member_repository), ) -> ProjectResponse: use_case = GetProjectUseCase(project_repository) project = use_case.execute(project_id) if project is None: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Project not found") - require_workspace_member(project.workspace_id, authenticated_user, workspace_member_repository) + # 检查用户是否有权限访问该项目 + if not project.can_access(authenticated_user.id): + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Access denied") return _to_project_response(project) @router.get("", response_model=ListProjectsResponse) def list_projects( - workspace_id: str, authenticated_user: AuthenticatedUser = Depends(get_current_user), project_repository: Any = Depends(get_project_repository), - workspace_member_repository: WorkspaceMemberRepository = Depends(get_workspace_member_repository), ) -> ListProjectsResponse: - require_workspace_member(workspace_id, authenticated_user, workspace_member_repository) use_case = ListProjectsUseCase(project_repository) - projects = use_case.execute(workspace_id) + projects = use_case.execute(authenticated_user.id) return ListProjectsResponse(items=[_to_project_response(item) for item in projects]) @@ -63,15 +62,37 @@ def create_project( request: CreateProjectRequest, authenticated_user: AuthenticatedUser = Depends(get_current_user), project_repository: Any = Depends(get_project_repository), - workspace_member_repository: WorkspaceMemberRepository = Depends(get_workspace_member_repository), ) -> ProjectResponse: - require_workspace_member(request.workspace_id, authenticated_user, workspace_member_repository) use_case = CreateProjectUseCase(project_repository) project = use_case.execute( CreateProjectCommand( - workspace_id=request.workspace_id, name=request.name, description=request.description, - ) + ), + owner_user_id=authenticated_user.id, ) return _to_project_response(project) + + +@router.post("/{project_id}/share") +def share_project( + project_id: str, + shared_user_id: str, + authenticated_user: AuthenticatedUser = Depends(get_current_user), + project_repository: Any = Depends(get_project_repository), +) -> ProjectResponse: + use_case = ShareProjectUseCase(project_repository) + project = use_case.execute(project_id, authenticated_user.id, shared_user_id) + return _to_project_response(project) + + +@router.delete("/{project_id}/share/{unshared_user_id}") +def unshare_project( + project_id: str, + unshared_user_id: str, + authenticated_user: AuthenticatedUser = Depends(get_current_user), + project_repository: Any = Depends(get_project_repository), +) -> ProjectResponse: + use_case = UnshareProjectUseCase(project_repository) + project = use_case.execute(project_id, authenticated_user.id, unshared_user_id) + return _to_project_response(project) diff --git a/apps/api/app/api/routes/upload.py b/apps/api/app/api/routes/upload.py index e542acccf..2ad209510 100644 --- a/apps/api/app/api/routes/upload.py +++ b/apps/api/app/api/routes/upload.py @@ -2,7 +2,6 @@ from typing import Any from uuid import uuid4 from app.auth import AuthenticatedUser, get_current_user -from app.api.routes.permissions import require_workspace_member from app.config import get_settings from app.core.celery_app import celery_app from app.core.storage import OSSStorageService, get_storage_service @@ -10,7 +9,6 @@ from app.dependencies import ( get_asset_library_repository, get_ingest_job_repository, get_project_repository, - get_workspace_member_repository, ) from app.schemas.upload import ( DirectUploadCompleteRequest, @@ -22,7 +20,6 @@ from app.schemas.upload import ( from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile, status from packages.application import GetProjectUseCase, SubmitIngestJobCommand, SubmitIngestJobUseCase -from packages.ports.workspace_member_repository import WorkspaceMemberRepository router = APIRouter() @@ -54,30 +51,37 @@ def _validate_mime_type(content_type: str | None) -> str: if base_type not in ALLOWED_MIME_TYPES: raise HTTPException( status_code=status.HTTP_415_UNSUPPORTED_MEDIA_TYPE, - detail=f"File type '{base_type}' is not supported. Allowed types: video, audio, and image files.", + detail=f"File type {base_type} is not supported. Allowed types: video, audio, and image files.", ) return base_type +def _check_project_access(project_id: str, user_id: str, project_repository) -> None: + """检查用户是否有项目访问权限""" + project = project_repository.find_by_id(project_id) + if project is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Project not found") + if not project.can_access(user_id): + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Access denied to project") + + def _require_project_and_library( - workspace_id: str, project_id: str, library_id: str, project_repository: Any, asset_library_repository: Any, ) -> None: project = GetProjectUseCase(project_repository).execute(project_id) - if project is None or project.workspace_id != workspace_id: + if project is None: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Project not found") - libraries = asset_library_repository.list_by_project(project_id) - if not any(item.id == library_id and item.workspace_id == workspace_id for item in libraries): + libraries = asset_library_repository.find_by_project(project_id) + if not any(item.id == library_id for item in libraries): raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Asset library not found") def _submit_ingest_job( - workspace_id: str, project_id: str, library_id: str, storage_key: str, @@ -86,7 +90,6 @@ def _submit_ingest_job( use_case = SubmitIngestJobUseCase(ingest_job_repository) job = use_case.execute( SubmitIngestJobCommand( - workspace_id=workspace_id, project_id=project_id, library_id=library_id, storage_key=storage_key, @@ -96,13 +99,12 @@ def _submit_ingest_job( return job -@router.post("/direct/prepare", response_model=DirectUploadPrepareResponse) +@router.post("/direct/prepare/", response_model=DirectUploadPrepareResponse) async def prepare_direct_upload( request: DirectUploadPrepareRequest, authenticated_user: AuthenticatedUser = Depends(get_current_user), project_repository: Any = Depends(get_project_repository), asset_library_repository: Any = Depends(get_asset_library_repository), - workspace_member_repository: WorkspaceMemberRepository = Depends(get_workspace_member_repository), storage_service: OSSStorageService = Depends(get_storage_service), ) -> DirectUploadPrepareResponse: """创建浏览器直传 OSS 的短期表单签名。""" @@ -117,9 +119,8 @@ async def prepare_direct_upload( # P2-5: 服务端验证 MIME 类型 validated_content_type = _validate_mime_type(request.content_type) - require_workspace_member(request.workspace_id, authenticated_user, workspace_member_repository) + _check_project_access(request.project_id, authenticated_user.id, project_repository) _require_project_and_library( - request.workspace_id, request.project_id, request.library_id, project_repository, @@ -127,7 +128,7 @@ async def prepare_direct_upload( ) file_id = uuid4().hex[:8] - safe_filename = request.filename.replace("/", "_").replace("\\", "_") + safe_filename = request.filename.replace("/", "_").replace("\", "_") storage_key = f"uploads/{file_id}/{safe_filename}" try: payload = storage_service.create_direct_upload_post( @@ -149,20 +150,18 @@ async def prepare_direct_upload( ) -@router.post("/direct/complete", response_model=DirectUploadCompleteResponse) +@router.post("/direct/complete/", response_model=DirectUploadCompleteResponse) async def complete_direct_upload( request: DirectUploadCompleteRequest, authenticated_user: AuthenticatedUser = Depends(get_current_user), ingest_job_repository: Any = Depends(get_ingest_job_repository), project_repository: Any = Depends(get_project_repository), asset_library_repository: Any = Depends(get_asset_library_repository), - workspace_member_repository: WorkspaceMemberRepository = Depends(get_workspace_member_repository), storage_service: OSSStorageService = Depends(get_storage_service), ) -> DirectUploadCompleteResponse: """确认浏览器直传完成并创建导入任务。""" - require_workspace_member(request.workspace_id, authenticated_user, workspace_member_repository) + _check_project_access(request.project_id, authenticated_user.id, project_repository) _require_project_and_library( - request.workspace_id, request.project_id, request.library_id, project_repository, @@ -175,7 +174,6 @@ async def complete_direct_upload( raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Uploaded file not found") job = _submit_ingest_job( - workspace_id=request.workspace_id, project_id=request.project_id, library_id=request.library_id, storage_key=normalized_key, @@ -187,25 +185,23 @@ async def complete_direct_upload( @router.post("", response_model=UploadAssetResponse) async def upload_asset( file: UploadFile = File(..., description="要上传的文件(视频、音频、图片等)"), - workspace_id: str = Form(..., description="工作空间 ID"), project_id: str = Form(..., description="项目 ID"), library_id: str = Form(..., description="素材库 ID"), authenticated_user: AuthenticatedUser = Depends(get_current_user), ingest_job_repository: Any = Depends(get_ingest_job_repository), project_repository: Any = Depends(get_project_repository), asset_library_repository: Any = Depends(get_asset_library_repository), - workspace_member_repository: WorkspaceMemberRepository = Depends(get_workspace_member_repository), storage_service: OSSStorageService = Depends(get_storage_service), ) -> UploadAssetResponse: """上传素材文件并触发导入流水线。""" - require_workspace_member(workspace_id, authenticated_user, workspace_member_repository) - _require_project_and_library(workspace_id, project_id, library_id, project_repository, asset_library_repository) + _check_project_access(project_id, authenticated_user.id, project_repository) + _require_project_and_library(project_id, library_id, project_repository, asset_library_repository) # P2-5: 服务端验证 MIME 类型 validated_content_type = _validate_mime_type(file.content_type) file_id = uuid4().hex[:8] - safe_filename = file.filename.replace("/", "_").replace("\\", "_") if file.filename else "unknown" + safe_filename = file.filename.replace("/", "_").replace("\", "_") if file.filename else "unknown" storage_key = f"uploads/{file_id}/{safe_filename}" file_url = storage_service.upload_file( @@ -215,7 +211,6 @@ async def upload_asset( ) job = _submit_ingest_job( - workspace_id=workspace_id, project_id=project_id, library_id=library_id, storage_key=storage_key, diff --git a/apps/api/app/api/routes/workspaces.py b/apps/api/app/api/routes/workspaces.py deleted file mode 100644 index b8b917ef2..000000000 --- a/apps/api/app/api/routes/workspaces.py +++ /dev/null @@ -1,369 +0,0 @@ -""" -Workspace API routes wired through the canonical API composition root. - -Only the core create/list/detail endpoints are enabled here. Member invitation -and subscription endpoints remain in application use cases but are mounted only -after their persistence repositories are wired and smoke-tested. -""" - -from app.auth import AuthenticatedUser, get_current_user -from app.dependencies import ( - get_user_repository, - get_workspace_invitation_repository, - get_workspace_member_repository, - get_workspace_repository, -) -from fastapi import APIRouter, Depends, HTTPException, status -from pydantic import BaseModel, EmailStr - -from packages.application.workspace.accept_invitation_use_case import AcceptInvitationRequest, AcceptInvitationUseCase -from packages.application.workspace.create_workspace_use_case import CreateWorkspaceRequest, CreateWorkspaceUseCase -from packages.application.workspace.invite_member_use_case import InviteMemberRequest, InviteMemberUseCase -from packages.application.workspace.list_members_use_case import ListMembersRequest, ListMembersUseCase -from packages.application.workspace.list_workspaces_use_case import ( - GetWorkspaceDetailRequest, - GetWorkspaceDetailUseCase, - ListWorkspacesRequest, - ListWorkspacesUseCase, -) -from packages.application.workspace.remove_member_use_case import RemoveMemberRequest, RemoveMemberUseCase -from packages.application.workspace.update_member_role_use_case import UpdateMemberRoleRequest, UpdateMemberRoleUseCase -from packages.ports.user_repository import UserRepository -from packages.ports.workspace_invitation_repository import WorkspaceInvitationRepository -from packages.ports.workspace_member_repository import WorkspaceMemberRepository -from packages.ports.workspace_repository import WorkspaceRepository - -router = APIRouter(prefix="/workspaces", tags=["Workspaces"]) - - -class CreateWorkspaceRequestModel(BaseModel): - name: str - subscription_plan: str = "free" - - -class WorkspaceResponseModel(BaseModel): - workspace_id: str - name: str - subscription_plan: str - max_projects: int - max_storage_gb: int - - -class WorkspaceSummaryModel(BaseModel): - workspace_id: str - name: str - subscription_plan: str - max_projects: int - max_storage_gb: int - member_count: int - user_role: str - - -class WorkspaceListResponseModel(BaseModel): - workspaces: list[WorkspaceSummaryModel] - - -class WorkspaceDetailResponseModel(BaseModel): - workspace_id: str - name: str - owner_user_id: str - subscription_plan: str - subscription_status: str - max_projects: int - max_storage_gb: int - used_storage_gb: float - member_count: int - user_role: str - - -class InviteMemberRequestModel(BaseModel): - email: EmailStr - role: str - - -class InviteMemberResponseModel(BaseModel): - invitation_id: str - invitee_email: str - role: str - expires_at: str - - -class MemberResponseModel(BaseModel): - member_id: str - user_id: str - username: str - email: str - display_name: str - role: str - invited_by: str | None - joined_at: str - - -class MemberListResponseModel(BaseModel): - members: list[MemberResponseModel] - - -class UpdateMemberRoleRequestModel(BaseModel): - role: str - - -class UpdateMemberRoleResponseModel(BaseModel): - user_id: str - old_role: str - new_role: str - - -class AcceptInvitationResponseModel(BaseModel): - workspace_id: str - workspace_name: str - role: str - - -class _NoopInvitationEmailService: - def send_workspace_invitation_email(self, **kwargs): - return False, "Email delivery is disabled for workspace compatibility routes" - - -@router.post("", response_model=WorkspaceResponseModel, status_code=status.HTTP_201_CREATED) -async def create_workspace( - request: CreateWorkspaceRequestModel, - authenticated_user: AuthenticatedUser = Depends(get_current_user), - workspace_repository: WorkspaceRepository = Depends(get_workspace_repository), - workspace_member_repository: WorkspaceMemberRepository = Depends(get_workspace_member_repository), - user_repository: UserRepository = Depends(get_user_repository), -): - use_case = CreateWorkspaceUseCase( - workspace_repository=workspace_repository, - workspace_member_repository=workspace_member_repository, - user_repository=user_repository, - ) - response, error = use_case.execute( - CreateWorkspaceRequest( - name=request.name, - owner_user_id=authenticated_user.user.id, - subscription_plan=request.subscription_plan, - ) - ) - if error or response is None: - raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=error or "Failed to create workspace") - - return WorkspaceResponseModel( - workspace_id=response.workspace_id, - name=response.name, - subscription_plan=response.subscription_plan, - max_projects=response.max_projects, - max_storage_gb=response.max_storage_gb, - ) - - -@router.get("", response_model=WorkspaceListResponseModel) -async def list_workspaces( - authenticated_user: AuthenticatedUser = Depends(get_current_user), - workspace_repository: WorkspaceRepository = Depends(get_workspace_repository), - workspace_member_repository: WorkspaceMemberRepository = Depends(get_workspace_member_repository), -): - use_case = ListWorkspacesUseCase( - workspace_repository=workspace_repository, - workspace_member_repository=workspace_member_repository, - ) - response, error = use_case.execute(ListWorkspacesRequest(user_id=authenticated_user.user.id)) - if error or response is None: - raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=error or "Failed to list workspaces") - - return WorkspaceListResponseModel( - workspaces=[ - WorkspaceSummaryModel( - workspace_id=workspace.workspace_id, - name=workspace.name, - subscription_plan=workspace.subscription_plan, - max_projects=workspace.max_projects, - max_storage_gb=workspace.max_storage_gb, - member_count=workspace.member_count, - user_role=workspace.user_role, - ) - for workspace in response.workspaces - ] - ) - - -@router.post("/invitations/{invitation_token}/accept", response_model=AcceptInvitationResponseModel) -async def accept_invitation( - invitation_token: str, - authenticated_user: AuthenticatedUser = Depends(get_current_user), - workspace_repository: WorkspaceRepository = Depends(get_workspace_repository), - workspace_member_repository: WorkspaceMemberRepository = Depends(get_workspace_member_repository), - workspace_invitation_repository: WorkspaceInvitationRepository = Depends(get_workspace_invitation_repository), - user_repository: UserRepository = Depends(get_user_repository), -): - response, error = AcceptInvitationUseCase( - workspace_repository=workspace_repository, - workspace_member_repository=workspace_member_repository, - workspace_invitation_repository=workspace_invitation_repository, - user_repository=user_repository, - ).execute(AcceptInvitationRequest(invitation_token=invitation_token, user_id=authenticated_user.user.id)) - if error or response is None: - raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=error or "Failed to accept invitation") - - return AcceptInvitationResponseModel( - workspace_id=response.workspace_id, - workspace_name=response.workspace_name, - role=response.role, - ) - - -@router.get("/{workspace_id}", response_model=WorkspaceDetailResponseModel) -async def get_workspace_detail( - workspace_id: str, - authenticated_user: AuthenticatedUser = Depends(get_current_user), - workspace_repository: WorkspaceRepository = Depends(get_workspace_repository), - workspace_member_repository: WorkspaceMemberRepository = Depends(get_workspace_member_repository), -): - use_case = GetWorkspaceDetailUseCase( - workspace_repository=workspace_repository, - workspace_member_repository=workspace_member_repository, - ) - detail, error = use_case.execute( - GetWorkspaceDetailRequest(workspace_id=workspace_id, user_id=authenticated_user.user.id) - ) - if error or detail is None: - raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=error or "Workspace access denied") - - return WorkspaceDetailResponseModel( - workspace_id=detail.workspace_id, - name=detail.name, - owner_user_id=detail.owner_user_id, - subscription_plan=detail.subscription_plan, - subscription_status=detail.subscription_status, - max_projects=detail.max_projects, - max_storage_gb=detail.max_storage_gb, - used_storage_gb=detail.used_storage_gb, - member_count=detail.member_count, - user_role=detail.user_role, - ) - - -@router.post( - "/{workspace_id}/members/invite", response_model=InviteMemberResponseModel, status_code=status.HTTP_201_CREATED -) -async def invite_member( - workspace_id: str, - request: InviteMemberRequestModel, - authenticated_user: AuthenticatedUser = Depends(get_current_user), - workspace_repository: WorkspaceRepository = Depends(get_workspace_repository), - workspace_member_repository: WorkspaceMemberRepository = Depends(get_workspace_member_repository), - workspace_invitation_repository: WorkspaceInvitationRepository = Depends(get_workspace_invitation_repository), - user_repository: UserRepository = Depends(get_user_repository), -): - use_case = InviteMemberUseCase( - workspace_repository=workspace_repository, - workspace_member_repository=workspace_member_repository, - workspace_invitation_repository=workspace_invitation_repository, - user_repository=user_repository, - email_service=_NoopInvitationEmailService(), - ) - response, error = use_case.execute( - InviteMemberRequest( - workspace_id=workspace_id, - inviter_user_id=authenticated_user.user.id, - invitee_email=request.email, - role=request.role, - ) - ) - if error or response is None: - raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=error or "Failed to invite member") - - return InviteMemberResponseModel( - invitation_id=response.invitation_id, - invitee_email=response.invitee_email, - role=response.role, - expires_at=response.expires_at.isoformat(), - ) - - -@router.get("/{workspace_id}/members", response_model=MemberListResponseModel) -async def list_members( - workspace_id: str, - authenticated_user: AuthenticatedUser = Depends(get_current_user), - workspace_repository: WorkspaceRepository = Depends(get_workspace_repository), - workspace_member_repository: WorkspaceMemberRepository = Depends(get_workspace_member_repository), - user_repository: UserRepository = Depends(get_user_repository), -): - use_case = ListMembersUseCase( - workspace_repository=workspace_repository, - workspace_member_repository=workspace_member_repository, - user_repository=user_repository, - ) - response, error = use_case.execute( - ListMembersRequest(workspace_id=workspace_id, requester_user_id=authenticated_user.user.id) - ) - if error or response is None: - raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=error or "Failed to list members") - - return MemberListResponseModel( - members=[ - MemberResponseModel( - member_id=member.member_id, - user_id=member.user_id, - username=member.username, - email=member.email, - display_name=member.display_name, - role=member.role, - invited_by=member.invited_by, - joined_at=member.joined_at.isoformat(), - ) - for member in response.members - ] - ) - - -@router.patch("/{workspace_id}/members/{user_id}/role", response_model=UpdateMemberRoleResponseModel) -async def update_member_role( - workspace_id: str, - user_id: str, - request: UpdateMemberRoleRequestModel, - authenticated_user: AuthenticatedUser = Depends(get_current_user), - workspace_repository: WorkspaceRepository = Depends(get_workspace_repository), - workspace_member_repository: WorkspaceMemberRepository = Depends(get_workspace_member_repository), -): - use_case = UpdateMemberRoleUseCase( - workspace_repository=workspace_repository, - workspace_member_repository=workspace_member_repository, - ) - response, error = use_case.execute( - UpdateMemberRoleRequest( - workspace_id=workspace_id, - requester_user_id=authenticated_user.user.id, - target_user_id=user_id, - new_role=request.role, - ) - ) - if error or response is None: - raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=error or "Failed to update member role") - - return UpdateMemberRoleResponseModel( - user_id=response.user_id, - old_role=response.old_role, - new_role=response.new_role, - ) - - -@router.delete("/{workspace_id}/members/{user_id}", status_code=status.HTTP_204_NO_CONTENT) -async def remove_member( - workspace_id: str, - user_id: str, - authenticated_user: AuthenticatedUser = Depends(get_current_user), - workspace_repository: WorkspaceRepository = Depends(get_workspace_repository), - workspace_member_repository: WorkspaceMemberRepository = Depends(get_workspace_member_repository), -): - success, error = RemoveMemberUseCase( - workspace_repository=workspace_repository, - workspace_member_repository=workspace_member_repository, - ).execute( - RemoveMemberRequest( - workspace_id=workspace_id, - requester_user_id=authenticated_user.user.id, - target_user_id=user_id, - ) - ) - if not success: - raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=error or "Failed to remove member") diff --git a/apps/api/app/dependencies.py b/apps/api/app/dependencies.py index a1d45a039..affa4d10c 100644 --- a/apps/api/app/dependencies.py +++ b/apps/api/app/dependencies.py @@ -31,13 +31,7 @@ from packages.adapters.sqlalchemy_impl.project_title_repository import ( ) from packages.adapters.sqlalchemy_impl.session import build_session_factory from packages.adapters.sqlalchemy_impl.user_repository import SQLAlchemyUserRepository -from packages.adapters.sqlalchemy_impl.workspace_invitation_repository import SQLAlchemyWorkspaceInvitationRepository -from packages.adapters.sqlalchemy_impl.workspace_member_repository import SQLAlchemyWorkspaceMemberRepository -from packages.adapters.sqlalchemy_impl.workspace_repository import SQLAlchemyWorkspaceRepository from packages.ports.user_repository import UserRepository -from packages.ports.workspace_invitation_repository import WorkspaceInvitationRepository -from packages.ports.workspace_member_repository import WorkspaceMemberRepository -from packages.ports.workspace_repository import WorkspaceRepository _engine, _SessionLocal = build_session_factory(settings.DATABASE_URL) @@ -105,24 +99,6 @@ def get_user_repository( return SQLAlchemyUserRepository(session) -def get_workspace_repository( - session: Session = Depends(get_db_session), -) -> WorkspaceRepository: - return SQLAlchemyWorkspaceRepository(session) - - -def get_workspace_member_repository( - session: Session = Depends(get_db_session), -) -> WorkspaceMemberRepository: - return SQLAlchemyWorkspaceMemberRepository(session) - - -def get_workspace_invitation_repository( - session: Session = Depends(get_db_session), -) -> WorkspaceInvitationRepository: - return SQLAlchemyWorkspaceInvitationRepository(session) - - def get_auth_session_store() -> NoopSessionStore | SessionStore: if not settings.ENABLE_REDIS_SESSION: return NoopSessionStore() diff --git a/apps/api/app/schemas/asset.py b/apps/api/app/schemas/asset.py index 21127f98a..5749430d5 100644 --- a/apps/api/app/schemas/asset.py +++ b/apps/api/app/schemas/asset.py @@ -2,7 +2,6 @@ from pydantic import BaseModel, Field class CreateAssetRequest(BaseModel): - workspace_id: str = Field(..., min_length=1) project_id: str = Field(..., min_length=1) library_id: str = Field(..., min_length=1) name: str = Field(..., min_length=1, max_length=100) @@ -28,7 +27,6 @@ class UpdateAssetReviewRequest(BaseModel): class AssetResponse(BaseModel): id: str - workspace_id: str project_id: str library_id: str name: str diff --git a/apps/api/app/schemas/asset_library.py b/apps/api/app/schemas/asset_library.py index bb7b99414..0277fe886 100644 --- a/apps/api/app/schemas/asset_library.py +++ b/apps/api/app/schemas/asset_library.py @@ -2,7 +2,6 @@ from pydantic import BaseModel, Field class CreateAssetLibraryRequest(BaseModel): - workspace_id: str = Field(..., min_length=1) project_id: str = Field(..., min_length=1) name: str = Field(..., min_length=1, max_length=100) kind: str = Field(..., pattern="^(video|voice|image)$") @@ -10,7 +9,6 @@ class CreateAssetLibraryRequest(BaseModel): class AssetLibraryResponse(BaseModel): id: str - workspace_id: str project_id: str name: str kind: str diff --git a/apps/api/app/schemas/classification_job.py b/apps/api/app/schemas/classification_job.py index af4387fbb..f5355bc3f 100644 --- a/apps/api/app/schemas/classification_job.py +++ b/apps/api/app/schemas/classification_job.py @@ -2,14 +2,12 @@ from pydantic import BaseModel, Field class SubmitClassificationJobRequest(BaseModel): - workspace_id: str = Field(..., min_length=1) project_id: str = Field(..., min_length=1) asset_id: str = Field(..., min_length=1) class ClassificationJobResponse(BaseModel): id: str - workspace_id: str project_id: str asset_id: str status: str diff --git a/apps/api/app/schemas/generated_video.py b/apps/api/app/schemas/generated_video.py index c44e57d33..f9b319a06 100644 --- a/apps/api/app/schemas/generated_video.py +++ b/apps/api/app/schemas/generated_video.py @@ -11,7 +11,6 @@ class UpdateGeneratedVideoReviewRequest(BaseModel): class GeneratedVideoResponse(BaseModel): id: str - workspace_id: str project_id: str generation_task_id: str name: str diff --git a/apps/api/app/schemas/generation_task.py b/apps/api/app/schemas/generation_task.py index 372680677..7ab783be4 100644 --- a/apps/api/app/schemas/generation_task.py +++ b/apps/api/app/schemas/generation_task.py @@ -2,7 +2,6 @@ from pydantic import BaseModel, Field class CreateGenerationTaskRequest(BaseModel): - workspace_id: str = Field(..., min_length=1) project_id: str = Field(..., min_length=1) asset_library_id: str = Field(..., min_length=1) strategy_id: str = "" @@ -13,7 +12,6 @@ class CreateGenerationTaskRequest(BaseModel): class GenerationTaskResponse(BaseModel): id: str - workspace_id: str project_id: str asset_library_id: str strategy_id: str diff --git a/apps/api/app/schemas/ingest_job.py b/apps/api/app/schemas/ingest_job.py index ca1ec8387..c20c3820c 100644 --- a/apps/api/app/schemas/ingest_job.py +++ b/apps/api/app/schemas/ingest_job.py @@ -2,7 +2,6 @@ from pydantic import BaseModel, Field class SubmitIngestJobRequest(BaseModel): - workspace_id: str = Field(..., min_length=1) project_id: str = Field(..., min_length=1) library_id: str = Field(..., min_length=1) storage_key: str = Field(..., min_length=1, max_length=255) @@ -10,7 +9,6 @@ class SubmitIngestJobRequest(BaseModel): class IngestJobResponse(BaseModel): id: str - workspace_id: str project_id: str library_id: str storage_key: str diff --git a/apps/api/app/schemas/project.py b/apps/api/app/schemas/project.py index f3cdb4974..f03d99148 100644 --- a/apps/api/app/schemas/project.py +++ b/apps/api/app/schemas/project.py @@ -2,16 +2,16 @@ from pydantic import BaseModel, Field class CreateProjectRequest(BaseModel): - workspace_id: str = Field(..., min_length=1) name: str = Field(..., min_length=1, max_length=100) description: str = Field(default="", max_length=500) class ProjectResponse(BaseModel): id: str - workspace_id: str + owner_user_id: str name: str description: str + shared_users: list[str] = [] class ListProjectsResponse(BaseModel): diff --git a/apps/api/app/schemas/upload.py b/apps/api/app/schemas/upload.py index 8c619eb42..8e36febfd 100644 --- a/apps/api/app/schemas/upload.py +++ b/apps/api/app/schemas/upload.py @@ -8,7 +8,6 @@ class UploadAssetResponse(BaseModel): class DirectUploadPrepareRequest(BaseModel): - workspace_id: str = Field(..., min_length=1) project_id: str = Field(..., min_length=1) library_id: str = Field(..., min_length=1) filename: str = Field(..., min_length=1, max_length=255) @@ -26,7 +25,6 @@ class DirectUploadPrepareResponse(BaseModel): class DirectUploadCompleteRequest(BaseModel): - workspace_id: str = Field(..., min_length=1) project_id: str = Field(..., min_length=1) library_id: str = Field(..., min_length=1) storage_key: str = Field(..., min_length=1, max_length=255) diff --git a/packages/adapters/in_memory/project_repository.py b/packages/adapters/in_memory/project_repository.py index fd4589ccf..8df5e8359 100644 --- a/packages/adapters/in_memory/project_repository.py +++ b/packages/adapters/in_memory/project_repository.py @@ -5,19 +5,27 @@ from packages.domain import Project class InMemoryProjectRepository: def __init__(self): - self._items: dict[str, list[Project]] = {} + self._items: dict[str, Project] = {} # project_id -> Project - def list_by_workspace(self, workspace_id: str) -> list[Project]: - return list(self._items.get(workspace_id, [])) + def save(self, project: Project) -> Project: + self._items[project.id] = project + return project def find_by_id(self, project_id: str) -> Project | None: - for items in self._items.values(): - for project in items: - if project.id == project_id: - return project - return None + return self._items.get(project_id) - def create(self, project: Project) -> Project: - items = self._items.setdefault(project.workspace_id, []) - items.append(project) - return project + def find_by_owner_user_id(self, owner_user_id: str) -> list[Project]: + return [p for p in self._items.values() if p.owner_user_id == owner_user_id] + + def find_accessible_projects(self, user_id: str) -> list[Project]: + """查找用户可访问的所有项目(自己拥有的 + 被共享的)""" + return [p for p in self._items.values() if p.can_access(user_id)] + + def count_by_owner(self, owner_user_id: str) -> int: + return len([p for p in self._items.values() if p.owner_user_id == owner_user_id]) + + def delete(self, project_id: str) -> bool: + if project_id in self._items: + del self._items[project_id] + return True + return False diff --git a/packages/adapters/in_memory/workspace_invitation_repository.py b/packages/adapters/in_memory/workspace_invitation_repository.py deleted file mode 100644 index c94433066..000000000 --- a/packages/adapters/in_memory/workspace_invitation_repository.py +++ /dev/null @@ -1,69 +0,0 @@ -""" -WorkspaceInvitation 仓储 In-Memory 实现 -""" - -from typing import Dict, Optional - -from packages.domain.entities import InvitationStatus, WorkspaceInvitation -from packages.ports.workspace_invitation_repository import WorkspaceInvitationRepository - - -class InMemoryWorkspaceInvitationRepository(WorkspaceInvitationRepository): - """WorkspaceInvitation 仓储内存实现""" - - def __init__(self): - self._invitations: Dict[str, WorkspaceInvitation] = {} - self._token_index: Dict[str, str] = {} # token -> invitation_id - self._workspace_email_index: Dict[tuple[str, str], str] = {} # (workspace_id, email) -> invitation_id - - def save(self, invitation: WorkspaceInvitation) -> None: - """保存邀请""" - self._invitations[invitation.id] = invitation - self._token_index[invitation.invitation_token] = invitation.id - - # 只为 pending 状态的邀请建立索引 - if invitation.status == InvitationStatus.PENDING: - key = (invitation.workspace_id, invitation.invitee_email.lower()) - self._workspace_email_index[key] = invitation.id - else: - # 如果状态改变,清理索引 - key = (invitation.workspace_id, invitation.invitee_email.lower()) - self._workspace_email_index.pop(key, None) - - def find_by_id(self, invitation_id: str) -> Optional[WorkspaceInvitation]: - """根据 ID 查找邀请""" - return self._invitations.get(invitation_id) - - def find_by_token(self, token: str) -> Optional[WorkspaceInvitation]: - """根据令牌查找邀请""" - invitation_id = self._token_index.get(token) - if invitation_id: - return self._invitations.get(invitation_id) - return None - - def find_pending_by_workspace_and_email( - self, - workspace_id: str, - email: str, - ) -> Optional[WorkspaceInvitation]: - """查找 workspace 和邮箱的待处理邀请""" - key = (workspace_id, email.lower()) - invitation_id = self._workspace_email_index.get(key) - if invitation_id: - return self._invitations.get(invitation_id) - return None - - def delete(self, invitation_id: str) -> bool: - """删除邀请""" - invitation = self._invitations.get(invitation_id) - if not invitation: - return False - - # 清理索引 - self._token_index.pop(invitation.invitation_token, None) - key = (invitation.workspace_id, invitation.invitee_email.lower()) - self._workspace_email_index.pop(key, None) - - # 删除邀请 - del self._invitations[invitation_id] - return True diff --git a/packages/adapters/in_memory/workspace_member_repository.py b/packages/adapters/in_memory/workspace_member_repository.py deleted file mode 100644 index a0f83a161..000000000 --- a/packages/adapters/in_memory/workspace_member_repository.py +++ /dev/null @@ -1,86 +0,0 @@ -""" -WorkspaceMember 仓储 In-Memory 实现 -""" - -from typing import Dict, List, Optional - -from packages.domain.entities import WorkspaceMember -from packages.ports.workspace_member_repository import WorkspaceMemberRepository - - -class InMemoryWorkspaceMemberRepository(WorkspaceMemberRepository): - """WorkspaceMember 仓储内存实现""" - - def __init__(self): - self._members: Dict[str, WorkspaceMember] = {} - self._workspace_user_index: Dict[tuple[str, str], str] = {} # (workspace_id, user_id) -> member_id - self._user_index: Dict[str, List[str]] = {} # user_id -> [member_ids] - self._workspace_index: Dict[str, List[str]] = {} # workspace_id -> [member_ids] - - def save(self, member: WorkspaceMember) -> None: - """保存成员""" - self._members[member.id] = member - - # 更新索引 - key = (member.workspace_id, member.user_id) - self._workspace_user_index[key] = member.id - - if member.user_id not in self._user_index: - self._user_index[member.user_id] = [] - if member.id not in self._user_index[member.user_id]: - self._user_index[member.user_id].append(member.id) - - if member.workspace_id not in self._workspace_index: - self._workspace_index[member.workspace_id] = [] - if member.id not in self._workspace_index[member.workspace_id]: - self._workspace_index[member.workspace_id].append(member.id) - - def find_by_id(self, member_id: str) -> Optional[WorkspaceMember]: - """根据 ID 查找成员""" - return self._members.get(member_id) - - def find_by_workspace_and_user( - self, - workspace_id: str, - user_id: str, - ) -> Optional[WorkspaceMember]: - """根据 workspace 和 user 查找成员""" - key = (workspace_id, user_id) - member_id = self._workspace_user_index.get(key) - if member_id: - return self._members.get(member_id) - return None - - def find_by_user(self, user_id: str) -> List[WorkspaceMember]: - """查找用户的所有成员记录""" - member_ids = self._user_index.get(user_id, []) - return [self._members[mid] for mid in member_ids if mid in self._members] - - def find_by_workspace(self, workspace_id: str) -> List[WorkspaceMember]: - """查找 workspace 的所有成员""" - member_ids = self._workspace_index.get(workspace_id, []) - return [self._members[mid] for mid in member_ids if mid in self._members] - - def count_by_workspace(self, workspace_id: str) -> int: - """统计 workspace 的成员数量""" - return len(self._workspace_index.get(workspace_id, [])) - - def delete(self, member_id: str) -> bool: - """删除成员""" - member = self._members.get(member_id) - if not member: - return False - - # 清理索引 - key = (member.workspace_id, member.user_id) - self._workspace_user_index.pop(key, None) - - if member.user_id in self._user_index: - self._user_index[member.user_id].remove(member_id) - - if member.workspace_id in self._workspace_index: - self._workspace_index[member.workspace_id].remove(member_id) - - # 删除成员 - del self._members[member_id] - return True diff --git a/packages/adapters/in_memory/workspace_repository.py b/packages/adapters/in_memory/workspace_repository.py deleted file mode 100644 index 3c3f6606e..000000000 --- a/packages/adapters/in_memory/workspace_repository.py +++ /dev/null @@ -1,30 +0,0 @@ -""" -Workspace 仓储 In-Memory 实现 -""" - -from typing import Dict, Optional - -from packages.domain.entities import Workspace -from packages.ports.workspace_repository import WorkspaceRepository - - -class InMemoryWorkspaceRepository(WorkspaceRepository): - """Workspace 仓储内存实现""" - - def __init__(self): - self._workspaces: Dict[str, Workspace] = {} - - def save(self, workspace: Workspace) -> None: - """保存 Workspace""" - self._workspaces[workspace.id] = workspace - - def find_by_id(self, workspace_id: str) -> Optional[Workspace]: - """根据 ID 查找 Workspace""" - return self._workspaces.get(workspace_id) - - def delete(self, workspace_id: str) -> bool: - """删除 Workspace""" - if workspace_id in self._workspaces: - del self._workspaces[workspace_id] - return True - return False diff --git a/packages/adapters/sqlalchemy_impl/asset_library_repository.py b/packages/adapters/sqlalchemy_impl/asset_library_repository.py index 8928a1be3..5b418084c 100644 --- a/packages/adapters/sqlalchemy_impl/asset_library_repository.py +++ b/packages/adapters/sqlalchemy_impl/asset_library_repository.py @@ -14,7 +14,6 @@ class SQLAlchemyAssetLibraryRepository: return None return AssetLibrary( id=model.id, - workspace_id=model.workspace_id, project_id=model.project_id, name=model.name, kind=AssetLibraryKind(model.kind), @@ -24,15 +23,16 @@ class SQLAlchemyAssetLibraryRepository: updated_at=model.updated_at, ) - def list_by_project(self, project_id: str, kind: AssetLibraryKind | None = None) -> list[AssetLibrary]: - query = self.session.query(AssetLibraryModel).filter(AssetLibraryModel.project_id == project_id) - if kind is not None: - query = query.filter(AssetLibraryModel.kind == kind.value) - models = query.all() + def find_by_id(self, library_id: str) -> AssetLibrary | None: + return self.get(library_id) + + def find_by_project(self, project_id: str) -> list[AssetLibrary]: + models = self.session.query(AssetLibraryModel).filter( + AssetLibraryModel.project_id == project_id + ).all() return [ AssetLibrary( id=model.id, - workspace_id=model.workspace_id, project_id=model.project_id, name=model.name, kind=AssetLibraryKind(model.kind), @@ -47,7 +47,6 @@ class SQLAlchemyAssetLibraryRepository: def create(self, library: AssetLibrary) -> AssetLibrary: model = AssetLibraryModel( id=library.id, - workspace_id=library.workspace_id, project_id=library.project_id, name=library.name, kind=library.kind.value, @@ -59,3 +58,45 @@ class SQLAlchemyAssetLibraryRepository: self.session.add(model) self.session.commit() return library + + def update(self, library: AssetLibrary) -> AssetLibrary: + model = self.session.query(AssetLibraryModel).filter( + AssetLibraryModel.id == library.id + ).first() + if model: + model.project_id = library.project_id + model.name = library.name + model.kind = library.kind.value + model.asset_count = library.asset_count + model.total_size = library.total_size + model.updated_at = library.updated_at + self.session.commit() + return library + + def delete(self, library_id: str) -> bool: + model = self.session.query(AssetLibraryModel).filter( + AssetLibraryModel.id == library_id + ).first() + if model: + self.session.delete(model) + self.session.commit() + return True + return False + + async def increment_asset_count(self, library_id: str, size_delta: int) -> None: + model = self.session.query(AssetLibraryModel).filter( + AssetLibraryModel.id == library_id + ).first() + if model: + model.asset_count = (model.asset_count or 0) + 1 + model.total_size = (model.total_size or 0) + size_delta + self.session.commit() + + async def decrement_asset_count(self, library_id: str, size_delta: int) -> None: + model = self.session.query(AssetLibraryModel).filter( + AssetLibraryModel.id == library_id + ).first() + if model: + model.asset_count = max(0, (model.asset_count or 0) - 1) + model.total_size = max(0, (model.total_size or 0) - size_delta) + self.session.commit() diff --git a/packages/adapters/sqlalchemy_impl/asset_repository.py b/packages/adapters/sqlalchemy_impl/asset_repository.py index c08854703..063edf77e 100644 --- a/packages/adapters/sqlalchemy_impl/asset_repository.py +++ b/packages/adapters/sqlalchemy_impl/asset_repository.py @@ -11,21 +11,41 @@ class SQLAlchemyAssetRepository: def __init__(self, session: Session): self.session = session - def list_by_library(self, library_id: str) -> list[Asset]: - models = self.session.query(AssetModel).filter(AssetModel.asset_library_id == library_id).all() + async def find_by_library( + self, + library_id: str, + skip: int = 0, + limit: int = 100, + ) -> list[Asset]: + models = self.session.query(AssetModel).filter( + AssetModel.asset_library_id == library_id + ).offset(skip).limit(limit).all() return [self._to_domain(model) for model in models] - def get(self, asset_id: str) -> Asset | None: + async def find_by_project( + self, + project_id: str, + skip: int = 0, + limit: int = 100, + ) -> list[Asset]: + models = self.session.query(AssetModel).filter( + AssetModel.project_id == project_id + ).offset(skip).limit(limit).all() + return [self._to_domain(model) for model in models] + + async def find_by_id(self, asset_id: str) -> Asset | None: model = self.session.query(AssetModel).filter(AssetModel.id == asset_id).first() if model is None: return None return self._to_domain(model) - def create(self, asset: Asset) -> Asset: + def get(self, asset_id: str) -> Asset | None: + return self.find_by_id(asset_id) + + async def create(self, asset: Asset) -> Asset: now = datetime.now(timezone.utc) model = AssetModel( id=asset.id, - workspace_id=asset.workspace_id, project_id=asset.project_id, asset_library_id=asset.library_id, name=asset.name, @@ -50,7 +70,7 @@ class SQLAlchemyAssetRepository: self.session.commit() return asset - def update(self, asset: Asset) -> Asset: + async def update(self, asset: Asset) -> Asset: model = self.session.query(AssetModel).filter(AssetModel.id == asset.id).first() if model is None: raise ValueError(f"Asset {asset.id} not found") @@ -72,6 +92,19 @@ class SQLAlchemyAssetRepository: self.session.commit() return asset + async def delete(self, asset_id: str) -> bool: + model = self.session.query(AssetModel).filter(AssetModel.id == asset_id).first() + if model: + self.session.delete(model) + self.session.commit() + return True + return False + + async def count_by_project(self, project_id: str) -> int: + return self.session.query(AssetModel).filter( + AssetModel.project_id == project_id + ).count() + def _to_domain(self, model: AssetModel) -> Asset: metadata = {} if model.classification_result: @@ -88,7 +121,6 @@ class SQLAlchemyAssetRepository: }.get(mime_type, mime_type) return Asset( id=model.id, - workspace_id=model.workspace_id, project_id=model.project_id, library_id=model.asset_library_id, name=model.name, diff --git a/packages/adapters/sqlalchemy_impl/classification_job_repository.py b/packages/adapters/sqlalchemy_impl/classification_job_repository.py index 88711564c..29dc09f75 100644 --- a/packages/adapters/sqlalchemy_impl/classification_job_repository.py +++ b/packages/adapters/sqlalchemy_impl/classification_job_repository.py @@ -1,7 +1,7 @@ from sqlalchemy.orm import Session from packages.adapters.sqlalchemy_impl.models import ClassificationJobModel -from packages.domain import ClassificationJob, ClassificationJobStatus +from packages.domain import ClassificationJob class SQLAlchemyClassificationJobRepository: @@ -11,10 +11,9 @@ class SQLAlchemyClassificationJobRepository: def create(self, job: ClassificationJob) -> ClassificationJob: model = ClassificationJobModel( id=job.id, - workspace_id=job.workspace_id, project_id=job.project_id, asset_id=job.asset_id, - status=job.status.value, + status=job.status, classification=job.classification, confidence=job.confidence, error_message=job.error_message, @@ -31,10 +30,9 @@ class SQLAlchemyClassificationJobRepository: return None return ClassificationJob( id=model.id, - workspace_id=model.workspace_id, project_id=model.project_id, asset_id=model.asset_id, - status=ClassificationJobStatus(model.status), + status=model.status, classification=model.classification, confidence=model.confidence, error_message=model.error_message, @@ -46,7 +44,7 @@ class SQLAlchemyClassificationJobRepository: model = self.session.query(ClassificationJobModel).filter(ClassificationJobModel.id == job.id).first() if model is None: raise ValueError(f"ClassificationJob {job.id} not found") - model.status = job.status.value + model.status = job.status model.classification = job.classification model.confidence = job.confidence model.error_message = job.error_message diff --git a/packages/adapters/sqlalchemy_impl/generated_video_repository.py b/packages/adapters/sqlalchemy_impl/generated_video_repository.py index bfe1056ab..e3513df82 100644 --- a/packages/adapters/sqlalchemy_impl/generated_video_repository.py +++ b/packages/adapters/sqlalchemy_impl/generated_video_repository.py @@ -13,7 +13,6 @@ class SQLAlchemyGeneratedVideoRepository: def create(self, video: GeneratedVideo) -> GeneratedVideo: model = GeneratedVideoModel( id=video.id, - workspace_id=video.workspace_id, project_id=video.project_id, generation_task_id=video.generation_task_id, name=video.name, @@ -40,7 +39,6 @@ class SQLAlchemyGeneratedVideoRepository: return None return GeneratedVideo( id=model.id, - workspace_id=model.workspace_id, project_id=model.project_id, generation_task_id=model.generation_task_id, name=model.name, diff --git a/packages/adapters/sqlalchemy_impl/generation_task_repository.py b/packages/adapters/sqlalchemy_impl/generation_task_repository.py index 2358ac690..91f75449e 100644 --- a/packages/adapters/sqlalchemy_impl/generation_task_repository.py +++ b/packages/adapters/sqlalchemy_impl/generation_task_repository.py @@ -1,7 +1,7 @@ from sqlalchemy.orm import Session from packages.adapters.sqlalchemy_impl.models import GenerationTaskModel -from packages.domain import GenerationTask, GenerationTaskStatus +from packages.domain import GenerationTask class SQLAlchemyGenerationTaskRepository: @@ -11,13 +11,12 @@ class SQLAlchemyGenerationTaskRepository: def create(self, task: GenerationTask) -> GenerationTask: model = GenerationTaskModel( id=task.id, - workspace_id=task.workspace_id, project_id=task.project_id, strategy_id=task.strategy_id, asset_library_id=task.asset_library_id, voice_library_id=task.voice_library_id, edit_plan_id=task.edit_plan_id, - status=task.status.value, + status=task.status, progress=task.progress, result_count=task.result_count, error_message=task.error_message, @@ -36,13 +35,12 @@ class SQLAlchemyGenerationTaskRepository: return None return GenerationTask( id=model.id, - workspace_id=model.workspace_id, project_id=model.project_id, strategy_id=model.strategy_id, asset_library_id=model.asset_library_id, voice_library_id=model.voice_library_id, edit_plan_id=getattr(model, "edit_plan_id", "") or "", - status=GenerationTaskStatus(model.status), + status=model.status, progress=model.progress, result_count=int(model.result_count or 0), error_message=model.error_message, @@ -62,7 +60,7 @@ class SQLAlchemyGenerationTaskRepository: raise ValueError(f"GenerationTask {task.id} not found") model.voice_library_id = task.voice_library_id model.edit_plan_id = task.edit_plan_id - model.status = task.status.value + model.status = task.status model.progress = task.progress model.result_count = task.result_count model.error_message = task.error_message diff --git a/packages/adapters/sqlalchemy_impl/ingest_job_repository.py b/packages/adapters/sqlalchemy_impl/ingest_job_repository.py index 81d3db3ec..ed3a2ff49 100644 --- a/packages/adapters/sqlalchemy_impl/ingest_job_repository.py +++ b/packages/adapters/sqlalchemy_impl/ingest_job_repository.py @@ -11,7 +11,6 @@ class SQLAlchemyIngestJobRepository: def create(self, job: IngestJob) -> IngestJob: model = IngestJobModel( id=job.id, - workspace_id=job.workspace_id, project_id=job.project_id, library_id=job.library_id, storage_key=job.storage_key, @@ -31,7 +30,6 @@ class SQLAlchemyIngestJobRepository: return None return IngestJob( id=model.id, - workspace_id=model.workspace_id, project_id=model.project_id, library_id=model.library_id, storage_key=model.storage_key, diff --git a/packages/adapters/sqlalchemy_impl/models.py b/packages/adapters/sqlalchemy_impl/models.py index 9217703b4..f973c9263 100644 --- a/packages/adapters/sqlalchemy_impl/models.py +++ b/packages/adapters/sqlalchemy_impl/models.py @@ -1,6 +1,6 @@ from datetime import datetime, timezone -from sqlalchemy import Boolean, Column, DateTime, Float, Integer, String, Text, UniqueConstraint, create_engine +from sqlalchemy import Boolean, Column, DateTime, Float, Integer, String, Text, JSON, UniqueConstraint, create_engine from sqlalchemy.orm import declarative_base Base = declarative_base() @@ -20,58 +20,25 @@ class UserModel(Base): password_reset_expires_at = Column(DateTime, nullable=True) last_login_at = Column(DateTime, nullable=True) last_login_ip = Column(String(50), nullable=True) - created_at = Column(DateTime, nullable=False, default=lambda: datetime.now(timezone.utc)) - - -class WorkspaceModel(Base): - __tablename__ = "workspaces" - - id = Column(String(36), primary_key=True) - name = Column(String(100), nullable=False) - owner_user_id = Column(String(36), nullable=False, index=True) + # 订阅相关字段 (移到 User 级别) subscription_plan = Column(String(20), nullable=False, default="free") subscription_status = Column(String(20), nullable=False, default="active") subscription_expires_at = Column(DateTime, nullable=True) + # 配额限制 (移到 User 级别) max_projects = Column(Float, nullable=False, default=3) max_storage_gb = Column(Float, nullable=False, default=10) used_storage_gb = Column(Float, nullable=False, default=0.0) created_at = Column(DateTime, nullable=False, default=lambda: datetime.now(timezone.utc)) -class WorkspaceMemberModel(Base): - __tablename__ = "workspace_members" - __table_args__ = (UniqueConstraint("workspace_id", "user_id", name="uq_workspace_members_workspace_user"),) - - id = Column(String(36), primary_key=True) - workspace_id = Column(String(36), nullable=False, index=True) - user_id = Column(String(36), nullable=False, index=True) - role = Column(String(20), nullable=False) - invited_by = Column(String(36), nullable=True) - joined_at = Column(DateTime, nullable=False, default=lambda: datetime.now(timezone.utc)) - - -class WorkspaceInvitationModel(Base): - __tablename__ = "workspace_invitations" - - id = Column(String(36), primary_key=True) - workspace_id = Column(String(36), nullable=False, index=True) - inviter_user_id = Column(String(36), nullable=False) - invitee_email = Column(String(255), nullable=False, index=True) - role = Column(String(20), nullable=False) - invitation_token = Column(String(255), nullable=False, unique=True, index=True) - status = Column(String(20), nullable=False, default="pending", index=True) - expires_at = Column(DateTime, nullable=True) - accepted_at = Column(DateTime, nullable=True) - created_at = Column(DateTime, nullable=False, default=lambda: datetime.now(timezone.utc)) - - class ProjectModel(Base): __tablename__ = "projects" id = Column(String(32), primary_key=True) - workspace_id = Column(String(32), nullable=False, index=True) + owner_user_id = Column(String(32), nullable=False, index=True) name = Column(String(100), nullable=False) description = Column(Text, nullable=False, default="") + shared_users = Column(JSON, nullable=False, default=list) # 被共享的用户 ID 列表 created_at = Column(DateTime, nullable=False, default=lambda: datetime.now(timezone.utc)) @@ -79,7 +46,6 @@ class AssetLibraryModel(Base): __tablename__ = "asset_libraries" id = Column(String(36), primary_key=True) - workspace_id = Column(String(36), nullable=False, index=True) project_id = Column(String(36), nullable=True, index=True) name = Column(String(255), nullable=False) kind = Column(String(20), nullable=False, index=True) @@ -93,7 +59,6 @@ class AssetModel(Base): __tablename__ = "assets" id = Column(String(36), primary_key=True) - workspace_id = Column(String(36), nullable=False, index=True) project_id = Column(String(36), nullable=False, index=True) asset_library_id = Column(String(36), nullable=False, index=True) name = Column(String(500), nullable=False) @@ -119,7 +84,6 @@ class ProjectTitleModel(Base): __tablename__ = "project_titles" id = Column(String(36), primary_key=True) - workspace_id = Column(String(36), nullable=False, index=True) project_id = Column(String(36), nullable=False, index=True) text = Column(String(200), nullable=False) category = Column(String(50), nullable=False, default="default", index=True) @@ -135,7 +99,6 @@ class EditTemplateModel(Base): __tablename__ = "edit_templates" id = Column(String(32), primary_key=True) - workspace_id = Column(String(32), nullable=False, index=True) project_id = Column(String(32), nullable=False, index=True) name = Column(String(120), nullable=False) description = Column(Text, nullable=False, default="") @@ -151,7 +114,6 @@ class EditPlanModel(Base): __tablename__ = "edit_plans" id = Column(String(32), primary_key=True) - workspace_id = Column(String(32), nullable=False, index=True) project_id = Column(String(32), nullable=False, index=True) template_id = Column(String(32), nullable=False, index=True) asset_library_id = Column(String(32), nullable=False, index=True) @@ -179,7 +141,6 @@ class IngestJobModel(Base): __tablename__ = "ingest_jobs" id = Column(String(32), primary_key=True) - workspace_id = Column(String(32), nullable=False, index=True) project_id = Column(String(32), nullable=False, index=True) library_id = Column(String(32), nullable=False, index=True) storage_key = Column(String(255), nullable=False) @@ -194,7 +155,6 @@ class ClassificationJobModel(Base): __tablename__ = "classification_jobs" id = Column(String(32), primary_key=True) - workspace_id = Column(String(32), nullable=False, index=True) project_id = Column(String(32), nullable=False, index=True) asset_id = Column(String(32), nullable=False, index=True) status = Column(String(20), nullable=False, default="pending") @@ -209,7 +169,6 @@ class GenerationTaskModel(Base): __tablename__ = "generation_tasks" id = Column(String(32), primary_key=True) - workspace_id = Column(String(32), nullable=False, index=True) project_id = Column(String(32), nullable=False, index=True) strategy_id = Column(String(32), nullable=False, default="") asset_library_id = Column(String(32), nullable=False, index=True) @@ -229,7 +188,6 @@ class GeneratedVideoModel(Base): __tablename__ = "generated_videos" id = Column(String(32), primary_key=True) - workspace_id = Column(String(32), nullable=False, index=True) project_id = Column(String(32), nullable=False, index=True) generation_task_id = Column(String(32), nullable=False, index=True) name = Column(String(255), nullable=False) @@ -253,7 +211,6 @@ class TaskModel(Base): id = Column(String(32), primary_key=True) project_id = Column(String(32), nullable=False, index=True) - workspace_id = Column(String(32), nullable=False, index=True) name = Column(String(200), nullable=False) description = Column(Text, nullable=False, default="") status = Column(String(20), nullable=False, default="pending", index=True) @@ -275,7 +232,6 @@ class MilestoneModel(Base): id = Column(String(32), primary_key=True) project_id = Column(String(32), nullable=False, index=True) - workspace_id = Column(String(32), nullable=False, index=True) name = Column(String(200), nullable=False) description = Column(Text, nullable=False, default="") target_date = Column(DateTime, nullable=True) @@ -291,7 +247,6 @@ class TaskIssueModel(Base): id = Column(String(32), primary_key=True) task_id = Column(String(32), nullable=False, index=True) project_id = Column(String(32), nullable=False, index=True) - workspace_id = Column(String(32), nullable=False, index=True) title = Column(String(200), nullable=False) description = Column(Text, nullable=False, default="") resolved = Column(Boolean, nullable=False, default=False) diff --git a/packages/adapters/sqlalchemy_impl/project_repository.py b/packages/adapters/sqlalchemy_impl/project_repository.py index 7bc78f603..963fd0681 100644 --- a/packages/adapters/sqlalchemy_impl/project_repository.py +++ b/packages/adapters/sqlalchemy_impl/project_repository.py @@ -11,15 +11,33 @@ class SQLAlchemyProjectRepository: def _to_entity(self, model: ProjectModel) -> Project: return Project( id=model.id, - workspace_id=model.workspace_id, + owner_user_id=model.owner_user_id, name=model.name, description=model.description, + shared_users=model.shared_users or [], created_at=model.created_at, ) - def list_by_workspace(self, workspace_id: str) -> list[Project]: - models = self.session.query(ProjectModel).filter(ProjectModel.workspace_id == workspace_id).all() - return [self._to_entity(model) for model in models] + def save(self, project: Project) -> Project: + """保存项目(创建或更新)""" + existing = self.session.query(ProjectModel).filter(ProjectModel.id == project.id).first() + if existing: + existing.owner_user_id = project.owner_user_id + existing.name = project.name + existing.description = project.description + existing.shared_users = project.shared_users + else: + model = ProjectModel( + id=project.id, + owner_user_id=project.owner_user_id, + name=project.name, + description=project.description, + shared_users=project.shared_users, + created_at=project.created_at, + ) + self.session.add(model) + self.session.commit() + return project def find_by_id(self, project_id: str) -> Project | None: model = self.session.query(ProjectModel).filter(ProjectModel.id == project_id).first() @@ -27,14 +45,36 @@ class SQLAlchemyProjectRepository: return None return self._to_entity(model) - def create(self, project: Project) -> Project: - model = ProjectModel( - id=project.id, - workspace_id=project.workspace_id, - name=project.name, - description=project.description, - created_at=project.created_at, - ) - self.session.add(model) + def find_by_owner_user_id(self, owner_user_id: str) -> list[Project]: + """根据所有者用户 ID 查找项目""" + models = self.session.query(ProjectModel).filter( + ProjectModel.owner_user_id == owner_user_id + ).all() + return [self._to_entity(model) for model in models] + + def find_accessible_projects(self, user_id: str) -> list[Project]: + """查找用户可访问的所有项目(自己拥有的 + 被共享的)""" + from sqlalchemy import or_ + + models = self.session.query(ProjectModel).filter( + or_( + ProjectModel.owner_user_id == user_id, + ProjectModel.shared_users.contains([user_id]) + ) + ).all() + return [self._to_entity(model) for model in models] + + def count_by_owner(self, owner_user_id: str) -> int: + """统计用户的项目数量""" + return self.session.query(ProjectModel).filter( + ProjectModel.owner_user_id == owner_user_id + ).count() + + def delete(self, project_id: str) -> bool: + """删除项目""" + model = self.session.query(ProjectModel).filter(ProjectModel.id == project_id).first() + if model is None: + return False + self.session.delete(model) self.session.commit() - return project + return True diff --git a/packages/adapters/sqlalchemy_impl/workspace_invitation_repository.py b/packages/adapters/sqlalchemy_impl/workspace_invitation_repository.py deleted file mode 100644 index 9958be1da..000000000 --- a/packages/adapters/sqlalchemy_impl/workspace_invitation_repository.py +++ /dev/null @@ -1,79 +0,0 @@ -from __future__ import annotations - -from sqlalchemy.orm import Session - -from packages.adapters.sqlalchemy_impl.models import WorkspaceInvitationModel -from packages.domain.entities import WorkspaceInvitation -from packages.ports.workspace_invitation_repository import WorkspaceInvitationRepository - - -class SQLAlchemyWorkspaceInvitationRepository(WorkspaceInvitationRepository): - def __init__(self, session: Session): - self.session = session - - def save(self, invitation: WorkspaceInvitation) -> None: - model = self.session.get(WorkspaceInvitationModel, invitation.id) - if model is None: - model = WorkspaceInvitationModel(id=invitation.id) - self.session.add(model) - - model.workspace_id = invitation.workspace_id - model.inviter_user_id = invitation.inviter_user_id - model.invitee_email = invitation.invitee_email.strip().lower() - model.role = str(invitation.role) - model.invitation_token = invitation.invitation_token - model.status = str(invitation.status) - model.expires_at = invitation.expires_at - model.accepted_at = invitation.accepted_at - model.created_at = invitation.created_at - - self.session.commit() - self.session.refresh(model) - - def find_by_id(self, invitation_id: str) -> WorkspaceInvitation | None: - return self._to_entity(self.session.get(WorkspaceInvitationModel, invitation_id)) - - def find_by_token(self, token: str) -> WorkspaceInvitation | None: - model = ( - self.session.query(WorkspaceInvitationModel) - .filter(WorkspaceInvitationModel.invitation_token == token) - .first() - ) - return self._to_entity(model) - - def find_pending_by_workspace_and_email(self, workspace_id: str, email: str) -> WorkspaceInvitation | None: - model = ( - self.session.query(WorkspaceInvitationModel) - .filter( - WorkspaceInvitationModel.workspace_id == workspace_id, - WorkspaceInvitationModel.invitee_email == email.strip().lower(), - WorkspaceInvitationModel.status == "pending", - ) - .first() - ) - return self._to_entity(model) - - def delete(self, invitation_id: str) -> bool: - model = self.session.get(WorkspaceInvitationModel, invitation_id) - if model is None: - return False - self.session.delete(model) - self.session.commit() - return True - - @staticmethod - def _to_entity(model: WorkspaceInvitationModel | None) -> WorkspaceInvitation | None: - if model is None: - return None - return WorkspaceInvitation( - id=model.id, - workspace_id=model.workspace_id, - inviter_user_id=model.inviter_user_id, - invitee_email=model.invitee_email, - role=model.role, - invitation_token=model.invitation_token, - status=model.status, - expires_at=model.expires_at, - accepted_at=model.accepted_at, - created_at=model.created_at, - ) diff --git a/packages/adapters/sqlalchemy_impl/workspace_member_repository.py b/packages/adapters/sqlalchemy_impl/workspace_member_repository.py deleted file mode 100644 index ef0f3551b..000000000 --- a/packages/adapters/sqlalchemy_impl/workspace_member_repository.py +++ /dev/null @@ -1,102 +0,0 @@ -from __future__ import annotations - -from typing import List, Optional, TYPE_CHECKING - -from sqlalchemy.orm import Session, joinedload - -from packages.adapters.sqlalchemy_impl.models import WorkspaceMemberModel -from packages.domain.entities import WorkspaceMember -from packages.ports.workspace_member_repository import WorkspaceMemberRepository - -if TYPE_CHECKING: - from packages.adapters.sqlalchemy_impl.models import UserModel - - -class SQLAlchemyWorkspaceMemberRepository(WorkspaceMemberRepository): - def __init__(self, session: Session): - self.session = session - - def save(self, member: WorkspaceMember) -> None: - model = self.session.get(WorkspaceMemberModel, member.id) - if model is None: - model = ( - self.session.query(WorkspaceMemberModel) - .filter( - WorkspaceMemberModel.workspace_id == member.workspace_id, - WorkspaceMemberModel.user_id == member.user_id, - ) - .first() - ) - if model is None: - model = WorkspaceMemberModel(id=member.id) - self.session.add(model) - - model.workspace_id = member.workspace_id - model.user_id = member.user_id - model.role = str(member.role) - model.invited_by = member.invited_by - model.joined_at = member.joined_at - - self.session.commit() - self.session.refresh(model) - - def find_by_id(self, member_id: str) -> WorkspaceMember | None: - return self._to_entity(self.session.get(WorkspaceMemberModel, member_id)) - - def find_by_workspace_and_user(self, workspace_id: str, user_id: str) -> WorkspaceMember | None: - model = ( - self.session.query(WorkspaceMemberModel) - .filter(WorkspaceMemberModel.workspace_id == workspace_id, WorkspaceMemberModel.user_id == user_id) - .first() - ) - return self._to_entity(model) - - def find_by_user(self, user_id: str) -> list[WorkspaceMember]: - models = ( - self.session.query(WorkspaceMemberModel) - .filter(WorkspaceMemberModel.user_id == user_id) - .order_by(WorkspaceMemberModel.joined_at.desc()) - .all() - ) - return [self._to_entity(model) for model in models if model is not None] - - def find_by_workspace(self, workspace_id: str) -> list[WorkspaceMember]: - """查找 workspace 的所有成员,使用 JOIN 预加载用户数据避免 N+1 查询问题。 - - P2-3 Fix: 使用 joinedload 预加载关联的用户数据, - 这样在访问 member.user 时不会触发额外的数据库查询。 - """ - models = ( - self.session.query(WorkspaceMemberModel) - .filter(WorkspaceMemberModel.workspace_id == workspace_id) - .options(joinedload(WorkspaceMemberModel.user)) # P2-3: JOIN 预加载用户数据 - .order_by(WorkspaceMemberModel.joined_at.asc()) - .all() - ) - return [self._to_entity(model) for model in models if model is not None] - - def count_by_workspace(self, workspace_id: str) -> int: - return ( - self.session.query(WorkspaceMemberModel).filter(WorkspaceMemberModel.workspace_id == workspace_id).count() - ) - - def delete(self, member_id: str) -> bool: - model = self.session.get(WorkspaceMemberModel, member_id) - if model is None: - return False - self.session.delete(model) - self.session.commit() - return True - - @staticmethod - def _to_entity(model: WorkspaceMemberModel | None) -> WorkspaceMember | None: - if model is None: - return None - return WorkspaceMember( - id=model.id, - workspace_id=model.workspace_id, - user_id=model.user_id, - role=model.role, - invited_by=model.invited_by, - joined_at=model.joined_at, - ) diff --git a/packages/adapters/sqlalchemy_impl/workspace_repository.py b/packages/adapters/sqlalchemy_impl/workspace_repository.py deleted file mode 100644 index 2903a699e..000000000 --- a/packages/adapters/sqlalchemy_impl/workspace_repository.py +++ /dev/null @@ -1,59 +0,0 @@ -from __future__ import annotations - -from sqlalchemy.orm import Session - -from packages.adapters.sqlalchemy_impl.models import WorkspaceModel -from packages.domain.entities import Workspace -from packages.ports.workspace_repository import WorkspaceRepository - - -class SQLAlchemyWorkspaceRepository(WorkspaceRepository): - def __init__(self, session: Session): - self.session = session - - def save(self, workspace: Workspace) -> None: - model = self.session.get(WorkspaceModel, workspace.id) - if model is None: - model = WorkspaceModel(id=workspace.id) - self.session.add(model) - - model.name = workspace.name - model.owner_user_id = workspace.owner_user_id - model.subscription_plan = workspace.subscription_plan - model.subscription_status = workspace.subscription_status - model.subscription_expires_at = workspace.subscription_expires_at - model.max_projects = workspace.max_projects - model.max_storage_gb = workspace.max_storage_gb - model.used_storage_gb = workspace.used_storage_gb - model.created_at = workspace.created_at - - self.session.commit() - self.session.refresh(model) - - def find_by_id(self, workspace_id: str) -> Workspace | None: - return self._to_entity(self.session.get(WorkspaceModel, workspace_id)) - - def delete(self, workspace_id: str) -> bool: - model = self.session.get(WorkspaceModel, workspace_id) - if model is None: - return False - self.session.delete(model) - self.session.commit() - return True - - @staticmethod - def _to_entity(model: WorkspaceModel | None) -> Workspace | None: - if model is None: - return None - return Workspace( - id=model.id, - name=model.name, - owner_user_id=model.owner_user_id, - subscription_plan=model.subscription_plan, - subscription_status=model.subscription_status, - subscription_expires_at=model.subscription_expires_at, - max_projects=int(model.max_projects), - max_storage_gb=int(model.max_storage_gb), - used_storage_gb=float(model.used_storage_gb), - created_at=model.created_at, - ) diff --git a/packages/application/asset_libraries.py b/packages/application/asset_libraries.py index b18ff3f09..105b8a267 100644 --- a/packages/application/asset_libraries.py +++ b/packages/application/asset_libraries.py @@ -8,7 +8,6 @@ from packages.ports.asset_library_repository import AssetLibraryRepository @dataclass(slots=True) class CreateAssetLibraryCommand: - workspace_id: str project_id: str name: str kind: AssetLibraryKind @@ -18,10 +17,10 @@ class ListAssetLibrariesUseCase: def __init__(self, asset_library_repository: AssetLibraryRepository): self.asset_library_repository = asset_library_repository - def execute(self, project_id: str, kind: AssetLibraryKind | None = None) -> list[AssetLibrary]: + def execute(self, project_id: str) -> list[AssetLibrary]: if not project_id.strip(): raise ValueError("project_id 不能为空") - return self.asset_library_repository.list_by_project(project_id.strip(), kind=kind) + return self.asset_library_repository.find_by_project(project_id.strip()) class CreateAssetLibraryUseCase: @@ -30,7 +29,6 @@ class CreateAssetLibraryUseCase: def execute(self, command: CreateAssetLibraryCommand) -> AssetLibrary: library = AssetLibrary.create( - workspace_id=command.workspace_id, project_id=command.project_id, name=command.name, kind=command.kind, diff --git a/packages/application/assets.py b/packages/application/assets.py index bc4813124..d38eceadb 100644 --- a/packages/application/assets.py +++ b/packages/application/assets.py @@ -8,7 +8,6 @@ from packages.ports.asset_repository import AssetRepository @dataclass(slots=True) class CreateAssetCommand: - workspace_id: str project_id: str library_id: str name: str @@ -35,7 +34,7 @@ class ListAssetsUseCase: def execute(self, library_id: str) -> list[Asset]: if not library_id.strip(): raise ValueError("library_id 不能为空") - return self.asset_repository.list_by_library(library_id.strip()) + return self.asset_repository.find_by_library(library_id.strip()) class CreateAssetUseCase: @@ -44,7 +43,6 @@ class CreateAssetUseCase: def execute(self, command: CreateAssetCommand) -> Asset: asset = Asset.create( - workspace_id=command.workspace_id, project_id=command.project_id, library_id=command.library_id, name=command.name, diff --git a/packages/application/classification_jobs.py b/packages/application/classification_jobs.py index 2d0db6edc..e4f682d22 100644 --- a/packages/application/classification_jobs.py +++ b/packages/application/classification_jobs.py @@ -1,6 +1,7 @@ from __future__ import annotations from dataclasses import dataclass +from uuid import uuid4 from packages.domain import ClassificationJob from packages.ports.classification_job_repository import ClassificationJobRepository @@ -8,7 +9,6 @@ from packages.ports.classification_job_repository import ClassificationJobReposi @dataclass(slots=True) class SubmitClassificationJobCommand: - workspace_id: str project_id: str asset_id: str @@ -18,9 +18,13 @@ class SubmitClassificationJobUseCase: self.classification_job_repository = classification_job_repository def execute(self, command: SubmitClassificationJobCommand) -> ClassificationJob: - job = ClassificationJob.create( - workspace_id=command.workspace_id, + job = ClassificationJob( + id=uuid4().hex, project_id=command.project_id, asset_id=command.asset_id, + status="pending", + classification="", + confidence=0.0, + error_message="", ) return self.classification_job_repository.create(job) diff --git a/packages/application/generation_tasks.py b/packages/application/generation_tasks.py index 7ff8e3c7f..4dab04c7a 100644 --- a/packages/application/generation_tasks.py +++ b/packages/application/generation_tasks.py @@ -1,6 +1,7 @@ from __future__ import annotations from dataclasses import dataclass +from uuid import uuid4 from packages.domain import GenerationTask from packages.ports.generation_task_repository import GenerationTaskRepository @@ -8,7 +9,6 @@ from packages.ports.generation_task_repository import GenerationTaskRepository @dataclass(slots=True) class CreateGenerationTaskCommand: - workspace_id: str project_id: str asset_library_id: str strategy_id: str = "" @@ -22,13 +22,19 @@ class CreateGenerationTaskUseCase: self.generation_task_repository = generation_task_repository def execute(self, command: CreateGenerationTaskCommand) -> GenerationTask: - task = GenerationTask.create( - workspace_id=command.workspace_id, + task = GenerationTask( + id=uuid4().hex, project_id=command.project_id, asset_library_id=command.asset_library_id, strategy_id=command.strategy_id, voice_library_id=command.voice_library_id, edit_plan_id=command.edit_plan_id, + status="pending", + progress=0.0, + result_count=0, + error_message="", + started_at=None, + completed_at=None, created_by_user_id=command.created_by_user_id, ) return self.generation_task_repository.create(task) diff --git a/packages/application/ingest_jobs.py b/packages/application/ingest_jobs.py index 00d5620ba..bbb05553e 100644 --- a/packages/application/ingest_jobs.py +++ b/packages/application/ingest_jobs.py @@ -8,7 +8,6 @@ from packages.ports.ingest_job_repository import IngestJobRepository @dataclass(slots=True) class SubmitIngestJobCommand: - workspace_id: str project_id: str library_id: str storage_key: str @@ -20,7 +19,6 @@ class SubmitIngestJobUseCase: def execute(self, command: SubmitIngestJobCommand) -> IngestJob: job = IngestJob.create( - workspace_id=command.workspace_id, project_id=command.project_id, library_id=command.library_id, storage_key=command.storage_key, diff --git a/packages/application/projects.py b/packages/application/projects.py index f9ac955b7..842c97eb7 100644 --- a/packages/application/projects.py +++ b/packages/application/projects.py @@ -8,7 +8,6 @@ from packages.ports import ProjectRepository @dataclass(slots=True) class CreateProjectCommand: - workspace_id: str name: str description: str = "" @@ -17,10 +16,10 @@ class ListProjectsUseCase: def __init__(self, project_repository: ProjectRepository): self.project_repository = project_repository - def execute(self, workspace_id: str) -> list[Project]: - if not workspace_id.strip(): - raise ValueError("workspace_id 不能为空") - return self.project_repository.list_by_workspace(workspace_id.strip()) + def execute(self, user_id: str) -> list[Project]: + if not user_id.strip(): + raise ValueError("user_id 不能为空") + return self.project_repository.find_accessible_projects(user_id.strip()) class GetProjectUseCase: @@ -37,10 +36,42 @@ class CreateProjectUseCase: def __init__(self, project_repository: ProjectRepository): self.project_repository = project_repository - def execute(self, command: CreateProjectCommand) -> Project: + def execute(self, command: CreateProjectCommand, owner_user_id: str) -> Project: project = Project.create( - workspace_id=command.workspace_id, + owner_user_id=owner_user_id, name=command.name, description=command.description, ) - return self.project_repository.create(project) + return self.project_repository.save(project) + + +class ShareProjectUseCase: + def __init__(self, project_repository: ProjectRepository): + self.project_repository = project_repository + + def execute(self, project_id: str, owner_user_id: str, shared_user_id: str) -> Project: + project = self.project_repository.find_by_id(project_id) + if not project: + raise ValueError("项目不存在") + if not project.is_owner(owner_user_id): + raise ValueError("只有项目所有者可以共享项目") + if shared_user_id not in project.shared_users: + project.shared_users.append(shared_user_id) + self.project_repository.save(project) + return project + + +class UnshareProjectUseCase: + def __init__(self, project_repository: ProjectRepository): + self.project_repository = project_repository + + def execute(self, project_id: str, owner_user_id: str, shared_user_id: str) -> Project: + project = self.project_repository.find_by_id(project_id) + if not project: + raise ValueError("项目不存在") + if not project.is_owner(owner_user_id): + raise ValueError("只有项目所有者可以取消共享") + if shared_user_id in project.shared_users: + project.shared_users.remove(shared_user_id) + self.project_repository.save(project) + return project diff --git a/packages/application/workspace/__init__.py b/packages/application/workspace/__init__.py deleted file mode 100644 index 165ad98bd..000000000 --- a/packages/application/workspace/__init__.py +++ /dev/null @@ -1,89 +0,0 @@ -"""Workspace 相关 Use Cases""" - -from packages.application.workspace.accept_invitation_use_case import ( - AcceptInvitationRequest, - AcceptInvitationResponse, - AcceptInvitationUseCase, - DeclineInvitationRequest, - DeclineInvitationUseCase, -) -from packages.application.workspace.create_workspace_use_case import ( - CreateWorkspaceRequest, - CreateWorkspaceResponse, - CreateWorkspaceUseCase, -) -from packages.application.workspace.invite_member_use_case import ( - InviteMemberRequest, - InviteMemberResponse, - InviteMemberUseCase, -) -from packages.application.workspace.list_members_use_case import ( - ListMembersRequest, - ListMembersResponse, - ListMembersUseCase, - MemberInfo, -) -from packages.application.workspace.list_workspaces_use_case import ( - GetWorkspaceDetailRequest, - GetWorkspaceDetailUseCase, - ListWorkspacesRequest, - ListWorkspacesResponse, - ListWorkspacesUseCase, - WorkspaceDetailInfo, - WorkspaceInfo, -) -from packages.application.workspace.remove_member_use_case import ( - LeaveWorkspaceRequest, - LeaveWorkspaceUseCase, - RemoveMemberRequest, - RemoveMemberUseCase, -) -from packages.application.workspace.subscription_use_case import ( - CancelSubscriptionRequest, - CancelSubscriptionUseCase, - UpgradeSubscriptionRequest, - UpgradeSubscriptionResponse, - UpgradeSubscriptionUseCase, -) -from packages.application.workspace.update_member_role_use_case import ( - UpdateMemberRoleRequest, - UpdateMemberRoleResponse, - UpdateMemberRoleUseCase, -) - -__all__ = [ - "CreateWorkspaceUseCase", - "CreateWorkspaceRequest", - "CreateWorkspaceResponse", - "InviteMemberUseCase", - "InviteMemberRequest", - "InviteMemberResponse", - "AcceptInvitationUseCase", - "AcceptInvitationRequest", - "AcceptInvitationResponse", - "DeclineInvitationUseCase", - "DeclineInvitationRequest", - "RemoveMemberUseCase", - "RemoveMemberRequest", - "LeaveWorkspaceUseCase", - "LeaveWorkspaceRequest", - "UpdateMemberRoleUseCase", - "UpdateMemberRoleRequest", - "UpdateMemberRoleResponse", - "ListWorkspacesUseCase", - "ListWorkspacesRequest", - "ListWorkspacesResponse", - "GetWorkspaceDetailUseCase", - "GetWorkspaceDetailRequest", - "WorkspaceInfo", - "WorkspaceDetailInfo", - "ListMembersUseCase", - "ListMembersRequest", - "ListMembersResponse", - "MemberInfo", - "UpgradeSubscriptionUseCase", - "UpgradeSubscriptionRequest", - "UpgradeSubscriptionResponse", - "CancelSubscriptionUseCase", - "CancelSubscriptionRequest", -] diff --git a/packages/application/workspace/accept_invitation_use_case.py b/packages/application/workspace/accept_invitation_use_case.py deleted file mode 100644 index b2ab9ad3a..000000000 --- a/packages/application/workspace/accept_invitation_use_case.py +++ /dev/null @@ -1,195 +0,0 @@ -""" -接受/拒绝邀请 Use Case -""" - -from datetime import datetime, timezone -from typing import Optional -from uuid import uuid4 - -from packages.domain.entities import InvitationStatus, WorkspaceMember - - -class AcceptInvitationRequest: - """接受邀请请求""" - - def __init__(self, invitation_token: str, user_id: str): - self.invitation_token = invitation_token - self.user_id = user_id - - -class AcceptInvitationResponse: - """接受邀请响应""" - - def __init__( - self, - workspace_id: str, - workspace_name: str, - role: str, - ): - self.workspace_id = workspace_id - self.workspace_name = workspace_name - self.role = role - - -class AcceptInvitationUseCase: - """接受邀请用例""" - - def __init__( - self, - workspace_repository, - workspace_member_repository, - workspace_invitation_repository, - user_repository, - ): - self.workspace_repository = workspace_repository - self.workspace_member_repository = workspace_member_repository - self.workspace_invitation_repository = workspace_invitation_repository - self.user_repository = user_repository - - def execute(self, request: AcceptInvitationRequest) -> tuple[Optional[AcceptInvitationResponse], Optional[str]]: - """ - 执行接受邀请 - - Args: - request: 接受请求 - - Returns: - (响应, 错误信息) - """ - try: - # 1. 验证输入 - if not request.invitation_token: - return None, "Invitation token is required" - - if not request.user_id: - return None, "User ID is required" - - # 2. 查找邀请 - invitation = self.workspace_invitation_repository.find_by_token(request.invitation_token) - if not invitation: - return None, "Invalid invitation token" - - # 3. 检查邀请状态 - if invitation.status != InvitationStatus.PENDING: - return None, f"Invitation has already been {invitation.status}" - - # 4. 检查是否过期 - expires_at = invitation.expires_at - if expires_at and expires_at.tzinfo is None: - expires_at = expires_at.replace(tzinfo=timezone.utc) - if expires_at and datetime.now(timezone.utc) > expires_at: - # 更新状态为过期 - invitation.status = InvitationStatus.EXPIRED - self.workspace_invitation_repository.save(invitation) - return None, "Invitation has expired" - - # 5. 验证用户存在 - user = self.user_repository.find_by_id(request.user_id) - if not user: - return None, "User not found" - - # 6. 验证用户邮箱匹配 - if user.email.lower() != invitation.invitee_email.lower(): - return None, "This invitation is for a different email address" - - # 7. 验证 Workspace 存在 - workspace = self.workspace_repository.find_by_id(invitation.workspace_id) - if not workspace: - return None, "Workspace not found" - - # 8. 检查用户是否已经是成员 - existing_member = self.workspace_member_repository.find_by_workspace_and_user( - invitation.workspace_id, - request.user_id, - ) - if existing_member: - # 已经是成员,标记邀请为已接受 - invitation.status = InvitationStatus.ACCEPTED - invitation.accepted_at = datetime.now(timezone.utc) - self.workspace_invitation_repository.save(invitation) - - return ( - AcceptInvitationResponse( - workspace_id=workspace.id, - workspace_name=workspace.name, - role=existing_member.role, - ), - None, - ) - - # 9. 创建成员记录 - member = WorkspaceMember( - id=uuid4().hex, - workspace_id=invitation.workspace_id, - user_id=request.user_id, - role=invitation.role, - invited_by=invitation.inviter_user_id, - joined_at=datetime.now(timezone.utc), - ) - - self.workspace_member_repository.save(member) - - # 10. 更新邀请状态 - invitation.status = InvitationStatus.ACCEPTED - invitation.accepted_at = datetime.now(timezone.utc) - self.workspace_invitation_repository.save(invitation) - - # 11. 返回响应 - return ( - AcceptInvitationResponse( - workspace_id=workspace.id, - workspace_name=workspace.name, - role=member.role, - ), - None, - ) - - except Exception as e: - return None, f"Failed to accept invitation: {str(e)}" - - -class DeclineInvitationRequest: - """拒绝邀请请求""" - - def __init__(self, invitation_token: str): - self.invitation_token = invitation_token - - -class DeclineInvitationUseCase: - """拒绝邀请用例""" - - def __init__(self, workspace_invitation_repository): - self.workspace_invitation_repository = workspace_invitation_repository - - def execute(self, request: DeclineInvitationRequest) -> tuple[bool, Optional[str]]: - """ - 执行拒绝邀请 - - Args: - request: 拒绝请求 - - Returns: - (是否成功, 错误信息) - """ - try: - # 1. 验证输入 - if not request.invitation_token: - return False, "Invitation token is required" - - # 2. 查找邀请 - invitation = self.workspace_invitation_repository.find_by_token(request.invitation_token) - if not invitation: - return False, "Invalid invitation token" - - # 3. 检查邀请状态 - if invitation.status != InvitationStatus.PENDING: - return False, f"Invitation has already been {invitation.status}" - - # 4. 更新状态为已拒绝 - invitation.status = InvitationStatus.DECLINED - self.workspace_invitation_repository.save(invitation) - - return True, None - - except Exception as e: - return False, f"Failed to decline invitation: {str(e)}" diff --git a/packages/application/workspace/create_workspace_use_case.py b/packages/application/workspace/create_workspace_use_case.py deleted file mode 100644 index 1a87fca8e..000000000 --- a/packages/application/workspace/create_workspace_use_case.py +++ /dev/null @@ -1,138 +0,0 @@ -""" -创建 Workspace Use Case -""" - -from datetime import datetime, timezone -from typing import Optional -from uuid import uuid4 - -from packages.domain.entities import Workspace, WorkspaceMember, WorkspaceMemberRole - - -class CreateWorkspaceRequest: - """创建工作空间请求""" - - def __init__( - self, - name: str, - owner_user_id: str, - subscription_plan: str = "free", - ): - self.name = name.strip() - self.owner_user_id = owner_user_id - self.subscription_plan = subscription_plan - - -class CreateWorkspaceResponse: - """创建工作空间响应""" - - def __init__( - self, - workspace_id: str, - name: str, - subscription_plan: str, - max_projects: int, - max_storage_gb: int, - ): - self.workspace_id = workspace_id - self.name = name - self.subscription_plan = subscription_plan - self.max_projects = max_projects - self.max_storage_gb = max_storage_gb - - -class CreateWorkspaceUseCase: - """创建工作空间用例""" - - # 订阅计划配额配置 - PLAN_QUOTAS = { - "free": {"max_projects": 3, "max_storage_gb": 10}, - "pro": {"max_projects": 999999, "max_storage_gb": 100}, # 999999 表示无限 - "enterprise": {"max_projects": 999999, "max_storage_gb": 1000}, - } - - def __init__( - self, - workspace_repository, - workspace_member_repository, - user_repository, - ): - self.workspace_repository = workspace_repository - self.workspace_member_repository = workspace_member_repository - self.user_repository = user_repository - - def execute(self, request: CreateWorkspaceRequest) -> tuple[Optional[CreateWorkspaceResponse], Optional[str]]: - """ - 执行创建工作空间 - - Args: - request: 创建请求 - - Returns: - (响应, 错误信息) - """ - try: - # 1. 验证输入 - if not request.name: - return None, "Workspace name is required" - - if len(request.name) > 100: - return None, "Workspace name is too long (max 100 characters)" - - if not request.owner_user_id: - return None, "Owner user ID is required" - - # 2. 验证用户存在 - owner = self.user_repository.find_by_id(request.owner_user_id) - if not owner: - return None, "Owner user not found" - - # 3. 验证订阅计划 - if request.subscription_plan not in self.PLAN_QUOTAS: - return None, f"Invalid subscription plan: {request.subscription_plan}" - - # 4. 获取配额 - quota = self.PLAN_QUOTAS[request.subscription_plan] - - # 5. 创建 Workspace - workspace = Workspace( - id=uuid4().hex, - name=request.name, - owner_user_id=request.owner_user_id, - subscription_plan=request.subscription_plan, - subscription_status="active", - max_projects=quota["max_projects"], - max_storage_gb=quota["max_storage_gb"], - used_storage_gb=0.0, - created_at=datetime.now(timezone.utc), - ) - - # 6. 保存 Workspace - self.workspace_repository.save(workspace) - - # 7. 创建 Owner 成员记录 - owner_member = WorkspaceMember( - id=uuid4().hex, - workspace_id=workspace.id, - user_id=request.owner_user_id, - role=WorkspaceMemberRole.OWNER, - invited_by=None, # Owner 不需要邀请 - joined_at=datetime.now(timezone.utc), - ) - - self.workspace_member_repository.save(owner_member) - - # 8. 返回响应 - return ( - CreateWorkspaceResponse( - workspace_id=workspace.id, - name=workspace.name, - subscription_plan=workspace.subscription_plan, - max_projects=workspace.max_projects, - max_storage_gb=workspace.max_storage_gb, - ), - None, - ) - - except Exception as e: - return None, f"Failed to create workspace: {str(e)}" diff --git a/packages/application/workspace/invite_member_use_case.py b/packages/application/workspace/invite_member_use_case.py deleted file mode 100644 index d907cedc2..000000000 --- a/packages/application/workspace/invite_member_use_case.py +++ /dev/null @@ -1,194 +0,0 @@ -""" -邀请成员到 Workspace Use Case -""" - -import secrets -from datetime import datetime, timedelta, timezone -from typing import Optional -from uuid import uuid4 - -from packages.adapters.smtp import get_email_service -from packages.domain.entities import ( - InvitationStatus, - WorkspaceInvitation, - WorkspaceMemberRole, -) - - -class InviteMemberRequest: - """邀请成员请求""" - - def __init__( - self, - workspace_id: str, - inviter_user_id: str, - invitee_email: str, - role: str, - ): - self.workspace_id = workspace_id - self.inviter_user_id = inviter_user_id - self.invitee_email = invitee_email.strip().lower() - self.role = role - - -class InviteMemberResponse: - """邀请成员响应""" - - def __init__( - self, - invitation_id: str, - invitee_email: str, - role: str, - expires_at: datetime, - ): - self.invitation_id = invitation_id - self.invitee_email = invitee_email - self.role = role - self.expires_at = expires_at - - -class InviteMemberUseCase: - """邀请成员用例""" - - VALID_ROLES = [ - WorkspaceMemberRole.ADMIN, - WorkspaceMemberRole.MEMBER, - WorkspaceMemberRole.VIEWER, - ] - - def __init__( - self, - workspace_repository, - workspace_member_repository, - workspace_invitation_repository, - user_repository, - base_url: str = "http://localhost:3000", - invitation_expire_days: int = 7, - email_service=None, - ): - self.workspace_repository = workspace_repository - self.workspace_member_repository = workspace_member_repository - self.workspace_invitation_repository = workspace_invitation_repository - self.user_repository = user_repository - self.base_url = base_url - self.invitation_expire_days = invitation_expire_days - self.email_service = email_service or get_email_service() - - def execute(self, request: InviteMemberRequest) -> tuple[Optional[InviteMemberResponse], Optional[str]]: - """ - 执行邀请成员 - - Args: - request: 邀请请求 - - Returns: - (响应, 错误信息) - """ - try: - # 1. 验证输入 - if not request.workspace_id: - return None, "Workspace ID is required" - - if not request.inviter_user_id: - return None, "Inviter user ID is required" - - if not request.invitee_email: - return None, "Invitee email is required" - - if not request.role: - return None, "Role is required" - - # 2. 验证角色(不能邀请 owner) - if request.role not in self.VALID_ROLES: - return None, f"Invalid role: {request.role}. Cannot invite as owner." - - # 3. 验证 Workspace 存在 - workspace = self.workspace_repository.find_by_id(request.workspace_id) - if not workspace: - return None, "Workspace not found" - - # 4. 验证邀请人是成员且有权限(owner 或 admin) - inviter_member = self.workspace_member_repository.find_by_workspace_and_user( - request.workspace_id, - request.inviter_user_id, - ) - if not inviter_member: - return None, "You are not a member of this workspace" - - if inviter_member.role not in [ - WorkspaceMemberRole.OWNER, - WorkspaceMemberRole.ADMIN, - ]: - return None, "Only owners and admins can invite members" - - # 5. 检查被邀请人是否已经是成员 - invitee_user = self.user_repository.find_by_email(request.invitee_email) - if invitee_user: - existing_member = self.workspace_member_repository.find_by_workspace_and_user( - request.workspace_id, - invitee_user.id, - ) - if existing_member: - return None, "User is already a member of this workspace" - - # 6. 检查是否已有待处理的邀请 - existing_invitation = self.workspace_invitation_repository.find_pending_by_workspace_and_email( - request.workspace_id, - request.invitee_email, - ) - if existing_invitation: - return None, "An invitation has already been sent to this email" - - # 7. 生成邀请令牌 - invitation_token = secrets.token_urlsafe(32) - expires_at = datetime.now(timezone.utc) + timedelta(days=self.invitation_expire_days) - - # 8. 创建邀请记录 - invitation = WorkspaceInvitation( - id=uuid4().hex, - workspace_id=request.workspace_id, - inviter_user_id=request.inviter_user_id, - invitee_email=request.invitee_email, - role=request.role, - invitation_token=invitation_token, - status=InvitationStatus.PENDING, - expires_at=expires_at, - created_at=datetime.now(timezone.utc), - ) - - # 9. 保存邀请 - self.workspace_invitation_repository.save(invitation) - - # 10. 发送邀请邮件 - invitation_url = f"{self.base_url}/invitations/{invitation_token}/accept" - - try: - inviter = self.user_repository.find_by_id(request.inviter_user_id) - inviter_name = inviter.display_name if inviter else "Someone" - - success, error = self.email_service.send_workspace_invitation_email( - to_email=request.invitee_email, - inviter_name=inviter_name, - workspace_name=workspace.name, - role=request.role, - invitation_url=invitation_url, - ) - - if not success: - print(f"Failed to send invitation email: {error}") - except Exception as e: - print(f"Email service error: {e}") - - # 11. 返回响应 - return ( - InviteMemberResponse( - invitation_id=invitation.id, - invitee_email=invitation.invitee_email, - role=invitation.role, - expires_at=invitation.expires_at, - ), - None, - ) - - except Exception as e: - return None, f"Failed to invite member: {str(e)}" diff --git a/packages/application/workspace/list_members_use_case.py b/packages/application/workspace/list_members_use_case.py deleted file mode 100644 index afe4a42c5..000000000 --- a/packages/application/workspace/list_members_use_case.py +++ /dev/null @@ -1,118 +0,0 @@ -""" -获取成员列表 Use Case -""" - -from datetime import datetime -from typing import List, Optional - - -class MemberInfo: - """成员信息""" - - def __init__( - self, - member_id: str, - user_id: str, - username: str, - email: str, - display_name: str, - role: str, - invited_by: Optional[str], - joined_at: datetime, - ): - self.member_id = member_id - self.user_id = user_id - self.username = username - self.email = email - self.display_name = display_name - self.role = role - self.invited_by = invited_by - self.joined_at = joined_at - - -class ListMembersRequest: - """获取成员列表请求""" - - def __init__(self, workspace_id: str, requester_user_id: str): - self.workspace_id = workspace_id - self.requester_user_id = requester_user_id - - -class ListMembersResponse: - """获取成员列表响应""" - - def __init__(self, members: List[MemberInfo]): - self.members = members - - -class ListMembersUseCase: - """获取成员列表用例""" - - def __init__( - self, - workspace_repository, - workspace_member_repository, - user_repository, - ): - self.workspace_repository = workspace_repository - self.workspace_member_repository = workspace_member_repository - self.user_repository = user_repository - - def execute(self, request: ListMembersRequest) -> tuple[Optional[ListMembersResponse], Optional[str]]: - """ - 执行获取成员列表 - - Args: - request: 请求 - - Returns: - (响应, 错误信息) - """ - try: - # 1. 验证输入 - if not request.workspace_id: - return None, "Workspace ID is required" - - if not request.requester_user_id: - return None, "Requester user ID is required" - - # 2. 验证工作空间存在 - workspace = self.workspace_repository.find_by_id(request.workspace_id) - if not workspace: - return None, "Workspace not found" - - # 3. 验证请求者是成员(只有成员才能查看成员列表) - requester_member = self.workspace_member_repository.find_by_workspace_and_user( - request.workspace_id, - request.requester_user_id, - ) - if not requester_member: - return None, "You are not a member of this workspace" - - # 4. 获取所有成员 - members = self.workspace_member_repository.find_by_workspace(request.workspace_id) - - # 5. 获取每个成员的用户信息 - member_infos = [] - for member in members: - user = self.user_repository.find_by_id(member.user_id) - if not user: - continue # 跳过不存在的用户 - - member_info = MemberInfo( - member_id=member.id, - user_id=user.id, - username=user.username, - email=user.email, - display_name=user.display_name, - role=member.role, - invited_by=member.invited_by, - joined_at=member.joined_at, - ) - member_infos.append(member_info) - - # 6. 返回响应 - return ListMembersResponse(members=member_infos), None - - except Exception as e: - return None, f"Failed to list members: {str(e)}" diff --git a/packages/application/workspace/list_workspaces_use_case.py b/packages/application/workspace/list_workspaces_use_case.py deleted file mode 100644 index 1c27e3841..000000000 --- a/packages/application/workspace/list_workspaces_use_case.py +++ /dev/null @@ -1,218 +0,0 @@ -""" -获取工作空间列表和详情 Use Case -""" - -from datetime import datetime -from typing import List, Optional - - -class WorkspaceInfo: - """工作空间信息""" - - def __init__( - self, - workspace_id: str, - name: str, - owner_user_id: str, - subscription_plan: str, - subscription_status: str, - max_projects: int, - max_storage_gb: int, - used_storage_gb: float, - member_count: int, - user_role: str, - created_at: datetime, - ): - self.workspace_id = workspace_id - self.name = name - self.owner_user_id = owner_user_id - self.subscription_plan = subscription_plan - self.subscription_status = subscription_status - self.max_projects = max_projects - self.max_storage_gb = max_storage_gb - self.used_storage_gb = used_storage_gb - self.member_count = member_count - self.user_role = user_role - self.created_at = created_at - - -class ListWorkspacesRequest: - """获取工作空间列表请求""" - - def __init__(self, user_id: str): - self.user_id = user_id - - -class ListWorkspacesResponse: - """获取工作空间列表响应""" - - def __init__(self, workspaces: List[WorkspaceInfo]): - self.workspaces = workspaces - - -class ListWorkspacesUseCase: - """获取工作空间列表用例""" - - def __init__( - self, - workspace_repository, - workspace_member_repository, - ): - self.workspace_repository = workspace_repository - self.workspace_member_repository = workspace_member_repository - - def execute(self, request: ListWorkspacesRequest) -> tuple[Optional[ListWorkspacesResponse], Optional[str]]: - """ - 执行获取工作空间列表 - - Args: - request: 请求 - - Returns: - (响应, 错误信息) - """ - try: - # 1. 验证输入 - if not request.user_id: - return None, "User ID is required" - - # 2. 获取用户所有的成员记录 - memberships = self.workspace_member_repository.find_by_user(request.user_id) - - # 3. 获取每个工作空间的信息 - workspace_infos = [] - for membership in memberships: - workspace = self.workspace_repository.find_by_id(membership.workspace_id) - if not workspace: - continue - - # 获取成员数量 - member_count = self.workspace_member_repository.count_by_workspace(membership.workspace_id) - - workspace_info = WorkspaceInfo( - workspace_id=workspace.id, - name=workspace.name, - owner_user_id=workspace.owner_user_id, - subscription_plan=workspace.subscription_plan, - subscription_status=workspace.subscription_status, - max_projects=workspace.max_projects, - max_storage_gb=workspace.max_storage_gb, - used_storage_gb=workspace.used_storage_gb, - member_count=member_count, - user_role=membership.role, - created_at=workspace.created_at, - ) - workspace_infos.append(workspace_info) - - # 4. 返回响应 - return ListWorkspacesResponse(workspaces=workspace_infos), None - - except Exception as e: - return None, f"Failed to list workspaces: {str(e)}" - - -class GetWorkspaceDetailRequest: - """获取工作空间详情请求""" - - def __init__(self, workspace_id: str, user_id: str): - self.workspace_id = workspace_id - self.user_id = user_id - - -class WorkspaceDetailInfo: - """工作空间详情信息""" - - def __init__( - self, - workspace_id: str, - name: str, - owner_user_id: str, - subscription_plan: str, - subscription_status: str, - subscription_expires_at: Optional[datetime], - max_projects: int, - max_storage_gb: int, - used_storage_gb: float, - member_count: int, - user_role: str, - created_at: datetime, - ): - self.workspace_id = workspace_id - self.name = name - self.owner_user_id = owner_user_id - self.subscription_plan = subscription_plan - self.subscription_status = subscription_status - self.subscription_expires_at = subscription_expires_at - self.max_projects = max_projects - self.max_storage_gb = max_storage_gb - self.used_storage_gb = used_storage_gb - self.member_count = member_count - self.user_role = user_role - self.created_at = created_at - - -class GetWorkspaceDetailUseCase: - """获取工作空间详情用例""" - - def __init__( - self, - workspace_repository, - workspace_member_repository, - ): - self.workspace_repository = workspace_repository - self.workspace_member_repository = workspace_member_repository - - def execute(self, request: GetWorkspaceDetailRequest) -> tuple[Optional[WorkspaceDetailInfo], Optional[str]]: - """ - 执行获取工作空间详情 - - Args: - request: 请求 - - Returns: - (详情信息, 错误信息) - """ - try: - # 1. 验证输入 - if not request.workspace_id: - return None, "Workspace ID is required" - - if not request.user_id: - return None, "User ID is required" - - # 2. 验证工作空间存在 - workspace = self.workspace_repository.find_by_id(request.workspace_id) - if not workspace: - return None, "Workspace not found" - - # 3. 验证用户是成员 - membership = self.workspace_member_repository.find_by_workspace_and_user( - request.workspace_id, - request.user_id, - ) - if not membership: - return None, "You are not a member of this workspace" - - # 4. 获取成员数量 - member_count = self.workspace_member_repository.count_by_workspace(request.workspace_id) - - # 5. 构建详情信息 - detail_info = WorkspaceDetailInfo( - workspace_id=workspace.id, - name=workspace.name, - owner_user_id=workspace.owner_user_id, - subscription_plan=workspace.subscription_plan, - subscription_status=workspace.subscription_status, - subscription_expires_at=workspace.subscription_expires_at, - max_projects=workspace.max_projects, - max_storage_gb=workspace.max_storage_gb, - used_storage_gb=workspace.used_storage_gb, - member_count=member_count, - user_role=membership.role, - created_at=workspace.created_at, - ) - - return detail_info, None - - except Exception as e: - return None, f"Failed to get workspace detail: {str(e)}" diff --git a/packages/application/workspace/remove_member_use_case.py b/packages/application/workspace/remove_member_use_case.py deleted file mode 100644 index 046cf5574..000000000 --- a/packages/application/workspace/remove_member_use_case.py +++ /dev/null @@ -1,171 +0,0 @@ -""" -移除成员 Use Case -""" - -from typing import Optional - -from packages.domain.entities import WorkspaceMemberRole - - -class RemoveMemberRequest: - """移除成员请求""" - - def __init__( - self, - workspace_id: str, - requester_user_id: str, - target_user_id: str, - ): - self.workspace_id = workspace_id - self.requester_user_id = requester_user_id - self.target_user_id = target_user_id - - -class RemoveMemberUseCase: - """移除成员用例""" - - def __init__( - self, - workspace_repository, - workspace_member_repository, - ): - self.workspace_repository = workspace_repository - self.workspace_member_repository = workspace_member_repository - - def execute(self, request: RemoveMemberRequest) -> tuple[bool, Optional[str]]: - """ - 执行移除成员 - - Args: - request: 移除请求 - - Returns: - (是否成功, 错误信息) - """ - try: - # 1. 验证输入 - if not request.workspace_id: - return False, "Workspace ID is required" - - if not request.requester_user_id: - return False, "Requester user ID is required" - - if not request.target_user_id: - return False, "Target user ID is required" - - # 2. 验证 Workspace 存在 - workspace = self.workspace_repository.find_by_id(request.workspace_id) - if not workspace: - return False, "Workspace not found" - - # 3. 验证请求者是成员且有权限 - requester_member = self.workspace_member_repository.find_by_workspace_and_user( - request.workspace_id, - request.requester_user_id, - ) - if not requester_member: - return False, "You are not a member of this workspace" - - if requester_member.role not in [ - WorkspaceMemberRole.OWNER, - WorkspaceMemberRole.ADMIN, - ]: - return False, "Only owners and admins can remove members" - - # 4. 验证目标成员存在 - target_member = self.workspace_member_repository.find_by_workspace_and_user( - request.workspace_id, - request.target_user_id, - ) - if not target_member: - return False, "Target user is not a member of this workspace" - - # 5. 不能移除自己(应该用离开 workspace 的功能) - if request.requester_user_id == request.target_user_id: - return False, "Cannot remove yourself. Use leave workspace instead." - - # 6. 不能移除 owner - if target_member.role == WorkspaceMemberRole.OWNER: - return False, "Cannot remove the workspace owner" - - # 7. Admin 不能移除另一个 Admin(只有 owner 可以) - if requester_member.role == WorkspaceMemberRole.ADMIN and target_member.role == WorkspaceMemberRole.ADMIN: - return False, "Admins cannot remove other admins" - - # 8. 删除成员记录 - success = self.workspace_member_repository.delete(target_member.id) - if not success: - return False, "Failed to remove member" - - return True, None - - except Exception as e: - return False, f"Failed to remove member: {str(e)}" - - -class LeaveWorkspaceRequest: - """离开 Workspace 请求""" - - def __init__(self, workspace_id: str, user_id: str): - self.workspace_id = workspace_id - self.user_id = user_id - - -class LeaveWorkspaceUseCase: - """离开 Workspace 用例""" - - def __init__( - self, - workspace_repository, - workspace_member_repository, - ): - self.workspace_repository = workspace_repository - self.workspace_member_repository = workspace_member_repository - - def execute(self, request: LeaveWorkspaceRequest) -> tuple[bool, Optional[str]]: - """ - 执行离开 Workspace - - Args: - request: 离开请求 - - Returns: - (是否成功, 错误信息) - """ - try: - # 1. 验证输入 - if not request.workspace_id: - return False, "Workspace ID is required" - - if not request.user_id: - return False, "User ID is required" - - # 2. 验证 Workspace 存在 - workspace = self.workspace_repository.find_by_id(request.workspace_id) - if not workspace: - return False, "Workspace not found" - - # 3. 验证用户是成员 - member = self.workspace_member_repository.find_by_workspace_and_user( - request.workspace_id, - request.user_id, - ) - if not member: - return False, "You are not a member of this workspace" - - # 4. Owner 不能离开(需要先转移 ownership 或删除 workspace) - if member.role == WorkspaceMemberRole.OWNER: - return ( - False, - "Owner cannot leave workspace. Transfer ownership or delete workspace first.", - ) - - # 5. 删除成员记录 - success = self.workspace_member_repository.delete(member.id) - if not success: - return False, "Failed to leave workspace" - - return True, None - - except Exception as e: - return False, f"Failed to leave workspace: {str(e)}" diff --git a/packages/application/workspace/subscription_use_case.py b/packages/application/workspace/subscription_use_case.py deleted file mode 100644 index dd2952edd..000000000 --- a/packages/application/workspace/subscription_use_case.py +++ /dev/null @@ -1,223 +0,0 @@ -""" -Subscription 管理 Use Case -""" - -from datetime import datetime, timedelta, timezone -from typing import Optional - -from packages.domain.entities import WorkspaceMemberRole - - -class UpgradeSubscriptionRequest: - """升级订阅请求""" - - def __init__( - self, - workspace_id: str, - requester_user_id: str, - new_plan: str, - ): - self.workspace_id = workspace_id - self.requester_user_id = requester_user_id - self.new_plan = new_plan - - -class UpgradeSubscriptionResponse: - """升级订阅响应""" - - def __init__( - self, - workspace_id: str, - old_plan: str, - new_plan: str, - max_projects: int, - max_storage_gb: int, - ): - self.workspace_id = workspace_id - self.old_plan = old_plan - self.new_plan = new_plan - self.max_projects = max_projects - self.max_storage_gb = max_storage_gb - - -class UpgradeSubscriptionUseCase: - """升级订阅用例""" - - # 订阅计划配额 - PLAN_QUOTAS = { - "free": {"max_projects": 3, "max_storage_gb": 10, "price": 0}, - "pro": {"max_projects": 999999, "max_storage_gb": 100, "price": 99}, - "enterprise": {"max_projects": 999999, "max_storage_gb": 1000, "price": 999}, - } - - # 计划等级 - PLAN_LEVELS = { - "free": 0, - "pro": 1, - "enterprise": 2, - } - - def __init__( - self, - workspace_repository, - workspace_member_repository, - ): - self.workspace_repository = workspace_repository - self.workspace_member_repository = workspace_member_repository - - def execute( - self, request: UpgradeSubscriptionRequest - ) -> tuple[Optional[UpgradeSubscriptionResponse], Optional[str]]: - """ - 执行升级订阅 - - Args: - request: 升级请求 - - Returns: - (响应, 错误信息) - """ - try: - # 1. 验证输入 - if not request.workspace_id: - return None, "Workspace ID is required" - - if not request.requester_user_id: - return None, "Requester user ID is required" - - if not request.new_plan: - return None, "New plan is required" - - # 2. 验证新计划有效 - if request.new_plan not in self.PLAN_QUOTAS: - return None, f"Invalid plan: {request.new_plan}" - - # 3. 验证工作空间存在 - workspace = self.workspace_repository.find_by_id(request.workspace_id) - if not workspace: - return None, "Workspace not found" - - # 4. 验证权限(只有 Owner 可以管理订阅) - member = self.workspace_member_repository.find_by_workspace_and_user( - request.workspace_id, - request.requester_user_id, - ) - if not member: - return None, "You are not a member of this workspace" - - if member.role != WorkspaceMemberRole.OWNER: - return None, "Only workspace owner can manage subscription" - - # 5. 检查是否是升级(不能降级到免费计划,需要用取消订阅) - current_level = self.PLAN_LEVELS.get(workspace.subscription_plan, 0) - new_level = self.PLAN_LEVELS.get(request.new_plan, 0) - - if new_level < current_level: - return ( - None, - "Cannot downgrade plan. Use cancel subscription to return to free plan.", - ) - - if new_level == current_level: - return None, f"Workspace is already on {request.new_plan} plan" - - # 6. 更新订阅 - old_plan = workspace.subscription_plan - quota = self.PLAN_QUOTAS[request.new_plan] - - workspace.subscription_plan = request.new_plan - workspace.subscription_status = "active" - workspace.max_projects = quota["max_projects"] - workspace.max_storage_gb = quota["max_storage_gb"] - - # 设置过期时间(假设按月订阅) - workspace.subscription_expires_at = datetime.now(timezone.utc) + timedelta(days=30) - - self.workspace_repository.save(workspace) - - # 7. 返回响应 - return ( - UpgradeSubscriptionResponse( - workspace_id=workspace.id, - old_plan=old_plan, - new_plan=workspace.subscription_plan, - max_projects=workspace.max_projects, - max_storage_gb=workspace.max_storage_gb, - ), - None, - ) - - except Exception as e: - return None, f"Failed to upgrade subscription: {str(e)}" - - -class CancelSubscriptionRequest: - """取消订阅请求""" - - def __init__(self, workspace_id: str, requester_user_id: str): - self.workspace_id = workspace_id - self.requester_user_id = requester_user_id - - -class CancelSubscriptionUseCase: - """取消订阅用例""" - - def __init__( - self, - workspace_repository, - workspace_member_repository, - ): - self.workspace_repository = workspace_repository - self.workspace_member_repository = workspace_member_repository - - def execute(self, request: CancelSubscriptionRequest) -> tuple[bool, Optional[str]]: - """ - 执行取消订阅 - - Args: - request: 取消请求 - - Returns: - (是否成功, 错误信息) - """ - try: - # 1. 验证输入 - if not request.workspace_id: - return False, "Workspace ID is required" - - if not request.requester_user_id: - return False, "Requester user ID is required" - - # 2. 验证工作空间存在 - workspace = self.workspace_repository.find_by_id(request.workspace_id) - if not workspace: - return False, "Workspace not found" - - # 3. 验证权限(只有 Owner 可以管理订阅) - member = self.workspace_member_repository.find_by_workspace_and_user( - request.workspace_id, - request.requester_user_id, - ) - if not member: - return False, "You are not a member of this workspace" - - if member.role != WorkspaceMemberRole.OWNER: - return False, "Only workspace owner can manage subscription" - - # 4. 检查当前计划 - if workspace.subscription_plan == "free": - return False, "Workspace is already on free plan" - - # 5. 降级到 free 计划 - workspace.subscription_plan = "free" - workspace.subscription_status = "active" - workspace.subscription_expires_at = None - workspace.max_projects = 3 - workspace.max_storage_gb = 10 - - self.workspace_repository.save(workspace) - - return True, None - - except Exception as e: - return False, f"Failed to cancel subscription: {str(e)}" diff --git a/packages/application/workspace/update_member_role_use_case.py b/packages/application/workspace/update_member_role_use_case.py deleted file mode 100644 index f11b0f208..000000000 --- a/packages/application/workspace/update_member_role_use_case.py +++ /dev/null @@ -1,142 +0,0 @@ -""" -修改成员角色 Use Case -""" - -from typing import Optional - -from packages.domain.entities import WorkspaceMemberRole - - -class UpdateMemberRoleRequest: - """修改成员角色请求""" - - def __init__( - self, - workspace_id: str, - requester_user_id: str, - target_user_id: str, - new_role: str, - ): - self.workspace_id = workspace_id - self.requester_user_id = requester_user_id - self.target_user_id = target_user_id - self.new_role = new_role - - -class UpdateMemberRoleResponse: - """修改成员角色响应""" - - def __init__(self, user_id: str, old_role: str, new_role: str): - self.user_id = user_id - self.old_role = old_role - self.new_role = new_role - - -class UpdateMemberRoleUseCase: - """修改成员角色用例""" - - VALID_ROLES = [ - WorkspaceMemberRole.ADMIN, - WorkspaceMemberRole.MEMBER, - WorkspaceMemberRole.VIEWER, - ] - - def __init__( - self, - workspace_repository, - workspace_member_repository, - ): - self.workspace_repository = workspace_repository - self.workspace_member_repository = workspace_member_repository - - def execute(self, request: UpdateMemberRoleRequest) -> tuple[Optional[UpdateMemberRoleResponse], Optional[str]]: - """ - 执行修改成员角色 - - Args: - request: 修改请求 - - Returns: - (响应, 错误信息) - """ - try: - # 1. 验证输入 - if not request.workspace_id: - return None, "Workspace ID is required" - - if not request.requester_user_id: - return None, "Requester user ID is required" - - if not request.target_user_id: - return None, "Target user ID is required" - - if not request.new_role: - return None, "New role is required" - - # 2. 验证新角色(不能修改为 owner) - if request.new_role not in self.VALID_ROLES: - return ( - None, - f"Invalid role: {request.new_role}. Cannot change to owner.", - ) - - # 3. 验证 Workspace 存在 - workspace = self.workspace_repository.find_by_id(request.workspace_id) - if not workspace: - return None, "Workspace not found" - - # 4. 验证请求者是成员且有权限(只有 owner 和 admin 可以修改角色) - requester_member = self.workspace_member_repository.find_by_workspace_and_user( - request.workspace_id, - request.requester_user_id, - ) - if not requester_member: - return None, "You are not a member of this workspace" - - if requester_member.role not in [ - WorkspaceMemberRole.OWNER, - WorkspaceMemberRole.ADMIN, - ]: - return None, "Only owners and admins can change member roles" - - # 5. 验证目标成员存在 - target_member = self.workspace_member_repository.find_by_workspace_and_user( - request.workspace_id, - request.target_user_id, - ) - if not target_member: - return None, "Target user is not a member of this workspace" - - # 6. 不能修改自己的角色 - if request.requester_user_id == request.target_user_id: - return None, "Cannot change your own role" - - # 7. 不能修改 owner 的角色 - if target_member.role == WorkspaceMemberRole.OWNER: - return None, "Cannot change the owner's role" - - # 8. Admin 不能修改另一个 Admin 的角色(只有 owner 可以) - if requester_member.role == WorkspaceMemberRole.ADMIN and target_member.role == WorkspaceMemberRole.ADMIN: - return None, "Admins cannot change other admins' roles" - - # 9. 检查角色是否相同 - if target_member.role == request.new_role: - return None, f"User already has the {request.new_role} role" - - # 10. 更新角色 - old_role = target_member.role - target_member.role = request.new_role - self.workspace_member_repository.save(target_member) - - # 11. 返回响应 - return ( - UpdateMemberRoleResponse( - user_id=request.target_user_id, - old_role=old_role, - new_role=request.new_role, - ), - None, - ) - - except Exception as e: - return None, f"Failed to update member role: {str(e)}" diff --git a/packages/domain/entities.py b/packages/domain/entities.py index 7d3ed3088..4de800777 100644 --- a/packages/domain/entities.py +++ b/packages/domain/entities.py @@ -34,96 +34,55 @@ class User: password_reset_expires_at: datetime | None = None last_login_at: datetime | None = None last_login_ip: str | None = None - created_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc)) - - -@dataclass(slots=True) -class Workspace: - id: str - name: str - owner_user_id: str - # 订阅相关字段 + # 订阅相关字段 (移到 User 级别) subscription_plan: str = "free" # free, pro, enterprise subscription_status: str = "active" # active, cancelled, expired subscription_expires_at: datetime | None = None - # 配额限制 + # 配额限制 (移到 User 级别) max_projects: int = 3 # free: 3, pro: unlimited, enterprise: unlimited max_storage_gb: int = 10 # free: 10, pro: 100, enterprise: 1000 used_storage_gb: float = 0.0 created_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc)) -class WorkspaceMemberRole(StrEnum): - """工作空间成员角色""" - - OWNER = "owner" # 所有者(创建者,唯一) - ADMIN = "admin" # 管理员(可管理成员和项目) - MEMBER = "member" # 成员(可创建和编辑项目) - VIEWER = "viewer" # 查看者(只读) - - -class InvitationStatus(StrEnum): - """邀请状态""" - - PENDING = "pending" # 待处理 - ACCEPTED = "accepted" # 已接受 - DECLINED = "declined" # 已拒绝 - EXPIRED = "expired" # 已过期 - - -@dataclass(slots=True) -class WorkspaceMember: - """工作空间成员""" - - id: str - workspace_id: str - user_id: str - role: str # owner, admin, member, viewer - invited_by: str | None = None # 邀请人 user_id - joined_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc)) - - -@dataclass(slots=True) -class WorkspaceInvitation: - """工作空间邀请""" - - id: str - workspace_id: str - inviter_user_id: str # 邀请人 - invitee_email: str # 被邀请人邮箱 - role: str # 邀请角色 - invitation_token: str # 邀请令牌 - status: str = "pending" # pending, accepted, declined, expired - expires_at: datetime | None = None # 过期时间 - accepted_at: datetime | None = None # 接受时间 - created_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc)) - - @dataclass(slots=True) class Project: id: str - workspace_id: str + owner_user_id: str name: str description: str = "" + shared_users: list[str] = field(default_factory=list) # 被共享的用户 ID 列表 created_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc)) @classmethod - def create(cls, workspace_id: str, name: str, description: str = "") -> "Project": + def create(cls, owner_user_id: str, name: str, description: str = "") -> "Project": clean_name = name.strip() if not clean_name: raise ValueError("项目名称不能为空") return cls( id=uuid4().hex, - workspace_id=workspace_id, + owner_user_id=owner_user_id, name=clean_name, description=description.strip(), + shared_users=[], ) + def is_owner(self, user_id: str) -> bool: + """检查用户是否为项目所有者""" + return self.owner_user_id == user_id + + def is_shared_with(self, user_id: str) -> bool: + """检查项目是否共享给指定用户""" + return user_id in self.shared_users + + def can_access(self, user_id: str) -> bool: + """检查用户是否有权限访问项目""" + return self.is_owner(user_id) or self.is_shared_with(user_id) + @dataclass(slots=True) class AssetLibrary: id: str - workspace_id: str project_id: str name: str kind: AssetLibraryKind @@ -135,7 +94,6 @@ class AssetLibrary: @classmethod def create( cls, - workspace_id: str, project_id: str, name: str, kind: AssetLibraryKind, @@ -145,7 +103,6 @@ class AssetLibrary: raise ValueError("素材库名称不能为空") return cls( id=uuid4().hex, - workspace_id=workspace_id, project_id=project_id, name=clean_name, kind=kind, @@ -171,7 +128,6 @@ class ClassificationStatus(StrEnum): @dataclass(slots=True) class Asset: id: str - workspace_id: str project_id: str library_id: str name: str @@ -196,7 +152,6 @@ class Asset: @classmethod def create( cls, - workspace_id: str, project_id: str, library_id: str, name: str, @@ -225,7 +180,6 @@ class Asset: raise ValueError("mime_type 不能为空") return cls( id=uuid4().hex, - workspace_id=workspace_id, project_id=project_id, library_id=library_id, name=clean_name, @@ -266,7 +220,6 @@ class Asset: @dataclass(slots=True) class IngestJob: id: str - workspace_id: str project_id: str library_id: str storage_key: str @@ -279,13 +232,10 @@ class IngestJob: @classmethod def create( cls, - workspace_id: str, project_id: str, library_id: str, storage_key: str, ) -> "IngestJob": - if not workspace_id.strip(): - raise ValueError("workspace_id 不能为空") if not project_id.strip(): raise ValueError("project_id 不能为空") if not library_id.strip(): @@ -294,8 +244,149 @@ class IngestJob: raise ValueError("storage_key 不能为空") return cls( id=uuid4().hex, - workspace_id=workspace_id.strip(), project_id=project_id.strip(), library_id=library_id.strip(), storage_key=storage_key.strip(), ) + + +# 继续读取其他实体定义 - 生成任务、生成视频等 +@dataclass(slots=True) +class ClassificationJob: + id: str + project_id: str + asset_id: str + status: str = "pending" + classification: str = "" + confidence: float = 0.0 + error_message: str = "" + created_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc)) + updated_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc)) + + +@dataclass(slots=True) +class GenerationTask: + id: str + project_id: str + asset_library_id: str + strategy_id: str = "" + voice_library_id: str = "" + edit_plan_id: str = "" + status: str = "pending" + progress: float = 0.0 + result_count: int = 0 + error_message: str = "" + started_at: datetime | None = None + completed_at: datetime | None = None + created_by_user_id: str = "" + created_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc)) + + +@dataclass(slots=True) +class GeneratedVideo: + id: str + project_id: str + generation_task_id: str + name: str + file_url: str + file_size: float = 0.0 + duration: float = 0.0 + thumbnail_url: str | None = None + width: int = 0 + height: int = 0 + fps: float = 0.0 + status: str = "completed" + review_status: str = "pending_review" + generation_params: dict[str, Any] = field(default_factory=dict) + generated_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc)) + created_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc)) + updated_at: datetime | None = None + + +@dataclass(slots=True) +class EditTemplate: + id: str + project_id: str + name: str + description: str = "" + target_duration: float = 30.0 + clip_count: int = 3 + is_active: bool = True + created_by_user_id: str = "" + created_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc)) + updated_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc)) + + +@dataclass(slots=True) +class EditPlan: + id: str + project_id: str + template_id: str + asset_library_id: str + title_id: str = "" + status: str = "draft" + summary: str = "" + created_by_user_id: str = "" + created_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc)) + updated_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc)) + + +@dataclass(slots=True) +class ProjectTitle: + id: str + project_id: str + text: str + category: str = "default" + favorite: bool = False + usage_count: int = 0 + is_active: bool = True + created_by_user_id: str = "" + created_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc)) + updated_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc)) + + +@dataclass(slots=True) +class Task: + id: str + project_id: str + name: str + description: str = "" + status: str = "pending" + priority: str = "medium" + parent_task_id: str = "" + assignee_user_id: str = "" + progress: float = 0.0 + planned_start_date: datetime | None = None + planned_end_date: datetime | None = None + actual_start_date: datetime | None = None + actual_end_date: datetime | None = None + tags: list[str] = field(default_factory=list) + created_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc)) + updated_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc)) + + +@dataclass(slots=True) +class Milestone: + id: str + project_id: str + name: str + description: str = "" + target_date: datetime | None = None + completed: bool = False + completed_at: datetime | None = None + created_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc)) + updated_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc)) + + +@dataclass(slots=True) +class TaskIssue: + id: str + task_id: str + project_id: str + title: str + description: str = "" + resolved: bool = False + resolved_at: datetime | None = None + created_by_user_id: str = "" + created_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc)) + updated_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc)) diff --git a/packages/ports/asset_library_repository.py b/packages/ports/asset_library_repository.py index 83dc95a8f..1821c1191 100644 --- a/packages/ports/asset_library_repository.py +++ b/packages/ports/asset_library_repository.py @@ -14,19 +14,10 @@ class AssetLibraryRepository(ABC): async def find_by_id(self, library_id: str) -> AssetLibrary | None: pass - @abstractmethod - async def find_by_workspace( - self, - workspace_id: str, - kind: AssetLibraryKind | None = None, - ) -> list[AssetLibrary]: - pass - @abstractmethod async def find_by_project( self, project_id: str, - workspace_id: str, ) -> list[AssetLibrary]: pass @@ -35,7 +26,7 @@ class AssetLibraryRepository(ABC): pass @abstractmethod - async def delete(self, library_id: str, workspace_id: str) -> bool: + async def delete(self, library_id: str) -> bool: pass @abstractmethod diff --git a/packages/ports/asset_repository.py b/packages/ports/asset_repository.py index 47bad8c03..14616936f 100644 --- a/packages/ports/asset_repository.py +++ b/packages/ports/asset_repository.py @@ -18,7 +18,6 @@ class AssetRepository(ABC): async def find_by_project( self, project_id: str, - workspace_id: str, skip: int = 0, limit: int = 100, ) -> list[Asset]: @@ -28,7 +27,6 @@ class AssetRepository(ABC): async def find_by_library( self, library_id: str, - workspace_id: str, skip: int = 0, limit: int = 100, ) -> list[Asset]: @@ -39,9 +37,9 @@ class AssetRepository(ABC): pass @abstractmethod - async def delete(self, asset_id: str, workspace_id: str) -> bool: + async def delete(self, asset_id: str) -> bool: pass @abstractmethod - async def count_by_project(self, project_id: str, workspace_id: str) -> int: + async def count_by_project(self, project_id: str) -> int: pass diff --git a/packages/ports/project_repository.py b/packages/ports/project_repository.py index 82867e4e8..c706fa6fc 100644 --- a/packages/ports/project_repository.py +++ b/packages/ports/project_repository.py @@ -22,8 +22,18 @@ class ProjectRepository(ABC): pass @abstractmethod - def count_by_workspace(self, workspace_id: str) -> int: - """统计工作空间的项目数量""" + def find_by_owner_user_id(self, owner_user_id: str) -> list[Project]: + """根据所有者用户 ID 查找项目""" + pass + + @abstractmethod + def find_accessible_projects(self, user_id: str) -> list[Project]: + """查找用户可访问的所有项目(自己拥有的 + 被共享的)""" + pass + + @abstractmethod + def count_by_owner(self, owner_user_id: str) -> int: + """统计用户的项目数量""" pass @abstractmethod diff --git a/packages/ports/workspace_invitation_repository.py b/packages/ports/workspace_invitation_repository.py deleted file mode 100644 index 78076e68a..000000000 --- a/packages/ports/workspace_invitation_repository.py +++ /dev/null @@ -1,41 +0,0 @@ -""" -WorkspaceInvitation 仓储接口 -""" - -from abc import ABC, abstractmethod -from typing import Optional - -from packages.domain.entities import WorkspaceInvitation - - -class WorkspaceInvitationRepository(ABC): - """WorkspaceInvitation 仓储接口""" - - @abstractmethod - def save(self, invitation: WorkspaceInvitation) -> None: - """保存邀请""" - pass - - @abstractmethod - def find_by_id(self, invitation_id: str) -> Optional[WorkspaceInvitation]: - """根据 ID 查找邀请""" - pass - - @abstractmethod - def find_by_token(self, token: str) -> Optional[WorkspaceInvitation]: - """根据令牌查找邀请""" - pass - - @abstractmethod - def find_pending_by_workspace_and_email( - self, - workspace_id: str, - email: str, - ) -> Optional[WorkspaceInvitation]: - """查找 workspace 和邮箱的待处理邀请""" - pass - - @abstractmethod - def delete(self, invitation_id: str) -> bool: - """删除邀请""" - pass diff --git a/packages/ports/workspace_member_repository.py b/packages/ports/workspace_member_repository.py deleted file mode 100644 index ca71ff5c8..000000000 --- a/packages/ports/workspace_member_repository.py +++ /dev/null @@ -1,51 +0,0 @@ -""" -WorkspaceMember 仓储接口 -""" - -from abc import ABC, abstractmethod -from typing import List, Optional - -from packages.domain.entities import WorkspaceMember - - -class WorkspaceMemberRepository(ABC): - """WorkspaceMember 仓储接口""" - - @abstractmethod - def save(self, member: WorkspaceMember) -> None: - """保存成员""" - pass - - @abstractmethod - def find_by_id(self, member_id: str) -> Optional[WorkspaceMember]: - """根据 ID 查找成员""" - pass - - @abstractmethod - def find_by_workspace_and_user( - self, - workspace_id: str, - user_id: str, - ) -> Optional[WorkspaceMember]: - """根据 workspace 和 user 查找成员""" - pass - - @abstractmethod - def find_by_user(self, user_id: str) -> List[WorkspaceMember]: - """查找用户的所有成员记录""" - pass - - @abstractmethod - def find_by_workspace(self, workspace_id: str) -> List[WorkspaceMember]: - """查找 workspace 的所有成员""" - pass - - @abstractmethod - def count_by_workspace(self, workspace_id: str) -> int: - """统计 workspace 的成员数量""" - pass - - @abstractmethod - def delete(self, member_id: str) -> bool: - """删除成员""" - pass diff --git a/packages/ports/workspace_repository.py b/packages/ports/workspace_repository.py deleted file mode 100644 index d1a9c77fc..000000000 --- a/packages/ports/workspace_repository.py +++ /dev/null @@ -1,27 +0,0 @@ -""" -Workspace 仓储接口 -""" - -from abc import ABC, abstractmethod -from typing import Optional - -from packages.domain.entities import Workspace - - -class WorkspaceRepository(ABC): - """Workspace 仓储接口""" - - @abstractmethod - def save(self, workspace: Workspace) -> None: - """保存 Workspace""" - pass - - @abstractmethod - def find_by_id(self, workspace_id: str) -> Optional[Workspace]: - """根据 ID 查找 Workspace""" - pass - - @abstractmethod - def delete(self, workspace_id: str) -> bool: - """删除 Workspace""" - pass