Merge pull request 'fix: 消除 API↔Worker 跨端依赖(架构解耦)' (#44) from fix/architecture-decouple into develop

This commit is contained in:
2026-06-27 11:16:03 +08:00
11 changed files with 338 additions and 50 deletions
+1 -4
View File
@@ -16,10 +16,7 @@ from app.schemas.edit_plan import (
EditPlanResponse,
EditTemplateResponse,
)
from apps.worker.worker_app.tasks.edit_plan_generator import (
EditingMode,
SmartEditPlanGenerator,
)
from packages.domain.edit_plan import EditingMode, SmartEditPlanGenerator
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session
+3 -4
View File
@@ -2,9 +2,6 @@ import os
import tempfile
from datetime import datetime, timezone
from app.config import get_settings
from app.core.storage import get_storage_service
from packages.adapters.sqlalchemy_impl.asset_repository import SQLAlchemyAssetRepository
from packages.adapters.sqlalchemy_impl.generated_video_repository import (
SQLAlchemyGeneratedVideoRepository,
@@ -17,11 +14,13 @@ from packages.adapters.sqlalchemy_impl.session import (
build_session_factory,
)
from packages.domain import GeneratedVideo, GenerationTaskStatus
from packages.shared.config import get_shared_settings
from packages.shared.storage import get_storage_service
from .celery_app import celery_app
from .video_processing import VideoProcessor
settings = get_settings()
settings = get_shared_settings()
if SessionLocal is None:
build_session_factory(settings.database_url)
+3 -3
View File
@@ -15,13 +15,13 @@ from sqlalchemy.orm import Session
from packages.adapters.sqlalchemy_impl.generated_video_repository import SQLAlchemyGeneratedVideoRepository
from packages.adapters.sqlalchemy_impl.session import SessionLocal, build_session_factory
from app.config import get_settings
from app.core.storage import get_storage_service
from packages.shared.config import get_shared_settings
from packages.shared.storage import get_storage_service
from apps.worker.celery_app import celery_app
logger = logging.getLogger(__name__)
settings = get_settings()
settings = get_shared_settings()
if SessionLocal is None:
build_session_factory(settings.database_url)
+26 -6
View File
@@ -1,9 +1,29 @@
"""Task modules."""
"""Task modules - lazy loading to avoid Celery dependency at import time."""
from __future__ import annotations
def __getattr__(name: str):
"""Lazy import task functions to avoid loading Celery at module import time."""
if name == "classify_asset":
from .classification import classify_asset
return classify_asset
elif name == "generate_video":
from .generation import generate_video
return generate_video
elif name == "healthcheck":
from .health import healthcheck
return healthcheck
elif name == "ingest_asset":
from .ingest import ingest_asset
return ingest_asset
elif name == "extract_voice_task":
from .voice_extraction import extract_voice_task
return extract_voice_task
elif name == "extract_background_task":
from .voice_extraction import extract_background_task
return extract_background_task
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
from .classification import classify_asset
from .generation import generate_video
from .health import healthcheck
from .ingest import ingest_asset
from .voice_extraction import extract_voice_task, extract_background_task
__all__ = ["classify_asset", "generate_video", "healthcheck", "ingest_asset", "extract_voice_task", "extract_background_task"]
@@ -8,39 +8,11 @@ from enum import StrEnum
from typing import Any
from packages.domain import Asset, AssetClassification, AssetStatus
from packages.domain.edit_plan import EditClipPlan, EditPlanResult, EditingMode
logger = logging.getLogger(__name__)
class EditingMode(StrEnum):
"""剪辑模式枚举"""
ONE_TAKE = "one_take" # 顺序拼接模式
PIP = "pip" # 画中画模式
VOICE_OVER = "voice_over" # 口播+B-roll模式
VOICE_PIP = "voice_pip" # 口播+画中画组合模式
@dataclass
class EditClipPlan:
"""单个剪辑片段的编排计划"""
asset_id: str
sequence: int
start_time: float = 0.0
duration: float = 0.0
layer: str = "main" # main, pip, broll
reason: str = ""
@dataclass
class EditPlanResult:
"""完整剪辑计划结果"""
project_id: str
editing_mode: EditingMode
clips: list[EditClipPlan]
total_duration: float
summary: str
def _calculate_start_times(clips: list[EditClipPlan]) -> list[EditClipPlan]:
"""
计算时间轴,根据前面的片段时长累加 start_time
@@ -11,13 +11,13 @@ from sqlalchemy.orm import Session
from packages.adapters.sqlalchemy_impl.asset_repository import SQLAlchemyAssetRepository
from packages.adapters.sqlalchemy_impl.session import SessionLocal, build_session_factory
from app.config import get_settings
from app.core.storage import get_storage_service
from packages.shared.config import get_shared_settings
from packages.shared.storage import get_storage_service
from .celery_app import celery_app
logger = logging.getLogger(__name__)
settings = get_settings()
settings = get_shared_settings()
if SessionLocal is None:
build_session_factory(settings.database_url)
+10
View File
@@ -5,6 +5,11 @@ from .classification import (
ClassificationJob,
ClassificationJobStatus,
)
from .edit_plan import (
EditClipPlan,
EditPlanResult,
EditingMode,
)
from .entities import (
Asset,
AssetLibrary,
@@ -15,6 +20,7 @@ from .entities import (
IngestJobStatus,
Project,
User,
Workspace,
)
from .generated_video import GeneratedVideo
from .generation_task import GenerationTask, GenerationTaskStatus
@@ -29,6 +35,9 @@ __all__ = [
"ClassificationJob",
"ClassificationJobStatus",
"ClassificationStatus",
"EditClipPlan",
"EditPlanResult",
"EditingMode",
"GeneratedVideo",
"GenerationTask",
"GenerationTaskStatus",
@@ -41,4 +50,5 @@ __all__ = [
"TaskPriority",
"TaskStatus",
"User",
"Workspace",
]
+33
View File
@@ -0,0 +1,33 @@
"""Edit Plan domain models - shared between API and Worker."""
from dataclasses import dataclass
from enum import StrEnum
class EditingMode(StrEnum):
"""剪辑模式枚举"""
ONE_TAKE = "one_take" # 顺序拼接模式
PIP = "pip" # 画中画模式
VOICE_OVER = "voice_over" # 口播+B-roll模式
VOICE_PIP = "voice_pip" # 口播+画中画组合模式
@dataclass
class EditClipPlan:
"""单个剪辑片段的编排计划"""
asset_id: str
sequence: int
start_time: float = 0.0
duration: float = 0.0
layer: str = "main" # main, pip, broll
reason: str = ""
@dataclass
class EditPlanResult:
"""完整剪辑计划结果"""
project_id: str
editing_mode: EditingMode
clips: list[EditClipPlan]
total_duration: float
summary: str
+17 -1
View File
@@ -1 +1,17 @@
"""Shared package for cross-cutting, non-business-specific utilities."""
"""Shared utilities for API and Worker services."""
from .config import SharedSettings, get_shared_settings
from .storage import (
SharedStorageService,
get_shared_settings,
get_shared_storage_service,
get_storage_service,
)
__all__ = [
"SharedSettings",
"SharedStorageService",
"get_shared_settings",
"get_shared_storage_service",
"get_storage_service",
]
+57
View File
@@ -0,0 +1,57 @@
"""Shared settings for API and Worker services."""
import os
from typing import Optional
from pydantic_settings import BaseSettings, SettingsConfigDict
class SharedSettings(BaseSettings):
"""Settings shared between API and Worker services."""
# Database
database_url: str = "postgresql+psycopg://postgres:postgres@localhost:5432/xiaoxia_saas"
database_pool_size: int = 20
database_max_overflow: int = 40
database_pool_timeout: int = 30
database_pool_recycle: int = 3600
# Redis
redis_url: str = "redis://localhost:6379/0"
# Celery
celery_broker_url: str = "redis://localhost:6379/0"
celery_result_backend: str = "redis://localhost:6379/1"
# OSS Aliyun
oss_endpoint: str = "oss-cn-hangzhou.aliiyuncs.com"
oss_access_key_id: str = ""
oss_access_key_secret: str = ""
oss_bucket_name: str = "xiaoxia-autocut"
# Environment
environment: str = "development"
auto_create_schema: bool = False
model_config = SettingsConfigDict(
env_file=".env",
env_file_encoding="utf-8",
case_sensitive=False,
extra="ignore",
)
_settings: Optional[SharedSettings] = None
def get_shared_settings() -> SharedSettings:
"""Get shared settings instance (global singleton)."""
global _settings
if _settings is None:
env = os.getenv("APP_ENV", "development")
env_file = f".env.{env}" if env != "development" else ".env"
if os.path.exists(env_file):
_settings = SharedSettings(_env_file=env_file)
else:
_settings = SharedSettings()
return _settings
+184
View File
@@ -0,0 +1,184 @@
"""Shared OSS storage service for API and Worker."""
import base64
import datetime as dt
import hashlib
import hmac
import json
import logging
import os
from urllib.parse import urlparse
from typing import Optional
try:
import oss2
except ImportError: # pragma: no cover
oss2 = None
from packages.shared.config import get_shared_settings
logger = logging.getLogger(__name__)
class SharedStorageService:
"""Shared OSS storage service."""
def __init__(self):
settings = get_shared_settings()
self.bucket_name = settings.oss_bucket_name
self.public_url = f"https://{settings.oss_bucket_name}.{settings.oss_endpoint}"
self.local_url_prefix = os.getenv("GENERATED_FILES_URL_PREFIX", "/generated-files")
self.bucket = None
if settings.oss_access_key_id and settings.oss_access_key_secret:
if oss2 is not None:
auth = oss2.Auth(
settings.oss_access_key_id,
settings.oss_access_key_secret,
)
self.bucket = oss2.Bucket(
auth,
settings.oss_endpoint,
settings.oss_bucket_name,
)
self.access_key_id = settings.oss_access_key_id
self.access_key_secret = settings.oss_access_key_secret
self.endpoint = settings.oss_endpoint
def _is_local_generated_url(self, storage_key_or_url: str) -> bool:
parsed = urlparse(storage_key_or_url)
path = parsed.path if parsed.scheme else storage_key_or_url
return path.startswith(f"{self.local_url_prefix}/")
def upload_file(
self,
file_or_path,
storage_key: str,
content_type: str = "application/octet-stream",
) -> str:
"""Upload file to OSS."""
if self.bucket is None:
raise RuntimeError("OSS storage is not configured")
try:
if isinstance(file_or_path, str):
self.bucket.put_object_from_file(storage_key, file_or_path, headers={"Content-Type": content_type})
else:
file_or_path.seek(0)
self.bucket.put_object(storage_key, file_or_path, headers={"Content-Type": content_type})
return f"{self.public_url}/{storage_key}"
except Exception as e:
raise Exception(f"Failed to upload file to OSS: {e}")
def get_url(self, storage_key: str) -> str:
"""Get public URL for a file."""
return f"{self.public_url}/{storage_key}"
def download_file(self, storage_key: str, local_path: str):
"""Download file from OSS to local path."""
if self.bucket is None:
raise RuntimeError("OSS storage is not configured")
try:
os.makedirs(os.path.dirname(local_path), exist_ok=True)
self.bucket.get_object_to_file(storage_key, local_path)
except Exception as e:
raise Exception(f"Failed to download file from OSS: {e}")
def get_download_url(self, storage_key_or_url: str, expires_seconds: int = 3600) -> str:
"""Get signed download URL."""
if self.bucket is None:
if self._is_local_generated_url(storage_key_or_url):
return storage_key_or_url
return self.get_url(self._normalize_storage_key(storage_key_or_url))
storage_key = self._normalize_storage_key(storage_key_or_url)
try:
return self.bucket.sign_url("GET", storage_key, expires_seconds)
except Exception:
return self.get_url(storage_key)
def _normalize_storage_key(self, storage_key_or_url: str) -> str:
"""Extract storage key from URL."""
if storage_key_or_url.startswith("http://") or storage_key_or_url.startswith("https://"):
parsed = urlparse(storage_key_or_url)
return parsed.path.lstrip("/")
return storage_key_or_url.lstrip("/")
def create_direct_upload_post(
self,
storage_key: str,
content_type: str,
max_size_bytes: int,
expires_seconds: int,
) -> dict[str, object]:
"""Create browser direct upload POST form."""
if not self.access_key_id or not self.access_key_secret:
raise RuntimeError("OSS storage is not configured")
normalized_key = self._normalize_storage_key(storage_key)
if not normalized_key.startswith("uploads/"):
raise ValueError("direct upload key must be under uploads/")
expiration = (dt.datetime.now(dt.timezone.utc) + dt.timedelta(seconds=expires_seconds)).strftime(
"%Y-%m-%dT%H:%M:%S.000Z"
)
policy = {
"expiration": expiration,
"conditions": [
{"bucket": self.bucket_name},
{"key": normalized_key},
["content-length-range", 1, max_size_bytes],
["starts-with", "$Content-Type", content_type.split("/", 1)[0] + "/" if "/" in content_type else ""],
],
}
encoded_policy = base64.b64encode(json.dumps(policy, separators=(",", ":")).encode("utf-8")).decode("ascii")
signature = base64.b64encode(
hmac.new(self.access_key_secret.encode("utf-8"), encoded_policy.encode("utf-8"), hashlib.sha1).digest()
).decode("ascii")
return {
"url": self.public_url,
"method": "POST",
"storage_key": normalized_key,
"expires_at": expiration,
"fields": {
"key": normalized_key,
"OSSAccessKeyId": self.access_key_id,
"policy": encoded_policy,
"Signature": signature,
"success_action_status": "201",
"Content-Type": content_type,
},
}
def delete_file(self, storage_key: str):
"""Delete file from OSS."""
if self.bucket is None:
return
try:
self.bucket.delete_object(storage_key)
except Exception as error:
logger.warning("Failed to delete file from OSS", extra={"storage_key": storage_key, "error": str(error)})
def file_exists(self, storage_key: str) -> bool:
"""Check if file exists."""
if self.bucket is None:
return False
return self.bucket.object_exists(storage_key)
_storage_service: Optional[SharedStorageService] = None
def get_shared_storage_service() -> SharedStorageService:
"""Get shared storage service instance (global singleton)."""
global _storage_service
if _storage_service is None:
_storage_service = SharedStorageService()
return _storage_service
# Backward compatibility alias
def get_storage_service() -> SharedStorageService:
"""Backward compatibility: returns shared storage service."""
return get_shared_storage_service()