Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 276342520f | |||
| 6feb541127 | |||
| 6efac8de4b |
+4
-1
@@ -3,6 +3,7 @@
|
|||||||
# ==================== 应用配置 ====================
|
# ==================== 应用配置 ====================
|
||||||
APP_NAME=小虾 SaaS
|
APP_NAME=小虾 SaaS
|
||||||
APP_BASE_URL=http://localhost:3000
|
APP_BASE_URL=http://localhost:3000
|
||||||
|
APP_ENV=development
|
||||||
|
|
||||||
# ==================== 数据库配置 ====================
|
# ==================== 数据库配置 ====================
|
||||||
DATABASE_URL=postgresql://xiaoxia_user:your_password@localhost:5432/xiaoxia_saas
|
DATABASE_URL=postgresql://xiaoxia_user:your_password@localhost:5432/xiaoxia_saas
|
||||||
@@ -35,7 +36,8 @@ ENVIRONMENT=development
|
|||||||
DEBUG=true
|
DEBUG=true
|
||||||
|
|
||||||
# ==================== CORS 配置 ====================
|
# ==================== CORS 配置 ====================
|
||||||
CORS_ORIGINS=["http://localhost:3000","http://localhost:5173"]
|
# 逗号分隔的域名列表(Settings 读取 CORS_ORIGINS_RAW)
|
||||||
|
CORS_ORIGINS_RAW=http://localhost:3000,http://localhost:5173
|
||||||
|
|
||||||
# ==================== 阿里云 OSS 配置 ====================
|
# ==================== 阿里云 OSS 配置 ====================
|
||||||
OSS_ENDPOINT=oss-cn-hangzhou.aliyuncs.com
|
OSS_ENDPOINT=oss-cn-hangzhou.aliyuncs.com
|
||||||
@@ -44,6 +46,7 @@ OSS_ACCESS_KEY_SECRET=your-access-key-secret
|
|||||||
OSS_BUCKET_NAME=xiaoxia-autocut
|
OSS_BUCKET_NAME=xiaoxia-autocut
|
||||||
|
|
||||||
# ==================== CosyVoice 语音合成配置 ====================
|
# ==================== CosyVoice 语音合成配置 ====================
|
||||||
|
# 注意:COSYVOICE_* 变量由 packages/shared/config.py 的 SharedSettings 读取
|
||||||
COSYVOICE_API_KEY=your-cosyvoice-api-key
|
COSYVOICE_API_KEY=your-cosyvoice-api-key
|
||||||
COSYVOICE_BASE_URL=https://dashscope.aliyuncs.com/api/v1/services/aigc/text2audio
|
COSYVOICE_BASE_URL=https://dashscope.aliyuncs.com/api/v1/services/aigc/text2audio
|
||||||
COSYVOICE_MODEL=cosyvoice-v1
|
COSYVOICE_MODEL=cosyvoice-v1
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
"""API application package."""
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
"""API package."""
|
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
"""路由层共享辅助函数 — 消除跨文件重复定义。"""
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from fastapi import HTTPException, status
|
||||||
|
|
||||||
|
from packages.application import GetProjectUseCase
|
||||||
|
from packages.ports.user_repository import UserRepository
|
||||||
|
|
||||||
|
|
||||||
|
def check_project_access(project_id: str, user_id: str, project_repository) -> None:
|
||||||
|
"""检查用户是否有项目访问权限。"""
|
||||||
|
project = project_repository.find_by_id(project_id)
|
||||||
|
if project is None:
|
||||||
|
raise HTTPException(status_code=404, detail=f"Project {project_id} not found")
|
||||||
|
if not project.can_access(user_id):
|
||||||
|
raise HTTPException(status_code=403, detail="Access denied to project")
|
||||||
|
|
||||||
|
|
||||||
|
def get_user_plan(user_id: str, user_repository: UserRepository) -> str:
|
||||||
|
"""获取用户的订阅计划名称。"""
|
||||||
|
user = user_repository.find_by_id(user_id)
|
||||||
|
if user is None:
|
||||||
|
return "free"
|
||||||
|
return getattr(user, "subscription_plan", "free") or "free"
|
||||||
|
|
||||||
|
|
||||||
|
def require_project_and_library(
|
||||||
|
project_id: str,
|
||||||
|
library_id: str,
|
||||||
|
project_repository: Any,
|
||||||
|
asset_library_repository: Any,
|
||||||
|
) -> None:
|
||||||
|
"""Verify project and asset library exist."""
|
||||||
|
project = GetProjectUseCase(project_repository).execute(project_id)
|
||||||
|
if project is None:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Project not found")
|
||||||
|
|
||||||
|
libraries = asset_library_repository.find_by_project(project_id)
|
||||||
|
if not any(item.id == library_id for item in libraries):
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Asset library not found")
|
||||||
@@ -27,6 +27,8 @@ from packages.application import (
|
|||||||
)
|
)
|
||||||
from packages.domain import AssetStatus, ClassificationStatus
|
from packages.domain import AssetStatus, ClassificationStatus
|
||||||
|
|
||||||
|
from app.api.routes._helpers import check_project_access
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
@@ -72,14 +74,6 @@ def _to_asset_response(item, storage_service=None) -> AssetResponse:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def _check_project_access(project_id: str, user_id: str, project_repository) -> None:
|
|
||||||
"""检查用户是否有项目访问权限"""
|
|
||||||
project = project_repository.find_by_id(project_id)
|
|
||||||
if project is None:
|
|
||||||
raise HTTPException(status_code=404, detail=f"Project {project_id} not found")
|
|
||||||
if not project.can_access(user_id):
|
|
||||||
raise HTTPException(status_code=403, detail="Access denied to project")
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("", response_model=ListAssetsResponse)
|
@router.get("", response_model=ListAssetsResponse)
|
||||||
def list_assets(
|
def list_assets(
|
||||||
@@ -136,7 +130,7 @@ def list_assets(
|
|||||||
library = asset_library_repository.get(library_id)
|
library = asset_library_repository.get(library_id)
|
||||||
if library is None:
|
if library is None:
|
||||||
raise HTTPException(status_code=404, detail=f"AssetLibrary {library_id} not found")
|
raise HTTPException(status_code=404, detail=f"AssetLibrary {library_id} not found")
|
||||||
_check_project_access(library.project_id, user_id, project_repository)
|
check_project_access(library.project_id, user_id, project_repository)
|
||||||
if ft:
|
if ft:
|
||||||
items = asset_repository.find_by_library_and_file_type(library_id, ft, skip=skip, limit=limit)
|
items = asset_repository.find_by_library_and_file_type(library_id, ft, skip=skip, limit=limit)
|
||||||
total = asset_repository.count_by_project(library.project_id) if not kind else len(items)
|
total = asset_repository.count_by_project(library.project_id) if not kind else len(items)
|
||||||
@@ -152,7 +146,7 @@ def list_assets(
|
|||||||
|
|
||||||
# 模式2:指定 project_id
|
# 模式2:指定 project_id
|
||||||
if project_id:
|
if project_id:
|
||||||
_check_project_access(project_id, user_id, project_repository)
|
check_project_access(project_id, user_id, project_repository)
|
||||||
if ft:
|
if ft:
|
||||||
# 无直接方法,加载后按 file_type 过滤(仍比全量加载好)
|
# 无直接方法,加载后按 file_type 过滤(仍比全量加载好)
|
||||||
all_items = asset_repository.find_by_project(project_id)
|
all_items = asset_repository.find_by_project(project_id)
|
||||||
@@ -210,13 +204,13 @@ def list_assets(
|
|||||||
library = asset_library_repository.get(library_id)
|
library = asset_library_repository.get(library_id)
|
||||||
if library is None:
|
if library is None:
|
||||||
raise HTTPException(status_code=404, detail=f"AssetLibrary {library_id} not found")
|
raise HTTPException(status_code=404, detail=f"AssetLibrary {library_id} not found")
|
||||||
_check_project_access(library.project_id, user_id, project_repository)
|
check_project_access(library.project_id, user_id, project_repository)
|
||||||
if kind:
|
if kind:
|
||||||
all_items = asset_repository.find_by_library_and_file_type(library_id, kind_to_file_type[kind])
|
all_items = asset_repository.find_by_library_and_file_type(library_id, kind_to_file_type[kind])
|
||||||
else:
|
else:
|
||||||
all_items = asset_repository.find_by_library(library_id)
|
all_items = asset_repository.find_by_library(library_id)
|
||||||
elif project_id:
|
elif project_id:
|
||||||
_check_project_access(project_id, user_id, project_repository)
|
check_project_access(project_id, user_id, project_repository)
|
||||||
all_items = asset_repository.find_by_project(project_id)
|
all_items = asset_repository.find_by_project(project_id)
|
||||||
else:
|
else:
|
||||||
try:
|
try:
|
||||||
@@ -262,7 +256,7 @@ def update_asset_review_status(
|
|||||||
item = asset_repository.get(asset_id)
|
item = asset_repository.get(asset_id)
|
||||||
if item is None:
|
if item is None:
|
||||||
raise HTTPException(status_code=404, detail=f"Asset {asset_id} not found")
|
raise HTTPException(status_code=404, detail=f"Asset {asset_id} not found")
|
||||||
_check_project_access(item.project_id, authenticated_user.user.id, project_repository)
|
check_project_access(item.project_id, authenticated_user.user.id, project_repository)
|
||||||
_apply_asset_review_status(item, request.review_status)
|
_apply_asset_review_status(item, request.review_status)
|
||||||
updated = asset_repository.update(item)
|
updated = asset_repository.update(item)
|
||||||
return _to_asset_response(updated)
|
return _to_asset_response(updated)
|
||||||
@@ -286,7 +280,7 @@ def batch_delete_assets(
|
|||||||
failed_ids.append(asset_id)
|
failed_ids.append(asset_id)
|
||||||
continue
|
continue
|
||||||
try:
|
try:
|
||||||
_check_project_access(item.project_id, user_id, project_repository)
|
check_project_access(item.project_id, user_id, project_repository)
|
||||||
deleted_ids.append(asset_id)
|
deleted_ids.append(asset_id)
|
||||||
except HTTPException:
|
except HTTPException:
|
||||||
failed_ids.append(asset_id)
|
failed_ids.append(asset_id)
|
||||||
@@ -307,7 +301,7 @@ def get_asset(
|
|||||||
item = asset_repository.find_by_id(asset_id)
|
item = asset_repository.find_by_id(asset_id)
|
||||||
if item is None:
|
if item is None:
|
||||||
raise HTTPException(status_code=404, detail=f"Asset {asset_id} not found")
|
raise HTTPException(status_code=404, detail=f"Asset {asset_id} not found")
|
||||||
_check_project_access(item.project_id, authenticated_user.user.id, project_repository)
|
check_project_access(item.project_id, authenticated_user.user.id, project_repository)
|
||||||
return _to_asset_response(item)
|
return _to_asset_response(item)
|
||||||
|
|
||||||
|
|
||||||
@@ -322,7 +316,7 @@ def update_asset(
|
|||||||
item = asset_repository.find_by_id(asset_id)
|
item = asset_repository.find_by_id(asset_id)
|
||||||
if item is None:
|
if item is None:
|
||||||
raise HTTPException(status_code=404, detail=f"Asset {asset_id} not found")
|
raise HTTPException(status_code=404, detail=f"Asset {asset_id} not found")
|
||||||
_check_project_access(item.project_id, authenticated_user.user.id, project_repository)
|
check_project_access(item.project_id, authenticated_user.user.id, project_repository)
|
||||||
|
|
||||||
# 合并可修改字段
|
# 合并可修改字段
|
||||||
if request.name is not None:
|
if request.name is not None:
|
||||||
@@ -346,7 +340,7 @@ def delete_asset(
|
|||||||
item = asset_repository.find_by_id(asset_id)
|
item = asset_repository.find_by_id(asset_id)
|
||||||
if item is None:
|
if item is None:
|
||||||
raise HTTPException(status_code=404, detail=f"Asset {asset_id} not found")
|
raise HTTPException(status_code=404, detail=f"Asset {asset_id} not found")
|
||||||
_check_project_access(item.project_id, authenticated_user.user.id, project_repository)
|
check_project_access(item.project_id, authenticated_user.user.id, project_repository)
|
||||||
asset_repository.delete(asset_id)
|
asset_repository.delete(asset_id)
|
||||||
|
|
||||||
|
|
||||||
@@ -363,7 +357,7 @@ def tag_asset(
|
|||||||
item = asset_repository.find_by_id(asset_id)
|
item = asset_repository.find_by_id(asset_id)
|
||||||
if item is None:
|
if item is None:
|
||||||
raise HTTPException(status_code=404, detail=f"Asset {asset_id} not found")
|
raise HTTPException(status_code=404, detail=f"Asset {asset_id} not found")
|
||||||
_check_project_access(item.project_id, authenticated_user.user.id, project_repository)
|
check_project_access(item.project_id, authenticated_user.user.id, project_repository)
|
||||||
for tag_id in request.tag_ids:
|
for tag_id in request.tag_ids:
|
||||||
tag = tag_repository.get(tag_id)
|
tag = tag_repository.get(tag_id)
|
||||||
if tag is None:
|
if tag is None:
|
||||||
@@ -387,7 +381,7 @@ def untag_asset(
|
|||||||
item = asset_repository.find_by_id(asset_id)
|
item = asset_repository.find_by_id(asset_id)
|
||||||
if item is None:
|
if item is None:
|
||||||
raise HTTPException(status_code=404, detail=f"Asset {asset_id} not found")
|
raise HTTPException(status_code=404, detail=f"Asset {asset_id} not found")
|
||||||
_check_project_access(item.project_id, authenticated_user.user.id, project_repository)
|
check_project_access(item.project_id, authenticated_user.user.id, project_repository)
|
||||||
item.remove_tag(tag_id)
|
item.remove_tag(tag_id)
|
||||||
asset_repository.update(item)
|
asset_repository.update(item)
|
||||||
|
|
||||||
|
|||||||
@@ -243,7 +243,6 @@ async def logout(
|
|||||||
current_user: AuthenticatedUser = Depends(get_current_user),
|
current_user: AuthenticatedUser = Depends(get_current_user),
|
||||||
):
|
):
|
||||||
"""登出 - 将当前 token 加入黑名单"""
|
"""登出 - 将当前 token 加入黑名单"""
|
||||||
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
|
||||||
|
|
||||||
if credentials:
|
if credentials:
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -14,7 +14,6 @@ from typing import Any
|
|||||||
from uuid import uuid4
|
from uuid import uuid4
|
||||||
|
|
||||||
from app.auth import AuthenticatedUser, get_current_user
|
from app.auth import AuthenticatedUser, get_current_user
|
||||||
from app.config import get_settings
|
|
||||||
from app.core.celery_app import celery_app
|
from app.core.celery_app import celery_app
|
||||||
from app.core.storage import OSSStorageService, get_storage_service
|
from app.core.storage import OSSStorageService, get_storage_service
|
||||||
from app.dependencies import (
|
from app.dependencies import (
|
||||||
@@ -35,6 +34,8 @@ from fastapi.params import File
|
|||||||
|
|
||||||
from packages.application import GetProjectUseCase, SubmitIngestJobCommand, SubmitIngestJobUseCase
|
from packages.application import GetProjectUseCase, SubmitIngestJobCommand, SubmitIngestJobUseCase
|
||||||
|
|
||||||
|
from app.api.routes._helpers import require_project_and_library
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -113,22 +114,6 @@ def _atomic_check_and_record(upload_id: str, chunk_index: int) -> bool:
|
|||||||
fcntl.flock(f.fileno(), fcntl.LOCK_UN)
|
fcntl.flock(f.fileno(), fcntl.LOCK_UN)
|
||||||
|
|
||||||
|
|
||||||
def _require_project_and_library(
|
|
||||||
project_id: str,
|
|
||||||
library_id: str,
|
|
||||||
project_repository: Any,
|
|
||||||
asset_library_repository: Any,
|
|
||||||
) -> None:
|
|
||||||
"""Verify project and asset library exist"""
|
|
||||||
project = GetProjectUseCase(project_repository).execute(project_id)
|
|
||||||
if project is None:
|
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Project not found")
|
|
||||||
|
|
||||||
libraries = asset_library_repository.find_by_project(project_id)
|
|
||||||
if not any(item.id == library_id for item in libraries):
|
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Asset library not found")
|
|
||||||
|
|
||||||
|
|
||||||
def _load_upload_meta(upload_id: str) -> dict[str, Any]:
|
def _load_upload_meta(upload_id: str) -> dict[str, Any]:
|
||||||
"""Load upload metadata"""
|
"""Load upload metadata"""
|
||||||
meta_path = _get_upload_meta_path(upload_id)
|
meta_path = _get_upload_meta_path(upload_id)
|
||||||
@@ -206,7 +191,6 @@ async def init_chunked_upload(
|
|||||||
asset_library_repository: Any = Depends(get_asset_library_repository),
|
asset_library_repository: Any = Depends(get_asset_library_repository),
|
||||||
) -> ChunkedUploadInitResponse:
|
) -> ChunkedUploadInitResponse:
|
||||||
"""Initialize chunked upload"""
|
"""Initialize chunked upload"""
|
||||||
settings = get_settings()
|
|
||||||
|
|
||||||
# Validate file size
|
# Validate file size
|
||||||
if request.file_size > MAX_FILE_SIZE:
|
if request.file_size > MAX_FILE_SIZE:
|
||||||
@@ -221,7 +205,7 @@ async def init_chunked_upload(
|
|||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Project not found")
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Project not found")
|
||||||
|
|
||||||
# Verify asset library
|
# Verify asset library
|
||||||
_require_project_and_library(
|
require_project_and_library(
|
||||||
request.project_id,
|
request.project_id,
|
||||||
request.library_id,
|
request.library_id,
|
||||||
project_repository,
|
project_repository,
|
||||||
|
|||||||
@@ -31,12 +31,6 @@ from fastapi import APIRouter, Depends, HTTPException, Query, status
|
|||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from packages.adapters.sqlalchemy_impl.asset_library_repository import (
|
|
||||||
SQLAlchemyAssetLibraryRepository,
|
|
||||||
)
|
|
||||||
from packages.adapters.sqlalchemy_impl.asset_repository import (
|
|
||||||
SQLAlchemyAssetRepository,
|
|
||||||
)
|
|
||||||
from packages.adapters.sqlalchemy_impl.generation_task_repository import (
|
from packages.adapters.sqlalchemy_impl.generation_task_repository import (
|
||||||
SQLAlchemyGenerationTaskRepository,
|
SQLAlchemyGenerationTaskRepository,
|
||||||
)
|
)
|
||||||
@@ -684,7 +678,7 @@ def generate_plan(
|
|||||||
except HTTPException:
|
except HTTPException:
|
||||||
# 已处理的 HTTP 异常直接透传
|
# 已处理的 HTTP 异常直接透传
|
||||||
raise
|
raise
|
||||||
except Exception as exc:
|
except Exception:
|
||||||
logger.exception("触发剪辑计划生成失败: plan_id=%s", plan_id)
|
logger.exception("触发剪辑计划生成失败: plan_id=%s", plan_id)
|
||||||
# 尝试将计划标记为失败(RENDERING → FAILED 是合法的状态流转)
|
# 尝试将计划标记为失败(RENDERING → FAILED 是合法的状态流转)
|
||||||
try:
|
try:
|
||||||
@@ -883,7 +877,7 @@ def ai_recommend_clips(
|
|||||||
config=normalized_config,
|
config=normalized_config,
|
||||||
total_duration=result["total_duration"],
|
total_duration=result["total_duration"],
|
||||||
)
|
)
|
||||||
except Exception as exc:
|
except Exception:
|
||||||
logger.exception("AI 推荐写入失败,plan_id=%s 数据可能不一致", plan_id)
|
logger.exception("AI 推荐写入失败,plan_id=%s 数据可能不一致", plan_id)
|
||||||
# 尝试回滚未提交的变更
|
# 尝试回滚未提交的变更
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -24,6 +24,8 @@ from app.schemas.generation_task import (
|
|||||||
)
|
)
|
||||||
from fastapi import APIRouter, Depends, HTTPException
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
|
|
||||||
|
from app.api.routes._helpers import check_project_access
|
||||||
|
|
||||||
from packages.application import (
|
from packages.application import (
|
||||||
CreateGenerationTaskCommand,
|
CreateGenerationTaskCommand,
|
||||||
CreateGenerationTaskUseCase,
|
CreateGenerationTaskUseCase,
|
||||||
@@ -34,15 +36,6 @@ from packages.application import (
|
|||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
def _check_project_access(project_id: str, user_id: str, project_repository) -> None:
|
|
||||||
"""检查用户是否有项目访问权限"""
|
|
||||||
project = project_repository.find_by_id(project_id)
|
|
||||||
if project is None:
|
|
||||||
raise HTTPException(status_code=404, detail=f"Project {project_id} not found")
|
|
||||||
if not project.can_access(user_id):
|
|
||||||
raise HTTPException(status_code=403, detail="Access denied to project")
|
|
||||||
|
|
||||||
|
|
||||||
def _to_generation_task_response(task) -> GenerationTaskResponse:
|
def _to_generation_task_response(task) -> GenerationTaskResponse:
|
||||||
return GenerationTaskResponse(
|
return GenerationTaskResponse(
|
||||||
id=task.id,
|
id=task.id,
|
||||||
@@ -256,7 +249,7 @@ def get_generation_task(
|
|||||||
if task is None:
|
if task is None:
|
||||||
raise HTTPException(status_code=404, detail=f"GenerationTask {task_id} not found")
|
raise HTTPException(status_code=404, detail=f"GenerationTask {task_id} not found")
|
||||||
if task.project_id:
|
if task.project_id:
|
||||||
_check_project_access(task.project_id, authenticated_user.user.id, project_repository)
|
check_project_access(task.project_id, authenticated_user.user.id, project_repository)
|
||||||
return _to_generation_task_response(task)
|
return _to_generation_task_response(task)
|
||||||
|
|
||||||
|
|
||||||
@@ -273,7 +266,7 @@ def list_generation_results(
|
|||||||
if task is None:
|
if task is None:
|
||||||
raise HTTPException(status_code=404, detail=f"GenerationTask {task_id} not found")
|
raise HTTPException(status_code=404, detail=f"GenerationTask {task_id} not found")
|
||||||
if task.project_id:
|
if task.project_id:
|
||||||
_check_project_access(task.project_id, authenticated_user.user.id, project_repository)
|
check_project_access(task.project_id, authenticated_user.user.id, project_repository)
|
||||||
use_case = ListGeneratedVideosByTaskUseCase(generated_video_repository)
|
use_case = ListGeneratedVideosByTaskUseCase(generated_video_repository)
|
||||||
items = use_case.execute(task_id)
|
items = use_case.execute(task_id)
|
||||||
responses = []
|
responses = []
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ from typing import Any
|
|||||||
|
|
||||||
from app.auth import AuthenticatedUser, get_current_user
|
from app.auth import AuthenticatedUser, get_current_user
|
||||||
from app.core.celery_app import celery_app
|
from app.core.celery_app import celery_app
|
||||||
from app.dependencies import get_db_session, get_job_repository, get_project_repository
|
from app.dependencies import get_job_repository, get_project_repository
|
||||||
from app.schemas.job import (
|
from app.schemas.job import (
|
||||||
CompleteJobRequest,
|
CompleteJobRequest,
|
||||||
CreateJobRequest,
|
CreateJobRequest,
|
||||||
@@ -51,6 +51,8 @@ from packages.application.jobs import (
|
|||||||
)
|
)
|
||||||
from packages.domain.job import JobType
|
from packages.domain.job import JobType
|
||||||
|
|
||||||
|
from app.api.routes._helpers import check_project_access
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
@@ -66,15 +68,6 @@ _JOB_TYPE_TO_CELERY_TASK: dict[str, str] = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def _check_project_access(project_id: str, user_id: str, project_repository) -> None:
|
|
||||||
"""检查用户是否有项目访问权限。"""
|
|
||||||
project = project_repository.find_by_id(project_id)
|
|
||||||
if project is None:
|
|
||||||
raise HTTPException(status_code=404, detail=f"Project {project_id} not found")
|
|
||||||
if not project.can_access(user_id):
|
|
||||||
raise HTTPException(status_code=403, detail="Access denied to project")
|
|
||||||
|
|
||||||
|
|
||||||
# ── 创建任务 ──────────────────────────────────────────────────────────────────
|
# ── 创建任务 ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
@@ -89,7 +82,7 @@ def create_job(
|
|||||||
|
|
||||||
创建后任务处于 pending 状态,需要调用 /submit 提交执行。
|
创建后任务处于 pending 状态,需要调用 /submit 提交执行。
|
||||||
"""
|
"""
|
||||||
_check_project_access(request.project_id, authenticated_user.user.id, project_repository)
|
check_project_access(request.project_id, authenticated_user.user.id, project_repository)
|
||||||
|
|
||||||
# 校验 job_type
|
# 校验 job_type
|
||||||
try:
|
try:
|
||||||
@@ -182,7 +175,7 @@ def list_project_jobs(
|
|||||||
offset: int = Query(default=0, ge=0),
|
offset: int = Query(default=0, ge=0),
|
||||||
) -> ListJobsResponse:
|
) -> ListJobsResponse:
|
||||||
"""获取项目下的任务列表。"""
|
"""获取项目下的任务列表。"""
|
||||||
_check_project_access(project_id, authenticated_user.user.id, project_repository)
|
check_project_access(project_id, authenticated_user.user.id, project_repository)
|
||||||
|
|
||||||
use_case = ListJobsUseCase(job_repo)
|
use_case = ListJobsUseCase(job_repo)
|
||||||
jobs = use_case.execute(
|
jobs = use_case.execute(
|
||||||
@@ -204,7 +197,7 @@ def get_job_statistics(
|
|||||||
project_repository: Any = Depends(get_project_repository),
|
project_repository: Any = Depends(get_project_repository),
|
||||||
) -> JobStatisticsResponse:
|
) -> JobStatisticsResponse:
|
||||||
"""获取项目任务统计摘要。"""
|
"""获取项目任务统计摘要。"""
|
||||||
_check_project_access(project_id, authenticated_user.user.id, project_repository)
|
check_project_access(project_id, authenticated_user.user.id, project_repository)
|
||||||
|
|
||||||
use_case = GetJobStatisticsUseCase(job_repo)
|
use_case = GetJobStatisticsUseCase(job_repo)
|
||||||
stats = use_case.execute(project_id)
|
stats = use_case.execute(project_id)
|
||||||
|
|||||||
@@ -33,6 +33,8 @@ from packages.application.recipe.use_cases import (
|
|||||||
)
|
)
|
||||||
from packages.ports.user_repository import UserRepository
|
from packages.ports.user_repository import UserRepository
|
||||||
|
|
||||||
|
from app.api.routes._helpers import get_user_plan
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
@@ -40,13 +42,6 @@ def _get_recipe_repository(session: Session = Depends(get_db_session)) -> SQLAlc
|
|||||||
return SQLAlchemyRecipeRepository(session)
|
return SQLAlchemyRecipeRepository(session)
|
||||||
|
|
||||||
|
|
||||||
def _get_user_plan(user_id: str, user_repository: UserRepository) -> str:
|
|
||||||
user = user_repository.find_by_id(user_id)
|
|
||||||
if user is None:
|
|
||||||
return "free"
|
|
||||||
return getattr(user, "subscription_plan", "free") or "free"
|
|
||||||
|
|
||||||
|
|
||||||
def _item_to_response(item) -> RecipeItemResponse:
|
def _item_to_response(item) -> RecipeItemResponse:
|
||||||
return RecipeItemResponse(
|
return RecipeItemResponse(
|
||||||
id=item.id,
|
id=item.id,
|
||||||
@@ -194,7 +189,7 @@ def use_recipe(
|
|||||||
user_repository: UserRepository = Depends(get_user_repository),
|
user_repository: UserRepository = Depends(get_user_repository),
|
||||||
) -> UseRecipeResponse:
|
) -> UseRecipeResponse:
|
||||||
user_id = authenticated_user.user.id
|
user_id = authenticated_user.user.id
|
||||||
plan_name = _get_user_plan(user_id, user_repository)
|
plan_name = get_user_plan(user_id, user_repository)
|
||||||
use_case = UseRecipeUseCase(recipe_repository)
|
use_case = UseRecipeUseCase(recipe_repository)
|
||||||
try:
|
try:
|
||||||
result = use_case.execute(recipe_id, user_id, user_plan=plan_name)
|
result = use_case.execute(recipe_id, user_id, user_plan=plan_name)
|
||||||
|
|||||||
@@ -232,7 +232,7 @@ async def payment_callback(
|
|||||||
|
|
||||||
# 创建账单记录
|
# 创建账单记录
|
||||||
record_id = uuid.uuid4().hex
|
record_id = uuid.uuid4().hex
|
||||||
record = repo.create(
|
repo.create(
|
||||||
{
|
{
|
||||||
"id": record_id,
|
"id": record_id,
|
||||||
"user_id": user_id,
|
"user_id": user_id,
|
||||||
|
|||||||
@@ -28,6 +28,8 @@ from packages.application.title_library.use_cases import (
|
|||||||
)
|
)
|
||||||
from packages.ports.user_repository import UserRepository
|
from packages.ports.user_repository import UserRepository
|
||||||
|
|
||||||
|
from app.api.routes._helpers import get_user_plan
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
@@ -51,13 +53,6 @@ def _to_response(item) -> TitleLibraryItemResponse:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def _get_user_plan(user_id: str, user_repository: UserRepository) -> str:
|
|
||||||
user = user_repository.find_by_id(user_id)
|
|
||||||
if user is None:
|
|
||||||
return "free"
|
|
||||||
return getattr(user, "subscription_plan", "free") or "free"
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("", response_model=ListTitleLibraryResponse)
|
@router.get("", response_model=ListTitleLibraryResponse)
|
||||||
def list_titles(
|
def list_titles(
|
||||||
category: Optional[str] = Query(None),
|
category: Optional[str] = Query(None),
|
||||||
@@ -98,7 +93,7 @@ def create_title(
|
|||||||
user_repository: UserRepository = Depends(get_user_repository),
|
user_repository: UserRepository = Depends(get_user_repository),
|
||||||
) -> TitleLibraryItemResponse:
|
) -> TitleLibraryItemResponse:
|
||||||
user_id = authenticated_user.user.id
|
user_id = authenticated_user.user.id
|
||||||
plan_name = _get_user_plan(user_id, user_repository)
|
plan_name = get_user_plan(user_id, user_repository)
|
||||||
command = CreateTitleLibraryCommand(
|
command = CreateTitleLibraryCommand(
|
||||||
user_id=user_id,
|
user_id=user_id,
|
||||||
name=request.name,
|
name=request.name,
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import logging
|
import logging
|
||||||
from typing import Annotated, Any
|
from typing import Any
|
||||||
from uuid import uuid4
|
from uuid import uuid4
|
||||||
|
|
||||||
from app.auth import AuthenticatedUser, get_current_user
|
from app.auth import AuthenticatedUser, get_current_user
|
||||||
@@ -17,12 +17,13 @@ from app.schemas.upload import (
|
|||||||
DirectUploadCompleteResponse,
|
DirectUploadCompleteResponse,
|
||||||
DirectUploadPrepareRequest,
|
DirectUploadPrepareRequest,
|
||||||
DirectUploadPrepareResponse,
|
DirectUploadPrepareResponse,
|
||||||
UploadAssetRequest,
|
|
||||||
UploadAssetResponse,
|
UploadAssetResponse,
|
||||||
)
|
)
|
||||||
from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile, status
|
from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile, status
|
||||||
|
|
||||||
from packages.application import GetProjectUseCase, SubmitIngestJobCommand, SubmitIngestJobUseCase
|
from packages.application import SubmitIngestJobCommand, SubmitIngestJobUseCase
|
||||||
|
|
||||||
|
from app.api.routes._helpers import require_project_and_library
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -80,21 +81,6 @@ def _validate_mime_type(content_type: str | None) -> str:
|
|||||||
return base_type
|
return base_type
|
||||||
|
|
||||||
|
|
||||||
def _require_project_and_library(
|
|
||||||
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:
|
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Project not found")
|
|
||||||
|
|
||||||
libraries = asset_library_repository.find_by_project(project_id)
|
|
||||||
if not any(item.id == library_id for item in libraries):
|
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Asset library not found")
|
|
||||||
|
|
||||||
|
|
||||||
def _submit_ingest_job(
|
def _submit_ingest_job(
|
||||||
project_id: str,
|
project_id: str,
|
||||||
library_id: str,
|
library_id: str,
|
||||||
@@ -135,7 +121,7 @@ async def prepare_direct_upload(
|
|||||||
# P2-5: 服务端验证 MIME 类型
|
# P2-5: 服务端验证 MIME 类型
|
||||||
validated_content_type = _validate_mime_type(request.content_type)
|
validated_content_type = _validate_mime_type(request.content_type)
|
||||||
|
|
||||||
_require_project_and_library(
|
require_project_and_library(
|
||||||
request.project_id,
|
request.project_id,
|
||||||
request.library_id,
|
request.library_id,
|
||||||
project_repository,
|
project_repository,
|
||||||
@@ -183,7 +169,7 @@ async def complete_direct_upload(
|
|||||||
storage_service: OSSStorageService = Depends(get_storage_service),
|
storage_service: OSSStorageService = Depends(get_storage_service),
|
||||||
) -> DirectUploadCompleteResponse:
|
) -> DirectUploadCompleteResponse:
|
||||||
"""确认浏览器直传完成并创建导入任务。"""
|
"""确认浏览器直传完成并创建导入任务。"""
|
||||||
_require_project_and_library(
|
require_project_and_library(
|
||||||
request.project_id,
|
request.project_id,
|
||||||
request.library_id,
|
request.library_id,
|
||||||
project_repository,
|
project_repository,
|
||||||
@@ -252,7 +238,7 @@ async def upload_asset(
|
|||||||
storage_service: OSSStorageService = Depends(get_storage_service),
|
storage_service: OSSStorageService = Depends(get_storage_service),
|
||||||
) -> UploadAssetResponse:
|
) -> UploadAssetResponse:
|
||||||
"""上传素材文件并触发导入流水线。"""
|
"""上传素材文件并触发导入流水线。"""
|
||||||
_require_project_and_library(project_id, library_id, project_repository, asset_library_repository)
|
require_project_and_library(project_id, library_id, project_repository, asset_library_repository)
|
||||||
|
|
||||||
# ── 素材去重检测:上传前检查同素材库 + 同 file_hash ──
|
# ── 素材去重检测:上传前检查同素材库 + 同 file_hash ──
|
||||||
if file_hash:
|
if file_hash:
|
||||||
|
|||||||
@@ -28,7 +28,6 @@ from packages.application.voice_clone.use_cases import (
|
|||||||
VoiceCloneNotRetryableError,
|
VoiceCloneNotRetryableError,
|
||||||
)
|
)
|
||||||
from packages.application.voice_clone.workflow import (
|
from packages.application.voice_clone.workflow import (
|
||||||
VoiceCloneWorkflowError,
|
|
||||||
VoiceCloneWorkflowService,
|
VoiceCloneWorkflowService,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -39,6 +39,8 @@ from packages.application.voice_library.use_cases import (
|
|||||||
from packages.domain.preset_voices import PRESET_VOICES
|
from packages.domain.preset_voices import PRESET_VOICES
|
||||||
from packages.ports.user_repository import UserRepository
|
from packages.ports.user_repository import UserRepository
|
||||||
|
|
||||||
|
from app.api.routes._helpers import get_user_plan
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
@@ -118,13 +120,6 @@ def _preset_to_unified_response(preset) -> UnifiedVoiceItemResponse:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def _get_user_plan(user_id: str, user_repository: UserRepository) -> str:
|
|
||||||
user = user_repository.find_by_id(user_id)
|
|
||||||
if user is None:
|
|
||||||
return "free"
|
|
||||||
return getattr(user, "subscription_plan", "free") or "free"
|
|
||||||
|
|
||||||
|
|
||||||
# ==================== 统一配音列表(预置 + 克隆)====================
|
# ==================== 统一配音列表(预置 + 克隆)====================
|
||||||
|
|
||||||
|
|
||||||
@@ -260,7 +255,7 @@ def create_voice(
|
|||||||
user_repository: UserRepository = Depends(get_user_repository),
|
user_repository: UserRepository = Depends(get_user_repository),
|
||||||
) -> VoiceLibraryItemResponse:
|
) -> VoiceLibraryItemResponse:
|
||||||
user_id = authenticated_user.user.id
|
user_id = authenticated_user.user.id
|
||||||
plan_name = _get_user_plan(user_id, user_repository)
|
plan_name = get_user_plan(user_id, user_repository)
|
||||||
command = CreateVoiceLibraryCommand(
|
command = CreateVoiceLibraryCommand(
|
||||||
user_id=user_id,
|
user_id=user_id,
|
||||||
name=request.name,
|
name=request.name,
|
||||||
|
|||||||
+28
-2
@@ -25,7 +25,7 @@ class Settings(BaseSettings):
|
|||||||
DATABASE_POOL_SIZE: int = 20
|
DATABASE_POOL_SIZE: int = 20
|
||||||
DATABASE_MAX_OVERFLOW: int = 10 # 调整为合理值:pool_size(20) + max_overflow(10) = 最大30连接
|
DATABASE_MAX_OVERFLOW: int = 10 # 调整为合理值:pool_size(20) + max_overflow(10) = 最大30连接
|
||||||
DATABASE_POOL_TIMEOUT: int = 30
|
DATABASE_POOL_TIMEOUT: int = 30
|
||||||
DATABASE_POOL_RECYLE: int = 3600
|
DATABASE_POOL_RECYCLE: int = 3600
|
||||||
USE_IN_MEMORY_DB: bool = False
|
USE_IN_MEMORY_DB: bool = False
|
||||||
AUTO_CREATE_SCHEMA: bool = False
|
AUTO_CREATE_SCHEMA: bool = False
|
||||||
|
|
||||||
@@ -41,6 +41,11 @@ class Settings(BaseSettings):
|
|||||||
# 密钥轮换天数(到达此天数后建议更换密钥)
|
# 密钥轮换天数(到达此天数后建议更换密钥)
|
||||||
SECRET_ROTATION_DAYS: int = 90
|
SECRET_ROTATION_DAYS: int = 90
|
||||||
|
|
||||||
|
# JWT 算法与过期时间(与 .env.example 对齐)
|
||||||
|
JWT_ALGORITHM: str = "HS256"
|
||||||
|
JWT_ACCESS_TOKEN_EXPIRE_MINUTES: int = 30
|
||||||
|
JWT_REFRESH_TOKEN_EXPIRE_DAYS: int = 30
|
||||||
|
|
||||||
@field_validator("JWT_SECRET_KEY", mode="before")
|
@field_validator("JWT_SECRET_KEY", mode="before")
|
||||||
@classmethod
|
@classmethod
|
||||||
def validate_jwt_secret_key(cls, v):
|
def validate_jwt_secret_key(cls, v):
|
||||||
@@ -75,10 +80,31 @@ class Settings(BaseSettings):
|
|||||||
CELERY_RESULT_BACKEND: str = "redis://localhost:6379/1"
|
CELERY_RESULT_BACKEND: str = "redis://localhost:6379/1"
|
||||||
|
|
||||||
# OSS 七牛云相关
|
# OSS 七牛云相关
|
||||||
OSS_ENDPOINT: str = "oss-cn-hangzhou.aliiyuncs.com"
|
OSS_ENDPOINT: str = "oss-cn-hangzhou.aliyuncs.com"
|
||||||
OSS_ACCESS_KEY_ID: str = ""
|
OSS_ACCESS_KEY_ID: str = ""
|
||||||
OSS_ACCESS_KEY_SECRET: str = ""
|
OSS_ACCESS_KEY_SECRET: str = ""
|
||||||
OSS_BUCKET_NAME: str = "xiaoxia-autocut"
|
OSS_BUCKET_NAME: str = "xiaoxia-autocut"
|
||||||
|
|
||||||
|
@field_validator("OSS_ACCESS_KEY_ID", mode="before")
|
||||||
|
@classmethod
|
||||||
|
def validate_oss_access_key_id(cls, v):
|
||||||
|
if (v is None or v == "") and os.getenv("APP_ENV", "development") != "development":
|
||||||
|
raise ValueError(
|
||||||
|
"OSS_ACCESS_KEY_ID must be set via environment variable in non-development environments. "
|
||||||
|
"Check the server .env file (e.g. /var/lib/xiaoxia-saas-staging/.env)."
|
||||||
|
)
|
||||||
|
return v or ""
|
||||||
|
|
||||||
|
@field_validator("OSS_ACCESS_KEY_SECRET", mode="before")
|
||||||
|
@classmethod
|
||||||
|
def validate_oss_access_key_secret(cls, v):
|
||||||
|
if (v is None or v == "") and os.getenv("APP_ENV", "development") != "development":
|
||||||
|
raise ValueError(
|
||||||
|
"OSS_ACCESS_KEY_SECRET must be set via environment variable in non-development environments. "
|
||||||
|
"Check the server .env file (e.g. /var/lib/xiaoxia-saas-staging/.env)."
|
||||||
|
)
|
||||||
|
return v or ""
|
||||||
|
|
||||||
OSS_DIRECT_UPLOAD_MAX_MB: int = Field(
|
OSS_DIRECT_UPLOAD_MAX_MB: int = Field(
|
||||||
default=2000,
|
default=2000,
|
||||||
validation_alias=AliasChoices("OSS_DIRECT_UPLOAD_MAX_MB", "MAX_UPLOAD_SIZE_MB"),
|
validation_alias=AliasChoices("OSS_DIRECT_UPLOAD_MAX_MB", "MAX_UPLOAD_SIZE_MB"),
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
"""Core configuration package."""
|
|
||||||
@@ -34,13 +34,18 @@ class OSSStorageService:
|
|||||||
if has_key_id and has_key_secret:
|
if has_key_id and has_key_secret:
|
||||||
if oss2 is not None:
|
if oss2 is not None:
|
||||||
try:
|
try:
|
||||||
|
# P0-2 修复:oss2.Bucket 的 endpoint 必须带 https:// 前缀,
|
||||||
|
# 否则 sign_url 默认生成 HTTP URL。
|
||||||
|
bucket_endpoint = settings.OSS_ENDPOINT
|
||||||
|
if not bucket_endpoint.startswith(("http://", "https://")):
|
||||||
|
bucket_endpoint = f"https://{bucket_endpoint}"
|
||||||
auth = oss2.Auth(
|
auth = oss2.Auth(
|
||||||
settings.OSS_ACCESS_KEY_ID,
|
settings.OSS_ACCESS_KEY_ID,
|
||||||
settings.OSS_ACCESS_KEY_SECRET,
|
settings.OSS_ACCESS_KEY_SECRET,
|
||||||
)
|
)
|
||||||
self.bucket = oss2.Bucket(
|
self.bucket = oss2.Bucket(
|
||||||
auth,
|
auth,
|
||||||
settings.OSS_ENDPOINT,
|
bucket_endpoint,
|
||||||
settings.OSS_BUCKET_NAME,
|
settings.OSS_BUCKET_NAME,
|
||||||
)
|
)
|
||||||
logger.info(
|
logger.info(
|
||||||
@@ -64,6 +69,26 @@ class OSSStorageService:
|
|||||||
self.access_key_secret = settings.OSS_ACCESS_KEY_SECRET
|
self.access_key_secret = settings.OSS_ACCESS_KEY_SECRET
|
||||||
self.endpoint = settings.OSS_ENDPOINT
|
self.endpoint = settings.OSS_ENDPOINT
|
||||||
|
|
||||||
|
def diagnose(self) -> None:
|
||||||
|
"""启动诊断:输出 OSS 配置状态,帮助排查预签名 URL 问题。"""
|
||||||
|
key_id_display = (
|
||||||
|
f"{self.access_key_id[:4]}...{self.access_key_id[-4:]}" if len(self.access_key_id) > 8 else "(empty)"
|
||||||
|
)
|
||||||
|
logger.info(
|
||||||
|
"[OSS诊断] endpoint=%s bucket_name=%s access_key_id=%s",
|
||||||
|
self.endpoint,
|
||||||
|
self.bucket_name,
|
||||||
|
key_id_display,
|
||||||
|
)
|
||||||
|
if self.bucket is None:
|
||||||
|
logger.error(
|
||||||
|
"[OSS诊断] ❌ bucket=None — 预签名URL不可用!"
|
||||||
|
"原因: OSS_ACCESS_KEY_ID/OSS_ACCESS_KEY_SECRET 未配置或 oss2 未安装。"
|
||||||
|
"请检查服务器 .env 文件(如 /var/lib/xiaoxia-saas-staging/.env)"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
logger.info("[OSS诊断] ✅ bucket 已配置,预签名URL可用")
|
||||||
|
|
||||||
def _is_local_generated_url(self, storage_key_or_url: str) -> bool:
|
def _is_local_generated_url(self, storage_key_or_url: str) -> bool:
|
||||||
parsed = urlparse(storage_key_or_url)
|
parsed = urlparse(storage_key_or_url)
|
||||||
path = parsed.path if parsed.scheme else storage_key_or_url
|
path = parsed.path if parsed.scheme else storage_key_or_url
|
||||||
@@ -167,8 +192,7 @@ class OSSStorageService:
|
|||||||
if self._is_local_generated_url(storage_key_or_url):
|
if self._is_local_generated_url(storage_key_or_url):
|
||||||
return storage_key_or_url
|
return storage_key_or_url
|
||||||
logger.warning(
|
logger.warning(
|
||||||
"get_download_url: OSS bucket not configured, returning raw URL. "
|
"get_download_url: OSS bucket not configured, returning raw URL. " "storage_key_or_url=%s",
|
||||||
"storage_key_or_url=%s",
|
|
||||||
storage_key_or_url[:200],
|
storage_key_or_url[:200],
|
||||||
)
|
)
|
||||||
return self.get_url(self._normalize_storage_key(storage_key_or_url))
|
return self.get_url(self._normalize_storage_key(storage_key_or_url))
|
||||||
@@ -184,8 +208,7 @@ class OSSStorageService:
|
|||||||
return signed
|
return signed
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.exception(
|
logger.exception(
|
||||||
"get_download_url: sign_url failed, falling back to raw URL. "
|
"get_download_url: sign_url failed, falling back to raw URL. " "storage_key=%s",
|
||||||
"storage_key=%s",
|
|
||||||
storage_key[:200],
|
storage_key[:200],
|
||||||
)
|
)
|
||||||
return self.get_url(storage_key)
|
return self.get_url(storage_key)
|
||||||
@@ -256,4 +279,5 @@ def get_storage_service() -> OSSStorageService:
|
|||||||
global _storage_service
|
global _storage_service
|
||||||
if _storage_service is None:
|
if _storage_service is None:
|
||||||
_storage_service = OSSStorageService()
|
_storage_service = OSSStorageService()
|
||||||
|
_storage_service.diagnose()
|
||||||
return _storage_service
|
return _storage_service
|
||||||
|
|||||||
@@ -50,20 +50,8 @@ from packages.adapters.sqlalchemy_impl.voice_clone_profile_repository import (
|
|||||||
from packages.adapters.sqlalchemy_impl.voice_library_repository import (
|
from packages.adapters.sqlalchemy_impl.voice_library_repository import (
|
||||||
SQLAlchemyVoiceLibraryRepository,
|
SQLAlchemyVoiceLibraryRepository,
|
||||||
)
|
)
|
||||||
from packages.ports.asset_library_repository import AssetLibraryRepository
|
|
||||||
from packages.ports.asset_repository import AssetRepository
|
|
||||||
from packages.ports.classification_job_repository import ClassificationJobRepository
|
|
||||||
from packages.ports.duplication_repository import DuplicationRecordRepository
|
|
||||||
from packages.ports.generated_video_repository import GeneratedVideoRepository
|
|
||||||
from packages.ports.generation_task_repository import GenerationTaskRepository
|
|
||||||
from packages.ports.ingest_job_repository import IngestJobRepository
|
|
||||||
from packages.ports.job_repository import JobRepository
|
|
||||||
from packages.ports.project_repository import ProjectRepository
|
|
||||||
from packages.ports.tag_repository import TagRepository
|
from packages.ports.tag_repository import TagRepository
|
||||||
from packages.ports.title_library_repository import TitleLibraryRepository
|
|
||||||
from packages.ports.user_repository import UserRepository
|
from packages.ports.user_repository import UserRepository
|
||||||
from packages.ports.voice_clone_profile_repository import VoiceCloneProfileRepository
|
|
||||||
from packages.ports.voice_library_repository import VoiceLibraryRepository
|
|
||||||
|
|
||||||
_engine, _SessionLocal = build_session_factory(settings.DATABASE_URL)
|
_engine, _SessionLocal = build_session_factory(settings.DATABASE_URL)
|
||||||
|
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ from __future__ import annotations
|
|||||||
from app.auth import AuthenticatedUser
|
from app.auth import AuthenticatedUser
|
||||||
from app.auth import get_current_user as get_authenticated_user
|
from app.auth import get_current_user as get_authenticated_user
|
||||||
from app.dependencies import get_user_repository
|
from app.dependencies import get_user_repository
|
||||||
from fastapi import Depends
|
from fastapi import Depends, HTTPException
|
||||||
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
||||||
|
|
||||||
from packages.domain.entities import User
|
from packages.domain.entities import User
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import logging
|
|||||||
import time
|
import time
|
||||||
from typing import Callable
|
from typing import Callable
|
||||||
|
|
||||||
from fastapi import Request, Response
|
from fastapi import Request
|
||||||
from starlette.middleware.base import BaseHTTPMiddleware
|
from starlette.middleware.base import BaseHTTPMiddleware
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from typing import List, Optional
|
from typing import Optional
|
||||||
|
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ from packages.adapters.sqlalchemy_impl import (
|
|||||||
)
|
)
|
||||||
from packages.domain.asset import AssetType
|
from packages.domain.asset import AssetType
|
||||||
from packages.domain.classification import AssetClassification
|
from packages.domain.classification import AssetClassification
|
||||||
from packages.domain.edit_plan_clip import EditPlanClip, EditPlanClipStatus
|
from packages.domain.edit_plan_clip import EditPlanClip
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|||||||
@@ -18,7 +18,6 @@ from packages.adapters.sqlalchemy_impl import (
|
|||||||
)
|
)
|
||||||
from packages.domain.edit_plan import EditPlan, EditPlanStatus
|
from packages.domain.edit_plan import EditPlan, EditPlanStatus
|
||||||
from packages.domain.edit_plan_clip import EditPlanClip, EditPlanClipStatus
|
from packages.domain.edit_plan_clip import EditPlanClip, EditPlanClipStatus
|
||||||
from packages.domain.generation_task import GenerationTaskStatus
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|||||||
@@ -13,7 +13,6 @@ from __future__ import annotations
|
|||||||
import logging
|
import logging
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from sqlalchemy.orm import Session
|
|
||||||
|
|
||||||
from packages.application.jobs import (
|
from packages.application.jobs import (
|
||||||
CancelJobUseCase,
|
CancelJobUseCase,
|
||||||
|
|||||||
@@ -13,7 +13,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
from typing import Any, List, Optional
|
from typing import Any, List
|
||||||
|
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
|||||||
@@ -16,7 +16,6 @@ FFmpeg 视频合成编排服务:
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
import shutil
|
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
@@ -28,7 +27,7 @@ from packages.adapters.sqlalchemy_impl.edit_plan_clip_repository import (
|
|||||||
from packages.adapters.sqlalchemy_impl.edit_plan_repository import (
|
from packages.adapters.sqlalchemy_impl.edit_plan_repository import (
|
||||||
SQLAlchemyEditPlanRepository,
|
SQLAlchemyEditPlanRepository,
|
||||||
)
|
)
|
||||||
from packages.domain.edit_plan import EditPlan, EditPlanStatus
|
from packages.domain.edit_plan import EditPlanStatus
|
||||||
from packages.domain.edit_plan_clip import EditPlanClip, EditPlanClipStatus
|
from packages.domain.edit_plan_clip import EditPlanClip, EditPlanClipStatus
|
||||||
from packages.domain.template_clip_config import TransitionEffect
|
from packages.domain.template_clip_config import TransitionEffect
|
||||||
|
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
"""Packages root."""
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
"""Adapters package for external implementations."""
|
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
from sqlalchemy import JSON, Boolean, Column, DateTime, Float, Integer, String, Text, UniqueConstraint, create_engine
|
from sqlalchemy import JSON, Boolean, Column, DateTime, Float, Integer, String, Text, UniqueConstraint
|
||||||
from sqlalchemy.orm import declarative_base
|
from sqlalchemy.orm import declarative_base
|
||||||
|
|
||||||
Base = declarative_base()
|
Base = declarative_base()
|
||||||
|
|||||||
@@ -27,7 +27,6 @@ from .generated_videos import (
|
|||||||
from .generation_tasks import (
|
from .generation_tasks import (
|
||||||
CreateGenerationTaskCommand,
|
CreateGenerationTaskCommand,
|
||||||
CreateGenerationTaskUseCase,
|
CreateGenerationTaskUseCase,
|
||||||
GetGenerationTaskUseCase,
|
|
||||||
)
|
)
|
||||||
from .ingest_jobs import SubmitIngestJobCommand, SubmitIngestJobUseCase
|
from .ingest_jobs import SubmitIngestJobCommand, SubmitIngestJobUseCase
|
||||||
from .jobs import (
|
from .jobs import (
|
||||||
|
|||||||
@@ -12,10 +12,9 @@ JWT 处理器委托层
|
|||||||
payload = jwt_handler.verify_access_token(token)
|
payload = jwt_handler.verify_access_token(token)
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from datetime import datetime, timedelta
|
|
||||||
from typing import Any, Dict, Optional
|
from typing import Any, Dict, Optional
|
||||||
|
|
||||||
from packages.application.auth.jwt_service import JWTConfig, JWTService, TokenType
|
from packages.application.auth.jwt_service import JWTConfig, JWTService
|
||||||
|
|
||||||
|
|
||||||
class JWTHandler:
|
class JWTHandler:
|
||||||
|
|||||||
@@ -208,10 +208,10 @@ def _get_jwt_service():
|
|||||||
kw = dict(secret_key=settings.JWT_SECRET_KEY)
|
kw = dict(secret_key=settings.JWT_SECRET_KEY)
|
||||||
if hasattr(settings, "JWT_ALGORITHM"):
|
if hasattr(settings, "JWT_ALGORITHM"):
|
||||||
kw["algorithm"] = settings.JWT_ALGORITHM
|
kw["algorithm"] = settings.JWT_ALGORITHM
|
||||||
if hasattr(settings, "ACCESS_TOKEN_EXPIRE_MINUTES"):
|
if hasattr(settings, "JWT_ACCESS_TOKEN_EXPIRE_MINUTES"):
|
||||||
kw["access_token_expire_minutes"] = settings.ACCESS_TOKEN_EXPIRE_MINUTES
|
kw["access_token_expire_minutes"] = settings.JWT_ACCESS_TOKEN_EXPIRE_MINUTES
|
||||||
if hasattr(settings, "REFRESH_TOKEN_EXPIRE_DAYS"):
|
if hasattr(settings, "JWT_REFRESH_TOKEN_EXPIRE_DAYS"):
|
||||||
kw["refresh_token_expire_days"] = settings.REFRESH_TOKEN_EXPIRE_DAYS
|
kw["refresh_token_expire_days"] = settings.JWT_REFRESH_TOKEN_EXPIRE_DAYS
|
||||||
_jwt_service_instance = JWTService(JWTConfig(**kw))
|
_jwt_service_instance = JWTService(JWTConfig(**kw))
|
||||||
return _jwt_service_instance
|
return _jwt_service_instance
|
||||||
|
|
||||||
|
|||||||
@@ -293,7 +293,7 @@ class LogoutUseCase:
|
|||||||
try:
|
try:
|
||||||
if request.logout_all_devices:
|
if request.logout_all_devices:
|
||||||
# 删除所有设备的 session
|
# 删除所有设备的 session
|
||||||
count = self.session_store.delete_all_user_sessions(request.user_id)
|
self.session_store.delete_all_user_sessions(request.user_id)
|
||||||
return True, None
|
return True, None
|
||||||
else:
|
else:
|
||||||
# 删除当前 session
|
# 删除当前 session
|
||||||
|
|||||||
@@ -85,8 +85,6 @@ class PasswordHasher:
|
|||||||
True 如果需要重新哈希
|
True 如果需要重新哈希
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
hashed_bytes = hashed_password.encode("utf-8")
|
|
||||||
current_rounds = bcrypt.getsalt(hashed_bytes)
|
|
||||||
|
|
||||||
# 提取当前的 cost factor
|
# 提取当前的 cost factor
|
||||||
# bcrypt hash 格式: $2b$rounds$salt+hash
|
# bcrypt hash 格式: $2b$rounds$salt+hash
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import secrets
|
import secrets
|
||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime, timezone
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
from uuid import uuid4
|
from uuid import uuid4
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
from math import ceil
|
from math import ceil
|
||||||
from typing import Generic, List, Optional, TypeVar
|
from typing import Generic, List, TypeVar
|
||||||
|
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
|||||||
@@ -7,9 +7,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import logging
|
import logging
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from datetime import datetime, timezone
|
|
||||||
from typing import Any
|
from typing import Any
|
||||||
from uuid import uuid4
|
|
||||||
|
|
||||||
from packages.domain.job import Job, JobStatus, JobType
|
from packages.domain.job import Job, JobStatus, JobType
|
||||||
from packages.ports.job_repository import JobRepository
|
from packages.ports.job_repository import JobRepository
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ from typing import List, Optional
|
|||||||
from packages.adapters.sqlalchemy_impl.recipe_repository import SQLAlchemyRecipeRepository
|
from packages.adapters.sqlalchemy_impl.recipe_repository import SQLAlchemyRecipeRepository
|
||||||
from packages.application.recipe.commands import (
|
from packages.application.recipe.commands import (
|
||||||
CreateRecipeCommand,
|
CreateRecipeCommand,
|
||||||
RecipeItemCommand,
|
|
||||||
UpdateRecipeCommand,
|
UpdateRecipeCommand,
|
||||||
)
|
)
|
||||||
from packages.domain.recipe import Recipe, RecipeItem
|
from packages.domain.recipe import Recipe, RecipeItem
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
"""TTS Job application layer."""
|
|
||||||
@@ -148,7 +148,6 @@ class TTSStreamingService:
|
|||||||
|
|
||||||
# 并发合成所有分段,按顺序流式推送
|
# 并发合成所有分段,按顺序流式推送
|
||||||
queue: asyncio.Queue[tuple[int, Optional[bytes], Optional[str]]] = asyncio.Queue()
|
queue: asyncio.Queue[tuple[int, Optional[bytes], Optional[str]]] = asyncio.Queue()
|
||||||
completed_count = 0
|
|
||||||
|
|
||||||
async def _synthesize_one(idx: int, seg_text: str) -> None:
|
async def _synthesize_one(idx: int, seg_text: str) -> None:
|
||||||
"""合成单个分段并放入队列。"""
|
"""合成单个分段并放入队列。"""
|
||||||
|
|||||||
@@ -25,9 +25,9 @@ from packages.application.cosyvoice_service import (
|
|||||||
CosyVoiceError,
|
CosyVoiceError,
|
||||||
CosyVoiceService,
|
CosyVoiceService,
|
||||||
)
|
)
|
||||||
from packages.application.tts_job.audio_merger import AudioMergeError, AudioMerger
|
from packages.application.tts_job.audio_merger import AudioMerger
|
||||||
from packages.application.tts_job.text_splitter import split_text
|
from packages.application.tts_job.text_splitter import split_text
|
||||||
from packages.domain.tts_job import TTSJob, TTSJobStatus
|
from packages.domain.tts_job import TTSJob
|
||||||
from packages.ports.tts_job_repository import TTSJobRepository
|
from packages.ports.tts_job_repository import TTSJobRepository
|
||||||
from packages.shared.storage import SharedStorageService, get_shared_storage_service
|
from packages.shared.storage import SharedStorageService, get_shared_storage_service
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,6 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import uuid
|
|
||||||
from typing import List, Optional
|
from typing import List, Optional
|
||||||
|
|
||||||
from packages.domain.voice_clone_profile import VoiceCloneProfile
|
from packages.domain.voice_clone_profile import VoiceCloneProfile
|
||||||
|
|||||||
@@ -10,7 +10,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
from typing import Any, Optional
|
from typing import Optional
|
||||||
|
|
||||||
from packages.application.cosyvoice_service import (
|
from packages.application.cosyvoice_service import (
|
||||||
CosyVoiceAuthError,
|
CosyVoiceAuthError,
|
||||||
@@ -21,9 +21,8 @@ from packages.application.voice_clone.use_cases import (
|
|||||||
CreateVoiceCloneUseCase,
|
CreateVoiceCloneUseCase,
|
||||||
RetryVoiceCloneUseCase,
|
RetryVoiceCloneUseCase,
|
||||||
VoiceCloneNotFoundError,
|
VoiceCloneNotFoundError,
|
||||||
VoiceCloneNotRetryableError,
|
|
||||||
)
|
)
|
||||||
from packages.domain.voice_clone_profile import VoiceCloneProfile, VoiceCloneStatus
|
from packages.domain.voice_clone_profile import VoiceCloneProfile
|
||||||
from packages.ports.voice_clone_profile_repository import VoiceCloneProfileRepository
|
from packages.ports.voice_clone_profile_repository import VoiceCloneProfileRepository
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|||||||
@@ -13,7 +13,6 @@ else:
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
from typing import Any
|
|
||||||
from uuid import uuid4
|
from uuid import uuid4
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import copy
|
import copy
|
||||||
from enum import Enum
|
from enum import Enum
|
||||||
from typing import List, Optional
|
from typing import Optional
|
||||||
|
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import logging
|
import logging
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from typing import Any, Dict, Optional, Set
|
from typing import Dict, Optional
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ from __future__ import annotations
|
|||||||
import logging
|
import logging
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from enum import Enum
|
from enum import Enum
|
||||||
from typing import Any, Callable, Dict, List, Optional, Set
|
from typing import Any, Callable, Dict, List, Optional
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
from abc import ABC, abstractmethod
|
from abc import ABC, abstractmethod
|
||||||
|
|
||||||
from packages.domain import AssetLibrary, AssetLibraryKind
|
from packages.domain import AssetLibrary
|
||||||
|
|
||||||
|
|
||||||
class AssetLibraryRepository(ABC):
|
class AssetLibraryRepository(ABC):
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ class SharedSettings(BaseSettings):
|
|||||||
celery_result_backend: str = "redis://localhost:6379/1"
|
celery_result_backend: str = "redis://localhost:6379/1"
|
||||||
|
|
||||||
# OSS Aliyun
|
# OSS Aliyun
|
||||||
oss_endpoint: str = "oss-cn-hangzhou.aliiyuncs.com"
|
oss_endpoint: str = "oss-cn-hangzhou.aliyuncs.com"
|
||||||
oss_access_key_id: str = ""
|
oss_access_key_id: str = ""
|
||||||
oss_access_key_secret: str = ""
|
oss_access_key_secret: str = ""
|
||||||
oss_bucket_name: str = "xiaoxia-autocut"
|
oss_bucket_name: str = "xiaoxia-autocut"
|
||||||
|
|||||||
@@ -247,3 +247,4 @@ def main() -> int:
|
|||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
sys.exit(main())
|
sys.exit(main())
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,158 @@
|
|||||||
|
"""P0-2 修复:OSS 凭证验证 + 启动诊断。
|
||||||
|
|
||||||
|
验证:
|
||||||
|
1. 非开发环境 OSS_ACCESS_KEY_ID/SECRET 为空时启动失败
|
||||||
|
2. 开发环境允许空凭证
|
||||||
|
3. diagnose() 方法正确输出配置状态
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
|
||||||
|
def _fresh_settings(env: str):
|
||||||
|
"""清除 config 模块缓存,以指定 APP_ENV 重新导入 Settings。
|
||||||
|
|
||||||
|
为非开发环境预设 OSS 环境变量,确保模块级 get_settings() 能成功完成导入。
|
||||||
|
测试方法内可根据需要清除这些变量来测试验证器。
|
||||||
|
"""
|
||||||
|
for mod_name in [m for m in list(sys.modules) if "app.config" in m]:
|
||||||
|
del sys.modules[mod_name]
|
||||||
|
os.environ["APP_ENV"] = env
|
||||||
|
# 非开发环境下,为模块级导入提供有效凭证(避免导入时验证失败)
|
||||||
|
if env != "development":
|
||||||
|
os.environ.setdefault("OSS_ACCESS_KEY_ID", "test-key-for-import")
|
||||||
|
os.environ.setdefault("OSS_ACCESS_KEY_SECRET", "test-secret-for-import")
|
||||||
|
# 重置单例,让测试方法自行控制实例化
|
||||||
|
from apps.api.app import config as _cfg
|
||||||
|
from apps.api.app.config import Settings
|
||||||
|
|
||||||
|
_cfg._settings = None
|
||||||
|
return Settings
|
||||||
|
|
||||||
|
|
||||||
|
class TestOSSCredentialValidation:
|
||||||
|
"""测试 OSS 凭证验证器(直接调用验证器类方法)。"""
|
||||||
|
|
||||||
|
def test_empty_oss_key_id_rejected_in_staging(self):
|
||||||
|
"""非开发环境 OSS_ACCESS_KEY_ID 为空应报错。"""
|
||||||
|
Settings = _fresh_settings("staging")
|
||||||
|
with pytest.raises(Exception, match="OSS_ACCESS_KEY_ID"):
|
||||||
|
Settings.validate_oss_access_key_id("")
|
||||||
|
|
||||||
|
def test_empty_oss_key_secret_rejected_in_staging(self):
|
||||||
|
"""非开发环境 OSS_ACCESS_KEY_SECRET 为空应报错。"""
|
||||||
|
Settings = _fresh_settings("staging")
|
||||||
|
with pytest.raises(Exception, match="OSS_ACCESS_KEY_SECRET"):
|
||||||
|
Settings.validate_oss_access_key_secret("")
|
||||||
|
|
||||||
|
def test_empty_oss_credentials_allowed_in_development(self):
|
||||||
|
"""开发环境允许空 OSS 凭证。"""
|
||||||
|
Settings = _fresh_settings("development")
|
||||||
|
assert Settings.validate_oss_access_key_id("") == ""
|
||||||
|
assert Settings.validate_oss_access_key_secret("") == ""
|
||||||
|
|
||||||
|
def test_valid_credentials_pass_validation(self):
|
||||||
|
"""有效凭证应通过验证。"""
|
||||||
|
Settings = _fresh_settings("staging")
|
||||||
|
assert Settings.validate_oss_access_key_id("test-key-id") == "test-key-id"
|
||||||
|
assert Settings.validate_oss_access_key_secret("test-key-secret") == "test-key-secret"
|
||||||
|
|
||||||
|
def test_valid_credentials_instantiation_succeeds(self):
|
||||||
|
"""有效凭证应能成功创建 Settings 实例。"""
|
||||||
|
os.environ.pop("OSS_ACCESS_KEY_ID", None)
|
||||||
|
os.environ.pop("OSS_ACCESS_KEY_SECRET", None)
|
||||||
|
Settings = _fresh_settings("staging")
|
||||||
|
os.environ["OSS_ACCESS_KEY_ID"] = "test-key-id"
|
||||||
|
os.environ["OSS_ACCESS_KEY_SECRET"] = "test-key-secret"
|
||||||
|
s = Settings(_env_file=None)
|
||||||
|
assert s.OSS_ACCESS_KEY_ID == "test-key-id"
|
||||||
|
assert s.OSS_ACCESS_KEY_SECRET == "test-key-secret"
|
||||||
|
|
||||||
|
|
||||||
|
class TestOSSDiagnose:
|
||||||
|
"""测试 OSSStorageService.diagnose() 方法。"""
|
||||||
|
|
||||||
|
@patch("apps.api.app.core.storage.oss2", None)
|
||||||
|
@patch("apps.api.app.core.storage.get_settings")
|
||||||
|
def test_diagnose_logs_error_when_bucket_none(self, mock_settings, caplog):
|
||||||
|
"""bucket=None 时 diagnose 应输出 ERROR 日志。"""
|
||||||
|
from apps.api.app.core.storage import OSSStorageService
|
||||||
|
|
||||||
|
mock_settings.return_value.OSS_BUCKET_NAME = "test-bucket"
|
||||||
|
mock_settings.return_value.OSS_ENDPOINT = "oss-cn-test.com"
|
||||||
|
mock_settings.return_value.OSS_ACCESS_KEY_ID = ""
|
||||||
|
mock_settings.return_value.OSS_ACCESS_KEY_SECRET = ""
|
||||||
|
|
||||||
|
service = OSSStorageService()
|
||||||
|
assert service.bucket is None
|
||||||
|
|
||||||
|
with caplog.at_level(logging.ERROR, logger="apps.api.app.core.storage"):
|
||||||
|
service.diagnose()
|
||||||
|
|
||||||
|
assert any("❌" in record.message for record in caplog.records)
|
||||||
|
|
||||||
|
@patch("apps.api.app.core.storage.oss2")
|
||||||
|
@patch("apps.api.app.core.storage.get_settings")
|
||||||
|
def test_diagnose_logs_success_when_bucket_configured(self, mock_settings, mock_oss2, caplog):
|
||||||
|
"""bucket 已配置时 diagnose 应输出成功日志。"""
|
||||||
|
from apps.api.app.core.storage import OSSStorageService
|
||||||
|
|
||||||
|
mock_settings.return_value.OSS_BUCKET_NAME = "test-bucket"
|
||||||
|
mock_settings.return_value.OSS_ENDPOINT = "oss-cn-test.com"
|
||||||
|
mock_settings.return_value.OSS_ACCESS_KEY_ID = "test-key-id"
|
||||||
|
mock_settings.return_value.OSS_ACCESS_KEY_SECRET = "test-key-secret"
|
||||||
|
mock_oss2.Bucket.return_value = MagicMock()
|
||||||
|
|
||||||
|
service = OSSStorageService()
|
||||||
|
assert service.bucket is not None
|
||||||
|
|
||||||
|
with caplog.at_level(logging.INFO, logger="apps.api.app.core.storage"):
|
||||||
|
service.diagnose()
|
||||||
|
|
||||||
|
assert any("OSS诊断" in record.message for record in caplog.records)
|
||||||
|
|
||||||
|
|
||||||
|
class TestOSSHTTPSEndpoint:
|
||||||
|
"""测试 P0-2 真正根因:sign_url 必须返回 HTTPS URL。"""
|
||||||
|
|
||||||
|
@patch("apps.api.app.core.storage.oss2")
|
||||||
|
@patch("apps.api.app.core.storage.get_settings")
|
||||||
|
def test_endpoint_without_scheme_gets_https_prefix(self, mock_settings, mock_oss2):
|
||||||
|
"""endpoint 无 scheme 时应自动加 https://,确保 sign_url 生成 HTTPS URL。"""
|
||||||
|
from apps.api.app.core.storage import OSSStorageService
|
||||||
|
|
||||||
|
mock_settings.return_value.OSS_BUCKET_NAME = "test-bucket"
|
||||||
|
mock_settings.return_value.OSS_ENDPOINT = "oss-cn-hangzhou.aliyuncs.com"
|
||||||
|
mock_settings.return_value.OSS_ACCESS_KEY_ID = "test-key-id"
|
||||||
|
mock_settings.return_value.OSS_ACCESS_KEY_SECRET = "test-key-secret"
|
||||||
|
mock_oss2.Bucket.return_value = MagicMock()
|
||||||
|
|
||||||
|
OSSStorageService()
|
||||||
|
|
||||||
|
# 验证传给 oss2.Bucket 的 endpoint 带了 https://
|
||||||
|
call_args = mock_oss2.Bucket.call_args
|
||||||
|
endpoint_passed = call_args[0][1] # 第二个位置参数
|
||||||
|
assert endpoint_passed == "https://oss-cn-hangzhou.aliyuncs.com"
|
||||||
|
|
||||||
|
@patch("apps.api.app.core.storage.oss2")
|
||||||
|
@patch("apps.api.app.core.storage.get_settings")
|
||||||
|
def test_endpoint_with_existing_https_not_doubled(self, mock_settings, mock_oss2):
|
||||||
|
"""endpoint 已有 https:// 时不应重复添加。"""
|
||||||
|
from apps.api.app.core.storage import OSSStorageService
|
||||||
|
|
||||||
|
mock_settings.return_value.OSS_BUCKET_NAME = "test-bucket"
|
||||||
|
mock_settings.return_value.OSS_ENDPOINT = "https://oss-cn-hangzhou.aliyuncs.com"
|
||||||
|
mock_settings.return_value.OSS_ACCESS_KEY_ID = "test-key-id"
|
||||||
|
mock_settings.return_value.OSS_ACCESS_KEY_SECRET = "test-key-secret"
|
||||||
|
mock_oss2.Bucket.return_value = MagicMock()
|
||||||
|
|
||||||
|
OSSStorageService()
|
||||||
|
|
||||||
|
call_args = mock_oss2.Bucket.call_args
|
||||||
|
endpoint_passed = call_args[0][1]
|
||||||
|
assert endpoint_passed == "https://oss-cn-hangzhou.aliyuncs.com"
|
||||||
Reference in New Issue
Block a user