fix: 为所有 ORM 模型添加外键约束及级联删除策略 #65

Merged
xiaoxia merged 14 commits from fix/database-foreign-key-constraints into main 2026-06-28 08:47:51 +08:00
110 changed files with 588 additions and 3076 deletions
+7 -7
View File
@@ -43,14 +43,14 @@ CELERY_WORKER_CONCURRENCY=8
CELERY_WORKER_MAX_TASKS_PER_CHILD=1000
# =======================
# MinIO 对象存储配置
# OSS 对象存储配置
# =======================
MINIO_ENDPOINT=47.98.113.167:9000
MINIO_ACCESS_KEY=CHANGE_ME_PRODUCTION
MINIO_SECRET_KEY=CHANGE_ME_PRODUCTION
MINIO_BUCKET=xiaoxia-assets
MINIO_SECURE=false
MINIO_PUBLIC_URL=http://47.98.113.167:9000
OSS_ENDPOINT=47.98.113.167:9000
OSS_ACCESS_KEY_ID=CHANGE_ME_PRODUCTION
OSS_ACCESS_KEY_SECRET=CHANGE_ME_PRODUCTION
OSS_BUCKET_NAME=xiaoxia-assets
OSS_SECURE=false
OSS_PUBLIC_URL=http://47.98.113.167:9000
# =======================
# 日志配置
+1 -2
View File
@@ -43,9 +43,8 @@ CELERY_WORKER_CONCURRENCY=4
CELERY_WORKER_MAX_TASKS_PER_CHILD=1000
# =======================
# MinIO 对象存储配置
# OSS 对象存储配置
# =======================
# OSS 对象存储配置 (原 MINIO_* 配置已统一为 OSS_* 命名)
OSS_ENDPOINT=oss-cn-hangzhou.aliyuncs.com
OSS_ACCESS_KEY_ID=CHANGE_ME_STAGING
OSS_ACCESS_KEY_SECRET=CHANGE_ME_STAGING
@@ -0,0 +1,190 @@
"""add foreign key constraints and missing indexes
Revision ID: 010
Revises: 009
Create Date: 2026-06-28
Summary:
- Add ForeignKey constraints to all 35 logical FK columns across 16 tables
- ON DELETE CASCADE for parent-child relationships (project → children)
- ON DELETE SET NULL for optional user references (created_by, assignee, etc.)
- Add missing indexes on 10 FK columns
- Alter columns from NOT NULL to nullable where SET NULL is needed
"""
from alembic import op
import sqlalchemy as sa
revision = "010"
down_revision = "009"
branch_labels = None
depends_on = None
def upgrade() -> None:
# ── projects ──────────────────────────────────────────────
op.create_foreign_key("fk_projects_owner_user_id", "projects", "users", ["owner_user_id"], ["id"], ondelete="CASCADE")
# ── asset_libraries ───────────────────────────────────────
op.create_foreign_key("fk_asset_libraries_project_id", "asset_libraries", "projects", ["project_id"], ["id"], ondelete="CASCADE")
# ── assets ────────────────────────────────────────────────
# uploaded_by_user_id: nullable=False → nullable=True for SET NULL
op.alter_column("assets", "uploaded_by_user_id", existing_type=sa.String(36), nullable=True)
op.create_foreign_key("fk_assets_project_id", "assets", "projects", ["project_id"], ["id"], ondelete="CASCADE")
op.create_foreign_key("fk_assets_asset_library_id", "assets", "asset_libraries", ["asset_library_id"], ["id"], ondelete="CASCADE")
op.create_foreign_key("fk_assets_uploaded_by_user_id", "assets", "users", ["uploaded_by_user_id"], ["id"], ondelete="SET NULL")
# Add missing index
op.create_index("ix_assets_uploaded_by_user_id", "assets", ["uploaded_by_user_id"])
# ── project_titles ────────────────────────────────────────
# created_by_user_id: nullable=False → nullable=True for SET NULL
op.alter_column("project_titles", "created_by_user_id", existing_type=sa.String(36), nullable=True)
op.create_foreign_key("fk_project_titles_project_id", "project_titles", "projects", ["project_id"], ["id"], ondelete="CASCADE")
op.create_foreign_key("fk_project_titles_created_by_user_id", "project_titles", "users", ["created_by_user_id"], ["id"], ondelete="SET NULL")
# Add missing index
op.create_index("ix_project_titles_created_by_user_id", "project_titles", ["created_by_user_id"])
# ── edit_templates ────────────────────────────────────────
# created_by_user_id: nullable=False → nullable=True for SET NULL
op.alter_column("edit_templates", "created_by_user_id", existing_type=sa.String(32), nullable=True)
op.create_foreign_key("fk_edit_templates_project_id", "edit_templates", "projects", ["project_id"], ["id"], ondelete="CASCADE")
op.create_foreign_key("fk_edit_templates_created_by_user_id", "edit_templates", "users", ["created_by_user_id"], ["id"], ondelete="SET NULL")
# ── edit_plans ────────────────────────────────────────────
# title_id: nullable=False → nullable=True for SET NULL
# created_by_user_id: nullable=False → nullable=True for SET NULL
op.alter_column("edit_plans", "title_id", existing_type=sa.String(32), nullable=True)
op.alter_column("edit_plans", "created_by_user_id", existing_type=sa.String(32), nullable=True)
op.create_foreign_key("fk_edit_plans_project_id", "edit_plans", "projects", ["project_id"], ["id"], ondelete="CASCADE")
op.create_foreign_key("fk_edit_plans_template_id", "edit_plans", "edit_templates", ["template_id"], ["id"], ondelete="CASCADE")
op.create_foreign_key("fk_edit_plans_asset_library_id", "edit_plans", "asset_libraries", ["asset_library_id"], ["id"], ondelete="CASCADE")
op.create_foreign_key("fk_edit_plans_title_id", "edit_plans", "project_titles", ["title_id"], ["id"], ondelete="SET NULL")
op.create_foreign_key("fk_edit_plans_created_by_user_id", "edit_plans", "users", ["created_by_user_id"], ["id"], ondelete="SET NULL")
# ── edit_plan_clips ───────────────────────────────────────
op.create_foreign_key("fk_edit_plan_clips_edit_plan_id", "edit_plan_clips", "edit_plans", ["edit_plan_id"], ["id"], ondelete="CASCADE")
op.create_foreign_key("fk_edit_plan_clips_asset_id", "edit_plan_clips", "assets", ["asset_id"], ["id"], ondelete="CASCADE")
# ── ingest_jobs ───────────────────────────────────────────
# result_asset_id: nullable=False → nullable=True for SET NULL
op.alter_column("ingest_jobs", "result_asset_id", existing_type=sa.String(32), nullable=True)
op.create_foreign_key("fk_ingest_jobs_project_id", "ingest_jobs", "projects", ["project_id"], ["id"], ondelete="CASCADE")
op.create_foreign_key("fk_ingest_jobs_library_id", "ingest_jobs", "asset_libraries", ["library_id"], ["id"], ondelete="CASCADE")
op.create_foreign_key("fk_ingest_jobs_result_asset_id", "ingest_jobs", "assets", ["result_asset_id"], ["id"], ondelete="SET NULL")
# ── classification_jobs ───────────────────────────────────
op.create_foreign_key("fk_classification_jobs_project_id", "classification_jobs", "projects", ["project_id"], ["id"], ondelete="CASCADE")
op.create_foreign_key("fk_classification_jobs_asset_id", "classification_jobs", "assets", ["asset_id"], ["id"], ondelete="CASCADE")
# ── generation_tasks ──────────────────────────────────────
# edit_plan_id: nullable=False → nullable=True for SET NULL
# created_by_user_id: nullable=False → nullable=True for SET NULL
op.alter_column("generation_tasks", "edit_plan_id", existing_type=sa.String(32), nullable=True)
op.alter_column("generation_tasks", "created_by_user_id", existing_type=sa.String(32), nullable=True)
op.create_foreign_key("fk_generation_tasks_project_id", "generation_tasks", "projects", ["project_id"], ["id"], ondelete="CASCADE")
op.create_foreign_key("fk_generation_tasks_asset_library_id", "generation_tasks", "asset_libraries", ["asset_library_id"], ["id"], ondelete="CASCADE")
op.create_foreign_key("fk_generation_tasks_edit_plan_id", "generation_tasks", "edit_plans", ["edit_plan_id"], ["id"], ondelete="SET NULL")
op.create_foreign_key("fk_generation_tasks_created_by_user_id", "generation_tasks", "users", ["created_by_user_id"], ["id"], ondelete="SET NULL")
# ── generated_videos ──────────────────────────────────────
op.create_foreign_key("fk_generated_videos_project_id", "generated_videos", "projects", ["project_id"], ["id"], ondelete="CASCADE")
op.create_foreign_key("fk_generated_videos_generation_task_id", "generated_videos", "generation_tasks", ["generation_task_id"], ["id"], ondelete="CASCADE")
op.create_foreign_key("fk_generated_videos_duplicate_of", "generated_videos", "generated_videos", ["duplicate_of"], ["id"], ondelete="SET NULL")
# ── tasks ─────────────────────────────────────────────────
# parent_task_id: nullable=False → nullable=True for SET NULL
# assignee_user_id: nullable=False → nullable=True for SET NULL
op.alter_column("tasks", "parent_task_id", existing_type=sa.String(32), nullable=True)
op.alter_column("tasks", "assignee_user_id", existing_type=sa.String(32), nullable=True)
op.create_foreign_key("fk_tasks_project_id", "tasks", "projects", ["project_id"], ["id"], ondelete="CASCADE")
op.create_foreign_key("fk_tasks_parent_task_id", "tasks", "tasks", ["parent_task_id"], ["id"], ondelete="SET NULL")
op.create_foreign_key("fk_tasks_assignee_user_id", "tasks", "users", ["assignee_user_id"], ["id"], ondelete="SET NULL")
# ── milestones ────────────────────────────────────────────
op.create_foreign_key("fk_milestones_project_id", "milestones", "projects", ["project_id"], ["id"], ondelete="CASCADE")
# ── task_issues ───────────────────────────────────────────
# created_by_user_id: nullable=False → nullable=True for SET NULL
op.alter_column("task_issues", "created_by_user_id", existing_type=sa.String(32), nullable=True)
op.create_foreign_key("fk_task_issues_task_id", "task_issues", "tasks", ["task_id"], ["id"], ondelete="CASCADE")
op.create_foreign_key("fk_task_issues_project_id", "task_issues", "projects", ["project_id"], ["id"], ondelete="CASCADE")
op.create_foreign_key("fk_task_issues_created_by_user_id", "task_issues", "users", ["created_by_user_id"], ["id"], ondelete="SET NULL")
def downgrade() -> None:
# ── task_issues ───────────────────────────────────────────
op.drop_constraint("fk_task_issues_created_by_user_id", "task_issues", type_="foreignkey")
op.drop_constraint("fk_task_issues_project_id", "task_issues", type_="foreignkey")
op.drop_constraint("fk_task_issues_task_id", "task_issues", type_="foreignkey")
op.alter_column("task_issues", "created_by_user_id", existing_type=sa.String(32), nullable=False)
# ── milestones ────────────────────────────────────────────
op.drop_constraint("fk_milestones_project_id", "milestones", type_="foreignkey")
# ── tasks ─────────────────────────────────────────────────
op.drop_constraint("fk_tasks_assignee_user_id", "tasks", type_="foreignkey")
op.drop_constraint("fk_tasks_parent_task_id", "tasks", type_="foreignkey")
op.drop_constraint("fk_tasks_project_id", "tasks", type_="foreignkey")
op.alter_column("tasks", "assignee_user_id", existing_type=sa.String(32), nullable=False)
op.alter_column("tasks", "parent_task_id", existing_type=sa.String(32), nullable=False)
# ── generated_videos ──────────────────────────────────────
op.drop_constraint("fk_generated_videos_duplicate_of", "generated_videos", type_="foreignkey")
op.drop_constraint("fk_generated_videos_generation_task_id", "generated_videos", type_="foreignkey")
op.drop_constraint("fk_generated_videos_project_id", "generated_videos", type_="foreignkey")
# ── generation_tasks ──────────────────────────────────────
op.drop_constraint("fk_generation_tasks_created_by_user_id", "generation_tasks", type_="foreignkey")
op.drop_constraint("fk_generation_tasks_edit_plan_id", "generation_tasks", type_="foreignkey")
op.drop_constraint("fk_generation_tasks_asset_library_id", "generation_tasks", type_="foreignkey")
op.drop_constraint("fk_generation_tasks_project_id", "generation_tasks", type_="foreignkey")
op.alter_column("generation_tasks", "created_by_user_id", existing_type=sa.String(32), nullable=False)
op.alter_column("generation_tasks", "edit_plan_id", existing_type=sa.String(32), nullable=False)
# ── classification_jobs ───────────────────────────────────
op.drop_constraint("fk_classification_jobs_asset_id", "classification_jobs", type_="foreignkey")
op.drop_constraint("fk_classification_jobs_project_id", "classification_jobs", type_="foreignkey")
# ── ingest_jobs ───────────────────────────────────────────
op.drop_constraint("fk_ingest_jobs_result_asset_id", "ingest_jobs", type_="foreignkey")
op.drop_constraint("fk_ingest_jobs_library_id", "ingest_jobs", type_="foreignkey")
op.drop_constraint("fk_ingest_jobs_project_id", "ingest_jobs", type_="foreignkey")
op.alter_column("ingest_jobs", "result_asset_id", existing_type=sa.String(32), nullable=False)
# ── edit_plan_clips ───────────────────────────────────────
op.drop_constraint("fk_edit_plan_clips_asset_id", "edit_plan_clips", type_="foreignkey")
op.drop_constraint("fk_edit_plan_clips_edit_plan_id", "edit_plan_clips", type_="foreignkey")
# ── edit_plans ────────────────────────────────────────────
op.drop_constraint("fk_edit_plans_created_by_user_id", "edit_plans", type_="foreignkey")
op.drop_constraint("fk_edit_plans_title_id", "edit_plans", type_="foreignkey")
op.drop_constraint("fk_edit_plans_asset_library_id", "edit_plans", type_="foreignkey")
op.drop_constraint("fk_edit_plans_template_id", "edit_plans", type_="foreignkey")
op.drop_constraint("fk_edit_plans_project_id", "edit_plans", type_="foreignkey")
op.alter_column("edit_plans", "created_by_user_id", existing_type=sa.String(32), nullable=False)
op.alter_column("edit_plans", "title_id", existing_type=sa.String(32), nullable=False)
# ── edit_templates ────────────────────────────────────────
op.drop_constraint("fk_edit_templates_created_by_user_id", "edit_templates", type_="foreignkey")
op.drop_constraint("fk_edit_templates_project_id", "edit_templates", type_="foreignkey")
op.alter_column("edit_templates", "created_by_user_id", existing_type=sa.String(32), nullable=False)
# ── project_titles ────────────────────────────────────────
op.drop_index("ix_project_titles_created_by_user_id", table_name="project_titles")
op.drop_constraint("fk_project_titles_created_by_user_id", "project_titles", type_="foreignkey")
op.drop_constraint("fk_project_titles_project_id", "project_titles", type_="foreignkey")
op.alter_column("project_titles", "created_by_user_id", existing_type=sa.String(36), nullable=False)
# ── assets ────────────────────────────────────────────────
op.drop_index("ix_assets_uploaded_by_user_id", table_name="assets")
op.drop_constraint("fk_assets_uploaded_by_user_id", "assets", type_="foreignkey")
op.drop_constraint("fk_assets_asset_library_id", "assets", type_="foreignkey")
op.drop_constraint("fk_assets_project_id", "assets", type_="foreignkey")
op.alter_column("assets", "uploaded_by_user_id", existing_type=sa.String(36), nullable=False)
# ── asset_libraries ───────────────────────────────────────
op.drop_constraint("fk_asset_libraries_project_id", "asset_libraries", type_="foreignkey")
# ── projects ──────────────────────────────────────────────
op.drop_constraint("fk_projects_owner_user_id", "projects", type_="foreignkey")
+2 -8
View File
@@ -1,18 +1,15 @@
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_diagnosis import AssetGapItem, AssetSmartViewItem, ProjectAssetDiagnosisResponse
from fastapi import APIRouter, Depends, HTTPException
from packages.domain import Asset, AssetLibraryKind, AssetStatus
from packages.ports.workspace_member_repository import WorkspaceMemberRepository
router = APIRouter()
@@ -37,7 +34,7 @@ def _readiness_label(score: int) -> str:
return "暂不建议生成"
def _build_diagnosis(workspace_id: str, project_id: str, assets: list[Asset]) -> ProjectAssetDiagnosisResponse:
def _build_diagnosis(project_id: str, assets: list[Asset]) -> ProjectAssetDiagnosisResponse:
ready_assets = [asset for asset in assets if asset.status == AssetStatus.READY]
video_assets = [asset for asset in ready_assets if _asset_kind(asset) == AssetLibraryKind.VIDEO]
image_assets = [asset for asset in ready_assets if _asset_kind(asset) == AssetLibraryKind.IMAGE]
@@ -175,7 +172,6 @@ def _build_diagnosis(workspace_id: str, project_id: str, assets: list[Asset]) ->
]
return ProjectAssetDiagnosisResponse(
workspace_id=workspace_id,
project_id=project_id,
readiness_score=score,
readiness_label=_readiness_label(score),
@@ -201,16 +197,14 @@ def get_project_asset_diagnosis(
project_repository: Any = Depends(get_project_repository),
asset_library_repository: Any = Depends(get_asset_library_repository),
asset_repository: Any = Depends(get_asset_repository),
workspace_member_repository: WorkspaceMemberRepository = Depends(get_workspace_member_repository),
) -> ProjectAssetDiagnosisResponse:
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)
libraries = asset_library_repository.list_by_project(project_id)
assets: list[Asset] = []
for library in libraries:
assets.extend(asset_repository.list_by_library(library.id))
return _build_diagnosis(project.workspace_id, project_id, assets)
return _build_diagnosis(project_id, assets)
+33
View File
@@ -17,6 +17,8 @@ from packages.adapters.redis import NoopSessionStore
from packages.adapters.smtp import NoopEmailService
from packages.application.auth.login_use_case import LoginRequest as LoginUseCaseRequest
from packages.application.auth.login_use_case import LoginUseCase
from packages.application.auth.login_use_case import RefreshTokenRequest as RefreshTokenUseCaseRequest
from packages.application.auth.login_use_case import RefreshTokenUseCase
from packages.application.auth.password_reset_use_case import RequestPasswordResetRequest as PasswordResetUseCaseRequest
from packages.application.auth.password_reset_use_case import (
RequestPasswordResetUseCase,
@@ -50,8 +52,12 @@ class LoginRequest(BaseModel):
password: str
class RefreshRequest(BaseModel):
refresh_token: str
class LoginResponse(BaseModel):
access_token: str
refresh_token: str
token_type: str = "bearer"
user_id: str
email: str
@@ -133,6 +139,7 @@ async def login(
return LoginResponse(
access_token=response.access_token,
refresh_token=response.refresh_token,
user_id=response.user_id,
email=response.email,
username=response.username,
@@ -141,6 +148,32 @@ async def login(
)
@router.post("/refresh")
async def refresh(
request: RefreshRequest,
user_repository: UserRepository = Depends(get_user_repository),
session_store=Depends(get_auth_session_store),
):
use_case = RefreshTokenUseCase(
user_repository=user_repository,
session_store=session_store,
)
response, error = use_case.execute(RefreshTokenUseCaseRequest(refresh_token=request.refresh_token))
if error or response is None:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid or expired refresh token")
return LoginResponse(
access_token=response.access_token,
refresh_token=response.refresh_token,
user_id=response.user_id,
email=response.email,
username=response.username,
display_name=response.display_name,
expires_in=response.expires_in,
)
def _verify_email_token(token: str, user_repository: UserRepository) -> MessageResponse:
success, error = VerifyEmailUseCase(user_repository=user_repository).execute(VerifyEmailRequest(token=token))
if not success:
+4 -37
View File
@@ -21,7 +21,6 @@ from app.dependencies import (
get_asset_library_repository,
get_ingest_job_repository,
get_project_repository,
get_workspace_member_repository,
)
from app.schemas.chunked_upload import (
ChunkedUploadCompleteRequest,
@@ -34,7 +33,6 @@ from fastapi import APIRouter, Depends, HTTPException, UploadFile, status
from fastapi.params import File
from packages.application import GetProjectUseCase, SubmitIngestJobCommand, SubmitIngestJobUseCase
from packages.ports.workspace_member_repository import WorkspaceMemberRepository
router = APIRouter()
logger = logging.getLogger(__name__)
@@ -92,19 +90,7 @@ def _atomic_check_and_record(upload_id: str, chunk_index: int) -> bool:
fcntl.flock(f.fileno(), fcntl.LOCK_UN)
def _require_workspace_member(
workspace_id: str,
authenticated_user: AuthenticatedUser,
workspace_member_repository: WorkspaceMemberRepository,
) -> None:
"""Verify user has workspace permission"""
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 _require_project_and_library(
workspace_id: str,
project_id: str,
library_id: str,
project_repository: Any,
@@ -112,11 +98,11 @@ def _require_project_and_library(
) -> None:
"""Verify project and asset library exist"""
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):
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")
@@ -193,7 +179,6 @@ async def init_chunked_upload(
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),
) -> ChunkedUploadInitResponse:
"""Initialize chunked upload"""
settings = get_settings()
@@ -205,17 +190,13 @@ async def init_chunked_upload(
detail=f"File exceeds maximum size ({MAX_FILE_SIZE // (1024 * 1024 * 1024)}GB)",
)
# Validate project exists and get workspace_id
# Validate project exists
project = GetProjectUseCase(project_repository).execute(request.project_id)
if project is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Project not found")
workspace_id = project.workspace_id
# Verify permission and asset library
_require_workspace_member(workspace_id, authenticated_user, workspace_member_repository)
# Verify asset library
_require_project_and_library(
workspace_id,
request.project_id,
request.library_id,
project_repository,
@@ -253,7 +234,6 @@ async def init_chunked_upload(
"content_type": request.content_type,
"project_id": request.project_id,
"library_id": request.library_id,
"workspace_id": workspace_id,
"status": "pending",
"created_at": now.isoformat(),
"expires_at": expires_at.isoformat(),
@@ -275,7 +255,6 @@ async def upload_chunk(
chunk_index: int,
chunk: UploadFile = File(..., description="Chunk data"),
authenticated_user: AuthenticatedUser = Depends(get_current_user),
workspace_member_repository: WorkspaceMemberRepository = Depends(get_workspace_member_repository),
) -> dict[str, Any]:
"""Upload a single chunk"""
# Load metadata
@@ -296,9 +275,6 @@ async def upload_chunk(
detail=f"Invalid chunk index. Must be between 0 and {meta['total_chunks'] - 1}",
)
# Verify permission
_require_workspace_member(meta["workspace_id"], authenticated_user, workspace_member_repository)
# Atomic check and record to prevent race conditions
if not _atomic_check_and_record(upload_id, chunk_index):
return {"message": "Chunk already uploaded", "chunk_index": chunk_index}
@@ -350,14 +326,10 @@ async def upload_chunk(
async def get_upload_status(
upload_id: str,
authenticated_user: AuthenticatedUser = Depends(get_current_user),
workspace_member_repository: WorkspaceMemberRepository = Depends(get_workspace_member_repository),
) -> ChunkedUploadStatusResponse:
"""Get upload status (for resume)"""
meta = _load_upload_meta(upload_id)
# Verify permission
_require_workspace_member(meta["workspace_id"], authenticated_user, workspace_member_repository)
return ChunkedUploadStatusResponse(
upload_id=upload_id,
filename=meta["filename"],
@@ -377,7 +349,6 @@ async def complete_chunked_upload(
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),
ingest_job_repository: Any = Depends(get_ingest_job_repository),
storage_service: OSSStorageService = Depends(get_storage_service),
) -> ChunkedUploadCompleteResponse:
@@ -385,9 +356,6 @@ async def complete_chunked_upload(
# Load metadata
meta = _load_upload_meta(upload_id)
# Verify permission
_require_workspace_member(meta["workspace_id"], authenticated_user, workspace_member_repository)
# Verify project ID and library ID
if request.project_id != meta["project_id"] or request.library_id != meta["library_id"]:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Project or library ID mismatch")
@@ -448,7 +416,6 @@ async def complete_chunked_upload(
use_case = SubmitIngestJobUseCase(ingest_job_repository)
job = use_case.execute(
SubmitIngestJobCommand(
workspace_id=meta["workspace_id"],
project_id=meta["project_id"],
library_id=meta["library_id"],
storage_key=storage_key,
+10 -28
View File
@@ -1,13 +1,11 @@
from datetime import datetime, timezone
from uuid import uuid4
from app.api.routes.permissions import require_workspace_member
from app.auth import AuthenticatedUser, get_current_user
from app.dependencies import (
get_asset_repository,
get_db_session,
get_project_repository,
get_workspace_member_repository,
)
from app.schemas.edit_plan import (
AutoGenerateEditPlanRequest,
@@ -22,20 +20,18 @@ from sqlalchemy.orm import Session
from packages.adapters.sqlalchemy_impl.models import EditPlanClipModel, EditPlanModel, EditTemplateModel
from packages.domain import AssetStatus
from packages.ports.workspace_member_repository import WorkspaceMemberRepository
router = APIRouter(prefix="/projects/{project_id}/edit-plans", tags=["剪辑计划"])
def _ensure_project(project_id: str, workspace_id: str, user: AuthenticatedUser, project_repository, member_repository):
def _ensure_project(project_id: str, project_repository):
project = project_repository.find_by_id(project_id)
if project is None or project.workspace_id != workspace_id:
if project is None:
raise HTTPException(status_code=404, detail="Project not found")
require_workspace_member(workspace_id, user, member_repository)
return project
def _default_template(session: Session, workspace_id: str, project_id: str, user_id: str) -> EditTemplateModel:
def _default_template(session: Session, project_id: str, user_id: str) -> EditTemplateModel:
template = (
session.query(EditTemplateModel)
.filter(
@@ -49,7 +45,6 @@ def _default_template(session: Session, workspace_id: str, project_id: str, user
return template
template = EditTemplateModel(
id=uuid4().hex,
workspace_id=workspace_id,
project_id=project_id,
name="基础节奏模板",
description="自动选择可用视频素材,按上传顺序生成三段式剪辑计划。",
@@ -65,7 +60,6 @@ def _default_template(session: Session, workspace_id: str, project_id: str, user
def _to_template_response(template: EditTemplateModel) -> EditTemplateResponse:
return EditTemplateResponse(
id=template.id,
workspace_id=template.workspace_id,
project_id=template.project_id,
name=template.name,
description=template.description,
@@ -81,7 +75,6 @@ def _to_plan_response(
) -> EditPlanResponse:
return EditPlanResponse(
id=plan.id,
workspace_id=plan.workspace_id,
project_id=plan.project_id,
template_id=plan.template_id,
asset_library_id=plan.asset_library_id,
@@ -110,14 +103,12 @@ def _to_plan_response(
@router.get("/templates/", response_model=list[EditTemplateResponse])
def list_edit_templates(
project_id: str,
workspace_id: str,
authenticated_user: AuthenticatedUser = Depends(get_current_user),
project_repository=Depends(get_project_repository),
workspace_member_repository: WorkspaceMemberRepository = Depends(get_workspace_member_repository),
session: Session = Depends(get_db_session),
) -> list[EditTemplateResponse]:
_ensure_project(project_id, workspace_id, authenticated_user, project_repository, workspace_member_repository)
template = _default_template(session, workspace_id, project_id, authenticated_user.user.id)
_ensure_project(project_id, project_repository)
template = _default_template(session, project_id, authenticated_user.user.id)
templates = (
session.query(EditTemplateModel)
.filter(EditTemplateModel.project_id == project_id, EditTemplateModel.is_active.is_(True))
@@ -133,19 +124,16 @@ def create_edit_plan(
authenticated_user: AuthenticatedUser = Depends(get_current_user),
project_repository=Depends(get_project_repository),
asset_repository=Depends(get_asset_repository),
workspace_member_repository: WorkspaceMemberRepository = Depends(get_workspace_member_repository),
session: Session = Depends(get_db_session),
) -> EditPlanResponse:
_ensure_project(
project_id, request.workspace_id, authenticated_user, project_repository, workspace_member_repository
)
_ensure_project(project_id, project_repository)
template = (
session.query(EditTemplateModel).filter(EditTemplateModel.id == request.template_id).first()
if request.template_id
else None
)
if template is None:
template = _default_template(session, request.workspace_id, project_id, authenticated_user.user.id)
template = _default_template(session, project_id, authenticated_user.user.id)
assets = [
asset
for asset in asset_repository.list_by_library(request.asset_library_id)
@@ -158,7 +146,6 @@ def create_edit_plan(
]
plan = EditPlanModel(
id=uuid4().hex,
workspace_id=request.workspace_id,
project_id=project_id,
template_id=template.id,
asset_library_id=request.asset_library_id,
@@ -194,7 +181,6 @@ def auto_generate_edit_plan(
authenticated_user: AuthenticatedUser = Depends(get_current_user),
project_repository=Depends(get_project_repository),
asset_repository=Depends(get_asset_repository),
workspace_member_repository: WorkspaceMemberRepository = Depends(get_workspace_member_repository),
session: Session = Depends(get_db_session),
) -> EditPlanResponse:
"""
@@ -207,9 +193,7 @@ def auto_generate_edit_plan(
- voice_over: person 类素材为主播口播,其余穿插为 B-roll
- voice_pip: 结合 voice_over 和 pip,第一个高质量 person 素材为主画面
"""
_ensure_project(
project_id, request.workspace_id, authenticated_user, project_repository, workspace_member_repository
)
_ensure_project(project_id, project_repository)
# 获取素材库中的所有素材
assets = asset_repository.list_by_library(request.asset_library_id)
@@ -238,12 +222,11 @@ def auto_generate_edit_plan(
else None
)
if template is None:
template = _default_template(session, request.workspace_id, project_id, authenticated_user.user.id)
template = _default_template(session, project_id, authenticated_user.user.id)
# 创建剪辑计划
plan = EditPlanModel(
id=uuid4().hex,
workspace_id=request.workspace_id,
project_id=project_id,
template_id=template.id,
asset_library_id=request.asset_library_id,
@@ -286,7 +269,6 @@ def get_edit_plan(
authenticated_user: AuthenticatedUser = Depends(get_current_user),
project_repository=Depends(get_project_repository),
asset_repository=Depends(get_asset_repository),
workspace_member_repository: WorkspaceMemberRepository = Depends(get_workspace_member_repository),
session: Session = Depends(get_db_session),
) -> EditPlanResponse:
plan = (
@@ -294,7 +276,7 @@ def get_edit_plan(
)
if plan is None:
raise HTTPException(status_code=404, detail="Edit plan not found")
_ensure_project(project_id, plan.workspace_id, authenticated_user, project_repository, workspace_member_repository)
_ensure_project(project_id, project_repository)
clips = (
session.query(EditPlanClipModel)
.filter(EditPlanClipModel.edit_plan_id == plan.id)
+1 -12
View File
@@ -1,9 +1,8 @@
from typing import Any
from app.api.routes.permissions import require_workspace_member
from app.auth import AuthenticatedUser, get_current_user
from app.core.storage import OSSStorageService, get_storage_service
from app.dependencies import get_generated_video_repository, get_project_repository, get_workspace_member_repository
from app.dependencies import get_generated_video_repository, get_project_repository
from app.schemas.generated_video import (
GeneratedVideoDownloadUrlResponse,
GeneratedVideoResponse,
@@ -17,7 +16,6 @@ from packages.application import (
GetGeneratedVideoUseCase,
ListGeneratedVideosUseCase,
)
from packages.ports.workspace_member_repository import WorkspaceMemberRepository
router = APIRouter()
@@ -25,7 +23,6 @@ router = APIRouter()
def _to_generated_video_response(item, download_url: str | None = None) -> 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,
@@ -49,13 +46,11 @@ 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),
storage_service: OSSStorageService = Depends(get_storage_service),
) -> 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)
use_case = ListGeneratedVideosUseCase(generated_video_repository)
items = use_case.execute(project_id)
# Generate download URLs for each video
@@ -71,14 +66,12 @@ 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),
storage_service: OSSStorageService = Depends(get_storage_service),
) -> 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)
download_url = storage_service.get_download_url(item.file_url)
return _to_generated_video_response(item, download_url=download_url)
@@ -89,13 +82,11 @@ 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),
storage_service: OSSStorageService = Depends(get_storage_service),
) -> 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)
video.review_status = request.review_status
updated = generated_video_repository.update(video)
download_url = storage_service.get_download_url(updated.file_url)
@@ -107,13 +98,11 @@ 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),
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)
use_case = GetGeneratedVideoDownloadUrlUseCase(generated_video_repository)
file_url = use_case.execute(video_id)
if file_url is None:
+2 -8
View File
@@ -1,8 +1,2 @@
# Compatibility module - workspace concept has been removed
# This is a stub to maintain backward compatibility
def require_workspace_member(workspace_id, authenticated_user, workspace_member_repository):
"""Stub function for backward compatibility.
Workspace concept has been removed.
"""
pass
# Compatibility module - workspace concept has been removed.
# All permission checks are handled at the project level (see packages.domain.permissions).
+3 -23
View File
@@ -51,7 +51,6 @@ def get_issue_repo():
class CreateTaskRequest(BaseModel):
project_id: str
workspace_id: str = ""
name: str
description: str = ""
priority: TaskPriority = TaskPriority.MEDIUM
@@ -62,13 +61,12 @@ class CreateTaskRequest(BaseModel):
class TaskResponse(BaseModel):
id: str
project_id: str
workspace_id: str = ""
name: str
description: str
status: TaskStatus
priority: TaskPriority
parent_task_id: str
assignee_user_id: str
parent_task_id: str | None
assignee_user_id: str | None
progress: float
planned_start_date: datetime | None
planned_end_date: datetime | None
@@ -96,7 +94,6 @@ class UpdateTaskProgressRequest(BaseModel):
class CreateMilestoneRequest(BaseModel):
project_id: str
workspace_id: str = ""
name: str
description: str = ""
@@ -104,7 +101,6 @@ class CreateMilestoneRequest(BaseModel):
class MilestoneResponse(BaseModel):
id: str
project_id: str
workspace_id: str = ""
name: str
description: str
target_date: datetime | None
@@ -117,7 +113,6 @@ class MilestoneResponse(BaseModel):
class CreateTaskIssueRequest(BaseModel):
task_id: str
project_id: str
workspace_id: str = ""
title: str
description: str = ""
created_by_user_id: str = ""
@@ -127,12 +122,11 @@ class TaskIssueResponse(BaseModel):
id: str
task_id: str
project_id: str
workspace_id: str = ""
title: str
description: str
resolved: bool
resolved_at: datetime | None
created_by_user_id: str
created_by_user_id: str | None
created_at: datetime
updated_at: datetime
@@ -149,7 +143,6 @@ def create_task(
use_case = CreateTaskUseCase(task_repo)
task = use_case.execute(
project_id=req.project_id,
workspace_id=req.workspace_id,
name=req.name,
description=req.description,
priority=req.priority,
@@ -159,7 +152,6 @@ def create_task(
return TaskResponse(
id=task.id,
project_id=task.project_id,
workspace_id=task.workspace_id,
name=task.name,
description=task.description,
status=task.status,
@@ -189,7 +181,6 @@ def list_tasks(
TaskResponse(
id=t.id,
project_id=t.project_id,
workspace_id=t.workspace_id,
name=t.name,
description=t.description,
status=t.status,
@@ -223,7 +214,6 @@ def get_task(
return TaskResponse(
id=task.id,
project_id=task.project_id,
workspace_id=task.workspace_id,
name=task.name,
description=task.description,
status=task.status,
@@ -262,7 +252,6 @@ def update_task(
return TaskResponse(
id=task.id,
project_id=task.project_id,
workspace_id=task.workspace_id,
name=task.name,
description=task.description,
status=task.status,
@@ -295,7 +284,6 @@ def update_task_status(
return TaskResponse(
id=task.id,
project_id=task.project_id,
workspace_id=task.workspace_id,
name=task.name,
description=task.description,
status=task.status,
@@ -328,7 +316,6 @@ def update_task_progress(
return TaskResponse(
id=task.id,
project_id=task.project_id,
workspace_id=task.workspace_id,
name=task.name,
description=task.description,
status=task.status,
@@ -358,14 +345,12 @@ def create_milestone(
use_case = CreateMilestoneUseCase(milestone_repo)
milestone = use_case.execute(
project_id=req.project_id,
workspace_id=req.workspace_id,
name=req.name,
description=req.description,
)
return MilestoneResponse(
id=milestone.id,
project_id=milestone.project_id,
workspace_id=milestone.workspace_id,
name=milestone.name,
description=milestone.description,
target_date=milestone.target_date,
@@ -388,7 +373,6 @@ def list_milestones(
MilestoneResponse(
id=m.id,
project_id=m.project_id,
workspace_id=m.workspace_id,
name=m.name,
description=m.description,
target_date=m.target_date,
@@ -414,7 +398,6 @@ def create_issue(
issue = use_case.execute(
task_id=req.task_id,
project_id=req.project_id,
workspace_id=req.workspace_id,
title=req.title,
description=req.description,
created_by_user_id=req.created_by_user_id,
@@ -423,7 +406,6 @@ def create_issue(
id=issue.id,
task_id=issue.task_id,
project_id=issue.project_id,
workspace_id=issue.workspace_id,
title=issue.title,
description=issue.description,
resolved=issue.resolved,
@@ -447,7 +429,6 @@ def list_issues(
id=i.id,
task_id=i.task_id,
project_id=i.project_id,
workspace_id=i.workspace_id,
title=i.title,
description=i.description,
resolved=i.resolved,
@@ -475,7 +456,6 @@ def resolve_issue(
id=issue.id,
task_id=issue.task_id,
project_id=issue.project_id,
workspace_id=issue.workspace_id,
title=issue.title,
description=issue.description,
resolved=issue.resolved,
+2 -16
View File
@@ -1,11 +1,9 @@
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_project_repository,
get_project_title_repository,
get_workspace_member_repository,
)
from app.schemas.project_title import (
CreateProjectTitleRequest,
@@ -15,15 +13,12 @@ from app.schemas.project_title import (
)
from fastapi import APIRouter, Depends, HTTPException, status
from packages.ports.workspace_member_repository import WorkspaceMemberRepository
router = APIRouter()
def _to_response(item) -> ProjectTitleResponse:
return ProjectTitleResponse(
id=item.id,
workspace_id=item.workspace_id,
project_id=item.project_id,
text=item.text,
category=item.category,
@@ -49,10 +44,8 @@ def list_project_titles(
authenticated_user: AuthenticatedUser = Depends(get_current_user),
project_repository: Any = Depends(get_project_repository),
title_repository: Any = Depends(get_project_title_repository),
workspace_member_repository: WorkspaceMemberRepository = Depends(get_workspace_member_repository),
) -> ListProjectTitlesResponse:
project = _get_project_or_404(project_id, project_repository)
require_workspace_member(project.workspace_id, authenticated_user, workspace_member_repository)
_get_project_or_404(project_id, project_repository)
return ListProjectTitlesResponse(
items=[_to_response(item) for item in title_repository.list_by_project(project_id, active_only)]
)
@@ -65,14 +58,9 @@ def create_project_title(
authenticated_user: AuthenticatedUser = Depends(get_current_user),
project_repository: Any = Depends(get_project_repository),
title_repository: Any = Depends(get_project_title_repository),
workspace_member_repository: WorkspaceMemberRepository = Depends(get_workspace_member_repository),
) -> ProjectTitleResponse:
project = _get_project_or_404(project_id, project_repository)
if project.workspace_id != request.workspace_id:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="workspace_id does not match project")
require_workspace_member(project.workspace_id, authenticated_user, workspace_member_repository)
_get_project_or_404(project_id, project_repository)
item = title_repository.create(
workspace_id=project.workspace_id,
project_id=project_id,
text=request.text,
category=request.category,
@@ -88,12 +76,10 @@ def update_project_title(
request: UpdateProjectTitleRequest,
authenticated_user: AuthenticatedUser = Depends(get_current_user),
title_repository: Any = Depends(get_project_title_repository),
workspace_member_repository: WorkspaceMemberRepository = Depends(get_workspace_member_repository),
) -> ProjectTitleResponse:
item = title_repository.get(title_id)
if item is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Project title not found")
require_workspace_member(item.workspace_id, authenticated_user, workspace_member_repository)
if request.text is not None:
item.text = request.text.strip()
if request.category is not None:
+4 -14
View File
@@ -1,13 +1,12 @@
from typing import Any
from app.auth import AuthenticatedUser, get_current_user
from app.dependencies import get_project_repository, get_workspace_member_repository
from app.dependencies import get_project_repository
from app.schemas.project import (
CreateProjectRequest,
ListProjectsResponse,
ProjectResponse,
)
from app.api.routes.permissions import require_workspace_member
from fastapi import APIRouter, Depends, HTTPException, status
from packages.application import (
@@ -16,7 +15,6 @@ from packages.application import (
GetProjectUseCase,
ListProjectsUseCase,
)
from packages.ports.workspace_member_repository import WorkspaceMemberRepository
router = APIRouter()
@@ -24,7 +22,6 @@ router = APIRouter()
def _to_project_response(item) -> ProjectResponse:
return ProjectResponse(
id=item.id,
workspace_id=item.workspace_id,
name=item.name,
description=item.description,
)
@@ -35,26 +32,21 @@ 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)
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.user.id)
return ListProjectsResponse(items=[_to_project_response(item) for item in projects])
@@ -63,15 +55,13 @@ 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.user.id,
)
return _to_project_response(project)
-14
View File
@@ -1,13 +1,11 @@
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 (
get_generation_task_repository,
get_ingest_job_repository,
get_project_repository,
get_workspace_member_repository,
)
from app.schemas.task_center import ListProjectTasksResponse, ProjectTaskResponse
from fastapi import APIRouter, Depends, HTTPException
@@ -18,7 +16,6 @@ from packages.application import (
SubmitIngestJobCommand,
SubmitIngestJobUseCase,
)
from packages.ports.workspace_member_repository import WorkspaceMemberRepository
router = APIRouter()
@@ -68,12 +65,10 @@ def list_project_tasks(
project_repository: Any = Depends(get_project_repository),
ingest_job_repository: Any = Depends(get_ingest_job_repository),
generation_task_repository: Any = Depends(get_generation_task_repository),
workspace_member_repository: WorkspaceMemberRepository = Depends(get_workspace_member_repository),
) -> ListProjectTasksResponse:
project = project_repository.find_by_id(project_id)
if project is None:
raise HTTPException(status_code=404, detail="Project not found")
require_workspace_member(project.workspace_id, authenticated_user, workspace_member_repository)
items: list[ProjectTaskResponse] = []
for job in ingest_job_repository.list_by_project(project_id):
@@ -81,7 +76,6 @@ def list_project_tasks(
ProjectTaskResponse(
id=f"ingest:{job.id}",
task_type="ingest",
workspace_id=job.workspace_id,
project_id=job.project_id,
status=job.status.value,
progress=100.0 if job.status.value == "completed" else 0.0,
@@ -99,7 +93,6 @@ def list_project_tasks(
ProjectTaskResponse(
id=f"generation:{task.id}",
task_type="generation",
workspace_id=task.workspace_id,
project_id=task.project_id,
status=task.status.value,
progress=task.progress,
@@ -123,19 +116,16 @@ def retry_project_task(
authenticated_user: AuthenticatedUser = Depends(get_current_user),
ingest_job_repository: Any = Depends(get_ingest_job_repository),
generation_task_repository: Any = Depends(get_generation_task_repository),
workspace_member_repository: WorkspaceMemberRepository = Depends(get_workspace_member_repository),
) -> ProjectTaskResponse:
if task_type == "generation":
task = generation_task_repository.get(source_id)
if task is None:
raise HTTPException(status_code=404, detail="Generation task not found")
require_workspace_member(task.workspace_id, authenticated_user, workspace_member_repository)
if task.status.value != "failed":
raise HTTPException(status_code=409, detail="Only failed tasks can be retried")
use_case = CreateGenerationTaskUseCase(generation_task_repository)
retried = use_case.execute(
CreateGenerationTaskCommand(
workspace_id=task.workspace_id,
project_id=task.project_id,
asset_library_id=task.asset_library_id,
strategy_id=task.strategy_id,
@@ -148,7 +138,6 @@ def retry_project_task(
return ProjectTaskResponse(
id=f"generation:{retried.id}",
task_type="generation",
workspace_id=retried.workspace_id,
project_id=retried.project_id,
status=retried.status.value,
progress=retried.progress,
@@ -161,13 +150,11 @@ def retry_project_task(
job = ingest_job_repository.get(source_id)
if job is None:
raise HTTPException(status_code=404, detail="Ingest job not found")
require_workspace_member(job.workspace_id, authenticated_user, workspace_member_repository)
if job.status.value != "failed":
raise HTTPException(status_code=409, detail="Only failed tasks can be retried")
use_case = SubmitIngestJobUseCase(ingest_job_repository)
retried = use_case.execute(
SubmitIngestJobCommand(
workspace_id=job.workspace_id,
project_id=job.project_id,
library_id=job.library_id,
storage_key=job.storage_key,
@@ -177,7 +164,6 @@ def retry_project_task(
return ProjectTaskResponse(
id=f"ingest:{retried.id}",
task_type="ingest",
workspace_id=retried.workspace_id,
project_id=retried.project_id,
status=retried.status.value,
progress=0,
+5 -22
View File
@@ -2,7 +2,6 @@ from typing import Any
from uuid import uuid4
from app.auth import AuthenticatedUser, get_current_user
from app.api.routes.permissions import require_workspace_member
from app.config import get_settings
from app.core.celery_app import celery_app
from app.core.storage import OSSStorageService, get_storage_service
@@ -10,7 +9,6 @@ from app.dependencies import (
get_asset_library_repository,
get_ingest_job_repository,
get_project_repository,
get_workspace_member_repository,
)
from app.schemas.upload import (
DirectUploadCompleteRequest,
@@ -25,7 +23,6 @@ from typing import Annotated
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()
@@ -64,23 +61,21 @@ def _validate_mime_type(content_type: str | None) -> str:
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):
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,
@@ -89,7 +84,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,
@@ -105,7 +99,6 @@ async def prepare_direct_upload(
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 的短期表单签名。"""
@@ -119,10 +112,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)
_require_project_and_library(
request.workspace_id,
request.project_id,
request.library_id,
project_repository,
@@ -159,13 +150,10 @@ async def complete_direct_upload(
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)
_require_project_and_library(
request.workspace_id,
request.project_id,
request.library_id,
project_repository,
@@ -178,7 +166,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,
@@ -194,21 +181,18 @@ async def complete_direct_upload(
description="上传素材文件(multipart/form-data),支持视频、音频、图片。触发导入流水线自动处理。",
)
async def upload_asset(
file: UploadFile = File(..., description="要上传的文件(视频、音频、图片等)"),
form_data: Annotated[UploadAssetRequest, Form()],
file: UploadFile = File(..., description="要上传的文件(视频、音频、图片等)"),
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:
"""上传素材文件并触发导入流水线。"""
workspace_id = form_data.workspace_id
project_id = form_data.project_id
library_id = form_data.library_id
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)
_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)
@@ -224,7 +208,6 @@ async def upload_asset(
)
job = _submit_ingest_job(
workspace_id=workspace_id,
project_id=project_id,
library_id=library_id,
storage_key=storage_key,
-369
View File
@@ -1,369 +0,0 @@
"""
Workspace API routes wired through the canonical API composition root.
Only the core create/list/detail endpoints are enabled here. Member invitation
and subscription endpoints remain in application use cases but are mounted only
after their persistence repositories are wired and smoke-tested.
"""
from app.auth import AuthenticatedUser, get_current_user
from app.dependencies import (
get_user_repository,
get_workspace_invitation_repository,
get_workspace_member_repository,
get_workspace_repository,
)
from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import BaseModel, EmailStr
from packages.application.workspace.accept_invitation_use_case import AcceptInvitationRequest, AcceptInvitationUseCase
from packages.application.workspace.create_workspace_use_case import CreateWorkspaceRequest, CreateWorkspaceUseCase
from packages.application.workspace.invite_member_use_case import InviteMemberRequest, InviteMemberUseCase
from packages.application.workspace.list_members_use_case import ListMembersRequest, ListMembersUseCase
from packages.application.workspace.list_workspaces_use_case import (
GetWorkspaceDetailRequest,
GetWorkspaceDetailUseCase,
ListWorkspacesRequest,
ListWorkspacesUseCase,
)
from packages.application.workspace.remove_member_use_case import RemoveMemberRequest, RemoveMemberUseCase
from packages.application.workspace.update_member_role_use_case import UpdateMemberRoleRequest, UpdateMemberRoleUseCase
from packages.ports.user_repository import UserRepository
from packages.ports.workspace_invitation_repository import WorkspaceInvitationRepository
from packages.ports.workspace_member_repository import WorkspaceMemberRepository
from packages.ports.workspace_repository import WorkspaceRepository
router = APIRouter(prefix="/workspaces", tags=["Workspaces"])
class CreateWorkspaceRequestModel(BaseModel):
name: str
subscription_plan: str = "free"
class WorkspaceResponseModel(BaseModel):
workspace_id: str
name: str
subscription_plan: str
max_projects: int
max_storage_gb: int
class WorkspaceSummaryModel(BaseModel):
workspace_id: str
name: str
subscription_plan: str
max_projects: int
max_storage_gb: int
member_count: int
user_role: str
class WorkspaceListResponseModel(BaseModel):
workspaces: list[WorkspaceSummaryModel]
class WorkspaceDetailResponseModel(BaseModel):
workspace_id: str
name: str
owner_user_id: str
subscription_plan: str
subscription_status: str
max_projects: int
max_storage_gb: int
used_storage_gb: float
member_count: int
user_role: str
class InviteMemberRequestModel(BaseModel):
email: EmailStr
role: str
class InviteMemberResponseModel(BaseModel):
invitation_id: str
invitee_email: str
role: str
expires_at: str
class MemberResponseModel(BaseModel):
member_id: str
user_id: str
username: str
email: str
display_name: str
role: str
invited_by: str | None
joined_at: str
class MemberListResponseModel(BaseModel):
members: list[MemberResponseModel]
class UpdateMemberRoleRequestModel(BaseModel):
role: str
class UpdateMemberRoleResponseModel(BaseModel):
user_id: str
old_role: str
new_role: str
class AcceptInvitationResponseModel(BaseModel):
workspace_id: str
workspace_name: str
role: str
class _NoopInvitationEmailService:
def send_workspace_invitation_email(self, **kwargs):
return False, "Email delivery is disabled for workspace compatibility routes"
@router.post("", response_model=WorkspaceResponseModel, status_code=status.HTTP_201_CREATED)
async def create_workspace(
request: CreateWorkspaceRequestModel,
authenticated_user: AuthenticatedUser = Depends(get_current_user),
workspace_repository: WorkspaceRepository = Depends(get_workspace_repository),
workspace_member_repository: WorkspaceMemberRepository = Depends(get_workspace_member_repository),
user_repository: UserRepository = Depends(get_user_repository),
):
use_case = CreateWorkspaceUseCase(
workspace_repository=workspace_repository,
workspace_member_repository=workspace_member_repository,
user_repository=user_repository,
)
response, error = use_case.execute(
CreateWorkspaceRequest(
name=request.name,
owner_user_id=authenticated_user.user.id,
subscription_plan=request.subscription_plan,
)
)
if error or response is None:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=error or "Failed to create workspace")
return WorkspaceResponseModel(
workspace_id=response.workspace_id,
name=response.name,
subscription_plan=response.subscription_plan,
max_projects=response.max_projects,
max_storage_gb=response.max_storage_gb,
)
@router.get("", response_model=WorkspaceListResponseModel)
async def list_workspaces(
authenticated_user: AuthenticatedUser = Depends(get_current_user),
workspace_repository: WorkspaceRepository = Depends(get_workspace_repository),
workspace_member_repository: WorkspaceMemberRepository = Depends(get_workspace_member_repository),
):
use_case = ListWorkspacesUseCase(
workspace_repository=workspace_repository,
workspace_member_repository=workspace_member_repository,
)
response, error = use_case.execute(ListWorkspacesRequest(user_id=authenticated_user.user.id))
if error or response is None:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=error or "Failed to list workspaces")
return WorkspaceListResponseModel(
workspaces=[
WorkspaceSummaryModel(
workspace_id=workspace.workspace_id,
name=workspace.name,
subscription_plan=workspace.subscription_plan,
max_projects=workspace.max_projects,
max_storage_gb=workspace.max_storage_gb,
member_count=workspace.member_count,
user_role=workspace.user_role,
)
for workspace in response.workspaces
]
)
@router.post("/invitations/{invitation_token}/accept", response_model=AcceptInvitationResponseModel)
async def accept_invitation(
invitation_token: str,
authenticated_user: AuthenticatedUser = Depends(get_current_user),
workspace_repository: WorkspaceRepository = Depends(get_workspace_repository),
workspace_member_repository: WorkspaceMemberRepository = Depends(get_workspace_member_repository),
workspace_invitation_repository: WorkspaceInvitationRepository = Depends(get_workspace_invitation_repository),
user_repository: UserRepository = Depends(get_user_repository),
):
response, error = AcceptInvitationUseCase(
workspace_repository=workspace_repository,
workspace_member_repository=workspace_member_repository,
workspace_invitation_repository=workspace_invitation_repository,
user_repository=user_repository,
).execute(AcceptInvitationRequest(invitation_token=invitation_token, user_id=authenticated_user.user.id))
if error or response is None:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=error or "Failed to accept invitation")
return AcceptInvitationResponseModel(
workspace_id=response.workspace_id,
workspace_name=response.workspace_name,
role=response.role,
)
@router.get("/{workspace_id}", response_model=WorkspaceDetailResponseModel)
async def get_workspace_detail(
workspace_id: str,
authenticated_user: AuthenticatedUser = Depends(get_current_user),
workspace_repository: WorkspaceRepository = Depends(get_workspace_repository),
workspace_member_repository: WorkspaceMemberRepository = Depends(get_workspace_member_repository),
):
use_case = GetWorkspaceDetailUseCase(
workspace_repository=workspace_repository,
workspace_member_repository=workspace_member_repository,
)
detail, error = use_case.execute(
GetWorkspaceDetailRequest(workspace_id=workspace_id, user_id=authenticated_user.user.id)
)
if error or detail is None:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=error or "Workspace access denied")
return WorkspaceDetailResponseModel(
workspace_id=detail.workspace_id,
name=detail.name,
owner_user_id=detail.owner_user_id,
subscription_plan=detail.subscription_plan,
subscription_status=detail.subscription_status,
max_projects=detail.max_projects,
max_storage_gb=detail.max_storage_gb,
used_storage_gb=detail.used_storage_gb,
member_count=detail.member_count,
user_role=detail.user_role,
)
@router.post(
"/{workspace_id}/members/invite", response_model=InviteMemberResponseModel, status_code=status.HTTP_201_CREATED
)
async def invite_member(
workspace_id: str,
request: InviteMemberRequestModel,
authenticated_user: AuthenticatedUser = Depends(get_current_user),
workspace_repository: WorkspaceRepository = Depends(get_workspace_repository),
workspace_member_repository: WorkspaceMemberRepository = Depends(get_workspace_member_repository),
workspace_invitation_repository: WorkspaceInvitationRepository = Depends(get_workspace_invitation_repository),
user_repository: UserRepository = Depends(get_user_repository),
):
use_case = InviteMemberUseCase(
workspace_repository=workspace_repository,
workspace_member_repository=workspace_member_repository,
workspace_invitation_repository=workspace_invitation_repository,
user_repository=user_repository,
email_service=_NoopInvitationEmailService(),
)
response, error = use_case.execute(
InviteMemberRequest(
workspace_id=workspace_id,
inviter_user_id=authenticated_user.user.id,
invitee_email=request.email,
role=request.role,
)
)
if error or response is None:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=error or "Failed to invite member")
return InviteMemberResponseModel(
invitation_id=response.invitation_id,
invitee_email=response.invitee_email,
role=response.role,
expires_at=response.expires_at.isoformat(),
)
@router.get("/{workspace_id}/members", response_model=MemberListResponseModel)
async def list_members(
workspace_id: str,
authenticated_user: AuthenticatedUser = Depends(get_current_user),
workspace_repository: WorkspaceRepository = Depends(get_workspace_repository),
workspace_member_repository: WorkspaceMemberRepository = Depends(get_workspace_member_repository),
user_repository: UserRepository = Depends(get_user_repository),
):
use_case = ListMembersUseCase(
workspace_repository=workspace_repository,
workspace_member_repository=workspace_member_repository,
user_repository=user_repository,
)
response, error = use_case.execute(
ListMembersRequest(workspace_id=workspace_id, requester_user_id=authenticated_user.user.id)
)
if error or response is None:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=error or "Failed to list members")
return MemberListResponseModel(
members=[
MemberResponseModel(
member_id=member.member_id,
user_id=member.user_id,
username=member.username,
email=member.email,
display_name=member.display_name,
role=member.role,
invited_by=member.invited_by,
joined_at=member.joined_at.isoformat(),
)
for member in response.members
]
)
@router.patch("/{workspace_id}/members/{user_id}/role", response_model=UpdateMemberRoleResponseModel)
async def update_member_role(
workspace_id: str,
user_id: str,
request: UpdateMemberRoleRequestModel,
authenticated_user: AuthenticatedUser = Depends(get_current_user),
workspace_repository: WorkspaceRepository = Depends(get_workspace_repository),
workspace_member_repository: WorkspaceMemberRepository = Depends(get_workspace_member_repository),
):
use_case = UpdateMemberRoleUseCase(
workspace_repository=workspace_repository,
workspace_member_repository=workspace_member_repository,
)
response, error = use_case.execute(
UpdateMemberRoleRequest(
workspace_id=workspace_id,
requester_user_id=authenticated_user.user.id,
target_user_id=user_id,
new_role=request.role,
)
)
if error or response is None:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=error or "Failed to update member role")
return UpdateMemberRoleResponseModel(
user_id=response.user_id,
old_role=response.old_role,
new_role=response.new_role,
)
@router.delete("/{workspace_id}/members/{user_id}", status_code=status.HTTP_204_NO_CONTENT)
async def remove_member(
workspace_id: str,
user_id: str,
authenticated_user: AuthenticatedUser = Depends(get_current_user),
workspace_repository: WorkspaceRepository = Depends(get_workspace_repository),
workspace_member_repository: WorkspaceMemberRepository = Depends(get_workspace_member_repository),
):
success, error = RemoveMemberUseCase(
workspace_repository=workspace_repository,
workspace_member_repository=workspace_member_repository,
).execute(
RemoveMemberRequest(
workspace_id=workspace_id,
requester_user_id=authenticated_user.user.id,
target_user_id=user_id,
)
)
if not success:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=error or "Failed to remove member")
-7
View File
@@ -211,8 +211,6 @@ class OSSStorageService:
return self.bucket.object_exists(storage_key)
# 向后兼容的服务类名
MinIOService = OSSStorageService
_storage_service = None
@@ -225,8 +223,3 @@ def get_storage_service() -> OSSStorageService:
_storage_service = OSSStorageService()
return _storage_service
# 向后兼容的别名
def get_minio_service() -> OSSStorageService:
"""向后兼容:返回 OSS 服务(已替换 MinIO)"""
return get_storage_service()
-27
View File
@@ -38,15 +38,9 @@ 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.asset_repository import AssetRepository
from packages.ports.asset_library_repository import AssetLibraryRepository
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
from packages.ports.classification_job_repository import ClassificationJobRepository
from packages.ports.generation_task_repository import GenerationTaskRepository
from packages.ports.generated_video_repository import GeneratedVideoRepository
@@ -129,27 +123,6 @@ def get_user_repository(
return SQLAlchemyUserRepository(session)
def get_workspace_repository(
session: Session = Depends(get_db_session),
) -> WorkspaceRepository:
"""Provide the SQLAlchemy workspace repository implementation."""
return SQLAlchemyWorkspaceRepository(session)
def get_workspace_member_repository(
session: Session = Depends(get_db_session),
) -> WorkspaceMemberRepository:
"""Provide the SQLAlchemy workspace member repository implementation."""
return SQLAlchemyWorkspaceMemberRepository(session)
def get_workspace_invitation_repository(
session: Session = Depends(get_db_session),
) -> WorkspaceInvitationRepository:
"""Provide the SQLAlchemy workspace invitation repository implementation."""
return SQLAlchemyWorkspaceInvitationRepository(session)
def get_auth_session_store() -> SessionStore | NoopSessionStore:
"""Provide the session store based on configuration."""
if not settings.ENABLE_REDIS_SESSIONS:
+2 -40
View File
@@ -9,14 +9,12 @@ from __future__ import annotations
from app.auth import AuthenticatedUser
from app.auth import get_current_user as get_authenticated_user
from app.dependencies import get_user_repository, get_workspace_member_repository
from fastapi import Depends, HTTPException, status
from app.dependencies import get_user_repository
from fastapi import Depends
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from packages.domain.entities import User
from packages.domain.permissions import PermissionChecker
from packages.ports.user_repository import UserRepository
from packages.ports.workspace_member_repository import WorkspaceMemberRepository
optional_bearer_scheme = HTTPBearer(auto_error=False)
@@ -38,39 +36,3 @@ async def get_current_user_optional(
except HTTPException:
return None
return authenticated_user.user
def require_workspace_access(
workspace_id: str,
user: User = Depends(get_current_user),
workspace_member_repository: WorkspaceMemberRepository = Depends(get_workspace_member_repository),
) -> tuple[str, str]:
has_access, role = PermissionChecker(workspace_member_repository).check_workspace_access(workspace_id, user.id)
if not has_access or role is None:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="You don't have access to this workspace")
return workspace_id, role
def require_workspace_admin(
workspace_id: str,
user: User = Depends(get_current_user),
workspace_member_repository: WorkspaceMemberRepository = Depends(get_workspace_member_repository),
) -> str:
if not PermissionChecker(workspace_member_repository).check_is_admin_or_owner(workspace_id, user.id):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Only workspace owner or admin can perform this action",
)
return workspace_id
def require_workspace_owner(
workspace_id: str,
user: User = Depends(get_current_user),
workspace_member_repository: WorkspaceMemberRepository = Depends(get_workspace_member_repository),
) -> str:
if not PermissionChecker(workspace_member_repository).check_is_owner(workspace_id, user.id):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, detail="Only workspace owner can perform this action"
)
return workspace_id
@@ -0,0 +1,135 @@
"""
Prometheus metrics middleware and metrics endpoint for the API.
Exposes:
- HTTP request duration histogram (by method, path, status)
- HTTP requests total counter (by method, path, status)
- Requests in progress gauge
- Custom application metrics
"""
import re
import time
from typing import Callable
from fastapi import Request, Response
from prometheus_client import (
Counter,
Gauge,
Histogram,
generate_latest,
CONTENT_TYPE_LATEST,
REGISTRY,
)
from starlette.middleware.base import BaseHTTPMiddleware
from starlette.responses import PlainTextResponse
# Buckets for HTTP request duration (seconds)
HTTP_DURATION_BUCKETS = (0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1.0, 2.5, 5.0, 10.0)
# --- Metrics Definitions ---
HTTP_REQUESTS_TOTAL = Counter(
"http_requests_total",
"Total HTTP requests",
["method", "endpoint", "status"],
registry=REGISTRY,
)
HTTP_REQUEST_DURATION_SECONDS = Histogram(
"http_request_duration_seconds",
"HTTP request duration in seconds",
["method", "endpoint", "status"],
buckets=HTTP_DURATION_BUCKETS,
registry=REGISTRY,
)
HTTP_REQUESTS_IN_PROGRESS = Gauge(
"http_requests_in_progress",
"Number of HTTP requests currently being processed",
["method"],
registry=REGISTRY,
)
# --- Application Metrics ---
APP_INFO = Gauge(
"app_info",
"Application information",
["version", "environment"],
registry=REGISTRY,
)
def _normalize_path(path: str) -> str:
"""
Normalize request path to reduce cardinality.
Replace UUIDs and numeric IDs with placeholders.
"""
# Replace UUIDs
path = re.sub(
r"[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}",
":uuid",
path,
)
# Replace numeric IDs in path segments
path = re.sub(r"/\d+(/|$)", "/:id\\1", path)
return path
class PrometheusMetricsMiddleware(BaseHTTPMiddleware):
"""Middleware that records Prometheus metrics for every HTTP request."""
def __init__(self, app, exclude_paths: list[str] | None = None):
super().__init__(app)
self.exclude_paths = set(exclude_paths or ["/metrics", "/health", "/ready", "/startup"])
async def dispatch(self, request: Request, call_next: Callable) -> Response:
path = request.url.path
if path in self.exclude_paths:
return await call_next(request)
method = request.method
normalized_path = _normalize_path(path)
HTTP_REQUESTS_IN_PROGRESS.labels(method=method).inc()
start_time = time.perf_counter()
try:
response = await call_next(request)
status = str(response.status_code)
return response
except Exception:
status = "500"
raise
finally:
duration = time.perf_counter() - start_time
HTTP_REQUESTS_TOTAL.labels(
method=method,
endpoint=normalized_path,
status=status,
).inc()
HTTP_REQUEST_DURATION_SECONDS.labels(
method=method,
endpoint=normalized_path,
status=status,
).observe(duration)
HTTP_REQUESTS_IN_PROGRESS.labels(method=method).dec()
async def metrics_endpoint(request: Request) -> PlainTextResponse:
"""FastAPI endpoint that returns Prometheus metrics in text format."""
import os
version = os.getenv("APP_VERSION", "unknown")
environment = os.getenv("APP_ENV", "unknown")
APP_INFO.labels(version=version, environment=environment).set(1)
metrics_output = generate_latest(REGISTRY)
return PlainTextResponse(
content=metrics_output,
media_type=CONTENT_TYPE_LATEST,
)
+1 -1
View File
@@ -30,7 +30,7 @@ class EditPlanResponse(BaseModel):
project_id: str
template_id: str
asset_library_id: str
title_id: str = ""
title_id: str | None = ""
status: str
summary: str
editing_mode: str | None = None # one_take, pip, voice_over, voice_pip
+1 -1
View File
@@ -16,7 +16,7 @@ class GenerationTaskResponse(BaseModel):
asset_library_id: str
strategy_id: str
voice_library_id: str
edit_plan_id: str
edit_plan_id: str | None
status: str
progress: float
result_count: int
+1 -1
View File
@@ -14,4 +14,4 @@ class IngestJobResponse(BaseModel):
storage_key: str
status: str
error_message: str
result_asset_id: str
result_asset_id: str | None
-1
View File
@@ -6,7 +6,6 @@ class UploadAssetRequest(BaseModel):
"""素材上传请求(multipart form"""
project_id: str = Field(..., min_length=1, description="项目 ID")
library_id: str = Field(..., min_length=1, description="素材库 ID")
workspace_id: str = Field(default="", description="工作空间 ID(已废弃,可传空)")
class UploadAssetResponse(BaseModel):
storage_key: str
+3
View File
@@ -10,6 +10,7 @@ from app.middleware.exceptions import (
validation_exception_handler,
)
from app.middleware.logging import RequestLoggingMiddleware
from app.middleware.prometheus_metrics import PrometheusMetricsMiddleware, metrics_endpoint
from fastapi import FastAPI
from fastapi.exceptions import RequestValidationError
from fastapi.middleware.cors import CORSMiddleware
@@ -51,8 +52,10 @@ app.add_middleware(
)
app.add_middleware(GZipMiddleware, minimum_size=1000)
app.add_middleware(RequestLoggingMiddleware)
app.add_middleware(PrometheusMetricsMiddleware)
app.include_router(health_router)
app.add_route("/metrics", metrics_endpoint, methods=["GET"])
app.include_router(api_router)
_generated_files_dir = os.getenv("GENERATED_FILES_DIR", "/app/generated")
@@ -161,8 +161,8 @@ const AutoArrangePanel: React.FC<{
try {
const data = await getEditPlans(projectId);
setPlans(data);
} catch (error) {
console.error("Failed to fetch edit plans:", error);
} catch {
// 编排方案获取失败,静默处理
} finally {
setLoading(false);
}
+2 -3
View File
@@ -93,8 +93,8 @@ def generate_video(task_id: str) -> dict:
session.commit()
# 6. 上传到 OSS
storage_key = f"workspaces/{task.workspace_id}/projects/{task.project_id}/generated/{task.id}/{output_filename}"
thumbnail_key = f"workspaces/{task.workspace_id}/projects/{task.project_id}/generated/{task.id}/thumbnail.jpg"
storage_key = f"projects/{task.project_id}/generated/{task.id}/{output_filename}"
thumbnail_key = f"projects/{task.project_id}/generated/{task.id}/thumbnail.jpg"
storage_service.upload_file(result.output_path, storage_key)
storage_service.upload_file(result.thumbnail_path, thumbnail_key)
@@ -108,7 +108,6 @@ def generate_video(task_id: str) -> dict:
# 7. 创建 GeneratedVideo 记录
video = GeneratedVideo.create(
workspace_id=task.workspace_id,
project_id=task.project_id,
generation_task_id=task.id,
name=output_filename,
+1 -1
View File
@@ -154,7 +154,7 @@ def check_duplicate_task(self: Task, generated_video_id: str) -> dict:
local_path = os.path.join(temp_dir, f"{generated_video_id}.mp4")
storage_key = video.file_url.split("/")[-1]
storage_service.download_file(f"workspaces/{video.workspace_id}/projects/{video.project_id}/generated/{generated_video_id}/{generated_video_id}.mp4", local_path)
storage_service.download_file(f"projects/{video.project_id}/generated/{generated_video_id}/{generated_video_id}.mp4", local_path)
fingerprint = deduplicator.compute_fingerprint(local_path)
+1 -1
View File
@@ -7,7 +7,7 @@ def mark_title_used_for_generation(db, task) -> None:
if not task.strategy_id:
return
title = db.query(ProjectTitleModel).filter(ProjectTitleModel.id == task.strategy_id).first()
if title is None or title.workspace_id != task.workspace_id or title.project_id != task.project_id:
if title is None or title.project_id != task.project_id:
return
title.usage_count = int(title.usage_count or 0) + 1
title.updated_at = datetime.now(timezone.utc)
+1 -2
View File
@@ -240,7 +240,6 @@ def generate_video(self, task_id: str) -> dict:
gen_task = task_repo.get(task_id)
if gen_task is None:
return {"status": "failed", "error": f"generation task {task_id} not found"}
workspace_id = gen_task.workspace_id
project_id = gen_task.project_id
asset_library_id = gen_task.asset_library_id
voice_library_id = gen_task.voice_library_id or ""
@@ -254,7 +253,7 @@ def generate_video(self, task_id: str) -> dict:
editing_mode = GenerationMode.ONE_TAKE
output_name = f"generated-{task_id}.mp4"
storage_key = f"generated/workspaces/{workspace_id}/projects/{project_id}/tasks/{task_id}/{output_name}"
storage_key = f"generated/projects/{project_id}/tasks/{task_id}/{output_name}"
try:
with tempfile.TemporaryDirectory(prefix="xiaoxia-generation-") as temp_dir:
-1
View File
@@ -151,7 +151,6 @@ def ingest_asset(job_id: str) -> dict:
# Create Asset
asset = Asset.create(
workspace_id=job.workspace_id,
project_id=job.project_id,
library_id=job.library_id,
name=filename,
+1 -1
View File
@@ -134,7 +134,7 @@ mypy packages/ apps/ --ignore-missing-imports
### Deploy 成功但业务链不通
优先检查:
- `/var/lib/xiaoxia-saas-staging/.env`
- `MINIO_ENDPOINT`
- `OSS_ENDPOINT`
- `DATABASE_URL`
- worker 日志中的 Celery 任务消费情况
+6 -6
View File
@@ -296,12 +296,12 @@ CELERY_BROKER_URL=redis://localhost:6379/1
CELERY_RESULT_BACKEND=redis://localhost:6379/2
# MinIO
MINIO_ENDPOINT=localhost:9000
MINIO_ACCESS_KEY=minioadmin
MINIO_SECRET_KEY=minioadmin
MINIO_BUCKET=xiaoxia-saas
MINIO_SECURE=false
MINIO_PUBLIC_URL=http://localhost:9000
OSS_ENDPOINT=localhost:9000
OSS_ACCESS_KEY_ID=minioadmin
OSS_ACCESS_KEY_SECRET=minioadmin
OSS_BUCKET_NAME=xiaoxia-saas
OSS_SECURE=false
OSS_PUBLIC_URL=http://localhost:9000
# JWT
JWT_SECRET_KEY=xiaoxia-dev-secret-key-please-change-in-production-min-32-chars
+6 -6
View File
@@ -42,12 +42,12 @@ DATABASE_URL=postgresql://xiaoxia:xiaoxia123@localhost:5432/xiaoxia_saas
REDIS_URL=redis://localhost:6379/0
# MinIO
MINIO_ENDPOINT=localhost:9000
MINIO_ACCESS_KEY=minioadmin
MINIO_SECRET_KEY=minioadmin
MINIO_BUCKET=xiaoxia-saas
MINIO_SECURE=false
MINIO_PUBLIC_URL=http://localhost:9000
OSS_ENDPOINT=localhost:9000
OSS_ACCESS_KEY_ID=minioadmin
OSS_ACCESS_KEY_SECRET=minioadmin
OSS_BUCKET_NAME=xiaoxia-saas
OSS_SECURE=false
OSS_PUBLIC_URL=http://localhost:9000
# JWT
JWT_SECRET_KEY=your-super-secret-key-change-in-production
+1 -1
View File
@@ -127,7 +127,7 @@
**检查项**
- ✅ 数据库配置(DATABASE_URL
- ✅ Redis 配置(REDIS_URL
-MinIO 配置(MINIO_*
-OSS 配置(OSS_*
- ✅ JWT 配置(JWT_SECRET_KEY
- ✅ Celery 配置(CELERY_BROKER_URL
@@ -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
+120 -37
View File
@@ -1,7 +1,7 @@
from datetime import datetime, timezone
from sqlalchemy import Boolean, Column, DateTime, Float, Integer, String, Text, JSON, UniqueConstraint, create_engine
from sqlalchemy.orm import declarative_base
from sqlalchemy import Boolean, Column, DateTime, Float, ForeignKey, Integer, String, Text, JSON, UniqueConstraint, create_engine
from sqlalchemy.orm import declarative_base, relationship
Base = declarative_base()
@@ -30,23 +30,41 @@ class UserModel(Base):
used_storage_gb = Column(Float, nullable=False, default=0.0)
created_at = Column(DateTime, nullable=False, default=lambda: datetime.now(timezone.utc))
# relationships
projects = relationship("ProjectModel", back_populates="owner", cascade="all, delete-orphan")
class ProjectModel(Base):
__tablename__ = "projects"
id = Column(String(32), primary_key=True)
owner_user_id = Column(String(32), nullable=False, index=True)
owner_user_id = Column(String(32), ForeignKey("users.id", ondelete="CASCADE"), 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))
# relationships
owner = relationship("UserModel", back_populates="projects")
asset_libraries = relationship("AssetLibraryModel", back_populates="project", cascade="all, delete-orphan")
assets = relationship("AssetModel", back_populates="project", cascade="all, delete-orphan")
project_titles = relationship("ProjectTitleModel", back_populates="project", cascade="all, delete-orphan")
edit_templates = relationship("EditTemplateModel", back_populates="project", cascade="all, delete-orphan")
edit_plans = relationship("EditPlanModel", back_populates="project", cascade="all, delete-orphan")
ingest_jobs = relationship("IngestJobModel", back_populates="project", cascade="all, delete-orphan")
classification_jobs = relationship("ClassificationJobModel", back_populates="project", cascade="all, delete-orphan")
generation_tasks = relationship("GenerationTaskModel", back_populates="project", cascade="all, delete-orphan")
generated_videos = relationship("GeneratedVideoModel", back_populates="project", cascade="all, delete-orphan")
tasks = relationship("TaskModel", back_populates="project", cascade="all, delete-orphan")
milestones = relationship("MilestoneModel", back_populates="project", cascade="all, delete-orphan")
task_issues = relationship("TaskIssueModel", back_populates="project", cascade="all, delete-orphan")
class AssetLibraryModel(Base):
__tablename__ = "asset_libraries"
id = Column(String(36), primary_key=True)
project_id = Column(String(36), nullable=True, index=True)
project_id = Column(String(36), ForeignKey("projects.id", ondelete="CASCADE"), nullable=True, index=True)
name = Column(String(255), nullable=False)
kind = Column(String(20), nullable=False, index=True)
asset_count = Column(Float, nullable=False, default=0)
@@ -54,13 +72,17 @@ class AssetLibraryModel(Base):
created_at = Column(DateTime, nullable=False, default=lambda: datetime.now(timezone.utc))
updated_at = Column(DateTime, nullable=False, default=lambda: datetime.now(timezone.utc))
# relationships
project = relationship("ProjectModel", back_populates="asset_libraries")
assets = relationship("AssetModel", back_populates="asset_library")
class AssetModel(Base):
__tablename__ = "assets"
id = Column(String(36), primary_key=True)
project_id = Column(String(36), nullable=False, index=True)
asset_library_id = Column(String(36), nullable=False, index=True)
project_id = Column(String(36), ForeignKey("projects.id", ondelete="CASCADE"), nullable=False, index=True)
asset_library_id = Column(String(36), ForeignKey("asset_libraries.id", ondelete="CASCADE"), nullable=False, index=True)
name = Column(String(500), nullable=False)
file_type = Column(String(20), nullable=False, index=True)
# storage_key: OSS 对象键(相对路径),用于内部存储和操作
@@ -79,90 +101,121 @@ class AssetModel(Base):
classification_status = Column(String(20), nullable=False, default="pending", index=True)
classification_result = Column(Text, nullable=True)
quality_score = Column(Float, nullable=True)
uploaded_by_user_id = Column(String(36), nullable=False)
uploaded_by_user_id = Column(String(36), ForeignKey("users.id", ondelete="SET NULL"), nullable=True, index=True)
created_at = Column(DateTime, nullable=False, default=lambda: datetime.now(timezone.utc), index=True)
updated_at = Column(DateTime, nullable=False, default=lambda: datetime.now(timezone.utc))
# relationships
project = relationship("ProjectModel", back_populates="assets")
asset_library = relationship("AssetLibraryModel", back_populates="assets")
uploaded_by = relationship("UserModel")
class ProjectTitleModel(Base):
__tablename__ = "project_titles"
id = Column(String(36), primary_key=True)
project_id = Column(String(36), nullable=False, index=True)
project_id = Column(String(36), ForeignKey("projects.id", ondelete="CASCADE"), nullable=False, index=True)
text = Column(String(200), nullable=False)
category = Column(String(50), nullable=False, default="default", index=True)
favorite = Column(Boolean, nullable=False, default=False, index=True)
usage_count = Column(Integer, nullable=False, default=0)
is_active = Column(Boolean, nullable=False, default=True)
created_by_user_id = Column(String(36), nullable=False)
created_by_user_id = Column(String(36), ForeignKey("users.id", ondelete="SET NULL"), nullable=True, index=True)
created_at = Column(DateTime, nullable=False, default=lambda: datetime.now(timezone.utc))
updated_at = Column(DateTime, nullable=False, default=lambda: datetime.now(timezone.utc))
# relationships
project = relationship("ProjectModel", back_populates="project_titles")
created_by = relationship("UserModel")
class EditTemplateModel(Base):
__tablename__ = "edit_templates"
id = Column(String(32), primary_key=True)
project_id = Column(String(32), nullable=False, index=True)
project_id = Column(String(32), ForeignKey("projects.id", ondelete="CASCADE"), nullable=False, index=True)
name = Column(String(120), nullable=False)
description = Column(Text, nullable=False, default="")
target_duration = Column(Float, nullable=False, default=30)
clip_count = Column(Integer, nullable=False, default=3)
is_active = Column(Boolean, nullable=False, default=True)
created_by_user_id = Column(String(32), nullable=False, default="")
created_by_user_id = Column(String(32), ForeignKey("users.id", ondelete="SET NULL"), nullable=True)
created_at = Column(DateTime, nullable=False, default=lambda: datetime.now(timezone.utc))
updated_at = Column(DateTime, nullable=False, default=lambda: datetime.now(timezone.utc))
# relationships
project = relationship("ProjectModel", back_populates="edit_templates")
created_by = relationship("UserModel")
edit_plans = relationship("EditPlanModel", back_populates="template")
class EditPlanModel(Base):
__tablename__ = "edit_plans"
id = Column(String(32), primary_key=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)
title_id = Column(String(32), nullable=False, default="")
project_id = Column(String(32), ForeignKey("projects.id", ondelete="CASCADE"), nullable=False, index=True)
template_id = Column(String(32), ForeignKey("edit_templates.id", ondelete="CASCADE"), nullable=False, index=True)
asset_library_id = Column(String(32), ForeignKey("asset_libraries.id", ondelete="CASCADE"), nullable=False, index=True)
title_id = Column(String(32), ForeignKey("project_titles.id", ondelete="SET NULL"), nullable=True)
editing_mode = Column(String(20), nullable=True, default=None, index=True)
status = Column(String(20), nullable=False, default="draft", index=True)
summary = Column(Text, nullable=False, default="")
created_by_user_id = Column(String(32), nullable=False, default="")
created_by_user_id = Column(String(32), ForeignKey("users.id", ondelete="SET NULL"), nullable=True)
created_at = Column(DateTime, nullable=False, default=lambda: datetime.now(timezone.utc))
updated_at = Column(DateTime, nullable=False, default=lambda: datetime.now(timezone.utc))
# relationships
project = relationship("ProjectModel", back_populates="edit_plans")
template = relationship("EditTemplateModel", back_populates="edit_plans")
asset_library = relationship("AssetLibraryModel")
title = relationship("ProjectTitleModel")
created_by = relationship("UserModel")
clips = relationship("EditPlanClipModel", back_populates="edit_plan", cascade="all, delete-orphan")
class EditPlanClipModel(Base):
__tablename__ = "edit_plan_clips"
id = Column(String(32), primary_key=True)
edit_plan_id = Column(String(32), nullable=False, index=True)
asset_id = Column(String(32), nullable=False, index=True)
edit_plan_id = Column(String(32), ForeignKey("edit_plans.id", ondelete="CASCADE"), nullable=False, index=True)
asset_id = Column(String(32), ForeignKey("assets.id", ondelete="CASCADE"), nullable=False, index=True)
sequence = Column(Integer, nullable=False)
start_time = Column(Float, nullable=False, default=0)
duration = Column(Float, nullable=False, default=0)
reason = Column(Text, nullable=False, default="")
layer = Column(String(20), nullable=False, default="main")
# relationships
edit_plan = relationship("EditPlanModel", back_populates="clips")
asset = relationship("AssetModel")
class IngestJobModel(Base):
__tablename__ = "ingest_jobs"
id = Column(String(32), primary_key=True)
project_id = Column(String(32), nullable=False, index=True)
library_id = Column(String(32), nullable=False, index=True)
project_id = Column(String(32), ForeignKey("projects.id", ondelete="CASCADE"), nullable=False, index=True)
library_id = Column(String(32), ForeignKey("asset_libraries.id", ondelete="CASCADE"), nullable=False, index=True)
storage_key = Column(String(255), nullable=False)
status = Column(String(20), nullable=False, default="pending")
error_message = Column(Text, nullable=False, default="")
result_asset_id = Column(String(32), nullable=False, default="")
result_asset_id = Column(String(32), ForeignKey("assets.id", ondelete="SET NULL"), nullable=True)
created_at = Column(DateTime, nullable=False, default=lambda: datetime.now(timezone.utc))
updated_at = Column(DateTime, nullable=False, default=lambda: datetime.now(timezone.utc))
# relationships
project = relationship("ProjectModel", back_populates="ingest_jobs")
library = relationship("AssetLibraryModel")
result_asset = relationship("AssetModel")
class ClassificationJobModel(Base):
__tablename__ = "classification_jobs"
id = Column(String(32), primary_key=True)
project_id = Column(String(32), nullable=False, index=True)
asset_id = Column(String(32), nullable=False, index=True)
project_id = Column(String(32), ForeignKey("projects.id", ondelete="CASCADE"), nullable=False, index=True)
asset_id = Column(String(32), ForeignKey("assets.id", ondelete="CASCADE"), nullable=False, index=True)
status = Column(String(20), nullable=False, default="pending")
classification = Column(String(50), nullable=False, default="")
confidence = Column(Float, nullable=False, default=0.0)
@@ -170,16 +223,20 @@ class ClassificationJobModel(Base):
created_at = Column(DateTime, nullable=False, default=lambda: datetime.now(timezone.utc))
updated_at = Column(DateTime, nullable=False, default=lambda: datetime.now(timezone.utc))
# relationships
project = relationship("ProjectModel", back_populates="classification_jobs")
asset = relationship("AssetModel")
class GenerationTaskModel(Base):
__tablename__ = "generation_tasks"
id = Column(String(32), primary_key=True)
project_id = Column(String(32), nullable=False, index=True)
project_id = Column(String(32), ForeignKey("projects.id", ondelete="CASCADE"), nullable=False, index=True)
strategy_id = Column(String(32), nullable=False, default="")
asset_library_id = Column(String(32), nullable=False, index=True)
asset_library_id = Column(String(32), ForeignKey("asset_libraries.id", ondelete="CASCADE"), nullable=False, index=True)
voice_library_id = Column(String(32), nullable=False, default="")
edit_plan_id = Column(String(32), nullable=False, default="", index=True)
edit_plan_id = Column(String(32), ForeignKey("edit_plans.id", ondelete="SET NULL"), nullable=True, index=True)
editing_mode = Column(String(20), nullable=False, default="one_take", index=True) # 剪辑模式: one_take, pip, voice_over, voice_pip
status = Column(String(20), nullable=False, default="pending", index=True)
progress = Column(Float, nullable=False, default=0.0)
@@ -187,16 +244,23 @@ class GenerationTaskModel(Base):
error_message = Column(Text, nullable=False, default="")
started_at = Column(DateTime, nullable=True)
completed_at = Column(DateTime, nullable=True)
created_by_user_id = Column(String(32), nullable=False, default="")
created_by_user_id = Column(String(32), ForeignKey("users.id", ondelete="SET NULL"), nullable=True)
created_at = Column(DateTime, nullable=False, default=lambda: datetime.now(timezone.utc))
# relationships
project = relationship("ProjectModel", back_populates="generation_tasks")
asset_library = relationship("AssetLibraryModel")
edit_plan = relationship("EditPlanModel")
created_by = relationship("UserModel")
generated_videos = relationship("GeneratedVideoModel", back_populates="generation_task", cascade="all, delete-orphan")
class GeneratedVideoModel(Base):
__tablename__ = "generated_videos"
id = Column(String(32), primary_key=True)
project_id = Column(String(32), nullable=False, index=True)
generation_task_id = Column(String(32), nullable=False, index=True)
project_id = Column(String(32), ForeignKey("projects.id", ondelete="CASCADE"), nullable=False, index=True)
generation_task_id = Column(String(32), ForeignKey("generation_tasks.id", ondelete="CASCADE"), nullable=False, index=True)
name = Column(String(255), nullable=False)
# file_url: 完整可访问的 URL,用于客户端直接访问视频
file_url = Column(String(1000), nullable=False)
@@ -215,20 +279,25 @@ class GeneratedVideoModel(Base):
updated_at = Column(DateTime, nullable=True)
video_fingerprint = Column(Text, nullable=True)
is_duplicate = Column(Boolean, nullable=False, default=False)
duplicate_of = Column(String(32), nullable=True)
duplicate_of = Column(String(32), ForeignKey("generated_videos.id", ondelete="SET NULL"), nullable=True)
# relationships
project = relationship("ProjectModel", back_populates="generated_videos")
generation_task = relationship("GenerationTaskModel", back_populates="generated_videos")
duplicate_target = relationship("GeneratedVideoModel", remote_side=[id])
class TaskModel(Base):
__tablename__ = "tasks"
id = Column(String(32), primary_key=True)
project_id = Column(String(32), nullable=False, index=True)
project_id = Column(String(32), ForeignKey("projects.id", ondelete="CASCADE"), 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)
priority = Column(String(20), nullable=False, default="medium")
parent_task_id = Column(String(32), nullable=False, default="", index=True)
assignee_user_id = Column(String(32), nullable=False, default="")
parent_task_id = Column(String(32), ForeignKey("tasks.id", ondelete="SET NULL"), nullable=True, index=True)
assignee_user_id = Column(String(32), ForeignKey("users.id", ondelete="SET NULL"), nullable=True)
progress = Column(Float, nullable=False, default=0.0)
planned_start_date = Column(DateTime, nullable=True)
planned_end_date = Column(DateTime, nullable=True)
@@ -238,12 +307,18 @@ class TaskModel(Base):
created_at = Column(DateTime, nullable=False, default=lambda: datetime.now(timezone.utc))
updated_at = Column(DateTime, nullable=False, default=lambda: datetime.now(timezone.utc))
# relationships
project = relationship("ProjectModel", back_populates="tasks")
parent_task = relationship("TaskModel", remote_side=[id])
assignee = relationship("UserModel")
issues = relationship("TaskIssueModel", back_populates="task", cascade="all, delete-orphan")
class MilestoneModel(Base):
__tablename__ = "milestones"
id = Column(String(32), primary_key=True)
project_id = Column(String(32), nullable=False, index=True)
project_id = Column(String(32), ForeignKey("projects.id", ondelete="CASCADE"), nullable=False, index=True)
name = Column(String(200), nullable=False)
description = Column(Text, nullable=False, default="")
target_date = Column(DateTime, nullable=True)
@@ -252,17 +327,25 @@ class MilestoneModel(Base):
created_at = Column(DateTime, nullable=False, default=lambda: datetime.now(timezone.utc))
updated_at = Column(DateTime, nullable=False, default=lambda: datetime.now(timezone.utc))
# relationships
project = relationship("ProjectModel", back_populates="milestones")
class TaskIssueModel(Base):
__tablename__ = "task_issues"
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)
task_id = Column(String(32), ForeignKey("tasks.id", ondelete="CASCADE"), nullable=False, index=True)
project_id = Column(String(32), ForeignKey("projects.id", ondelete="CASCADE"), nullable=False, index=True)
title = Column(String(200), nullable=False)
description = Column(Text, nullable=False, default="")
resolved = Column(Boolean, nullable=False, default=False)
resolved_at = Column(DateTime, nullable=True)
created_by_user_id = Column(String(32), nullable=False, default="")
created_by_user_id = Column(String(32), ForeignKey("users.id", ondelete="SET NULL"), nullable=True)
created_at = Column(DateTime, nullable=False, default=lambda: datetime.now(timezone.utc))
updated_at = Column(DateTime, nullable=False, default=lambda: datetime.now(timezone.utc))
# relationships
task = relationship("TaskModel", back_populates="issues")
project = relationship("ProjectModel", back_populates="task_issues")
created_by = relationship("UserModel")
@@ -24,7 +24,6 @@ class SQLAlchemyTaskRepository(TaskRepository):
model = TaskModel(
id=task.id,
project_id=task.project_id,
workspace_id=task.workspace_id,
name=task.name,
description=task.description,
status=task.status.value,
@@ -90,7 +89,6 @@ class SQLAlchemyTaskRepository(TaskRepository):
return Task(
id=model.id,
project_id=model.project_id,
workspace_id=model.workspace_id,
name=model.name,
description=model.description,
status=TaskStatus(model.status),
@@ -118,7 +116,6 @@ class SQLAlchemyMilestoneRepository(MilestoneRepository):
model = MilestoneModel(
id=milestone.id,
project_id=milestone.project_id,
workspace_id=milestone.workspace_id,
name=milestone.name,
description=milestone.description,
target_date=milestone.target_date,
@@ -164,7 +161,6 @@ class SQLAlchemyMilestoneRepository(MilestoneRepository):
return Milestone(
id=model.id,
project_id=model.project_id,
workspace_id=model.workspace_id,
name=model.name,
description=model.description,
target_date=model.target_date,
@@ -186,7 +182,6 @@ class SQLAlchemyTaskIssueRepository(TaskIssueRepository):
id=issue.id,
task_id=issue.task_id,
project_id=issue.project_id,
workspace_id=issue.workspace_id,
title=issue.title,
description=issue.description,
resolved=issue.resolved,
@@ -236,7 +231,6 @@ class SQLAlchemyTaskIssueRepository(TaskIssueRepository):
id=model.id,
task_id=model.task_id,
project_id=model.project_id,
workspace_id=model.workspace_id,
title=model.title,
description=model.description,
resolved=model.resolved,
@@ -20,7 +20,6 @@ class SQLAlchemyProjectTitleRepository:
def create(
self,
*,
workspace_id: str,
project_id: str,
text: str,
category: str,
@@ -30,7 +29,6 @@ class SQLAlchemyProjectTitleRepository:
now = datetime.now(timezone.utc)
item = ProjectTitleModel(
id=uuid4().hex,
workspace_id=workspace_id,
project_id=project_id,
text=text.strip(),
category=category,
@@ -1,5 +0,0 @@
"""Stub adapter for workspace invitation repository - workspace concept removed."""
class SQLAlchemyWorkspaceInvitationRepository:
def __init__(self, session):
self.session = session
@@ -1,23 +0,0 @@
from __future__ import annotations
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from sqlalchemy.orm import Session
from packages.ports.workspace_member_repository import WorkspaceMemberRepository
class SQLAlchemyWorkspaceMemberRepository(WorkspaceMemberRepository):
def __init__(self, session=None):
self.session = session
def save(self, member) -> None:
raise NotImplementedError("Workspace feature removed")
def find_by_id(self, member_id: str):
return None
def find_by_workspace_and_user(self, workspace_id: str, user_id: str):
return None
def find_by_user(self, user_id: str):
return []
def find_by_workspace(self, workspace_id: str):
return []
def count_by_workspace(self, workspace_id: str) -> int:
return 0
def delete(self, member_id: str) -> bool:
return False
@@ -1,5 +0,0 @@
"""Stub adapter for workspace repository - workspace concept removed."""
class SQLAlchemyWorkspaceRepository:
def __init__(self, session):
self.session = session
@@ -38,7 +38,6 @@ class SQLiteTaskRepository:
priority=(TaskPriority(row["priority"]) if row["priority"] else TaskPriority.MEDIUM),
progress=0, # tracker.db 没有 progress 字段
project_id=row["phase"] or "xiaoxia-saas",
workspace_id="xiaoxia-workspace",
assignee_user_id=row["assigned_to"] or "",
created_at=(datetime.fromisoformat(row["created_at"]) if row["created_at"] else datetime.now()),
updated_at=datetime.now(),
@@ -73,7 +72,6 @@ class SQLiteTaskRepository:
priority=(TaskPriority(row["priority"]) if row["priority"] else TaskPriority.MEDIUM),
progress=0,
project_id=row["phase"] or "xiaoxia-saas",
workspace_id="xiaoxia-workspace",
assignee_user_id=row["assigned_to"] or "",
created_at=(datetime.fromisoformat(row["created_at"]) if row["created_at"] else datetime.now()),
updated_at=datetime.now(),
@@ -148,7 +146,6 @@ class SQLiteMilestoneRepository:
description=row["description"] or "",
target_date=row["end_date"] or "",
project_id=row["phase"] or "xiaoxia-saas",
workspace_id="xiaoxia-workspace",
created_at=(datetime.fromisoformat(row["start_date"]) if row["start_date"] else datetime.now()),
)
)
+3
View File
@@ -1,5 +1,6 @@
"""认证相关 Use Cases 和委托处理器"""
from packages.application.auth.password_hasher import password_hasher, password_validator
from packages.application.auth.login_use_case import (
LoginRequest,
LoginResponse,
@@ -58,4 +59,6 @@ __all__ = [
"PasswordHandler",
"configure_password_handler",
"get_password_handler",
"password_hasher",
"password_validator",
]
+5 -8
View File
@@ -8,14 +8,14 @@ JWT 处理器委托层
from packages.application.auth.jwt_handler import JWTHandler, get_jwt_handler
jwt_handler = JWTHandler(secret_key="<YOUR_SECRET_KEY>")
token = jwt_handler.create_access_token(user_id="user123", workspace_id="ws456", role="admin")
token = jwt_handler.create_access_token(user_id="user123", role="admin")
payload = jwt_handler.verify_access_token(token)
"""
from datetime import datetime, timedelta
from typing import Any, Dict, Optional
from packages.domain.auth.jwt_service import JWTConfig, JWTService, TokenType
from packages.application.auth.jwt_service import JWTConfig, JWTService, TokenType
class JWTHandler:
@@ -45,25 +45,22 @@ class JWTHandler:
def create_access_token(
self,
user_id: str,
workspace_id: str,
role: str,
role: str = "",
additional_claims: Optional[Dict[str, Any]] = None,
) -> str:
"""
创建 access_token
Args:
user_id: 用户 ID
workspace_id: 工作空间 ID
role: 用户角色
additional_claims: 额外的声明信息
Returns:
JWT Token 字符串
"""
return self._service.create_access_token(
user_id=user_id,
workspace_id=workspace_id,
role=role,
additional_claims=additional_claims,
)
+3 -6
View File
@@ -14,8 +14,8 @@ class JWTConfig:
self,
secret_key: str,
algorithm: str = "HS256",
access_token_expire_minutes: int = 30,
refresh_token_expire_days: int = 30,
access_token_expire_minutes: int = 15,
refresh_token_expire_days: int = 7,
):
"""
初始化 JWT 配置
@@ -75,8 +75,7 @@ class JWTService:
def create_access_token(
self,
user_id: str,
workspace_id: str,
role: str,
role: str = "",
additional_claims: Optional[Dict[str, Any]] = None,
) -> str:
"""
@@ -84,7 +83,6 @@ class JWTService:
Args:
user_id: 用户 ID
workspace_id: 工作空间 ID
role: 用户角色(admin/user/guest
additional_claims: 额外的声明信息
@@ -98,7 +96,6 @@ class JWTService:
payload = {
"sub": user_id, # subject (用户ID)
"workspace_id": workspace_id,
"role": role,
"type": TokenType.ACCESS,
"iat": now, # issued at
@@ -14,7 +14,7 @@
from typing import Optional, Tuple
from packages.domain.auth.password_hasher import PasswordHasher, PasswordValidator
from packages.application.auth.password_hasher import PasswordHasher, PasswordValidator
class PasswordHandler:
@@ -7,7 +7,7 @@ from datetime import datetime, timedelta, timezone
from typing import Optional
from packages.adapters.smtp import get_email_service
from packages.application.auth import password_hasher, password_validator
from packages.application.auth.password_hasher import password_hasher, password_validator
class RequestPasswordResetRequest:
@@ -8,7 +8,7 @@ from typing import Optional
from uuid import uuid4
from packages.adapters.smtp import get_email_service
from packages.application.auth import password_hasher, password_validator
from packages.application.auth.password_hasher import password_hasher, password_validator
from packages.domain.entities import User
@@ -13,7 +13,6 @@ class CreateTaskUseCase:
def execute(
self,
project_id: str,
workspace_id: str,
name: str,
description: str = "",
priority: TaskPriority = TaskPriority.MEDIUM,
@@ -22,7 +21,6 @@ class CreateTaskUseCase:
) -> Task:
task = Task.create(
project_id=project_id,
workspace_id=workspace_id,
name=name,
description=description,
priority=priority,
@@ -79,13 +77,11 @@ class CreateMilestoneUseCase:
def execute(
self,
project_id: str,
workspace_id: str,
name: str,
description: str = "",
) -> Milestone:
milestone = Milestone.create(
project_id=project_id,
workspace_id=workspace_id,
name=name,
description=description,
)
@@ -112,7 +108,6 @@ class CreateTaskIssueUseCase:
self,
task_id: str,
project_id: str,
workspace_id: str,
title: str,
description: str = "",
created_by_user_id: str = "",
@@ -120,7 +115,6 @@ class CreateTaskIssueUseCase:
issue = TaskIssue.create(
task_id=task_id,
project_id=project_id,
workspace_id=workspace_id,
title=title,
description=description,
created_by_user_id=created_by_user_id,
@@ -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)}"
+2 -2
View File
@@ -6,13 +6,13 @@ services such as Redis session storage and SMTP email delivery live under
"""
from packages.domain.auth.email_service import EmailConfig, EmailService
from packages.domain.auth.jwt_service import (
from packages.application.auth.jwt_service import (
JWTConfig,
JWTService,
TokenType,
jwt_service,
)
from packages.domain.auth.password_hasher import (
from packages.application.auth.password_hasher import (
PasswordHasher,
PasswordValidator,
password_hasher,
-5
View File
@@ -43,7 +43,6 @@ class AssetClassification(StrEnum):
@dataclass(slots=True)
class ClassificationJob:
id: str
workspace_id: str
project_id: str
asset_id: str
status: ClassificationJobStatus = ClassificationJobStatus.PENDING
@@ -56,19 +55,15 @@ class ClassificationJob:
@classmethod
def create(
cls,
workspace_id: str,
project_id: str,
asset_id: str,
) -> "ClassificationJob":
if not workspace_id.strip():
raise ValueError("workspace_id 不能为空")
if not project_id.strip():
raise ValueError("project_id 不能为空")
if not asset_id.strip():
raise ValueError("asset_id 不能为空")
return cls(
id=uuid4().hex,
workspace_id=workspace_id.strip(),
project_id=project_id.strip(),
asset_id=asset_id.strip(),
)
-1
View File
@@ -51,7 +51,6 @@ class Project:
owner_user_id: str
name: str
description: str = ""
workspace_id: str = "" # 兼容旧代码,待移除
shared_users: list[str] = field(default_factory=list) # 被共享的用户 ID 列表
created_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
-3
View File
@@ -18,7 +18,6 @@ class GeneratedVideo:
width: int
height: int
fps: float
workspace_id: str = ""
thumbnail_url: str | None = None
status: str = "completed"
review_status: str = "pending_review"
@@ -37,7 +36,6 @@ class GeneratedVideo:
name: str,
file_url: str,
*,
workspace_id: str = "",
file_size: int = 0,
duration: float = 0.0,
width: int = 0,
@@ -56,7 +54,6 @@ class GeneratedVideo:
raise ValueError("file_url cannot be empty")
return cls(
id=uuid4().hex,
workspace_id=workspace_id.strip(),
project_id=project_id.strip(),
generation_task_id=generation_task_id.strip(),
name=name.strip(),
-3
View File
@@ -19,7 +19,6 @@ class GenerationTask:
id: str
project_id: str
asset_library_id: str
workspace_id: str = ""
strategy_id: str = ""
voice_library_id: str = ""
edit_plan_id: str = ""
@@ -38,7 +37,6 @@ class GenerationTask:
project_id: str,
asset_library_id: str,
*,
workspace_id: str = "",
strategy_id: str = "",
voice_library_id: str = "",
edit_plan_id: str = "",
@@ -50,7 +48,6 @@ class GenerationTask:
raise ValueError("asset_library_id 不能为空")
return cls(
id=uuid4().hex,
workspace_id=workspace_id.strip(),
project_id=project_id.strip(),
asset_library_id=asset_library_id.strip(),
strategy_id=strategy_id.strip(),
+9 -33
View File
@@ -3,60 +3,36 @@ Permissions module - stub implementation.
Workspace concept has been removed. All permission checks pass by default.
"""
from typing import Optional
class WorkspaceMemberRole:
"""Stub enum - workspace concept removed."""
OWNER = "owner"
ADMIN = "admin"
MEMBER = "member"
VIEWER = "viewer"
class PermissionChecker:
"""Stub permission checker - all checks pass since workspace is removed."""
def __init__(self, workspace_member_repository=None):
self.workspace_member_repository = workspace_member_repository
def __init__(self, member_repository=None):
self.member_repository = member_repository
def check_workspace_access(self, workspace_id, user_id):
def check_access(self, project_id, user_id):
return True, "owner"
def check_is_owner(self, workspace_id, user_id):
def check_is_owner(self, project_id, user_id):
return True
def check_is_admin_or_owner(self, workspace_id, user_id):
def check_is_admin_or_owner(self, project_id, user_id):
return True
def check_can_manage_members(self, workspace_id, user_id):
def check_can_manage_members(self, project_id, user_id):
return True
def check_can_edit_workspace(self, workspace_id, user_id):
def check_can_edit_project(self, project_id, user_id):
return True
def check_can_create_project(self, workspace_id, user_id):
def check_can_delete_project(self, project_id, user_id):
return True
def check_can_edit_project(self, workspace_id, user_id):
return True
def check_can_delete_project(self, workspace_id, user_id):
return True
def check_can_view_workspace(self, workspace_id, user_id):
def check_can_view_project(self, project_id, user_id):
return True
class Permission:
WORKSPACE_VIEW = "workspace:view"
WORKSPACE_EDIT = "workspace:edit"
WORKSPACE_DELETE = "workspace:delete"
WORKSPACE_MANAGE_SUBSCRIPTION = "workspace:manage_subscription"
MEMBER_VIEW = "member:view"
MEMBER_INVITE = "member:invite"
MEMBER_REMOVE = "member:remove"
MEMBER_UPDATE_ROLE = "member:update_role"
PROJECT_VIEW = "project:view"
PROJECT_CREATE = "project:create"
PROJECT_EDIT = "project:edit"
-15
View File
@@ -33,7 +33,6 @@ class Task:
id: str
project_id: str
workspace_id: str
name: str
description: str = ""
status: TaskStatus = TaskStatus.PENDING
@@ -53,7 +52,6 @@ class Task:
def create(
cls,
project_id: str,
workspace_id: str,
name: str,
description: str = "",
priority: TaskPriority = TaskPriority.MEDIUM,
@@ -68,13 +66,10 @@ class Task:
raise ValueError("任务名称不能为空")
if not project_id.strip():
raise ValueError("project_id 不能为空")
if not workspace_id.strip():
raise ValueError("workspace_id 不能为空")
return cls(
id=uuid4().hex,
project_id=project_id.strip(),
workspace_id=workspace_id.strip(),
name=clean_name,
description=description.strip(),
priority=priority,
@@ -134,7 +129,6 @@ class Milestone:
id: str
project_id: str
workspace_id: str
name: str
description: str = ""
target_date: datetime | None = None
@@ -147,7 +141,6 @@ class Milestone:
def create(
cls,
project_id: str,
workspace_id: str,
name: str,
description: str = "",
target_date: datetime | None = None,
@@ -158,13 +151,10 @@ class Milestone:
raise ValueError("里程碑名称不能为空")
if not project_id.strip():
raise ValueError("project_id 不能为空")
if not workspace_id.strip():
raise ValueError("workspace_id 不能为空")
return cls(
id=uuid4().hex,
project_id=project_id.strip(),
workspace_id=workspace_id.strip(),
name=clean_name,
description=description.strip(),
target_date=target_date,
@@ -192,7 +182,6 @@ class TaskIssue:
id: str
task_id: str
project_id: str
workspace_id: str
title: str
description: str = ""
resolved: bool = False
@@ -206,7 +195,6 @@ class TaskIssue:
cls,
task_id: str,
project_id: str,
workspace_id: str,
title: str,
description: str = "",
created_by_user_id: str = "",
@@ -219,14 +207,11 @@ class TaskIssue:
raise ValueError("task_id 不能为空")
if not project_id.strip():
raise ValueError("project_id 不能为空")
if not workspace_id.strip():
raise ValueError("workspace_id 不能为空")
return cls(
id=uuid4().hex,
task_id=task_id.strip(),
project_id=project_id.strip(),
workspace_id=workspace_id.strip(),
title=clean_title,
description=description.strip(),
created_by_user_id=created_by_user_id.strip(),
+5 -9
View File
@@ -3,31 +3,27 @@ Quota checker - stub implementation.
Workspace concept removed. All quota checks pass by default.
"""
from typing import Optional
class QuotaChecker:
"""Stub quota checker - all checks pass since workspace is removed."""
def __init__(self, workspace_repository=None, project_repository=None):
self.workspace_repository = workspace_repository
def __init__(self, project_repository=None):
self.project_repository = project_repository
def check_can_create_project(self, workspace_id=None):
def check_can_create_project(self, user_id=None):
return True, None
def check_storage_available(self, workspace_id=None, additional_gb=0):
def check_storage_available(self, user_id=None, additional_gb=0):
return True, None
def get_quota_status(self, workspace_id=None):
def get_quota_status(self, user_id=None):
return {
"workspace_id": workspace_id or "",
"subscription_plan": "unlimited",
"projects": {"used": 0, "limit": 999999, "unlimited": True, "usage_percent": 0},
"storage": {"used_gb": 0, "limit_gb": 999999, "remaining_gb": 999999, "usage_percent": 0},
}
def update_storage_usage(self, workspace_id=None, delta_gb=0):
def update_storage_usage(self, user_id=None, delta_gb=0):
return True, None
+1 -1
View File
@@ -5,5 +5,5 @@ from typing import Protocol, Any
class ProjectTitleRepository(Protocol):
def list_by_project(self, project_id: str, active_only: bool = False) -> list[Any]: ...
def get(self, title_id: str) -> Any | None: ...
def create(self, *, workspace_id: str, project_id: str, text: str, category: str, created_by_user_id: str, favorite: bool = False) -> Any: ...
def create(self, *, project_id: str, text: str, category: str, created_by_user_id: str, favorite: bool = False) -> Any: ...
def update(self, item: Any) -> Any: ...
@@ -1,6 +0,0 @@
"""Stub port for workspace invitation repository - workspace concept removed."""
from __future__ import annotations
from typing import Protocol
class WorkspaceInvitationRepository(Protocol):
pass
@@ -1,6 +0,0 @@
"""Stub port for workspace member repository - workspace concept removed."""
from __future__ import annotations
from typing import Protocol
class WorkspaceMemberRepository(Protocol):
pass
-6
View File
@@ -1,6 +0,0 @@
"""Stub port for workspace repository - workspace concept removed."""
from __future__ import annotations
from typing import Protocol
class WorkspaceRepository(Protocol):
pass
+3
View File
@@ -50,3 +50,6 @@ numpy>=1.24.0
scipy>=1.10.0
opencv-python-headless>=4.8.0
# Prometheus monitoring
prometheus-client==0.21.1
-3
View File
@@ -26,7 +26,6 @@ def create_milestone(name, target_date, description=""):
"name": name,
"target_date": target_date,
"project_id": PROJECT_ID,
"workspace_id": WORKSPACE_ID,
"description": description,
}
resp = requests.post(f"{API_BASE}/project-management/milestones", json=payload)
@@ -44,7 +43,6 @@ def create_task(name, description, priority="medium"):
"name": name,
"description": description,
"project_id": PROJECT_ID,
"workspace_id": WORKSPACE_ID,
"priority": priority,
}
resp = requests.post(f"{API_BASE}/project-management/tasks", json=payload)
@@ -102,7 +100,6 @@ def main():
create_task("WorkspaceMembership 实体", "成员关系、角色存储", "high")
create_task("WorkspaceRole 枚举", "Owner/Admin/Member/Viewer 权限定义", "high")
create_task("权限检查中间件", "@require_permission 装饰器", "urgent")
create_task("数据隔离过滤器", "所有查询自动加 workspace_id", "urgent")
# Day 3-4: 成员管理
create_task("邀请成员 API", "发送邀请邮件 + 生成邀请令牌", "high")
-2
View File
@@ -29,7 +29,6 @@ def create_milestone(name, target_date, description=""):
"name": name,
"target_date": target_date,
"project_id": PROJECT_ID,
"workspace_id": WORKSPACE_ID,
"description": description,
}
resp = requests.post(f"{API_BASE}/project-management/milestones", json=payload)
@@ -47,7 +46,6 @@ def create_task(name, description, priority="medium", status="pending", progress
"name": name,
"description": description,
"project_id": PROJECT_ID,
"workspace_id": WORKSPACE_ID,
"priority": priority,
}
resp = requests.post(f"{API_BASE}/project-management/tasks", json=payload)
-6
View File
@@ -48,12 +48,10 @@ def main() -> None:
"owner_workspace",
owner.post(f"{BASE_URL}/workspaces", json={"name": "Boundary Workspace"}, headers=owner_headers, timeout=30),
)
workspace_id = workspace.get("id") or workspace["workspace_id"]
project = _json_or_raise(
"owner_project",
owner.post(
f"{BASE_URL}/projects",
json={"workspace_id": workspace_id, "name": "Boundary Project", "description": "smoke"},
headers=owner_headers,
timeout=30,
),
@@ -63,7 +61,6 @@ def main() -> None:
"owner_library",
owner.post(
f"{BASE_URL}/asset-libraries",
json={"workspace_id": workspace_id, "project_id": project_id, "name": "Boundary Library", "kind": "video"},
headers=owner_headers,
timeout=30,
),
@@ -74,7 +71,6 @@ def main() -> None:
owner.post(
f"{BASE_URL}/upload",
files={"file": ("boundary.mp4", io.BytesIO(b"boundary-owner"), "video/mp4")},
data={"workspace_id": workspace_id, "project_id": project_id, "library_id": library_id},
headers=owner_headers,
timeout=60,
),
@@ -93,7 +89,6 @@ def main() -> None:
"owner_generation_create",
owner.post(
f"{BASE_URL}/generation/tasks",
json={"workspace_id": workspace_id, "project_id": project_id, "asset_library_id": library_id},
headers=owner_headers,
timeout=30,
),
@@ -184,7 +179,6 @@ def main() -> None:
upload_response = intruder.post(
f"{BASE_URL}/upload",
files={"file": ("boundary.txt", io.BytesIO(b"boundary"), "text/plain")},
data={"workspace_id": workspace_id, "project_id": project_id, "library_id": library_id},
headers=intruder_headers,
timeout=60,
)
-4
View File
@@ -51,13 +51,11 @@ def main() -> None:
"workspace",
session.post(f"{BASE_URL}/workspaces", json={"name": "Upload Smoke Workspace"}, headers=headers, timeout=30),
)
workspace_id = workspace.get("id") or workspace["workspace_id"]
project = _json_or_raise(
"project",
session.post(
f"{BASE_URL}/projects",
json={"workspace_id": workspace_id, "name": "Upload Smoke Project", "description": "smoke"},
headers=headers,
timeout=30,
),
@@ -69,7 +67,6 @@ def main() -> None:
session.post(
f"{BASE_URL}/asset-libraries",
json={
"workspace_id": workspace_id,
"project_id": project_id,
"name": "Smoke Video Library",
"kind": "video",
@@ -85,7 +82,6 @@ def main() -> None:
session.post(
f"{BASE_URL}/upload",
files={"file": ("smoke.txt", io.BytesIO(b"xiaoxia upload smoke"), "text/plain")},
data={"workspace_id": workspace_id, "project_id": project_id, "library_id": library_id},
headers=headers,
timeout=60,
),
-5
View File
@@ -6,7 +6,6 @@ from packages.domain import Asset
def test_add_tag_to_asset():
"""测试添加标签到 Asset。"""
asset = Asset.create(
workspace_id="ws-1",
project_id="proj-1",
library_id="lib-1",
name="video.mp4",
@@ -25,7 +24,6 @@ def test_add_tag_to_asset():
def test_add_duplicate_tag_should_ignore():
"""测试添加重复标签应自动去重。"""
asset = Asset.create(
workspace_id="ws-1",
project_id="proj-1",
library_id="lib-1",
name="video.mp4",
@@ -43,7 +41,6 @@ def test_add_duplicate_tag_should_ignore():
def test_add_empty_tag_should_fail():
"""测试添加空标签应失败。"""
asset = Asset.create(
workspace_id="ws-1",
project_id="proj-1",
library_id="lib-1",
name="video.mp4",
@@ -61,7 +58,6 @@ def test_add_empty_tag_should_fail():
def test_remove_tag_from_asset():
"""测试从 Asset 删除标签。"""
asset = Asset.create(
workspace_id="ws-1",
project_id="proj-1",
library_id="lib-1",
name="video.mp4",
@@ -82,7 +78,6 @@ def test_remove_tag_from_asset():
def test_remove_nonexistent_tag_should_be_idempotent():
"""测试删除不存在的标签应幂等(不报错)。"""
asset = Asset.create(
workspace_id="ws-1",
project_id="proj-1",
library_id="lib-1",
name="video.mp4",
@@ -56,7 +56,6 @@ def test_classification_pipeline():
use_case = SubmitClassificationJobUseCase(job_repo)
job = use_case.execute(
SubmitClassificationJobCommand(
workspace_id="ws-1",
project_id="proj-1",
asset_id="asset-123",
)
@@ -60,11 +60,9 @@ def simulate_generate_video(
task_repo.update(task)
file_url = (
f"http://localhost:9000/xiaoxia-assets/workspaces/{task.workspace_id}"
f"/projects/{task.project_id}/generated/{task.id}/{task.id}.mp4"
)
video = GeneratedVideo.create(
workspace_id=task.workspace_id,
project_id=task.project_id,
generation_task_id=task.id,
name=f"{task.id}.mp4",
@@ -96,7 +94,6 @@ def test_create_generation_task_smoke():
use_case = CreateGenerationTaskUseCase(repo)
task = use_case.execute(
CreateGenerationTaskCommand(
workspace_id="ws-1",
project_id="proj-1",
asset_library_id="lib-1",
strategy_id="str-1",
@@ -104,7 +101,6 @@ def test_create_generation_task_smoke():
created_by_user_id="user-1",
)
)
assert task.workspace_id == "ws-1"
assert task.project_id == "proj-1"
assert task.asset_library_id == "lib-1"
assert task.status == GenerationTaskStatus.PENDING
@@ -117,7 +113,6 @@ def test_generation_pipeline_smoke():
use_case = CreateGenerationTaskUseCase(task_repo)
task = use_case.execute(
CreateGenerationTaskCommand(
workspace_id="ws-1",
project_id="proj-1",
asset_library_id="lib-1",
strategy_id="str-1",
@@ -144,7 +139,6 @@ def test_generation_pipeline_smoke():
def test_get_generated_video_download_url():
video_repo = DummyGeneratedVideoRepository()
video = GeneratedVideo.create(
workspace_id="ws-1",
project_id="proj-1",
generation_task_id="task-1",
name="task-1.mp4",
@@ -165,7 +159,6 @@ def test_get_generated_video_download_url():
def test_generated_video_download_source_url_is_stable():
video_repo = DummyGeneratedVideoRepository()
video = GeneratedVideo.create(
workspace_id="ws-1",
project_id="proj-1",
generation_task_id="task-2",
name="task-2.mp4",
@@ -38,7 +38,6 @@ def simulate_ingest_asset(
# Create Asset
asset = Asset.create(
workspace_id=job.workspace_id,
project_id=job.project_id,
library_id=job.library_id,
name=filename,
@@ -80,7 +79,6 @@ def test_ingest_asset_pipeline():
use_case = SubmitIngestJobUseCase(job_repo)
job = use_case.execute(
SubmitIngestJobCommand(
workspace_id="ws-1",
project_id="proj-1",
library_id="lib-1",
storage_key="uploads/test-video.mp4",
@@ -27,7 +27,6 @@ def test_create_task():
task = use_case.execute(
project_id="proj_1",
workspace_id="ws_1",
name="开发登录功能",
description="实现用户登录功能",
priority=TaskPriority.HIGH,
@@ -48,12 +47,10 @@ def test_list_tasks():
# 创建两个任务
create_use_case.execute(
project_id="proj_1",
workspace_id="ws_1",
name="任务1",
)
create_use_case.execute(
project_id="proj_1",
workspace_id="ws_1",
name="任务2",
)
@@ -75,7 +72,6 @@ def test_update_task_status():
# 创建任务
task = create_use_case.execute(
project_id="proj_1",
workspace_id="ws_1",
name="测试任务",
)
@@ -95,7 +91,6 @@ def test_update_task_progress():
# 创建任务
task = create_use_case.execute(
project_id="proj_1",
workspace_id="ws_1",
name="测试任务",
)
@@ -120,7 +115,6 @@ def test_create_milestone():
milestone = use_case.execute(
project_id="proj_1",
workspace_id="ws_1",
name="V1.0 发布",
description="第一个正式版本",
)
@@ -141,7 +135,6 @@ def test_create_and_resolve_issue():
issue = create_use_case.execute(
task_id="task_1",
project_id="proj_1",
workspace_id="ws_1",
title="接口报错",
description="调用登录接口返回 500",
)
@@ -170,21 +163,18 @@ def test_task_hierarchy():
# 创建父任务
parent_task = create_use_case.execute(
project_id="proj_1",
workspace_id="ws_1",
name="开发用户模块",
)
# 创建子任务
child_task_1 = create_use_case.execute(
project_id="proj_1",
workspace_id="ws_1",
name="登录功能",
parent_task_id=parent_task.id,
)
child_task_2 = create_use_case.execute(
project_id="proj_1",
workspace_id="ws_1",
name="注册功能",
parent_task_id=parent_task.id,
)
@@ -208,7 +198,6 @@ def test_get_task_detail():
# 创建任务
task = create_use_case.execute(
project_id="proj_1",
workspace_id="ws_1",
name="测试任务",
description="这是一个测试任务",
)
@@ -239,7 +228,6 @@ def test_update_task():
# 创建任务
task = create_use_case.execute(
project_id="proj_1",
workspace_id="ws_1",
name="原始任务",
description="原始描述",
priority="low",
-8
View File
@@ -26,9 +26,7 @@ def test_create_and_list_projects():
create_use_case = CreateProjectUseCase(repository)
list_use_case = ListProjectsUseCase(repository)
project = create_use_case.execute(CreateProjectCommand(workspace_id="ws-1", name=" Demo Project "))
assert project.name == "Demo Project"
assert project.workspace_id == "ws-1"
items = list_use_case.execute("ws-1")
assert len(items) == 1
@@ -40,12 +38,10 @@ def test_get_project_by_id_restores_workspace_context():
create_use_case = CreateProjectUseCase(repository)
get_use_case = GetProjectUseCase(repository)
project = create_use_case.execute(CreateProjectCommand(workspace_id="ws-1", name="Demo Project"))
retrieved = get_use_case.execute(project.id)
assert retrieved is not None
assert retrieved.id == project.id
assert retrieved.workspace_id == "ws-1"
def test_create_and_list_asset_libraries():
@@ -55,7 +51,6 @@ def test_create_and_list_asset_libraries():
library = create_use_case.execute(
CreateAssetLibraryCommand(
workspace_id="ws-1",
project_id="proj-1",
name=" 素材库 A ",
kind=AssetLibraryKind.VIDEO,
@@ -76,7 +71,6 @@ def test_create_and_list_assets():
asset = create_use_case.execute(
CreateAssetCommand(
workspace_id="ws-1",
project_id="proj-1",
library_id="lib-1",
name=" demo.mp4 ",
@@ -100,13 +94,11 @@ def test_submit_ingest_job():
job = use_case.execute(
SubmitIngestJobCommand(
workspace_id="ws-1",
project_id="proj-1",
library_id="lib-1",
storage_key="uploads/raw-video.mp4",
)
)
assert job.workspace_id == "ws-1"
assert job.storage_key == "uploads/raw-video.mp4"
assert job.status == IngestJobStatus.PENDING
assert job.result_asset_id == ""
@@ -24,14 +24,12 @@ def test_sqlalchemy_project_repository():
# Create project
project = use_case.execute(
CreateProjectCommand(
workspace_id="ws-1",
name="Test Project",
description="Test description",
)
)
assert project.name == "Test Project"
assert project.workspace_id == "ws-1"
# List projects
projects = repository.list_by_workspace("ws-1")
@@ -7,7 +7,6 @@ from packages.domain import IngestJobStatus
def simulate_upload_and_ingest(
workspace_id: str,
project_id: str,
library_id: str,
filename: str,
@@ -26,7 +25,6 @@ def simulate_upload_and_ingest(
use_case = SubmitIngestJobUseCase(job_repo)
job = use_case.execute(
SubmitIngestJobCommand(
workspace_id=workspace_id,
project_id=project_id,
library_id=library_id,
storage_key=storage_key,
@@ -50,7 +48,6 @@ def test_upload_to_asset_full_pipeline():
# Simulate upload
result = simulate_upload_and_ingest(
workspace_id="ws-1",
project_id="proj-1",
library_id="lib-1",
filename="demo-video.mp4",
@@ -87,7 +87,6 @@ class TestAcceptInvitationUseCase:
def valid_invitation(self):
return WorkspaceInvitation(
id="invitation-1",
workspace_id="workspace-123",
inviter_user_id="inviter-id",
invitee_email="invitee@test.com",
role="member",
@@ -122,7 +121,6 @@ class TestAcceptInvitationUseCase:
assert error is None
assert response is not None
assert response.workspace_id == "workspace-123"
assert response.workspace_name == "Test Workspace"
assert response.role == "member"
@@ -159,7 +157,6 @@ class TestAcceptInvitationUseCase:
assert error is None
assert response is not None
assert response.workspace_id == "workspace-123"
def test_accept_invitation_invalid_token(self, use_case, mock_invitation_repo):
"""测试无效令牌"""
@@ -262,7 +259,6 @@ class TestAcceptInvitationUseCase:
existing_member = WorkspaceMember(
id="member-1",
workspace_id="workspace-123",
user_id="user-123",
role="admin",
)
@@ -306,7 +302,6 @@ class TestDeclineInvitationUseCase:
def valid_invitation(self):
return WorkspaceInvitation(
id="invitation-1",
workspace_id="workspace-123",
inviter_user_id="inviter-id",
invitee_email="invitee@test.com",
role="member",
+1 -1
View File
@@ -62,7 +62,7 @@ def test_canonical_auth_route_has_no_auth_simple_runtime_path():
def test_runtime_code_uses_oss_storage_naming_not_minio():
allowed = {Path("apps/api/app/core/storage.py")}
allowed: set[Path] = set()
offenders: list[str] = []
for root in [Path("apps"), Path("packages"), Path("tests")]:
for path in root.rglob("*.py"):
-1
View File
@@ -10,7 +10,6 @@ from packages.domain import Asset, AssetStatus, ClassificationStatus
def _asset(name: str, mime_type: str, *, status=AssetStatus.READY, duration=None, quality_score=None):
return Asset.create(
workspace_id="workspace-1",
project_id="project-1",
library_id="library-1",
name=name,
@@ -8,7 +8,6 @@ def test_asset_library_repository_get_returns_library():
session.add(
AssetLibraryModel(
id="lib-1",
workspace_id="workspace-1",
project_id="project-1",
name="素材库",
kind="video",
@@ -22,7 +21,6 @@ def test_asset_library_repository_get_returns_library():
assert library is not None
assert library.id == "lib-1"
assert library.workspace_id == "workspace-1"
assert library.project_id == "project-1"
assert library.kind.value == "video"
-1
View File
@@ -10,7 +10,6 @@ from packages.domain import Asset, AssetStatus
def test_apply_asset_review_status_preserves_existing_metadata():
asset = Asset.create(
workspace_id="workspace-1",
project_id="project-1",
library_id="library-1",
name="video.mp4",
+1 -1
View File
@@ -28,7 +28,7 @@ from app.config import settings
from packages.adapters.smtp import NoopEmailService
from packages.application.auth.login_use_case import LoginRequest, LoginUseCase
from packages.application.auth.register_user_use_case import RegisterUserRequest, RegisterUserUseCase
from packages.application.auth import password_hasher
from packages.application.auth.password_hasher import password_hasher
from packages.domain.entities import User
+1 -5
View File
@@ -15,7 +15,7 @@ if str(API_ROOT) not in sys.path:
from app.config import settings
from app.middleware.auth import get_current_user, get_current_user_optional, require_workspace_access
from packages.application.auth import password_hasher
from packages.application.auth.password_hasher import password_hasher
from packages.domain.entities import User
@@ -83,9 +83,7 @@ def test_legacy_middleware_optional_user_returns_user_with_valid_credentials():
def test_workspace_dependency_allows_member_access():
repo = _WorkspaceMemberRepositoryStub(role="member")
workspace_id, role = require_workspace_access("workspace-1", _repo_with_user().find_by_id("user-1"), repo)
assert workspace_id == "workspace-1"
assert role == "member"
@@ -102,12 +100,10 @@ class _WorkspaceMemberRepositoryStub:
def __init__(self, role=None):
self.role = role
def find_by_workspace_and_user(self, workspace_id, user_id):
if self.role is None:
return None
from packages.domain.entities import WorkspaceMember
return WorkspaceMember(id="member-1", workspace_id=workspace_id, user_id=user_id, role=self.role)
async def _authenticated_user():
@@ -21,7 +21,6 @@ def _repository():
def test_generated_video_repository_preserves_management_fields():
repository = _repository()
video = GeneratedVideo.create(
workspace_id="workspace-1",
project_id="project-1",
generation_task_id="task-1",
name="output.mp4",
@@ -10,7 +10,6 @@ from packages.domain import Asset, AssetStatus
def _asset() -> Asset:
return Asset.create(
workspace_id="workspace-1",
project_id="project-1",
library_id="library-1",
name="video.mp4",
-1
View File
@@ -13,7 +13,6 @@ from packages.domain import Asset, AssetStatus
def _asset(name: str, mime_type: str, status: AssetStatus) -> Asset:
return Asset.create(
workspace_id="workspace-1",
project_id="project-1",
library_id="library-1",
name=name,
-11
View File
@@ -89,7 +89,6 @@ class TestInviteMemberUseCase:
def owner_member(self):
return WorkspaceMember(
id="member-1",
workspace_id="workspace-123",
user_id="inviter-id",
role=WorkspaceMemberRole.OWNER,
)
@@ -98,7 +97,6 @@ class TestInviteMemberUseCase:
def admin_member(self):
return WorkspaceMember(
id="member-2",
workspace_id="workspace-123",
user_id="inviter-id",
role=WorkspaceMemberRole.ADMIN,
)
@@ -120,7 +118,6 @@ class TestInviteMemberUseCase:
mock_user_repo.find_by_id.return_value = test_inviter
request = InviteMemberRequest(
workspace_id="workspace-123",
inviter_user_id="inviter-id",
invitee_email="newuser@test.com",
role="member",
@@ -160,7 +157,6 @@ class TestInviteMemberUseCase:
mock_user_repo.find_by_id.return_value = test_inviter
request = InviteMemberRequest(
workspace_id="workspace-123",
inviter_user_id="inviter-id",
invitee_email="newuser@test.com",
role="viewer",
@@ -176,7 +172,6 @@ class TestInviteMemberUseCase:
mock_workspace_repo.find_by_id.return_value = None
request = InviteMemberRequest(
workspace_id="nonexistent",
inviter_user_id="inviter-id",
invitee_email="newuser@test.com",
role="member",
@@ -199,7 +194,6 @@ class TestInviteMemberUseCase:
mock_member_repo.find_by_workspace_and_user.return_value = None
request = InviteMemberRequest(
workspace_id="workspace-123",
inviter_user_id="nonmember-id",
invitee_email="newuser@test.com",
role="member",
@@ -222,14 +216,12 @@ class TestInviteMemberUseCase:
regular_member = WorkspaceMember(
id="member-3",
workspace_id="workspace-123",
user_id="inviter-id",
role=WorkspaceMemberRole.MEMBER,
)
mock_member_repo.find_by_workspace_and_user.return_value = regular_member
request = InviteMemberRequest(
workspace_id="workspace-123",
inviter_user_id="inviter-id",
invitee_email="newuser@test.com",
role="member",
@@ -255,7 +247,6 @@ class TestInviteMemberUseCase:
owner_member, # 第一次调用:inviter 是 owner
WorkspaceMember( # 第二次调用:invitee 已经是成员
id="member-4",
workspace_id="workspace-123",
user_id="existing-user-id",
role=WorkspaceMemberRole.MEMBER,
),
@@ -270,7 +261,6 @@ class TestInviteMemberUseCase:
mock_user_repo.find_by_email.return_value = existing_user
request = InviteMemberRequest(
workspace_id="workspace-123",
inviter_user_id="inviter-id",
invitee_email="existing@test.com",
role="member",
@@ -284,7 +274,6 @@ class TestInviteMemberUseCase:
def test_invite_member_invalid_role(self, use_case):
"""测试无效角色(不能邀请为 owner"""
request = InviteMemberRequest(
workspace_id="workspace-123",
inviter_user_id="inviter-id",
invitee_email="newuser@test.com",
role="owner",
+1 -10
View File
@@ -7,7 +7,7 @@ from datetime import datetime, timedelta
import pytest
from jwt.exceptions import ExpiredSignatureError, InvalidTokenError
from packages.domain.auth.jwt_service import JWTConfig, JWTService, TokenType
from packages.application.auth.jwt_service import JWTConfig, JWTService, TokenType
class TestJWTService:
@@ -22,7 +22,6 @@ class TestJWTService:
def test_create_access_token(self, jwt_service):
"""测试创建 access_token"""
token = jwt_service.create_access_token(user_id="user-123", workspace_id="workspace-456", role="admin")
assert isinstance(token, str)
assert len(token) > 0
@@ -30,7 +29,6 @@ class TestJWTService:
# 验证 Token 内容
payload = jwt_service.verify_access_token(token)
assert payload["sub"] == "user-123"
assert payload["workspace_id"] == "workspace-456"
assert payload["role"] == "admin"
assert payload["type"] == TokenType.ACCESS
@@ -49,11 +47,9 @@ class TestJWTService:
def test_verify_valid_access_token(self, jwt_service):
"""测试验证有效的 access_token"""
token = jwt_service.create_access_token(user_id="user-123", workspace_id="workspace-456", role="member")
payload = jwt_service.verify_access_token(token)
assert payload["sub"] == "user-123"
assert payload["workspace_id"] == "workspace-456"
assert payload["role"] == "member"
def test_verify_expired_token(self, jwt_service):
@@ -64,7 +60,6 @@ class TestJWTService:
config.ACCESS_TOKEN_EXPIRE_MINUTES = -1 # 负数,立即过期
expired_service = JWTService(config)
token = expired_service.create_access_token(user_id="user-123", workspace_id="workspace-456", role="admin")
# 验证应该抛出过期异常
with pytest.raises(ExpiredSignatureError):
@@ -87,14 +82,12 @@ class TestJWTService:
jwt_service.verify_access_token(refresh_token)
# 反过来也一样
access_token = jwt_service.create_access_token(user_id="user-123", workspace_id="workspace-456", role="admin")
with pytest.raises(ValueError, match="Token type must be 'refresh'"):
jwt_service.verify_refresh_token(access_token)
def test_verify_tampered_token(self, jwt_service):
"""测试验证被篡改的 Token"""
token = jwt_service.create_access_token(user_id="user-123", workspace_id="workspace-456", role="admin")
# 篡改 Token(修改最后几个字符)
tampered_token = token[:-5] + "XXXXX"
@@ -106,7 +99,6 @@ class TestJWTService:
"""测试额外的声明"""
token = jwt_service.create_access_token(
user_id="user-123",
workspace_id="workspace-456",
role="admin",
additional_claims={
"email": "user@example.com",
@@ -120,7 +112,6 @@ class TestJWTService:
def test_decode_unsafe(self, jwt_service):
"""测试不安全解码(不验证签名)"""
token = jwt_service.create_access_token(user_id="user-123", workspace_id="workspace-456", role="admin")
# 不验证签名地解码
payload = jwt_service.decode_token_unsafe(token)
-12
View File
@@ -71,7 +71,6 @@ class TestListMembersUseCase:
# 请求者是 Admin
requester_member = WorkspaceMember(
id="member-1",
workspace_id="workspace-123",
user_id="admin-id",
role=WorkspaceMemberRole.ADMIN,
)
@@ -80,7 +79,6 @@ class TestListMembersUseCase:
# 3 个成员
member1 = WorkspaceMember(
id="member-1",
workspace_id="workspace-123",
user_id="owner-id",
role=WorkspaceMemberRole.OWNER,
invited_by=None,
@@ -88,7 +86,6 @@ class TestListMembersUseCase:
member2 = WorkspaceMember(
id="member-2",
workspace_id="workspace-123",
user_id="admin-id",
role=WorkspaceMemberRole.ADMIN,
invited_by="owner-id",
@@ -96,7 +93,6 @@ class TestListMembersUseCase:
member3 = WorkspaceMember(
id="member-3",
workspace_id="workspace-123",
user_id="user-id",
role=WorkspaceMemberRole.MEMBER,
invited_by="admin-id",
@@ -129,7 +125,6 @@ class TestListMembersUseCase:
mock_user_repo.find_by_id.side_effect = [user1, user2, user3]
request = ListMembersRequest(
workspace_id="workspace-123",
requester_user_id="admin-id",
)
@@ -168,7 +163,6 @@ class TestListMembersUseCase:
mock_workspace_repo.find_by_id.return_value = None
request = ListMembersRequest(
workspace_id="nonexistent",
requester_user_id="user-id",
)
@@ -189,7 +183,6 @@ class TestListMembersUseCase:
mock_member_repo.find_by_workspace_and_user.return_value = None
request = ListMembersRequest(
workspace_id="workspace-123",
requester_user_id="outsider-id",
)
@@ -210,7 +203,6 @@ class TestListMembersUseCase:
requester_member = WorkspaceMember(
id="member-1",
workspace_id="workspace-123",
user_id="user-id",
role=WorkspaceMemberRole.MEMBER,
)
@@ -218,7 +210,6 @@ class TestListMembersUseCase:
mock_member_repo.find_by_workspace.return_value = []
request = ListMembersRequest(
workspace_id="workspace-123",
requester_user_id="user-id",
)
@@ -228,10 +219,8 @@ class TestListMembersUseCase:
assert response is not None
assert len(response.members) == 0
def test_list_members_missing_workspace_id(self, use_case):
"""测试缺少工作空间 ID"""
request = ListMembersRequest(
workspace_id="",
requester_user_id="user-id",
)
@@ -243,7 +232,6 @@ class TestListMembersUseCase:
def test_list_members_missing_requester_id(self, use_case):
"""测试缺少请求者 ID"""
request = ListMembersRequest(
workspace_id="workspace-123",
requester_user_id="",
)
-265
View File
@@ -1,265 +0,0 @@
"""
获取工作空间列表和详情 Use Case 测试
"""
from datetime import datetime, timezone
from unittest.mock import Mock
import pytest
from packages.application.workspace.list_workspaces_use_case import (
GetWorkspaceDetailRequest,
GetWorkspaceDetailUseCase,
ListWorkspacesRequest,
ListWorkspacesUseCase,
)
from packages.domain.entities import Workspace, WorkspaceMember, WorkspaceMemberRole
class TestListWorkspacesUseCase:
"""获取工作空间列表测试"""
@pytest.fixture
def mock_workspace_repo(self):
repo = Mock()
repo.find_by_id = Mock(return_value=None)
return repo
@pytest.fixture
def mock_member_repo(self):
repo = Mock()
repo.find_by_user = Mock(return_value=[])
repo.count_by_workspace = Mock(return_value=0)
return repo
@pytest.fixture
def use_case(self, mock_workspace_repo, mock_member_repo):
return ListWorkspacesUseCase(
workspace_repository=mock_workspace_repo,
workspace_member_repository=mock_member_repo,
)
def test_list_workspaces_success(
self,
use_case,
mock_workspace_repo,
mock_member_repo,
):
"""测试获取工作空间列表成功"""
# 用户是 2 个工作空间的成员
membership1 = WorkspaceMember(
id="member-1",
workspace_id="workspace-1",
user_id="user-123",
role=WorkspaceMemberRole.OWNER,
)
membership2 = WorkspaceMember(
id="member-2",
workspace_id="workspace-2",
user_id="user-123",
role=WorkspaceMemberRole.MEMBER,
)
mock_member_repo.find_by_user.return_value = [membership1, membership2]
workspace1 = Workspace(
id="workspace-1",
name="My Workspace",
owner_user_id="user-123",
subscription_plan="free",
)
workspace2 = Workspace(
id="workspace-2",
name="Team Workspace",
owner_user_id="other-user",
subscription_plan="pro",
)
mock_workspace_repo.find_by_id.side_effect = [workspace1, workspace2]
mock_member_repo.count_by_workspace.side_effect = [1, 5]
request = ListWorkspacesRequest(user_id="user-123")
response, error = use_case.execute(request)
assert error is None
assert response is not None
assert len(response.workspaces) == 2
# 验证第一个工作空间
ws1 = response.workspaces[0]
assert ws1.workspace_id == "workspace-1"
assert ws1.name == "My Workspace"
assert ws1.user_role == "owner"
assert ws1.member_count == 1
assert ws1.subscription_plan == "free"
# 验证第二个工作空间
ws2 = response.workspaces[1]
assert ws2.workspace_id == "workspace-2"
assert ws2.name == "Team Workspace"
assert ws2.user_role == "member"
assert ws2.member_count == 5
assert ws2.subscription_plan == "pro"
def test_list_workspaces_no_memberships(
self,
use_case,
mock_member_repo,
):
"""测试用户没有加入任何工作空间"""
mock_member_repo.find_by_user.return_value = []
request = ListWorkspacesRequest(user_id="user-123")
response, error = use_case.execute(request)
assert error is None
assert response is not None
assert len(response.workspaces) == 0
def test_list_workspaces_missing_user_id(self, use_case):
"""测试缺少用户 ID"""
request = ListWorkspacesRequest(user_id="")
response, error = use_case.execute(request)
assert response is None
assert error == "User ID is required"
class TestGetWorkspaceDetailUseCase:
"""获取工作空间详情测试"""
@pytest.fixture
def mock_workspace_repo(self):
repo = Mock()
repo.find_by_id = Mock(return_value=None)
return repo
@pytest.fixture
def mock_member_repo(self):
repo = Mock()
repo.find_by_workspace_and_user = Mock(return_value=None)
repo.count_by_workspace = Mock(return_value=0)
return repo
@pytest.fixture
def use_case(self, mock_workspace_repo, mock_member_repo):
return GetWorkspaceDetailUseCase(
workspace_repository=mock_workspace_repo,
workspace_member_repository=mock_member_repo,
)
@pytest.fixture
def test_workspace(self):
return Workspace(
id="workspace-123",
name="Test Workspace",
owner_user_id="owner-id",
subscription_plan="pro",
subscription_status="active",
max_projects=999999,
max_storage_gb=100,
used_storage_gb=25.5,
)
def test_get_workspace_detail_success(
self,
use_case,
mock_workspace_repo,
mock_member_repo,
test_workspace,
):
"""测试获取工作空间详情成功"""
mock_workspace_repo.find_by_id.return_value = test_workspace
membership = WorkspaceMember(
id="member-1",
workspace_id="workspace-123",
user_id="user-123",
role=WorkspaceMemberRole.ADMIN,
)
mock_member_repo.find_by_workspace_and_user.return_value = membership
mock_member_repo.count_by_workspace.return_value = 8
request = GetWorkspaceDetailRequest(
workspace_id="workspace-123",
user_id="user-123",
)
detail, error = use_case.execute(request)
assert error is None
assert detail is not None
assert detail.workspace_id == "workspace-123"
assert detail.name == "Test Workspace"
assert detail.subscription_plan == "pro"
assert detail.subscription_status == "active"
assert detail.max_projects == 999999
assert detail.max_storage_gb == 100
assert detail.used_storage_gb == 25.5
assert detail.member_count == 8
assert detail.user_role == "admin"
def test_get_workspace_detail_not_found(
self,
use_case,
mock_workspace_repo,
):
"""测试工作空间不存在"""
mock_workspace_repo.find_by_id.return_value = None
request = GetWorkspaceDetailRequest(
workspace_id="nonexistent",
user_id="user-123",
)
detail, error = use_case.execute(request)
assert detail is None
assert error == "Workspace not found"
def test_get_workspace_detail_not_member(
self,
use_case,
mock_workspace_repo,
mock_member_repo,
test_workspace,
):
"""测试用户不是成员"""
mock_workspace_repo.find_by_id.return_value = test_workspace
mock_member_repo.find_by_workspace_and_user.return_value = None
request = GetWorkspaceDetailRequest(
workspace_id="workspace-123",
user_id="user-123",
)
detail, error = use_case.execute(request)
assert detail is None
assert error == "You are not a member of this workspace"
def test_get_workspace_detail_missing_workspace_id(self, use_case):
"""测试缺少工作空间 ID"""
request = GetWorkspaceDetailRequest(
workspace_id="",
user_id="user-123",
)
detail, error = use_case.execute(request)
assert detail is None
assert error == "Workspace ID is required"
def test_get_workspace_detail_missing_user_id(self, use_case):
"""测试缺少用户 ID"""
request = GetWorkspaceDetailRequest(
workspace_id="workspace-123",
user_id="",
)
detail, error = use_case.execute(request)
assert detail is None
assert error == "User ID is required"
+1 -1
View File
@@ -13,7 +13,7 @@ from packages.application.auth import (
LogoutRequest,
LogoutUseCase,
)
from packages.application.auth import password_hasher
from packages.application.auth.password_hasher import password_hasher
from packages.domain.entities import User

Some files were not shown because too many files have changed in this diff Show More