refactor: Remove workspace concept - Projects now directly under User
CI/CD Pipeline / Frontend Lint (pull_request) Failing after 1m1s
Tests / test (pull_request) Failing after 1m0s
Tests / lint (pull_request) Failing after 1m0s
CI/CD Pipeline / Validate Code Quality And Tests (pull_request) Failing after 16m46s

BREAKING CHANGES:
- Removed Workspace, WorkspaceMember, WorkspaceInvitation entities
- Project now has owner_user_id instead of workspace_id
- Added shared_users list to Project for collaboration
- Subscription/quota moved from Workspace to User level

Changes:
- packages/domain/entities.py: Removed Workspace entities, updated Project
- packages/adapters/sqlalchemy_impl/models.py: Updated models
- packages/application/: Removed workspace use cases, updated other use cases
- packages/ports/: Removed workspace repository interfaces
- apps/api/: Updated routes, schemas, dependencies, router
- alembic/versions/007_remove_workspace_concept.py: Database migration

New APIs:
- POST /projects/{id}/share: Share project with user
- DELETE /projects/{id}/share/{user_id}: Unshare project
This commit is contained in:
2026-06-26 19:16:54 +08:00
parent dbf2092e89
commit c5f8c50872
58 changed files with 748 additions and 2791 deletions
@@ -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.
-5
View File
@@ -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",
+15 -21
View File
@@ -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),
+19 -14
View File
@@ -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)
@@ -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,
+19 -13
View File
@@ -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:
+23 -18
View File
@@ -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])
-3
View File
@@ -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,
-16
View File
@@ -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")
+35 -14
View File
@@ -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)
+21 -26
View File
@@ -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,
-369
View File
@@ -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")
-24
View File
@@ -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()
-2
View File
@@ -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
-2
View File
@@ -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
@@ -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
-1
View File
@@ -11,7 +11,6 @@ class UpdateGeneratedVideoReviewRequest(BaseModel):
class GeneratedVideoResponse(BaseModel):
id: str
workspace_id: str
project_id: str
generation_task_id: str
name: str
-2
View File
@@ -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
-2
View File
@@ -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
+2 -2
View File
@@ -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):
-2
View File
@@ -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)
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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()
@@ -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,
@@ -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
@@ -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,
@@ -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
@@ -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,
+5 -50
View File
@@ -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)
@@ -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
@@ -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,
)
@@ -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,
)
@@ -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,
)
+2 -4
View File
@@ -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,
+1 -3
View File
@@ -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,
+7 -3
View File
@@ -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)
+9 -3
View File
@@ -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)
-2
View File
@@ -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,
+39 -8
View File
@@ -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
@@ -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",
]
@@ -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)}"
@@ -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)}"
@@ -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)}"
@@ -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)}"
@@ -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)}"
@@ -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)}"
@@ -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)}"
@@ -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)}"
+161 -70
View File
@@ -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))
+1 -10
View File
@@ -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
+2 -4
View File
@@ -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
+12 -2
View File
@@ -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
@@ -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
@@ -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
-27
View File
@@ -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