style: apply black formatting to pass CI validation (#126)
CI/CD Pipeline / Validate Code Quality And Tests (push) Has been cancelled
CI/CD Pipeline / Frontend Lint (push) Has been cancelled
Deploy / Deploy Staging (push) Has been cancelled
Deploy / Build Production Runtime Images (push) Has been cancelled
Deploy / Deploy Production (push) Has been cancelled
Deploy / Production Browser E2E (push) Has been cancelled
CI/CD Pipeline / Validate Code Quality And Tests (push) Has been cancelled
CI/CD Pipeline / Frontend Lint (push) Has been cancelled
Deploy / Deploy Staging (push) Has been cancelled
Deploy / Build Production Runtime Images (push) Has been cancelled
Deploy / Deploy Production (push) Has been cancelled
Deploy / Production Browser E2E (push) Has been cancelled
This commit was merged in pull request #126.
This commit is contained in:
@@ -4,25 +4,25 @@ Revision ID: 007
|
||||
Revises: 006
|
||||
Create Date: 2026-06-26
|
||||
"""
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
# revision identifiers
|
||||
revision = '007'
|
||||
down_revision = '006'
|
||||
revision = "007"
|
||||
down_revision = "006"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column(
|
||||
'generation_tasks',
|
||||
sa.Column('editing_mode', sa.String(20), nullable=False, server_default='one_take')
|
||||
"generation_tasks", sa.Column("editing_mode", sa.String(20), nullable=False, server_default="one_take")
|
||||
)
|
||||
# 添加索引以支持查询
|
||||
op.create_index('ix_generation_tasks_editing_mode', 'generation_tasks', ['editing_mode'])
|
||||
op.create_index("ix_generation_tasks_editing_mode", "generation_tasks", ["editing_mode"])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index('ix_generation_tasks_editing_mode', table_name='generation_tasks')
|
||||
op.drop_column('generation_tasks', 'editing_mode')
|
||||
op.drop_index("ix_generation_tasks_editing_mode", table_name="generation_tasks")
|
||||
op.drop_column("generation_tasks", "editing_mode")
|
||||
|
||||
@@ -4,6 +4,7 @@ Revision ID: 008
|
||||
Revises: 007
|
||||
Create Date: 2024-06-26
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
from alembic import op
|
||||
@@ -16,20 +17,11 @@ depends_on = None
|
||||
|
||||
def upgrade() -> None:
|
||||
# Add video_fingerprint column as JSON text
|
||||
op.add_column(
|
||||
"generated_videos",
|
||||
sa.Column("video_fingerprint", sa.Text(), nullable=True)
|
||||
)
|
||||
op.add_column("generated_videos", sa.Column("video_fingerprint", sa.Text(), nullable=True))
|
||||
# Add is_duplicate column
|
||||
op.add_column(
|
||||
"generated_videos",
|
||||
sa.Column("is_duplicate", sa.Boolean(), nullable=False, server_default="false")
|
||||
)
|
||||
op.add_column("generated_videos", sa.Column("is_duplicate", sa.Boolean(), nullable=False, server_default="false"))
|
||||
# Add duplicate_of column for tracking original video
|
||||
op.add_column(
|
||||
"generated_videos",
|
||||
sa.Column("duplicate_of", sa.String(32), nullable=True)
|
||||
)
|
||||
op.add_column("generated_videos", sa.Column("duplicate_of", sa.String(32), nullable=True))
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
|
||||
@@ -11,6 +11,7 @@ This migration:
|
||||
4. Removes workspace_id from all tables that had it
|
||||
5. Drops workspace-related tables: workspaces, workspace_members, workspace_invitations
|
||||
"""
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy import text
|
||||
@@ -24,7 +25,7 @@ depends_on = None
|
||||
|
||||
def upgrade() -> None:
|
||||
conn = op.get_bind()
|
||||
|
||||
|
||||
# Step 1: Add subscription/quota fields to users table
|
||||
conn.execute(text("""
|
||||
ALTER TABLE users
|
||||
@@ -50,7 +51,7 @@ def upgrade() -> None:
|
||||
ALTER TABLE users
|
||||
ADD COLUMN IF NOT EXISTS used_storage_gb FLOAT NOT NULL DEFAULT 0
|
||||
"""))
|
||||
|
||||
|
||||
# Step 2: Copy subscription data from workspaces to users
|
||||
conn.execute(text("""
|
||||
UPDATE users SET
|
||||
@@ -63,7 +64,7 @@ def upgrade() -> None:
|
||||
FROM workspaces w
|
||||
WHERE w.owner_user_id = users.id
|
||||
"""))
|
||||
|
||||
|
||||
# Step 3: Add owner_user_id and shared_users to projects table
|
||||
conn.execute(text("""
|
||||
ALTER TABLE projects
|
||||
@@ -73,7 +74,7 @@ def upgrade() -> None:
|
||||
ALTER TABLE projects
|
||||
ADD COLUMN IF NOT EXISTS shared_users JSON
|
||||
"""))
|
||||
|
||||
|
||||
# Step 4: Migrate workspace_id to owner_user_id (from workspace_members where role=owner)
|
||||
conn.execute(text("""
|
||||
UPDATE projects SET
|
||||
@@ -82,13 +83,13 @@ def upgrade() -> None:
|
||||
WHERE wm.workspace_id = projects.workspace_id
|
||||
AND wm.role = 'owner'
|
||||
"""))
|
||||
|
||||
|
||||
# Set shared_users to empty array for all projects
|
||||
conn.execute(text("""
|
||||
UPDATE projects SET shared_users = '[]'::json
|
||||
WHERE shared_users IS NULL
|
||||
"""))
|
||||
|
||||
|
||||
# Step 5: Remove workspace_id from all tables
|
||||
tables_with_workspace_id = [
|
||||
"asset_libraries",
|
||||
@@ -104,12 +105,12 @@ def upgrade() -> None:
|
||||
"tasks",
|
||||
"task_issues",
|
||||
]
|
||||
|
||||
|
||||
for table in tables_with_workspace_id:
|
||||
conn.execute(text(f"""
|
||||
ALTER TABLE {table} DROP COLUMN IF EXISTS workspace_id
|
||||
"""))
|
||||
|
||||
|
||||
# Step 6: Drop workspace-related tables
|
||||
conn.execute(text("""
|
||||
DROP TABLE IF EXISTS workspace_invitations
|
||||
@@ -120,7 +121,7 @@ def upgrade() -> None:
|
||||
conn.execute(text("""
|
||||
DROP TABLE IF EXISTS workspaces
|
||||
"""))
|
||||
|
||||
|
||||
# Step 7: Drop workspace_id from projects table
|
||||
conn.execute(text("""
|
||||
ALTER TABLE projects DROP COLUMN IF EXISTS workspace_id
|
||||
@@ -129,7 +130,7 @@ def upgrade() -> None:
|
||||
|
||||
def downgrade() -> None:
|
||||
conn = op.get_bind()
|
||||
|
||||
|
||||
# Add back workspace tables (simplified - in real scenario would need full recreation)
|
||||
conn.execute(text("""
|
||||
CREATE TABLE IF NOT EXISTS workspaces (
|
||||
@@ -145,7 +146,7 @@ def downgrade() -> None:
|
||||
created_at TIMESTAMP NOT NULL DEFAULT NOW()
|
||||
)
|
||||
"""))
|
||||
|
||||
|
||||
conn.execute(text("""
|
||||
CREATE TABLE IF NOT EXISTS workspace_members (
|
||||
id VARCHAR(36) PRIMARY KEY,
|
||||
@@ -157,7 +158,7 @@ def downgrade() -> None:
|
||||
UNIQUE(workspace_id, user_id)
|
||||
)
|
||||
"""))
|
||||
|
||||
|
||||
conn.execute(text("""
|
||||
CREATE TABLE IF NOT EXISTS workspace_invitations (
|
||||
id VARCHAR(36) PRIMARY KEY,
|
||||
@@ -172,12 +173,12 @@ def downgrade() -> None:
|
||||
created_at TIMESTAMP NOT NULL DEFAULT NOW()
|
||||
)
|
||||
"""))
|
||||
|
||||
|
||||
# Add back workspace_id column to projects
|
||||
conn.execute(text("""
|
||||
ALTER TABLE projects ADD COLUMN workspace_id VARCHAR(32)
|
||||
"""))
|
||||
|
||||
|
||||
# Add back workspace_id columns to other tables
|
||||
tables_with_workspace_id = [
|
||||
"asset_libraries",
|
||||
@@ -193,11 +194,11 @@ def downgrade() -> None:
|
||||
"tasks",
|
||||
"task_issues",
|
||||
]
|
||||
|
||||
|
||||
for table in tables_with_workspace_id:
|
||||
conn.execute(text(f"""
|
||||
ALTER TABLE {table} ADD COLUMN workspace_id VARCHAR(36)
|
||||
"""))
|
||||
|
||||
|
||||
# Note: This downgrade is incomplete - projects.owner_user_id data would need to be
|
||||
# converted back to workspace_ids, which requires reconstructing workspace records.
|
||||
|
||||
@@ -10,6 +10,7 @@ This migration:
|
||||
2. Creates title_libraries table (独立标题库,支持跨项目复用)
|
||||
3. Creates voice_libraries table (配音库,支持 AI 配音管理)
|
||||
"""
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
@@ -25,21 +26,11 @@ def upgrade() -> None:
|
||||
|
||||
# ── 1. Add metadata JSONB to existing tables ──
|
||||
|
||||
conn.execute(sa.text(
|
||||
"ALTER TABLE projects ADD COLUMN IF NOT EXISTS metadata JSONB NOT NULL DEFAULT '{}'"
|
||||
))
|
||||
conn.execute(sa.text(
|
||||
"ALTER TABLE asset_libraries ADD COLUMN IF NOT EXISTS metadata JSONB NOT NULL DEFAULT '{}'"
|
||||
))
|
||||
conn.execute(sa.text(
|
||||
"ALTER TABLE assets ADD COLUMN IF NOT EXISTS metadata JSONB NOT NULL DEFAULT '{}'"
|
||||
))
|
||||
conn.execute(sa.text(
|
||||
"ALTER TABLE edit_templates ADD COLUMN IF NOT EXISTS metadata JSONB NOT NULL DEFAULT '{}'"
|
||||
))
|
||||
conn.execute(sa.text(
|
||||
"ALTER TABLE generation_tasks ADD COLUMN IF NOT EXISTS metadata JSONB NOT NULL DEFAULT '{}'"
|
||||
))
|
||||
conn.execute(sa.text("ALTER TABLE projects ADD COLUMN IF NOT EXISTS metadata JSONB NOT NULL DEFAULT '{}'"))
|
||||
conn.execute(sa.text("ALTER TABLE asset_libraries ADD COLUMN IF NOT EXISTS metadata JSONB NOT NULL DEFAULT '{}'"))
|
||||
conn.execute(sa.text("ALTER TABLE assets ADD COLUMN IF NOT EXISTS metadata JSONB NOT NULL DEFAULT '{}'"))
|
||||
conn.execute(sa.text("ALTER TABLE edit_templates ADD COLUMN IF NOT EXISTS metadata JSONB NOT NULL DEFAULT '{}'"))
|
||||
conn.execute(sa.text("ALTER TABLE generation_tasks ADD COLUMN IF NOT EXISTS metadata JSONB NOT NULL DEFAULT '{}'"))
|
||||
|
||||
# ── 2. Create title_libraries table ──
|
||||
|
||||
@@ -59,15 +50,9 @@ def upgrade() -> None:
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT NOW()
|
||||
)
|
||||
"""))
|
||||
conn.execute(sa.text(
|
||||
"CREATE INDEX IF NOT EXISTS ix_title_libraries_user_id ON title_libraries(user_id)"
|
||||
))
|
||||
conn.execute(sa.text(
|
||||
"CREATE INDEX IF NOT EXISTS ix_title_libraries_category ON title_libraries(category)"
|
||||
))
|
||||
conn.execute(sa.text(
|
||||
"CREATE INDEX IF NOT EXISTS ix_title_libraries_is_active ON title_libraries(is_active)"
|
||||
))
|
||||
conn.execute(sa.text("CREATE INDEX IF NOT EXISTS ix_title_libraries_user_id ON title_libraries(user_id)"))
|
||||
conn.execute(sa.text("CREATE INDEX IF NOT EXISTS ix_title_libraries_category ON title_libraries(category)"))
|
||||
conn.execute(sa.text("CREATE INDEX IF NOT EXISTS ix_title_libraries_is_active ON title_libraries(is_active)"))
|
||||
|
||||
# ── 3. Create voice_libraries table ──
|
||||
|
||||
@@ -91,15 +76,9 @@ def upgrade() -> None:
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT NOW()
|
||||
)
|
||||
"""))
|
||||
conn.execute(sa.text(
|
||||
"CREATE INDEX IF NOT EXISTS ix_voice_libraries_user_id ON voice_libraries(user_id)"
|
||||
))
|
||||
conn.execute(sa.text(
|
||||
"CREATE INDEX IF NOT EXISTS ix_voice_libraries_project_id ON voice_libraries(project_id)"
|
||||
))
|
||||
conn.execute(sa.text(
|
||||
"CREATE INDEX IF NOT EXISTS ix_voice_libraries_status ON voice_libraries(status)"
|
||||
))
|
||||
conn.execute(sa.text("CREATE INDEX IF NOT EXISTS ix_voice_libraries_user_id ON voice_libraries(user_id)"))
|
||||
conn.execute(sa.text("CREATE INDEX IF NOT EXISTS ix_voice_libraries_project_id ON voice_libraries(project_id)"))
|
||||
conn.execute(sa.text("CREATE INDEX IF NOT EXISTS ix_voice_libraries_status ON voice_libraries(status)"))
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
|
||||
@@ -14,6 +14,7 @@ This migration:
|
||||
- edit_plan_clips (编辑计划片段)
|
||||
2. Removes edit_plan_id column from generation_tasks table
|
||||
"""
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
@@ -39,9 +40,7 @@ def upgrade() -> None:
|
||||
|
||||
# ── 2. Remove edit_plan_id from generation_tasks ──
|
||||
|
||||
conn.execute(sa.text(
|
||||
"ALTER TABLE generation_tasks DROP COLUMN IF EXISTS edit_plan_id"
|
||||
))
|
||||
conn.execute(sa.text("ALTER TABLE generation_tasks DROP COLUMN IF EXISTS edit_plan_id"))
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
@@ -49,9 +48,7 @@ def downgrade() -> None:
|
||||
|
||||
# ── 1. Re-add edit_plan_id to generation_tasks ──
|
||||
|
||||
conn.execute(sa.text(
|
||||
"ALTER TABLE generation_tasks ADD COLUMN IF NOT EXISTS edit_plan_id VARCHAR(32)"
|
||||
))
|
||||
conn.execute(sa.text("ALTER TABLE generation_tasks ADD COLUMN IF NOT EXISTS edit_plan_id VARCHAR(32)"))
|
||||
|
||||
# ── 2. Recreate deprecated tables (basic structure) ──
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ This migration creates two new tables:
|
||||
1. duplication_records — 查重记录主表
|
||||
2. duplication_segments — 重复片段详情表
|
||||
"""
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
@@ -40,12 +41,8 @@ def upgrade() -> None:
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT NOW()
|
||||
)
|
||||
"""))
|
||||
conn.execute(sa.text(
|
||||
"CREATE INDEX IF NOT EXISTS ix_duplication_records_user_id ON duplication_records(user_id)"
|
||||
))
|
||||
conn.execute(sa.text(
|
||||
"CREATE INDEX IF NOT EXISTS ix_duplication_records_status ON duplication_records(status)"
|
||||
))
|
||||
conn.execute(sa.text("CREATE INDEX IF NOT EXISTS ix_duplication_records_user_id ON duplication_records(user_id)"))
|
||||
conn.execute(sa.text("CREATE INDEX IF NOT EXISTS ix_duplication_records_status ON duplication_records(status)"))
|
||||
|
||||
# ── 2. Create duplication_segments table ──
|
||||
|
||||
@@ -62,9 +59,9 @@ def upgrade() -> None:
|
||||
similarity FLOAT NOT NULL
|
||||
)
|
||||
"""))
|
||||
conn.execute(sa.text(
|
||||
"CREATE INDEX IF NOT EXISTS ix_duplication_segments_record_id ON duplication_segments(record_id)"
|
||||
))
|
||||
conn.execute(
|
||||
sa.text("CREATE INDEX IF NOT EXISTS ix_duplication_segments_record_id ON duplication_segments(record_id)")
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
|
||||
@@ -8,6 +8,7 @@ This migration creates two new tables:
|
||||
1. recipes — 配方主表
|
||||
2. recipe_items — 配方素材项表
|
||||
"""
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
@@ -37,9 +38,7 @@ def upgrade() -> None:
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT NOW()
|
||||
)
|
||||
"""))
|
||||
conn.execute(sa.text(
|
||||
"CREATE INDEX IF NOT EXISTS ix_recipes_user_id ON recipes(user_id)"
|
||||
))
|
||||
conn.execute(sa.text("CREATE INDEX IF NOT EXISTS ix_recipes_user_id ON recipes(user_id)"))
|
||||
|
||||
# ── 2. Create recipe_items table ──
|
||||
|
||||
@@ -53,9 +52,7 @@ def upgrade() -> None:
|
||||
metadata JSONB NOT NULL DEFAULT '{}'
|
||||
)
|
||||
"""))
|
||||
conn.execute(sa.text(
|
||||
"CREATE INDEX IF NOT EXISTS ix_recipe_items_recipe_id ON recipe_items(recipe_id)"
|
||||
))
|
||||
conn.execute(sa.text("CREATE INDEX IF NOT EXISTS ix_recipe_items_recipe_id ON recipe_items(recipe_id)"))
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
|
||||
@@ -9,6 +9,7 @@ This migration creates three new tables:
|
||||
2. template_segments — 模板片段表
|
||||
3. template_categories — 模板分类表
|
||||
"""
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
@@ -40,12 +41,8 @@ def upgrade() -> None:
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT NOW()
|
||||
)
|
||||
"""))
|
||||
conn.execute(sa.text(
|
||||
"CREATE INDEX IF NOT EXISTS ix_templates_user_id ON templates(user_id)"
|
||||
))
|
||||
conn.execute(sa.text(
|
||||
"CREATE INDEX IF NOT EXISTS ix_templates_mode ON templates(mode)"
|
||||
))
|
||||
conn.execute(sa.text("CREATE INDEX IF NOT EXISTS ix_templates_user_id ON templates(user_id)"))
|
||||
conn.execute(sa.text("CREATE INDEX IF NOT EXISTS ix_templates_mode ON templates(mode)"))
|
||||
|
||||
# ── 2. Create template_segments table ──
|
||||
conn.execute(sa.text("""
|
||||
@@ -60,10 +57,9 @@ def upgrade() -> None:
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT NOW()
|
||||
)
|
||||
"""))
|
||||
conn.execute(sa.text(
|
||||
"CREATE INDEX IF NOT EXISTS ix_template_segments_template_id "
|
||||
"ON template_segments(template_id)"
|
||||
))
|
||||
conn.execute(
|
||||
sa.text("CREATE INDEX IF NOT EXISTS ix_template_segments_template_id " "ON template_segments(template_id)")
|
||||
)
|
||||
|
||||
# ── 3. Create template_categories table ──
|
||||
conn.execute(sa.text("""
|
||||
@@ -74,10 +70,9 @@ def upgrade() -> None:
|
||||
created_at TIMESTAMP NOT NULL DEFAULT NOW()
|
||||
)
|
||||
"""))
|
||||
conn.execute(sa.text(
|
||||
"CREATE INDEX IF NOT EXISTS ix_template_categories_user_id "
|
||||
"ON template_categories(user_id)"
|
||||
))
|
||||
conn.execute(
|
||||
sa.text("CREATE INDEX IF NOT EXISTS ix_template_categories_user_id " "ON template_categories(user_id)")
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
|
||||
@@ -49,7 +49,7 @@ def list_asset_libraries(
|
||||
) -> ListAssetLibrariesResponse:
|
||||
user_id = authenticated_user.user.id
|
||||
use_case = ListAssetLibrariesUseCase(asset_library_repository)
|
||||
|
||||
|
||||
if project_id:
|
||||
# If project_id provided, check access and filter by project
|
||||
project = GetProjectUseCase(project_repository).execute(project_id)
|
||||
@@ -65,7 +65,7 @@ def list_asset_libraries(
|
||||
for proj in accessible_projects:
|
||||
all_items.extend(use_case.execute(proj.id))
|
||||
items = all_items
|
||||
|
||||
|
||||
return ListAssetLibrariesResponse(items=[_to_asset_library_response(item) for item in items])
|
||||
|
||||
|
||||
|
||||
@@ -106,7 +106,7 @@ def create_asset(
|
||||
raise HTTPException(status_code=404, detail=f"Project {request.project_id} not found")
|
||||
if not project.can_access(authenticated_user.user.id):
|
||||
raise HTTPException(status_code=403, detail="Access denied to project")
|
||||
|
||||
|
||||
library = asset_library_repository.get(request.library_id)
|
||||
if library is None or library.project_id != request.project_id:
|
||||
raise HTTPException(status_code=404, detail=f"AssetLibrary {request.library_id} not found")
|
||||
|
||||
@@ -55,6 +55,7 @@ class LoginRequest(BaseModel):
|
||||
class RefreshRequest(BaseModel):
|
||||
refresh_token: str
|
||||
|
||||
|
||||
class LoginResponse(BaseModel):
|
||||
access_token: str
|
||||
refresh_token: str
|
||||
@@ -173,7 +174,6 @@ async def refresh(
|
||||
)
|
||||
|
||||
|
||||
|
||||
def _verify_email_token(token: str, user_repository: UserRepository) -> MessageResponse:
|
||||
success, error = VerifyEmailUseCase(user_repository=user_repository).execute(VerifyEmailRequest(token=token))
|
||||
if not success:
|
||||
|
||||
@@ -44,9 +44,18 @@ CHUNK_EXPIRY_HOURS = 24
|
||||
|
||||
# Allowed file types (consistent with existing upload.py)
|
||||
ALLOWED_MIME_TYPES = {
|
||||
"image/jpeg", "image/png", "image/gif", "image/webp",
|
||||
"video/mp4", "video/quicktime", "video/x-msvideo", "video/webm",
|
||||
"audio/mpeg", "audio/wav", "audio/ogg", "audio/mp3",
|
||||
"image/jpeg",
|
||||
"image/png",
|
||||
"image/gif",
|
||||
"image/webp",
|
||||
"video/mp4",
|
||||
"video/quicktime",
|
||||
"video/x-msvideo",
|
||||
"video/webm",
|
||||
"audio/mpeg",
|
||||
"audio/wav",
|
||||
"audio/ogg",
|
||||
"audio/mp3",
|
||||
}
|
||||
|
||||
# Chunk storage root directory
|
||||
@@ -67,13 +76,13 @@ def _atomic_check_and_record(upload_id: str, chunk_index: int) -> bool:
|
||||
"""
|
||||
Atomically check if chunk is uploaded and record if not.
|
||||
Uses file locking to prevent race conditions.
|
||||
|
||||
|
||||
Returns:
|
||||
True if chunk was newly recorded, False if already exists
|
||||
"""
|
||||
meta_path = _get_upload_meta_path(upload_id)
|
||||
CHUNK_STORAGE_ROOT.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
|
||||
with open(meta_path, "r+", encoding="utf-8") as f:
|
||||
fcntl.flock(f.fileno(), fcntl.LOCK_EX)
|
||||
try:
|
||||
@@ -128,15 +137,17 @@ def _validate_file_type(content: bytes, filename: str) -> str:
|
||||
"""Validate file type"""
|
||||
try:
|
||||
import magic
|
||||
|
||||
detected_mime = magic.from_buffer(content, mime=True)
|
||||
except ImportError:
|
||||
import mimetypes
|
||||
|
||||
detected_mime = mimetypes.guess_type(filename)[0] or "application/octet-stream"
|
||||
|
||||
if detected_mime not in ALLOWED_MIME_TYPES:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Unsupported file type: {detected_mime}. Allowed types: {', '.join(sorted(ALLOWED_MIME_TYPES))}"
|
||||
detail=f"Unsupported file type: {detected_mime}. Allowed types: {', '.join(sorted(ALLOWED_MIME_TYPES))}",
|
||||
)
|
||||
return detected_mime
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
"""查重 API 路由。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
@@ -31,10 +32,17 @@ logger = logging.getLogger(__name__)
|
||||
router = APIRouter()
|
||||
|
||||
# 查重功能只接受视频文件
|
||||
ALLOWED_VIDEO_MIME_TYPES = frozenset({
|
||||
"video/mp4", "video/mpeg", "video/quicktime", "video/x-msvideo",
|
||||
"video/webm", "video/x-matroska", "video/3gpp",
|
||||
})
|
||||
ALLOWED_VIDEO_MIME_TYPES = frozenset(
|
||||
{
|
||||
"video/mp4",
|
||||
"video/mpeg",
|
||||
"video/quicktime",
|
||||
"video/x-msvideo",
|
||||
"video/webm",
|
||||
"video/x-matroska",
|
||||
"video/3gpp",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def _validate_video_mime_type(content_type: str | None) -> str:
|
||||
@@ -44,16 +52,16 @@ def _validate_video_mime_type(content_type: str | None) -> str:
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Content-Type header is required",
|
||||
)
|
||||
|
||||
|
||||
# 处理带参数的类型,如 "video/mp4; charset=utf-8"
|
||||
base_type = content_type.split(";")[0].strip().lower()
|
||||
|
||||
|
||||
if base_type not in ALLOWED_VIDEO_MIME_TYPES:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_415_UNSUPPORTED_MEDIA_TYPE,
|
||||
detail=f"只支持视频文件。支持的类型: mp4, mpeg, mov, avi, webm, mkv, 3gp",
|
||||
)
|
||||
|
||||
|
||||
return base_type
|
||||
|
||||
|
||||
@@ -117,9 +125,10 @@ async def upload_for_duplication(
|
||||
|
||||
# P0-2: 验证文件大小(参考 OSS_DIRECT_UPLOAD_MAX_MB)
|
||||
from app.config import get_settings
|
||||
|
||||
settings = get_settings()
|
||||
max_size_bytes = settings.OSS_DIRECT_UPLOAD_MAX_MB * 1024 * 1024
|
||||
|
||||
|
||||
# 先检查 Content-Length header(如果可用)
|
||||
if file.size is not None and file.size > max_size_bytes:
|
||||
raise HTTPException(
|
||||
@@ -135,7 +144,7 @@ async def upload_for_duplication(
|
||||
try:
|
||||
content = await file.read()
|
||||
file_size = len(content)
|
||||
|
||||
|
||||
# 再次检查实际文件大小
|
||||
if file_size > max_size_bytes:
|
||||
raise HTTPException(
|
||||
|
||||
@@ -50,7 +50,7 @@ def list_generated_videos(
|
||||
) -> ListGeneratedVideosResponse:
|
||||
user_id = authenticated_user.user.id
|
||||
use_case = ListGeneratedVideosUseCase(generated_video_repository)
|
||||
|
||||
|
||||
if project_id:
|
||||
# If project_id provided, check access and filter by project
|
||||
project = project_repository.find_by_id(project_id)
|
||||
@@ -64,7 +64,7 @@ def list_generated_videos(
|
||||
for proj in accessible_projects:
|
||||
all_items.extend(use_case.execute(proj.id))
|
||||
items = all_items
|
||||
|
||||
|
||||
# Generate download URLs for each video
|
||||
responses = []
|
||||
for item in items:
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
"""Recipe CRUD + use routes."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import List
|
||||
@@ -209,8 +210,5 @@ def use_recipe(
|
||||
|
||||
return UseRecipeResponse(
|
||||
recipe=_to_response(result.recipe),
|
||||
warnings=[
|
||||
{"item_type": w.item_type, "item_id": w.item_id, "position": w.position}
|
||||
for w in result.warnings
|
||||
],
|
||||
warnings=[{"item_type": w.item_type, "item_id": w.item_id, "position": w.position} for w in result.warnings],
|
||||
)
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
"""Subscription management API routes."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import replace
|
||||
@@ -34,6 +35,7 @@ PLAN_QUOTAS = {
|
||||
|
||||
# ============ Helper Functions ============
|
||||
|
||||
|
||||
def _get_plan_name(plan_id: str) -> str:
|
||||
"""获取套餐显示名称"""
|
||||
plan_names = {
|
||||
@@ -86,6 +88,7 @@ def _build_subscription_info(user: AuthenticatedUser) -> SubscriptionInfo:
|
||||
|
||||
# ============ API Endpoints ============
|
||||
|
||||
|
||||
@router.get("/current", response_model=SubscriptionInfo)
|
||||
async def get_current_subscription(
|
||||
current_user: AuthenticatedUser = Depends(get_current_user),
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
"""Template CRUD + generate + category routes."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import List
|
||||
@@ -234,10 +235,7 @@ def validate_template(
|
||||
|
||||
return ValidateTemplateResponse(
|
||||
template=_to_response(result.template),
|
||||
warnings=[
|
||||
GenerateWarningResponse(code=w.code, message=w.message, details=w.details)
|
||||
for w in result.warnings
|
||||
],
|
||||
warnings=[GenerateWarningResponse(code=w.code, message=w.message, details=w.details) for w in result.warnings],
|
||||
)
|
||||
|
||||
|
||||
@@ -253,10 +251,7 @@ def list_categories(
|
||||
use_case = ListCategoriesUseCase(template_repository)
|
||||
categories = use_case.execute(user_id)
|
||||
return ListCategoriesResponse(
|
||||
items=[
|
||||
CategoryResponse(id=c.id, user_id=c.user_id, name=c.name, created_at=c.created_at)
|
||||
for c in categories
|
||||
],
|
||||
items=[CategoryResponse(id=c.id, user_id=c.user_id, name=c.name, created_at=c.created_at) for c in categories],
|
||||
)
|
||||
|
||||
|
||||
@@ -271,7 +266,10 @@ def create_category(
|
||||
use_case = CreateCategoryUseCase(template_repository)
|
||||
category = use_case.execute(command)
|
||||
return CategoryResponse(
|
||||
id=category.id, user_id=category.user_id, name=category.name, created_at=category.created_at,
|
||||
id=category.id,
|
||||
user_id=category.user_id,
|
||||
name=category.name,
|
||||
created_at=category.created_at,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
"""Title library CRUD routes."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Optional
|
||||
|
||||
@@ -30,17 +30,35 @@ logger = logging.getLogger(__name__)
|
||||
router = APIRouter()
|
||||
|
||||
# 允许上传的文件 MIME 类型
|
||||
ALLOWED_MIME_TYPES = frozenset({
|
||||
# 视频
|
||||
"video/mp4", "video/mpeg", "video/quicktime", "video/x-msvideo",
|
||||
"video/webm", "video/x-matroska", "video/3gpp",
|
||||
# 音频
|
||||
"audio/mpeg", "audio/wav", "audio/ogg", "audio/flac", "audio/aac",
|
||||
"audio/mp3", "audio/x-m4a", "audio/webm",
|
||||
# 图片
|
||||
"image/jpeg", "image/png", "image/gif", "image/webp", "image/bmp",
|
||||
"image/svg+xml", "image/tiff",
|
||||
})
|
||||
ALLOWED_MIME_TYPES = frozenset(
|
||||
{
|
||||
# 视频
|
||||
"video/mp4",
|
||||
"video/mpeg",
|
||||
"video/quicktime",
|
||||
"video/x-msvideo",
|
||||
"video/webm",
|
||||
"video/x-matroska",
|
||||
"video/3gpp",
|
||||
# 音频
|
||||
"audio/mpeg",
|
||||
"audio/wav",
|
||||
"audio/ogg",
|
||||
"audio/flac",
|
||||
"audio/aac",
|
||||
"audio/mp3",
|
||||
"audio/x-m4a",
|
||||
"audio/webm",
|
||||
# 图片
|
||||
"image/jpeg",
|
||||
"image/png",
|
||||
"image/gif",
|
||||
"image/webp",
|
||||
"image/bmp",
|
||||
"image/svg+xml",
|
||||
"image/tiff",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def _validate_mime_type(content_type: str | None) -> str:
|
||||
@@ -50,16 +68,16 @@ def _validate_mime_type(content_type: str | None) -> str:
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Content-Type header is required",
|
||||
)
|
||||
|
||||
|
||||
# 处理带参数的类型,如 "video/mp4; charset=utf-8"
|
||||
base_type = content_type.split(";")[0].strip().lower()
|
||||
|
||||
|
||||
if base_type not in ALLOWED_MIME_TYPES:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_415_UNSUPPORTED_MEDIA_TYPE,
|
||||
detail=f"File type '{base_type}' is not supported. Allowed types: video, audio, and image files.",
|
||||
)
|
||||
|
||||
|
||||
return base_type
|
||||
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
"""Voice library CRUD routes."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Optional
|
||||
|
||||
+5
-15
@@ -21,9 +21,7 @@ class Settings(BaseSettings):
|
||||
API_PORT: int = 8000
|
||||
API_PREFIX: str = "/api/v1"
|
||||
|
||||
DATABASE_URL: str = (
|
||||
"postgresql+psycopg://postgres:postgres@localhost:5432/xiaoxia_saas"
|
||||
)
|
||||
DATABASE_URL: str = "postgresql+psycopg://postgres:postgres@localhost:5432/xiaoxia_saas"
|
||||
DATABASE_POOL_SIZE: int = 20
|
||||
DATABASE_MAX_OVERFLOW: int = 10 # 调整为合理值:pool_size(20) + max_overflow(10) = 最大30连接
|
||||
DATABASE_POOL_TIMEOUT: int = 30
|
||||
@@ -48,8 +46,7 @@ class Settings(BaseSettings):
|
||||
def validate_jwt_secret_key(cls, v):
|
||||
if v is None or v == "":
|
||||
raise ValueError(
|
||||
"JWT_SECRET_KEY must be set via environment variable. "
|
||||
"Do not use default value in production!"
|
||||
"JWT_SECRET_KEY must be set via environment variable. " "Do not use default value in production!"
|
||||
)
|
||||
# Block known insecure default values
|
||||
insecure_defaults = [
|
||||
@@ -61,8 +58,7 @@ class Settings(BaseSettings):
|
||||
]
|
||||
if v.lower() in [d.lower() for d in insecure_defaults]:
|
||||
raise ValueError(
|
||||
f"JWT_SECRET_KEY '{v}' is insecure. "
|
||||
"Please set a strong random secret via environment variable."
|
||||
f"JWT_SECRET_KEY '{v}' is insecure. " "Please set a strong random secret via environment variable."
|
||||
)
|
||||
return v
|
||||
|
||||
@@ -90,9 +86,7 @@ class Settings(BaseSettings):
|
||||
OSS_DIRECT_UPLOAD_EXPIRE_SECONDS: int = 900
|
||||
|
||||
LOG_LEVEL: str = "INFO"
|
||||
CORS_ORIGINS_RAW: str = (
|
||||
"http://localhost:3000,http://localhost:5173,http://localhost:8000"
|
||||
)
|
||||
CORS_ORIGINS_RAW: str = "http://localhost:3000,http://localhost:5173,http://localhost:8000"
|
||||
|
||||
model_config = SettingsConfigDict(
|
||||
env_file=".env",
|
||||
@@ -103,11 +97,7 @@ class Settings(BaseSettings):
|
||||
|
||||
@property
|
||||
def CORS_ORIGINS(self) -> list[str]:
|
||||
return [
|
||||
origin.strip()
|
||||
for origin in self.CORS_ORIGINS_RAW.split(",")
|
||||
if origin.strip()
|
||||
]
|
||||
return [origin.strip() for origin in self.CORS_ORIGINS_RAW.split(",") if origin.strip()]
|
||||
|
||||
@property
|
||||
def database_url(self) -> str:
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
统一使用 app.config 中的数据库配置,移除重复的 DatabaseSettings。
|
||||
"""
|
||||
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import sessionmaker, Session
|
||||
from contextlib import contextmanager
|
||||
@@ -9,7 +10,6 @@ from typing import Generator
|
||||
|
||||
from app.config import settings
|
||||
|
||||
|
||||
engine = create_engine(
|
||||
settings.database_url,
|
||||
pool_size=settings.DATABASE_POOL_SIZE,
|
||||
@@ -33,7 +33,7 @@ def get_db() -> Generator[Session, None, None]:
|
||||
@contextmanager
|
||||
def get_db_context() -> Generator[Session, None, None]:
|
||||
"""Context manager for database sessions.
|
||||
|
||||
|
||||
Usage:
|
||||
with get_db_context() as db:
|
||||
db.query(Model).all()
|
||||
|
||||
@@ -232,8 +232,6 @@ class OSSStorageService:
|
||||
return self.bucket.object_exists(storage_key)
|
||||
|
||||
|
||||
|
||||
|
||||
_storage_service = None
|
||||
|
||||
|
||||
@@ -243,4 +241,3 @@ def get_storage_service() -> OSSStorageService:
|
||||
if _storage_service is None:
|
||||
_storage_service = OSSStorageService()
|
||||
return _storage_service
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
All repository and service factories are defined here as FastAPI dependencies,
|
||||
ensuring proper lifecycle management and testability.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import redis
|
||||
@@ -124,7 +125,6 @@ def get_project_repository(
|
||||
return SQLAlchemyProjectRepository(session)
|
||||
|
||||
|
||||
|
||||
def get_user_repository(
|
||||
session: Session = Depends(get_db_session),
|
||||
) -> UserRepository:
|
||||
@@ -156,6 +156,7 @@ def get_auth_email_service() -> NoopEmailService | EmailService:
|
||||
enabled=True,
|
||||
)
|
||||
|
||||
|
||||
def get_title_library_repository(
|
||||
session: Session = Depends(get_db_session),
|
||||
) -> SQLAlchemyTitleLibraryRepository:
|
||||
|
||||
@@ -69,9 +69,7 @@ class RequestLoggingMiddleware(BaseHTTPMiddleware):
|
||||
|
||||
# 记录请求信息(不包含敏感参数)
|
||||
if safe_query:
|
||||
logger.info(
|
||||
f"Request: {request.method} {request.url.path}?{safe_query}" # noqa: E501
|
||||
)
|
||||
logger.info(f"Request: {request.method} {request.url.path}?{safe_query}") # noqa: E501
|
||||
else:
|
||||
logger.info(f"Request: {request.method} {request.url.path}")
|
||||
|
||||
@@ -83,8 +81,7 @@ class RequestLoggingMiddleware(BaseHTTPMiddleware):
|
||||
|
||||
# 记录响应信息
|
||||
logger.info(
|
||||
f"Response: {request.method} {request.url.path} "
|
||||
f"status={response.status_code} time={process_time:.3f}s"
|
||||
f"Response: {request.method} {request.url.path} " f"status={response.status_code} time={process_time:.3f}s"
|
||||
)
|
||||
|
||||
# 添加处理时间到响应头
|
||||
@@ -111,9 +108,7 @@ class RateLimitMiddleware(BaseHTTPMiddleware):
|
||||
# 清理过期记录
|
||||
if client_ip in self.requests:
|
||||
self.requests[client_ip] = [
|
||||
ts
|
||||
for ts in self.requests[client_ip]
|
||||
if current_time - ts < self.window_seconds
|
||||
ts for ts in self.requests[client_ip] if current_time - ts < self.window_seconds
|
||||
]
|
||||
|
||||
# 计算请求次数
|
||||
@@ -128,8 +123,7 @@ class RateLimitMiddleware(BaseHTTPMiddleware):
|
||||
"error": {
|
||||
"code": "RATE_LIMIT_EXCEEDED",
|
||||
"message": ( # noqa: E501
|
||||
f"Too many requests. Limit: "
|
||||
f"{self.max_requests} per {self.window_seconds}s"
|
||||
f"Too many requests. Limit: " f"{self.max_requests} per {self.window_seconds}s"
|
||||
),
|
||||
}
|
||||
},
|
||||
@@ -145,8 +139,6 @@ class RateLimitMiddleware(BaseHTTPMiddleware):
|
||||
|
||||
# 添加限流信息到响应头
|
||||
response.headers["X-RateLimit-Limit"] = str(self.max_requests)
|
||||
response.headers["X-RateLimit-Remaining"] = str(
|
||||
self.max_requests - len(self.requests[client_ip])
|
||||
)
|
||||
response.headers["X-RateLimit-Remaining"] = str(self.max_requests - len(self.requests[client_ip]))
|
||||
|
||||
return response
|
||||
|
||||
@@ -24,7 +24,6 @@ from prometheus_client import (
|
||||
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)
|
||||
|
||||
@@ -124,6 +123,7 @@ class PrometheusMetricsMiddleware(BaseHTTPMiddleware):
|
||||
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)
|
||||
|
||||
@@ -7,7 +7,9 @@ class ChunkedUploadInitRequest(BaseModel):
|
||||
filename: str = Field(..., min_length=1, max_length=255, description="Filename")
|
||||
file_size: int = Field(..., gt=0, le=2147483648, description="File size in bytes, max 2GB")
|
||||
total_chunks: int = Field(..., gt=0, description="Total number of chunks")
|
||||
content_type: str = Field(default="application/octet-stream", min_length=1, max_length=100, description="Content type")
|
||||
content_type: str = Field(
|
||||
default="application/octet-stream", min_length=1, max_length=100, description="Content type"
|
||||
)
|
||||
project_id: str = Field(..., min_length=1, description="Project ID")
|
||||
library_id: str = Field(..., min_length=1, description="Asset library ID")
|
||||
|
||||
|
||||
@@ -14,12 +14,14 @@ class RecentTaskItem(BaseModel):
|
||||
|
||||
class SubscriptionInfo(BaseModel):
|
||||
"""用户订阅信息。"""
|
||||
|
||||
plan: str = "free"
|
||||
is_active: bool = False
|
||||
|
||||
|
||||
class DashboardOverviewResponse(BaseModel):
|
||||
"""Dashboard 概览数据。"""
|
||||
|
||||
total_assets: int = 0
|
||||
used_storage_bytes: int = 0
|
||||
total_titles: int = 0
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
"""查重 API Pydantic schemas。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
@@ -8,6 +8,7 @@ class CreateGenerationTaskRequest(BaseModel):
|
||||
- 项目模式:project_id + asset_library_id(向后兼容)
|
||||
- 模板模式:template_id + asset_ids / title_ids / voice_ids
|
||||
"""
|
||||
|
||||
project_id: str = ""
|
||||
asset_library_id: str = ""
|
||||
strategy_id: str = ""
|
||||
@@ -50,4 +51,5 @@ class GenerationTaskResponse(BaseModel):
|
||||
|
||||
class ListGenerationTasksResponse(BaseModel):
|
||||
"""用户级生成任务列表响应(跨 project)。"""
|
||||
|
||||
items: list[GenerationTaskResponse]
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
"""Recipe API schemas."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
@@ -6,9 +7,9 @@ from typing import Any, Dict, List, Optional
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
# ── Response ──
|
||||
|
||||
|
||||
class RecipeItemResponse(BaseModel):
|
||||
id: str
|
||||
recipe_id: str
|
||||
@@ -50,6 +51,7 @@ class UseRecipeResponse(BaseModel):
|
||||
|
||||
# ── Request ──
|
||||
|
||||
|
||||
class RecipeItemRequest(BaseModel):
|
||||
item_type: str
|
||||
item_id: str
|
||||
|
||||
@@ -1,15 +1,17 @@
|
||||
"""Subscription schemas for API request/response models."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import List, Optional
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
# ============ Enums / Types ============
|
||||
|
||||
|
||||
class PlanType(str):
|
||||
"""套餐类型"""
|
||||
|
||||
FREE = "free"
|
||||
STANDARD = "standard"
|
||||
PRO = "pro"
|
||||
@@ -18,6 +20,7 @@ class PlanType(str):
|
||||
|
||||
class SubscriptionStatus(str):
|
||||
"""订阅状态"""
|
||||
|
||||
ACTIVE = "active"
|
||||
EXPIRED = "expired"
|
||||
CANCELLED = "cancelled"
|
||||
@@ -26,6 +29,7 @@ class SubscriptionStatus(str):
|
||||
|
||||
class BillingStatus(str):
|
||||
"""账单状态"""
|
||||
|
||||
PAID = "paid"
|
||||
PENDING = "pending"
|
||||
FAILED = "failed"
|
||||
@@ -34,14 +38,17 @@ class BillingStatus(str):
|
||||
|
||||
class BillingCycle(str):
|
||||
"""计费周期"""
|
||||
|
||||
MONTHLY = "monthly"
|
||||
YEARLY = "yearly"
|
||||
|
||||
|
||||
# ============ Response Schemas ============
|
||||
|
||||
|
||||
class SubscriptionInfo(BaseModel):
|
||||
"""当前订阅信息"""
|
||||
|
||||
id: str
|
||||
plan_id: str
|
||||
plan_name: str
|
||||
@@ -56,6 +63,7 @@ class SubscriptionInfo(BaseModel):
|
||||
|
||||
class BillingRecord(BaseModel):
|
||||
"""账单记录"""
|
||||
|
||||
id: str
|
||||
plan_name: str
|
||||
amount: float
|
||||
@@ -68,6 +76,7 @@ class BillingRecord(BaseModel):
|
||||
|
||||
class ChangePlanResponse(BaseModel):
|
||||
"""升级/降级响应"""
|
||||
|
||||
success: bool
|
||||
message: str
|
||||
new_subscription: Optional[SubscriptionInfo] = None
|
||||
@@ -75,18 +84,22 @@ class ChangePlanResponse(BaseModel):
|
||||
|
||||
class SimpleResponse(BaseModel):
|
||||
"""简单响应(用于取消订阅、切换自动续费等)"""
|
||||
|
||||
success: bool
|
||||
message: str
|
||||
|
||||
|
||||
# ============ Request Schemas ============
|
||||
|
||||
|
||||
class ChangePlanRequest(BaseModel):
|
||||
"""升级/降级请求"""
|
||||
|
||||
target_plan_id: str = Field(..., description="目标套餐ID")
|
||||
billing_cycle: str = Field(..., description="计费周期: monthly/yearly")
|
||||
|
||||
|
||||
class ToggleAutoRenewRequest(BaseModel):
|
||||
"""切换自动续费请求"""
|
||||
|
||||
enabled: bool = Field(..., description="是否开启自动续费")
|
||||
|
||||
@@ -25,6 +25,7 @@ class ListProjectTasksResponse(BaseModel):
|
||||
|
||||
class UserTaskResponse(BaseModel):
|
||||
"""用户级任务响应(跨 project,用于模板模式)。"""
|
||||
|
||||
id: str
|
||||
task_type: str
|
||||
project_id: str = ""
|
||||
@@ -42,4 +43,5 @@ class UserTaskResponse(BaseModel):
|
||||
|
||||
class ListTasksResponse(BaseModel):
|
||||
"""用户级任务列表响应(GET /api/v1/tasks)。"""
|
||||
|
||||
items: list[UserTaskResponse] = Field(default_factory=list)
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
"""Template API schemas."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
@@ -6,9 +7,9 @@ from typing import Any, Dict, List, Optional
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
# ── Segment ──
|
||||
|
||||
|
||||
class SegmentResponse(BaseModel):
|
||||
id: str
|
||||
template_id: str
|
||||
@@ -29,6 +30,7 @@ class SegmentRequest(BaseModel):
|
||||
|
||||
# ── Template Response ──
|
||||
|
||||
|
||||
class TemplateResponse(BaseModel):
|
||||
id: str
|
||||
user_id: str
|
||||
@@ -53,6 +55,7 @@ class ListTemplatesResponse(BaseModel):
|
||||
|
||||
# ── Template Request ──
|
||||
|
||||
|
||||
class CreateTemplateRequest(BaseModel):
|
||||
name: str
|
||||
mode: str
|
||||
@@ -79,6 +82,7 @@ class UpdateTemplateRequest(BaseModel):
|
||||
|
||||
# ── Validate ──
|
||||
|
||||
|
||||
class ValidateTemplateRequest(BaseModel):
|
||||
voiceover_duration: Optional[float] = None # 配音实际时长(秒)
|
||||
|
||||
@@ -96,6 +100,7 @@ class ValidateTemplateResponse(BaseModel):
|
||||
|
||||
# ── Category ──
|
||||
|
||||
|
||||
class CategoryResponse(BaseModel):
|
||||
id: str
|
||||
user_id: str
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
"""Title library Pydantic schemas."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
|
||||
class UploadAssetRequest(BaseModel):
|
||||
"""素材上传请求(multipart form)"""
|
||||
|
||||
project_id: str = Field(..., min_length=1, description="项目 ID")
|
||||
library_id: str = Field(..., min_length=1, description="素材库 ID")
|
||||
|
||||
|
||||
class UploadAssetResponse(BaseModel):
|
||||
storage_key: str
|
||||
ingest_job_id: str
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
"""Voice library Pydantic schemas."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
"""Video deduplication module - compute fingerprints and detect duplicates."""
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
@@ -59,6 +60,7 @@ def compute_color_histogram(image: np.ndarray, bins: int = 32) -> list[float]:
|
||||
@dataclass
|
||||
class VideoFingerprint:
|
||||
"""Video fingerprint containing multiple similarity metrics."""
|
||||
|
||||
md5: str
|
||||
keyframe_phashes: list[str]
|
||||
color_histograms: list[list[float]]
|
||||
@@ -66,7 +68,13 @@ class VideoFingerprint:
|
||||
resolution: tuple[int, int]
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return {"md5": self.md5, "keyframe_phashes": self.keyframe_phashes, "color_histograms": self.color_histograms, "duration": self.duration, "resolution": list(self.resolution)}
|
||||
return {
|
||||
"md5": self.md5,
|
||||
"keyframe_phashes": self.keyframe_phashes,
|
||||
"color_histograms": self.color_histograms,
|
||||
"duration": self.duration,
|
||||
"resolution": list(self.resolution),
|
||||
}
|
||||
|
||||
|
||||
class VideoDeduplicator:
|
||||
@@ -80,48 +88,54 @@ class VideoDeduplicator:
|
||||
cap = cv2.VideoCapture(video_path)
|
||||
if not cap.isOpened():
|
||||
raise RuntimeError(f"Cannot open video: {video_path}")
|
||||
|
||||
|
||||
fps = cap.get(cv2.CAP_PROP_FPS)
|
||||
frame_count = int(cap.get(cv2.CAP_PROP_FRAME_COUNT))
|
||||
duration = frame_count / fps if fps > 0 else 0
|
||||
width = int(cap.get(cv2.CAP_PROP_FRAME_WIDTH))
|
||||
height = int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT))
|
||||
|
||||
|
||||
md5_hash = hashlib.md5()
|
||||
keyframe_phashes = []
|
||||
color_histograms = []
|
||||
|
||||
|
||||
frame_interval = max(1, frame_count // 10)
|
||||
for i in range(0, frame_count, frame_interval):
|
||||
cap.set(cv2.CAP_PROP_POS_FRAMES, i)
|
||||
ret, frame = cap.read()
|
||||
if not ret:
|
||||
continue
|
||||
|
||||
|
||||
_, buffer = cv2.imencode(".jpg", frame)
|
||||
md5_hash.update(buffer)
|
||||
|
||||
|
||||
keyframe_phashes.append(compute_phash(frame))
|
||||
color_histograms.append(compute_color_histogram(frame))
|
||||
|
||||
|
||||
cap.release()
|
||||
|
||||
return VideoFingerprint(md5=md5_hash.hexdigest(), keyframe_phashes=keyframe_phashes, color_histograms=color_histograms, duration=duration, resolution=(width, height))
|
||||
|
||||
return VideoFingerprint(
|
||||
md5=md5_hash.hexdigest(),
|
||||
keyframe_phashes=keyframe_phashes,
|
||||
color_histograms=color_histograms,
|
||||
duration=duration,
|
||||
resolution=(width, height),
|
||||
)
|
||||
|
||||
def check_duplicate(self, fingerprint: VideoFingerprint, project_id: str, session: Session) -> Optional[dict]:
|
||||
"""Check if video is duplicate of existing one. Returns duplicate info if found."""
|
||||
video_repo = SQLAlchemyGeneratedVideoRepository(session)
|
||||
existing_videos = video_repo.list_by_project(project_id)
|
||||
|
||||
|
||||
for existing in existing_videos:
|
||||
if not existing.video_fingerprint:
|
||||
continue
|
||||
|
||||
|
||||
ef = existing.video_fingerprint
|
||||
|
||||
|
||||
if fingerprint.md5 == ef.get("md5"):
|
||||
return {"duplicate": True, "duplicate_of": existing.id, "reason": "exact_md5_match", "similarity": 1.0}
|
||||
|
||||
|
||||
existing_phashes = ef.get("keyframe_phashes", [])
|
||||
if existing_phashes:
|
||||
total_distance = 0
|
||||
@@ -130,10 +144,15 @@ class VideoDeduplicator:
|
||||
distances = [hamming_distance(phash, ep) for ep in existing_phashes]
|
||||
min_distances.append(min(distances))
|
||||
avg_distance = sum(min_distances) / len(min_distances) if min_distances else 100
|
||||
|
||||
|
||||
if avg_distance < self.PHASH_THRESHOLD:
|
||||
return {"duplicate": True, "duplicate_of": existing.id, "reason": "phash_similar", "similarity": 1.0 - (avg_distance / 64)}
|
||||
|
||||
return {
|
||||
"duplicate": True,
|
||||
"duplicate_of": existing.id,
|
||||
"reason": "phash_similar",
|
||||
"similarity": 1.0 - (avg_distance / 64),
|
||||
}
|
||||
|
||||
return None
|
||||
|
||||
|
||||
@@ -142,24 +161,26 @@ def check_duplicate_task(self: Task, generated_video_id: str) -> dict:
|
||||
"""Celery task to check if generated video is a duplicate."""
|
||||
session = SessionLocal()
|
||||
temp_dir = tempfile.mkdtemp()
|
||||
|
||||
|
||||
try:
|
||||
video_repo = SQLAlchemyGeneratedVideoRepository(session)
|
||||
storage_service = get_storage_service()
|
||||
deduplicator = VideoDeduplicator()
|
||||
|
||||
|
||||
video = video_repo.get(generated_video_id)
|
||||
if video is None:
|
||||
raise ValueError(f"Generated video {generated_video_id} not found")
|
||||
|
||||
|
||||
local_path = os.path.join(temp_dir, f"{generated_video_id}.mp4")
|
||||
storage_key = video.file_url.split("/")[-1]
|
||||
storage_service.download_file(f"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)
|
||||
|
||||
|
||||
duplicate_result = deduplicator.check_duplicate(fingerprint, video.project_id, session)
|
||||
|
||||
|
||||
video.video_fingerprint = fingerprint.to_dict()
|
||||
if duplicate_result:
|
||||
video.is_duplicate = True
|
||||
@@ -167,13 +188,19 @@ def check_duplicate_task(self: Task, generated_video_id: str) -> dict:
|
||||
else:
|
||||
video.is_duplicate = False
|
||||
video.duplicate_of = None
|
||||
|
||||
|
||||
video_repo.update(video)
|
||||
session.commit()
|
||||
|
||||
|
||||
logger.info(f"Duplicate check completed for video {generated_video_id}: is_duplicate={video.is_duplicate}")
|
||||
|
||||
return {"ok": True, "video_id": generated_video_id, "is_duplicate": video.is_duplicate, "duplicate_of": video.duplicate_of, "fingerprint": fingerprint.to_dict()}
|
||||
|
||||
return {
|
||||
"ok": True,
|
||||
"video_id": generated_video_id,
|
||||
"is_duplicate": video.is_duplicate,
|
||||
"duplicate_of": video.duplicate_of,
|
||||
"fingerprint": fingerprint.to_dict(),
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"Duplicate check failed for {generated_video_id}: {str(e)}")
|
||||
session.rollback()
|
||||
@@ -181,4 +208,5 @@ def check_duplicate_task(self: Task, generated_video_id: str) -> dict:
|
||||
finally:
|
||||
session.close()
|
||||
import shutil
|
||||
|
||||
shutil.rmtree(temp_dir, ignore_errors=True)
|
||||
|
||||
@@ -140,14 +140,24 @@ class EditingModeProcessor:
|
||||
try:
|
||||
result = subprocess.run(
|
||||
[
|
||||
self._ffprobe_bin, "-v", "error",
|
||||
"-show_entries", "stream=width,height,r_frame_rate,duration,codec_name",
|
||||
"-show_entries", "format=duration,size",
|
||||
"-of", "json", video_path,
|
||||
self._ffprobe_bin,
|
||||
"-v",
|
||||
"error",
|
||||
"-show_entries",
|
||||
"stream=width,height,r_frame_rate,duration,codec_name",
|
||||
"-show_entries",
|
||||
"format=duration,size",
|
||||
"-of",
|
||||
"json",
|
||||
video_path,
|
||||
],
|
||||
check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True,
|
||||
check=True,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
text=True,
|
||||
)
|
||||
import json
|
||||
|
||||
data = json.loads(result.stdout)
|
||||
streams = data.get("streams", [{}])
|
||||
video_stream = next((s for s in streams if s.get("codec_type") == "video"), streams[0] if streams else {})
|
||||
@@ -169,7 +179,9 @@ class EditingModeProcessor:
|
||||
logger.warning(f"Failed to get video info for {video_path}: {e}")
|
||||
return {"width": 0, "height": 0, "fps": 25, "duration": 0, "codec": "unknown", "size": 0}
|
||||
|
||||
def _get_pip_position_offset(self, main_width: int, main_height: int, pip_width: int, pip_height: int) -> tuple[int, int]:
|
||||
def _get_pip_position_offset(
|
||||
self, main_width: int, main_height: int, pip_width: int, pip_height: int
|
||||
) -> tuple[int, int]:
|
||||
"""获取画中画位置偏移量"""
|
||||
margin = 10
|
||||
position_offsets = {
|
||||
@@ -183,16 +195,28 @@ class EditingModeProcessor:
|
||||
def _normalize_video(self, input_path: str, output_path: str) -> dict:
|
||||
"""标准化视频格式:先统一帧率,再缩放/填充"""
|
||||
command = [
|
||||
self._ffmpeg_bin, "-y", "-i", input_path,
|
||||
"-r", str(self.config.output_fps), # 先统一帧率
|
||||
"-vf", f"scale={self.config.output_width}:{self.config.output_height}:force_original_aspect_ratio=decrease,pad={self.config.output_width}:{self.config.output_height}:(ow-iw)/2:(oh-ih)/2,setsar=1",
|
||||
"-r", str(self.config.output_fps),
|
||||
"-c:v", self.config.output_codec,
|
||||
"-preset", self.config.output_preset,
|
||||
"-crf", str(self.config.output_crf),
|
||||
"-pix_fmt", "yuv420p",
|
||||
"-movflags", "+faststart",
|
||||
"-an", output_path,
|
||||
self._ffmpeg_bin,
|
||||
"-y",
|
||||
"-i",
|
||||
input_path,
|
||||
"-r",
|
||||
str(self.config.output_fps), # 先统一帧率
|
||||
"-vf",
|
||||
f"scale={self.config.output_width}:{self.config.output_height}:force_original_aspect_ratio=decrease,pad={self.config.output_width}:{self.config.output_height}:(ow-iw)/2:(oh-ih)/2,setsar=1",
|
||||
"-r",
|
||||
str(self.config.output_fps),
|
||||
"-c:v",
|
||||
self.config.output_codec,
|
||||
"-preset",
|
||||
self.config.output_preset,
|
||||
"-crf",
|
||||
str(self.config.output_crf),
|
||||
"-pix_fmt",
|
||||
"yuv420p",
|
||||
"-movflags",
|
||||
"+faststart",
|
||||
"-an",
|
||||
output_path,
|
||||
]
|
||||
self._run_ffmpeg(command)
|
||||
return self._get_video_info(output_path)
|
||||
@@ -231,11 +255,25 @@ class EditingModeProcessor:
|
||||
offset1 = durations[0] - transition / 2
|
||||
|
||||
command = [
|
||||
self._ffmpeg_bin, "-y", "-i", normalized_paths[0], "-i", normalized_paths[1],
|
||||
"-filter_complex", f"[0:v][1:v]xfade=transition=fade:duration={transition}:offset={offset1}[v]",
|
||||
"-map", "[v]",
|
||||
"-c:v", self.config.output_codec, "-preset", self.config.output_preset,
|
||||
"-crf", str(self.config.output_crf), "-pix_fmt", "yuv420p", output_path,
|
||||
self._ffmpeg_bin,
|
||||
"-y",
|
||||
"-i",
|
||||
normalized_paths[0],
|
||||
"-i",
|
||||
normalized_paths[1],
|
||||
"-filter_complex",
|
||||
f"[0:v][1:v]xfade=transition=fade:duration={transition}:offset={offset1}[v]",
|
||||
"-map",
|
||||
"[v]",
|
||||
"-c:v",
|
||||
self.config.output_codec,
|
||||
"-preset",
|
||||
self.config.output_preset,
|
||||
"-crf",
|
||||
str(self.config.output_crf),
|
||||
"-pix_fmt",
|
||||
"yuv420p",
|
||||
output_path,
|
||||
]
|
||||
self._run_ffmpeg(command)
|
||||
return output_path
|
||||
@@ -250,8 +288,17 @@ class EditingModeProcessor:
|
||||
f.write(f"file '{os.path.abspath(path)}'\n")
|
||||
|
||||
command = [
|
||||
self._ffmpeg_bin, "-y", "-f", "concat", "-safe", "0",
|
||||
"-i", concat_file, "-c", "copy", output_path,
|
||||
self._ffmpeg_bin,
|
||||
"-y",
|
||||
"-f",
|
||||
"concat",
|
||||
"-safe",
|
||||
"0",
|
||||
"-i",
|
||||
concat_file,
|
||||
"-c",
|
||||
"copy",
|
||||
output_path,
|
||||
]
|
||||
self._run_ffmpeg(command)
|
||||
|
||||
@@ -277,7 +324,9 @@ class EditingModeProcessor:
|
||||
|
||||
pip_width = int(self.config.output_width * self.config.pip_scale)
|
||||
pip_height = int(self.config.output_height * self.config.pip_scale)
|
||||
x_offset, y_offset = self._get_pip_position_offset(self.config.output_width, self.config.output_height, pip_width, pip_height)
|
||||
x_offset, y_offset = self._get_pip_position_offset(
|
||||
self.config.output_width, self.config.output_height, pip_width, pip_height
|
||||
)
|
||||
|
||||
pip_normalized = os.path.join(self.work_dir, f"pip_{os.getpid()}.mp4")
|
||||
pip_info = self._get_video_info(video_paths[1])
|
||||
@@ -285,19 +334,43 @@ class EditingModeProcessor:
|
||||
if pip_info["duration"] > main_info["duration"]:
|
||||
temp_pip = os.path.join(self.work_dir, f"pip_temp_{os.getpid()}.mp4")
|
||||
command = [
|
||||
self._ffmpeg_bin, "-y", "-i", video_paths[1], "-t", str(main_info["duration"]),
|
||||
"-vf", f"scale={pip_width}:{pip_height}",
|
||||
"-c:v", self.config.output_codec, "-preset", self.config.output_preset,
|
||||
"-crf", str(self.config.output_crf), "-pix_fmt", "yuv420p", temp_pip,
|
||||
self._ffmpeg_bin,
|
||||
"-y",
|
||||
"-i",
|
||||
video_paths[1],
|
||||
"-t",
|
||||
str(main_info["duration"]),
|
||||
"-vf",
|
||||
f"scale={pip_width}:{pip_height}",
|
||||
"-c:v",
|
||||
self.config.output_codec,
|
||||
"-preset",
|
||||
self.config.output_preset,
|
||||
"-crf",
|
||||
str(self.config.output_crf),
|
||||
"-pix_fmt",
|
||||
"yuv420p",
|
||||
temp_pip,
|
||||
]
|
||||
self._run_ffmpeg(command)
|
||||
pip_normalized_input = temp_pip
|
||||
else:
|
||||
command = [
|
||||
self._ffmpeg_bin, "-y", "-i", video_paths[1],
|
||||
"-vf", f"scale={pip_width}:{pip_height}",
|
||||
"-c:v", self.config.output_codec, "-preset", self.config.output_preset,
|
||||
"-crf", str(self.config.output_crf), "-pix_fmt", "yuv420p", pip_normalized,
|
||||
self._ffmpeg_bin,
|
||||
"-y",
|
||||
"-i",
|
||||
video_paths[1],
|
||||
"-vf",
|
||||
f"scale={pip_width}:{pip_height}",
|
||||
"-c:v",
|
||||
self.config.output_codec,
|
||||
"-preset",
|
||||
self.config.output_preset,
|
||||
"-crf",
|
||||
str(self.config.output_crf),
|
||||
"-pix_fmt",
|
||||
"yuv420p",
|
||||
pip_normalized,
|
||||
]
|
||||
self._run_ffmpeg(command)
|
||||
pip_normalized_input = pip_normalized
|
||||
@@ -305,21 +378,49 @@ class EditingModeProcessor:
|
||||
if main_info["duration"] > pip_info["duration"]:
|
||||
looped_pip = os.path.join(self.work_dir, f"pip_looped_{os.getpid()}.mp4")
|
||||
command = [
|
||||
self._ffmpeg_bin, "-y", "-stream_loop", "-1", "-i", pip_normalized_input,
|
||||
"-t", str(main_info["duration"]),
|
||||
"-vf", f"scale={pip_width}:{pip_height}",
|
||||
"-c:v", self.config.output_codec, "-preset", self.config.output_preset,
|
||||
"-crf", str(self.config.output_crf), "-pix_fmt", "yuv420p", looped_pip,
|
||||
self._ffmpeg_bin,
|
||||
"-y",
|
||||
"-stream_loop",
|
||||
"-1",
|
||||
"-i",
|
||||
pip_normalized_input,
|
||||
"-t",
|
||||
str(main_info["duration"]),
|
||||
"-vf",
|
||||
f"scale={pip_width}:{pip_height}",
|
||||
"-c:v",
|
||||
self.config.output_codec,
|
||||
"-preset",
|
||||
self.config.output_preset,
|
||||
"-crf",
|
||||
str(self.config.output_crf),
|
||||
"-pix_fmt",
|
||||
"yuv420p",
|
||||
looped_pip,
|
||||
]
|
||||
self._run_ffmpeg(command)
|
||||
pip_normalized_input = looped_pip
|
||||
|
||||
command = [
|
||||
self._ffmpeg_bin, "-y", "-i", main_normalized, "-i", pip_normalized_input,
|
||||
"-filter_complex", f"[0:v][1:v]overlay={x_offset}:{y_offset}[v]",
|
||||
"-map", "[v]",
|
||||
"-c:v", self.config.output_codec, "-preset", self.config.output_preset,
|
||||
"-crf", str(self.config.output_crf), "-pix_fmt", "yuv420p", output_path,
|
||||
self._ffmpeg_bin,
|
||||
"-y",
|
||||
"-i",
|
||||
main_normalized,
|
||||
"-i",
|
||||
pip_normalized_input,
|
||||
"-filter_complex",
|
||||
f"[0:v][1:v]overlay={x_offset}:{y_offset}[v]",
|
||||
"-map",
|
||||
"[v]",
|
||||
"-c:v",
|
||||
self.config.output_codec,
|
||||
"-preset",
|
||||
self.config.output_preset,
|
||||
"-crf",
|
||||
str(self.config.output_crf),
|
||||
"-pix_fmt",
|
||||
"yuv420p",
|
||||
output_path,
|
||||
]
|
||||
self._run_ffmpeg(command)
|
||||
|
||||
@@ -349,38 +450,87 @@ class EditingModeProcessor:
|
||||
if bg_info["duration"] < audio_duration:
|
||||
looped_bg = os.path.join(self.work_dir, f"bg_looped_{os.getpid()}.mp4")
|
||||
command = [
|
||||
self._ffmpeg_bin, "-y", "-stream_loop", "-1", "-i", bg_normalized,
|
||||
"-t", str(audio_duration),
|
||||
"-vf", f"scale={self.config.output_width}:{self.config.output_height}",
|
||||
"-c:v", self.config.output_codec, "-preset", self.config.output_preset,
|
||||
"-crf", str(self.config.output_crf), "-pix_fmt", "yuv420p", looped_bg,
|
||||
self._ffmpeg_bin,
|
||||
"-y",
|
||||
"-stream_loop",
|
||||
"-1",
|
||||
"-i",
|
||||
bg_normalized,
|
||||
"-t",
|
||||
str(audio_duration),
|
||||
"-vf",
|
||||
f"scale={self.config.output_width}:{self.config.output_height}",
|
||||
"-c:v",
|
||||
self.config.output_codec,
|
||||
"-preset",
|
||||
self.config.output_preset,
|
||||
"-crf",
|
||||
str(self.config.output_crf),
|
||||
"-pix_fmt",
|
||||
"yuv420p",
|
||||
looped_bg,
|
||||
]
|
||||
self._run_ffmpeg(command)
|
||||
bg_normalized = looped_bg
|
||||
elif bg_info["duration"] > audio_duration:
|
||||
temp_bg = os.path.join(self.work_dir, f"bg_trimmed_{os.getpid()}.mp4")
|
||||
command = [
|
||||
self._ffmpeg_bin, "-y", "-i", bg_normalized, "-t", str(audio_duration),
|
||||
"-c:v", "copy", temp_bg,
|
||||
self._ffmpeg_bin,
|
||||
"-y",
|
||||
"-i",
|
||||
bg_normalized,
|
||||
"-t",
|
||||
str(audio_duration),
|
||||
"-c:v",
|
||||
"copy",
|
||||
temp_bg,
|
||||
]
|
||||
self._run_ffmpeg(command)
|
||||
bg_normalized = temp_bg
|
||||
|
||||
blurred_bg = os.path.join(self.work_dir, f"bg_blurred_{os.getpid()}.mp4")
|
||||
command = [
|
||||
self._ffmpeg_bin, "-y", "-i", bg_normalized,
|
||||
"-vf", f"boxblur=5:5,scale={self.config.output_width}:{self.config.output_height}",
|
||||
"-c:v", self.config.output_codec, "-preset", self.config.output_preset,
|
||||
"-crf", str(self.config.output_crf), "-pix_fmt", "yuv420p", blurred_bg,
|
||||
self._ffmpeg_bin,
|
||||
"-y",
|
||||
"-i",
|
||||
bg_normalized,
|
||||
"-vf",
|
||||
f"boxblur=5:5,scale={self.config.output_width}:{self.config.output_height}",
|
||||
"-c:v",
|
||||
self.config.output_codec,
|
||||
"-preset",
|
||||
self.config.output_preset,
|
||||
"-crf",
|
||||
str(self.config.output_crf),
|
||||
"-pix_fmt",
|
||||
"yuv420p",
|
||||
blurred_bg,
|
||||
]
|
||||
self._run_ffmpeg(command)
|
||||
|
||||
command = [
|
||||
self._ffmpeg_bin, "-y", "-i", blurred_bg, "-i", audio_path,
|
||||
"-filter_complex", "[0:v]drawbox=x=0:y=0:w=iw:h=ih:color=black@0.3:t=fill[v]",
|
||||
"-map", "[v]", "-map", "1:a",
|
||||
"-c:v", self.config.output_codec, "-preset", self.config.output_preset,
|
||||
"-crf", str(self.config.output_crf), "-pix_fmt", "yuv420p", "-shortest", output_path,
|
||||
self._ffmpeg_bin,
|
||||
"-y",
|
||||
"-i",
|
||||
blurred_bg,
|
||||
"-i",
|
||||
audio_path,
|
||||
"-filter_complex",
|
||||
"[0:v]drawbox=x=0:y=0:w=iw:h=ih:color=black@0.3:t=fill[v]",
|
||||
"-map",
|
||||
"[v]",
|
||||
"-map",
|
||||
"1:a",
|
||||
"-c:v",
|
||||
self.config.output_codec,
|
||||
"-preset",
|
||||
self.config.output_preset,
|
||||
"-crf",
|
||||
str(self.config.output_crf),
|
||||
"-pix_fmt",
|
||||
"yuv420p",
|
||||
"-shortest",
|
||||
output_path,
|
||||
]
|
||||
self._run_ffmpeg(command)
|
||||
|
||||
@@ -414,39 +564,97 @@ class EditingModeProcessor:
|
||||
|
||||
pip_width = int(self.config.output_width * self.config.pip_scale)
|
||||
pip_height = int(self.config.output_height * self.config.pip_scale)
|
||||
x_offset, y_offset = self._get_pip_position_offset(self.config.output_width, self.config.output_height, pip_width, pip_height)
|
||||
x_offset, y_offset = self._get_pip_position_offset(
|
||||
self.config.output_width, self.config.output_height, pip_width, pip_height
|
||||
)
|
||||
|
||||
voice_adjusted = os.path.join(self.work_dir, f"voice_adj_{os.getpid()}.mp4")
|
||||
command = [
|
||||
self._ffmpeg_bin, "-y", "-i", voice_normalized, "-t", str(final_duration),
|
||||
"-vf", f"scale={pip_width}:{pip_height}",
|
||||
"-c:v", self.config.output_codec, "-preset", self.config.output_preset,
|
||||
"-crf", str(self.config.output_crf), "-pix_fmt", "yuv420p", voice_adjusted,
|
||||
self._ffmpeg_bin,
|
||||
"-y",
|
||||
"-i",
|
||||
voice_normalized,
|
||||
"-t",
|
||||
str(final_duration),
|
||||
"-vf",
|
||||
f"scale={pip_width}:{pip_height}",
|
||||
"-c:v",
|
||||
self.config.output_codec,
|
||||
"-preset",
|
||||
self.config.output_preset,
|
||||
"-crf",
|
||||
str(self.config.output_crf),
|
||||
"-pix_fmt",
|
||||
"yuv420p",
|
||||
voice_adjusted,
|
||||
]
|
||||
self._run_ffmpeg(command)
|
||||
|
||||
bg_adjusted = os.path.join(self.work_dir, f"bg_adj_{os.getpid()}.mp4")
|
||||
command = [
|
||||
self._ffmpeg_bin, "-y", "-i", bg_normalized, "-t", str(final_duration),
|
||||
"-c:v", "copy", bg_adjusted,
|
||||
self._ffmpeg_bin,
|
||||
"-y",
|
||||
"-i",
|
||||
bg_normalized,
|
||||
"-t",
|
||||
str(final_duration),
|
||||
"-c:v",
|
||||
"copy",
|
||||
bg_adjusted,
|
||||
]
|
||||
self._run_ffmpeg(command)
|
||||
|
||||
if audio_path:
|
||||
command = [
|
||||
self._ffmpeg_bin, "-y", "-i", bg_adjusted, "-i", voice_adjusted, "-i", audio_path,
|
||||
"-filter_complex", f"[0:v][1:v]overlay={x_offset}:{y_offset}[v]",
|
||||
"-map", "[v]", "-map", "2:a", "-shortest",
|
||||
"-c:v", self.config.output_codec, "-preset", self.config.output_preset,
|
||||
"-crf", str(self.config.output_crf), "-pix_fmt", "yuv420p", output_path,
|
||||
self._ffmpeg_bin,
|
||||
"-y",
|
||||
"-i",
|
||||
bg_adjusted,
|
||||
"-i",
|
||||
voice_adjusted,
|
||||
"-i",
|
||||
audio_path,
|
||||
"-filter_complex",
|
||||
f"[0:v][1:v]overlay={x_offset}:{y_offset}[v]",
|
||||
"-map",
|
||||
"[v]",
|
||||
"-map",
|
||||
"2:a",
|
||||
"-shortest",
|
||||
"-c:v",
|
||||
self.config.output_codec,
|
||||
"-preset",
|
||||
self.config.output_preset,
|
||||
"-crf",
|
||||
str(self.config.output_crf),
|
||||
"-pix_fmt",
|
||||
"yuv420p",
|
||||
output_path,
|
||||
]
|
||||
else:
|
||||
command = [
|
||||
self._ffmpeg_bin, "-y", "-i", bg_adjusted, "-i", voice_adjusted,
|
||||
"-filter_complex", f"[0:v][1:v]overlay={x_offset}:{y_offset}[v]",
|
||||
"-map", "[v]", "-map", "1:a", "-shortest",
|
||||
"-c:v", self.config.output_codec, "-preset", self.config.output_preset,
|
||||
"-crf", str(self.config.output_crf), "-pix_fmt", "yuv420p", output_path,
|
||||
self._ffmpeg_bin,
|
||||
"-y",
|
||||
"-i",
|
||||
bg_adjusted,
|
||||
"-i",
|
||||
voice_adjusted,
|
||||
"-filter_complex",
|
||||
f"[0:v][1:v]overlay={x_offset}:{y_offset}[v]",
|
||||
"-map",
|
||||
"[v]",
|
||||
"-map",
|
||||
"1:a",
|
||||
"-shortest",
|
||||
"-c:v",
|
||||
self.config.output_codec,
|
||||
"-preset",
|
||||
self.config.output_preset,
|
||||
"-crf",
|
||||
str(self.config.output_crf),
|
||||
"-pix_fmt",
|
||||
"yuv420p",
|
||||
output_path,
|
||||
]
|
||||
self._run_ffmpeg(command)
|
||||
|
||||
|
||||
@@ -7,23 +7,36 @@ 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}")
|
||||
|
||||
|
||||
__all__ = ["classify_asset", "generate_video", "healthcheck", "ingest_asset", "extract_voice_task", "extract_background_task"]
|
||||
__all__ = [
|
||||
"classify_asset",
|
||||
"generate_video",
|
||||
"healthcheck",
|
||||
"ingest_asset",
|
||||
"extract_voice_task",
|
||||
"extract_background_task",
|
||||
]
|
||||
|
||||
@@ -27,6 +27,7 @@ logger = logging.getLogger(__name__)
|
||||
@dataclass
|
||||
class VideoInfo:
|
||||
"""视频基本信息"""
|
||||
|
||||
width: int = 0
|
||||
height: int = 0
|
||||
fps: float = 0.0
|
||||
@@ -40,6 +41,7 @@ class VideoInfo:
|
||||
@dataclass
|
||||
class ColorAnalysis:
|
||||
"""色彩分析结果"""
|
||||
|
||||
dominant_hue: float = 0.0 # 主色调 (0-360)
|
||||
green_ratio: float = 0.0 # 绿色占比
|
||||
warm_ratio: float = 0.0 # 暖色调占比
|
||||
@@ -51,6 +53,7 @@ class ColorAnalysis:
|
||||
@dataclass
|
||||
class MotionAnalysis:
|
||||
"""运动分析结果"""
|
||||
|
||||
motion_score: float = 0.0 # 运动幅度 (0-1)
|
||||
scene_changes: int = 0 # 场景切换次数
|
||||
|
||||
@@ -58,6 +61,7 @@ class MotionAnalysis:
|
||||
@dataclass
|
||||
class AudioAnalysis:
|
||||
"""音频分析结果"""
|
||||
|
||||
has_audio: bool = False
|
||||
speech_ratio: float = 0.0 # 人声比例
|
||||
music_ratio: float = 0.0 # 音乐比例
|
||||
@@ -67,6 +71,7 @@ class AudioAnalysis:
|
||||
@dataclass
|
||||
class ClassificationResult:
|
||||
"""分类结果"""
|
||||
|
||||
category: AssetClassification
|
||||
confidence: float
|
||||
scores: dict[str, float] = field(default_factory=dict)
|
||||
@@ -75,6 +80,7 @@ class ClassificationResult:
|
||||
@dataclass
|
||||
class QualityScore:
|
||||
"""质量评分结果"""
|
||||
|
||||
total: float
|
||||
resolution_score: float = 0.0
|
||||
fps_score: float = 0.0
|
||||
@@ -86,14 +92,14 @@ class QualityScore:
|
||||
class AssetAnalyzer:
|
||||
"""
|
||||
轻量级视频素材分析器
|
||||
|
||||
|
||||
使用 FFmpeg + NumPy 进行视频特征分析,不依赖外部 AI API。
|
||||
"""
|
||||
|
||||
def __init__(self, video_path: str, temp_dir: str | None = None):
|
||||
"""
|
||||
初始化分析器
|
||||
|
||||
|
||||
Args:
|
||||
video_path: 视频文件路径
|
||||
temp_dir: 临时目录,用于存储提取的帧
|
||||
@@ -111,6 +117,7 @@ class AssetAnalyzer:
|
||||
"""清理临时目录"""
|
||||
try:
|
||||
import shutil
|
||||
|
||||
if os.path.exists(self._temp_dir):
|
||||
shutil.rmtree(self._temp_dir)
|
||||
except Exception:
|
||||
@@ -126,8 +133,10 @@ class AssetAnalyzer:
|
||||
try:
|
||||
cmd = [
|
||||
"ffprobe",
|
||||
"-v", "quiet",
|
||||
"-print_format", "json",
|
||||
"-v",
|
||||
"quiet",
|
||||
"-print_format",
|
||||
"json",
|
||||
"-show_format",
|
||||
"-show_streams",
|
||||
self.video_path,
|
||||
@@ -174,10 +183,10 @@ class AssetAnalyzer:
|
||||
def extract_frames(self, count: int = 10, max_frames: int = 30) -> list[np.ndarray]:
|
||||
"""
|
||||
从视频中均匀抽取帧
|
||||
|
||||
|
||||
Args:
|
||||
count: 抽取的帧数
|
||||
|
||||
|
||||
Returns:
|
||||
帧数据列表 (RGB 格式)
|
||||
"""
|
||||
@@ -203,11 +212,16 @@ class AssetAnalyzer:
|
||||
cmd = [
|
||||
"ffmpeg",
|
||||
"-y", # 覆盖输出文件
|
||||
"-ss", str(timestamp),
|
||||
"-i", self.video_path,
|
||||
"-vframes", "1",
|
||||
"-q:v", "2", # 高质量
|
||||
"-f", "image2",
|
||||
"-ss",
|
||||
str(timestamp),
|
||||
"-i",
|
||||
self.video_path,
|
||||
"-vframes",
|
||||
"1",
|
||||
"-q:v",
|
||||
"2", # 高质量
|
||||
"-f",
|
||||
"image2",
|
||||
output_path,
|
||||
]
|
||||
|
||||
@@ -234,6 +248,7 @@ class AssetAnalyzer:
|
||||
"""加载图片为 numpy 数组 (RGB 格式)"""
|
||||
try:
|
||||
from PIL import Image
|
||||
|
||||
img = Image.open(path)
|
||||
if img.mode != "RGB":
|
||||
img = img.convert("RGB")
|
||||
@@ -245,7 +260,7 @@ class AssetAnalyzer:
|
||||
def analyze_color_distribution(self, frames: list[np.ndarray] | None = None) -> ColorAnalysis:
|
||||
"""
|
||||
分析色彩分布 (HSV 空间)
|
||||
|
||||
|
||||
Returns:
|
||||
ColorAnalysis 对象
|
||||
"""
|
||||
@@ -312,7 +327,7 @@ class AssetAnalyzer:
|
||||
def analyze_motion(self, frames: list[np.ndarray] | None = None) -> MotionAnalysis:
|
||||
"""
|
||||
分析画面运动幅度
|
||||
|
||||
|
||||
Returns:
|
||||
MotionAnalysis 对象
|
||||
"""
|
||||
@@ -353,7 +368,7 @@ class AssetAnalyzer:
|
||||
def analyze_audio(self) -> AudioAnalysis:
|
||||
"""
|
||||
分析音频特征
|
||||
|
||||
|
||||
Returns:
|
||||
AudioAnalysis 对象
|
||||
"""
|
||||
@@ -371,11 +386,15 @@ class AssetAnalyzer:
|
||||
cmd = [
|
||||
"ffmpeg",
|
||||
"-y",
|
||||
"-i", self.video_path,
|
||||
"-i",
|
||||
self.video_path,
|
||||
"-vn", # 不要视频
|
||||
"-ac", "1", # 单声道
|
||||
"-ar", "8000", # 降低采样率
|
||||
"-f", "wav",
|
||||
"-ac",
|
||||
"1", # 单声道
|
||||
"-ar",
|
||||
"8000", # 降低采样率
|
||||
"-f",
|
||||
"wav",
|
||||
audio_path,
|
||||
]
|
||||
|
||||
@@ -389,6 +408,7 @@ class AssetAnalyzer:
|
||||
if result_audio.returncode == 0 and os.path.exists(audio_path):
|
||||
# 读取音频数据
|
||||
import struct
|
||||
|
||||
with open(audio_path, "rb") as f:
|
||||
# 跳过 WAV 头
|
||||
f.read(44)
|
||||
@@ -396,16 +416,13 @@ class AssetAnalyzer:
|
||||
|
||||
if len(audio_data) >= 2:
|
||||
# 转换为 numpy 数组
|
||||
audio_samples = np.array(
|
||||
struct.unpack(f"<{len(audio_data)//2}h", audio_data),
|
||||
dtype=float
|
||||
)
|
||||
audio_samples = np.array(struct.unpack(f"<{len(audio_data)//2}h", audio_data), dtype=float)
|
||||
audio_samples = audio_samples / 32768.0
|
||||
|
||||
if len(audio_samples) > 0:
|
||||
# 简单频谱分析
|
||||
fft = np.abs(np.fft.rfft(audio_samples[:min(len(audio_samples), 8000)]))
|
||||
freqs = np.fft.rfftfreq(min(len(audio_samples), 8000), 1/8000)
|
||||
fft = np.abs(np.fft.rfft(audio_samples[: min(len(audio_samples), 8000)]))
|
||||
freqs = np.fft.rfftfreq(min(len(audio_samples), 8000), 1 / 8000)
|
||||
|
||||
# 人声频率: 300-3400 Hz
|
||||
speech_mask = (freqs >= 300) & (freqs <= 3400)
|
||||
@@ -433,7 +450,7 @@ class AssetAnalyzer:
|
||||
def classify(self) -> ClassificationResult:
|
||||
"""
|
||||
综合分析得出分类结果
|
||||
|
||||
|
||||
Returns:
|
||||
ClassificationResult 对象
|
||||
"""
|
||||
@@ -445,7 +462,7 @@ class AssetAnalyzer:
|
||||
|
||||
# 计算各类别得分
|
||||
scores = self._calculate_category_scores(color, motion, audio)
|
||||
|
||||
|
||||
# 找最高分
|
||||
if not scores:
|
||||
return ClassificationResult(
|
||||
@@ -472,12 +489,12 @@ class AssetAnalyzer:
|
||||
) -> dict[str, float]:
|
||||
"""
|
||||
计算各类别的置信度得分
|
||||
|
||||
|
||||
Args:
|
||||
color: 色彩分析结果
|
||||
motion: 运动分析结果
|
||||
audio: 音频分析结果
|
||||
|
||||
|
||||
Returns:
|
||||
各类别得分字典
|
||||
"""
|
||||
@@ -576,7 +593,7 @@ class AssetAnalyzer:
|
||||
def calculate_quality_score(self) -> QualityScore:
|
||||
"""
|
||||
计算视频质量综合评分 (0-100)
|
||||
|
||||
|
||||
评分维度:
|
||||
1. 分辨率得分 (25分)
|
||||
2. 帧率得分 (20分)
|
||||
@@ -661,7 +678,7 @@ class AssetAnalyzer:
|
||||
def _score_clarity(self, frames: list[np.ndarray]) -> float:
|
||||
"""
|
||||
清晰度评分 (满分 20)
|
||||
|
||||
|
||||
使用 Laplacian 方差评估画面清晰度
|
||||
高方差 = 细节丰富 = 高分
|
||||
"""
|
||||
@@ -679,15 +696,12 @@ class AssetAnalyzer:
|
||||
gray = frame
|
||||
|
||||
# Laplacian 算子
|
||||
laplacian = np.array([
|
||||
[0, 1, 0],
|
||||
[1, -4, 1],
|
||||
[0, 1, 0]
|
||||
], dtype=np.float32)
|
||||
laplacian = np.array([[0, 1, 0], [1, -4, 1], [0, 1, 0]], dtype=np.float32)
|
||||
|
||||
# 手动计算卷积
|
||||
from scipy import signal
|
||||
laplacian_img = signal.convolve2d(gray.astype(float), laplacian, mode='same')
|
||||
|
||||
laplacian_img = signal.convolve2d(gray.astype(float), laplacian, mode="same")
|
||||
variance = np.var(laplacian_img)
|
||||
variances.append(variance)
|
||||
|
||||
@@ -706,7 +720,7 @@ class AssetAnalyzer:
|
||||
def _score_stability(self, frames: list[np.ndarray]) -> float:
|
||||
"""
|
||||
稳定性评分 (满分 15)
|
||||
|
||||
|
||||
分析帧间位移方差
|
||||
画面稳定 = 高分
|
||||
剧烈抖动 = 低分
|
||||
@@ -722,14 +736,10 @@ class AssetAnalyzer:
|
||||
scale = 0.25
|
||||
new_h = int(frames[i].shape[0] * scale)
|
||||
new_w = int(frames[i].shape[1] * scale)
|
||||
frame1_small = np.array(
|
||||
Image.fromarray(frames[i]).resize((new_w, new_h))
|
||||
)
|
||||
frame1_small = np.array(Image.fromarray(frames[i]).resize((new_w, new_h)))
|
||||
new_h2 = int(frames[i + 1].shape[0] * scale)
|
||||
new_w2 = int(frames[i + 1].shape[1] * scale)
|
||||
frame2_small = np.array(
|
||||
Image.fromarray(frames[i + 1]).resize((new_w2, new_h2))
|
||||
)
|
||||
frame2_small = np.array(Image.fromarray(frames[i + 1]).resize((new_w2, new_h2)))
|
||||
|
||||
# 简单位移检测:灰度差
|
||||
gray1 = np.mean(frame1_small, axis=2) if len(frame1_small.shape) == 3 else frame1_small
|
||||
@@ -756,10 +766,10 @@ class AssetAnalyzer:
|
||||
def classify_asset_real(video_path: str) -> tuple[str, float]:
|
||||
"""
|
||||
真实分类入口函数
|
||||
|
||||
|
||||
Args:
|
||||
video_path: 视频文件路径
|
||||
|
||||
|
||||
Returns:
|
||||
(分类类别, 置信度)
|
||||
"""
|
||||
@@ -775,10 +785,10 @@ def classify_asset_real(video_path: str) -> tuple[str, float]:
|
||||
def calculate_quality_score_real(video_path: str) -> float:
|
||||
"""
|
||||
质量评分入口函数
|
||||
|
||||
|
||||
Args:
|
||||
video_path: 视频文件路径
|
||||
|
||||
|
||||
Returns:
|
||||
质量评分 (0-100)
|
||||
"""
|
||||
|
||||
@@ -75,8 +75,7 @@ def classify_asset(self, job_id: str) -> dict:
|
||||
session.commit()
|
||||
|
||||
logger.info(
|
||||
f"Classification completed for asset {asset.id}: "
|
||||
f"category={classification}, confidence={confidence}"
|
||||
f"Classification completed for asset {asset.id}: " f"category={classification}, confidence={confidence}"
|
||||
)
|
||||
|
||||
return {
|
||||
|
||||
@@ -78,12 +78,19 @@ def _probe_duration(local_path: Path) -> float:
|
||||
try:
|
||||
result = subprocess.run(
|
||||
[
|
||||
FFPROBE_BIN, "-v", "error",
|
||||
"-show_entries", "format=duration",
|
||||
"-of", "default=noprint_wrappers=1:nokey=1",
|
||||
FFPROBE_BIN,
|
||||
"-v",
|
||||
"error",
|
||||
"-show_entries",
|
||||
"format=duration",
|
||||
"-of",
|
||||
"default=noprint_wrappers=1:nokey=1",
|
||||
str(local_path),
|
||||
],
|
||||
check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True,
|
||||
check=True,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
text=True,
|
||||
) # nosec B603
|
||||
return round(float(result.stdout.strip()), 3)
|
||||
except Exception:
|
||||
@@ -95,10 +102,20 @@ def _create_fallback_clip(output_path: Path, title: str) -> None:
|
||||
safe_title = title.replace(":", "\\:").replace("'", "\\'")[:80]
|
||||
_run_ffmpeg(
|
||||
[
|
||||
FFMPEG_BIN, "-y", "-f", "lavfi",
|
||||
"-i", f"color=c=#111827:s={OUTPUT_WIDTH}x{OUTPUT_HEIGHT}:d={OUTPUT_DURATION_SECONDS}:r={int(OUTPUT_FPS)}",
|
||||
"-vf", f"drawtext=text='{safe_title}':fontcolor=white:fontsize=48:x=(w-text_w)/2:y=(h-text_h)/2",
|
||||
"-c:v", "libx264", "-pix_fmt", "yuv420p", "-movflags", "+faststart",
|
||||
FFMPEG_BIN,
|
||||
"-y",
|
||||
"-f",
|
||||
"lavfi",
|
||||
"-i",
|
||||
f"color=c=#111827:s={OUTPUT_WIDTH}x{OUTPUT_HEIGHT}:d={OUTPUT_DURATION_SECONDS}:r={int(OUTPUT_FPS)}",
|
||||
"-vf",
|
||||
f"drawtext=text='{safe_title}':fontcolor=white:fontsize=48:x=(w-text_w)/2:y=(h-text_h)/2",
|
||||
"-c:v",
|
||||
"libx264",
|
||||
"-pix_fmt",
|
||||
"yuv420p",
|
||||
"-movflags",
|
||||
"+faststart",
|
||||
str(output_path),
|
||||
]
|
||||
)
|
||||
@@ -144,11 +161,16 @@ def _download_library_assets(
|
||||
|
||||
try:
|
||||
# 查询素材库中的视频素材
|
||||
assets = session.query(AssetModel).filter(
|
||||
AssetModel.asset_library_id == asset_library_id,
|
||||
AssetModel.status == "ready",
|
||||
AssetModel.file_type.in_(["video", "video/mp4", "video/quicktime"]),
|
||||
).order_by(AssetModel.created_at).all()
|
||||
assets = (
|
||||
session.query(AssetModel)
|
||||
.filter(
|
||||
AssetModel.asset_library_id == asset_library_id,
|
||||
AssetModel.status == "ready",
|
||||
AssetModel.file_type.in_(["video", "video/mp4", "video/quicktime"]),
|
||||
)
|
||||
.order_by(AssetModel.created_at)
|
||||
.all()
|
||||
)
|
||||
|
||||
if not assets:
|
||||
logger.info(f"No video assets found in library {asset_library_id}")
|
||||
|
||||
@@ -37,8 +37,10 @@ def extract_media_metadata(file_url: str, media_type: str) -> dict:
|
||||
# 使用 ffprobe 提取视频元数据
|
||||
cmd = [
|
||||
"ffprobe",
|
||||
"-v", "quiet",
|
||||
"-print_format", "json",
|
||||
"-v",
|
||||
"quiet",
|
||||
"-print_format",
|
||||
"json",
|
||||
"-show_format",
|
||||
"-show_streams",
|
||||
file_url,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
"""Voice extraction tasks - extract voice tracks and background music from videos."""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
@@ -31,10 +32,31 @@ class VoiceExtractor:
|
||||
raise RuntimeError(f"FFmpeg failed: {result.stderr}")
|
||||
return result
|
||||
|
||||
def extract_voice(self, input_path: str, output_path: str, highpass: int = 200, bandpass_freq: int = 300, bandpass_width: int = 3000, noise_reduction: int = 20) -> str:
|
||||
def extract_voice(
|
||||
self,
|
||||
input_path: str,
|
||||
output_path: str,
|
||||
highpass: int = 200,
|
||||
bandpass_freq: int = 300,
|
||||
bandpass_width: int = 3000,
|
||||
noise_reduction: int = 20,
|
||||
) -> str:
|
||||
"""Extract voice track from video using FFmpeg."""
|
||||
os.makedirs(os.path.dirname(output_path), exist_ok=True)
|
||||
cmd = ["ffmpeg", "-y", "-i", input_path, "-af", f"highpass=f={highpass},afftdn=bn={noise_reduction},bandpass=f={bandpass_freq}:width_type=h:width={bandpass_width},loudnorm", "-vn", "-acodec", "libmp3lame", "-q:a", "2", output_path]
|
||||
cmd = [
|
||||
"ffmpeg",
|
||||
"-y",
|
||||
"-i",
|
||||
input_path,
|
||||
"-af",
|
||||
f"highpass=f={highpass},afftdn=bn={noise_reduction},bandpass=f={bandpass_freq}:width_type=h:width={bandpass_width},loudnorm",
|
||||
"-vn",
|
||||
"-acodec",
|
||||
"libmp3lame",
|
||||
"-q:a",
|
||||
"2",
|
||||
output_path,
|
||||
]
|
||||
self._run_ffmpeg(cmd)
|
||||
logger.info(f"Voice extracted to: {output_path}")
|
||||
return output_path
|
||||
@@ -42,7 +64,20 @@ class VoiceExtractor:
|
||||
def extract_background(self, input_path: str, output_path: str, lowpass: int = 200) -> str:
|
||||
"""Extract background music from video."""
|
||||
os.makedirs(os.path.dirname(output_path), exist_ok=True)
|
||||
cmd = ["ffmpeg", "-y", "-i", input_path, "-af", f"lowpass=f={lowpass},loudnorm", "-vn", "-acodec", "libmp3lame", "-q:a", "2", output_path]
|
||||
cmd = [
|
||||
"ffmpeg",
|
||||
"-y",
|
||||
"-i",
|
||||
input_path,
|
||||
"-af",
|
||||
f"lowpass=f={lowpass},loudnorm",
|
||||
"-vn",
|
||||
"-acodec",
|
||||
"libmp3lame",
|
||||
"-q:a",
|
||||
"2",
|
||||
output_path,
|
||||
]
|
||||
self._run_ffmpeg(cmd)
|
||||
logger.info(f"Background extracted to: {output_path}")
|
||||
return output_path
|
||||
@@ -80,6 +115,7 @@ def extract_voice_task(self: Task, asset_id: str) -> dict:
|
||||
finally:
|
||||
session.close()
|
||||
import shutil
|
||||
|
||||
shutil.rmtree(temp_dir, ignore_errors=True)
|
||||
|
||||
|
||||
@@ -115,4 +151,5 @@ def extract_background_task(self: Task, asset_id: str) -> dict:
|
||||
finally:
|
||||
session.close()
|
||||
import shutil
|
||||
|
||||
shutil.rmtree(temp_dir, ignore_errors=True)
|
||||
|
||||
+3
-3
@@ -1,8 +1,8 @@
|
||||
content = open('F:/openclaw-saas/scripts/init_tracker_data.py', 'r', encoding='utf-8').read()
|
||||
content = open("F:/openclaw-saas/scripts/init_tracker_data.py", "r", encoding="utf-8").read()
|
||||
content = content.replace('"title":', '"name":')
|
||||
content = content.replace('"URGENT"', '"urgent"')
|
||||
content = content.replace('"HIGH"', '"high"')
|
||||
content = content.replace('"MEDIUM"', '"medium"')
|
||||
content = content.replace('"LOW"', '"low"')
|
||||
open('F:/openclaw-saas/scripts/init_tracker_data.py', 'w', encoding='utf-8').write(content)
|
||||
print('Fixed all fields')
|
||||
open("F:/openclaw-saas/scripts/init_tracker_data.py", "w", encoding="utf-8").write(content)
|
||||
print("Fixed all fields")
|
||||
|
||||
+92
-88
@@ -4,15 +4,15 @@ import sqlite3
|
||||
from datetime import datetime
|
||||
|
||||
# 删除旧数据库,重新创建
|
||||
conn = sqlite3.connect('/app/tracker.db')
|
||||
conn = sqlite3.connect("/app/tracker.db")
|
||||
cursor = conn.cursor()
|
||||
|
||||
# 删除所有表
|
||||
cursor.execute('DROP TABLE IF EXISTS tasks')
|
||||
cursor.execute('DROP TABLE IF EXISTS milestones')
|
||||
cursor.execute("DROP TABLE IF EXISTS tasks")
|
||||
cursor.execute("DROP TABLE IF EXISTS milestones")
|
||||
|
||||
# 重新创建表
|
||||
cursor.execute('''CREATE TABLE tasks (
|
||||
cursor.execute("""CREATE TABLE tasks (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL,
|
||||
description TEXT,
|
||||
@@ -22,9 +22,9 @@ cursor.execute('''CREATE TABLE tasks (
|
||||
priority TEXT DEFAULT 'medium',
|
||||
created_at TEXT,
|
||||
updated_at TEXT
|
||||
)''')
|
||||
)""")
|
||||
|
||||
cursor.execute('''CREATE TABLE milestones (
|
||||
cursor.execute("""CREATE TABLE milestones (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL,
|
||||
phase TEXT,
|
||||
@@ -33,119 +33,123 @@ cursor.execute('''CREATE TABLE milestones (
|
||||
status TEXT DEFAULT 'pending',
|
||||
description TEXT,
|
||||
created_at TEXT
|
||||
)''')
|
||||
)""")
|
||||
|
||||
# Phase 4 已完成的任务(56个)
|
||||
phase4_tasks = [
|
||||
('JWT Service 实现', '实现 access token 和 refresh token', 'completed', 'high'),
|
||||
('Password Hasher 实现', 'bcrypt 密码加密 cost=12', 'completed', 'high'),
|
||||
('Redis Session Store', '基于 Redis 的 Session 存储', 'completed', 'high'),
|
||||
('Email Service 实现', 'SMTP 邮件服务', 'completed', 'high'),
|
||||
('用户注册 API', '用户注册接口', 'completed', 'high'),
|
||||
('邮箱验证 API', '邮箱验证接口', 'completed', 'high'),
|
||||
('用户登录 API', '用户登录接口', 'completed', 'high'),
|
||||
('用户登出 API', '用户登出接口', 'completed', 'high'),
|
||||
('密码重置 API', '密码重置流程', 'completed', 'medium'),
|
||||
('创建工作空间 API', '创建工作空间接口', 'completed', 'high'),
|
||||
('邀请成员 API', '邀请成员接口', 'completed', 'high'),
|
||||
('接受拒绝邀请 API', '处理邀请接口', 'completed', 'high'),
|
||||
('移除成员 API', '移除成员接口', 'completed', 'medium'),
|
||||
('离开工作空间 API', '成员离开接口', 'completed', 'medium'),
|
||||
('更新成员角色 API', '修改成员角色', 'completed', 'high'),
|
||||
('列出工作空间 API', '查询工作空间列表', 'completed', 'medium'),
|
||||
('工作空间详情 API', '工作空间详情', 'completed', 'medium'),
|
||||
('列出成员 API', '查询成员列表', 'completed', 'medium'),
|
||||
('Permission Checker', '权限检查器', 'completed', 'high'),
|
||||
('订阅计划定义', 'Free Pro Enterprise', 'completed', 'high'),
|
||||
('升级订阅 API', '订阅升级接口', 'completed', 'high'),
|
||||
('取消订阅 API', '订阅取消接口', 'completed', 'medium'),
|
||||
('配额检查工具', '配额管理工具', 'completed', 'high'),
|
||||
('UserRepository 接口', 'User 仓储接口', 'completed', 'high'),
|
||||
('UserRepository InMemory 实现', 'InMemory 实现', 'completed', 'high'),
|
||||
('WorkspaceRepository 接口', 'Workspace 仓储接口', 'completed', 'high'),
|
||||
('WorkspaceRepository InMemory 实现', 'InMemory 实现', 'completed', 'high'),
|
||||
('WorkspaceMemberRepository 接口', 'Member 仓储接口', 'completed', 'high'),
|
||||
('WorkspaceMemberRepository InMemory 实现', 'InMemory 实现', 'completed', 'high'),
|
||||
('WorkspaceInvitationRepository 接口', 'Invitation 仓储接口', 'completed', 'high'),
|
||||
('WorkspaceInvitationRepository InMemory 实现', 'InMemory 实现', 'completed', 'high'),
|
||||
('SubscriptionRepository 接口', 'Subscription 仓储接口', 'completed', 'high'),
|
||||
('SubscriptionRepository InMemory 实现', 'InMemory 实现', 'completed', 'high'),
|
||||
('PostgreSQL Repository 实现', 'PostgreSQL 数据库适配器', 'completed', 'high'),
|
||||
('Database Migration 脚本', '数据库迁移脚本', 'completed', 'high'),
|
||||
('FastAPI 路由层', 'API 路由实现', 'completed', 'high'),
|
||||
('API 文档 Swagger', 'Swagger 文档', 'completed', 'medium'),
|
||||
('错误处理中间件', '统一错误处理', 'completed', 'high'),
|
||||
('参数验证', 'Pydantic 参数验证', 'completed', 'high'),
|
||||
('Docker 配置', 'Docker Compose 配置', 'completed', 'high'),
|
||||
('Kubernetes 配置', 'K8s 部署配置', 'completed', 'medium'),
|
||||
('健康检查接口', 'Health Check API', 'completed', 'high'),
|
||||
('Celery Worker 配置', '异步任务配置', 'completed', 'medium'),
|
||||
('Redis 缓存集成', 'Redis 缓存', 'completed', 'high'),
|
||||
('GitHub Actions CI/CD', 'CI/CD 流水线', 'completed', 'high'),
|
||||
('单元测试 170个', '170 个单元测试', 'completed', 'high'),
|
||||
('集成测试', '12 个集成测试', 'completed', 'medium'),
|
||||
('性能测试', '性能测试用例', 'completed', 'medium'),
|
||||
('连接池优化', '5-6x 性能优化', 'completed', 'high'),
|
||||
('API 文档编写', 'API 使用文档', 'completed', 'medium'),
|
||||
('部署文档', '部署指南', 'completed', 'medium'),
|
||||
('开发文档', '开发指南', 'completed', 'medium'),
|
||||
('MIT 开源许可', 'MIT License', 'completed', 'low'),
|
||||
('README 完善', 'README.md', 'completed', 'medium'),
|
||||
('CONTRIBUTING 指南', '贡献指南', 'completed', 'low'),
|
||||
('CODE_OF_CONDUCT', '行为准则', 'completed', 'low'),
|
||||
("JWT Service 实现", "实现 access token 和 refresh token", "completed", "high"),
|
||||
("Password Hasher 实现", "bcrypt 密码加密 cost=12", "completed", "high"),
|
||||
("Redis Session Store", "基于 Redis 的 Session 存储", "completed", "high"),
|
||||
("Email Service 实现", "SMTP 邮件服务", "completed", "high"),
|
||||
("用户注册 API", "用户注册接口", "completed", "high"),
|
||||
("邮箱验证 API", "邮箱验证接口", "completed", "high"),
|
||||
("用户登录 API", "用户登录接口", "completed", "high"),
|
||||
("用户登出 API", "用户登出接口", "completed", "high"),
|
||||
("密码重置 API", "密码重置流程", "completed", "medium"),
|
||||
("创建工作空间 API", "创建工作空间接口", "completed", "high"),
|
||||
("邀请成员 API", "邀请成员接口", "completed", "high"),
|
||||
("接受拒绝邀请 API", "处理邀请接口", "completed", "high"),
|
||||
("移除成员 API", "移除成员接口", "completed", "medium"),
|
||||
("离开工作空间 API", "成员离开接口", "completed", "medium"),
|
||||
("更新成员角色 API", "修改成员角色", "completed", "high"),
|
||||
("列出工作空间 API", "查询工作空间列表", "completed", "medium"),
|
||||
("工作空间详情 API", "工作空间详情", "completed", "medium"),
|
||||
("列出成员 API", "查询成员列表", "completed", "medium"),
|
||||
("Permission Checker", "权限检查器", "completed", "high"),
|
||||
("订阅计划定义", "Free Pro Enterprise", "completed", "high"),
|
||||
("升级订阅 API", "订阅升级接口", "completed", "high"),
|
||||
("取消订阅 API", "订阅取消接口", "completed", "medium"),
|
||||
("配额检查工具", "配额管理工具", "completed", "high"),
|
||||
("UserRepository 接口", "User 仓储接口", "completed", "high"),
|
||||
("UserRepository InMemory 实现", "InMemory 实现", "completed", "high"),
|
||||
("WorkspaceRepository 接口", "Workspace 仓储接口", "completed", "high"),
|
||||
("WorkspaceRepository InMemory 实现", "InMemory 实现", "completed", "high"),
|
||||
("WorkspaceMemberRepository 接口", "Member 仓储接口", "completed", "high"),
|
||||
("WorkspaceMemberRepository InMemory 实现", "InMemory 实现", "completed", "high"),
|
||||
("WorkspaceInvitationRepository 接口", "Invitation 仓储接口", "completed", "high"),
|
||||
("WorkspaceInvitationRepository InMemory 实现", "InMemory 实现", "completed", "high"),
|
||||
("SubscriptionRepository 接口", "Subscription 仓储接口", "completed", "high"),
|
||||
("SubscriptionRepository InMemory 实现", "InMemory 实现", "completed", "high"),
|
||||
("PostgreSQL Repository 实现", "PostgreSQL 数据库适配器", "completed", "high"),
|
||||
("Database Migration 脚本", "数据库迁移脚本", "completed", "high"),
|
||||
("FastAPI 路由层", "API 路由实现", "completed", "high"),
|
||||
("API 文档 Swagger", "Swagger 文档", "completed", "medium"),
|
||||
("错误处理中间件", "统一错误处理", "completed", "high"),
|
||||
("参数验证", "Pydantic 参数验证", "completed", "high"),
|
||||
("Docker 配置", "Docker Compose 配置", "completed", "high"),
|
||||
("Kubernetes 配置", "K8s 部署配置", "completed", "medium"),
|
||||
("健康检查接口", "Health Check API", "completed", "high"),
|
||||
("Celery Worker 配置", "异步任务配置", "completed", "medium"),
|
||||
("Redis 缓存集成", "Redis 缓存", "completed", "high"),
|
||||
("GitHub Actions CI/CD", "CI/CD 流水线", "completed", "high"),
|
||||
("单元测试 170个", "170 个单元测试", "completed", "high"),
|
||||
("集成测试", "12 个集成测试", "completed", "medium"),
|
||||
("性能测试", "性能测试用例", "completed", "medium"),
|
||||
("连接池优化", "5-6x 性能优化", "completed", "high"),
|
||||
("API 文档编写", "API 使用文档", "completed", "medium"),
|
||||
("部署文档", "部署指南", "completed", "medium"),
|
||||
("开发文档", "开发指南", "completed", "medium"),
|
||||
("MIT 开源许可", "MIT License", "completed", "low"),
|
||||
("README 完善", "README.md", "completed", "medium"),
|
||||
("CONTRIBUTING 指南", "贡献指南", "completed", "low"),
|
||||
("CODE_OF_CONDUCT", "行为准则", "completed", "low"),
|
||||
]
|
||||
|
||||
# Phase 4 未完成的任务(4个)
|
||||
phase4_pending = [
|
||||
('文件上传 OSS', '阿里云 OSS 文件上传', 'pending', 'medium'),
|
||||
('搜索功能', '全文搜索', 'pending', 'medium'),
|
||||
('WebSocket 实时通信', 'WebSocket 支持', 'pending', 'low'),
|
||||
('Webhook 支持', 'Webhook 事件推送', 'pending', 'low'),
|
||||
("文件上传 OSS", "阿里云 OSS 文件上传", "pending", "medium"),
|
||||
("搜索功能", "全文搜索", "pending", "medium"),
|
||||
("WebSocket 实时通信", "WebSocket 支持", "pending", "low"),
|
||||
("Webhook 支持", "Webhook 事件推送", "pending", "low"),
|
||||
]
|
||||
|
||||
now = datetime.now().isoformat()
|
||||
|
||||
# 插入 Phase 4 任务
|
||||
for name, desc, status, priority in phase4_tasks + phase4_pending:
|
||||
cursor.execute('''INSERT INTO tasks
|
||||
cursor.execute(
|
||||
"""INSERT INTO tasks
|
||||
(name, description, status, phase, priority, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)''',
|
||||
(name, desc, status, 'Phase 4', priority, now, now))
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)""",
|
||||
(name, desc, status, "Phase 4", priority, now, now),
|
||||
)
|
||||
|
||||
# 插入里程碑
|
||||
milestones = [
|
||||
('认证与账号体系', 'Phase 4', '2026-06-17', '2026-06-17', 'completed', '用户注册登录密码管理'),
|
||||
('多租户权限体系', 'Phase 4', '2026-06-17', '2026-06-17', 'completed', '工作空间成员管理权限控制'),
|
||||
('订阅与计费体系', 'Phase 4', '2026-06-17', '2026-06-17', 'completed', '订阅计划配额管理'),
|
||||
('Repository 层', 'Phase 4', '2026-06-17', '2026-06-17', 'completed', '数据仓储层实现'),
|
||||
('API 层', 'Phase 4', '2026-06-17', '2026-06-17', 'completed', 'FastAPI 接口实现'),
|
||||
('测试与部署', 'Phase 4', '2026-06-17', '2026-06-17', 'completed', '测试 Docker CI/CD'),
|
||||
("认证与账号体系", "Phase 4", "2026-06-17", "2026-06-17", "completed", "用户注册登录密码管理"),
|
||||
("多租户权限体系", "Phase 4", "2026-06-17", "2026-06-17", "completed", "工作空间成员管理权限控制"),
|
||||
("订阅与计费体系", "Phase 4", "2026-06-17", "2026-06-17", "completed", "订阅计划配额管理"),
|
||||
("Repository 层", "Phase 4", "2026-06-17", "2026-06-17", "completed", "数据仓储层实现"),
|
||||
("API 层", "Phase 4", "2026-06-17", "2026-06-17", "completed", "FastAPI 接口实现"),
|
||||
("测试与部署", "Phase 4", "2026-06-17", "2026-06-17", "completed", "测试 Docker CI/CD"),
|
||||
]
|
||||
|
||||
for name, phase, start, end, status, desc in milestones:
|
||||
cursor.execute('''INSERT INTO milestones
|
||||
cursor.execute(
|
||||
"""INSERT INTO milestones
|
||||
(name, phase, start_date, end_date, status, description, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)''',
|
||||
(name, phase, start, end, status, desc, now))
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)""",
|
||||
(name, phase, start, end, status, desc, now),
|
||||
)
|
||||
|
||||
conn.commit()
|
||||
|
||||
# 验证
|
||||
cursor.execute('SELECT COUNT(*) FROM tasks WHERE status = "completed"')
|
||||
completed = cursor.fetchone()[0]
|
||||
cursor.execute('SELECT COUNT(*) FROM tasks')
|
||||
cursor.execute("SELECT COUNT(*) FROM tasks")
|
||||
total = cursor.fetchone()[0]
|
||||
|
||||
print(f'✅ Tracker 修复完成!')
|
||||
print(f' - 总任务数: {total}')
|
||||
print(f' - 已完成: {completed}')
|
||||
print(f' - 待完成: {total - completed}')
|
||||
print(f' - 完成率: {completed/total*100:.1f}%')
|
||||
print(f"✅ Tracker 修复完成!")
|
||||
print(f" - 总任务数: {total}")
|
||||
print(f" - 已完成: {completed}")
|
||||
print(f" - 待完成: {total - completed}")
|
||||
print(f" - 完成率: {completed/total*100:.1f}%")
|
||||
|
||||
# 测试中文显示
|
||||
cursor.execute('SELECT name FROM tasks LIMIT 3')
|
||||
print(f'\n前3个任务:')
|
||||
cursor.execute("SELECT name FROM tasks LIMIT 3")
|
||||
print(f"\n前3个任务:")
|
||||
for row in cursor.fetchall():
|
||||
print(f' - {row[0]}')
|
||||
print(f" - {row[0]}")
|
||||
|
||||
conn.close()
|
||||
|
||||
+87
-83
@@ -1,11 +1,11 @@
|
||||
import sqlite3
|
||||
from datetime import datetime
|
||||
|
||||
conn = sqlite3.connect('/app/tracker.db')
|
||||
conn = sqlite3.connect("/app/tracker.db")
|
||||
cursor = conn.cursor()
|
||||
|
||||
# 创建表
|
||||
cursor.execute('''CREATE TABLE IF NOT EXISTS tasks (
|
||||
cursor.execute("""CREATE TABLE IF NOT EXISTS tasks (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL,
|
||||
description TEXT,
|
||||
@@ -15,9 +15,9 @@ cursor.execute('''CREATE TABLE IF NOT EXISTS tasks (
|
||||
priority TEXT DEFAULT 'medium',
|
||||
created_at TEXT,
|
||||
updated_at TEXT
|
||||
)''')
|
||||
)""")
|
||||
|
||||
cursor.execute('''CREATE TABLE IF NOT EXISTS milestones (
|
||||
cursor.execute("""CREATE TABLE IF NOT EXISTS milestones (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL,
|
||||
phase TEXT,
|
||||
@@ -26,113 +26,117 @@ cursor.execute('''CREATE TABLE IF NOT EXISTS milestones (
|
||||
status TEXT DEFAULT 'pending',
|
||||
description TEXT,
|
||||
created_at TEXT
|
||||
)''')
|
||||
)""")
|
||||
|
||||
# Phase 4 已完成的任务(56个)
|
||||
phase4_tasks = [
|
||||
('JWT Service 实现', '实现 access token 和 refresh token', 'completed', 'high'),
|
||||
('Password Hasher 实现', 'bcrypt 密码加密,cost=12', 'completed', 'high'),
|
||||
('Redis Session Store', '基于 Redis 的 Session 存储', 'completed', 'high'),
|
||||
('Email Service 实现', 'SMTP 邮件服务', 'completed', 'high'),
|
||||
('用户注册 API', '用户注册接口', 'completed', 'high'),
|
||||
('邮箱验证 API', '邮箱验证接口', 'completed', 'high'),
|
||||
('用户登录 API', '用户登录接口', 'completed', 'high'),
|
||||
('用户登出 API', '用户登出接口', 'completed', 'high'),
|
||||
('密码重置 API', '密码重置流程', 'completed', 'medium'),
|
||||
('创建工作空间 API', '创建工作空间接口', 'completed', 'high'),
|
||||
('邀请成员 API', '邀请成员接口', 'completed', 'high'),
|
||||
('接受/拒绝邀请 API', '处理邀请接口', 'completed', 'high'),
|
||||
('移除成员 API', '移除成员接口', 'completed', 'medium'),
|
||||
('离开工作空间 API', '成员离开接口', 'completed', 'medium'),
|
||||
('更新成员角色 API', '修改成员角色', 'completed', 'high'),
|
||||
('列出工作空间 API', '查询工作空间列表', 'completed', 'medium'),
|
||||
('工作空间详情 API', '工作空间详情', 'completed', 'medium'),
|
||||
('列出成员 API', '查询成员列表', 'completed', 'medium'),
|
||||
('Permission Checker', '权限检查器', 'completed', 'high'),
|
||||
('订阅计划定义', 'Free/Pro/Enterprise', 'completed', 'high'),
|
||||
('升级订阅 API', '订阅升级接口', 'completed', 'high'),
|
||||
('取消订阅 API', '订阅取消接口', 'completed', 'medium'),
|
||||
('配额检查工具', '配额管理工具', 'completed', 'high'),
|
||||
('UserRepository 接口', 'User 仓储接口', 'completed', 'high'),
|
||||
('UserRepository InMemory 实现', 'InMemory 实现', 'completed', 'high'),
|
||||
('WorkspaceRepository 接口', 'Workspace 仓储接口', 'completed', 'high'),
|
||||
('WorkspaceRepository InMemory 实现', 'InMemory 实现', 'completed', 'high'),
|
||||
('WorkspaceMemberRepository 接口', 'Member 仓储接口', 'completed', 'high'),
|
||||
('WorkspaceMemberRepository InMemory 实现', 'InMemory 实现', 'completed', 'high'),
|
||||
('WorkspaceInvitationRepository 接口', 'Invitation 仓储接口', 'completed', 'high'),
|
||||
('WorkspaceInvitationRepository InMemory 实现', 'InMemory 实现', 'completed', 'high'),
|
||||
('SubscriptionRepository 接口', 'Subscription 仓储接口', 'completed', 'high'),
|
||||
('SubscriptionRepository InMemory 实现', 'InMemory 实现', 'completed', 'high'),
|
||||
('PostgreSQL Repository 实现', 'PostgreSQL 数据库适配器', 'completed', 'high'),
|
||||
('Database Migration 脚本', '数据库迁移脚本', 'completed', 'high'),
|
||||
('FastAPI 路由层', 'API 路由实现', 'completed', 'high'),
|
||||
('API 文档(Swagger)', 'Swagger 文档', 'completed', 'medium'),
|
||||
('错误处理中间件', '统一错误处理', 'completed', 'high'),
|
||||
('参数验证', 'Pydantic 参数验证', 'completed', 'high'),
|
||||
('Docker 配置', 'Docker Compose 配置', 'completed', 'high'),
|
||||
('Kubernetes 配置', 'K8s 部署配置', 'completed', 'medium'),
|
||||
('健康检查接口', 'Health Check API', 'completed', 'high'),
|
||||
('Celery Worker 配置', '异步任务配置', 'completed', 'medium'),
|
||||
('Redis 缓存集成', 'Redis 缓存', 'completed', 'high'),
|
||||
('GitHub Actions CI/CD', 'CI/CD 流水线', 'completed', 'high'),
|
||||
('单元测试(170个)', '170 个单元测试', 'completed', 'high'),
|
||||
('集成测试', '12 个集成测试', 'completed', 'medium'),
|
||||
('性能测试', '性能测试用例', 'completed', 'medium'),
|
||||
('连接池优化', '5-6x 性能优化', 'completed', 'high'),
|
||||
('API 文档编写', 'API 使用文档', 'completed', 'medium'),
|
||||
('部署文档', '部署指南', 'completed', 'medium'),
|
||||
('开发文档', '开发指南', 'completed', 'medium'),
|
||||
('MIT 开源许可', 'MIT License', 'completed', 'low'),
|
||||
('README 完善', 'README.md', 'completed', 'medium'),
|
||||
('CONTRIBUTING 指南', '贡献指南', 'completed', 'low'),
|
||||
('CODE_OF_CONDUCT', '行为准则', 'completed', 'low'),
|
||||
("JWT Service 实现", "实现 access token 和 refresh token", "completed", "high"),
|
||||
("Password Hasher 实现", "bcrypt 密码加密,cost=12", "completed", "high"),
|
||||
("Redis Session Store", "基于 Redis 的 Session 存储", "completed", "high"),
|
||||
("Email Service 实现", "SMTP 邮件服务", "completed", "high"),
|
||||
("用户注册 API", "用户注册接口", "completed", "high"),
|
||||
("邮箱验证 API", "邮箱验证接口", "completed", "high"),
|
||||
("用户登录 API", "用户登录接口", "completed", "high"),
|
||||
("用户登出 API", "用户登出接口", "completed", "high"),
|
||||
("密码重置 API", "密码重置流程", "completed", "medium"),
|
||||
("创建工作空间 API", "创建工作空间接口", "completed", "high"),
|
||||
("邀请成员 API", "邀请成员接口", "completed", "high"),
|
||||
("接受/拒绝邀请 API", "处理邀请接口", "completed", "high"),
|
||||
("移除成员 API", "移除成员接口", "completed", "medium"),
|
||||
("离开工作空间 API", "成员离开接口", "completed", "medium"),
|
||||
("更新成员角色 API", "修改成员角色", "completed", "high"),
|
||||
("列出工作空间 API", "查询工作空间列表", "completed", "medium"),
|
||||
("工作空间详情 API", "工作空间详情", "completed", "medium"),
|
||||
("列出成员 API", "查询成员列表", "completed", "medium"),
|
||||
("Permission Checker", "权限检查器", "completed", "high"),
|
||||
("订阅计划定义", "Free/Pro/Enterprise", "completed", "high"),
|
||||
("升级订阅 API", "订阅升级接口", "completed", "high"),
|
||||
("取消订阅 API", "订阅取消接口", "completed", "medium"),
|
||||
("配额检查工具", "配额管理工具", "completed", "high"),
|
||||
("UserRepository 接口", "User 仓储接口", "completed", "high"),
|
||||
("UserRepository InMemory 实现", "InMemory 实现", "completed", "high"),
|
||||
("WorkspaceRepository 接口", "Workspace 仓储接口", "completed", "high"),
|
||||
("WorkspaceRepository InMemory 实现", "InMemory 实现", "completed", "high"),
|
||||
("WorkspaceMemberRepository 接口", "Member 仓储接口", "completed", "high"),
|
||||
("WorkspaceMemberRepository InMemory 实现", "InMemory 实现", "completed", "high"),
|
||||
("WorkspaceInvitationRepository 接口", "Invitation 仓储接口", "completed", "high"),
|
||||
("WorkspaceInvitationRepository InMemory 实现", "InMemory 实现", "completed", "high"),
|
||||
("SubscriptionRepository 接口", "Subscription 仓储接口", "completed", "high"),
|
||||
("SubscriptionRepository InMemory 实现", "InMemory 实现", "completed", "high"),
|
||||
("PostgreSQL Repository 实现", "PostgreSQL 数据库适配器", "completed", "high"),
|
||||
("Database Migration 脚本", "数据库迁移脚本", "completed", "high"),
|
||||
("FastAPI 路由层", "API 路由实现", "completed", "high"),
|
||||
("API 文档(Swagger)", "Swagger 文档", "completed", "medium"),
|
||||
("错误处理中间件", "统一错误处理", "completed", "high"),
|
||||
("参数验证", "Pydantic 参数验证", "completed", "high"),
|
||||
("Docker 配置", "Docker Compose 配置", "completed", "high"),
|
||||
("Kubernetes 配置", "K8s 部署配置", "completed", "medium"),
|
||||
("健康检查接口", "Health Check API", "completed", "high"),
|
||||
("Celery Worker 配置", "异步任务配置", "completed", "medium"),
|
||||
("Redis 缓存集成", "Redis 缓存", "completed", "high"),
|
||||
("GitHub Actions CI/CD", "CI/CD 流水线", "completed", "high"),
|
||||
("单元测试(170个)", "170 个单元测试", "completed", "high"),
|
||||
("集成测试", "12 个集成测试", "completed", "medium"),
|
||||
("性能测试", "性能测试用例", "completed", "medium"),
|
||||
("连接池优化", "5-6x 性能优化", "completed", "high"),
|
||||
("API 文档编写", "API 使用文档", "completed", "medium"),
|
||||
("部署文档", "部署指南", "completed", "medium"),
|
||||
("开发文档", "开发指南", "completed", "medium"),
|
||||
("MIT 开源许可", "MIT License", "completed", "low"),
|
||||
("README 完善", "README.md", "completed", "medium"),
|
||||
("CONTRIBUTING 指南", "贡献指南", "completed", "low"),
|
||||
("CODE_OF_CONDUCT", "行为准则", "completed", "low"),
|
||||
]
|
||||
|
||||
# Phase 4 未完成的任务(4个)
|
||||
phase4_pending = [
|
||||
('文件上传(OSS)', '阿里云 OSS 文件上传', 'pending', 'medium'),
|
||||
('搜索功能', '全文搜索', 'pending', 'medium'),
|
||||
('WebSocket 实时通信', 'WebSocket 支持', 'pending', 'low'),
|
||||
('Webhook 支持', 'Webhook 事件推送', 'pending', 'low'),
|
||||
("文件上传(OSS)", "阿里云 OSS 文件上传", "pending", "medium"),
|
||||
("搜索功能", "全文搜索", "pending", "medium"),
|
||||
("WebSocket 实时通信", "WebSocket 支持", "pending", "low"),
|
||||
("Webhook 支持", "Webhook 事件推送", "pending", "low"),
|
||||
]
|
||||
|
||||
now = datetime.now().isoformat()
|
||||
|
||||
# 插入 Phase 4 任务
|
||||
for name, desc, status, priority in phase4_tasks + phase4_pending:
|
||||
cursor.execute('''INSERT INTO tasks
|
||||
cursor.execute(
|
||||
"""INSERT INTO tasks
|
||||
(name, description, status, phase, priority, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)''',
|
||||
(name, desc, status, 'Phase 4', priority, now, now))
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)""",
|
||||
(name, desc, status, "Phase 4", priority, now, now),
|
||||
)
|
||||
|
||||
# 插入里程碑
|
||||
milestones = [
|
||||
('认证与账号体系', 'Phase 4', '2026-06-17', '2026-06-17', 'completed', '用户注册、登录、密码管理'),
|
||||
('多租户权限体系', 'Phase 4', '2026-06-17', '2026-06-17', 'completed', '工作空间、成员管理、权限控制'),
|
||||
('订阅与计费体系', 'Phase 4', '2026-06-17', '2026-06-17', 'completed', '订阅计划、配额管理'),
|
||||
('Repository 层', 'Phase 4', '2026-06-17', '2026-06-17', 'completed', '数据仓储层实现'),
|
||||
('API 层', 'Phase 4', '2026-06-17', '2026-06-17', 'completed', 'FastAPI 接口实现'),
|
||||
('测试与部署', 'Phase 4', '2026-06-17', '2026-06-17', 'completed', '测试、Docker、CI/CD'),
|
||||
("认证与账号体系", "Phase 4", "2026-06-17", "2026-06-17", "completed", "用户注册、登录、密码管理"),
|
||||
("多租户权限体系", "Phase 4", "2026-06-17", "2026-06-17", "completed", "工作空间、成员管理、权限控制"),
|
||||
("订阅与计费体系", "Phase 4", "2026-06-17", "2026-06-17", "completed", "订阅计划、配额管理"),
|
||||
("Repository 层", "Phase 4", "2026-06-17", "2026-06-17", "completed", "数据仓储层实现"),
|
||||
("API 层", "Phase 4", "2026-06-17", "2026-06-17", "completed", "FastAPI 接口实现"),
|
||||
("测试与部署", "Phase 4", "2026-06-17", "2026-06-17", "completed", "测试、Docker、CI/CD"),
|
||||
]
|
||||
|
||||
for name, phase, start, end, status, desc in milestones:
|
||||
cursor.execute('''INSERT INTO milestones
|
||||
cursor.execute(
|
||||
"""INSERT INTO milestones
|
||||
(name, phase, start_date, end_date, status, description, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)''',
|
||||
(name, phase, start, end, status, desc, now))
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)""",
|
||||
(name, phase, start, end, status, desc, now),
|
||||
)
|
||||
|
||||
conn.commit()
|
||||
|
||||
# 统计
|
||||
cursor.execute('SELECT COUNT(*) FROM tasks WHERE status = "completed"')
|
||||
completed = cursor.fetchone()[0]
|
||||
cursor.execute('SELECT COUNT(*) FROM tasks')
|
||||
cursor.execute("SELECT COUNT(*) FROM tasks")
|
||||
total = cursor.fetchone()[0]
|
||||
|
||||
print(f'✅ Tracker 初始化完成!')
|
||||
print(f' - 总任务数: {total}')
|
||||
print(f' - 已完成: {completed}')
|
||||
print(f' - 待完成: {total - completed}')
|
||||
print(f' - 完成率: {completed/total*100:.1f}%')
|
||||
print(f"✅ Tracker 初始化完成!")
|
||||
print(f" - 总任务数: {total}")
|
||||
print(f" - 已完成: {completed}")
|
||||
print(f" - 待完成: {total - completed}")
|
||||
print(f" - 完成率: {completed/total*100:.1f}%")
|
||||
|
||||
conn.close()
|
||||
|
||||
+21
-15
@@ -1,11 +1,11 @@
|
||||
import sqlite3
|
||||
from datetime import datetime
|
||||
|
||||
conn = sqlite3.connect('/app/tracker.db')
|
||||
conn = sqlite3.connect("/app/tracker.db")
|
||||
cursor = conn.cursor()
|
||||
|
||||
# 创建表
|
||||
cursor.execute('''CREATE TABLE IF NOT EXISTS tasks (
|
||||
cursor.execute("""CREATE TABLE IF NOT EXISTS tasks (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL,
|
||||
description TEXT,
|
||||
@@ -14,9 +14,9 @@ cursor.execute('''CREATE TABLE IF NOT EXISTS tasks (
|
||||
milestone TEXT,
|
||||
priority TEXT DEFAULT 'medium',
|
||||
created_at TEXT
|
||||
)''')
|
||||
)""")
|
||||
|
||||
cursor.execute('''CREATE TABLE IF NOT EXISTS milestones (
|
||||
cursor.execute("""CREATE TABLE IF NOT EXISTS milestones (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL,
|
||||
phase TEXT,
|
||||
@@ -24,17 +24,17 @@ cursor.execute('''CREATE TABLE IF NOT EXISTS milestones (
|
||||
end_date TEXT,
|
||||
status TEXT DEFAULT 'pending',
|
||||
description TEXT
|
||||
)''')
|
||||
)""")
|
||||
|
||||
cursor.execute('''CREATE TABLE IF NOT EXISTS logs (
|
||||
cursor.execute("""CREATE TABLE IF NOT EXISTS logs (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
task_id INTEGER,
|
||||
message TEXT,
|
||||
created_at TEXT
|
||||
)''')
|
||||
)""")
|
||||
|
||||
conn.commit()
|
||||
print('[OK] Database structure created')
|
||||
print("[OK] Database structure created")
|
||||
|
||||
# 插入 Phase 4 和 Phase 6 数据
|
||||
# Phase 4 里程碑和任务
|
||||
@@ -52,8 +52,10 @@ milestones = [
|
||||
]
|
||||
|
||||
for name, phase, start, end, status in milestones:
|
||||
cursor.execute("INSERT INTO milestones (name, phase, start_date, end_date, status) VALUES (?, ?, ?, ?, ?)",
|
||||
(name, phase, start, end, status))
|
||||
cursor.execute(
|
||||
"INSERT INTO milestones (name, phase, start_date, end_date, status) VALUES (?, ?, ?, ?, ?)",
|
||||
(name, phase, start, end, status),
|
||||
)
|
||||
|
||||
# Phase 4 任务 (30个)
|
||||
phase4_tasks = [
|
||||
@@ -90,8 +92,10 @@ phase4_tasks = [
|
||||
]
|
||||
|
||||
for name, milestone, status, priority in phase4_tasks:
|
||||
cursor.execute("INSERT INTO tasks (name, milestone, status, phase, priority, created_at) VALUES (?, ?, ?, ?, ?, ?)",
|
||||
(name, milestone, status, "Phase 4", priority, datetime.now().isoformat()))
|
||||
cursor.execute(
|
||||
"INSERT INTO tasks (name, milestone, status, phase, priority, created_at) VALUES (?, ?, ?, ?, ?, ?)",
|
||||
(name, milestone, status, "Phase 4", priority, datetime.now().isoformat()),
|
||||
)
|
||||
|
||||
# Phase 6 任务 (40个)
|
||||
phase6_tasks = [
|
||||
@@ -138,10 +142,12 @@ phase6_tasks = [
|
||||
]
|
||||
|
||||
for name, milestone, status, priority in phase6_tasks:
|
||||
cursor.execute("INSERT INTO tasks (name, milestone, status, phase, priority, created_at) VALUES (?, ?, ?, ?, ?, ?)",
|
||||
(name, milestone, status, "Phase 6", priority, datetime.now().isoformat()))
|
||||
cursor.execute(
|
||||
"INSERT INTO tasks (name, milestone, status, phase, priority, created_at) VALUES (?, ?, ?, ?, ?, ?)",
|
||||
(name, milestone, status, "Phase 6", priority, datetime.now().isoformat()),
|
||||
)
|
||||
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
print('[SUCCESS] tracker.db initialized with 70 tasks and 10 milestones')
|
||||
print("[SUCCESS] tracker.db initialized with 70 tasks and 10 milestones")
|
||||
|
||||
@@ -27,9 +27,7 @@ class SQLAlchemyAssetLibraryRepository:
|
||||
return self.get(library_id)
|
||||
|
||||
def find_by_project(self, project_id: str) -> list[AssetLibrary]:
|
||||
models = self.session.query(AssetLibraryModel).filter(
|
||||
AssetLibraryModel.project_id == project_id
|
||||
).all()
|
||||
models = self.session.query(AssetLibraryModel).filter(AssetLibraryModel.project_id == project_id).all()
|
||||
return [
|
||||
AssetLibrary(
|
||||
id=model.id,
|
||||
@@ -60,9 +58,7 @@ class SQLAlchemyAssetLibraryRepository:
|
||||
return library
|
||||
|
||||
def update(self, library: AssetLibrary) -> AssetLibrary:
|
||||
model = self.session.query(AssetLibraryModel).filter(
|
||||
AssetLibraryModel.id == library.id
|
||||
).first()
|
||||
model = self.session.query(AssetLibraryModel).filter(AssetLibraryModel.id == library.id).first()
|
||||
if model:
|
||||
model.project_id = library.project_id
|
||||
model.name = library.name
|
||||
@@ -74,9 +70,7 @@ class SQLAlchemyAssetLibraryRepository:
|
||||
return library
|
||||
|
||||
def delete(self, library_id: str) -> bool:
|
||||
model = self.session.query(AssetLibraryModel).filter(
|
||||
AssetLibraryModel.id == library_id
|
||||
).first()
|
||||
model = self.session.query(AssetLibraryModel).filter(AssetLibraryModel.id == library_id).first()
|
||||
if model:
|
||||
self.session.delete(model)
|
||||
self.session.commit()
|
||||
@@ -84,18 +78,14 @@ class SQLAlchemyAssetLibraryRepository:
|
||||
return False
|
||||
|
||||
async def increment_asset_count(self, library_id: str, size_delta: int) -> None:
|
||||
model = self.session.query(AssetLibraryModel).filter(
|
||||
AssetLibraryModel.id == library_id
|
||||
).first()
|
||||
model = self.session.query(AssetLibraryModel).filter(AssetLibraryModel.id == library_id).first()
|
||||
if model:
|
||||
model.asset_count = (model.asset_count or 0) + 1
|
||||
model.total_size = (model.total_size or 0) + size_delta
|
||||
self.session.commit()
|
||||
|
||||
async def decrement_asset_count(self, library_id: str, size_delta: int) -> None:
|
||||
model = self.session.query(AssetLibraryModel).filter(
|
||||
AssetLibraryModel.id == library_id
|
||||
).first()
|
||||
model = self.session.query(AssetLibraryModel).filter(AssetLibraryModel.id == library_id).first()
|
||||
if model:
|
||||
model.asset_count = max(0, (model.asset_count or 0) - 1)
|
||||
model.total_size = max(0, (model.total_size or 0) - size_delta)
|
||||
|
||||
@@ -17,9 +17,13 @@ class SQLAlchemyAssetRepository:
|
||||
skip: int = 0,
|
||||
limit: int = 100,
|
||||
) -> list[Asset]:
|
||||
models = self.session.query(AssetModel).filter(
|
||||
AssetModel.asset_library_id == library_id
|
||||
).offset(skip).limit(limit).all()
|
||||
models = (
|
||||
self.session.query(AssetModel)
|
||||
.filter(AssetModel.asset_library_id == library_id)
|
||||
.offset(skip)
|
||||
.limit(limit)
|
||||
.all()
|
||||
)
|
||||
return [self._to_domain(model) for model in models]
|
||||
|
||||
def find_by_project(
|
||||
@@ -28,9 +32,9 @@ class SQLAlchemyAssetRepository:
|
||||
skip: int = 0,
|
||||
limit: int = 100,
|
||||
) -> list[Asset]:
|
||||
models = self.session.query(AssetModel).filter(
|
||||
AssetModel.project_id == project_id
|
||||
).offset(skip).limit(limit).all()
|
||||
models = (
|
||||
self.session.query(AssetModel).filter(AssetModel.project_id == project_id).offset(skip).limit(limit).all()
|
||||
)
|
||||
return [self._to_domain(model) for model in models]
|
||||
|
||||
def find_by_id(self, asset_id: str) -> Asset | None:
|
||||
@@ -101,24 +105,23 @@ class SQLAlchemyAssetRepository:
|
||||
return False
|
||||
|
||||
def count_by_project(self, project_id: str) -> int:
|
||||
return self.session.query(AssetModel).filter(
|
||||
AssetModel.project_id == project_id
|
||||
).count()
|
||||
return self.session.query(AssetModel).filter(AssetModel.project_id == project_id).count()
|
||||
|
||||
def count_by_project_ids(self, project_ids: list[str]) -> int:
|
||||
if not project_ids:
|
||||
return 0
|
||||
return self.session.query(AssetModel).filter(
|
||||
AssetModel.project_id.in_(project_ids)
|
||||
).count()
|
||||
return self.session.query(AssetModel).filter(AssetModel.project_id.in_(project_ids)).count()
|
||||
|
||||
def sum_storage_by_project_ids(self, project_ids: list[str]) -> int:
|
||||
if not project_ids:
|
||||
return 0
|
||||
from sqlalchemy import func
|
||||
result = self.session.query(func.coalesce(func.sum(AssetModel.file_size), 0)).filter(
|
||||
AssetModel.project_id.in_(project_ids)
|
||||
).scalar()
|
||||
|
||||
result = (
|
||||
self.session.query(func.coalesce(func.sum(AssetModel.file_size), 0))
|
||||
.filter(AssetModel.project_id.in_(project_ids))
|
||||
.scalar()
|
||||
)
|
||||
return int(result or 0)
|
||||
|
||||
def _to_domain(self, model: AssetModel) -> Asset:
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
"""查重记录 SQLAlchemy 仓库实现。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
@@ -34,9 +35,7 @@ class SQLAlchemyDuplicationRecordRepository:
|
||||
return record
|
||||
|
||||
def get(self, record_id: str) -> DuplicationRecord | None:
|
||||
model = self.session.query(DuplicationRecordModel).filter(
|
||||
DuplicationRecordModel.id == record_id
|
||||
).first()
|
||||
model = self.session.query(DuplicationRecordModel).filter(DuplicationRecordModel.id == record_id).first()
|
||||
if model is None:
|
||||
return None
|
||||
return self._to_domain(model)
|
||||
@@ -53,9 +52,7 @@ class SQLAlchemyDuplicationRecordRepository:
|
||||
return [self._to_domain(m) for m in models]
|
||||
|
||||
def update(self, record: DuplicationRecord) -> DuplicationRecord:
|
||||
model = self.session.query(DuplicationRecordModel).filter(
|
||||
DuplicationRecordModel.id == record.id
|
||||
).first()
|
||||
model = self.session.query(DuplicationRecordModel).filter(DuplicationRecordModel.id == record.id).first()
|
||||
if model is None:
|
||||
return record
|
||||
model.status = record.status
|
||||
@@ -66,9 +63,7 @@ class SQLAlchemyDuplicationRecordRepository:
|
||||
model.updated_at = record.updated_at
|
||||
|
||||
# 更新 segments:先删后建
|
||||
self.session.query(DuplicationSegmentModel).filter(
|
||||
DuplicationSegmentModel.record_id == record.id
|
||||
).delete()
|
||||
self.session.query(DuplicationSegmentModel).filter(DuplicationSegmentModel.record_id == record.id).delete()
|
||||
for seg in record.segments:
|
||||
seg_model = DuplicationSegmentModel(
|
||||
id=seg.id,
|
||||
@@ -87,20 +82,14 @@ class SQLAlchemyDuplicationRecordRepository:
|
||||
return record
|
||||
|
||||
def delete(self, record_id: str) -> bool:
|
||||
count = self.session.query(DuplicationRecordModel).filter(
|
||||
DuplicationRecordModel.id == record_id
|
||||
).delete()
|
||||
self.session.query(DuplicationSegmentModel).filter(
|
||||
DuplicationSegmentModel.record_id == record_id
|
||||
).delete()
|
||||
count = self.session.query(DuplicationRecordModel).filter(DuplicationRecordModel.id == record_id).delete()
|
||||
self.session.query(DuplicationSegmentModel).filter(DuplicationSegmentModel.record_id == record_id).delete()
|
||||
self.session.commit()
|
||||
return count > 0
|
||||
|
||||
def _to_domain(self, model: DuplicationRecordModel) -> DuplicationRecord:
|
||||
segment_models = (
|
||||
self.session.query(DuplicationSegmentModel)
|
||||
.filter(DuplicationSegmentModel.record_id == model.id)
|
||||
.all()
|
||||
self.session.query(DuplicationSegmentModel).filter(DuplicationSegmentModel.record_id == model.id).all()
|
||||
)
|
||||
segments = [
|
||||
DuplicateSegment(
|
||||
|
||||
@@ -80,11 +80,7 @@ class SQLAlchemyGenerationTaskRepository:
|
||||
return [_to_domain(m) for m in models]
|
||||
|
||||
def count_by_user(self, user_id: str) -> int:
|
||||
return (
|
||||
self.session.query(GenerationTaskModel)
|
||||
.filter(GenerationTaskModel.created_by_user_id == user_id)
|
||||
.count()
|
||||
)
|
||||
return self.session.query(GenerationTaskModel).filter(GenerationTaskModel.created_by_user_id == user_id).count()
|
||||
|
||||
def list_recent_by_user(self, user_id: str, limit: int = 5) -> list[GenerationTask]:
|
||||
models = (
|
||||
|
||||
@@ -39,7 +39,7 @@ class ProjectModel(Base):
|
||||
name = Column(String(100), nullable=False)
|
||||
description = Column(Text, nullable=False, default="")
|
||||
shared_users = Column(JSON, nullable=False, default=list) # 被共享的用户 ID 列表
|
||||
extra_meta = Column('metadata', JSON, nullable=False, default=dict)
|
||||
extra_meta = Column("metadata", JSON, nullable=False, default=dict)
|
||||
created_at = Column(DateTime, nullable=False, default=lambda: datetime.now(timezone.utc))
|
||||
|
||||
|
||||
@@ -52,7 +52,7 @@ class AssetLibraryModel(Base):
|
||||
kind = Column(String(20), nullable=False, index=True)
|
||||
asset_count = Column(Float, nullable=False, default=0)
|
||||
total_size = Column(Float, nullable=False, default=0)
|
||||
extra_meta = Column('metadata', JSON, nullable=False, default=dict)
|
||||
extra_meta = Column("metadata", JSON, nullable=False, default=dict)
|
||||
created_at = Column(DateTime, nullable=False, default=lambda: datetime.now(timezone.utc))
|
||||
updated_at = Column(DateTime, nullable=False, default=lambda: datetime.now(timezone.utc))
|
||||
|
||||
@@ -80,12 +80,11 @@ class AssetModel(Base):
|
||||
classification_result = Column(Text, nullable=True)
|
||||
quality_score = Column(Float, nullable=True)
|
||||
uploaded_by_user_id = Column(String(36), nullable=False)
|
||||
extra_meta = Column('metadata', JSON, nullable=False, default=dict)
|
||||
extra_meta = Column("metadata", JSON, nullable=False, default=dict)
|
||||
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))
|
||||
|
||||
|
||||
|
||||
class EditTemplateModel(Base):
|
||||
__tablename__ = "edit_templates"
|
||||
|
||||
@@ -97,14 +96,11 @@ class EditTemplateModel(Base):
|
||||
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="")
|
||||
extra_meta = Column('metadata', JSON, nullable=False, default=dict)
|
||||
extra_meta = Column("metadata", JSON, nullable=False, default=dict)
|
||||
created_at = Column(DateTime, nullable=False, default=lambda: datetime.now(timezone.utc))
|
||||
updated_at = Column(DateTime, nullable=False, default=lambda: datetime.now(timezone.utc))
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
class IngestJobModel(Base):
|
||||
__tablename__ = "ingest_jobs"
|
||||
|
||||
@@ -145,7 +141,9 @@ class GenerationTaskModel(Base):
|
||||
asset_ids = Column(JSON, nullable=False, default=list)
|
||||
title_ids = Column(JSON, nullable=False, default=list)
|
||||
voice_ids = Column(JSON, nullable=False, default=list)
|
||||
editing_mode = Column(String(20), nullable=False, default="one_take", index=True) # 剪辑模式: one_take, pip, voice_over, voice_pip
|
||||
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)
|
||||
result_count = Column(Float, nullable=False, default=0)
|
||||
@@ -153,7 +151,7 @@ class GenerationTaskModel(Base):
|
||||
started_at = Column(DateTime, nullable=True)
|
||||
completed_at = Column(DateTime, nullable=True)
|
||||
created_by_user_id = Column(String(32), nullable=False, default="", index=True)
|
||||
extra_meta = Column('metadata', JSON, nullable=False, default=dict)
|
||||
extra_meta = Column("metadata", JSON, nullable=False, default=dict)
|
||||
created_at = Column(DateTime, nullable=False, default=lambda: datetime.now(timezone.utc))
|
||||
|
||||
|
||||
@@ -184,9 +182,6 @@ class GeneratedVideoModel(Base):
|
||||
duplicate_of = Column(String(32), nullable=True)
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
class TitleLibraryModel(Base):
|
||||
__tablename__ = "title_libraries"
|
||||
|
||||
@@ -199,7 +194,7 @@ class TitleLibraryModel(Base):
|
||||
tags = Column(JSON, nullable=False, default=list)
|
||||
usage_count = Column(Integer, nullable=False, default=0)
|
||||
is_active = Column(Boolean, nullable=False, default=True, index=True)
|
||||
extra_meta = Column('metadata', JSON, nullable=False, default=dict)
|
||||
extra_meta = Column("metadata", JSON, nullable=False, default=dict)
|
||||
created_at = Column(DateTime, nullable=False, default=lambda: datetime.now(timezone.utc))
|
||||
updated_at = Column(DateTime, nullable=False, default=lambda: datetime.now(timezone.utc))
|
||||
|
||||
@@ -220,7 +215,7 @@ class VoiceLibraryModel(Base):
|
||||
file_size = Column(Integer, nullable=False, default=0)
|
||||
status = Column(String(20), nullable=False, default="completed", index=True)
|
||||
tags = Column(JSON, nullable=False, default=list)
|
||||
extra_meta = Column('metadata', JSON, nullable=False, default=dict)
|
||||
extra_meta = Column("metadata", JSON, nullable=False, default=dict)
|
||||
created_at = Column(DateTime, nullable=False, default=lambda: datetime.now(timezone.utc))
|
||||
updated_at = Column(DateTime, nullable=False, default=lambda: datetime.now(timezone.utc))
|
||||
|
||||
@@ -267,7 +262,7 @@ class RecipeModel(Base):
|
||||
template_id = Column(String(36), nullable=False, default="")
|
||||
generation_params = Column(JSON, nullable=False, default=dict)
|
||||
is_active = Column(Boolean, nullable=False, default=True)
|
||||
extra_meta = Column('metadata', JSON, nullable=False, default=dict)
|
||||
extra_meta = Column("metadata", JSON, nullable=False, default=dict)
|
||||
created_at = Column(DateTime, nullable=False, default=lambda: datetime.now(timezone.utc))
|
||||
updated_at = Column(DateTime, nullable=False, default=lambda: datetime.now(timezone.utc))
|
||||
|
||||
@@ -280,7 +275,7 @@ class RecipeItemModel(Base):
|
||||
item_type = Column(String(20), nullable=False)
|
||||
item_id = Column(String(36), nullable=False)
|
||||
position = Column(Integer, nullable=False, default=0)
|
||||
extra_meta = Column('metadata', JSON, nullable=False, default=dict)
|
||||
extra_meta = Column("metadata", JSON, nullable=False, default=dict)
|
||||
|
||||
|
||||
class TemplateModel(Base):
|
||||
@@ -321,4 +316,3 @@ class TemplateCategoryModel(Base):
|
||||
user_id = Column(String(36), nullable=False, index=True)
|
||||
name = Column(String(100), nullable=False)
|
||||
created_at = Column(DateTime, nullable=False, default=lambda: datetime.now(timezone.utc))
|
||||
|
||||
|
||||
@@ -47,9 +47,7 @@ class SQLAlchemyProjectRepository:
|
||||
|
||||
def find_by_owner_user_id(self, owner_user_id: str) -> list[Project]:
|
||||
"""根据所有者用户 ID 查找项目"""
|
||||
models = self.session.query(ProjectModel).filter(
|
||||
ProjectModel.owner_user_id == owner_user_id
|
||||
).all()
|
||||
models = self.session.query(ProjectModel).filter(ProjectModel.owner_user_id == owner_user_id).all()
|
||||
return [self._to_entity(model) for model in models]
|
||||
|
||||
def find_accessible_projects(self, user_id: str) -> list[Project]:
|
||||
@@ -57,19 +55,18 @@ class SQLAlchemyProjectRepository:
|
||||
from sqlalchemy import or_, cast
|
||||
from sqlalchemy.dialects.postgresql import JSONB
|
||||
|
||||
models = self.session.query(ProjectModel).filter(
|
||||
or_(
|
||||
ProjectModel.owner_user_id == user_id,
|
||||
cast(ProjectModel.shared_users, JSONB).contains([user_id])
|
||||
models = (
|
||||
self.session.query(ProjectModel)
|
||||
.filter(
|
||||
or_(ProjectModel.owner_user_id == user_id, cast(ProjectModel.shared_users, JSONB).contains([user_id]))
|
||||
)
|
||||
).all()
|
||||
.all()
|
||||
)
|
||||
return [self._to_entity(model) for model in models]
|
||||
|
||||
def count_by_owner(self, owner_user_id: str) -> int:
|
||||
"""统计用户的项目数量"""
|
||||
return self.session.query(ProjectModel).filter(
|
||||
ProjectModel.owner_user_id == owner_user_id
|
||||
).count()
|
||||
return self.session.query(ProjectModel).filter(ProjectModel.owner_user_id == owner_user_id).count()
|
||||
|
||||
def delete(self, project_id: str) -> bool:
|
||||
"""删除项目"""
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
"""SQLAlchemy implementation of RecipeRepository."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import List, Optional
|
||||
@@ -144,11 +145,7 @@ class SQLAlchemyRecipeRepository:
|
||||
return items
|
||||
|
||||
def delete_items_by_recipe(self, recipe_id: str) -> int:
|
||||
count = (
|
||||
self.session.query(RecipeItemModel)
|
||||
.filter(RecipeItemModel.recipe_id == recipe_id)
|
||||
.delete()
|
||||
)
|
||||
count = self.session.query(RecipeItemModel).filter(RecipeItemModel.recipe_id == recipe_id).delete()
|
||||
self.session.commit()
|
||||
return count
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
"""SQLAlchemy implementation of TemplateRepository."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import List, Optional
|
||||
@@ -178,9 +179,7 @@ class SQLAlchemyTemplateRepository:
|
||||
|
||||
def delete_segments_by_template(self, template_id: str) -> int:
|
||||
count = (
|
||||
self.session.query(TemplateSegmentModel)
|
||||
.filter(TemplateSegmentModel.template_id == template_id)
|
||||
.delete()
|
||||
self.session.query(TemplateSegmentModel).filter(TemplateSegmentModel.template_id == template_id).delete()
|
||||
)
|
||||
self.session.commit()
|
||||
return count
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
"""SQLAlchemy implementation of TitleLibraryRepository."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import List, Optional
|
||||
@@ -35,10 +36,14 @@ class SQLAlchemyTitleLibraryRepository:
|
||||
return [self._model_to_entity(m) for m in models]
|
||||
|
||||
def get(self, title_id: str, user_id: str) -> Optional[TitleLibraryItem]:
|
||||
model = self.session.query(TitleLibraryModel).filter(
|
||||
TitleLibraryModel.id == title_id,
|
||||
TitleLibraryModel.user_id == user_id,
|
||||
).first()
|
||||
model = (
|
||||
self.session.query(TitleLibraryModel)
|
||||
.filter(
|
||||
TitleLibraryModel.id == title_id,
|
||||
TitleLibraryModel.user_id == user_id,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
if model is None:
|
||||
return None
|
||||
return self._model_to_entity(model)
|
||||
@@ -62,10 +67,14 @@ class SQLAlchemyTitleLibraryRepository:
|
||||
return self._model_to_entity(model)
|
||||
|
||||
def update(self, item: TitleLibraryItem) -> TitleLibraryItem:
|
||||
model = self.session.query(TitleLibraryModel).filter(
|
||||
TitleLibraryModel.id == item.id,
|
||||
TitleLibraryModel.user_id == item.user_id,
|
||||
).first()
|
||||
model = (
|
||||
self.session.query(TitleLibraryModel)
|
||||
.filter(
|
||||
TitleLibraryModel.id == item.id,
|
||||
TitleLibraryModel.user_id == item.user_id,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
if model is None:
|
||||
raise ValueError(f"TitleLibraryItem {item.id} not found")
|
||||
model.name = item.name
|
||||
@@ -80,10 +89,14 @@ class SQLAlchemyTitleLibraryRepository:
|
||||
return self._model_to_entity(model)
|
||||
|
||||
def delete(self, title_id: str, user_id: str) -> bool:
|
||||
model = self.session.query(TitleLibraryModel).filter(
|
||||
TitleLibraryModel.id == title_id,
|
||||
TitleLibraryModel.user_id == user_id,
|
||||
).first()
|
||||
model = (
|
||||
self.session.query(TitleLibraryModel)
|
||||
.filter(
|
||||
TitleLibraryModel.id == title_id,
|
||||
TitleLibraryModel.user_id == user_id,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
if model is None:
|
||||
return False
|
||||
model.is_active = False
|
||||
@@ -91,10 +104,14 @@ class SQLAlchemyTitleLibraryRepository:
|
||||
return True
|
||||
|
||||
def count_by_user(self, user_id: str, is_active: bool = True) -> int:
|
||||
return self.session.query(TitleLibraryModel).filter(
|
||||
TitleLibraryModel.user_id == user_id,
|
||||
TitleLibraryModel.is_active == is_active,
|
||||
).count()
|
||||
return (
|
||||
self.session.query(TitleLibraryModel)
|
||||
.filter(
|
||||
TitleLibraryModel.user_id == user_id,
|
||||
TitleLibraryModel.is_active == is_active,
|
||||
)
|
||||
.count()
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _model_to_entity(model: TitleLibraryModel) -> TitleLibraryItem:
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
"""SQLAlchemy implementation of VoiceLibraryRepository."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import List, Optional
|
||||
@@ -33,10 +34,14 @@ class SQLAlchemyVoiceLibraryRepository:
|
||||
return [self._model_to_entity(m) for m in models]
|
||||
|
||||
def get(self, voice_id: str, user_id: str) -> Optional[VoiceLibraryItem]:
|
||||
model = self.session.query(VoiceLibraryModel).filter(
|
||||
VoiceLibraryModel.id == voice_id,
|
||||
VoiceLibraryModel.user_id == user_id,
|
||||
).first()
|
||||
model = (
|
||||
self.session.query(VoiceLibraryModel)
|
||||
.filter(
|
||||
VoiceLibraryModel.id == voice_id,
|
||||
VoiceLibraryModel.user_id == user_id,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
if model is None:
|
||||
return None
|
||||
return self._model_to_entity(model)
|
||||
@@ -64,10 +69,14 @@ class SQLAlchemyVoiceLibraryRepository:
|
||||
return self._model_to_entity(model)
|
||||
|
||||
def update(self, item: VoiceLibraryItem) -> VoiceLibraryItem:
|
||||
model = self.session.query(VoiceLibraryModel).filter(
|
||||
VoiceLibraryModel.id == item.id,
|
||||
VoiceLibraryModel.user_id == item.user_id,
|
||||
).first()
|
||||
model = (
|
||||
self.session.query(VoiceLibraryModel)
|
||||
.filter(
|
||||
VoiceLibraryModel.id == item.id,
|
||||
VoiceLibraryModel.user_id == item.user_id,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
if model is None:
|
||||
raise ValueError(f"VoiceLibraryItem {item.id} not found")
|
||||
model.name = item.name
|
||||
@@ -86,10 +95,14 @@ class SQLAlchemyVoiceLibraryRepository:
|
||||
return self._model_to_entity(model)
|
||||
|
||||
def delete(self, voice_id: str, user_id: str) -> bool:
|
||||
model = self.session.query(VoiceLibraryModel).filter(
|
||||
VoiceLibraryModel.id == voice_id,
|
||||
VoiceLibraryModel.user_id == user_id,
|
||||
).first()
|
||||
model = (
|
||||
self.session.query(VoiceLibraryModel)
|
||||
.filter(
|
||||
VoiceLibraryModel.id == voice_id,
|
||||
VoiceLibraryModel.user_id == user_id,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
if model is None:
|
||||
return False
|
||||
# Soft delete by setting status to deleted
|
||||
@@ -98,10 +111,14 @@ class SQLAlchemyVoiceLibraryRepository:
|
||||
return True
|
||||
|
||||
def count_by_user(self, user_id: str) -> int:
|
||||
return self.session.query(VoiceLibraryModel).filter(
|
||||
VoiceLibraryModel.user_id == user_id,
|
||||
VoiceLibraryModel.status != "deleted",
|
||||
).count()
|
||||
return (
|
||||
self.session.query(VoiceLibraryModel)
|
||||
.filter(
|
||||
VoiceLibraryModel.user_id == user_id,
|
||||
VoiceLibraryModel.status != "deleted",
|
||||
)
|
||||
.count()
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _model_to_entity(model: VoiceLibraryModel) -> VoiceLibraryItem:
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
"""SQLite Tracker Adapter"""
|
||||
|
||||
|
||||
__all__ = [
|
||||
"SQLiteTaskRepository",
|
||||
"SQLiteMilestoneRepository",
|
||||
|
||||
@@ -6,7 +6,7 @@ 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", role="admin")
|
||||
payload = jwt_handler.verify_access_token(token)
|
||||
@@ -21,7 +21,7 @@ from packages.application.auth.jwt_service import JWTConfig, JWTService, TokenTy
|
||||
class JWTHandler:
|
||||
"""
|
||||
JWT 处理器委托类
|
||||
|
||||
|
||||
委托给 packages.domain.auth.jwt_service.JWTService 进行实际的 JWT 操作,
|
||||
此层仅负责配置和封装,不直接依赖 jwt 库。
|
||||
"""
|
||||
@@ -29,7 +29,7 @@ class JWTHandler:
|
||||
def __init__(self, secret_key: str, algorithm: str = "HS256", access_token_expire_minutes: int = 30):
|
||||
"""
|
||||
初始化 JWT 处理器
|
||||
|
||||
|
||||
Args:
|
||||
secret_key: JWT 签名密钥(必须从环境变量或配置注入)
|
||||
algorithm: 加密算法,默认 HS256
|
||||
@@ -68,13 +68,13 @@ class JWTHandler:
|
||||
def verify_access_token(self, token: str) -> Dict[str, Any]:
|
||||
"""
|
||||
验证 access_token
|
||||
|
||||
|
||||
Args:
|
||||
token: JWT Token 字符串
|
||||
|
||||
|
||||
Returns:
|
||||
Token payload
|
||||
|
||||
|
||||
Raises:
|
||||
ExpiredSignatureError: Token 已过期
|
||||
ValueError: Token 类型不是 access
|
||||
@@ -84,10 +84,10 @@ class JWTHandler:
|
||||
def verify_token(self, token: str) -> Dict[str, Any]:
|
||||
"""
|
||||
验证任意 Token
|
||||
|
||||
|
||||
Args:
|
||||
token: JWT Token 字符串
|
||||
|
||||
|
||||
Returns:
|
||||
Token payload
|
||||
"""
|
||||
@@ -105,12 +105,12 @@ def configure_jwt_handler(
|
||||
) -> JWTHandler:
|
||||
"""
|
||||
配置全局 JWT 处理器
|
||||
|
||||
|
||||
Args:
|
||||
secret_key: JWT 签名密钥
|
||||
algorithm: 加密算法
|
||||
access_token_expire_minutes: Access Token 过期时间(分钟)
|
||||
|
||||
|
||||
Returns:
|
||||
配置好的 JWTHandler 实例
|
||||
"""
|
||||
@@ -126,15 +126,13 @@ def configure_jwt_handler(
|
||||
def get_jwt_handler() -> JWTHandler:
|
||||
"""
|
||||
获取全局 JWT 处理器
|
||||
|
||||
|
||||
Returns:
|
||||
JWTHandler 实例
|
||||
|
||||
|
||||
Raises:
|
||||
RuntimeError: 如果尚未配置 JWT 处理器
|
||||
"""
|
||||
if _default_handler is None:
|
||||
raise RuntimeError(
|
||||
"JWT handler not configured. Call configure_jwt_handler() first."
|
||||
)
|
||||
raise RuntimeError("JWT handler not configured. Call configure_jwt_handler() first.")
|
||||
return _default_handler
|
||||
|
||||
@@ -30,9 +30,7 @@ class JWTConfig:
|
||||
ValueError: 如果 secret_key 为空或包含不安全默认值
|
||||
"""
|
||||
if not secret_key or secret_key.strip() == "":
|
||||
raise ValueError( # noqa: E501
|
||||
"JWT secret_key must be provided and cannot be empty"
|
||||
)
|
||||
raise ValueError("JWT secret_key must be provided and cannot be empty") # noqa: E501
|
||||
|
||||
insecure_defaults = [
|
||||
"your-secret-key-change-in-production",
|
||||
@@ -43,8 +41,7 @@ class JWTConfig:
|
||||
]
|
||||
if secret_key.lower() in [d.lower() for d in insecure_defaults]:
|
||||
raise ValueError( # noqa: E501
|
||||
f"JWT secret_key '{secret_key}' is insecure. "
|
||||
"Please provide a strong random secret."
|
||||
f"JWT secret_key '{secret_key}' is insecure. " "Please provide a strong random secret."
|
||||
)
|
||||
|
||||
self.SECRET_KEY: str = secret_key
|
||||
@@ -90,9 +87,7 @@ class JWTService:
|
||||
JWT Token 字符串
|
||||
"""
|
||||
now = datetime.utcnow()
|
||||
expire = now + timedelta( # noqa: E501
|
||||
minutes=self.config.ACCESS_TOKEN_EXPIRE_MINUTES
|
||||
)
|
||||
expire = now + timedelta(minutes=self.config.ACCESS_TOKEN_EXPIRE_MINUTES) # noqa: E501
|
||||
|
||||
payload = {
|
||||
"sub": user_id, # subject (用户ID)
|
||||
@@ -105,9 +100,7 @@ class JWTService:
|
||||
if additional_claims:
|
||||
payload.update(additional_claims)
|
||||
|
||||
return jwt.encode(
|
||||
payload, self.config.SECRET_KEY, algorithm=self.config.ALGORITHM
|
||||
)
|
||||
return jwt.encode(payload, self.config.SECRET_KEY, algorithm=self.config.ALGORITHM)
|
||||
|
||||
def create_refresh_token(self, user_id: str, session_id: str) -> str:
|
||||
"""
|
||||
@@ -131,9 +124,7 @@ class JWTService:
|
||||
"exp": expire,
|
||||
}
|
||||
|
||||
return jwt.encode(
|
||||
payload, self.config.SECRET_KEY, algorithm=self.config.ALGORITHM
|
||||
)
|
||||
return jwt.encode(payload, self.config.SECRET_KEY, algorithm=self.config.ALGORITHM)
|
||||
|
||||
def verify_token(self, token: str) -> Dict[str, Any]:
|
||||
"""
|
||||
@@ -223,10 +214,12 @@ class JWTService:
|
||||
# Lazy singleton - created with settings on first access
|
||||
_jwt_service_instance = None
|
||||
|
||||
|
||||
def _get_jwt_service():
|
||||
global _jwt_service_instance
|
||||
if _jwt_service_instance is None:
|
||||
from app.config import settings
|
||||
|
||||
kw = dict(secret_key=settings.JWT_SECRET_KEY)
|
||||
if hasattr(settings, "JWT_ALGORITHM"):
|
||||
kw["algorithm"] = settings.JWT_ALGORITHM
|
||||
@@ -237,9 +230,10 @@ def _get_jwt_service():
|
||||
_jwt_service_instance = JWTService(JWTConfig(**kw))
|
||||
return _jwt_service_instance
|
||||
|
||||
|
||||
class _JWTServiceProxy:
|
||||
def __getattr__(self, name):
|
||||
return getattr(_get_jwt_service(), name)
|
||||
|
||||
jwt_service = _JWTServiceProxy()
|
||||
|
||||
jwt_service = _JWTServiceProxy()
|
||||
|
||||
@@ -247,13 +247,13 @@ class RefreshTokenUseCase:
|
||||
def _find_session_by_refresh_token(self, refresh_token: str) -> Optional[dict]:
|
||||
"""
|
||||
通过 refresh_token 查找 session
|
||||
|
||||
|
||||
使用 Redis 中的反向索引 (refresh_token -> session_id) 快速查找 session。
|
||||
反向索引在 save_session 时创建,确保了 O(1) 的查找复杂度。
|
||||
|
||||
|
||||
Args:
|
||||
refresh_token: 刷新令牌
|
||||
|
||||
|
||||
Returns:
|
||||
Session 数据字典,包含 session_id, user_id 等信息;如果不存在返回 None
|
||||
"""
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
|
||||
使用方式:
|
||||
from packages.application.auth.password_handler import PasswordHandler, get_password_handler
|
||||
|
||||
|
||||
password_handler = PasswordHandler()
|
||||
hashed = password_handler.hash_password("my_secure_password")
|
||||
is_valid = password_handler.verify_password("my_secure_password", hashed)
|
||||
@@ -20,7 +20,7 @@ from packages.application.auth.password_hasher import PasswordHasher, PasswordVa
|
||||
class PasswordHandler:
|
||||
"""
|
||||
密码处理器委托类
|
||||
|
||||
|
||||
委托给 packages.domain.auth.password_hasher 进行实际的密码哈希操作,
|
||||
此层仅负责配置和封装,不直接依赖 bcrypt 库。
|
||||
"""
|
||||
@@ -28,7 +28,7 @@ class PasswordHandler:
|
||||
def __init__(self, rounds: int = 12):
|
||||
"""
|
||||
初始化密码处理器
|
||||
|
||||
|
||||
Args:
|
||||
rounds: bcrypt cost factor(默认 12,推荐范围 10-14)
|
||||
"""
|
||||
@@ -44,13 +44,13 @@ class PasswordHandler:
|
||||
def hash_password(self, password: str) -> str:
|
||||
"""
|
||||
哈希密码
|
||||
|
||||
|
||||
Args:
|
||||
password: 明文密码
|
||||
|
||||
|
||||
Returns:
|
||||
bcrypt 哈希字符串
|
||||
|
||||
|
||||
Raises:
|
||||
ValueError: 密码为空
|
||||
"""
|
||||
@@ -59,11 +59,11 @@ class PasswordHandler:
|
||||
def verify_password(self, password: str, hashed_password: str) -> bool:
|
||||
"""
|
||||
验证密码
|
||||
|
||||
|
||||
Args:
|
||||
password: 明文密码
|
||||
hashed_password: 存储的哈希密码
|
||||
|
||||
|
||||
Returns:
|
||||
True 如果密码正确,否则 False
|
||||
"""
|
||||
@@ -72,10 +72,10 @@ class PasswordHandler:
|
||||
def needs_rehash(self, hashed_password: str) -> bool:
|
||||
"""
|
||||
检查哈希是否需要重新计算
|
||||
|
||||
|
||||
Args:
|
||||
hashed_password: 存储的哈希密码
|
||||
|
||||
|
||||
Returns:
|
||||
True 如果需要重新哈希
|
||||
"""
|
||||
@@ -84,10 +84,10 @@ class PasswordHandler:
|
||||
def validate_strength(self, password: str) -> Tuple[bool, Optional[str]]:
|
||||
"""
|
||||
验证密码强度
|
||||
|
||||
|
||||
Args:
|
||||
password: 明文密码
|
||||
|
||||
|
||||
Returns:
|
||||
(是否有效, 错误信息)
|
||||
"""
|
||||
@@ -101,10 +101,10 @@ _default_handler: Optional[PasswordHandler] = None
|
||||
def configure_password_handler(rounds: int = 12) -> PasswordHandler:
|
||||
"""
|
||||
配置全局密码处理器
|
||||
|
||||
|
||||
Args:
|
||||
rounds: bcrypt cost factor
|
||||
|
||||
|
||||
Returns:
|
||||
配置好的 PasswordHandler 实例
|
||||
"""
|
||||
@@ -116,7 +116,7 @@ def configure_password_handler(rounds: int = 12) -> PasswordHandler:
|
||||
def get_password_handler() -> PasswordHandler:
|
||||
"""
|
||||
获取全局密码处理器
|
||||
|
||||
|
||||
Returns:
|
||||
PasswordHandler 实例
|
||||
"""
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
"""查重应用层用例。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
"""Recipe commands."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
"""Recipe use cases."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
@@ -26,6 +27,7 @@ class FeatureDisabledError(Exception):
|
||||
@dataclass
|
||||
class MissingAssetWarning:
|
||||
"""使用配方时缺失的素材警告"""
|
||||
|
||||
item_type: str
|
||||
item_id: str
|
||||
position: int
|
||||
@@ -144,6 +146,7 @@ class DeleteRecipeUseCase:
|
||||
@dataclass
|
||||
class UseRecipeResult:
|
||||
"""使用配方的结果"""
|
||||
|
||||
recipe: Recipe
|
||||
warnings: List[MissingAssetWarning]
|
||||
|
||||
@@ -166,9 +169,7 @@ class UseRecipeUseCase:
|
||||
FeatureScope.RECIPE_REUSE,
|
||||
user_plan=user_plan,
|
||||
):
|
||||
raise FeatureDisabledError(
|
||||
"配方复用功能仅对基础版和高级版用户开放"
|
||||
)
|
||||
raise FeatureDisabledError("配方复用功能仅对基础版和高级版用户开放")
|
||||
|
||||
# 2. 获取配方
|
||||
recipe = self.repository.get(recipe_id, user_id)
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
"""Template commands."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
"""Template use cases."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
@@ -22,6 +23,7 @@ class NotFoundError(Exception):
|
||||
|
||||
class ValidationError(Exception):
|
||||
"""业务规则校验失败."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
@@ -32,6 +34,7 @@ VALID_MATERIAL_TYPES = {"人物", "场景"}
|
||||
@dataclass
|
||||
class GenerateWarning:
|
||||
"""生成时的警告信息."""
|
||||
|
||||
code: str # voiceover_duration_mismatch / missing_material_type / ...
|
||||
message: str
|
||||
details: dict = field(default_factory=dict)
|
||||
@@ -40,6 +43,7 @@ class GenerateWarning:
|
||||
@dataclass
|
||||
class ValidateResult:
|
||||
"""模板校验结果."""
|
||||
|
||||
template: Template
|
||||
warnings: List[GenerateWarning] = field(default_factory=list)
|
||||
|
||||
@@ -190,9 +194,7 @@ class ValidateTemplateUseCase:
|
||||
# 业务规则 1: one_take 必须恰好 1 个片段
|
||||
if template.mode == EditingMode.ONE_TAKE.value:
|
||||
if len(template.segments) != 1:
|
||||
raise ValidationError(
|
||||
f"一镜到底模式必须恰好有 1 个片段,当前有 {len(template.segments)} 个"
|
||||
)
|
||||
raise ValidationError(f"一镜到底模式必须恰好有 1 个片段,当前有 {len(template.segments)} 个")
|
||||
|
||||
# 业务规则 2: voice_over 每个片段必须有 material_type
|
||||
if template.mode == EditingMode.VOICE_OVER.value:
|
||||
@@ -207,19 +209,21 @@ class ValidateTemplateUseCase:
|
||||
if command.voiceover_duration is not None and template.estimated_duration > 0:
|
||||
ratio = command.voiceover_duration / template.estimated_duration
|
||||
if ratio < 0.7 or ratio > 1.3:
|
||||
warnings.append(GenerateWarning(
|
||||
code="voiceover_duration_mismatch",
|
||||
message=(
|
||||
f"配音时长 ({command.voiceover_duration:.1f}s) "
|
||||
f"与预估时长 ({template.estimated_duration:.1f}s) "
|
||||
f"偏差超过 ±30%,可能影响剪辑效果"
|
||||
),
|
||||
details={
|
||||
"voiceover_duration": command.voiceover_duration,
|
||||
"estimated_duration": template.estimated_duration,
|
||||
"ratio": round(ratio, 3),
|
||||
},
|
||||
))
|
||||
warnings.append(
|
||||
GenerateWarning(
|
||||
code="voiceover_duration_mismatch",
|
||||
message=(
|
||||
f"配音时长 ({command.voiceover_duration:.1f}s) "
|
||||
f"与预估时长 ({template.estimated_duration:.1f}s) "
|
||||
f"偏差超过 ±30%,可能影响剪辑效果"
|
||||
),
|
||||
details={
|
||||
"voiceover_duration": command.voiceover_duration,
|
||||
"estimated_duration": template.estimated_duration,
|
||||
"ratio": round(ratio, 3),
|
||||
},
|
||||
)
|
||||
)
|
||||
|
||||
return ValidateResult(template=template, warnings=warnings)
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
"""Title library application module."""
|
||||
|
||||
from packages.application.title_library.use_cases import (
|
||||
CreateTitleLibraryUseCase,
|
||||
DeleteTitleLibraryUseCase,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
"""Title library commands."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
"""Title library use cases."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
"""Voice library application module."""
|
||||
|
||||
from packages.application.voice_library.use_cases import (
|
||||
CreateVoiceLibraryUseCase,
|
||||
DeleteVoiceLibraryUseCase,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
"""Voice library commands."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
"""Voice library use cases."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
"""查重记录领域实体。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
@@ -5,7 +5,8 @@ from enum import StrEnum
|
||||
|
||||
class EditingMode(StrEnum):
|
||||
"""剪辑模式枚举"""
|
||||
ONE_TAKE = "one_take" # 顺序拼接模式
|
||||
PIP = "pip" # 画中画模式
|
||||
VOICE_OVER = "voice_over" # 口播+B-roll模式
|
||||
VOICE_PIP = "voice_pip" # 口播+画中画组合模式
|
||||
|
||||
ONE_TAKE = "one_take" # 顺序拼接模式
|
||||
PIP = "pip" # 画中画模式
|
||||
VOICE_OVER = "voice_over" # 口播+B-roll模式
|
||||
VOICE_PIP = "voice_pip" # 口播+画中画组合模式
|
||||
|
||||
@@ -248,10 +248,3 @@ class IngestJob:
|
||||
library_id=library_id.strip(),
|
||||
storage_key=storage_key.strip(),
|
||||
)
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
+20
-19
@@ -18,23 +18,25 @@ from typing import Dict, List, Optional
|
||||
|
||||
class QuotaDimension(str, Enum):
|
||||
"""配额维度 - 所有可量化的资源限制"""
|
||||
STORAGE_GB = "storage_gb" # 存储空间 (GB)
|
||||
VIDEOS_PER_MONTH = "videos_per_month" # 每月生成视频数
|
||||
MAX_CONCURRENT = "max_concurrent" # 最大并发任务数
|
||||
MAX_TEMPLATES = "max_templates" # 最大模板数
|
||||
MAX_TITLES = "max_titles" # 最大标题库条目数
|
||||
MAX_VOICEOVERS = "max_voiceovers" # 最大配音库条目数
|
||||
AI_VOICE_ENABLED = "ai_voice_enabled" # AI 配音是否可用 (0/1)
|
||||
|
||||
STORAGE_GB = "storage_gb" # 存储空间 (GB)
|
||||
VIDEOS_PER_MONTH = "videos_per_month" # 每月生成视频数
|
||||
MAX_CONCURRENT = "max_concurrent" # 最大并发任务数
|
||||
MAX_TEMPLATES = "max_templates" # 最大模板数
|
||||
MAX_TITLES = "max_titles" # 最大标题库条目数
|
||||
MAX_VOICEOVERS = "max_voiceovers" # 最大配音库条目数
|
||||
AI_VOICE_ENABLED = "ai_voice_enabled" # AI 配音是否可用 (0/1)
|
||||
# 以下维度由扩展模块注册,初始配额为 0(由模块注册时填充)
|
||||
AI_VOICE_CREDITS = "ai_voice_credits" # AI 配音积分(每月)
|
||||
BATCH_EXPORT_ENABLED = "batch_export_enabled" # 批量导出
|
||||
MULTI_PLATFORM_ENABLED = "multi_platform_enabled" # 多平台发布
|
||||
DEDUP_REPORT_ENABLED = "dedup_report_enabled" # 去重检测报告
|
||||
AI_VOICE_CREDITS = "ai_voice_credits" # AI 配音积分(每月)
|
||||
BATCH_EXPORT_ENABLED = "batch_export_enabled" # 批量导出
|
||||
MULTI_PLATFORM_ENABLED = "multi_platform_enabled" # 多平台发布
|
||||
DEDUP_REPORT_ENABLED = "dedup_report_enabled" # 去重检测报告
|
||||
|
||||
|
||||
@dataclass
|
||||
class QuotaTier:
|
||||
"""一个套餐等级的配额定义"""
|
||||
|
||||
name: str
|
||||
limits: Dict[str, float] = field(default_factory=dict)
|
||||
|
||||
@@ -102,15 +104,17 @@ QUOTA_TIERS: Dict[str, QuotaTier] = {
|
||||
|
||||
class QuotaWarningLevel:
|
||||
"""配额告警级别"""
|
||||
NORMAL = "normal" # 使用量 < 80%
|
||||
WARNING = "warning" # 80% <= 使用量 < 100%
|
||||
CRITICAL = "critical" # 95% <= 使用量 < 100%
|
||||
EXCEEDED = "exceeded" # 使用量 >= 100%
|
||||
|
||||
NORMAL = "normal" # 使用量 < 80%
|
||||
WARNING = "warning" # 80% <= 使用量 < 100%
|
||||
CRITICAL = "critical" # 95% <= 使用量 < 100%
|
||||
EXCEEDED = "exceeded" # 使用量 >= 100%
|
||||
|
||||
|
||||
@dataclass
|
||||
class QuotaCheckResult:
|
||||
"""配额检查结果"""
|
||||
|
||||
allowed: bool
|
||||
dimension: str
|
||||
limit: float
|
||||
@@ -236,10 +240,7 @@ class QuotaChecker:
|
||||
usage: Dict[str, float],
|
||||
) -> List[QuotaCheckResult]:
|
||||
"""批量检查多个维度的配额"""
|
||||
return [
|
||||
self.check(plan_name, dim, used)
|
||||
for dim, used in usage.items()
|
||||
]
|
||||
return [self.check(plan_name, dim, used) for dim, used in usage.items()]
|
||||
|
||||
@staticmethod
|
||||
def _compute_warning_level(used: float, limit: float) -> str:
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
"""Recipe domain entities."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
@@ -9,6 +10,7 @@ from typing import List
|
||||
@dataclass
|
||||
class RecipeItem:
|
||||
"""配方中的单个素材/标题/配音项"""
|
||||
|
||||
id: str
|
||||
recipe_id: str
|
||||
item_type: str # asset / title / voice
|
||||
@@ -20,6 +22,7 @@ class RecipeItem:
|
||||
@dataclass
|
||||
class Recipe:
|
||||
"""配方 — 一次「一键生成」的完整参数组合"""
|
||||
|
||||
id: str
|
||||
user_id: str
|
||||
name: str
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
"""Template domain entities — 剪辑计划模板."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
@@ -9,6 +10,7 @@ from typing import List, Optional
|
||||
@dataclass
|
||||
class TemplateSegment:
|
||||
"""模板中的单个片段."""
|
||||
|
||||
id: str
|
||||
template_id: str
|
||||
segment_order: int
|
||||
@@ -22,6 +24,7 @@ class TemplateSegment:
|
||||
@dataclass
|
||||
class Template:
|
||||
"""剪辑计划模板."""
|
||||
|
||||
id: str
|
||||
user_id: str
|
||||
name: str
|
||||
@@ -41,6 +44,7 @@ class Template:
|
||||
@dataclass
|
||||
class TemplateCategory:
|
||||
"""模板分类."""
|
||||
|
||||
id: str
|
||||
user_id: str
|
||||
name: str
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
"""Title library domain entity."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
@@ -9,6 +10,7 @@ from typing import List
|
||||
@dataclass
|
||||
class TitleLibraryItem:
|
||||
"""标题库条目"""
|
||||
|
||||
id: str
|
||||
user_id: str
|
||||
name: str
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
"""Voice library domain entity."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
@@ -9,6 +10,7 @@ from typing import List, Optional
|
||||
@dataclass
|
||||
class VoiceLibraryItem:
|
||||
"""配音库条目"""
|
||||
|
||||
id: str
|
||||
user_id: str
|
||||
name: str
|
||||
|
||||
@@ -32,6 +32,7 @@ logger = logging.getLogger(__name__)
|
||||
@dataclass
|
||||
class FeatureFlag:
|
||||
"""单个 Feature Flag 的定义"""
|
||||
|
||||
name: str
|
||||
description: str = ""
|
||||
# 全局开关,默认 True(启用)
|
||||
@@ -64,6 +65,7 @@ class FeatureFlag:
|
||||
|
||||
class FeatureScope:
|
||||
"""Feature Flag 名称常量,避免硬编码字符串"""
|
||||
|
||||
AI_VOICE_GENERATION = "ai_voice_generation"
|
||||
DEDUPLICATION_REPORT = "deduplication_report"
|
||||
BATCH_EXPORT = "batch_export"
|
||||
@@ -179,10 +181,7 @@ class FeatureFlags:
|
||||
|
||||
def get_enabled_for_plan(self, plan: str) -> list[str]:
|
||||
"""获取指定套餐下所有启用的功能名称"""
|
||||
return [
|
||||
name for name, flag in self._flags.items()
|
||||
if flag.is_enabled(user_plan=plan)
|
||||
]
|
||||
return [name for name, flag in self._flags.items() if flag.is_enabled(user_plan=plan)]
|
||||
|
||||
|
||||
# 全局单例
|
||||
|
||||
@@ -21,10 +21,11 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
class ModuleStatus(str, Enum):
|
||||
"""模块运行状态"""
|
||||
REGISTERED = "registered" # 已注册,未激活
|
||||
ACTIVE = "active" # 已激活,可用
|
||||
DISABLED = "disabled" # 已禁用(管理员/Feature Flag 控制)
|
||||
ERROR = "error" # 注册或初始化出错
|
||||
|
||||
REGISTERED = "registered" # 已注册,未激活
|
||||
ACTIVE = "active" # 已激活,可用
|
||||
DISABLED = "disabled" # 已禁用(管理员/Feature Flag 控制)
|
||||
ERROR = "error" # 注册或初始化出错
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -34,9 +35,10 @@ class QuotaRule:
|
||||
描述该模块消耗哪些配额维度,以及每个操作消耗多少。
|
||||
例如:AI 配音模块每生成一条配音消耗 1 个 ai_voice_credit。
|
||||
"""
|
||||
dimension: str # 配额维度名,如 "ai_voice_credits", "storage_gb"
|
||||
per_operation: float # 每次操作消耗量
|
||||
description: str = "" # 人类可读描述
|
||||
|
||||
dimension: str # 配额维度名,如 "ai_voice_credits", "storage_gb"
|
||||
per_operation: float # 每次操作消耗量
|
||||
description: str = "" # 人类可读描述
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -45,10 +47,11 @@ class ModuleCapability:
|
||||
|
||||
能力是模块对外暴露的可调用功能单元。
|
||||
"""
|
||||
name: str # 能力名,如 "generate_voice"
|
||||
description: str = "" # 人类可读描述
|
||||
|
||||
name: str # 能力名,如 "generate_voice"
|
||||
description: str = "" # 人类可读描述
|
||||
quota_rules: List[QuotaRule] = field(default_factory=list) # 该能力消耗的配额规则
|
||||
metadata: Dict[str, Any] = field(default_factory=dict) # 扩展元数据
|
||||
metadata: Dict[str, Any] = field(default_factory=dict) # 扩展元数据
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -57,13 +60,14 @@ class Module:
|
||||
|
||||
每个扩展模块通过 Module 描述自身,注册到 ModuleRegistry。
|
||||
"""
|
||||
name: str # 模块唯一标识,如 "ai_voice"
|
||||
version: str = "1.0.0" # 模块版本
|
||||
description: str = "" # 人类可读描述
|
||||
|
||||
name: str # 模块唯一标识,如 "ai_voice"
|
||||
version: str = "1.0.0" # 模块版本
|
||||
description: str = "" # 人类可读描述
|
||||
capabilities: List[ModuleCapability] = field(default_factory=list)
|
||||
dependencies: List[str] = field(default_factory=list) # 依赖的其他模块名
|
||||
dependencies: List[str] = field(default_factory=list) # 依赖的其他模块名
|
||||
status: ModuleStatus = ModuleStatus.REGISTERED
|
||||
config: Dict[str, Any] = field(default_factory=dict) # 模块配置
|
||||
config: Dict[str, Any] = field(default_factory=dict) # 模块配置
|
||||
_init_func: Optional[Callable] = field(default=None, repr=False) # 初始化回调
|
||||
|
||||
def activate(self) -> None:
|
||||
@@ -144,14 +148,9 @@ class ModuleRegistry:
|
||||
raise KeyError(f"Module '{name}' not found")
|
||||
|
||||
# 检查是否有其他模块依赖它
|
||||
dependents = [
|
||||
m.name for m in self._modules.values()
|
||||
if name in m.dependencies and m.name != name
|
||||
]
|
||||
dependents = [m.name for m in self._modules.values() if name in m.dependencies and m.name != name]
|
||||
if dependents:
|
||||
raise ValueError(
|
||||
f"Cannot unregister module '{name}': depended on by {dependents}"
|
||||
)
|
||||
raise ValueError(f"Cannot unregister module '{name}': depended on by {dependents}")
|
||||
|
||||
del self._modules[name]
|
||||
logger.info(f"Module '{name}' unregistered")
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
"""查重记录仓库端口(Protocol)。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Protocol
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
"""Recipe repository port."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import List, Optional, Protocol
|
||||
@@ -15,29 +16,20 @@ class RecipeRepository(Protocol):
|
||||
*,
|
||||
skip: int = 0,
|
||||
limit: int = 50,
|
||||
) -> List[Recipe]:
|
||||
...
|
||||
) -> List[Recipe]: ...
|
||||
|
||||
def get(self, recipe_id: str, user_id: str) -> Optional[Recipe]:
|
||||
...
|
||||
def get(self, recipe_id: str, user_id: str) -> Optional[Recipe]: ...
|
||||
|
||||
def create(self, recipe: Recipe) -> Recipe:
|
||||
...
|
||||
def create(self, recipe: Recipe) -> Recipe: ...
|
||||
|
||||
def update(self, recipe: Recipe) -> Recipe:
|
||||
...
|
||||
def update(self, recipe: Recipe) -> Recipe: ...
|
||||
|
||||
def delete(self, recipe_id: str, user_id: str) -> bool:
|
||||
...
|
||||
def delete(self, recipe_id: str, user_id: str) -> bool: ...
|
||||
|
||||
def count_by_user(self, user_id: str, is_active: bool = True) -> int:
|
||||
...
|
||||
def count_by_user(self, user_id: str, is_active: bool = True) -> int: ...
|
||||
|
||||
def list_items(self, recipe_id: str) -> List[RecipeItem]:
|
||||
...
|
||||
def list_items(self, recipe_id: str) -> List[RecipeItem]: ...
|
||||
|
||||
def create_items(self, items: List[RecipeItem]) -> List[RecipeItem]:
|
||||
...
|
||||
def create_items(self, items: List[RecipeItem]) -> List[RecipeItem]: ...
|
||||
|
||||
def delete_items_by_recipe(self, recipe_id: str) -> int:
|
||||
...
|
||||
def delete_items_by_recipe(self, recipe_id: str) -> int: ...
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
"""Template repository port (Protocol)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import List, Optional, Protocol
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
"""Title library repository port."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import List, Optional, Protocol
|
||||
@@ -17,20 +18,14 @@ class TitleLibraryRepository(Protocol):
|
||||
is_active: bool = True,
|
||||
skip: int = 0,
|
||||
limit: int = 50,
|
||||
) -> List[TitleLibraryItem]:
|
||||
...
|
||||
) -> List[TitleLibraryItem]: ...
|
||||
|
||||
def get(self, title_id: str, user_id: str) -> Optional[TitleLibraryItem]:
|
||||
...
|
||||
def get(self, title_id: str, user_id: str) -> Optional[TitleLibraryItem]: ...
|
||||
|
||||
def create(self, item: TitleLibraryItem) -> TitleLibraryItem:
|
||||
...
|
||||
def create(self, item: TitleLibraryItem) -> TitleLibraryItem: ...
|
||||
|
||||
def update(self, item: TitleLibraryItem) -> TitleLibraryItem:
|
||||
...
|
||||
def update(self, item: TitleLibraryItem) -> TitleLibraryItem: ...
|
||||
|
||||
def delete(self, title_id: str, user_id: str) -> bool:
|
||||
...
|
||||
def delete(self, title_id: str, user_id: str) -> bool: ...
|
||||
|
||||
def count_by_user(self, user_id: str, is_active: bool = True) -> int:
|
||||
...
|
||||
def count_by_user(self, user_id: str, is_active: bool = True) -> int: ...
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
"""Voice library repository port."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import List, Optional, Protocol
|
||||
@@ -16,20 +17,14 @@ class VoiceLibraryRepository(Protocol):
|
||||
status: Optional[str] = None,
|
||||
skip: int = 0,
|
||||
limit: int = 50,
|
||||
) -> List[VoiceLibraryItem]:
|
||||
...
|
||||
) -> List[VoiceLibraryItem]: ...
|
||||
|
||||
def get(self, voice_id: str, user_id: str) -> Optional[VoiceLibraryItem]:
|
||||
...
|
||||
def get(self, voice_id: str, user_id: str) -> Optional[VoiceLibraryItem]: ...
|
||||
|
||||
def create(self, item: VoiceLibraryItem) -> VoiceLibraryItem:
|
||||
...
|
||||
def create(self, item: VoiceLibraryItem) -> VoiceLibraryItem: ...
|
||||
|
||||
def update(self, item: VoiceLibraryItem) -> VoiceLibraryItem:
|
||||
...
|
||||
def update(self, item: VoiceLibraryItem) -> VoiceLibraryItem: ...
|
||||
|
||||
def delete(self, voice_id: str, user_id: str) -> bool:
|
||||
...
|
||||
def delete(self, voice_id: str, user_id: str) -> bool: ...
|
||||
|
||||
def count_by_user(self, user_id: str) -> int:
|
||||
...
|
||||
def count_by_user(self, user_id: str) -> int: ...
|
||||
|
||||
+49
-36
@@ -4,6 +4,7 @@
|
||||
用法: python3 smoke_test.py <API_BASE_URL> [--email EMAIL] [--password PASSWORD] [--json]
|
||||
示例: python3 smoke_test.py https://saas-api.xiaoxiajianji.com --email test@example.com --password test123 --json
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import sys
|
||||
@@ -13,19 +14,29 @@ import urllib.error
|
||||
import ssl
|
||||
|
||||
CORE_ENDPOINTS = [
|
||||
{"name": "upload/direct/prepare", "method": "POST", "path": "/api/v1/upload/direct/prepare",
|
||||
"body": {"project_id": "smoke-test", "file_name": "test.mp4", "file_size": 1024, "content_type": "video/mp4"},
|
||||
"expect": [200, 401, 422]},
|
||||
{"name": "upload/chunk/init", "method": "POST", "path": "/api/v1/upload/chunk/init",
|
||||
"body": {"project_id": "smoke-test", "file_name": "test.mp4", "file_size": 1024000, "total_chunks": 2},
|
||||
"expect": [200, 401, 422]},
|
||||
{"name": "dashboard/overview", "method": "GET", "path": "/api/v1/dashboard/overview",
|
||||
"expect": [200, 401]},
|
||||
{"name": "assets", "method": "GET", "path": "/api/v1/assets?library_id=smoke-test",
|
||||
"expect": [200, 401]},
|
||||
{"name": "generation/tasks", "method": "POST", "path": "/api/v1/generation/tasks",
|
||||
"body": {},
|
||||
"expect": [200, 401, 422]},
|
||||
{
|
||||
"name": "upload/direct/prepare",
|
||||
"method": "POST",
|
||||
"path": "/api/v1/upload/direct/prepare",
|
||||
"body": {"project_id": "smoke-test", "file_name": "test.mp4", "file_size": 1024, "content_type": "video/mp4"},
|
||||
"expect": [200, 401, 422],
|
||||
},
|
||||
{
|
||||
"name": "upload/chunk/init",
|
||||
"method": "POST",
|
||||
"path": "/api/v1/upload/chunk/init",
|
||||
"body": {"project_id": "smoke-test", "file_name": "test.mp4", "file_size": 1024000, "total_chunks": 2},
|
||||
"expect": [200, 401, 422],
|
||||
},
|
||||
{"name": "dashboard/overview", "method": "GET", "path": "/api/v1/dashboard/overview", "expect": [200, 401]},
|
||||
{"name": "assets", "method": "GET", "path": "/api/v1/assets?library_id=smoke-test", "expect": [200, 401]},
|
||||
{
|
||||
"name": "generation/tasks",
|
||||
"method": "POST",
|
||||
"path": "/api/v1/generation/tasks",
|
||||
"body": {},
|
||||
"expect": [200, 401, 422],
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
@@ -34,14 +45,14 @@ def make_request(base_url, endpoint, token=None):
|
||||
headers = {"Content-Type": "application/json"}
|
||||
if token:
|
||||
headers["Authorization"] = f"Bearer {token}"
|
||||
|
||||
|
||||
data = json.dumps(endpoint.get("body", {})).encode() if endpoint.get("body") is not None else None
|
||||
req = urllib.request.Request(url, data=data, headers=headers, method=endpoint["method"])
|
||||
|
||||
|
||||
ctx = ssl.create_default_context()
|
||||
ctx.check_hostname = False
|
||||
ctx.verify_mode = ssl.CERT_NONE
|
||||
|
||||
|
||||
try:
|
||||
start = time.time()
|
||||
resp = urllib.request.urlopen(req, timeout=15, context=ctx)
|
||||
@@ -78,15 +89,15 @@ def login(base_url, email, password):
|
||||
def run_smoke_test(base_url, email=None, password=None, output_json=False):
|
||||
base_url = base_url.rstrip("/")
|
||||
token = None
|
||||
|
||||
|
||||
if email and password:
|
||||
token = login(base_url, email, password)
|
||||
if not output_json:
|
||||
print(f"{'✅ 登录成功' if token else '⚠️ 登录失败,将以未认证模式测试'}")
|
||||
|
||||
|
||||
results = []
|
||||
all_passed = True
|
||||
|
||||
|
||||
for ep in CORE_ENDPOINTS:
|
||||
result = make_request(base_url, ep, token)
|
||||
passed = result["status"] in ep["expect"] and result["error"] is None
|
||||
@@ -94,24 +105,26 @@ def run_smoke_test(base_url, email=None, password=None, output_json=False):
|
||||
if is_5xx:
|
||||
passed = False
|
||||
all_passed = False
|
||||
|
||||
results.append({
|
||||
"name": ep["name"],
|
||||
"path": ep["path"],
|
||||
"status": result["status"],
|
||||
"elapsed_ms": result["elapsed_ms"],
|
||||
"passed": passed,
|
||||
"error": result["error"],
|
||||
"is_5xx": is_5xx
|
||||
})
|
||||
|
||||
|
||||
results.append(
|
||||
{
|
||||
"name": ep["name"],
|
||||
"path": ep["path"],
|
||||
"status": result["status"],
|
||||
"elapsed_ms": result["elapsed_ms"],
|
||||
"passed": passed,
|
||||
"error": result["error"],
|
||||
"is_5xx": is_5xx,
|
||||
}
|
||||
)
|
||||
|
||||
if not output_json:
|
||||
icon = "✅" if passed else "❌"
|
||||
print(f" {icon} {ep['name']}: {result['status']} ({result['elapsed_ms']}ms)")
|
||||
|
||||
|
||||
if output_json:
|
||||
print(json.dumps({"success": all_passed, "results": results, "base_url": base_url}, indent=2))
|
||||
|
||||
|
||||
return 0 if all_passed else 1
|
||||
|
||||
|
||||
@@ -122,17 +135,17 @@ def main():
|
||||
parser.add_argument("--password", help="登录密码")
|
||||
parser.add_argument("--json", action="store_true", help="JSON 格式输出")
|
||||
args = parser.parse_args()
|
||||
|
||||
|
||||
if not args.json:
|
||||
print(f"\n🔍 冒烟测试: {args.base_url}")
|
||||
print("-" * 50)
|
||||
|
||||
|
||||
exit_code = run_smoke_test(args.base_url, args.email, args.password, args.json)
|
||||
|
||||
|
||||
if not args.json:
|
||||
print("-" * 50)
|
||||
print(f"{'✅ 全部通过' if exit_code == 0 else '❌ 存在失败端点'}\n")
|
||||
|
||||
|
||||
sys.exit(exit_code)
|
||||
|
||||
|
||||
|
||||
@@ -9,7 +9,6 @@ from fastapi.testclient import TestClient
|
||||
|
||||
from apps.api.main import app
|
||||
|
||||
|
||||
client = TestClient(app)
|
||||
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
|
||||
覆盖端点:POST /upload(查重上传)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
@@ -22,11 +23,11 @@ import pytest
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 1. Mock 项目内部模块
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _install_mocks():
|
||||
"""安装所有必需的 mock 模块。"""
|
||||
|
||||
@@ -88,6 +89,7 @@ def _install_mocks():
|
||||
@classmethod
|
||||
def create(cls, user_id, filename, file_size, storage_key, **kwargs):
|
||||
from uuid import uuid4
|
||||
|
||||
return cls(
|
||||
id=uuid4().hex,
|
||||
user_id=user_id,
|
||||
@@ -111,18 +113,24 @@ def _install_mocks():
|
||||
|
||||
# packages.domain, packages.adapters, packages.application namespace
|
||||
for name in [
|
||||
"packages", "packages.domain", "packages.ports",
|
||||
"packages.adapters", "packages.adapters.sqlalchemy_impl",
|
||||
"packages",
|
||||
"packages.domain",
|
||||
"packages.ports",
|
||||
"packages.adapters",
|
||||
"packages.adapters.sqlalchemy_impl",
|
||||
"packages.adapters.sqlalchemy_impl.user_repository",
|
||||
"packages.adapters.sqlalchemy_impl.duplication_repository",
|
||||
"packages.adapters.sqlalchemy_impl.session",
|
||||
"packages.adapters.redis", "packages.adapters.smtp",
|
||||
"packages.adapters.redis",
|
||||
"packages.adapters.smtp",
|
||||
]:
|
||||
if name not in sys.modules:
|
||||
sys.modules[name] = types.ModuleType(name)
|
||||
|
||||
sys.modules["packages.adapters.sqlalchemy_impl.user_repository"].SQLAlchemyUserRepository = MagicMock
|
||||
sys.modules["packages.adapters.sqlalchemy_impl.duplication_repository"].SQLAlchemyDuplicationRecordRepository = MagicMock
|
||||
sys.modules["packages.adapters.sqlalchemy_impl.duplication_repository"].SQLAlchemyDuplicationRecordRepository = (
|
||||
MagicMock
|
||||
)
|
||||
sys.modules["packages.adapters.sqlalchemy_impl.session"].build_session_factory = MagicMock(
|
||||
return_value=(MagicMock(), MagicMock())
|
||||
)
|
||||
@@ -146,6 +154,7 @@ def _install_mocks():
|
||||
class UploadForDuplicationUseCase:
|
||||
def __init__(self, repo):
|
||||
self.repo = repo
|
||||
|
||||
def execute(self, cmd):
|
||||
record = DuplicationRecord.create(
|
||||
user_id=cmd.user_id,
|
||||
@@ -156,20 +165,32 @@ def _install_mocks():
|
||||
return record
|
||||
|
||||
class ListDuplicationRecordsUseCase:
|
||||
def __init__(self, repo): self.repo = repo
|
||||
def execute(self, user_id, **kw): return []
|
||||
def __init__(self, repo):
|
||||
self.repo = repo
|
||||
|
||||
def execute(self, user_id, **kw):
|
||||
return []
|
||||
|
||||
class GetDuplicationDetailUseCase:
|
||||
def __init__(self, repo): self.repo = repo
|
||||
def execute(self, record_id): return None
|
||||
def __init__(self, repo):
|
||||
self.repo = repo
|
||||
|
||||
def execute(self, record_id):
|
||||
return None
|
||||
|
||||
class DeleteDuplicationRecordUseCase:
|
||||
def __init__(self, repo): self.repo = repo
|
||||
def execute(self, record_id): return True
|
||||
def __init__(self, repo):
|
||||
self.repo = repo
|
||||
|
||||
def execute(self, record_id):
|
||||
return True
|
||||
|
||||
class RetryDuplicationUseCase:
|
||||
def __init__(self, repo): self.repo = repo
|
||||
def execute(self, record_id): return None
|
||||
def __init__(self, repo):
|
||||
self.repo = repo
|
||||
|
||||
def execute(self, record_id):
|
||||
return None
|
||||
|
||||
app_mod.UploadForDuplicationCommand = UploadForDuplicationCommand
|
||||
app_mod.UploadForDuplicationUseCase = UploadForDuplicationUseCase
|
||||
@@ -300,9 +321,8 @@ for ns in ["app", "app.api", "app.api.routes"]:
|
||||
sys.modules[ns] = types.ModuleType(ns)
|
||||
|
||||
import importlib.util
|
||||
_spec = importlib.util.spec_from_file_location(
|
||||
"app.api.routes.duplication", "/tmp/duplication_routes_fixed.py"
|
||||
)
|
||||
|
||||
_spec = importlib.util.spec_from_file_location("app.api.routes.duplication", "/tmp/duplication_routes_fixed.py")
|
||||
duplication = importlib.util.module_from_spec(_spec)
|
||||
sys.modules["app.api.routes.duplication"] = duplication
|
||||
_spec.loader.exec_module(duplication)
|
||||
@@ -312,6 +332,7 @@ _spec.loader.exec_module(duplication)
|
||||
# 2. Fixtures
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _make_user(**overrides) -> User:
|
||||
defaults = dict(
|
||||
id="user-dup-001",
|
||||
@@ -330,15 +351,26 @@ def _make_user(**overrides) -> User:
|
||||
|
||||
class MockDuplicationRepo:
|
||||
"""内存中的查重记录 Repository mock。"""
|
||||
def create(self, record): return record
|
||||
def get(self, record_id): return None
|
||||
def list_by_user(self, user_id, **kw): return []
|
||||
def update(self, record): return record
|
||||
def delete(self, record_id): return True
|
||||
|
||||
def create(self, record):
|
||||
return record
|
||||
|
||||
def get(self, record_id):
|
||||
return None
|
||||
|
||||
def list_by_user(self, user_id, **kw):
|
||||
return []
|
||||
|
||||
def update(self, record):
|
||||
return record
|
||||
|
||||
def delete(self, record_id):
|
||||
return True
|
||||
|
||||
|
||||
class MockStorageService:
|
||||
"""可控的存储服务 mock。"""
|
||||
|
||||
def __init__(self, should_fail=False, error_msg="Internal server error details"):
|
||||
self.should_fail = should_fail
|
||||
self.error_msg = error_msg
|
||||
@@ -386,6 +418,7 @@ def client(mock_dup_repo, mock_storage):
|
||||
# 3. MIME 类型验证(P0 修复验证)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestMIMETypeValidation:
|
||||
"""验证 MIME 类型白名单校验。"""
|
||||
|
||||
@@ -519,6 +552,7 @@ class TestMIMETypeValidation:
|
||||
# 4. 文件大小限制(P0 修复验证)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestFileSizeLimit:
|
||||
"""验证文件大小限制。"""
|
||||
|
||||
@@ -555,6 +589,7 @@ class TestFileSizeLimit:
|
||||
# 5. 错误信息不泄露内部异常(P1 核心修复验证)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestErrorInfoLeakPrevention:
|
||||
"""P1 修复核心:验证错误响应不泄露内部异常堆栈和详细信息。"""
|
||||
|
||||
@@ -628,7 +663,7 @@ class TestErrorInfoLeakPrevention:
|
||||
assert resp.status_code == 415
|
||||
body = resp.text
|
||||
assert "Traceback" not in body
|
||||
assert "File \"" not in body
|
||||
assert 'File "' not in body
|
||||
assert "line " not in body
|
||||
|
||||
def test_error_response_no_internal_paths(self, client):
|
||||
@@ -672,6 +707,7 @@ class TestErrorInfoLeakPrevention:
|
||||
# 6. 正常上传流程(验证修复不影响正常功能)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestNormalUploadFlow:
|
||||
"""验证正常上传流程不受修复影响。"""
|
||||
|
||||
@@ -738,6 +774,7 @@ class TestNormalUploadFlow:
|
||||
# 7. 边界情况
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestEdgeCases:
|
||||
|
||||
def test_missing_filename_returns_400(self, client):
|
||||
@@ -765,6 +802,7 @@ class TestEdgeCases:
|
||||
# 8. _validate_video_mime_type 辅助函数单元测试
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestValidateVideoMimeType:
|
||||
"""直接测试 _validate_video_mime_type 函数。"""
|
||||
|
||||
@@ -785,8 +823,13 @@ class TestValidateVideoMimeType:
|
||||
def test_all_allowed_types_pass(self):
|
||||
"""所有允许的 MIME 类型都应通过。"""
|
||||
allowed = [
|
||||
"video/mp4", "video/mpeg", "video/quicktime", "video/x-msvideo",
|
||||
"video/webm", "video/x-matroska", "video/3gpp",
|
||||
"video/mp4",
|
||||
"video/mpeg",
|
||||
"video/quicktime",
|
||||
"video/x-msvideo",
|
||||
"video/webm",
|
||||
"video/x-matroska",
|
||||
"video/3gpp",
|
||||
]
|
||||
for mime in allowed:
|
||||
result = duplication._validate_video_mime_type(mime)
|
||||
@@ -795,6 +838,7 @@ class TestValidateVideoMimeType:
|
||||
def test_empty_content_type_raises_400(self):
|
||||
"""空 Content-Type 应抛出 400。"""
|
||||
from fastapi import HTTPException
|
||||
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
duplication._validate_video_mime_type("")
|
||||
# 空字符串 split 后为空,不在白名单 → 415
|
||||
@@ -805,6 +849,7 @@ class TestValidateVideoMimeType:
|
||||
def test_none_content_type_raises_400(self):
|
||||
"""None Content-Type 应抛出 400。"""
|
||||
from fastapi import HTTPException
|
||||
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
duplication._validate_video_mime_type(None)
|
||||
assert exc_info.value.status_code == 400
|
||||
@@ -812,6 +857,7 @@ class TestValidateVideoMimeType:
|
||||
def test_invalid_mime_raises_415(self):
|
||||
"""无效 MIME 类型应抛出 415。"""
|
||||
from fastapi import HTTPException
|
||||
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
duplication._validate_video_mime_type("text/html")
|
||||
assert exc_info.value.status_code == 415
|
||||
@@ -819,6 +865,7 @@ class TestValidateVideoMimeType:
|
||||
def test_415_message_is_safe(self):
|
||||
"""415 错误消息不包含技术实现细节。"""
|
||||
from fastapi import HTTPException
|
||||
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
duplication._validate_video_mime_type("application/json")
|
||||
detail = exc_info.value.detail
|
||||
|
||||
@@ -59,9 +59,7 @@ def simulate_generate_video(
|
||||
task.started_at = task.started_at or datetime.now(timezone.utc)
|
||||
task_repo.update(task)
|
||||
|
||||
file_url = (
|
||||
f"/projects/{task.project_id}/generated/{task.id}/{task.id}.mp4"
|
||||
)
|
||||
file_url = f"/projects/{task.project_id}/generated/{task.id}/{task.id}.mp4"
|
||||
video = GeneratedVideo.create(
|
||||
project_id=task.project_id,
|
||||
generation_task_id=task.id,
|
||||
|
||||
@@ -38,7 +38,6 @@ def test_get_project_by_id_restores_workspace_context():
|
||||
create_use_case = CreateProjectUseCase(repository)
|
||||
get_use_case = GetProjectUseCase(repository)
|
||||
|
||||
|
||||
retrieved = get_use_case.execute(project.id)
|
||||
assert retrieved is not None
|
||||
assert retrieved.id == project.id
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
测试使用 FastAPI TestClient + 依赖覆盖(dependency_overrides),
|
||||
不连接真实数据库,不访问外部服务。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
@@ -23,11 +24,11 @@ import pytest
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 1. Mock 项目内部模块(使 subscription 路由可独立导入)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _install_mocks():
|
||||
"""在 sys.modules 中安装所有必需的 mock 模块,使 subscription.py 可导入。"""
|
||||
|
||||
@@ -58,24 +59,41 @@ def _install_mocks():
|
||||
|
||||
# ---------- packages.ports.user_repository ----------
|
||||
class UserRepository:
|
||||
def save(self, user): pass
|
||||
def find_by_id(self, user_id): return None
|
||||
def find_by_email(self, email): return None
|
||||
def find_by_username(self, username): return None
|
||||
def find_by_verification_token(self, token): return None
|
||||
def find_by_password_reset_token(self, token): return None
|
||||
def delete(self, user_id): return True
|
||||
def save(self, user):
|
||||
pass
|
||||
|
||||
def find_by_id(self, user_id):
|
||||
return None
|
||||
|
||||
def find_by_email(self, email):
|
||||
return None
|
||||
|
||||
def find_by_username(self, username):
|
||||
return None
|
||||
|
||||
def find_by_verification_token(self, token):
|
||||
return None
|
||||
|
||||
def find_by_password_reset_token(self, token):
|
||||
return None
|
||||
|
||||
def delete(self, user_id):
|
||||
return True
|
||||
|
||||
user_repo_mod = types.ModuleType("packages.ports.user_repository")
|
||||
user_repo_mod.UserRepository = UserRepository
|
||||
|
||||
# ---------- packages (namespace) ----------
|
||||
for name in [
|
||||
"packages", "packages.domain", "packages.ports",
|
||||
"packages.adapters", "packages.adapters.sqlalchemy_impl",
|
||||
"packages",
|
||||
"packages.domain",
|
||||
"packages.ports",
|
||||
"packages.adapters",
|
||||
"packages.adapters.sqlalchemy_impl",
|
||||
"packages.adapters.sqlalchemy_impl.user_repository",
|
||||
"packages.adapters.sqlalchemy_impl.session",
|
||||
"packages.adapters.redis", "packages.adapters.smtp",
|
||||
"packages.adapters.redis",
|
||||
"packages.adapters.smtp",
|
||||
"packages.application",
|
||||
]:
|
||||
if name not in sys.modules:
|
||||
@@ -95,11 +113,16 @@ def _install_mocks():
|
||||
|
||||
# Stub 其他 repository ports(dependencies.py 会 import 它们)
|
||||
for port_name in [
|
||||
"asset_repository", "asset_library_repository",
|
||||
"classification_job_repository", "duplication_repository",
|
||||
"generated_video_repository", "generation_task_repository",
|
||||
"title_library_repository", "voice_library_repository",
|
||||
"ingest_job_repository", "project_repository",
|
||||
"asset_repository",
|
||||
"asset_library_repository",
|
||||
"classification_job_repository",
|
||||
"duplication_repository",
|
||||
"generated_video_repository",
|
||||
"generation_task_repository",
|
||||
"title_library_repository",
|
||||
"voice_library_repository",
|
||||
"ingest_job_repository",
|
||||
"project_repository",
|
||||
]:
|
||||
mod = types.ModuleType(f"packages.ports.{port_name}")
|
||||
# 动态创建一个 Mock repository class
|
||||
@@ -251,9 +274,8 @@ for ns in ["app", "app.api", "app.api.routes"]:
|
||||
|
||||
# 导入 subscription 路由
|
||||
import importlib.util
|
||||
_spec = importlib.util.spec_from_file_location(
|
||||
"app.api.routes.subscription", "/tmp/subscription_routes.py"
|
||||
)
|
||||
|
||||
_spec = importlib.util.spec_from_file_location("app.api.routes.subscription", "/tmp/subscription_routes.py")
|
||||
subscription = importlib.util.module_from_spec(_spec)
|
||||
sys.modules["app.api.routes.subscription"] = subscription
|
||||
_spec.loader.exec_module(subscription)
|
||||
@@ -263,6 +285,7 @@ _spec.loader.exec_module(subscription)
|
||||
# 2. Fixtures
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _make_user(**overrides) -> User:
|
||||
"""创建测试用 User 实例。"""
|
||||
defaults = dict(
|
||||
@@ -324,12 +347,14 @@ def pro_client(mock_user_repo):
|
||||
app.include_router(subscription.router)
|
||||
|
||||
def _override_get_current_user():
|
||||
return AuthenticatedUser(user=_make_user(
|
||||
subscription_plan="pro",
|
||||
subscription_status="active",
|
||||
max_projects=-1,
|
||||
max_storage_gb=100,
|
||||
))
|
||||
return AuthenticatedUser(
|
||||
user=_make_user(
|
||||
subscription_plan="pro",
|
||||
subscription_status="active",
|
||||
max_projects=-1,
|
||||
max_storage_gb=100,
|
||||
)
|
||||
)
|
||||
|
||||
def _override_get_user_repo():
|
||||
return mock_user_repo
|
||||
@@ -344,6 +369,7 @@ def pro_client(mock_user_repo):
|
||||
# 3. GET /current — 获取当前订阅信息
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestGetCurrentSubscription:
|
||||
"""GET /current 端点测试。"""
|
||||
|
||||
@@ -391,6 +417,7 @@ class TestGetCurrentSubscription:
|
||||
# 4. GET /billing-records — 获取账单记录
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestGetBillingRecords:
|
||||
|
||||
def test_returns_empty_list(self, client):
|
||||
@@ -406,14 +433,18 @@ class TestGetBillingRecords:
|
||||
# 5. POST /change-plan — 变更套餐
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestChangePlan:
|
||||
|
||||
def test_upgrade_free_to_standard(self, client, mock_user_repo):
|
||||
"""从 free 升级到 standard 应成功。"""
|
||||
resp = client.post("/change-plan", json={
|
||||
"target_plan_id": "standard",
|
||||
"billing_cycle": "monthly",
|
||||
})
|
||||
resp = client.post(
|
||||
"/change-plan",
|
||||
json={
|
||||
"target_plan_id": "standard",
|
||||
"billing_cycle": "monthly",
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["success"] is True
|
||||
@@ -424,10 +455,13 @@ class TestChangePlan:
|
||||
|
||||
def test_upgrade_free_to_pro(self, client, mock_user_repo):
|
||||
"""从 free 升级到 pro 应成功,配额正确更新。"""
|
||||
resp = client.post("/change-plan", json={
|
||||
"target_plan_id": "pro",
|
||||
"billing_cycle": "yearly",
|
||||
})
|
||||
resp = client.post(
|
||||
"/change-plan",
|
||||
json={
|
||||
"target_plan_id": "pro",
|
||||
"billing_cycle": "yearly",
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["success"] is True
|
||||
@@ -444,10 +478,13 @@ class TestChangePlan:
|
||||
|
||||
def test_upgrade_to_enterprise(self, client, mock_user_repo):
|
||||
"""升级到 enterprise 套餐。"""
|
||||
resp = client.post("/change-plan", json={
|
||||
"target_plan_id": "enterprise",
|
||||
"billing_cycle": "monthly",
|
||||
})
|
||||
resp = client.post(
|
||||
"/change-plan",
|
||||
json={
|
||||
"target_plan_id": "enterprise",
|
||||
"billing_cycle": "monthly",
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["success"] is True
|
||||
@@ -459,10 +496,13 @@ class TestChangePlan:
|
||||
|
||||
def test_same_plan_returns_failure(self, client):
|
||||
"""当前套餐与目标套餐相同时应返回 success=False。"""
|
||||
resp = client.post("/change-plan", json={
|
||||
"target_plan_id": "free",
|
||||
"billing_cycle": "monthly",
|
||||
})
|
||||
resp = client.post(
|
||||
"/change-plan",
|
||||
json={
|
||||
"target_plan_id": "free",
|
||||
"billing_cycle": "monthly",
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["success"] is False
|
||||
@@ -470,19 +510,25 @@ class TestChangePlan:
|
||||
|
||||
def test_invalid_plan_id_returns_400(self, client):
|
||||
"""无效套餐 ID 应返回 400。"""
|
||||
resp = client.post("/change-plan", json={
|
||||
"target_plan_id": "ultra_mega_plan",
|
||||
"billing_cycle": "monthly",
|
||||
})
|
||||
resp = client.post(
|
||||
"/change-plan",
|
||||
json={
|
||||
"target_plan_id": "ultra_mega_plan",
|
||||
"billing_cycle": "monthly",
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
assert "无效的套餐ID" in resp.json()["detail"]
|
||||
|
||||
def test_invalid_billing_cycle_returns_400(self, client):
|
||||
"""无效计费周期应返回 400。"""
|
||||
resp = client.post("/change-plan", json={
|
||||
"target_plan_id": "pro",
|
||||
"billing_cycle": "weekly",
|
||||
})
|
||||
resp = client.post(
|
||||
"/change-plan",
|
||||
json={
|
||||
"target_plan_id": "pro",
|
||||
"billing_cycle": "weekly",
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
assert "无效的计费周期" in resp.json()["detail"]
|
||||
|
||||
@@ -510,10 +556,13 @@ class TestChangePlan:
|
||||
app.dependency_overrides[subscription.get_user_repository] = lambda: mock_user_repo
|
||||
|
||||
tc = TestClient(app)
|
||||
resp = tc.post("/change-plan", json={
|
||||
"target_plan_id": "standard",
|
||||
"billing_cycle": "monthly",
|
||||
})
|
||||
resp = tc.post(
|
||||
"/change-plan",
|
||||
json={
|
||||
"target_plan_id": "standard",
|
||||
"billing_cycle": "monthly",
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
# 原始 user 对象不变
|
||||
assert original_user.subscription_plan == "free"
|
||||
@@ -525,6 +574,7 @@ class TestChangePlan:
|
||||
# 6. POST /cancel — 取消订阅
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestCancelSubscription:
|
||||
|
||||
def test_cancel_pro_subscription(self, pro_client, mock_user_repo):
|
||||
@@ -569,6 +619,7 @@ class TestCancelSubscription:
|
||||
# 7. POST /toggle-auto-renew — 切换自动续费
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestToggleAutoRenew:
|
||||
|
||||
def test_enable_auto_renew(self, client):
|
||||
@@ -594,7 +645,7 @@ class TestToggleAutoRenew:
|
||||
|
||||
def test_invalid_type_returns_422(self, client):
|
||||
"""enabled 传非布尔值应返回 422。"""
|
||||
resp = client.post("/toggle-auto-renew", json={"enabled": [1,2,3]})
|
||||
resp = client.post("/toggle-auto-renew", json={"enabled": [1, 2, 3]})
|
||||
assert resp.status_code == 422
|
||||
|
||||
|
||||
@@ -602,6 +653,7 @@ class TestToggleAutoRenew:
|
||||
# 8. 辅助函数 / 工具测试
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestHelperFunctions:
|
||||
|
||||
def test_get_plan_name_known_plans(self):
|
||||
|
||||
@@ -176,9 +176,7 @@ class TestAssetDiagnosisRoute:
|
||||
|
||||
def test_find_by_project_called_with_correct_project_id(self):
|
||||
project = Project(id="proj-123", name="Test", owner_user_id="user-1")
|
||||
library = AssetLibrary(
|
||||
id="lib-1", name="Lib", project_id="proj-123", kind=AssetLibraryKind.VIDEO
|
||||
)
|
||||
library = AssetLibrary(id="lib-1", name="Lib", project_id="proj-123", kind=AssetLibraryKind.VIDEO)
|
||||
project_repo = _StubProjectRepository({"proj-123": project})
|
||||
library_repo = _StubAssetLibraryRepository({"lib-1": library})
|
||||
asset_repo = _StubAssetRepository()
|
||||
|
||||
@@ -83,7 +83,6 @@ def test_legacy_middleware_optional_user_returns_user_with_valid_credentials():
|
||||
def test_workspace_dependency_allows_member_access():
|
||||
repo = _WorkspaceMemberRepositoryStub(role="member")
|
||||
|
||||
|
||||
assert role == "member"
|
||||
|
||||
|
||||
@@ -105,7 +104,6 @@ class _WorkspaceMemberRepositoryStub:
|
||||
from packages.domain.entities import WorkspaceMember
|
||||
|
||||
|
||||
|
||||
async def _authenticated_user():
|
||||
from app.auth import get_current_user as get_authenticated_user
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ chunked_upload.py 路由单元测试
|
||||
- 文件大小校验
|
||||
- OSS 凭证校验
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
@@ -29,7 +30,6 @@ from fastapi.testclient import TestClient
|
||||
|
||||
from packages.domain import AssetLibrary, AssetLibraryKind, Project
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Stub 实现(不继承 Port ABC)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -7,6 +7,7 @@ config.py OSS 配置字段单元测试
|
||||
- 字段名与代码引用一致
|
||||
- pydantic_settings 加载行为
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user