Compare commits

..

1 Commits

Author SHA1 Message Date
xiaoxia ea65033b01 fix(worker): 修复瘦身后PYTHONPATH缺少packages目录导致shared模块无法导入
CI/CD Pipeline / Frontend Lint (pull_request) Successful in 1m26s
CI/CD Pipeline / Unit Tests (pull_request) Successful in 1m40s
CI/CD Pipeline / Validate Code Quality And Tests (pull_request) Successful in 1m43s
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / Integration Tests (pull_request) Successful in 1m20s
2026-07-15 08:30:58 +08:00
90 changed files with 1147 additions and 3202 deletions
File diff suppressed because one or more lines are too long
Executable → Regular
+787 -30
View File
File diff suppressed because one or more lines are too long
@@ -1,29 +0,0 @@
"""add result_count to edit_plans
Revision ID: 041_result_count
Revises: 040_playback_speed
Create Date: 2026-07-15 14:05:00.000000
"""
from __future__ import annotations
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision = "041_result_count"
down_revision = "040_playback_speed"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column(
"edit_plans",
sa.Column("result_count", sa.Integer(), nullable=False, server_default="0"),
)
def downgrade() -> None:
op.drop_column("edit_plans", "result_count")
+2 -4
View File
@@ -239,9 +239,7 @@ def get_duplication_detail(
return _to_detail_response(record) return _to_detail_response(record)
@router.delete( @router.delete("/records/{record_id}", status_code=status.HTTP_204_NO_CONTENT, response_model=None, response_class=Response)
"/records/{record_id}", status_code=status.HTTP_204_NO_CONTENT, response_model=None, response_class=Response
)
def delete_duplication_record( def delete_duplication_record(
record_id: str, record_id: str,
authenticated_user: AuthenticatedUser = Depends(get_current_user), authenticated_user: AuthenticatedUser = Depends(get_current_user),
@@ -289,7 +287,7 @@ def retry_duplication(
raise HTTPException( raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, status_code=status.HTTP_400_BAD_REQUEST,
detail=str(e), detail=str(e),
) from e )
if updated is None: if updated is None:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, status_code=status.HTTP_404_NOT_FOUND,
+8 -69
View File
@@ -64,13 +64,6 @@ class EditPlanUpdateRequest(BaseModel):
) )
class CopyPlanRequest(BaseModel):
"""复制剪辑计划请求体"""
name: Optional[str] = Field(default=None, min_length=1, max_length=200, description="新计划名称,不传则为「原名 - 副本」")
project_id: Optional[str] = Field(default=None, description="目标项目 ID,不传则复用源计划的项目")
class EditPlanResponse(BaseModel): class EditPlanResponse(BaseModel):
"""剪辑计划响应体""" """剪辑计划响应体"""
@@ -79,7 +72,6 @@ class EditPlanResponse(BaseModel):
name: str name: str
status: str status: str
total_duration: float total_duration: float
result_count: int = 0
project_id: str = "" project_id: str = ""
created_by_user_id: str = "" created_by_user_id: str = ""
config: dict[str, Any] config: dict[str, Any]
@@ -116,10 +108,6 @@ class EditPlanGenerationStatusResponse(BaseModel):
plan_id: str plan_id: str
plan_status: str plan_status: str
generation_task_id: Optional[str] = None generation_task_id: Optional[str] = None
generation_task_status: Optional[str] = None
progress: float = 0.0
video_url: str = ""
error_message: str = ""
clips: List[ClipStatusItem] clips: List[ClipStatusItem]
@@ -249,7 +237,6 @@ def _to_response(p: EditPlan) -> EditPlanResponse:
name=p.name, name=p.name,
status=p.status.value if hasattr(p.status, "value") else p.status, status=p.status.value if hasattr(p.status, "value") else p.status,
total_duration=p.total_duration, total_duration=p.total_duration,
result_count=getattr(p, "result_count", 0),
project_id=p.project_id or "", project_id=p.project_id or "",
created_by_user_id=p.created_by_user_id or "", created_by_user_id=p.created_by_user_id or "",
config=p.config, config=p.config,
@@ -288,11 +275,11 @@ def list_plans(
if status_filter: if status_filter:
try: try:
status_enum = EditPlanStatus(status_filter) status_enum = EditPlanStatus(status_filter)
except ValueError as _e: except ValueError:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, status_code=status.HTTP_400_BAD_REQUEST,
detail="无效的筛选条件,请选择正确的状态", detail="无效的筛选条件,请选择正确的状态",
) from _e )
# 项目鉴权:如果指定了 project_id,校验用户是否有权访问 # 项目鉴权:如果指定了 project_id,校验用户是否有权访问
if project_id: if project_id:
@@ -335,7 +322,7 @@ def get_plan(
raise HTTPException( raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, status_code=status.HTTP_404_NOT_FOUND,
detail=str(exc), detail=str(exc),
) from exc )
# 项目鉴权 # 项目鉴权
if plan.project_id: if plan.project_id:
check_project_access(plan.project_id, current_user.user.id, project_repository) check_project_access(plan.project_id, current_user.user.id, project_repository)
@@ -371,7 +358,7 @@ def create_plan(
raise HTTPException( raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, status_code=status.HTTP_400_BAD_REQUEST,
detail=str(exc), detail=str(exc),
) from exc )
logger.info( logger.info(
"创建剪辑计划: id=%s name=%s by user=%s", "创建剪辑计划: id=%s name=%s by user=%s",
created.id, created.id,
@@ -414,11 +401,11 @@ def update_plan(
if body.status is not None: if body.status is not None:
try: try:
target_status = EditPlanStatus(body.status) target_status = EditPlanStatus(body.status)
except ValueError as _e: except ValueError:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, status_code=status.HTTP_400_BAD_REQUEST,
detail="无效的状态值,请选择正确的状态", detail="无效的状态值,请选择正确的状态",
) from _e )
svc.transition_status(plan_id, target_status) svc.transition_status(plan_id, target_status)
except ValueError as exc: except ValueError as exc:
err_msg = str(exc) err_msg = str(exc)
@@ -426,11 +413,11 @@ def update_plan(
raise HTTPException( raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, status_code=status.HTTP_404_NOT_FOUND,
detail=err_msg, detail=err_msg,
) from exc )
raise HTTPException( raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, status_code=status.HTTP_400_BAD_REQUEST,
detail=err_msg, detail=err_msg,
) from exc )
# 返回最新状态 # 返回最新状态
result = svc.get_plan_or_raise(plan_id) result = svc.get_plan_or_raise(plan_id)
@@ -464,60 +451,12 @@ def delete_plan(
) )
@router.post("/{plan_id}/copy", response_model=EditPlanResponse, status_code=status.HTTP_201_CREATED)
def copy_plan(
plan_id: str,
body: CopyPlanRequest,
db: Session = Depends(get_db_session),
current_user: AuthenticatedUser = Depends(get_current_user),
project_repository: Any = Depends(get_project_repository),
) -> EditPlanResponse:
"""复制剪辑计划(含所有片段配置)
新计划状态为 editing,不含生成任务和结果记录。
"""
svc = EditPlanService(db)
# 源计划鉴权
existing = svc.get_plan(plan_id)
if existing is None:
raise HTTPException(status_code=404, detail=f"剪辑计划不存在: {plan_id}")
if existing.project_id:
check_project_access(existing.project_id, current_user.user.id, project_repository)
# 目标项目鉴权(如果指定了不同的项目)
target_project_id = body.project_id if body.project_id is not None else existing.project_id
if target_project_id and target_project_id != existing.project_id:
check_project_access(target_project_id, current_user.user.id, project_repository)
try:
new_plan = svc.copy_plan(
plan_id,
new_name=body.name,
project_id=target_project_id,
)
except ValueError as e:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) from e
logger.info(
"复制剪辑计划: source=%s target=%s by user=%s",
plan_id,
new_plan.id,
current_user.user.id,
)
return _to_response(new_plan)
# ── Include sub-routers (拆分模块) ──────────────────────────────────────────── # ── Include sub-routers (拆分模块) ────────────────────────────────────────────
from .edit_plans_ai import router as ai_router from .edit_plans_ai import router as ai_router
from .edit_plans_clips import router as clips_router
from .edit_plans_clips_batch import router as clips_batch_router
from .edit_plans_generation import router as generation_router from .edit_plans_generation import router as generation_router
from .edit_plans_timeline import router as timeline_router from .edit_plans_timeline import router as timeline_router
router.include_router(generation_router) router.include_router(generation_router)
router.include_router(ai_router) router.include_router(ai_router)
router.include_router(timeline_router) router.include_router(timeline_router)
router.include_router(clips_router, prefix="/{plan_id}/clips", tags=["EditPlan Clips"])
router.include_router(clips_batch_router, prefix="/{plan_id}/clips", tags=["EditPlan Clips"])
+4 -4
View File
@@ -58,7 +58,7 @@ def ai_recommend_clips(
try: try:
plan = svc.get_plan_or_raise(plan_id) plan = svc.get_plan_or_raise(plan_id)
except ValueError as exc: except ValueError as exc:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc))
if plan.project_id: if plan.project_id:
check_project_access(plan.project_id, current_user.user.id, project_repository) check_project_access(plan.project_id, current_user.user.id, project_repository)
@@ -103,7 +103,7 @@ def ai_recommend_clips(
config=normalized_config, config=normalized_config,
total_duration=result["total_duration"], total_duration=result["total_duration"],
) )
except Exception as _e: except Exception:
logger.exception("AI 推荐写入失败,plan_id=%s 数据可能不一致", plan_id) logger.exception("AI 推荐写入失败,plan_id=%s 数据可能不一致", plan_id)
try: try:
db.rollback() db.rollback()
@@ -116,7 +116,7 @@ def ai_recommend_clips(
raise HTTPException( raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="AI推荐结果保存失败,请稍后重试", detail="AI推荐结果保存失败,请稍后重试",
) from _e )
logger.info( logger.info(
"AI 推荐片段方案: plan_id=%s clips=%d duration=%.1f by user=%s", "AI 推荐片段方案: plan_id=%s clips=%d duration=%.1f by user=%s",
@@ -167,7 +167,7 @@ def generate_cover(
try: try:
plan = svc.get_plan_or_raise(plan_id) plan = svc.get_plan_or_raise(plan_id)
except ValueError as exc: except ValueError as exc:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc))
if plan.project_id: if plan.project_id:
check_project_access(plan.project_id, current_user.user.id, project_repository) check_project_access(plan.project_id, current_user.user.id, project_repository)
-275
View File
@@ -1,275 +0,0 @@
"""剪辑计划片段(Clip)CRUD 路由。"""
from __future__ import annotations
import logging
from typing import Any, List, Optional
from app.auth import AuthenticatedUser, get_current_user
from app.dependencies import get_db_session, get_project_repository
from fastapi import APIRouter, Depends, HTTPException, Query, Response, status
from sqlalchemy.orm import Session
from packages.domain.edit_plan_clip import EditPlanClipStatus
logger = logging.getLogger(__name__)
router = APIRouter()
# ── Schemas ──────────────────────────────────────────────────────────────────
from pydantic import BaseModel, Field
class EditPlanClipResponse(BaseModel):
"""剪辑片段响应体"""
id: str
plan_id: str
clip_type: str
order: int
asset_id: str = ""
text_content: str = ""
start_time: float = 0.0
duration: float = 0.0
transition_effect: str = "cut"
transition_duration: float = 0.0
playback_speed: float = 1.0
status: str
config: dict[str, Any] = Field(default_factory=dict)
created_at: Optional[str] = None
updated_at: Optional[str] = None
class EditPlanClipListResponse(BaseModel):
"""剪辑片段列表响应体"""
items: List[EditPlanClipResponse]
total: int
class EditPlanClipCreateRequest(BaseModel):
"""创建剪辑片段请求体"""
clip_type: str = Field(..., min_length=1, max_length=50, description="片段类型: main/intro/outro/overlay/background/b_roll 等")
order: int = Field(..., ge=0, description="排序序号")
asset_id: str = Field(default="", max_length=64, description="关联素材 ID")
text_content: str = Field(default="", max_length=5000, description="文本内容(字幕/配音等)")
start_time: float = Field(default=0.0, ge=0.0, description="起始时间 (秒)")
duration: float = Field(default=0.0, ge=0.0, description="时长 (秒)")
transition_effect: str = Field(default="cut", max_length=50, description="转场效果")
transition_duration: float = Field(default=0.0, ge=0.0, description="转场时长 (秒)")
playback_speed: float = Field(default=1.0, gt=0.0, le=10.0, description="播放速度倍率")
config: dict[str, Any] = Field(default_factory=dict, description="扩展配置 (JSON)")
class EditPlanClipUpdateRequest(BaseModel):
"""更新剪辑片段请求体"""
clip_type: Optional[str] = Field(default=None, min_length=1, max_length=50, description="片段类型")
order: Optional[int] = Field(default=None, ge=0, description="排序序号")
asset_id: Optional[str] = Field(default=None, max_length=64, description="关联素材 ID")
text_content: Optional[str] = Field(default=None, max_length=5000, description="文本内容")
start_time: Optional[float] = Field(default=None, ge=0.0, description="起始时间 (秒)")
duration: Optional[float] = Field(default=None, ge=0.0, description="时长 (秒)")
transition_effect: Optional[str] = Field(default=None, max_length=50, description="转场效果")
transition_duration: Optional[float] = Field(default=None, ge=0.0, description="转场时长 (秒)")
playback_speed: Optional[float] = Field(default=None, gt=0.0, le=10.0, description="播放速度倍率")
config: Optional[dict[str, Any]] = Field(default=None, description="扩展配置 (JSON)")
# ── Helpers ──────────────────────────────────────────────────────────────────
def _check_plan_access(plan_id: str, user_id: str, project_repository: Any, db: Session) -> Any:
"""验证用户是否有权限访问该剪辑计划(通过项目关联)。
返回 plan 对象供后续使用,避免重复查询。
"""
from ._helpers import check_project_access
from app.services.edit_plan_service import EditPlanService
svc = EditPlanService(db)
plan = svc.get_plan(plan_id)
if plan is None:
raise HTTPException(status_code=404, detail=f"剪辑计划不存在: {plan_id}")
if plan.project_id:
check_project_access(plan.project_id, user_id, project_repository)
return plan
def _clip_to_response(clip) -> EditPlanClipResponse:
"""将领域对象转换为响应体"""
return EditPlanClipResponse(
id=clip.id,
plan_id=clip.plan_id,
clip_type=clip.clip_type,
order=clip.order,
asset_id=clip.asset_id or "",
text_content=clip.text_content or "",
start_time=clip.start_time,
duration=clip.duration,
transition_effect=clip.transition_effect or "cut",
transition_duration=clip.transition_duration or 0.0,
playback_speed=clip.playback_speed or 1.0,
status=clip.status.value if hasattr(clip.status, "value") else str(clip.status),
config=clip.config or {},
created_at=clip.created_at.isoformat() if clip.created_at else None,
updated_at=clip.updated_at.isoformat() if clip.updated_at else None,
)
def _get_svc(db: Session):
"""获取 EditPlanService 实例"""
from app.services.edit_plan_service import EditPlanService
return EditPlanService(db)
# ── Routes ───────────────────────────────────────────────────────────────────
@router.get("", response_model=EditPlanClipListResponse)
def list_clips(
plan_id: str,
status_filter: Optional[str] = Query(None, alias="status", description="按状态过滤"),
skip: int = Query(0, ge=0, description="分页偏移"),
limit: int = Query(100, ge=1, le=500, description="每页数量"),
db: Session = Depends(get_db_session),
current_user: AuthenticatedUser = Depends(get_current_user),
project_repository: Any = Depends(get_project_repository),
) -> EditPlanClipListResponse:
"""获取剪辑计划的片段列表"""
_check_plan_access(plan_id, current_user.user.id, project_repository, db)
svc = _get_svc(db)
status_enum = EditPlanClipStatus(status_filter) if status_filter else None
clips = svc.list_clips(plan_id, status=status_enum, skip=skip, limit=limit)
total = svc.count_clips(plan_id, status=status_enum)
return EditPlanClipListResponse(
items=[_clip_to_response(c) for c in clips],
total=total,
)
@router.post("", response_model=EditPlanClipResponse, status_code=status.HTTP_201_CREATED)
def create_clip(
plan_id: str,
body: EditPlanClipCreateRequest,
db: Session = Depends(get_db_session),
current_user: AuthenticatedUser = Depends(get_current_user),
project_repository: Any = Depends(get_project_repository),
) -> EditPlanClipResponse:
"""创建剪辑片段"""
_check_plan_access(plan_id, current_user.user.id, project_repository, db)
svc = _get_svc(db)
try:
clip = svc.create_clip(
plan_id=plan_id,
clip_type=body.clip_type,
order=body.order,
asset_id=body.asset_id,
text_content=body.text_content,
start_time=body.start_time,
duration=body.duration,
transition_effect=body.transition_effect,
transition_duration=body.transition_duration,
playback_speed=body.playback_speed,
config=body.config,
)
except ValueError as e:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) from e
logger.info("创建剪辑片段: plan_id=%s clip_id=%s by user=%s", plan_id, clip.id, current_user.user.id)
return _clip_to_response(clip)
@router.get("/{clip_id}", response_model=EditPlanClipResponse)
def get_clip(
plan_id: str,
clip_id: str,
db: Session = Depends(get_db_session),
current_user: AuthenticatedUser = Depends(get_current_user),
project_repository: Any = Depends(get_project_repository),
) -> EditPlanClipResponse:
"""获取剪辑片段详情"""
_check_plan_access(plan_id, current_user.user.id, project_repository, db)
svc = _get_svc(db)
clip = svc.get_clip(clip_id)
if clip is None:
raise HTTPException(status_code=404, detail=f"片段不存在: {clip_id}")
if clip.plan_id != plan_id:
raise HTTPException(status_code=404, detail=f"片段不存在: {clip_id}")
return _clip_to_response(clip)
@router.put("/{clip_id}", response_model=EditPlanClipResponse)
def update_clip(
plan_id: str,
clip_id: str,
body: EditPlanClipUpdateRequest,
db: Session = Depends(get_db_session),
current_user: AuthenticatedUser = Depends(get_current_user),
project_repository: Any = Depends(get_project_repository),
) -> EditPlanClipResponse:
"""更新剪辑片段"""
_check_plan_access(plan_id, current_user.user.id, project_repository, db)
svc = _get_svc(db)
# 验证 clip 属于该 plan
clip = svc.get_clip(clip_id)
if clip is None:
raise HTTPException(status_code=404, detail=f"片段不存在: {clip_id}")
if clip.plan_id != plan_id:
raise HTTPException(status_code=404, detail=f"片段不存在: {clip_id}")
try:
updated = svc.update_clip(
clip_id,
clip_type=body.clip_type,
order=body.order,
asset_id=body.asset_id,
text_content=body.text_content,
start_time=body.start_time,
duration=body.duration,
transition_effect=body.transition_effect,
transition_duration=body.transition_duration,
playback_speed=body.playback_speed,
config=body.config,
)
except ValueError as e:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) from e
logger.info("更新剪辑片段: plan_id=%s clip_id=%s by user=%s", plan_id, clip_id, current_user.user.id)
return _clip_to_response(updated)
@router.delete("/{clip_id}", status_code=status.HTTP_204_NO_CONTENT, response_model=None, response_class=Response)
def delete_clip(
plan_id: str,
clip_id: str,
db: Session = Depends(get_db_session),
current_user: AuthenticatedUser = Depends(get_current_user),
project_repository: Any = Depends(get_project_repository),
) -> None:
"""删除剪辑片段"""
_check_plan_access(plan_id, current_user.user.id, project_repository, db)
svc = _get_svc(db)
# 验证 clip 属于该 plan
clip = svc.get_clip(clip_id)
if clip is None:
raise HTTPException(status_code=404, detail=f"片段不存在: {clip_id}")
if clip.plan_id != plan_id:
raise HTTPException(status_code=404, detail=f"片段不存在: {clip_id}")
deleted = svc.delete_clip(clip_id)
if not deleted:
raise HTTPException(status_code=404, detail=f"片段不存在: {clip_id}")
logger.info("删除剪辑片段: plan_id=%s clip_id=%s by user=%s", plan_id, clip_id, current_user.user.id)
return None
@@ -1,240 +0,0 @@
"""剪辑计划片段批量操作 API。"""
from __future__ import annotations
import logging
from typing import Any, List, Optional
from app.auth import AuthenticatedUser, get_current_user
from app.dependencies import get_db_session, get_project_repository
from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import BaseModel, Field
from sqlalchemy.orm import Session
logger = logging.getLogger(__name__)
router = APIRouter()
# ── Schemas ──────────────────────────────────────────────────────────────────
class ClipReorderItem(BaseModel):
"""重排序条目"""
clip_id: str
new_order: int = Field(..., ge=0, description="新的排序序号")
class ClipReorderRequest(BaseModel):
"""片段重排序请求"""
items: List[ClipReorderItem] = Field(..., min_length=1, max_length=500, description="重排序条目列表")
class ClipReorderResponse(BaseModel):
"""片段重排序响应"""
success: bool
updated_count: int
message: str = ""
class ClipBatchDeleteRequest(BaseModel):
"""批量删除片段请求"""
clip_ids: List[str] = Field(..., min_length=1, max_length=500, description="要删除的片段ID列表")
class ClipBatchDeleteResponse(BaseModel):
"""批量删除片段响应"""
success: bool
deleted_count: int
message: str = ""
class ClipsFromAssetsRequest(BaseModel):
"""从素材批量创建片段请求"""
asset_ids: List[str] = Field(..., min_length=1, max_length=200, description="素材 ID 列表,按顺序追加到时间线末尾")
clip_type: str = Field(default="main", description="片段类型,默认 main")
class ClipsFromAssetsResponse(BaseModel):
"""从素材批量创建片段响应"""
success: bool
created_count: int
message: str = ""
clip_ids: List[str] = Field(default_factory=list, description="创建的片段ID列表")
# ── Helpers ──────────────────────────────────────────────────────────────────
def _check_plan_access(plan_id: str, user_id: str, project_repository: Any, db: Session) -> Any:
"""验证用户是否有权限访问该剪辑计划,返回 plan 对象。"""
from ._helpers import check_project_access
from app.services.edit_plan_service import EditPlanService
svc = EditPlanService(db)
plan = svc.get_plan(plan_id)
if plan is None:
raise HTTPException(status_code=404, detail=f"剪辑计划不存在: {plan_id}")
if plan.project_id:
check_project_access(plan.project_id, user_id, project_repository)
return plan
def _get_svc(db: Session):
"""获取 EditPlanService 实例"""
from app.services.edit_plan_service import EditPlanService
return EditPlanService(db)
# ── Routes ───────────────────────────────────────────────────────────────────
@router.post("/reorder", response_model=ClipReorderResponse)
def reorder_clips(
plan_id: str,
body: ClipReorderRequest,
db: Session = Depends(get_db_session),
current_user: AuthenticatedUser = Depends(get_current_user),
project_repository: Any = Depends(get_project_repository),
) -> ClipReorderResponse:
"""批量重排序片段
前端拖拽调整顺序后,一次性提交所有变更的 order。
自动触发编辑状态回退(从 completed/failed 切回 editing)。
"""
_check_plan_access(plan_id, current_user.user.id, project_repository, db)
svc = _get_svc(db)
# 验证所有 clip 都属于该 plan
clip_ids = [item.clip_id for item in body.items]
existing_clips = svc.list_clips(plan_id, skip=0, limit=10000)
existing_ids = {c.id for c in existing_clips}
invalid_ids = [cid for cid in clip_ids if cid not in existing_ids]
if invalid_ids:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"以下片段不属于该计划: {', '.join(invalid_ids[:5])}",
)
# 执行重排序
updated_count = 0
for item in body.items:
try:
svc.update_clip(item.clip_id, order=item.new_order)
updated_count += 1
except ValueError as e:
logger.warning("重排序片段失败: clip_id=%s error=%s", item.clip_id, e)
logger.info(
"批量重排序片段: plan_id=%s count=%d by user=%s",
plan_id,
updated_count,
current_user.user.id,
)
return ClipReorderResponse(
success=True,
updated_count=updated_count,
message=f"成功更新 {updated_count} 个片段的顺序",
)
@router.post("/batch-delete", response_model=ClipBatchDeleteResponse)
def batch_delete_clips(
plan_id: str,
body: ClipBatchDeleteRequest,
db: Session = Depends(get_db_session),
current_user: AuthenticatedUser = Depends(get_current_user),
project_repository: Any = Depends(get_project_repository),
) -> ClipBatchDeleteResponse:
"""批量删除片段
自动触发编辑状态回退(从 completed/failed 切回 editing)。
"""
_check_plan_access(plan_id, current_user.user.id, project_repository, db)
svc = _get_svc(db)
# 验证所有 clip 都属于该 plan
existing_clips = svc.list_clips(plan_id, skip=0, limit=10000)
existing_ids = {c.id for c in existing_clips}
valid_ids = [cid for cid in body.clip_ids if cid in existing_ids]
skipped = len(body.clip_ids) - len(valid_ids)
# 执行删除
deleted_count = 0
for clip_id in valid_ids:
if svc.delete_clip(clip_id):
deleted_count += 1
message = f"成功删除 {deleted_count} 个片段"
if skipped > 0:
message += f",跳过 {skipped} 个不存在的片段"
logger.info(
"批量删除片段: plan_id=%s deleted=%d skipped=%d by user=%s",
plan_id,
deleted_count,
skipped,
current_user.user.id,
)
return ClipBatchDeleteResponse(
success=True,
deleted_count=deleted_count,
message=message,
)
@router.post("/from-assets", response_model=ClipsFromAssetsResponse)
def create_clips_from_assets(
plan_id: str,
body: ClipsFromAssetsRequest,
db: Session = Depends(get_db_session),
current_user: AuthenticatedUser = Depends(get_current_user),
project_repository: Any = Depends(get_project_repository),
) -> ClipsFromAssetsResponse:
"""从素材批量创建片段(追加到时间线末尾)
一次性将多个素材作为片段添加到剪辑计划,自动读取素材时长。
自动触发编辑状态回退(completed/failed → editing)。
"""
_check_plan_access(plan_id, current_user.user.id, project_repository, db)
svc = _get_svc(db)
try:
clips = svc.create_clips_from_assets(
plan_id=plan_id,
asset_ids=body.asset_ids,
clip_type=body.clip_type,
)
except ValueError as e:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) from e
clip_ids = [c.id for c in clips]
logger.info(
"从素材批量创建片段: plan_id=%s count=%d by user=%s",
plan_id,
len(clips),
current_user.user.id,
)
return ClipsFromAssetsResponse(
success=True,
created_count=len(clips),
message=f"成功创建 {len(clips)} 个片段",
clip_ids=clip_ids,
)
+9 -25
View File
@@ -183,7 +183,9 @@ def _auto_fallback_auto_material_mode(
def _check_queue_limits(gen_task_repo, user_id: str) -> None: def _check_queue_limits(gen_task_repo, user_id: str) -> None:
"""队列限流预检查""" """队列限流预检查"""
try: try:
has_count = hasattr(gen_task_repo, "count_pending_by_user") and hasattr(gen_task_repo, "count_pending_total") has_count = hasattr(gen_task_repo, "count_pending_by_user") and hasattr(
gen_task_repo, "count_pending_total"
)
if has_count: if has_count:
user_pending = gen_task_repo.count_pending_by_user(user_id) user_pending = gen_task_repo.count_pending_by_user(user_id)
global_pending = gen_task_repo.count_pending_total() global_pending = gen_task_repo.count_pending_total()
@@ -239,7 +241,7 @@ def generate_plan(
try: try:
can_gen, reason = svc.can_generate(plan_id) can_gen, reason = svc.can_generate(plan_id)
except ValueError as exc: except ValueError as exc:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc))
if not can_gen: if not can_gen:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=reason) raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=reason)
@@ -253,15 +255,12 @@ def generate_plan(
gen_task_use_case = CreateGenerationTaskUseCase(gen_task_repo) gen_task_use_case = CreateGenerationTaskUseCase(gen_task_repo)
plan = svc.get_plan_or_raise(plan_id) plan = svc.get_plan_or_raise(plan_id)
# 从 plan.config 中读取 asset_ids 并传递给 GenerationTask
config_asset_ids = (plan.config or {}).get("asset_ids", [])
gen_task = gen_task_use_case.execute( gen_task = gen_task_use_case.execute(
CreateGenerationTaskCommand( CreateGenerationTaskCommand(
project_id=plan.project_id or "", project_id="",
template_id=plan.template_id, template_id=plan.template_id,
created_by_user_id=current_user.user.id, created_by_user_id=current_user.user.id,
source_edit_plan_id=plan_id, source_edit_plan_id=plan_id,
asset_ids=list(config_asset_ids) if config_asset_ids else [],
) )
) )
@@ -287,7 +286,7 @@ def generate_plan(
) )
except HTTPException: except HTTPException:
raise raise
except Exception as _e: except Exception:
logger.exception("触发剪辑计划生成失败: plan_id=%s", plan_id) logger.exception("触发剪辑计划生成失败: plan_id=%s", plan_id)
try: try:
svc.transition_status(plan_id, EditPlanStatus.FAILED) svc.transition_status(plan_id, EditPlanStatus.FAILED)
@@ -296,7 +295,7 @@ def generate_plan(
raise HTTPException( raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="生成失败,请稍后重试", detail="生成失败,请稍后重试",
) from _e )
@router.get( @router.get(
@@ -314,7 +313,7 @@ def get_generation_status(
try: try:
gen_status = svc.get_generation_status(plan_id) gen_status = svc.get_generation_status(plan_id)
except ValueError as exc: except ValueError as exc:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc))
plan = gen_status["plan"] plan = gen_status["plan"]
if plan.project_id: if plan.project_id:
@@ -334,25 +333,10 @@ def get_generation_status(
for c in clips for c in clips
] ]
# 从 plan.config 中取渲染结果 URL
video_url = (plan.config or {}).get("rendered_url", "")
# 从 gen_status 中取进度、错误信息、任务状态
progress = gen_status.get("progress", 0.0)
error_message = gen_status.get("error_message", "")
gen_task_status = gen_status.get("generation_task_status")
# 如果计划已完成但进度还是0,补100
plan_status_val = plan.status.value if hasattr(plan.status, "value") else plan.status
if plan_status_val == "completed" and progress < 100:
progress = 100.0
return EditPlanGenerationStatusResponse( return EditPlanGenerationStatusResponse(
plan_id=plan_id, plan_id=plan_id,
plan_status=plan_status_val, plan_status=plan.status.value if hasattr(plan.status, "value") else plan.status,
generation_task_id=gen_status["generation_task_id"], generation_task_id=gen_status["generation_task_id"],
generation_task_status=gen_task_status,
progress=progress,
video_url=video_url,
error_message=error_message,
clips=clip_items, clips=clip_items,
) )
@@ -173,7 +173,7 @@ def generate_from_template(
try: try:
template = template_svc.get_template_or_raise(body.template_id) template = template_svc.get_template_or_raise(body.template_id)
except ValueError as exc: except ValueError as exc:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc))
clip_configs = template_svc.list_clip_configs(body.template_id, skip=0, limit=200) clip_configs = template_svc.list_clip_configs(body.template_id, skip=0, limit=200)
+6 -6
View File
@@ -105,7 +105,7 @@ async def list_feature_flags(
return sorted(result, key=lambda x: x.name) return sorted(result, key=lambda x: x.name)
except Exception as exc: except Exception as exc:
logger.error("Failed to list feature flags: %s", exc) logger.error("Failed to list feature flags: %s", exc)
raise HTTPException(status_code=500, detail=f"Failed to list flags: {exc}") from exc raise HTTPException(status_code=500, detail=f"Failed to list flags: {exc}")
@router.get("/{name}", response_model=FeatureFlagResponse) @router.get("/{name}", response_model=FeatureFlagResponse)
@@ -120,7 +120,7 @@ async def get_feature_flag(
return FeatureFlagResponse.from_config(config) return FeatureFlagResponse.from_config(config)
except Exception as exc: except Exception as exc:
logger.error("Failed to get feature flag %s: %s", name, exc) logger.error("Failed to get feature flag %s: %s", name, exc)
raise HTTPException(status_code=500, detail=f"Failed to get flag: {exc}") from exc raise HTTPException(status_code=500, detail=f"Failed to get flag: {exc}")
@router.get("/{name}/check", response_model=FeatureFlagCheckResponse) @router.get("/{name}/check", response_model=FeatureFlagCheckResponse)
@@ -136,7 +136,7 @@ async def check_feature_flag(
return FeatureFlagCheckResponse(name=name, active=active, identifier=identifier) return FeatureFlagCheckResponse(name=name, active=active, identifier=identifier)
except Exception as exc: except Exception as exc:
logger.error("Failed to check feature flag %s: %s", name, exc) logger.error("Failed to check feature flag %s: %s", name, exc)
raise HTTPException(status_code=500, detail=f"Failed to check flag: {exc}") from exc raise HTTPException(status_code=500, detail=f"Failed to check flag: {exc}")
@router.put("/{name}", response_model=FeatureFlagResponse) @router.put("/{name}", response_model=FeatureFlagResponse)
@@ -170,7 +170,7 @@ async def update_feature_flag(
return FeatureFlagResponse.from_config(config) return FeatureFlagResponse.from_config(config)
except Exception as exc: except Exception as exc:
logger.error("Failed to update feature flag %s: %s", name, exc) logger.error("Failed to update feature flag %s: %s", name, exc)
raise HTTPException(status_code=500, detail=f"Failed to update flag: {exc}") from exc raise HTTPException(status_code=500, detail=f"Failed to update flag: {exc}")
@router.delete("/{name}", status_code=status.HTTP_204_NO_CONTENT, response_model=None, response_class=Response) @router.delete("/{name}", status_code=status.HTTP_204_NO_CONTENT, response_model=None, response_class=Response)
@@ -178,7 +178,7 @@ async def delete_feature_flag(
name: str, name: str,
_: bool = Depends(_verify_internal_api_key), _: bool = Depends(_verify_internal_api_key),
store: RedisFeatureFlagStore = Depends(_get_feature_flag_store), store: RedisFeatureFlagStore = Depends(_get_feature_flag_store),
): ) :
"""删除 Feature Flag。 """删除 Feature Flag。
只允许删除 ALLOWED_FLAGS 列表中的 flag。 只允许删除 ALLOWED_FLAGS 列表中的 flag。
@@ -191,4 +191,4 @@ async def delete_feature_flag(
pass pass
except Exception as exc: except Exception as exc:
logger.error("Failed to delete feature flag %s: %s", name, exc) logger.error("Failed to delete feature flag %s: %s", name, exc)
raise HTTPException(status_code=500, detail=f"Failed to delete flag: {exc}") from exc raise HTTPException(status_code=500, detail=f"Failed to delete flag: {exc}")
+5 -6
View File
@@ -43,7 +43,6 @@ logger = logging.getLogger(__name__)
router = APIRouter() router = APIRouter()
def _to_generation_task_response(task) -> GenerationTaskResponse: def _to_generation_task_response(task) -> GenerationTaskResponse:
return GenerationTaskResponse( return GenerationTaskResponse(
id=task.id, id=task.id,
@@ -283,28 +282,28 @@ def create_generation_task(
created_tasks.append(task) created_tasks.append(task)
else: else:
failed_tasks.append(task) failed_tasks.append(task)
except UserPendingLimitExceeded as _e: except UserPendingLimitExceeded:
# 兜底:如果预检查后又并发提交了,在这里也拦住 # 兜底:如果预检查后又并发提交了,在这里也拦住
failed_tasks.append(task) failed_tasks.append(task)
if not created_tasks: if not created_tasks:
raise HTTPException( raise HTTPException(
status_code=429, status_code=429,
detail="您的待处理任务过多,请等待完成后再提交", detail="您的待处理任务过多,请等待完成后再提交",
) from _e )
break break
except GlobalQueueFull as _e: except GlobalQueueFull:
failed_tasks.append(task) failed_tasks.append(task)
if not created_tasks: if not created_tasks:
raise HTTPException( raise HTTPException(
status_code=503, status_code=503,
detail="系统繁忙,请稍后再试", detail="系统繁忙,请稍后再试",
) from _e )
break break
except HTTPException: except HTTPException:
raise raise
except Exception as e: except Exception as e:
logger.error("[生成任务] 创建失败: %s", e, exc_info=True) logger.error("[生成任务] 创建失败: %s", e, exc_info=True)
raise HTTPException(status_code=500, detail="创建生成任务失败,请稍后重试或查看任务日志") from e raise HTTPException(status_code=500, detail="创建生成任务失败,请稍后重试或查看任务日志")
items = [_to_generation_task_response(t) for t in created_tasks + failed_tasks] items = [_to_generation_task_response(t) for t in created_tasks + failed_tasks]
return BatchGenerationTaskResponse(items=items, total=len(items)) return BatchGenerationTaskResponse(items=items, total=len(items))
+3 -3
View File
@@ -81,11 +81,11 @@ def delete_project(
use_case = DeleteProjectUseCase(project_repository) use_case = DeleteProjectUseCase(project_repository)
try: try:
deleted = use_case.execute(project_id, authenticated_user.user.id) deleted = use_case.execute(project_id, authenticated_user.user.id)
except PermissionError as _e: except PermissionError:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, status_code=status.HTTP_403_FORBIDDEN,
detail="Only the project owner can delete this project", detail="Only the project owner can delete this project",
) from _e )
if not deleted: if not deleted:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Project not found") raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Project not found")
return # type: ignore[return-value] return
+1 -1
View File
@@ -254,7 +254,7 @@ async def payment_callback(
return {"success": True, "message": "支付成功", "record_id": record_id} return {"success": True, "message": "支付成功", "record_id": record_id}
except Exception as e: except Exception as e:
session.rollback() session.rollback()
raise HTTPException(status_code=500, detail=f"支付处理失败: {str(e)}") from e raise HTTPException(status_code=500, detail=f"支付处理失败: {str(e)}")
finally: finally:
session.close() session.close()
+3 -3
View File
@@ -368,9 +368,9 @@ def retry_project_task(
raise HTTPException(status_code=404, detail="Ingest job not found") raise HTTPException(status_code=404, detail="Ingest job not found")
if _status_value(job.status) != "failed": if _status_value(job.status) != "failed":
raise HTTPException(status_code=409, detail="Only failed tasks can be retried") raise HTTPException(status_code=409, detail="Only failed tasks can be retried")
use_case = SubmitIngestJobUseCase(ingest_job_repository) # type: ignore[assignment] use_case = SubmitIngestJobUseCase(ingest_job_repository)
retried = use_case.execute( retried = use_case.execute(
SubmitIngestJobCommand( # type: ignore[arg-type] SubmitIngestJobCommand(
project_id=job.project_id, project_id=job.project_id,
library_id=job.library_id, library_id=job.library_id,
storage_key=job.storage_key, storage_key=job.storage_key,
@@ -386,6 +386,6 @@ def retry_project_task(
current_step=_ingest_step(retried), current_step=_ingest_step(retried),
source_id=retried.id, source_id=retried.id,
created_at=retried.created_at, created_at=retried.created_at,
updated_at=retried.updated_at, # type: ignore[attr-defined] updated_at=retried.updated_at,
) )
raise HTTPException(status_code=400, detail="Unsupported task type") raise HTTPException(status_code=400, detail="Unsupported task type")
+15 -17
View File
@@ -147,9 +147,9 @@ def get_template(
use_case = GetTemplateUseCase(template_repository) use_case = GetTemplateUseCase(template_repository)
template = use_case.execute(template_id, user_id) template = use_case.execute(template_id, user_id)
usage = template_repository.get_usage_count(template_id) usage = template_repository.get_usage_count(template_id)
except Exception as _e: except Exception:
logger.exception("get_template 查询失败: template_id=%s", template_id) logger.exception("get_template 查询失败: template_id=%s", template_id)
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="模板查询失败") from _e raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="模板查询失败")
if template is None: if template is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Template not found") raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Template not found")
return _to_response(template, usage_count=usage) return _to_response(template, usage_count=usage)
@@ -186,7 +186,7 @@ def create_template(
try: try:
template = use_case.execute(command) template = use_case.execute(command)
except ValidationError as exc: except ValidationError as exc:
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc)) from exc raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc))
return _to_response(template) return _to_response(template)
@@ -226,10 +226,10 @@ def update_template(
use_case = UpdateTemplateUseCase(template_repository) use_case = UpdateTemplateUseCase(template_repository)
try: try:
template = use_case.execute(command) template = use_case.execute(command)
except NotFoundError as _e: except NotFoundError:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Template not found") from _e raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Template not found")
except ValidationError as exc: except ValidationError as exc:
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc)) from exc raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc))
return _to_response(template) return _to_response(template)
@@ -264,10 +264,10 @@ def copy_template(
use_case = CopyTemplateUseCase(template_repository) use_case = CopyTemplateUseCase(template_repository)
try: try:
template = use_case.execute(command) template = use_case.execute(command)
except NotFoundError as _e: except NotFoundError:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Template not found") from _e raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Template not found")
except ValidationError as exc: except ValidationError as exc:
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc)) from exc raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc))
return _to_response(template) return _to_response(template)
@@ -299,9 +299,9 @@ def toggle_favorite(
use_case = GetTemplateUseCase(template_repository) use_case = GetTemplateUseCase(template_repository)
try: try:
template = use_case.execute(template_id, user_id) template = use_case.execute(template_id, user_id)
except Exception as _e: except Exception:
logger.exception("toggle_favorite 查询失败: template_id=%s", template_id) logger.exception("toggle_favorite 查询失败: template_id=%s", template_id)
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Template not found") from _e raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Template not found")
if template is None: if template is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Template not found") raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Template not found")
return ToggleFavoriteResponse(id=template_id, is_favorite=False) return ToggleFavoriteResponse(id=template_id, is_favorite=False)
@@ -326,10 +326,10 @@ def validate_template(
use_case = ValidateTemplateUseCase(template_repository) use_case = ValidateTemplateUseCase(template_repository)
try: try:
result = use_case.execute(command) result = use_case.execute(command)
except NotFoundError as _e: except NotFoundError:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Template not found") from _e raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Template not found")
except ValidationError as exc: except ValidationError as exc:
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc)) from exc raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc))
return ValidateTemplateResponse( return ValidateTemplateResponse(
template=_to_response(result.template), template=_to_response(result.template),
@@ -375,9 +375,7 @@ def create_category(
) )
@router.delete( @router.delete("/categories/{category_id}", status_code=status.HTTP_204_NO_CONTENT, response_model=None, response_class=Response)
"/categories/{category_id}", status_code=status.HTTP_204_NO_CONTENT, response_model=None, response_class=Response
)
def delete_category( def delete_category(
category_id: str, category_id: str,
authenticated_user: AuthenticatedUser = Depends(get_current_user), authenticated_user: AuthenticatedUser = Depends(get_current_user),
+3 -3
View File
@@ -148,7 +148,7 @@ def create_title(
raise HTTPException( raise HTTPException(
status_code=status.HTTP_429_TOO_MANY_REQUESTS, status_code=status.HTTP_429_TOO_MANY_REQUESTS,
detail=f"标题库配额已满({exc.used}/{exc.limit}),请升级套餐", detail=f"标题库配额已满({exc.used}/{exc.limit}),请升级套餐",
) from exc )
return _to_response(item) return _to_response(item)
@@ -172,8 +172,8 @@ def update_title(
use_case = UpdateTitleLibraryUseCase(title_repository) use_case = UpdateTitleLibraryUseCase(title_repository)
try: try:
item = use_case.execute(command) item = use_case.execute(command)
except NotFoundError as _e: except NotFoundError:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Title not found") from _e raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Title not found")
return _to_response(item) return _to_response(item)
+7 -7
View File
@@ -236,8 +236,8 @@ def get_tts_job(
use_case = GetTTSJobUseCase(repository) use_case = GetTTSJobUseCase(repository)
try: try:
job = use_case.execute(job_id, user_id) job = use_case.execute(job_id, user_id)
except TTSJobNotFoundError as _e: except TTSJobNotFoundError:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="TTS job not found") from _e raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="TTS job not found")
return _to_response(job, sign_url) return _to_response(job, sign_url)
@@ -253,8 +253,8 @@ def get_tts_job_status(
use_case = GetTTSJobStatusUseCase(repository) use_case = GetTTSJobStatusUseCase(repository)
try: try:
job = use_case.execute(job_id, user_id) job = use_case.execute(job_id, user_id)
except TTSJobNotFoundError as _e: except TTSJobNotFoundError:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="TTS job not found") from _e raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="TTS job not found")
output_url = job.output_audio_url output_url = job.output_audio_url
if output_url: if output_url:
output_url = sign_url(output_url) output_url = sign_url(output_url)
@@ -309,8 +309,8 @@ def save_tts_job_to_library(
get_use_case = GetTTSJobUseCase(tts_repository) get_use_case = GetTTSJobUseCase(tts_repository)
try: try:
job = get_use_case.execute(job_id, user_id) job = get_use_case.execute(job_id, user_id)
except TTSJobNotFoundError as _e: except TTSJobNotFoundError:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="TTS job not found") from _e raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="TTS job not found")
# 校验已完成 # 校验已完成
if not job.is_completed: if not job.is_completed:
@@ -363,7 +363,7 @@ def save_tts_job_to_library(
raise HTTPException( raise HTTPException(
status_code=status.HTTP_429_TOO_MANY_REQUESTS, status_code=status.HTTP_429_TOO_MANY_REQUESTS,
detail=f"配音库配额已满({exc.used}/{exc.limit}),请升级套餐", detail=f"配音库配额已满({exc.used}/{exc.limit}),请升级套餐",
) from exc )
return SaveToLibraryResponse( return SaveToLibraryResponse(
id=item.id, id=item.id,
+8 -8
View File
@@ -141,8 +141,8 @@ def get_voice_clone(
use_case = GetVoiceCloneUseCase(repository) use_case = GetVoiceCloneUseCase(repository)
try: try:
profile = use_case.execute(clone_id, user_id) profile = use_case.execute(clone_id, user_id)
except VoiceCloneNotFoundError as _e: except VoiceCloneNotFoundError:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Voice clone not found") from _e raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Voice clone not found")
return _to_response(profile) return _to_response(profile)
@@ -157,8 +157,8 @@ def get_voice_clone_status(
use_case = GetVoiceCloneStatusUseCase(repository) use_case = GetVoiceCloneStatusUseCase(repository)
try: try:
profile = use_case.execute(clone_id, user_id) profile = use_case.execute(clone_id, user_id)
except VoiceCloneNotFoundError as _e: except VoiceCloneNotFoundError:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Voice clone not found") from _e raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Voice clone not found")
return VoiceCloneStatusResponse( return VoiceCloneStatusResponse(
id=profile.id, id=profile.id,
status=profile.status, status=profile.status,
@@ -201,13 +201,13 @@ def retry_voice_clone(
user_id = authenticated_user.user.id user_id = authenticated_user.user.id
try: try:
profile = workflow.retry_clone(clone_id, user_id) profile = workflow.retry_clone(clone_id, user_id)
except VoiceCloneNotFoundError as _e: except VoiceCloneNotFoundError:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Voice clone not found") from _e raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Voice clone not found")
except VoiceCloneNotRetryableError as _e: except VoiceCloneNotRetryableError:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, status_code=status.HTTP_400_BAD_REQUEST,
detail="Voice clone is not retryable (only failed clones can be retried)", detail="Voice clone is not retryable (only failed clones can be retried)",
) from _e )
# 如果 profile 处于 processing 且有 task_id,触发 Celery 异步轮询 # 如果 profile 处于 processing 且有 task_id,触发 Celery 异步轮询
task_id = (profile.metadata or {}).get("cosyvoice_task_id", "") task_id = (profile.metadata or {}).get("cosyvoice_task_id", "")
+3 -3
View File
@@ -287,7 +287,7 @@ def create_voice(
raise HTTPException( raise HTTPException(
status_code=status.HTTP_429_TOO_MANY_REQUESTS, status_code=status.HTTP_429_TOO_MANY_REQUESTS,
detail=f"配音库配额已满({exc.used}/{exc.limit}),请升级套餐", detail=f"配音库配额已满({exc.used}/{exc.limit}),请升级套餐",
) from exc )
return _to_response(item, sign_url) return _to_response(item, sign_url)
@@ -317,8 +317,8 @@ def update_voice(
use_case = UpdateVoiceLibraryUseCase(voice_repository) use_case = UpdateVoiceLibraryUseCase(voice_repository)
try: try:
item = use_case.execute(command) item = use_case.execute(command)
except NotFoundError as _e: except NotFoundError:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Voice not found") from _e raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Voice not found")
return _to_response(item, sign_url) return _to_response(item, sign_url)
+2 -2
View File
@@ -141,7 +141,7 @@ def safe_enqueue_generation_task(
global_pending_limit, global_pending_limit,
user_id or "unknown", user_id or "unknown",
) )
exc: Exception = GlobalQueueFull(pending_count=global_pending, limit=global_pending_limit) exc = GlobalQueueFull(pending_count=global_pending, limit=global_pending_limit)
_mark_task_failed_safely(task, generation_task_repository, log_prefix, str(exc)) _mark_task_failed_safely(task, generation_task_repository, log_prefix, str(exc))
raise exc raise exc
@@ -194,7 +194,7 @@ def safe_enqueue_generation_task(
if global_over or user_over: if global_over or user_over:
if global_over: if global_over:
reason = f"全局 pending 超限(入队后): {global_after}/{global_pending_limit}" reason = f"全局 pending 超限(入队后): {global_after}/{global_pending_limit}"
exc = GlobalQueueFull(pending_count=global_after, limit=global_pending_limit) exc: Exception = GlobalQueueFull(pending_count=global_after, limit=global_pending_limit)
else: else:
reason = f"用户 pending 超限(入队后): {user_after}/{user_pending_limit}" reason = f"用户 pending 超限(入队后): {user_after}/{user_pending_limit}"
exc = UserPendingLimitExceeded(user_id=user_id, pending_count=user_after, limit=user_pending_limit) exc = UserPendingLimitExceeded(user_id=user_id, pending_count=user_after, limit=user_pending_limit)
+1 -1
View File
@@ -132,7 +132,7 @@ def get_tag_repository(
session: Session = Depends(get_db_session), session: Session = Depends(get_db_session),
) -> TagRepository: ) -> TagRepository:
"""Provide the SQLAlchemy tag repository implementation.""" """Provide the SQLAlchemy tag repository implementation."""
return SQLAlchemyTagRepository(session) # type: ignore[return-value] return SQLAlchemyTagRepository(session)
def get_user_repository( def get_user_repository(
+1 -1
View File
@@ -105,7 +105,7 @@ class RateLimitMiddleware(BaseHTTPMiddleware):
self.max_requests = max_requests self.max_requests = max_requests
self.window_seconds = window_seconds self.window_seconds = window_seconds
self.paths = set(paths) if paths else None self.paths = set(paths) if paths else None
self.requests: dict[str, list[float]] = {} self.requests = {} # {ip: [timestamps]}
async def dispatch(self, request: Request, call_next): async def dispatch(self, request: Request, call_next):
# 如果配置了路径过滤,只对指定路径限流 # 如果配置了路径过滤,只对指定路径限流
+1 -2
View File
@@ -12,7 +12,6 @@
from __future__ import annotations from __future__ import annotations
import logging import logging
from collections.abc import Mapping
from dataclasses import dataclass from dataclasses import dataclass
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
@@ -156,7 +155,7 @@ class AutoClipService:
self, self,
clip: EditPlanClip, clip: EditPlanClip,
project_id: str, project_id: str,
config_map: Mapping[str, object], config_map: dict[str, object],
) -> ClipAssignDetail: ) -> ClipAssignDetail:
"""为单个片段分配素材。""" """为单个片段分配素材。"""
config = config_map.get(clip.template_clip_config_id) if clip.template_clip_config_id else None config = config_map.get(clip.template_clip_config_id) if clip.template_clip_config_id else None
+1 -190
View File
@@ -141,19 +141,6 @@ class EditPlanService:
logger.info("创建剪辑计划: id=%s name=%s", created.id, created.name) logger.info("创建剪辑计划: id=%s name=%s", created.id, created.name)
return created return created
def _auto_resume_editing(self, plan_id: str) -> None:
"""如果计划处于 completed/failed 状态,自动切回 editing(编辑操作前置)"""
plan = self._plan_repo.get(plan_id)
if plan is None:
return
if plan.status in (EditPlanStatus.COMPLETED, EditPlanStatus.FAILED):
try:
plan.resume_editing()
self._plan_repo.update(plan)
logger.info("自动重新编辑: plan_id=%s", plan_id)
except ValueError:
pass
def update_plan( def update_plan(
self, self,
plan_id: str, plan_id: str,
@@ -169,10 +156,6 @@ class EditPlanService:
""" """
existing = self.get_plan_or_raise(plan_id) existing = self.get_plan_or_raise(plan_id)
# 自动从 completed/failed 切回 editing
self._auto_resume_editing(plan_id)
existing = self.get_plan_or_raise(plan_id)
updated = EditPlan( updated = EditPlan(
id=existing.id, id=existing.id,
template_id=existing.template_id, template_id=existing.template_id,
@@ -229,24 +212,8 @@ class EditPlanService:
return plan return plan
# 根据目标状态调用对应的状态机方法 # 根据目标状态调用对应的状态机方法
# EDITING 支持从 draft / completed / failed 进入
if target_status == EditPlanStatus.EDITING:
if plan.status == EditPlanStatus.DRAFT:
plan.start_editing()
elif plan.status in (EditPlanStatus.COMPLETED, EditPlanStatus.FAILED):
plan.resume_editing()
else:
raise ValueError(f"无法从 {plan.status} 切换到 {target_status}")
result = self._plan_repo.update(plan)
logger.info(
"状态流转: plan_id=%s %s%s",
plan_id,
plan.status,
target_status,
)
return result
transition_map = { transition_map = {
EditPlanStatus.EDITING: plan.start_editing,
EditPlanStatus.RENDERING: plan.start_rendering, EditPlanStatus.RENDERING: plan.start_rendering,
EditPlanStatus.COMPLETED: plan.mark_completed, EditPlanStatus.COMPLETED: plan.mark_completed,
EditPlanStatus.FAILED: plan.mark_failed, EditPlanStatus.FAILED: plan.mark_failed,
@@ -325,8 +292,6 @@ class EditPlanService:
""" """
# 确保计划存在 # 确保计划存在
self.get_plan_or_raise(plan_id) self.get_plan_or_raise(plan_id)
# 自动从 completed/failed 切回 editing
self._auto_resume_editing(plan_id)
clip = EditPlanClip.create( clip = EditPlanClip.create(
plan_id=plan_id, plan_id=plan_id,
@@ -374,9 +339,6 @@ class EditPlanService:
""" """
existing = self.get_clip_or_raise(clip_id) existing = self.get_clip_or_raise(clip_id)
# 自动从 completed/failed 切回 editing
self._auto_resume_editing(existing.plan_id)
# 速度边界钳制 # 速度边界钳制
if playback_speed is not None: if playback_speed is not None:
if playback_speed <= 0: if playback_speed <= 0:
@@ -419,8 +381,6 @@ class EditPlanService:
ValueError: 片段不存在或 asset_id 为空 ValueError: 片段不存在或 asset_id 为空
""" """
clip = self.get_clip_or_raise(clip_id) clip = self.get_clip_or_raise(clip_id)
# 自动从 completed/failed 切回 editing
self._auto_resume_editing(clip.plan_id)
clip.assign_asset(asset_id) clip.assign_asset(asset_id)
result = self._clip_repo.update(clip) result = self._clip_repo.update(clip)
logger.info("分配素材: clip_id=%s asset_id=%s", clip_id, asset_id) logger.info("分配素材: clip_id=%s asset_id=%s", clip_id, asset_id)
@@ -447,62 +407,6 @@ class EditPlanService:
logger.info("删除所有片段: plan_id=%s count=%d", plan_id, count) logger.info("删除所有片段: plan_id=%s count=%d", plan_id, count)
return count return count
def create_clips_from_assets(
self,
plan_id: str,
asset_ids: list[str],
*,
clip_type: str = "main",
) -> list[EditPlanClip]:
"""从素材批量创建片段(追加到时间线末尾)。
Args:
plan_id: 计划 ID
asset_ids: 素材 ID 列表(按顺序追加)
clip_type: 片段类型
Returns:
list[EditPlanClip]: 创建的片段列表
"""
if not asset_ids:
return []
# 确保计划存在 + 自动回退状态
self.get_plan_or_raise(plan_id)
self._auto_resume_editing(plan_id)
# 查询素材信息(取 duration)
from packages.adapters.sqlalchemy_impl.models import AssetModel
session = self._clip_repo.session # type: ignore[attr-defined]
assets = session.query(AssetModel).filter(AssetModel.id.in_(asset_ids)).all()
asset_map = {a.id: a for a in assets}
# 从现有片段数量开始追加
existing_count = self._clip_repo.count(plan_id=plan_id)
# 批量创建片段
created: list[EditPlanClip] = []
for i, asset_id in enumerate(asset_ids):
asset = asset_map.get(asset_id)
duration = asset.duration if asset and asset.duration else 0.0
clip = self.create_clip(
plan_id=plan_id,
clip_type=clip_type,
order=existing_count + i,
asset_id=asset_id,
duration=duration,
)
created.append(clip)
logger.info(
"从素材批量创建片段: plan_id=%s count=%d",
plan_id,
len(created),
)
return created
# ── 渲染生成流程 ──────────────────────────────────────────────────────── # ── 渲染生成流程 ────────────────────────────────────────────────────────
def get_plan_with_clips(self, plan_id: str) -> Dict[str, Any]: def get_plan_with_clips(self, plan_id: str) -> Dict[str, Any]:
@@ -527,8 +431,6 @@ class EditPlanService:
"clips": List[EditPlanClip], "clips": List[EditPlanClip],
"generation_task_id": Optional[str], "generation_task_id": Optional[str],
"generation_task_status": Optional[str], "generation_task_status": Optional[str],
"progress": float,
"error_message": str,
} }
Raises: Raises:
@@ -540,23 +442,17 @@ class EditPlanService:
# 从 plan.config 中获取 generation_task_id # 从 plan.config 中获取 generation_task_id
generation_task_id = plan.config.get("generation_task_id") generation_task_id = plan.config.get("generation_task_id")
generation_task_status = None generation_task_status = None
progress = 0.0
error_message = ""
if generation_task_id: if generation_task_id:
task = self._generation_task_repo.get(generation_task_id) task = self._generation_task_repo.get(generation_task_id)
if task: if task:
generation_task_status = task.status.value if hasattr(task.status, "value") else task.status generation_task_status = task.status.value if hasattr(task.status, "value") else task.status
progress = getattr(task, "progress", 0.0) or 0.0
error_message = getattr(task, "error_message", "") or ""
return { return {
"plan": plan, "plan": plan,
"clips": clips, "clips": clips,
"generation_task_id": generation_task_id, "generation_task_id": generation_task_id,
"generation_task_status": generation_task_status, "generation_task_status": generation_task_status,
"progress": progress,
"error_message": error_message,
} }
def can_generate(self, plan_id: str) -> tuple[bool, str]: def can_generate(self, plan_id: str) -> tuple[bool, str]:
@@ -607,9 +503,6 @@ class EditPlanService:
更新后的计划 更新后的计划
""" """
plan = self.get_plan_or_raise(plan_id) plan = self.get_plan_or_raise(plan_id)
# 自动从 completed/failed 切回 editing
self._auto_resume_editing(plan_id)
plan = self.get_plan_or_raise(plan_id)
new_config = {**plan.config, **config_updates} new_config = {**plan.config, **config_updates}
updated = EditPlan( updated = EditPlan(
@@ -626,85 +519,3 @@ class EditPlanService:
updated_at=plan.updated_at, updated_at=plan.updated_at,
) )
return self._plan_repo.update(updated) return self._plan_repo.update(updated)
# ── 复制计划 ────────────────────────────────────────────────────────────
def copy_plan(
self,
plan_id: str,
*,
new_name: Optional[str] = None,
project_id: Optional[str] = None,
) -> EditPlan:
"""复制一个剪辑计划(含所有片段配置)。
新计划状态为 editing,不含生成任务和结果记录。
Args:
plan_id: 源计划 ID
new_name: 新计划名称,不传则为「原名 - 副本」
project_id: 新计划的项目 ID,不传则复用源计划
Returns:
EditPlan: 新创建的计划
Raises:
ValueError: 源计划不存在
"""
source = self.get_plan_or_raise(plan_id)
source_clips = self._clip_repo.list_by_plan(plan_id)
# 新计划名称
name = new_name or f"{source.name} - 副本"
new_project_id = project_id if project_id is not None else source.project_id
# 复制 plan 配置(去除渲染结果相关字段)
new_config = dict(source.config)
new_config.pop("rendered_url", None)
new_config.pop("rendered_storage_key", None)
new_config.pop("generation_task_id", None)
# 创建新计划
new_plan = EditPlan.create(
template_id=source.template_id,
name=name,
config=new_config,
total_duration=source.total_duration,
project_id=new_project_id,
created_by_user_id=source.created_by_user_id,
source_edit_plan_id=plan_id,
)
# 强制切到 editing 状态
if new_plan.status != EditPlanStatus.EDITING:
try:
new_plan.start_editing()
except ValueError:
pass
created_plan = self._plan_repo.create(new_plan)
logger.info(
"复制剪辑计划: source=%s target=%s name=%s clips=%d",
plan_id,
created_plan.id,
name,
len(source_clips),
)
# 复制所有片段
for clip in source_clips:
new_clip = self.create_clip(
plan_id=created_plan.id,
clip_type=clip.clip_type,
order=clip.order,
asset_id=clip.asset_id or "",
text_content=clip.text_content or "",
start_time=clip.start_time,
duration=clip.duration,
transition_effect=clip.transition_effect or "cut",
transition_duration=clip.transition_duration or 0.0,
playback_speed=clip.playback_speed or 1.0,
config=dict(clip.config) if clip.config else None,
)
logger.debug("复制片段: source=%s target=%s order=%d", clip.id, new_clip.id, clip.order)
return self.get_plan_or_raise(created_plan.id)
@@ -224,7 +224,7 @@ class PlanGeneratorService:
) )
order += 1 order += 1
# 剩余为 overlay # 剩余为 overlay
for _ in range(1, n): for i in range(1, n):
clips.append( clips.append(
EditPlanClip.create( EditPlanClip.create(
plan_id=plan_id, plan_id=plan_id,
@@ -237,7 +237,7 @@ class PlanGeneratorService:
elif editing_mode == EditingMode.VOICE_OVER.value: elif editing_mode == EditingMode.VOICE_OVER.value:
# N 个 main clipsB-roll # N 个 main clipsB-roll
for _ in range(n): for i in range(n):
clips.append( clips.append(
EditPlanClip.create( EditPlanClip.create(
plan_id=plan_id, plan_id=plan_id,
@@ -271,7 +271,7 @@ class PlanGeneratorService:
) )
order += 1 order += 1
# 剩余为 b_roll # 剩余为 b_roll
for _ in range(2, n): for i in range(2, n):
clips.append( clips.append(
EditPlanClip.create( EditPlanClip.create(
plan_id=plan_id, plan_id=plan_id,
@@ -284,7 +284,7 @@ class PlanGeneratorService:
else: else:
# ONE_TAKE: N 个 main clips # ONE_TAKE: N 个 main clips
for _ in range(n): for i in range(n):
clips.append( clips.append(
EditPlanClip.create( EditPlanClip.create(
plan_id=plan_id, plan_id=plan_id,
-17
View File
@@ -33,7 +33,6 @@
"eslint-plugin-react-hooks": "^4.6.2", "eslint-plugin-react-hooks": "^4.6.2",
"eslint-plugin-react-refresh": "^0.4.7", "eslint-plugin-react-refresh": "^0.4.7",
"jsdom": "^24.1.0", "jsdom": "^24.1.0",
"prettier": "^3.0.0",
"typescript": "^5.5.3", "typescript": "^5.5.3",
"vite": "^5.3.1", "vite": "^5.3.1",
"vitest": "^1.6.0" "vitest": "^1.6.0"
@@ -4829,22 +4828,6 @@
"node": ">= 0.8.0" "node": ">= 0.8.0"
} }
}, },
"node_modules/prettier": {
"version": "3.9.5",
"resolved": "https://registry.npmmirror.com/prettier/-/prettier-3.9.5.tgz",
"integrity": "sha512-/FVl766LpUfB5vXgCYOYa0MeV/441Ia99AeICQIQFTY/Nw0roZwULcXpku5i1/m5kt/baz+s4Zogspd839HSMg==",
"dev": true,
"license": "MIT",
"bin": {
"prettier": "bin/prettier.cjs"
},
"engines": {
"node": ">=14"
},
"funding": {
"url": "https://github.com/prettier/prettier?sponsor=1"
}
},
"node_modules/pretty-format": { "node_modules/pretty-format": {
"version": "27.5.1", "version": "27.5.1",
"resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz",
-1
View File
@@ -42,7 +42,6 @@
"eslint-plugin-react-hooks": "^4.6.2", "eslint-plugin-react-hooks": "^4.6.2",
"eslint-plugin-react-refresh": "^0.4.7", "eslint-plugin-react-refresh": "^0.4.7",
"jsdom": "^24.1.0", "jsdom": "^24.1.0",
"prettier": "^3.0.0",
"typescript": "^5.5.3", "typescript": "^5.5.3",
"vite": "^5.3.1", "vite": "^5.3.1",
"vitest": "^1.6.0" "vitest": "^1.6.0"
+6 -15
View File
@@ -130,8 +130,6 @@ export interface EditPlan {
name: string; name: string;
status: EditPlanStatus; status: EditPlanStatus;
total_duration: number; total_duration: number;
/** 生成视频数量(后端 EditPlanResponse.result_count */
result_count: number;
config: EditPlanConfig; config: EditPlanConfig;
created_at: string; created_at: string;
updated_at: string; updated_at: string;
@@ -163,21 +161,14 @@ export interface GenerateResponse {
clip_count: number; clip_count: number;
} }
/** 剪辑计划关联的生成记录(实际是 GenerationTask 对象) */ /** 剪辑计划关联的生成记录 */
export interface EditPlanGeneration { export interface EditPlanGeneration {
id: string; // 即 generation_task_id id: string;
source_edit_plan_id: string; edit_plan_id: string;
template_id: string; generation_task_id: string;
asset_ids: string[];
status: EditPlanStatus; status: EditPlanStatus;
progress: number; created_at: string;
result_count: number; updated_at: string;
error_message: string;
error_info: Record<string, unknown>;
logs: Array<Record<string, unknown>>;
retry_count: number;
created_at?: string;
updated_at?: string;
} }
/** 片段生成状态 */ /** 片段生成状态 */
-1
View File
@@ -1,6 +1,5 @@
/** /**
* 成品 / 视频相关 API * 成品 / 视频相关 API
* 包含:列表查询、复核状态、批量下载
* 后端无 /products 路由,实际从 /generation/tasks 端点获取数据 * 后端无 /products 路由,实际从 /generation/tasks 端点获取数据
*/ */
import apiClient from "./client"; import apiClient from "./client";
+4 -9
View File
@@ -16,22 +16,17 @@ import type { EditPlanConfig } from "./editPlans";
/** 模板条目(后端 TemplateResponse */ /** 模板条目(后端 TemplateResponse */
export interface TemplateItem { export interface TemplateItem {
id: string; id: string;
user_id?: string;
name: string; name: string;
description?: string; description: string;
mode?: string;
category: string; category: string;
tags?: string[]; tags?: string[];
/** 预估时长(后端字段名 estimated_duration */ target_duration: number;
estimated_duration?: number; clip_count: number;
/** @deprecated 后端已改名为 estimated_duration,保留兼容 */
target_duration?: number;
clip_count?: number;
/** 使用次数 */ /** 使用次数 */
usage_count?: number; usage_count?: number;
thumbnail_url?: string; thumbnail_url?: string;
preview_url?: string; preview_url?: string;
is_active?: boolean; is_active: boolean;
is_favorite?: boolean; is_favorite?: boolean;
/** 素材规则(片段配置) */ /** 素材规则(片段配置) */
segments?: TemplateSegment[]; segments?: TemplateSegment[];
+2 -4
View File
@@ -6,7 +6,7 @@ import React from "react";
import ReactDOM from "react-dom/client"; import ReactDOM from "react-dom/client";
import { RouterProvider } from "react-router-dom"; import { RouterProvider } from "react-router-dom";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { ConfigProvider, App as AntApp } from "antd"; import { ConfigProvider } from "antd";
import zhCN from "antd/locale/zh_CN"; import zhCN from "antd/locale/zh_CN";
import router from "./router"; import router from "./router";
import "./index.css"; import "./index.css";
@@ -91,9 +91,7 @@ ReactDOM.createRoot(document.getElementById("root")!).render(
<React.StrictMode> <React.StrictMode>
<QueryClientProvider client={queryClient}> <QueryClientProvider client={queryClient}>
<ConfigProvider locale={zhCN} theme={theme}> <ConfigProvider locale={zhCN} theme={theme}>
<AntApp> <RouterProvider router={router} />
<RouterProvider router={router} />
</AntApp>
</ConfigProvider> </ConfigProvider>
</QueryClientProvider> </QueryClientProvider>
</React.StrictMode>, </React.StrictMode>,
@@ -33,9 +33,8 @@ const formatSize = (bytes: number) => {
/** 格式化时长 */ /** 格式化时长 */
const formatDuration = (seconds?: number) => { const formatDuration = (seconds?: number) => {
if (!seconds) return "-"; if (!seconds) return "-";
const totalSec = Math.round(seconds); const m = Math.floor(seconds / 60);
const m = Math.floor(totalSec / 60); const s = seconds % 60;
const s = totalSec % 60;
return m > 0 ? `${m}${s}` : `${s}`; return m > 0 ? `${m}${s}` : `${s}`;
}; };
@@ -59,9 +59,8 @@ const formatSize = (bytes: number) => {
/** 格式化时长 */ /** 格式化时长 */
const formatDuration = (seconds?: number) => { const formatDuration = (seconds?: number) => {
if (!seconds) return "-"; if (!seconds) return "-";
const totalSec = Math.round(seconds); const m = Math.floor(seconds / 60);
const m = Math.floor(totalSec / 60); const s = seconds % 60;
const s = totalSec % 60;
return m > 0 ? `${m}${s}` : `${s}`; return m > 0 ? `${m}${s}` : `${s}`;
}; };
+2 -13
View File
@@ -86,9 +86,8 @@ const STATUS_CONFIG: Record<
/** 格式化时长 */ /** 格式化时长 */
const formatDuration = (seconds: number): string => { const formatDuration = (seconds: number): string => {
if (seconds <= 0) return "-"; if (seconds <= 0) return "-";
const totalSec = Math.round(seconds); const m = Math.floor(seconds / 60);
const m = Math.floor(totalSec / 60); const s = seconds % 60;
const s = totalSec % 60;
if (m === 0) return `${s}`; if (m === 0) return `${s}`;
return `${m}${s > 0 ? `${s}` : ""}`; return `${m}${s > 0 ? `${s}` : ""}`;
}; };
@@ -254,16 +253,6 @@ export default function EditPlans() {
<span className="plan-duration">{formatDuration(seconds)}</span> <span className="plan-duration">{formatDuration(seconds)}</span>
), ),
}, },
{
title: "视频数",
dataIndex: "result_count",
key: "result_count",
width: 80,
align: "center",
render: (count: number) => (
<span className="plan-result-count">{count > 0 ? count : "—"}</span>
),
},
{ {
title: "创建时间", title: "创建时间",
dataIndex: "created_at", dataIndex: "created_at",
@@ -1812,27 +1812,7 @@
═══════════════════════════════════════ */ ═══════════════════════════════════════ */
.ep-status-bar { .ep-status-bar {
display: flex; display: none;
align-items: center;
justify-content: space-between;
padding: 6px 16px;
background: var(--ep-bg-card, #fff);
border-bottom: 1px solid var(--ep-border, #e8e8e8);
font-size: 12px;
color: var(--text-secondary, #666);
flex-shrink: 0;
}
.ep-status-left,
.ep-status-right {
display: flex;
align-items: center;
gap: 4px;
}
.ep-status-sep {
margin: 0 4px;
opacity: 0.35;
} }
/* ═══════════════════════════════════════ /* ═══════════════════════════════════════
@@ -3,8 +3,8 @@
* 四行布局:顶栏(42px) → 模式栏(48px) → 三栏主体 → 底栏(40px) * 四行布局:顶栏(42px) → 模式栏(48px) → 三栏主体 → 底栏(40px)
*/ */
import React, { useState, useCallback, useEffect, useRef } from "react"; import React, { useState, useCallback, useEffect, useRef } from "react";
import { useSearchParams } from "react-router-dom"; import { useSearchParams, useNavigate } from "react-router-dom";
import { message, Modal, Progress, Button } from "antd"; import { message } from "antd";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import type { import type {
EditingTemplate, EditingTemplate,
@@ -20,23 +20,11 @@ import {
getTemplateCategories, getTemplateCategories,
MODE_LABELS, MODE_LABELS,
} from "@/api/editingPlanner"; } from "@/api/editingPlanner";
import type { import type { EditPlanGeneration, MediaAsset } from "@/api/editPlans";
EditPlanGeneration,
EditPlanConfig,
GeneratedVideo,
MediaAsset,
TransitionEffect,
} from "@/api/editPlans";
import { import {
getMediaAssets, getMediaAssets,
getEditPlanGenerations, getEditPlanGenerations,
generateCover, generateCover,
getEditPlan,
createEditPlan,
updateEditPlan,
generateEditPlan,
getGenerationStatus,
getGenerationTaskResults,
} from "@/api/editPlans"; } from "@/api/editPlans";
import { useUndoRedo } from "./hooks/useUndoRedo"; import { useUndoRedo } from "./hooks/useUndoRedo";
import type { import type {
@@ -45,7 +33,6 @@ import type {
TransitionConfig, TransitionConfig,
SpeedConfig, SpeedConfig,
TtsConfig, TtsConfig,
TtsMode,
TrimConfig, TrimConfig,
WatermarkConfig, WatermarkConfig,
IntroOutroConfig, IntroOutroConfig,
@@ -120,8 +107,8 @@ const FILTER_CATEGORIES = ["全部", "种草", "知识", "日常", "推荐"];
const EditingPlanner: React.FC = () => { const EditingPlanner: React.FC = () => {
const [searchParams] = useSearchParams(); const [searchParams] = useSearchParams();
const navigate = useNavigate();
const urlTemplateId = searchParams.get("templateId") || ""; const urlTemplateId = searchParams.get("templateId") || "";
const urlPlanId = searchParams.get("planId") || "";
/* ── 模板列表 ── */ /* ── 模板列表 ── */
const [templates, setTemplates] = useState<EditingTemplate[]>([]); const [templates, setTemplates] = useState<EditingTemplate[]>([]);
@@ -257,21 +244,6 @@ const EditingPlanner: React.FC = () => {
const [genHistory, setGenHistory] = useState<EditPlanGeneration[]>([]); const [genHistory, setGenHistory] = useState<EditPlanGeneration[]>([]);
const [genHistoryLoading, setGenHistoryLoading] = useState(false); const [genHistoryLoading, setGenHistoryLoading] = useState(false);
/* ── 剪辑计划(从列表页编辑进入时) ── */
const [loadedPlanId, setLoadedPlanId] = useState<string | null>(
urlPlanId || null,
);
/* ── 生成进度 ── */
const [generating, setGenerating] = useState(false);
const [genProgress, setGenProgress] = useState(0);
const [genTotalClips, setGenTotalClips] = useState(0);
const [genDoneClips, setGenDoneClips] = useState(0);
const [generated, setGenerated] = useState(false);
const [generatedVideos, setGeneratedVideos] = useState<GeneratedVideo[]>([]);
const [genError, setGenError] = useState<string | null>(null);
const genTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
/* ── 播放 ── */ /* ── 播放 ── */
const [isPlaying, setIsPlaying] = useState(false); const [isPlaying, setIsPlaying] = useState(false);
const [currentTime, setCurrentTime] = useState(0); const [currentTime, setCurrentTime] = useState(0);
@@ -402,85 +374,6 @@ const EditingPlanner: React.FC = () => {
.catch(() => message.error("加载模板详情失败")); .catch(() => message.error("加载模板详情失败"));
}, [loadedTemplateId, resetClips]); }, [loadedTemplateId, resetClips]);
/**
* 加载已有剪辑计划数据到编辑器
* 从列表页"编辑"按钮进入时,URL 带 planId,需要还原计划配置
*/
useEffect(() => {
if (!loadedPlanId) return;
getEditPlan(loadedPlanId)
.then((plan) => {
// 设置关联的模板(触发模板加载 effect)
setLoadedTemplateId(plan.template_id);
// 还原基本信息
setDraftName(plan.name);
// 还原 config 中的编辑器状态
const cfg = plan.config;
if (cfg.title_config) {
setTitleSettings((prev) => ({
...prev,
aiAutoSelect: cfg.title_config!.ai_auto_select,
title: cfg.title_config!.content,
position: cfg.title_config!.position,
font: cfg.title_config!.font_preset,
size: cfg.title_config!.font_size,
color: cfg.title_config!.font_color || "#ffffff",
}));
}
if (cfg.subtitle_config) {
setSubtitleSettings((prev) => ({
...prev,
enabled: cfg.subtitle_config!.enabled,
position: (cfg.subtitle_config!.position ||
"bottom") as SubtitleStyleConfig["position"],
font: cfg.subtitle_config!.font,
fontSize: cfg.subtitle_config!.size,
fontColor: cfg.subtitle_config!.color || "#ffffff",
animation: cfg.subtitle_config!.animation,
}));
}
if (cfg.bgm_config) {
setBgmSettings((prev) => ({
...prev,
enabled: cfg.bgm_config!.enabled,
music_id: cfg.bgm_config!.music_id || "",
}));
}
// 还原片段 — 延迟设置,等模板加载 effect 先执行 resetClips
if (cfg.segments && cfg.segments.length > 0) {
const mapped: ClipData[] = cfg.segments.map((seg, idx) => ({
id: `seg-${idx}`,
template_segment_id: `seg-${idx}`,
type: (seg.material_type === "voiceover"
? "voice"
: "pip") as ClipType,
duration: (seg.duration_min + seg.duration_max) / 2,
startOffset: 0,
script_text: "",
order: seg.segment_order,
transition: seg.transition
? {
type: seg.transition.type as TransitionEffect["type"],
duration: seg.transition.duration,
}
: undefined,
speed: seg.playback_speed
? { rate: seg.playback_speed, pitchCorrection: true }
: undefined,
tts_config: seg.tts_config
? { ...seg.tts_config, mode: seg.tts_config.mode as TtsMode }
: undefined,
trim_config: seg.trim_config || undefined,
}));
setTimeout(() => resetClips(mapped), 100);
}
})
.catch(() => message.error("加载剪辑计划失败"));
}, [loadedPlanId, resetClips]);
/* ──────────── 计算 ──────────── */ /* ──────────── 计算 ──────────── */
const currentTemplate = templates.find((t) => t.id === loadedTemplateId); const currentTemplate = templates.find((t) => t.id === loadedTemplateId);
@@ -778,65 +671,6 @@ const EditingPlanner: React.FC = () => {
} }
}; };
/** 构建剪辑计划 config(编辑器状态 → API config */
const buildPlanConfig = (): EditPlanConfig => ({
title_config: {
ai_auto_select: titleSettings.aiAutoSelect,
content: titleSettings.title,
position: titleSettings.position,
font_preset: titleSettings.font,
font_color: titleSettings.color,
font_size: titleSettings.size,
},
subtitle_config: {
enabled: subtitleSettings.enabled,
position: subtitleSettings.position,
font: subtitleSettings.font,
color: subtitleSettings.fontColor,
size: subtitleSettings.fontSize,
animation: subtitleSettings.animation,
},
bgm_config: {
enabled: bgmSettings.enabled,
music_id: bgmSettings.music_id,
},
estimated_duration: totalDuration,
segments: clips.map((c, i) => ({
segment_order: i,
duration_min: Math.max(1, c.duration - 2),
duration_max: c.duration + 2,
material_type: c.type === "voice" ? "voiceover" : "video",
transition: c.transition
? { type: c.transition.type, duration: c.transition.duration }
: undefined,
playback_speed: c.speed ? c.speed.rate : undefined,
tts_config: c.tts_config
? {
mode: c.tts_config.mode,
text: c.tts_config.text,
voice_id: c.tts_config.voice_id,
speed: c.tts_config.speed,
pitch: c.tts_config.pitch,
volume: c.tts_config.volume,
subtitle_sync: c.tts_config.subtitle_sync,
}
: undefined,
trim_config: c.trim_config
? {
start_time: c.trim_config.start_time,
end_time: c.trim_config.end_time,
}
: undefined,
})),
watermark_config: { ...watermarkSettings },
intro_outro_config: { ...introOutroSettings },
pip_config: { ...pipSettings },
filter_config: { ...filterSettings },
green_screen_config: { ...chromaKeySettings },
sticker_config: { ...stickerSettings },
cover_config: { ...coverSettings },
});
/* 保存 — 无论是否已加载模板,都打开保存弹窗;未加载时创建新模板 */ /* 保存 — 无论是否已加载模板,都打开保存弹窗;未加载时创建新模板 */
const handleOpenSaveModal = () => { const handleOpenSaveModal = () => {
setSaveModalOpen(true); setSaveModalOpen(true);
@@ -929,148 +763,94 @@ const EditingPlanner: React.FC = () => {
}; };
/** /**
* 剪辑计划生成 * 跳转到一键生成页面
* 1. 有 planId → 更新计划配置 + 触发生成 * 通过 URL SearchParams 传递 edit_plan_id 和完整 planConfigJSON 序列化)
* 2. 无 planId(从模板库直接进入)→ 先创建计划 + 触发生成 * 一键生成页面从 params 解析配置,无需重复请求接口
* 3. 触发生成后轮询状态,完成后获取视频结果
*/ */
const handleGoToGenerate = async () => { const handleGoToGenerate = () => {
if (!loadedTemplateId) { const planConfig = {
message.warning("请先选择一个模板"); title_config: {
return; ai_auto_select: titleSettings.aiAutoSelect,
} content: titleSettings.title,
if (clips.length === 0) { position: titleSettings.position,
message.warning("请先添加片段"); font_preset: titleSettings.font,
return; font_color: titleSettings.color,
} font_size: titleSettings.size,
bold: titleSettings.bold,
setGenerating(true); italic: titleSettings.italic,
setGenerated(false); stroke: titleSettings.stroke,
setGeneratedVideos([]); shadow: titleSettings.shadow,
setGenError(null); },
setGenProgress(0); subtitle_config: {
enabled: subtitleSettings.enabled,
try { position: subtitleSettings.position,
const config = buildPlanConfig(); font: subtitleSettings.font,
let planId = loadedPlanId; color: subtitleSettings.fontColor,
size: subtitleSettings.fontSize,
if (planId) { animation: subtitleSettings.animation,
// 已有计划 → 先重置状态为 draftfailed/editing 等非 draft 状态会被后端拒绝更新和生成) },
try { bgm_config: {
await updateEditPlan(planId, { status: "draft" }); enabled: bgmSettings.enabled,
} catch (resetErr) { music_id: bgmSettings.music_id,
console.warn("[状态重置跳过]", resetErr); },
} mode: currentMode,
// 再更新配置 total_duration: totalDuration,
try { segments: clips.map((c, i) => ({
await updateEditPlan(planId, { order: i,
config, material_type: c.type === "voice" ? "voiceover" : "video",
total_duration: totalDuration, duration: c.duration,
}); template_segment_id: c.template_segment_id,
} catch (updateErr) { script_text: c.script_text,
console.warn("[计划更新跳过]", updateErr); voice_asset_id: c.voice_asset_id,
} voice_file_url: c.voice_file_url,
} else { transition: c.transition
// 无计划 → 创建新计划 ? { type: c.transition.type, duration: c.transition.duration }
const plan = await createEditPlan({ : undefined,
template_id: loadedTemplateId, playback_speed: c.speed ? c.speed.rate : undefined,
name: draftName || "未命名计划", tts_config: c.tts_config
config, ? {
total_duration: totalDuration, mode: c.tts_config.mode,
}); text: c.tts_config.text,
planId = plan.id; voice_id: c.tts_config.voice_id,
setLoadedPlanId(planId); speed: c.tts_config.speed,
// 更新 URL 参数(不刷新页面) pitch: c.tts_config.pitch,
const params = new URLSearchParams(window.location.search); volume: c.tts_config.volume,
params.set("planId", planId); subtitle_sync: c.tts_config.subtitle_sync,
window.history.replaceState(null, "", `?${params.toString()}`);
}
// 触发生成
const genRes = await generateEditPlan(planId);
setGenTotalClips(genRes.clip_count);
message.info("已提交生成,等待处理...");
// 开始轮询
startPolling(planId);
} catch (err) {
console.error("[生成失败]", err);
setGenError("生成提交失败,请重试");
setGenerating(false);
}
};
/** 轮询生成状态,每 2 秒一次 */
const startPolling = (planId: string) => {
const poll = async () => {
try {
const status = await getGenerationStatus(planId);
// 计算进度
const total = status.clips.length || genTotalClips;
const done = status.clips.filter(
(c) => c.status === "completed" || c.status === "failed",
).length;
setGenDoneClips(done);
setGenTotalClips(total);
setGenProgress(total > 0 ? Math.round((done / total) * 100) : 5);
if (status.plan_status === "completed") {
setGenProgress(100);
setGenerating(false);
setGenerated(true);
// 获取视频结果
if (status.generation_task_id) {
try {
const videos = await getGenerationTaskResults(
status.generation_task_id,
);
setGeneratedVideos(videos);
} catch (e) {
console.error("[获取视频结果失败]", e);
} }
} : undefined,
message.success("视频生成完成!"); trim_config: c.trim_config
return; // 停止轮询 ? {
} start_time: c.trim_config.start_time,
end_time: c.trim_config.end_time,
if (status.plan_status === "failed") { }
setGenerating(false); : undefined,
setGenError("生成失败,请重试"); })),
return; // 停止轮询 watermark_config: { ...watermarkSettings },
} intro_outro_config: { ...introOutroSettings },
pip_config: { ...pipSettings },
// 继续轮询 filter_config: { ...filterSettings },
genTimerRef.current = setTimeout(poll, 2000); green_screen_config: { ...chromaKeySettings },
} catch (err) { sticker_config: { ...stickerSettings },
console.error("[轮询状态失败]", err); cover_config: { ...coverSettings },
genTimerRef.current = setTimeout(poll, 5000); // 出错后 5 秒重试
}
}; };
const params = new URLSearchParams();
// 首次延迟 2 秒后开始 if (loadedTemplateId) {
genTimerRef.current = setTimeout(poll, 2000); params.set("edit_plan_id", loadedTemplateId);
}
params.set("plan_config", JSON.stringify(planConfig));
navigate(`/app/generate?${params.toString()}`);
}; };
/** 清理轮询定时器 */
useEffect(() => {
return () => {
if (genTimerRef.current) clearTimeout(genTimerRef.current);
};
}, []);
/* 查看生成历史 */ /* 查看生成历史 */
const handleViewGenHistory = async () => { const handleViewGenHistory = async () => {
const targetId = loadedPlanId || loadedTemplateId; if (!loadedTemplateId) {
if (!targetId) { message.warning("请先加载一个模板");
message.warning("请先加载一个模板或计划");
return; return;
} }
setGenHistoryOpen(true); setGenHistoryOpen(true);
setGenHistoryLoading(true); setGenHistoryLoading(true);
try { try {
const items = await getEditPlanGenerations(targetId); const items = await getEditPlanGenerations(loadedTemplateId);
setGenHistory(items); setGenHistory(items);
} catch { } catch {
message.error("加载生成历史失败"); message.error("加载生成历史失败");
@@ -1119,9 +899,8 @@ const EditingPlanner: React.FC = () => {
<button <button
className="ep-btn ep-btn-primary" className="ep-btn ep-btn-primary"
onClick={handleGoToGenerate} onClick={handleGoToGenerate}
disabled={generating}
> >
{loadedPlanId ? "🎬 生成视频" : "🎬 创建计划并生成"} 🎬 使
</button> </button>
</div> </div>
</div> </div>
@@ -1286,111 +1065,6 @@ const EditingPlanner: React.FC = () => {
onClose={() => setGenHistoryOpen(false)} onClose={() => setGenHistoryOpen(false)}
/> />
{/* ═══ 生成进度弹窗 ═══ */}
<Modal
title={genError ? "生成失败" : generated ? "生成完成" : "正在生成视频"}
open={generating || generated || !!genError}
footer={
generated
? [
<Button
key="close"
onClick={() => {
setGenerated(false);
setGenerating(false);
}}
>
</Button>,
generatedVideos.length > 0 && (
<Button
key="download"
type="primary"
onClick={() => {
const v = generatedVideos[0];
const url = v.download_url || v.file_url;
if (url) {
const a = document.createElement("a");
a.href = url;
a.download = v.name || "video.mp4";
a.target = "_blank";
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
}
}}
>
</Button>
),
]
: null
}
closable={!generating}
maskClosable={false}
width={520}
>
{generating && (
<div style={{ padding: "16px 0" }}>
<Progress percent={genProgress} status="active" />
<p style={{ marginTop: 8, color: "var(--text-secondary)" }}>
{genDoneClips}/{genTotalClips}
</p>
<p style={{ color: "var(--text-secondary)", fontSize: 12 }}>
</p>
</div>
)}
{generated && generatedVideos.length > 0 && (
<div style={{ padding: "8px 0" }}>
<video
src={
generatedVideos[0].file_url || generatedVideos[0].download_url
}
controls
preload="metadata"
style={{ width: "100%", maxHeight: 320, borderRadius: 8 }}
/>
<p
style={{
marginTop: 8,
textAlign: "center",
color: "var(--text-secondary)",
}}
>
{generatedVideos[0].name}
</p>
</div>
)}
{generated && !generatedVideos.length && (
<div style={{ padding: "24px 0", textAlign: "center" }}>
<p></p>
<p style={{ color: "var(--text-secondary)", fontSize: 12 }}>
</p>
</div>
)}
{genError && (
<div
style={{
padding: "16px 0",
textAlign: "center",
color: "#ff4d4f",
}}
>
<p>{genError}</p>
<Button
onClick={() => {
setGenError(null);
setGenerating(false);
}}
>
</Button>
</div>
)}
</Modal>
{/* ═══ BGM 选择器 Drawer ═══ */} {/* ═══ BGM 选择器 Drawer ═══ */}
<BgmSelector <BgmSelector
open={bgmDrawerOpen} open={bgmDrawerOpen}
@@ -65,7 +65,7 @@ const GenerationHistoryModal: React.FC<GenerationHistoryModalProps> = ({
return ( return (
<tr key={gen.id} className="ep-gh-table-row"> <tr key={gen.id} className="ep-gh-table-row">
<td className="ep-gh-td ep-gh-td-id"> <td className="ep-gh-td ep-gh-td-id">
{gen.id ? `${gen.id.slice(0, 8)}...` : "—"} {gen.generation_task_id.slice(0, 8)}...
</td> </td>
<td className="ep-gh-td"> <td className="ep-gh-td">
<span className={`ep-gh-status-tag ${statusClass}`}> <span className={`ep-gh-status-tag ${statusClass}`}>
@@ -23,6 +23,7 @@ import {
getTemplate, getTemplate,
toggleFavoriteTemplate, toggleFavoriteTemplate,
copyTemplate, copyTemplate,
generateFromTemplate,
type TemplateItem, type TemplateItem,
type TemplateListParams, type TemplateListParams,
type TemplateSegment, type TemplateSegment,
@@ -90,11 +91,10 @@ const gradientForCategory = (category: string): string => {
}; };
/** 格式化时长 */ /** 格式化时长 */
const formatDuration = (seconds: number | undefined | null): string => { const formatDuration = (seconds: number): string => {
if (!seconds || seconds <= 0) return "0秒"; if (seconds <= 0) return "0秒";
const totalSec = Math.round(seconds); const m = Math.floor(seconds / 60);
const m = Math.floor(totalSec / 60); const s = seconds % 60;
const s = totalSec % 60;
if (m === 0) return `${s}`; if (m === 0) return `${s}`;
return `${m}${s > 0 ? `${s}` : ""}`; return `${m}${s > 0 ? `${s}` : ""}`;
}; };
@@ -237,9 +237,7 @@ const TemplateDetailModal: React.FC<TemplateDetailModalProps> = ({
{ {
key: "duration", key: "duration",
label: "目标时长", label: "目标时长",
children: formatDuration( children: formatDuration(template.target_duration),
template.estimated_duration ?? template.target_duration,
),
}, },
{ {
key: "clips", key: "clips",
@@ -411,9 +409,7 @@ const TemplateCard: React.FC<TemplateCardProps> = ({
<div className="xx-template-thumb-name">{template.name}</div> <div className="xx-template-thumb-name">{template.name}</div>
<div className="xx-template-thumb-meta"> <div className="xx-template-thumb-meta">
<span className="xx-template-thumb-duration"> <span className="xx-template-thumb-duration">
{formatDuration( {formatDuration(template.target_duration)}
template.estimated_duration ?? template.target_duration,
)}
</span> </span>
</div> </div>
<div className="xx-template-preview-hint"></div> <div className="xx-template-preview-hint"></div>
@@ -541,6 +537,19 @@ const TemplateLibrary: React.FC = () => {
}, },
}); });
// ── 从模板生成剪辑计划 mutation ──
const generateMutation = useMutation({
mutationFn: ({ templateId, name }: { templateId: string; name: string }) =>
generateFromTemplate(templateId, { name }),
onSuccess: (data) => {
message.success(`剪辑计划「${data.name}」已创建`);
navigate("/app/edit-plans");
},
onError: () => {
message.error("生成剪辑计划失败,请稍后重试");
},
});
/** 切换收藏 */ /** 切换收藏 */
const toggleFavorite = useCallback( const toggleFavorite = useCallback(
(id: string, e?: React.MouseEvent) => { (id: string, e?: React.MouseEvent) => {
@@ -573,12 +582,15 @@ const TemplateLibrary: React.FC = () => {
[copyMutation], [copyMutation],
); );
/** 使用模板 → 进入剪辑编辑器配置 */ /** 使用模板 → 生成剪辑计划 */
const handleUse = useCallback( const handleUse = useCallback(
(template: TemplateItem) => { (template: TemplateItem) => {
navigate(`/app/editing-planner?templateId=${template.id}`); generateMutation.mutate({
templateId: template.id,
name: `基于「${template.name}」的剪辑计划`,
});
}, },
[navigate], [generateMutation, navigate],
); );
/** 搜索防抖处理 */ /** 搜索防抖处理 */
-1
View File
@@ -37,7 +37,6 @@ export default defineConfig({
}, },
}, },
build: { build: {
cache: true,
rollupOptions: { rollupOptions: {
output: { output: {
manualChunks: { manualChunks: {
@@ -181,9 +181,9 @@ def _validate_video_path(video_path: str, work_dir: Path) -> None:
resolved_work_dir = work_dir.resolve() resolved_work_dir = work_dir.resolve()
try: try:
resolved_path.relative_to(resolved_work_dir) resolved_path.relative_to(resolved_work_dir)
except ValueError as _e: except ValueError:
if not is_in_allowed_dirs(resolved_path): if not is_in_allowed_dirs(resolved_path):
raise PathSecurityError(f"视频路径不在允许目录内: {video_path[:80]}") from _e raise PathSecurityError(f"视频路径不在允许目录内: {video_path[:80]}")
# URL类型路径不做本地路径校验(由下载阶段的SSRF防护负责) # URL类型路径不做本地路径校验(由下载阶段的SSRF防护负责)
# 但检查扩展名 # 但检查扩展名
else: else:
+1 -1
View File
@@ -356,7 +356,7 @@ def check_duplicate_task(self: Task, generated_video_id: str) -> dict:
except Exception as e: except Exception as e:
logger.error(f"Duplicate check failed for {generated_video_id}: {str(e)}") logger.error(f"Duplicate check failed for {generated_video_id}: {str(e)}")
session.rollback() session.rollback()
raise self.retry(exc=e, countdown=60) from e raise self.retry(exc=e, countdown=60)
finally: finally:
session.close() session.close()
import shutil import shutil
@@ -191,9 +191,9 @@ def _validate_audio_path(audio_path: str, work_dir: Path) -> None:
resolved_work_dir = work_dir.resolve() resolved_work_dir = work_dir.resolve()
try: try:
resolved_path.relative_to(resolved_work_dir) resolved_path.relative_to(resolved_work_dir)
except ValueError as _e: except ValueError:
if not is_in_allowed_dirs(resolved_path): if not is_in_allowed_dirs(resolved_path):
raise PathSecurityError(f"音频路径不在允许目录内: {audio_path[:80]}") from _e raise PathSecurityError(f"音频路径不在允许目录内: {audio_path[:80]}")
# URL类型路径不做本地路径校验(由下载阶段的SSRF防护负责) # URL类型路径不做本地路径校验(由下载阶段的SSRF防护负责)
# 但检查扩展名 # 但检查扩展名
else: else:
@@ -129,8 +129,8 @@ def safe_resolve_path(
if not allow_outside: if not allow_outside:
try: try:
full_path.relative_to(base_dir) full_path.relative_to(base_dir)
except ValueError as _e: except ValueError:
raise PathSecurityError(f"路径遍历检测:路径 '{path_str}' 超出基路径 '{base_dir}' 范围") from _e raise PathSecurityError(f"路径遍历检测:路径 '{path_str}' 超出基路径 '{base_dir}' 范围")
# 扩展名校验 # 扩展名校验
if allowed_extensions is not None: if allowed_extensions is not None:
+1 -1
View File
@@ -403,7 +403,7 @@ class PiPEngine:
input_args: list[str] = [] input_args: list[str] = []
current_label = base_label current_label = base_label
for i, (_input_label, layer, path) in enumerate(pip_sources): for i, (input_label, layer, path) in enumerate(pip_sources):
# 添加输入 # 添加输入
input_args.extend(["-i", str(path)]) input_args.extend(["-i", str(path)])
@@ -359,7 +359,7 @@ class StickerEngine:
image_stickers: list[ImageStickerConfig] = [] image_stickers: list[ImageStickerConfig] = []
image_paths: list[str] = [] image_paths: list[str] = []
for _, s in enumerate(stickers): for i, s in enumerate(stickers):
try: try:
sticker_type = s.get("type", "image") sticker_type = s.get("type", "image")
z = int(s.get("z_index", 10)) z = int(s.get("z_index", 10))
@@ -682,6 +682,6 @@ def _validate_subtitle_path(subtitle_path: str, work_dir: Path) -> None:
resolved_work_dir = work_dir.resolve() resolved_work_dir = work_dir.resolve()
try: try:
resolved_path.relative_to(resolved_work_dir) resolved_path.relative_to(resolved_work_dir)
except ValueError as _e: except ValueError:
if not is_in_allowed_dirs(resolved_path): if not is_in_allowed_dirs(resolved_path):
raise PathSecurityError(f"字幕路径不在允许目录内: {subtitle_path[:80]}") from _e raise PathSecurityError(f"字幕路径不在允许目录内: {subtitle_path[:80]}")
@@ -1363,37 +1363,23 @@ class UnifiedRenderService:
# 单 clip 层,直接使用预处理标签 # 单 clip 层,直接使用预处理标签
layer_output_labels[layer.role] = layer_labels[0] layer_output_labels[layer.role] = layer_labels[0]
else: else:
# 多 clip 层,用 TransitionEngine 构建转场链
out_label = f"{layer.role}_merged" out_label = f"{layer.role}_merged"
# 判断是否全部为硬切:是则用 concat filter,否则用 xfade 转场链 # 计算该层使用的转场时长(取首个非零值,否则用默认)
all_cut = all( layer_dur = 0.0
t is None or t == "" or str(t).lower() == "cut" for d in layer_transition_durations:
for t in layer_transitions[1:] # 第一个 clip 的转场忽略 if d > 0:
layer_dur = d
break
xfade_filter, _ = self._transition_engine.build_xfade_chain(
clip_durations=layer_durations,
clip_video_labels=layer_labels,
transitions=layer_transitions,
transition_duration=layer_dur if layer_dur > 0 else None,
output_label=out_label,
) )
if all_cut: if xfade_filter:
# 全硬切:用 concat filter,性能远优于 xfade filter_parts.append(xfade_filter)
concat_inputs = "".join(f"[{label}]" for label in layer_labels)
filter_parts.append(f"{concat_inputs}concat=n={len(layer_labels)}:v=1:a=0[{out_label}]")
logger.info(
"[unified-render] layer=%s clips=%d using concat (all hard-cut)",
layer.role,
len(layer_labels),
)
else:
# 有转场效果:用 TransitionEngine 构建 xfade 链
layer_dur = 0.0
for d in layer_transition_durations:
if d > 0:
layer_dur = d
break
xfade_filter, _ = self._transition_engine.build_xfade_chain(
clip_durations=layer_durations,
clip_video_labels=layer_labels,
transitions=layer_transitions,
transition_duration=layer_dur if layer_dur > 0 else None,
output_label=out_label,
)
if xfade_filter:
filter_parts.append(xfade_filter)
layer_output_labels[layer.role] = out_label layer_output_labels[layer.role] = out_label
# Step 3: 合成各层 # Step 3: 合成各层
@@ -187,7 +187,7 @@ class AssetAnalyzer:
if self._frames is not None: if self._frames is not None:
return self._frames return self._frames
frames: list[np.ndarray] = [] frames = []
info = self.get_video_info() info = self.get_video_info()
if info.duration <= 0: if info.duration <= 0:
@@ -398,7 +398,7 @@ class AssetAnalyzer:
run_ffmpeg(cmd, timeout=30) run_ffmpeg(cmd, timeout=30)
except Exception: except Exception:
# 音频提取失败,返回默认分析结果 # 音频提取失败,返回默认分析结果
return AudioAnalysis( # type: ignore[call-arg] return AudioAnalysis(
has_speech=False, has_speech=False,
speech_ratio=0.0, speech_ratio=0.0,
avg_volume=0.0, avg_volume=0.0,
+1 -13
View File
@@ -63,18 +63,6 @@ def compose_video(self, job_id: str, **kwargs):
resolver = get_render_engine_resolver() resolver = get_render_engine_resolver()
user_id = job.created_by_user_id or None user_id = job.created_by_user_id or None
engine = resolver.get_engine(user_id=user_id) engine = resolver.get_engine(user_id=user_id)
# 灰度期间打印详细 flag 配置,便于排查
config = resolver.get_config_snapshot()
logger.info(
"compose_video 引擎选择: job_id=%s engine=%s user_id=%s enabled=%s percentage=%s whitelist=%d default=%s",
job_id,
engine,
user_id,
config.get("enabled"),
config.get("percentage"),
len(config.get("whitelist", [])),
config.get("default_engine"),
)
if engine == "unified": if engine == "unified":
return _compose_with_unified_engine(self, job_service, job, plan_id, db) return _compose_with_unified_engine(self, job_service, job, plan_id, db)
@@ -90,7 +78,7 @@ def compose_video(self, job_id: str, **kwargs):
job_service.fail_job(job_id, str(exc)[:500]) job_service.fail_job(job_id, str(exc)[:500])
except Exception: except Exception:
logger.exception("更新 Job 失败状态时出错") logger.exception("更新 Job 失败状态时出错")
raise self.retry(exc=exc, countdown=60) from exc raise self.retry(exc=exc, countdown=60)
finally: finally:
db.close() db.close()
@@ -77,21 +77,9 @@ def _resolve_render_engine(user_id: str) -> str:
from video_processing.render_engine_resolver import get_render_engine_resolver from video_processing.render_engine_resolver import get_render_engine_resolver
resolver = get_render_engine_resolver() resolver = get_render_engine_resolver()
engine = resolver.get_engine(user_id=user_id) return resolver.get_engine(user_id=user_id)
# 灰度期间打印详细 flag 配置,便于排查
config = resolver.get_config_snapshot()
logger.info(
"edit_plan 引擎选择: user_id=%s engine=%s enabled=%s percentage=%s whitelist=%d default=%s",
user_id,
engine,
config.get("enabled"),
config.get("percentage"),
len(config.get("whitelist", [])),
config.get("default_engine"),
)
return engine
except Exception as exc: except Exception as exc:
logger.warning("获取渲染引擎配置失败,fallback 到 legacy: %s", exc, exc_info=True) logger.warning("获取渲染引擎配置失败,fallback 到 legacy: %s", exc)
return "legacy" return "legacy"
@@ -107,14 +95,6 @@ def _mark_plan_failed(plan_repo, plan_id, gen_task_repo, generation_task_id, err
gen_task.status = "failed" gen_task.status = "failed"
gen_task.error_message = error_msg gen_task.error_message = error_msg
gen_task.completed_at = datetime.now(timezone.utc) gen_task.completed_at = datetime.now(timezone.utc)
try:
gen_task.append_log(
stage="render_failed",
message=error_msg[:500],
level="ERROR",
)
except Exception:
pass
gen_task_repo.update(gen_task) gen_task_repo.update(gen_task)
@@ -168,13 +148,9 @@ def _finalize_render_success(
clip.mark_rendered() clip.mark_rendered()
clip_repo.update(clip) clip_repo.update(clip)
# 更新 EditPlan 状态为 completed + 回写实际渲染时长 + 结果数 # 更新 EditPlan 状态为 completed
plan.config["rendered_url"] = output_url or "" plan.config["rendered_url"] = output_url or ""
plan.config["rendered_storage_key"] = storage_key plan.config["rendered_storage_key"] = storage_key
if hasattr(plan, "total_duration") and duration > 0:
plan.total_duration = duration
if hasattr(plan, "result_count"):
plan.result_count = 1
plan.mark_completed() plan.mark_completed()
plan_repo.update(plan) plan_repo.update(plan)
@@ -184,15 +160,7 @@ def _finalize_render_success(
if gen_task: if gen_task:
gen_task.status = "completed" gen_task.status = "completed"
gen_task.progress = 100.0 gen_task.progress = 100.0
# 剪辑计划是多片段合成 1 个成片,result_count = 1 gen_task.result_count = len(rendered_clip_ids)
gen_task.result_count = 1
gen_task.append_log(
stage="render_complete",
message=f"渲染完成,输出时长 {duration:.1f}s",
level="INFO",
engine=engine,
clip_count=len(rendered_clip_ids),
)
gen_task.completed_at = datetime.now(timezone.utc) gen_task.completed_at = datetime.now(timezone.utc)
gen_task_repo.update(gen_task) gen_task_repo.update(gen_task)
@@ -397,13 +365,6 @@ def render_edit_plan(self, plan_id: str) -> dict:
if gen_task: if gen_task:
gen_task.status = "running" gen_task.status = "running"
gen_task.started_at = datetime.now(timezone.utc) gen_task.started_at = datetime.now(timezone.utc)
gen_task.append_log(
stage="render_start",
message=f"开始渲染,引擎 {engine},片段数 {len(clips)}",
level="INFO",
engine=engine,
clip_count=len(clips),
)
gen_task_repo.update(gen_task) gen_task_repo.update(gen_task)
# 3. 下载素材并构建 asset_path_map # 3. 下载素材并构建 asset_path_map
@@ -468,28 +429,9 @@ def render_edit_plan(self, plan_id: str) -> dict:
gen_task.status = "failed" gen_task.status = "failed"
gen_task.error_message = "所有片段素材下载失败" gen_task.error_message = "所有片段素材下载失败"
gen_task.completed_at = datetime.now(timezone.utc) gen_task.completed_at = datetime.now(timezone.utc)
gen_task.append_log(
stage="download_failed",
message="所有片段素材下载失败",
level="ERROR",
)
gen_task_repo.update(gen_task) gen_task_repo.update(gen_task)
return {"status": "error", "message": "所有片段素材下载失败"} return {"status": "error", "message": "所有片段素材下载失败"}
# 素材下载完成,记录日志
if generation_task_id:
gen_task = gen_task_repo.get(generation_task_id)
if gen_task:
gen_task.append_log(
stage="download_done",
message=f"素材下载完成,成功 {len(asset_path_map)} 个,失败 {len(failed_clip_ids)}",
level="INFO",
success_count=len(asset_path_map),
failed_count=len(failed_clip_ids),
)
gen_task.progress = 30.0
gen_task_repo.update(gen_task)
# 4. 根据引擎选择渲染方式 # 4. 根据引擎选择渲染方式
if engine == "unified": if engine == "unified":
result = _render_with_unified( result = _render_with_unified(
@@ -541,15 +483,6 @@ def render_edit_plan(self, plan_id: str) -> dict:
gen_task.status = "failed" gen_task.status = "failed"
gen_task.error_message = f"渲染异常: {type(exc).__name__}: {exc}" gen_task.error_message = f"渲染异常: {type(exc).__name__}: {exc}"
gen_task.completed_at = datetime.now(timezone.utc) gen_task.completed_at = datetime.now(timezone.utc)
try:
gen_task.append_log(
stage="render_failed",
message=f"渲染异常: {type(exc).__name__}: {str(exc)[:500]}",
level="ERROR",
exception_type=type(exc).__name__,
)
except Exception:
pass
gen_task_repo.update(gen_task) gen_task_repo.update(gen_task)
logger.info( logger.info(
"GenerationTask 已标记为 failed: task_id=%s plan_id=%s", "GenerationTask 已标记为 failed: task_id=%s plan_id=%s",
@@ -560,6 +493,6 @@ def render_edit_plan(self, plan_id: str) -> dict:
logger.warning( logger.warning(
"更新 GenerationTask 失败状态时异常: task_id=%s error=%s", generation_task_id, e, exc_info=True "更新 GenerationTask 失败状态时异常: task_id=%s error=%s", generation_task_id, e, exc_info=True
) )
raise self.retry(exc=exc, countdown=60) from exc raise self.retry(exc=exc, countdown=60)
return {"status": "error", "message": "数据库连接失败"} return {"status": "error", "message": "数据库连接失败"}
+4 -17
View File
@@ -787,23 +787,10 @@ def _resolve_render_engine(user_id: str) -> str:
from video_processing.render_engine_resolver import get_render_engine_resolver from video_processing.render_engine_resolver import get_render_engine_resolver
resolver = get_render_engine_resolver() resolver = get_render_engine_resolver()
engine = resolver.get_engine(user_id=user_id) return resolver.get_engine(user_id=user_id)
# 灰度期间打印详细 flag 配置,便于排查
config = resolver.get_config_snapshot()
logger.info(
"[渲染引擎] flag 解析: user_id=%s engine=%s enabled=%s percentage=%s whitelist=%d default=%s",
user_id,
engine,
config.get("enabled"),
config.get("percentage"),
len(config.get("whitelist", [])),
config.get("default_engine"),
)
return engine
except Exception as exc: except Exception as exc:
# 异常时 fallback 到 legacy(保守策略,与 edit_plan_generation 一致) logger.warning("获取渲染引擎配置失败,fallback 到 unified: %s", exc)
logger.warning("获取渲染引擎配置失败,fallback 到 legacy: %s", exc, exc_info=True) return ENGINE_UNIFIED
return ENGINE_LEGACY
# ── 旧引擎渲染(FFmpeg filter_complex) ──────────────────────────────────────── # ── 旧引擎渲染(FFmpeg filter_complex) ────────────────────────────────────────
@@ -1418,7 +1405,7 @@ def generate_video(self, task_id: str) -> dict:
_repo = SQLAlchemyGenerationTaskRepository(_session) _repo = SQLAlchemyGenerationTaskRepository(_session)
gen_task = _repo.get(task_id) gen_task = _repo.get(task_id)
if gen_task: if gen_task:
gen_task.append_log( # type: ignore[misc] gen_task.append_log(
"任务失败", "任务失败",
str(error), str(error),
level="ERROR", level="ERROR",
+3 -3
View File
@@ -70,13 +70,13 @@ def extract_media_metadata(file_url: str, media_type: str) -> dict:
metadata["height"] = int(stream.get("height", 0)) metadata["height"] = int(stream.get("height", 0))
metadata["codec"] = stream.get("codec_name", "") metadata["codec"] = stream.get("codec_name", "")
metadata["fps"] = ( metadata["fps"] = (
_safe_parse_fps(stream.get("r_frame_rate", "0/1")) if stream.get("r_frame_rate") else 0 # type: ignore[assignment] _safe_parse_fps(stream.get("r_frame_rate", "0/1")) if stream.get("r_frame_rate") else 0
) )
break break
# 提取格式信息 # 提取格式信息
format_info = probe_data.get("format", {}) format_info = probe_data.get("format", {})
metadata["duration"] = float(format_info.get("duration", 0)) # type: ignore[assignment] metadata["duration"] = float(format_info.get("duration", 0))
metadata["size_bytes"] = int(format_info.get("size", 0)) metadata["size_bytes"] = int(format_info.get("size", 0))
metadata["bitrate"] = int(format_info.get("bit_rate", 0)) metadata["bitrate"] = int(format_info.get("bit_rate", 0))
@@ -96,7 +96,7 @@ def extract_media_metadata(file_url: str, media_type: str) -> dict:
if hasattr(img, "_getexif") and img._getexif(): if hasattr(img, "_getexif") and img._getexif():
exif = img._getexif() exif = img._getexif()
if exif: if exif:
metadata["exif"] = {k: str(v) for k, v in exif.items() if isinstance(v, (str, int, float))} # type: ignore[assignment] metadata["exif"] = {k: str(v) for k, v in exif.items() if isinstance(v, (str, int, float))}
except ImportError: except ImportError:
logger.warning("Pillow not available for image metadata extraction") logger.warning("Pillow not available for image metadata extraction")
except Exception as e: except Exception as e:
@@ -63,7 +63,7 @@ def process_tts_synthesis(self: Task, job_id: str) -> dict:
if session is not None: if session is not None:
session.rollback() session.rollback()
# 超时重试,指数退避 # 超时重试,指数退避
raise self.retry(exc=e, countdown=30) from e raise self.retry(exc=e, countdown=30)
except CosyVoiceError as e: except CosyVoiceError as e:
logger.error(f"TTS synthesis failed for {job_id}: {e}") logger.error(f"TTS synthesis failed for {job_id}: {e}")
@@ -139,7 +139,7 @@ def process_tts_segment_synthesis(self: Task, job_id: str) -> dict:
logger.warning(f"TTS segment synthesis timeout for {job_id}: {e}") logger.warning(f"TTS segment synthesis timeout for {job_id}: {e}")
if session is not None: if session is not None:
session.rollback() session.rollback()
raise self.retry(exc=e, countdown=60) from e raise self.retry(exc=e, countdown=60)
except CosyVoiceError as e: except CosyVoiceError as e:
logger.error(f"TTS segment synthesis failed for {job_id}: {e}") logger.error(f"TTS segment synthesis failed for {job_id}: {e}")
+1 -1
View File
@@ -73,7 +73,7 @@ def process_voice_clone(self: Task, profile_id: str) -> dict:
if session is not None: if session is not None:
session.rollback() session.rollback()
# 超时属于临时性故障,延迟 30 秒后重试 # 超时属于临时性故障,延迟 30 秒后重试
raise self.retry(exc=e, countdown=30) from e raise self.retry(exc=e, countdown=30)
except CosyVoiceError as e: except CosyVoiceError as e:
logger.error(f"Voice clone failed for {profile_id}: {e}") logger.error(f"Voice clone failed for {profile_id}: {e}")
@@ -105,7 +105,7 @@ def extract_voice_task(self: Task, asset_id: str) -> dict:
except Exception as e: except Exception as e:
logger.error(f"Voice extraction failed for {asset_id}: {str(e)}") logger.error(f"Voice extraction failed for {asset_id}: {str(e)}")
session.rollback() session.rollback()
raise self.retry(exc=e, countdown=60) from e raise self.retry(exc=e, countdown=60)
finally: finally:
session.close() session.close()
import shutil import shutil
@@ -141,7 +141,7 @@ def extract_background_task(self: Task, asset_id: str) -> dict:
except Exception as e: except Exception as e:
logger.error(f"Background extraction failed for {asset_id}: {str(e)}") logger.error(f"Background extraction failed for {asset_id}: {str(e)}")
session.rollback() session.rollback()
raise self.retry(exc=e, countdown=60) from e raise self.retry(exc=e, countdown=60)
finally: finally:
session.close() session.close()
import shutil import shutil
-104
View File
@@ -1,104 +0,0 @@
# CI 大量失败根因排查报告
**排查时间:** 2026-07-13
**排查人:** 构建服务器运维Agent
**范围:** 最近15次 CI runPR #258~#265 + develop 分支多次 push
## 一、整体概况
最近 20 次 CI run 中 16 次失败,失败率 **80%**。失败集中在 3 个 Job
| Job | 失败率 | 根因类型 |
|-----|--------|----------|
| Validate Code Quality | 100% | black 代码格式检查失败 |
| Unit Tests | 100% | 测试断言未同步国际化改动 |
| Integration Tests | 100% | 密码重置接口变更未同步测试 |
| Frontend Lint | 20% | 各 PR 代码质量问题 |
**结论:3 个全局性失败点导致所有 PR CI 全红,不是代码本身问题,是基础设施/测试用例滞后。**
---
## 二、详细根因分析
### 1. Validate — black 格式检查失败
**现象:**
```
would reformat scripts/check_migration_safety.py
1 file would be reformatted, 369 files would be left unchanged.
Oh no! 💥 💔 💥
```
**根因:**
`scripts/check_migration_safety.py` 文件不符合 black 格式化规范。该文件是最近新增的迁移安全检查脚本,提交前未本地跑 black 格式化。
**影响范围:** 所有 PR 及 develop 分支,全量失败。
**修复方案:**
```bash
black scripts/check_migration_safety.py
```
---
### 2. Unit Tests — 1 个用例失败
**现象:**
```
FAILED tests/unit/test_asset_library_delete.py::TestDeleteAssetLibrary::test_delete_library_access_denied
AssertionError: assert 'Access denied' in '无权访问该项目'
```
**统计:** 1442 passed, 1 failed
**根因:**
项目之前做了国际化(i18n)改造,错误信息从英文改成了中文,但对应的单元测试断言仍然检查英文 "Access denied",导致断言失败。
**影响范围:** 所有 PR 及 develop 分支,全量失败。
**修复方案:**
修改 `tests/unit/test_asset_library_delete.py` 中的断言,将 `'Access denied'` 改为 `'无权访问该项目'`,或改为断言 HTTP 状态码(403)而不是错误消息文本。
---
### 3. Integration Tests — 1 个用例失败
**现象:**
```
FAILED tests/integration/test_auth.py::TestPasswordReset::test_request_password_reset_success
assert 404 in (200, 202)
```
**统计:** 45 passed, 1 failed, 13 deselected, 2 rerun
**根因:**
密码重置请求接口(`POST /auth/password-reset/request` 或类似路由)返回 404,说明该接口已被移除、路由变更,或对应的功能模块暂时被注释/下线。
**影响范围:** 所有 PR 及 develop 分支,全量失败。
**修复方案:**
- 如果接口确实下线了:删除或 skip 这个测试用例
- 如果是路由改了:更新测试中的 API 路径
- 如果是功能待开发:标记为 `@pytest.mark.skip` 并加上 TODO
---
## 三、修复优先级
| 优先级 | 问题 | 修复难度 | 预估时间 |
|--------|------|----------|----------|
| P0 | black 格式检查失败 | ⭐ | 5分钟 |
| P0 | 单元测试国际化断言失败 | ⭐ | 10分钟 |
| P1 | 集成测试密码重置接口404 | ⭐⭐ | 30分钟(需确认接口状态) |
**建议:** 先修前两个 P0(能让 2/3 的 job 变绿),再处理密码重置那个。
---
## 四、Runner 执行情况观察
- 当前 9 个 Runner 全部在线(构建服务器 4 个 + 新服务器 5 个)
- 失败的 Job 都是在构建服务器的 Runner 上执行的(xiaoxia-ci-runner-2/3 等)
- 新服务器 5 个 Runner 目前全部空闲(标签修复后首次接任务可能需要时间)
- 并发能力充足,瓶颈在代码/测试本身,不在 Runner 资源
-136
View File
@@ -1,136 +0,0 @@
# 三台服务器 Runner 分工规划
**制定日期:** 2026-07-13
**状态:** 规划中
---
## 一、现状总览
当前共 9 个 Gitea Actions Runner,分布在 3 台服务器上:
| 服务器 | IP | 配置 | Runner 数量 | 当前状态 |
|--------|-----|------|-------------|----------|
| 构建服务器 | 114.55.236.178 | 4核 / 7.1G RAM / 49G NVMe | 4个(ID: 8, 42, 46, 47 | ✅ 在线 |
| 新CI服务器 | 116.62.226.203 | 8核 / 14G RAM | 5个(ID: 58-62 | ✅ 在线 |
| 业务服务器 | 47.98.113.167 | - | 0个(旧3个已下线) | ⚠️ 待规划 |
**所有 Runner 共用标签:** `saas`, `runtime-builder`, `host`, `ubuntu-latest`
---
## 二、问题分析
### 2.1 标签无区分
所有 Runner 标签完全一致,CI 任务随机分配到任意 Runner,导致:
- 构建任务(Build)可能跑到配置低的机器上,构建慢
- 代码检查任务占着构建服务器,影响构建速度
- 业务服务器跑 CI 影响线上服务稳定性
### 2.2 资源浪费
- 新服务器 8核14G 跑 validate/lint 有点大材小用
- 构建服务器 4核7G 跑 Docker 构建偏紧张
---
## 三、规划方案
### 3.1 分工原则
| 服务器 | 角色 | 主要任务类型 | 标签策略 |
|--------|------|-------------|----------|
| **构建服务器** (114.55.236.178) | 构建专机 | Build Staging / Build Production / Docker 镜像构建 | 保留 `saas` + `host`,新增 `build-only` |
| **新CI服务器** (116.62.226.203) | 代码检查专机 | Validate / Unit Tests / Integration Tests / Frontend Lint | 保留 `saas` + `host`,新增 `ci-check` |
| **业务服务器** (47.98.113.167) | 部署专机 | Deploy Staging / Deploy Production / E2E Tests | 保留 `saas` + `host`,新增 `deploy-only` |
### 3.2 具体配置
#### 构建服务器(4个 Runner
- **数量:** 3个(从4个缩减,释放资源给构建缓存)
- **标签:** `saas`, `host`, `build-only`, `ubuntu-latest`
- **负责 Job**
- `build-staging`
- `build-production-runtime-images`
- 其他需要 Docker buildx 的任务
#### 新CI服务器(5个 Runner
- **数量:** 5个(保持不变)
- **标签:** `saas`, `host`, `ci-check`, `ubuntu-latest`
- **负责 Job**
- `validate`
- `unit-tests`
- `integration-tests`
- `frontend-lint`
- 安全扫描(gitleaks / pip-audit / vulture 等)
#### 业务服务器(1-2个 Runner
- **数量:** 1-2个(逐步替换旧的3个)
- **标签:** `saas`, `host`, `deploy-only`, `ubuntu-latest`
- **负责 Job**
- `deploy-staging`
- `deploy-production`
- `staging-e2e` / `production-e2e`
- `staging-api-tests`
---
## 四、实施步骤
### Phase 1: 标签打标(低风险,立即做)
1. 新服务器 5 个 Runner 添加 `ci-check` 标签
2. 构建服务器保留 3 个 Runner,添加 `build-only` 标签
3. 业务服务器部署 1 个新 Runner,标签 `deploy-only`
### Phase 2: Job 路由调整(中风险,逐步来)
1. validate / unit-tests / integration-tests / frontend-lint 改为 `runs-on: ci-check`
2. build-staging / build-production 改为 `runs-on: build-only`
3. deploy-* / e2e 改为 `runs-on: deploy-only`
### Phase 3: 旧 Runner 下线
- 业务服务器旧的 3 个 Runner 确认无任务后下线
- 构建服务器多余的 1 个 Runner 迁移到新服务器
---
## 五、并发配置优化建议
### 5.1 当前并发情况
- 首发并行 Jobvalidate + unit-tests + frontend-lint3个并行)
- integration-tests 依赖 validate(串行,浪费资源)
- 无 concurrency 限制,同一分支多次 push 会重复跑
### 5.2 优化建议
**1. integration-tests 改为与 unit-tests 并行**
```yaml
# 当前
integration-tests:
needs: validate # 没必要等validate
# 优化后
integration-tests:
needs: [] # 直接和unit-tests并行跑
```
**2. 增加分支级 concurrency,取消重复构建**
```yaml
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
```
同一 PR 多次 push 时,取消旧的构建,只跑最新的。
**3. Build Staging 移出 PR 门禁**
- 已在阶段二优化中完成(PR #245
- Build Staging 只在 develop/main 上异步构建
---
## 六、预期收益
| 指标 | 当前 | 优化后 | 提升 |
|------|------|--------|------|
| PR CI 总时长 | ~8-12分钟 | ~4-6分钟 | ⏱️ 缩短 40-50% |
| 构建速度 | 可能抢到慢机器 | 固定高配构建机 | 🚀 更稳定更快 |
| 线上稳定性 | CI和业务抢资源 | 部署独立Runner | 🛡️ 隔离保障 |
| Runner 利用率 | 随机分配 | 按任务类型调度 | 📈 更合理 |
-8
View File
@@ -990,14 +990,6 @@
"type": "FLOAT", "type": "FLOAT",
"unique": false "unique": false
}, },
{
"index": false,
"name": "result_count",
"nullable": false,
"primary_key": false,
"type": "INTEGER",
"unique": false
},
{ {
"index": false, "index": false,
"name": "config", "name": "config",
-3
View File
@@ -100,7 +100,6 @@ class SQLAlchemyEditPlanRepository:
name=plan.name, name=plan.name,
status=plan.status, status=plan.status,
total_duration=plan.total_duration, total_duration=plan.total_duration,
result_count=plan.result_count,
source_edit_plan_id=plan.source_edit_plan_id or None, source_edit_plan_id=plan.source_edit_plan_id or None,
project_id=plan.project_id or "", project_id=plan.project_id or "",
created_by_user_id=plan.created_by_user_id or "", created_by_user_id=plan.created_by_user_id or "",
@@ -120,7 +119,6 @@ class SQLAlchemyEditPlanRepository:
model.name = plan.name model.name = plan.name
model.status = plan.status model.status = plan.status
model.total_duration = plan.total_duration model.total_duration = plan.total_duration
model.result_count = plan.result_count
model.source_edit_plan_id = plan.source_edit_plan_id or None model.source_edit_plan_id = plan.source_edit_plan_id or None
model.project_id = plan.project_id or "" model.project_id = plan.project_id or ""
model.created_by_user_id = plan.created_by_user_id or "" model.created_by_user_id = plan.created_by_user_id or ""
@@ -154,7 +152,6 @@ class SQLAlchemyEditPlanRepository:
name=model.name, name=model.name,
status=EditPlanStatus(model.status) if model.status else EditPlanStatus.DRAFT, status=EditPlanStatus(model.status) if model.status else EditPlanStatus.DRAFT,
total_duration=model.total_duration or 0.0, total_duration=model.total_duration or 0.0,
result_count=int(model.result_count or 0),
source_edit_plan_id=model.source_edit_plan_id or "", source_edit_plan_id=model.source_edit_plan_id or "",
project_id=model.project_id or "", project_id=model.project_id or "",
created_by_user_id=model.created_by_user_id or "", created_by_user_id=model.created_by_user_id or "",
+1 -3
View File
@@ -1,10 +1,9 @@
from datetime import datetime, timezone from datetime import datetime, timezone
from typing import Any
from sqlalchemy import JSON, Boolean, Column, DateTime, Float, Integer, String, Text, UniqueConstraint from sqlalchemy import JSON, Boolean, Column, DateTime, Float, Integer, String, Text, UniqueConstraint
from sqlalchemy.orm import declarative_base from sqlalchemy.orm import declarative_base
Base: Any = declarative_base() Base = declarative_base()
class UserModel(Base): class UserModel(Base):
@@ -149,7 +148,6 @@ class EditPlanModel(Base):
name = Column(String(200), nullable=False) name = Column(String(200), nullable=False)
status = Column(String(20), nullable=False, default="draft", index=True) status = Column(String(20), nullable=False, default="draft", index=True)
total_duration = Column(Float, nullable=False, default=0.0) total_duration = Column(Float, nullable=False, default=0.0)
result_count = Column(Integer, nullable=False, default=0)
config = Column(JSON, nullable=False, default=dict) config = Column(JSON, nullable=False, default=dict)
source_edit_plan_id = Column(String(36), nullable=True, index=True) source_edit_plan_id = Column(String(36), nullable=True, index=True)
project_id = Column(String(36), nullable=False, default="", index=True) project_id = Column(String(36), nullable=False, default="", index=True)
+2 -2
View File
@@ -20,7 +20,7 @@ class ListAssetLibrariesUseCase:
def execute(self, project_id: str) -> list[AssetLibrary]: def execute(self, project_id: str) -> list[AssetLibrary]:
if not project_id.strip(): if not project_id.strip():
raise ValueError("project_id 不能为空") raise ValueError("project_id 不能为空")
return self.asset_library_repository.find_by_project(project_id.strip()) # type: ignore[return-value] return self.asset_library_repository.find_by_project(project_id.strip())
class CreateAssetLibraryUseCase: class CreateAssetLibraryUseCase:
@@ -33,4 +33,4 @@ class CreateAssetLibraryUseCase:
name=command.name, name=command.name,
kind=command.kind, kind=command.kind,
) )
return self.asset_library_repository.create(library) # type: ignore[return-value] return self.asset_library_repository.create(library)
+3 -3
View File
@@ -147,10 +147,10 @@ class JWTService:
algorithms=[self.config.ALGORITHM], algorithms=[self.config.ALGORITHM],
) )
return payload return payload
except ExpiredSignatureError as _e: except ExpiredSignatureError:
raise ExpiredSignatureError("Token has expired") from _e raise ExpiredSignatureError("Token has expired")
except InvalidTokenError as e: except InvalidTokenError as e:
raise InvalidTokenError(f"Invalid token: {str(e)}") from e raise InvalidTokenError(f"Invalid token: {str(e)}")
def verify_access_token(self, token: str) -> Dict[str, Any]: def verify_access_token(self, token: str) -> Dict[str, Any]:
""" """
+1 -1
View File
@@ -22,7 +22,7 @@ class SubmitClassificationJobUseCase:
id=uuid4().hex, id=uuid4().hex,
project_id=command.project_id, project_id=command.project_id,
asset_id=command.asset_id, asset_id=command.asset_id,
status="pending", # type: ignore[arg-type] status="pending",
classification="", classification="",
confidence=0.0, confidence=0.0,
error_message="", error_message="",
+4 -4
View File
@@ -15,7 +15,7 @@ from __future__ import annotations
import logging import logging
import time import time
from dataclasses import dataclass from dataclasses import dataclass
from typing import Any, Callable, Optional from typing import Any, Optional
import httpx import httpx
@@ -102,7 +102,7 @@ class CosyVoiceService:
model: str = "", model: str = "",
clone_model: str = "", clone_model: str = "",
http_client: Optional[httpx.Client] = None, http_client: Optional[httpx.Client] = None,
audio_url_signer: Optional[Callable[[str], str]] = None, audio_url_signer: Optional[callable] = None,
) -> None: ) -> None:
"""初始化 CosyVoice 服务. """初始化 CosyVoice 服务.
@@ -656,8 +656,8 @@ class CosyVoiceService:
code = body.get("code", "") code = body.get("code", "")
message = body.get("message", "") message = body.get("message", "")
raise CosyVoiceError(f"CosyVoice API 参数错误: HTTP 400, " f"code={code}, message={message}") raise CosyVoiceError(f"CosyVoice API 参数错误: HTTP 400, " f"code={code}, message={message}")
except ValueError as _e: except ValueError:
raise CosyVoiceError(f"CosyVoice API 调用失败: HTTP 400, body={body_text}") from _e raise CosyVoiceError(f"CosyVoice API 调用失败: HTTP 400, body={body_text}")
elif response.status_code >= 500: elif response.status_code >= 500:
# 服务端错误,可重试 # 服务端错误,可重试
last_error = CosyVoiceError(f"CosyVoice API 服务端错误: HTTP {response.status_code}") last_error = CosyVoiceError(f"CosyVoice API 服务端错误: HTTP {response.status_code}")
+1 -1
View File
@@ -40,7 +40,7 @@ class CreateGenerationTaskUseCase:
asset_ids=command.asset_ids, asset_ids=command.asset_ids,
title_ids=command.title_ids, title_ids=command.title_ids,
voice_ids=command.voice_ids, voice_ids=command.voice_ids,
status="pending", # type: ignore[arg-type] status="pending",
progress=0.0, progress=0.0,
result_count=0, result_count=0,
error_message="", error_message="",
+4 -4
View File
@@ -78,16 +78,16 @@ class AudioMerger:
run_ffmpeg(cmd, timeout=120) run_ffmpeg(cmd, timeout=120)
except CalledProcessError as e: except CalledProcessError as e:
logger.error(f"FFmpeg 合并失败: stderr={e.stderr}") logger.error(f"FFmpeg 合并失败: stderr={e.stderr}")
raise AudioMergeError(f"FFmpeg 合并失败: {str(e)[:500]}") from e raise AudioMergeError(f"FFmpeg 合并失败: {str(e)[:500]}")
with open(output_path, "rb") as f: with open(output_path, "rb") as f:
return f.read() return f.read()
except TimeoutExpired as _e: except TimeoutExpired:
raise AudioMergeError("FFmpeg 合并超时(120 秒)") from _e raise AudioMergeError("FFmpeg 合并超时(120 秒)")
except AudioMergeError: except AudioMergeError:
raise raise
except Exception as e: except Exception as e:
raise AudioMergeError(f"音频合并失败: {e}") from e raise AudioMergeError(f"音频合并失败: {e}")
finally: finally:
shutil.rmtree(temp_dir, ignore_errors=True) shutil.rmtree(temp_dir, ignore_errors=True)
Executable → Regular
-10
View File
@@ -42,7 +42,6 @@ class EditPlan:
name: str name: str
status: EditPlanStatus = EditPlanStatus.DRAFT status: EditPlanStatus = EditPlanStatus.DRAFT
total_duration: float = 0.0 total_duration: float = 0.0
result_count: int = 0
source_edit_plan_id: str = "" source_edit_plan_id: str = ""
project_id: str = "" project_id: str = ""
created_by_user_id: str = "" created_by_user_id: str = ""
@@ -58,7 +57,6 @@ class EditPlan:
*, *,
config: dict[str, Any] | None = None, config: dict[str, Any] | None = None,
total_duration: float = 0.0, total_duration: float = 0.0,
result_count: int = 0,
source_edit_plan_id: str = "", source_edit_plan_id: str = "",
project_id: str = "", project_id: str = "",
created_by_user_id: str = "", created_by_user_id: str = "",
@@ -75,7 +73,6 @@ class EditPlan:
name=clean_name, name=clean_name,
status=EditPlanStatus.DRAFT, status=EditPlanStatus.DRAFT,
total_duration=total_duration, total_duration=total_duration,
result_count=result_count,
source_edit_plan_id=source_edit_plan_id.strip(), source_edit_plan_id=source_edit_plan_id.strip(),
project_id=project_id.strip(), project_id=project_id.strip(),
created_by_user_id=created_by_user_id.strip(), created_by_user_id=created_by_user_id.strip(),
@@ -110,13 +107,6 @@ class EditPlan:
self.status = EditPlanStatus.FAILED self.status = EditPlanStatus.FAILED
self.updated_at = datetime.now(timezone.utc) self.updated_at = datetime.now(timezone.utc)
def resume_editing(self) -> None:
"""重新进入编辑状态(完成/失败后重新编辑)"""
if self.status not in (EditPlanStatus.COMPLETED, EditPlanStatus.FAILED):
raise ValueError(f"只有 completed/failed 状态的计划可以重新编辑,当前状态: {self.status}")
self.status = EditPlanStatus.EDITING
self.updated_at = datetime.now(timezone.utc)
def reset_to_draft(self) -> None: def reset_to_draft(self) -> None:
"""重置为草稿状态(仅从 failed 状态可重置)""" """重置为草稿状态(仅从 failed 状态可重置)"""
if self.status != EditPlanStatus.FAILED: if self.status != EditPlanStatus.FAILED:
+2 -2
View File
@@ -170,8 +170,8 @@ class GenerationTask:
if isinstance(new_status, str): if isinstance(new_status, str):
try: try:
new_status = GenerationTaskStatus(new_status) new_status = GenerationTaskStatus(new_status)
except ValueError as _e: except ValueError:
raise ValueError(f"无效状态: {new_status}") from _e raise ValueError(f"无效状态: {new_status}")
allowed = _VALID_TRANSITIONS.get(self.status, set()) allowed = _VALID_TRANSITIONS.get(self.status, set())
if new_status not in allowed: if new_status not in allowed:
+4 -4
View File
@@ -147,8 +147,8 @@ class Job:
if isinstance(job_type, str): if isinstance(job_type, str):
try: try:
job_type = JobType(job_type) job_type = JobType(job_type)
except ValueError as _e: except ValueError:
raise ValueError(f"不支持的任务类型: {job_type}") from _e raise ValueError(f"不支持的任务类型: {job_type}")
return cls( return cls(
id=uuid4().hex, id=uuid4().hex,
@@ -182,8 +182,8 @@ class Job:
if isinstance(new_status, str): if isinstance(new_status, str):
try: try:
new_status = JobStatus(new_status) new_status = JobStatus(new_status)
except ValueError as _e: except ValueError:
raise ValueError(f"无效状态: {new_status}") from _e raise ValueError(f"无效状态: {new_status}")
allowed = _VALID_TRANSITIONS.get(self.status, set()) allowed = _VALID_TRANSITIONS.get(self.status, set())
if new_status not in allowed: if new_status not in allowed:
+2 -2
View File
@@ -193,8 +193,8 @@ class TTSJob:
if isinstance(new_status, str): if isinstance(new_status, str):
try: try:
new_status = TTSJobStatus(new_status) new_status = TTSJobStatus(new_status)
except ValueError as _e: except ValueError:
raise ValueError(f"无效状态: {new_status}") from _e raise ValueError(f"无效状态: {new_status}")
allowed = _VALID_TRANSITIONS.get(self.status, set()) allowed = _VALID_TRANSITIONS.get(self.status, set())
if new_status not in allowed: if new_status not in allowed:
+2 -2
View File
@@ -177,8 +177,8 @@ class VoiceCloneProfile:
if isinstance(new_status, str): if isinstance(new_status, str):
try: try:
new_status = VoiceCloneStatus(new_status) new_status = VoiceCloneStatus(new_status)
except ValueError as _e: except ValueError:
raise ValueError(f"无效状态: {new_status}") from _e raise ValueError(f"无效状态: {new_status}")
allowed = _VALID_TRANSITIONS.get(self.status, set()) allowed = _VALID_TRANSITIONS.get(self.status, set())
if new_status not in allowed: if new_status not in allowed:
+2 -2
View File
@@ -114,7 +114,7 @@ class SharedStorageService:
self.bucket.put_object(storage_key, file_or_path, headers={"Content-Type": content_type}) self.bucket.put_object(storage_key, file_or_path, headers={"Content-Type": content_type})
return f"{self.public_url}/{storage_key}" return f"{self.public_url}/{storage_key}"
except Exception as e: except Exception as e:
raise Exception(f"Failed to upload file to OSS: {e}") from e raise Exception(f"Failed to upload file to OSS: {e}")
def get_url(self, storage_key: str) -> str: def get_url(self, storage_key: str) -> str:
"""Get public URL for a file.""" """Get public URL for a file."""
@@ -129,7 +129,7 @@ class SharedStorageService:
os.makedirs(os.path.dirname(local_path), exist_ok=True) os.makedirs(os.path.dirname(local_path), exist_ok=True)
self.bucket.get_object_to_file(storage_key, local_path) self.bucket.get_object_to_file(storage_key, local_path)
except Exception as e: except Exception as e:
raise Exception(f"Failed to download file from OSS: {e}") from e raise Exception(f"Failed to download file from OSS: {e}")
def get_download_url(self, storage_key_or_url: str, expires_seconds: int = 3600) -> str: def get_download_url(self, storage_key_or_url: str, expires_seconds: int = 3600) -> str:
"""Get signed download URL.""" """Get signed download URL."""
+12 -17
View File
@@ -63,23 +63,21 @@ exclude = [
".next", ".next",
"dist", "dist",
"build", "build",
"hostexecutor",
] ]
[tool.ruff.lint] [tool.ruff.lint]
# 正式替换 flake8规则集与原 flake8 完全对齐 # 当前阶段:摸底模式,规则集与原flake8对齐
# 后续迭代计划: # 后续迭代计划:
# Phase 2: 加入 B (flake8-bugbear),修完后升级为阻断级 # Phase 1: 修完 bugbear 后正式替换 flake8
# Phase 3: 启用 UP(pyupgrade) + SIM(simplify) # Phase 2: 启用 UP(pyupgrade) + SIM(simplify)
# Phase 4: 启用 RET(return) + ARG(unused-args) # Phase 3: 启用 RET(return) + ARG(unused-args)
select = [ select = [
"E", # pycodestyle errors(同 flake8 "E", # pycodestyle errors(同flake8
"F", # pyflakes(同 flake8 "F", # pyflakes(同flake8
"W", # pycodestyle warnings(同 flake8 "W", # pycodestyle warnings(同flake8
"B", # flake8-bugbearP0-5 Step 2 已完成修复 "B", # flake8-bugbear新增,摸底用
] ]
# 与原 setup.cfg + .flake8 的 flake8 配置完全对齐 # 与原 setup.cfg flake8 配置对齐,确保不新增阻断
# 注意:W503 在 ruff≥0.14 中已被移除(行为变默认),故不列入
ignore = [ ignore = [
"E203", "E203",
"E501", # line-too-longblack管) "E501", # line-too-longblack管)
@@ -88,19 +86,16 @@ ignore = [
"E722", # bare-except "E722", # bare-except
"W291", "W291",
"W293", "W293",
"B008", # function-call-in-default-argumentFastAPI 依赖注入模式,大量使用)
"F401", # unused-import "F401", # unused-import
"F403", "F403",
"F405", "F405",
"F841", # unused-variable "F841", # unused-variable
"B008", # do-not-perform-callback-from-argfastapi依赖注入)
] ]
[tool.ruff.lint.per-file-ignores] [tool.ruff.lint.per-file-ignores]
"__init__.py" = ["F401", "F403", "F405"] "__init__.py" = ["F401", "F403", "F405"]
"tests/*" = ["E402", "F401", "F821", "F841"] "tests/*" = ["E402", "F401", "F841"]
"packages/ports/*" = ["E301"] # E704 在 ruff≥0.14 已移除 "packages/ports/*" = ["E301"]
"apps/api/app/api/routes/auth.py" = ["ALL"]
"apps/api/app/api/routes/workspaces.py" = ["ALL"]
"apps/api/app/middleware/auth.py" = ["ALL"]
"apps/*/migrations/*" = ["ALL"] "apps/*/migrations/*" = ["ALL"]
"alembic/*" = ["ALL"] "alembic/*" = ["ALL"]
Executable → Regular
+1 -1
View File
@@ -3,7 +3,7 @@
# 代码质量 # 代码质量
black==26.5.1 black==26.5.1
isort==8.0.1 isort==8.0.1
ruff==0.14.0 flake8==7.3.0
bandit==1.9.4 bandit==1.9.4
# 测试 # 测试
+10 -75
View File
@@ -1,108 +1,43 @@
#!/bin/bash #!/bin/bash
# 自动合并通过 CI 检查的 PR # 自动合并通过 CI 检查的 PR
# 用法: ./scripts/auto_merge_prs.sh [target_branch] # 用法: ./scripts/auto_merge_prs.sh [target_branch]
#
# 合并前必须验证的 CI 检查项:
# - CI/CD Pipeline / Validate Code Quality And Tests (push)
# - CI/CD Pipeline / Frontend Lint (push)
# 只有两个检查项均为 success 状态才允许合并
GITEA_API="${GITEA_API_URL:-https://git.xiaoxiajianji.com/api/v1}" GITEA_API="https://git.xiaoxiajianji.com/api/v1"
TOKEN="${GITEA_API_TOKEN:?Please set GITEA_API_TOKEN environment variable}" TOKEN="${GITEA_API_TOKEN:?Please set GITEA_API_TOKEN environment variable}"
REPO="xiaoxia/xiaoxia-saas" REPO="xiaoxia/xiaoxia-saas"
TARGET_BRANCH="${1:-develop}" TARGET_BRANCH="${1:-develop}"
# 必需的 CI 检查项(context 名称前缀匹配,避免 pipeline 名称变化导致匹配失败)
REQUIRED_CHECKS=(
"Validate Code Quality And Tests"
"Frontend Lint"
)
echo "=== Checking open PRs targeting $TARGET_BRANCH ===" echo "=== Checking open PRs targeting $TARGET_BRANCH ==="
# 获取所有 open PR # 获取所有 open PR
PRS=$(curl -s -H "Authorization: token $TOKEN" \ PRS=$(curl -s -H "Authorization: token $TOKEN" \
"$GITEA_API/repos/$REPO/pulls?state=open&sort=updated&direction=desc" | python3 -c " "$GITEA_API/repos/$REPO/pulls?state=open&labels=0" | python3 -c "
import json, sys import json, sys
data = json.load(sys.stdin) data = json.load(sys.stdin)
for pr in data: for pr in data:
if pr.get('base', {}).get('ref') == '$TARGET_BRANCH': if pr.get('base', {}).get('ref') == '$TARGET_BRANCH':
head_sha = pr.get('head', {}).get('sha', '') if pr.get('mergeable', False):
print(f\"{pr['number']}|{pr['title']}|{head_sha}\") print(f\"{pr['number']}|{pr['title']}|{pr.get('mergeable', 'unknown')}\")
") ")
if [ -z "$PRS" ]; then if [ -z "$PRS" ]; then
echo "No open PRs found for $TARGET_BRANCH" echo "No mergeable PRs found for $TARGET_BRANCH"
exit 0 exit 0
fi fi
merge_count=0 echo "$PRS" | while IFS='|' read -r number title mergeable; do
skip_count=0 echo "Merging PR #$number: $title"
echo "$PRS" | while IFS='|' read -r number title head_sha; do
echo ""
echo "--- PR #$number: $title ---"
echo " Head SHA: $head_sha"
# 获取该 commit 的 combined CI 状态
STATUS_JSON=$(curl -s -H "Authorization: token $TOKEN" \
"$GITEA_API/repos/$REPO/commits/$head_sha/status")
# 检查每个必需的 CI 项是否通过
all_passed=true
failed_checks=""
for check_pattern in "${REQUIRED_CHECKS[@]}"; do
state=$(echo "$STATUS_JSON" | python3 -c "
import json, sys
d = json.load(sys.stdin)
pattern = '$check_pattern'
# 在 statuses 中找到匹配的最新状态
target = None
for s in d.get('statuses', []):
if pattern in s.get('context', ''):
target = s
break # status 接口返回的是每个 context 的最新状态,取第一个匹配即可
if target:
print(target.get('state', 'unknown'))
else:
print('not_found')
")
if [ "$state" = "success" ]; then
echo "$check_pattern: $state"
else
echo "$check_pattern: $state"
all_passed=false
failed_checks="$failed_checks $check_pattern($state)"
fi
done
if [ "$all_passed" != "true" ]; then
echo " ⏭️ Skipping - CI not passed:$failed_checks"
skip_count=$((skip_count + 1))
continue
fi
# CI 全部通过,执行合并
echo " 🚀 All CI checks passed, merging..."
RESULT=$(curl -s -X POST \ RESULT=$(curl -s -X POST \
-H "Authorization: token $TOKEN" \ -H "Authorization: token $TOKEN" \
-H "Content-Type: application/json" \ -H "Content-Type: application/json" \
"$GITEA_API/repos/$REPO/pulls/$number/merge" \ "$GITEA_API/repos/$REPO/pulls/$number/merge" \
-d '{"Do": "merge"}') -d '{\"merge_method\": \"merge\"}')
if echo "$RESULT" | python3 -c "import json,sys; d=json.load(sys.stdin); sys.exit(0 if d.get('merged', False) or 'id' in d else 1)" 2>/dev/null; then if echo "$RESULT" | python3 -c "import json,sys; d=json.load(sys.stdin); sys.exit(0 if 'id' in d else 1)"; then
echo " ✅ PR #$number merged successfully" echo " ✅ PR #$number merged successfully"
merge_count=$((merge_count + 1))
else else
echo " ❌ PR #$number merge failed" echo " ❌ PR #$number failed: $RESULT"
# 提取错误信息
err_msg=$(echo "$RESULT" | python3 -c "import json,sys; d=json.load(sys.stdin); print(d.get('message', str(d)[:200]))" 2>/dev/null)
echo " Error: $err_msg"
fi fi
done done
echo ""
echo "=== Done ===" echo "=== Done ==="
echo "Merged: $merge_count | Skipped: $skip_count"
-12
View File
@@ -60,9 +60,6 @@ fi
# 默认只读不写,防止 feature 分支污染主缓存 # 默认只读不写,防止 feature 分支污染主缓存
# 只有 develop/main 分支才写回缓存 # 只有 develop/main 分支才写回缓存
BRANCH_NAME="${GITHUB_REF_NAME:-${CI_COMMIT_BRANCH:-unknown}}" BRANCH_NAME="${GITHUB_REF_NAME:-${CI_COMMIT_BRANCH:-unknown}}"
# 清理本地旧镜像
docker rmi -f "$API_IMAGE" "$API_LATEST" 2>/dev/null || true
if [ "$USE_CACHE" -eq 1 ]; then if [ "$USE_CACHE" -eq 1 ]; then
docker buildx build \ docker buildx build \
--build-arg APP_VERSION="$VERSION" \ --build-arg APP_VERSION="$VERSION" \
@@ -92,9 +89,6 @@ build_with_cache() {
echo " cache: read-only from ${CACHE_REGISTRY}/${IMG_NAME}-cache:${CACHE_TAG_PRIMARY}" echo " cache: read-only from ${CACHE_REGISTRY}/${IMG_NAME}-cache:${CACHE_TAG_PRIMARY}"
fi fi
# 清理本地旧镜像,避免 buildx --load 报 already exists 错误
docker rmi -f "$IMG_NAME:$VERSION" 2>/dev/null || true
if [ "$USE_CACHE" -eq 1 ]; then if [ "$USE_CACHE" -eq 1 ]; then
if [ -n "$CACHE_TO" ]; then if [ -n "$CACHE_TO" ]; then
docker buildx build \ docker buildx build \
@@ -125,9 +119,6 @@ build_with_cache "api" "infra/docker/api.Dockerfile" \
docker tag "$API_IMAGE" "$API_LATEST" docker tag "$API_IMAGE" "$API_LATEST"
echo "=== Building Worker image ===" echo "=== Building Worker image ==="
# 清理本地旧镜像
docker rmi -f "$WORKER_IMAGE" "$WORKER_LATEST" 2>/dev/null || true
if [ "$USE_CACHE" -eq 1 ]; then if [ "$USE_CACHE" -eq 1 ]; then
docker buildx build \ docker buildx build \
--build-arg APP_VERSION="$VERSION" \ --build-arg APP_VERSION="$VERSION" \
@@ -157,9 +148,6 @@ docker run --rm \
test -f apps/web/dist/index.html test -f apps/web/dist/index.html
# 清理本地旧镜像
docker rmi -f "$WEB_IMAGE" 2>/dev/null || true
if [ "$USE_CACHE" -eq 1 ]; then if [ "$USE_CACHE" -eq 1 ]; then
docker buildx build \ docker buildx build \
--cache-from "type=registry,ref=${CACHE_REGISTRY}/web-cache:${CACHE_TAG_PRIMARY},ignore-error=true" \ --cache-from "type=registry,ref=${CACHE_REGISTRY}/web-cache:${CACHE_TAG_PRIMARY},ignore-error=true" \
+25 -141
View File
@@ -30,14 +30,10 @@
from __future__ import annotations from __future__ import annotations
import argparse import argparse
import json
import os import os
import re import re
import subprocess import subprocess
import sys import sys
import time
import urllib.error
import urllib.request
from pathlib import Path from pathlib import Path
from typing import List, Tuple from typing import List, Tuple
@@ -79,15 +75,6 @@ SAFE_PATTERNS = [
] ]
def _get_env(*names: str, default: str = "") -> str:
"""按优先级尝试多个环境变量名,返回第一个非空值。"""
for name in names:
val = os.environ.get(name, "")
if val:
return val
return default
def extract_upgrade_content(content: str) -> str: def extract_upgrade_content(content: str) -> str:
""" """
从迁移文件中提取 upgrade 函数的内容 从迁移文件中提取 upgrade 函数的内容
@@ -110,137 +97,34 @@ def extract_upgrade_content(content: str) -> str:
return content[upgrade_start:upgrade_end] return content[upgrade_start:upgrade_end]
def _api_get_with_retry(url: str, token: str, max_retries: int = 3) -> dict | list:
"""
带重试的 API 调用
指数退避1s, 2s, 4s
"""
last_error = None
for attempt in range(max_retries):
try:
req = urllib.request.Request(url, headers={"Authorization": f"token {token}"})
with urllib.request.urlopen(req, timeout=15) as resp:
return json.loads(resp.read().decode())
except urllib.error.HTTPError as e:
# 404 说明目录不存在或分支不存在,直接抛
if e.code == 404:
raise
last_error = e
if attempt < max_retries - 1:
wait = 2**attempt
print(f" API 请求失败,{wait}s 后重试 {attempt + 1}/{max_retries}{e}")
time.sleep(wait)
except Exception as e:
last_error = e
if attempt < max_retries - 1:
wait = 2**attempt
print(f" API 请求失败,{wait}s 后重试 {attempt + 1}/{max_retries}{e}")
time.sleep(wait)
raise last_error # type: ignore[misc]
def get_new_migrations_via_api(diff_target: str) -> List[Path] | None:
"""
通过 Gitea/GitHub Contents API 对比目标分支找出 alembic/versions/ 下新增的迁移文件
返回 None 表示 API 方式不可用调用方应尝试其他方式
"""
# 同时支持 Gitea 和 GitHub 的环境变量命名
api_url = _get_env("GITEA_API_URL", "GITHUB_API_URL", "CI_API_V4_URL")
repo = _get_env("GITEA_REPOSITORY", "GITHUB_REPOSITORY", "CI_PROJECT_PATH")
token = _get_env("GITEA_TOKEN", "GITHUB_TOKEN", "CI_JOB_TOKEN")
branch = diff_target.replace("origin/", "")
if not api_url or not repo or not token:
print(
f" API 环境变量不完整:api_url={'' if api_url else ''} repo={'' if repo else ''} token={'' if token else ''}"
)
return None
try:
url = f"{api_url}/repos/{repo}/contents/alembic/versions?ref={branch}"
data = _api_get_with_retry(url, token)
if isinstance(data, dict):
# Gitea 目录不存在时返回 404,不会到这里;如果返回 dict 可能是错误信息
print(f" API 返回异常:{str(data)[:100]}")
return None
remote_files = {item["name"] for item in data if item["name"].endswith(".py")}
local_files = {f.name for f in ALEMBIC_VERSIONS_DIR.glob("*.py")}
new_file_names = sorted(local_files - remote_files)
if new_file_names:
result = [ALEMBIC_VERSIONS_DIR / f for f in new_file_names]
print(f" API 对比 {branch} 分支,发现 {len(result)} 个新增迁移)")
return result
else:
print(f" API 对比 {branch} 分支,无新增迁移)")
return []
except Exception as e:
print(f" API 获取迁移列表失败:{e}")
return None
def get_new_migrations_via_git(diff_target: str) -> List[Path] | None:
"""
Fallback通过本地 git diff 找出新增的迁移文件
CI 环境中 git 可用时作为 API 失败后的兜底方案
"""
try:
# 确保目标分支存在
subprocess.run(
["git", "fetch", "origin", diff_target.replace("origin/", ""), "--depth=50"],
capture_output=True,
cwd=str(REPO_ROOT),
timeout=30,
)
result = subprocess.run(
["git", "diff", "--name-only", "--diff-filter=A", f"{diff_target}...HEAD"],
capture_output=True,
text=True,
cwd=str(REPO_ROOT),
timeout=10,
)
if result.returncode != 0:
print(f" git diff 失败:{result.stderr.strip()}")
return None
new_migrations = []
for line in result.stdout.strip().split("\n"):
line = line.strip()
if line.startswith("alembic/versions/") and line.endswith(".py"):
new_migrations.append(REPO_ROOT / line)
new_migrations.sort()
print(f" git diff 对比 {diff_target},发现 {len(new_migrations)} 个新增迁移)")
return new_migrations
except Exception as e:
print(f" git diff 方式失败:{e}")
return None
def get_new_migrations_via_diff(diff_target: str) -> List[Path]: def get_new_migrations_via_diff(diff_target: str) -> List[Path]:
""" """
找出相对目标分支新增的迁移文件按优先级尝试多种方式 通过 git diff 对比目标分支/commit找出 alembic/versions/ 下新增的迁移文件
1. Gitea/GitHub Contents API最可靠不受本地 checkout 深度影响 只包含新增文件A状态不包含修改或删除的文件
2. git diffAPI 失败时的兜底
3. 全量扫描以上都失败时的最后兜底会输出警告
""" """
print("🔍 尝试通过 API 获取新增迁移列表...") try:
result = get_new_migrations_via_api(diff_target) result = subprocess.run(
if result is not None: [
return result "git",
"diff",
print("🔍 API 不可用,尝试 git diff 方式...") "--name-only",
result = get_new_migrations_via_git(diff_target) "--diff-filter=A",
if result is not None: diff_target,
return result "HEAD",
"--",
print("⚠️ 所有增量方式均失败,降级为检查所有迁移文件") "alembic/versions/",
print(" 这可能导致历史迁移中的破坏性操作被误报") ],
print(" 建议检查 CI 环境变量配置(GITHUB_API_URL / GITHUB_REPOSITORY / GITHUB_TOKEN") cwd=str(REPO_ROOT),
return sorted(ALEMBIC_VERSIONS_DIR.glob("*.py")) capture_output=True,
text=True,
check=True,
)
files = [line.strip() for line in result.stdout.strip().split("\n") if line.strip()]
return [REPO_ROOT / f for f in files]
except subprocess.CalledProcessError as e:
print(f"⚠️ git diff 失败({diff_target}):{e.stderr.strip()}")
print(" 降级为检查所有迁移文件")
return sorted(ALEMBIC_VERSIONS_DIR.glob("*.py"))
def find_new_migrations(since_revision: str | None = None, diff_against: str | None = None) -> List[Path]: def find_new_migrations(since_revision: str | None = None, diff_against: str | None = None) -> List[Path]:
-91
View File
@@ -1,91 +0,0 @@
#!/bin/bash
# 通用Docker镜像构建+推送脚本(local cache为主 + registry cache兜底)
# M-2优化:解决registry缓存导入慢(247s)和推送不稳定问题
# 用法: docker_build_push.sh <Dockerfile> <image_tag> <cache_ref> [build_arg...]
set -eu
DOCKERFILE="$1"
IMAGE_TAG="$2"
CACHE_REF="$3"
shift 3
BUILD_ARGS=""
for arg in "$@"; do
BUILD_ARGS="$BUILD_ARGS --build-arg $arg"
done
if ! docker buildx inspect ci-builder > /dev/null 2>&1; then
docker buildx create --use --name ci-builder --driver docker-container
echo "Created ci-builder"
else
docker buildx use ci-builder
echo "Using existing ci-builder"
fi
docker buildx inspect --bootstrap
# 从cache_ref中提取缓存名称(如 api-cache:develop -> api-cache-develop
CACHE_NAME=$(echo "$CACHE_REF" | tr '/' '_' | tr ':' '-')
LOCAL_CACHE_DIR="/tmp/buildx-cache/${CACHE_NAME}"
mkdir -p "$LOCAL_CACHE_DIR"
# 缓存源:local优先,registry兜底
CACHE_FROM_LOCAL="type=local,src=${LOCAL_CACHE_DIR}"
CACHE_FROM_REGISTRY="type=registry,ref=${CACHE_REF},ignore-error=true"
# 本地缓存目标(必选,mode=max最大化命中率)
CACHE_TO_LOCAL="type=local,dest=${LOCAL_CACHE_DIR},mode=max"
echo "=== Step 1: Build & push image (local cache read-write + registry read) ==="
echo "Local cache: ${LOCAL_CACHE_DIR}"
echo "Registry cache: ${CACHE_REF}"
echo ""
docker buildx build \
$BUILD_ARGS \
--cache-from "${CACHE_FROM_LOCAL}" \
--cache-from "${CACHE_FROM_REGISTRY}" \
--cache-to "${CACHE_TO_LOCAL}" \
-f "${DOCKERFILE}" \
-t "${IMAGE_TAG}" \
--push \
.
echo ""
echo "Image pushed: ${IMAGE_TAG}"
echo "Local cache updated"
# DISABLED: registry cache too slow echo ""
# DISABLED: registry cache too slow echo "=== Step 2: Sync registry cache (best effort, retries 3x) ==="
# DISABLED: registry cache too slow CACHE_TO_REGISTRY="type=registry,ref=${CACHE_REF},mode=max,compression=zstd"
# DISABLED: registry cache too slow
# DISABLED: registry cache too slow MAX_RETRIES=3
# DISABLED: registry cache too slow SUCCESS=0
# DISABLED: registry cache too slow for attempt in $(seq 1 $MAX_RETRIES); do
# DISABLED: registry cache too slow echo "Registry cache sync attempt $attempt/$MAX_RETRIES"
# DISABLED: registry cache too slow if docker buildx build \
# DISABLED: registry cache too slow $BUILD_ARGS \
# DISABLED: registry cache too slow --cache-from "${CACHE_FROM_LOCAL}" \
# DISABLED: registry cache too slow --cache-to "${CACHE_TO_REGISTRY}" \
# DISABLED: registry cache too slow -f "${DOCKERFILE}" \
# DISABLED: registry cache too slow -t "${IMAGE_TAG}" \
# DISABLED: registry cache too slow --push \
# DISABLED: registry cache too slow .; then
# DISABLED: registry cache too slow echo "Registry cache synced (attempt $attempt)"
# DISABLED: registry cache too slow SUCCESS=1
# DISABLED: registry cache too slow break
# DISABLED: registry cache too slow else
# DISABLED: registry cache too slow echo "Registry cache sync failed (attempt $attempt)"
# DISABLED: registry cache too slow if [ $attempt -lt $MAX_RETRIES ]; then
# DISABLED: registry cache too slow WAIT=$((attempt * 5))
# DISABLED: registry cache too slow echo "Retrying in ${WAIT}s..."
# DISABLED: registry cache too slow sleep $WAIT
# DISABLED: registry cache too slow fi
# DISABLED: registry cache too slow fi
# DISABLED: registry cache too slow done
# DISABLED: registry cache too slow
# DISABLED: registry cache too slow if [ $SUCCESS -eq 0 ]; then
# DISABLED: registry cache too slow echo "WARNING: Registry cache sync failed after $MAX_RETRIES attempts (non-fatal, local cache still works)"
# DISABLED: registry cache too slow fi
echo ""
echo "Build completed: ${IMAGE_TAG}"
-48
View File
@@ -1,48 +0,0 @@
#!/bin/bash
# mypyå¢žé‡æ‰«æè„šæœ¬ - CI中调用
# 环境å˜é‡: SCAN_MODE, CHANGED_PY_FILES
set -e
echo "=== Installing mypy ==="
python3 -m pip install -q mypy
mypy --version
echo ""
echo "=== Running mypy type check (hard gate mode) ==="
echo "告警模å¼ï¼Œä¸Í阻断CI"
echo ""
MYPY_COMMON_ARGS="--ignore-missing-imports --no-site-packages --no-strict-optional --explicit-package-bases --exclude tests/|test_|migrations/|alembic/ --no-error-summary --incremental --cache-dir .mypy_cache"
EXIT_CODE=0
if [ "$SCAN_MODE" = "incremental" ] && [ -n "$CHANGED_PY_FILES" ]; then
echo "=== Incremental mypy scan (PR mode) ==="
echo "Changed files: $(echo $CHANGED_PY_FILES | wc -w) files"
MYPY_FILES=""
for f in $CHANGED_PY_FILES; do
case "$f" in
apps/*|packages/*)
MYPY_FILES="$MYPY_FILES $f"
;;
esac
done
if [ -n "$MYPY_FILES" ]; then
echo "Checking: $MYPY_FILES"
mypy $MYPY_FILES $MYPY_COMMON_ARGS 2>&1 | head -80 || EXIT_CODE=$?
else
echo "No mypy-checkable files changed, skipping"
fi
else
echo "=== Full mypy scan ==="
mypy apps/api/app packages $MYPY_COMMON_ARGS 2>&1 | head -60 || EXIT_CODE=$?
fi
echo ""
if [ "$EXIT_CODE" != "0" ]; then
echo "mypy å‘现类型问题(告警模å¼ï¼Œä¸Í阻断)"
echo "建议åŽç»­é€æ­¥ä¿®å¤"
else
echo "mypy 类型检查通过"
fi
+4 -4
View File
@@ -7,7 +7,7 @@
# 环境变量: # 环境变量:
# IMAGE_TAG - 镜像版本 tag(如 commit SHA 或分支名) # IMAGE_TAG - 镜像版本 tag(如 commit SHA 或分支名)
# REGISTRY_TOKEN - Registry 访问令牌 # REGISTRY_TOKEN - Registry 访问令牌
# REGISTRY - Registry 地址(默认 xiaoxia-registry.cn-hangzhou.cr.aliyuncs.com/xiaoxiakeji # REGISTRY - Registry 地址(默认 git.xiaoxiajianji.com/xiaoxia/xiaoxia-saas
# REGISTRY_USER - Registry 用户名(默认 xiaoxia # REGISTRY_USER - Registry 用户名(默认 xiaoxia
# ENV_FILE - 环境变量文件路径 # ENV_FILE - 环境变量文件路径
# GENERATED_DIR - 生成文件目录 # GENERATED_DIR - 生成文件目录
@@ -16,9 +16,9 @@
set -eu set -eu
IMAGE_TAG="${IMAGE_TAG:-}" IMAGE_TAG="${IMAGE_TAG:-}"
REGISTRY="${REGISTRY:-xiaoxia-registry.cn-hangzhou.cr.aliyuncs.com/xiaoxiakeji}" REGISTRY="${REGISTRY:-git.xiaoxiajianji.com/xiaoxia/xiaoxia-saas}"
REGISTRY_USER="${ACR_USERNAME:-${REGISTRY_USER:-nick0415343655}}" REGISTRY_USER="${REGISTRY_USER:-xiaoxia}"
REGISTRY_TOKEN="${ACR_PASSWORD:-${REGISTRY_TOKEN:-}}" REGISTRY_TOKEN="${REGISTRY_TOKEN:-}"
ENV_FILE="${ENV_FILE:-/var/lib/xiaoxia-saas-staging/.env}" ENV_FILE="${ENV_FILE:-/var/lib/xiaoxia-saas-staging/.env}"
GENERATED_DIR="${GENERATED_DIR:-/var/lib/xiaoxia-saas-staging/generated}" GENERATED_DIR="${GENERATED_DIR:-/var/lib/xiaoxia-saas-staging/generated}"
+2 -2
View File
@@ -137,7 +137,7 @@ class PerfAssert:
result = PerfResult(name=name or threshold_level, threshold_ms=threshold_ms) result = PerfResult(name=name or threshold_level, threshold_ms=threshold_ms)
last_response = None last_response = None
for _ in range(num_samples): for i in range(num_samples):
start = time.perf_counter() start = time.perf_counter()
last_response = func() last_response = func()
elapsed = (time.perf_counter() - start) * 1000 elapsed = (time.perf_counter() - start) * 1000
@@ -261,7 +261,7 @@ def run_perf_test(
result = PerfResult(name=name, threshold_ms=threshold_ms) result = PerfResult(name=name, threshold_ms=threshold_ms)
last_response = None last_response = None
for _ in range(samples): for i in range(samples):
start = time.perf_counter() start = time.perf_counter()
last_response = func() last_response = func()
elapsed = (time.perf_counter() - start) * 1000 elapsed = (time.perf_counter() - start) * 1000
+1 -1
View File
@@ -530,7 +530,7 @@ class TestLargeDataRequests:
def test_rapid_sequential_requests(self, auth_headers): def test_rapid_sequential_requests(self, auth_headers):
"""快速连续请求不应触发限流导致 500。""" """快速连续请求不应触发限流导致 500。"""
statuses = [] statuses = []
for _ in range(20): for i in range(20):
resp = client.get("/api/v1/projects", headers=auth_headers) resp = client.get("/api/v1/projects", headers=auth_headers)
statuses.append(resp.status_code) statuses.append(resp.status_code)
+1 -10
View File
@@ -665,16 +665,7 @@ class TestResponseSchema:
resp = client.get(f"/api/v1/edit-plans/{plan.id}/generation-status") resp = client.get(f"/api/v1/edit-plans/{plan.id}/generation-status")
assert resp.status_code == 200 assert resp.status_code == 200
data = resp.json() data = resp.json()
expected_keys = { expected_keys = {"plan_id", "plan_status", "generation_task_id", "clips"}
"plan_id",
"plan_status",
"generation_task_id",
"generation_task_status",
"progress",
"video_url",
"error_message",
"clips",
}
assert set(data.keys()) == expected_keys assert set(data.keys()) == expected_keys
-91
View File
@@ -636,94 +636,3 @@ class TestGenerationWorkflow:
p = svc.create_plan("tpl-001", "测试", config={"key1": "val1"}) p = svc.create_plan("tpl-001", "测试", config={"key1": "val1"})
updated = svc.update_plan_config(p.id, {"key1": "new_val"}) updated = svc.update_plan_config(p.id, {"key1": "new_val"})
assert updated.config["key1"] == "new_val" assert updated.config["key1"] == "new_val"
# ── 重新编辑 & 再生成 ────────────────────────────────────────────────────────
class TestResumeEditingAndRegenerate:
"""完成/失败后重新编辑 → 再生成的状态流转测试"""
def test_update_plan_from_completed_returns_to_editing(self):
"""更新计划配置:completed → 自动切回 editing"""
svc = _make_service()
p = svc.create_plan("tpl-001", "测试")
svc.transition_status(p.id, EditPlanStatus.EDITING)
svc.transition_status(p.id, EditPlanStatus.RENDERING)
svc.transition_status(p.id, EditPlanStatus.COMPLETED)
updated = svc.update_plan(p.id, name="新名字")
assert updated.status == EditPlanStatus.EDITING
assert updated.name == "新名字"
def test_update_plan_config_from_completed_returns_to_editing(self):
"""update_plan_config: completed → 自动切回 editing"""
svc = _make_service()
p = svc.create_plan("tpl-001", "测试")
svc.transition_status(p.id, EditPlanStatus.EDITING)
svc.transition_status(p.id, EditPlanStatus.RENDERING)
svc.transition_status(p.id, EditPlanStatus.COMPLETED)
updated = svc.update_plan_config(p.id, {"foo": "bar"})
assert updated.status == EditPlanStatus.EDITING
def test_create_clip_from_completed_returns_to_editing(self):
"""创建片段:completed → 自动切回 editing"""
svc = _make_service()
p = svc.create_plan("tpl-001", "测试")
svc.transition_status(p.id, EditPlanStatus.EDITING)
svc.transition_status(p.id, EditPlanStatus.RENDERING)
svc.transition_status(p.id, EditPlanStatus.COMPLETED)
svc.create_clip(p.id, "main", 0)
plan_after = svc.get_plan(p.id)
assert plan_after.status == EditPlanStatus.EDITING
def test_assign_asset_from_failed_returns_to_editing(self):
"""分配素材:failed → 自动切回 editing"""
svc = _make_service()
p = svc.create_plan("tpl-001", "测试")
clip = svc.create_clip(p.id, "main", 0)
svc.transition_status(p.id, EditPlanStatus.EDITING)
svc.transition_status(p.id, EditPlanStatus.RENDERING)
svc.transition_status(p.id, EditPlanStatus.FAILED)
svc.assign_asset(clip.id, "asset-001")
plan_after = svc.get_plan(p.id)
assert plan_after.status == EditPlanStatus.EDITING
def test_completed_can_regenerate_after_edit(self):
"""完成后编辑 → can_generate 返回 True,可再生成"""
svc = _make_service()
p = svc.create_plan("tpl-001", "测试")
svc.create_clip(p.id, "main", 0)
svc.transition_status(p.id, EditPlanStatus.EDITING)
svc.transition_status(p.id, EditPlanStatus.RENDERING)
svc.transition_status(p.id, EditPlanStatus.COMPLETED)
# 完成后不能直接生成
can, reason = svc.can_generate(p.id)
assert not can
assert "编辑" in reason
# 编辑后自动切回 editing,可以生成
svc.update_plan_config(p.id, {"edited": True})
can, reason = svc.can_generate(p.id)
assert can, f"期望可生成,实际: {reason}"
def test_transition_completed_to_editing_via_service(self):
"""通过 transition_status 从 completed 切到 editing"""
svc = _make_service()
p = svc.create_plan("tpl-001", "测试")
svc.transition_status(p.id, EditPlanStatus.EDITING)
svc.transition_status(p.id, EditPlanStatus.RENDERING)
svc.transition_status(p.id, EditPlanStatus.COMPLETED)
result = svc.transition_status(p.id, EditPlanStatus.EDITING)
assert result.status == EditPlanStatus.EDITING
def test_resume_editing_from_draft_raises(self):
"""从 draft 直接 resume_editing 应该报错"""
p = EditPlan.create("tpl-001", "测试")
with pytest.raises(ValueError):
p.resume_editing()
+6 -6
View File
@@ -44,7 +44,7 @@ class TestEditTemplate:
def test_create_empty_name_raises(self): def test_create_empty_name_raises(self):
try: try:
EditTemplate.create(" ") EditTemplate.create(" ")
raise AssertionError("应该抛出 ValueError") assert False, "应该抛出 ValueError"
except ValueError as e: except ValueError as e:
assert "模板名称不能为空" in str(e) assert "模板名称不能为空" in str(e)
@@ -74,14 +74,14 @@ class TestEditPlan:
def test_create_empty_name_raises(self): def test_create_empty_name_raises(self):
try: try:
EditPlan.create("tpl-1", " ") EditPlan.create("tpl-1", " ")
raise AssertionError("应该抛出 ValueError") assert False, "应该抛出 ValueError"
except ValueError as e: except ValueError as e:
assert "计划名称不能为空" in str(e) assert "计划名称不能为空" in str(e)
def test_create_empty_template_id_raises(self): def test_create_empty_template_id_raises(self):
try: try:
EditPlan.create(" ", "test") EditPlan.create(" ", "test")
raise AssertionError("应该抛出 ValueError") assert False, "应该抛出 ValueError"
except ValueError as e: except ValueError as e:
assert "template_id 不能为空" in str(e) assert "template_id 不能为空" in str(e)
@@ -112,7 +112,7 @@ class TestEditPlan:
p = EditPlan.create("tpl-1", "test") p = EditPlan.create("tpl-1", "test")
try: try:
p.start_rendering() # draft → rendering 不合法 p.start_rendering() # draft → rendering 不合法
raise AssertionError("应该抛出 ValueError") assert False, "应该抛出 ValueError"
except ValueError as e: except ValueError as e:
logger.warning(f"Operation failed in tests/unit/test_phase8_edit_models.py: {e}", exc_info=True) logger.warning(f"Operation failed in tests/unit/test_phase8_edit_models.py: {e}", exc_info=True)
@@ -120,7 +120,7 @@ class TestEditPlan:
p = EditPlan.create("tpl-1", "test") p = EditPlan.create("tpl-1", "test")
try: try:
p.mark_completed() # draft → completed 不合法 p.mark_completed() # draft → completed 不合法
raise AssertionError("应该抛出 ValueError") assert False, "应该抛出 ValueError"
except ValueError as e: except ValueError as e:
logger.warning(f"Operation failed in tests/unit/test_phase8_edit_models.py: {e}", exc_info=True) logger.warning(f"Operation failed in tests/unit/test_phase8_edit_models.py: {e}", exc_info=True)
@@ -128,7 +128,7 @@ class TestEditPlan:
p = EditPlan.create("tpl-1", "test") p = EditPlan.create("tpl-1", "test")
try: try:
p.reset_to_draft() # draft → draft 不合法 p.reset_to_draft() # draft → draft 不合法
raise AssertionError("应该抛出 ValueError") assert False, "应该抛出 ValueError"
except ValueError as e: except ValueError as e:
logger.warning(f"Operation failed in tests/unit/test_phase8_edit_models.py: {e}", exc_info=True) logger.warning(f"Operation failed in tests/unit/test_phase8_edit_models.py: {e}", exc_info=True)
+1 -1
View File
@@ -253,7 +253,7 @@ class TestConcatSecurity:
# 创建超过上限的段数 # 创建超过上限的段数
segments = [] segments = []
for _ in range(MAX_CONCAT_SEGMENTS + 5): for i in range(MAX_CONCAT_SEGMENTS + 5):
segments.append(ConcatSegment(video_path=str(sample_video))) segments.append(ConcatSegment(video_path=str(sample_video)))
config = ConcatConfig(segments=segments) config = ConcatConfig(segments=segments)
+1 -1
View File
@@ -367,7 +367,7 @@ class TestVerifyUrlRedirectValidation:
except urllib.error.HTTPError as e: except urllib.error.HTTPError as e:
if 300 <= e.code < 400 and e.headers.get("Location"): if 300 <= e.code < 400 and e.headers.get("Location"):
if redirect_count >= max_redirects: if redirect_count >= max_redirects:
raise Exception(f"重定向次数超过上限 ({max_redirects})") from e raise Exception(f"重定向次数超过上限 ({max_redirects})")
location = e.headers["Location"] location = e.headers["Location"]
current = urljoin(safe_url, location) current = urljoin(safe_url, location)
redirect_count += 1 redirect_count += 1
+2 -2
View File
@@ -115,7 +115,7 @@ class TestAudioMerger:
# 创建临时文件 # 创建临时文件
paths = [] paths = []
for _ in range(3): for i in range(3):
with tempfile.NamedTemporaryFile(suffix=".mp3", delete=False) as f: with tempfile.NamedTemporaryFile(suffix=".mp3", delete=False) as f:
f.write(b"audio") f.write(b"audio")
paths.append(f.name) paths.append(f.name)
@@ -151,7 +151,7 @@ class TestAudioMerger:
mock_run_ffmpeg.side_effect = CalledProcessError(returncode=1, cmd=["ffmpeg"], stderr="error details") mock_run_ffmpeg.side_effect = CalledProcessError(returncode=1, cmd=["ffmpeg"], stderr="error details")
paths = [] paths = []
for _ in range(2): for i in range(2):
with tempfile.NamedTemporaryFile(suffix=".mp3", delete=False) as f: with tempfile.NamedTemporaryFile(suffix=".mp3", delete=False) as f:
f.write(b"audio") f.write(b"audio")
paths.append(f.name) paths.append(f.name)
+2 -34
View File
@@ -45,7 +45,6 @@ class FakeClip:
start_time: float = 0.0 start_time: float = 0.0
duration: float = 0.0 duration: float = 0.0
transition_effect: str = "cut" transition_effect: str = "cut"
transition_duration: float = 0.0
status: str = "ready" status: str = "ready"
config: dict[str, Any] = field(default_factory=dict) config: dict[str, Any] = field(default_factory=dict)
@@ -66,7 +65,6 @@ def _make_clip(
asset_id: str = "", asset_id: str = "",
duration: float = 0.0, duration: float = 0.0,
transition_effect: str = "cut", transition_effect: str = "cut",
transition_duration: float = 0.0,
config: dict[str, Any] | None = None, config: dict[str, Any] | None = None,
) -> FakeClip: ) -> FakeClip:
return FakeClip( return FakeClip(
@@ -76,7 +74,6 @@ def _make_clip(
asset_id=asset_id or f"asset_{clip_id}.mp4", asset_id=asset_id or f"asset_{clip_id}.mp4",
duration=duration, duration=duration,
transition_effect=transition_effect, transition_effect=transition_effect,
transition_duration=transition_duration,
config=config or {}, config=config or {},
) )
@@ -335,7 +332,7 @@ class TestBuildFilterComplex:
assert "[final_video]" in fc assert "[final_video]" in fc
def test_single_layer_multi_clips(self): def test_single_layer_multi_clips(self):
"""多个 main clips(默认硬切)→ concat 串联。""" """多个 main clips → xfade 串联。"""
clips = [ clips = [
_make_clip("c1", "main", order=0, duration=3.0), _make_clip("c1", "main", order=0, duration=3.0),
_make_clip("c2", "main", order=1, duration=3.0), _make_clip("c2", "main", order=1, duration=3.0),
@@ -346,35 +343,6 @@ class TestBuildFilterComplex:
} }
svc = _make_service(clips, asset_paths) svc = _make_service(clips, asset_paths)
with _patch_path_exists(), patch("video_processing.unified_render_service.probe_duration", return_value=5.0):
resolved = svc._resolve_clips()
layers = svc._group_clips_into_layers(resolved)
fc, input_args = svc._build_filter_complex(layers)
assert input_args.count("-i") == 2
# 全硬切场景走 concat filter(性能远优于 xfade
assert "concat=n=2:v=1:a=0" in fc
assert "[final_video]" in fc
def test_single_layer_multi_clips_with_transition(self):
"""多个 main clips 带转场效果 → xfade 串联。"""
clips = [
_make_clip("c1", "main", order=0, duration=3.0),
_make_clip(
"c2",
"main",
order=1,
duration=3.0,
transition_effect="fade",
transition_duration=0.5,
),
]
asset_paths = {
"asset_c1.mp4": Path("/tmp/asset_c1.mp4"),
"asset_c2.mp4": Path("/tmp/asset_c2.mp4"),
}
svc = _make_service(clips, asset_paths)
with _patch_path_exists(), patch("video_processing.unified_render_service.probe_duration", return_value=5.0): with _patch_path_exists(), patch("video_processing.unified_render_service.probe_duration", return_value=5.0):
resolved = svc._resolve_clips() resolved = svc._resolve_clips()
layers = svc._group_clips_into_layers(resolved) layers = svc._group_clips_into_layers(resolved)
@@ -412,7 +380,7 @@ class TestBuildFilterComplex:
""" """
clips = [ clips = [
_make_clip("c1", "main", order=0, duration=3.0), _make_clip("c1", "main", order=0, duration=3.0),
_make_clip("c2", "main", order=1, duration=5.0, transition_effect="fade", transition_duration=0.5), _make_clip("c2", "main", order=1, duration=5.0),
] ]
asset_paths = { asset_paths = {
"asset_c1.mp4": Path("/tmp/asset_c1.mp4"), "asset_c1.mp4": Path("/tmp/asset_c1.mp4"),