refactor: Remove workspace concept - Projects now directly under User
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:
@@ -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.
|
||||
@@ -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",
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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])
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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")
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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")
|
||||
@@ -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,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,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
|
||||
|
||||
@@ -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,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,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,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):
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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))
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user