Compare commits

..

2 Commits

Author SHA1 Message Date
xiaoxia 1692ae135a fix(ci): 飞书通知消息添加CI构建关键词以通过关键词校验
CI/CD Pipeline / Frontend Lint (pull_request) Failing after 1m49s
CI/CD Pipeline / Validate Code Quality And Tests (pull_request) Successful in 2m10s
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 / Unit Tests (pull_request) Successful in 2m32s
CI/CD Pipeline / Integration Tests (pull_request) Successful in 1m29s
2026-07-14 19:03:32 +08:00
xiaoxia 3dc7a0a0bb fix(ci): 飞书通知失败不阻塞部署流程 - 添加continue-on-error 2026-07-14 19:03:21 +08:00
117 changed files with 1338 additions and 6044 deletions
-1
View File
@@ -1 +0,0 @@
re-trigger
File diff suppressed because one or more lines are too long
Executable → Regular
+740 -33
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)
@router.delete(
"/records/{record_id}", status_code=status.HTTP_204_NO_CONTENT, response_model=None, response_class=Response
)
@router.delete("/records/{record_id}", status_code=status.HTTP_204_NO_CONTENT, response_model=None, response_class=Response)
def delete_duplication_record(
record_id: str,
authenticated_user: AuthenticatedUser = Depends(get_current_user),
@@ -289,7 +287,7 @@ def retry_duplication(
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=str(e),
) from e
)
if updated is None:
raise HTTPException(
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):
"""剪辑计划响应体"""
@@ -79,7 +72,6 @@ class EditPlanResponse(BaseModel):
name: str
status: str
total_duration: float
result_count: int = 0
project_id: str = ""
created_by_user_id: str = ""
config: dict[str, Any]
@@ -116,10 +108,6 @@ class EditPlanGenerationStatusResponse(BaseModel):
plan_id: str
plan_status: str
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]
@@ -249,7 +237,6 @@ def _to_response(p: EditPlan) -> EditPlanResponse:
name=p.name,
status=p.status.value if hasattr(p.status, "value") else p.status,
total_duration=p.total_duration,
result_count=getattr(p, "result_count", 0),
project_id=p.project_id or "",
created_by_user_id=p.created_by_user_id or "",
config=p.config,
@@ -288,11 +275,11 @@ def list_plans(
if status_filter:
try:
status_enum = EditPlanStatus(status_filter)
except ValueError as _e:
except ValueError:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="无效的筛选条件,请选择正确的状态",
) from _e
)
# 项目鉴权:如果指定了 project_id,校验用户是否有权访问
if project_id:
@@ -335,7 +322,7 @@ def get_plan(
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=str(exc),
) from exc
)
# 项目鉴权
if plan.project_id:
check_project_access(plan.project_id, current_user.user.id, project_repository)
@@ -371,7 +358,7 @@ def create_plan(
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=str(exc),
) from exc
)
logger.info(
"创建剪辑计划: id=%s name=%s by user=%s",
created.id,
@@ -414,11 +401,11 @@ def update_plan(
if body.status is not None:
try:
target_status = EditPlanStatus(body.status)
except ValueError as _e:
except ValueError:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="无效的状态值,请选择正确的状态",
) from _e
)
svc.transition_status(plan_id, target_status)
except ValueError as exc:
err_msg = str(exc)
@@ -426,11 +413,11 @@ def update_plan(
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=err_msg,
) from exc
)
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=err_msg,
) from exc
)
# 返回最新状态
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 (拆分模块) ────────────────────────────────────────────
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_timeline import router as timeline_router
router.include_router(generation_router)
router.include_router(ai_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:
plan = svc.get_plan_or_raise(plan_id)
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:
check_project_access(plan.project_id, current_user.user.id, project_repository)
@@ -103,7 +103,7 @@ def ai_recommend_clips(
config=normalized_config,
total_duration=result["total_duration"],
)
except Exception as _e:
except Exception:
logger.exception("AI 推荐写入失败,plan_id=%s 数据可能不一致", plan_id)
try:
db.rollback()
@@ -116,7 +116,7 @@ def ai_recommend_clips(
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="AI推荐结果保存失败,请稍后重试",
) from _e
)
logger.info(
"AI 推荐片段方案: plan_id=%s clips=%d duration=%.1f by user=%s",
@@ -167,7 +167,7 @@ def generate_cover(
try:
plan = svc.get_plan_or_raise(plan_id)
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:
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:
"""队列限流预检查"""
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:
user_pending = gen_task_repo.count_pending_by_user(user_id)
global_pending = gen_task_repo.count_pending_total()
@@ -239,7 +241,7 @@ def generate_plan(
try:
can_gen, reason = svc.can_generate(plan_id)
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:
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)
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(
CreateGenerationTaskCommand(
project_id=plan.project_id or "",
project_id="",
template_id=plan.template_id,
created_by_user_id=current_user.user.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:
raise
except Exception as _e:
except Exception:
logger.exception("触发剪辑计划生成失败: plan_id=%s", plan_id)
try:
svc.transition_status(plan_id, EditPlanStatus.FAILED)
@@ -296,7 +295,7 @@ def generate_plan(
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="生成失败,请稍后重试",
) from _e
)
@router.get(
@@ -314,7 +313,7 @@ def get_generation_status(
try:
gen_status = svc.get_generation_status(plan_id)
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"]
if plan.project_id:
@@ -334,25 +333,10 @@ def get_generation_status(
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(
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_status=gen_task_status,
progress=progress,
video_url=video_url,
error_message=error_message,
clips=clip_items,
)
@@ -173,7 +173,7 @@ def generate_from_template(
try:
template = template_svc.get_template_or_raise(body.template_id)
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)
+6 -6
View File
@@ -105,7 +105,7 @@ async def list_feature_flags(
return sorted(result, key=lambda x: x.name)
except Exception as 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)
@@ -120,7 +120,7 @@ async def get_feature_flag(
return FeatureFlagResponse.from_config(config)
except Exception as 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)
@@ -136,7 +136,7 @@ async def check_feature_flag(
return FeatureFlagCheckResponse(name=name, active=active, identifier=identifier)
except Exception as 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)
@@ -170,7 +170,7 @@ async def update_feature_flag(
return FeatureFlagResponse.from_config(config)
except Exception as 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)
@@ -178,7 +178,7 @@ async def delete_feature_flag(
name: str,
_: bool = Depends(_verify_internal_api_key),
store: RedisFeatureFlagStore = Depends(_get_feature_flag_store),
):
) :
"""删除 Feature Flag。
只允许删除 ALLOWED_FLAGS 列表中的 flag。
@@ -191,4 +191,4 @@ async def delete_feature_flag(
pass
except Exception as 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()
def _to_generation_task_response(task) -> GenerationTaskResponse:
return GenerationTaskResponse(
id=task.id,
@@ -283,28 +282,28 @@ def create_generation_task(
created_tasks.append(task)
else:
failed_tasks.append(task)
except UserPendingLimitExceeded as _e:
except UserPendingLimitExceeded:
# 兜底:如果预检查后又并发提交了,在这里也拦住
failed_tasks.append(task)
if not created_tasks:
raise HTTPException(
status_code=429,
detail="您的待处理任务过多,请等待完成后再提交",
) from _e
)
break
except GlobalQueueFull as _e:
except GlobalQueueFull:
failed_tasks.append(task)
if not created_tasks:
raise HTTPException(
status_code=503,
detail="系统繁忙,请稍后再试",
) from _e
)
break
except HTTPException:
raise
except Exception as e:
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]
return BatchGenerationTaskResponse(items=items, total=len(items))
+3 -3
View File
@@ -81,11 +81,11 @@ def delete_project(
use_case = DeleteProjectUseCase(project_repository)
try:
deleted = use_case.execute(project_id, authenticated_user.user.id)
except PermissionError as _e:
except PermissionError:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Only the project owner can delete this project",
) from _e
)
if not deleted:
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}
except Exception as e:
session.rollback()
raise HTTPException(status_code=500, detail=f"支付处理失败: {str(e)}") from e
raise HTTPException(status_code=500, detail=f"支付处理失败: {str(e)}")
finally:
session.close()
+3 -3
View File
@@ -368,9 +368,9 @@ def retry_project_task(
raise HTTPException(status_code=404, detail="Ingest job not found")
if _status_value(job.status) != "failed":
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(
SubmitIngestJobCommand( # type: ignore[arg-type]
SubmitIngestJobCommand(
project_id=job.project_id,
library_id=job.library_id,
storage_key=job.storage_key,
@@ -386,6 +386,6 @@ def retry_project_task(
current_step=_ingest_step(retried),
source_id=retried.id,
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")
+15 -17
View File
@@ -147,9 +147,9 @@ def get_template(
use_case = GetTemplateUseCase(template_repository)
template = use_case.execute(template_id, user_id)
usage = template_repository.get_usage_count(template_id)
except Exception as _e:
except Exception:
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:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Template not found")
return _to_response(template, usage_count=usage)
@@ -186,7 +186,7 @@ def create_template(
try:
template = use_case.execute(command)
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)
@@ -226,10 +226,10 @@ def update_template(
use_case = UpdateTemplateUseCase(template_repository)
try:
template = use_case.execute(command)
except NotFoundError as _e:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Template not found") from _e
except NotFoundError:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Template not found")
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)
@@ -264,10 +264,10 @@ def copy_template(
use_case = CopyTemplateUseCase(template_repository)
try:
template = use_case.execute(command)
except NotFoundError as _e:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Template not found") from _e
except NotFoundError:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Template not found")
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)
@@ -299,9 +299,9 @@ def toggle_favorite(
use_case = GetTemplateUseCase(template_repository)
try:
template = use_case.execute(template_id, user_id)
except Exception as _e:
except Exception:
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:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Template not found")
return ToggleFavoriteResponse(id=template_id, is_favorite=False)
@@ -326,10 +326,10 @@ def validate_template(
use_case = ValidateTemplateUseCase(template_repository)
try:
result = use_case.execute(command)
except NotFoundError as _e:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Template not found") from _e
except NotFoundError:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Template not found")
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(
template=_to_response(result.template),
@@ -375,9 +375,7 @@ def create_category(
)
@router.delete(
"/categories/{category_id}", status_code=status.HTTP_204_NO_CONTENT, response_model=None, response_class=Response
)
@router.delete("/categories/{category_id}", status_code=status.HTTP_204_NO_CONTENT, response_model=None, response_class=Response)
def delete_category(
category_id: str,
authenticated_user: AuthenticatedUser = Depends(get_current_user),
+3 -3
View File
@@ -148,7 +148,7 @@ def create_title(
raise HTTPException(
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
detail=f"标题库配额已满({exc.used}/{exc.limit}),请升级套餐",
) from exc
)
return _to_response(item)
@@ -172,8 +172,8 @@ def update_title(
use_case = UpdateTitleLibraryUseCase(title_repository)
try:
item = use_case.execute(command)
except NotFoundError as _e:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Title not found") from _e
except NotFoundError:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Title not found")
return _to_response(item)
+7 -7
View File
@@ -236,8 +236,8 @@ def get_tts_job(
use_case = GetTTSJobUseCase(repository)
try:
job = use_case.execute(job_id, user_id)
except TTSJobNotFoundError as _e:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="TTS job not found") from _e
except TTSJobNotFoundError:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="TTS job not found")
return _to_response(job, sign_url)
@@ -253,8 +253,8 @@ def get_tts_job_status(
use_case = GetTTSJobStatusUseCase(repository)
try:
job = use_case.execute(job_id, user_id)
except TTSJobNotFoundError as _e:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="TTS job not found") from _e
except TTSJobNotFoundError:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="TTS job not found")
output_url = job.output_audio_url
if output_url:
output_url = sign_url(output_url)
@@ -309,8 +309,8 @@ def save_tts_job_to_library(
get_use_case = GetTTSJobUseCase(tts_repository)
try:
job = get_use_case.execute(job_id, user_id)
except TTSJobNotFoundError as _e:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="TTS job not found") from _e
except TTSJobNotFoundError:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="TTS job not found")
# 校验已完成
if not job.is_completed:
@@ -363,7 +363,7 @@ def save_tts_job_to_library(
raise HTTPException(
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
detail=f"配音库配额已满({exc.used}/{exc.limit}),请升级套餐",
) from exc
)
return SaveToLibraryResponse(
id=item.id,
+8 -8
View File
@@ -141,8 +141,8 @@ def get_voice_clone(
use_case = GetVoiceCloneUseCase(repository)
try:
profile = use_case.execute(clone_id, user_id)
except VoiceCloneNotFoundError as _e:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Voice clone not found") from _e
except VoiceCloneNotFoundError:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Voice clone not found")
return _to_response(profile)
@@ -157,8 +157,8 @@ def get_voice_clone_status(
use_case = GetVoiceCloneStatusUseCase(repository)
try:
profile = use_case.execute(clone_id, user_id)
except VoiceCloneNotFoundError as _e:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Voice clone not found") from _e
except VoiceCloneNotFoundError:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Voice clone not found")
return VoiceCloneStatusResponse(
id=profile.id,
status=profile.status,
@@ -201,13 +201,13 @@ def retry_voice_clone(
user_id = authenticated_user.user.id
try:
profile = workflow.retry_clone(clone_id, user_id)
except VoiceCloneNotFoundError as _e:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Voice clone not found") from _e
except VoiceCloneNotRetryableError as _e:
except VoiceCloneNotFoundError:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Voice clone not found")
except VoiceCloneNotRetryableError:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Voice clone is not retryable (only failed clones can be retried)",
) from _e
)
# 如果 profile 处于 processing 且有 task_id,触发 Celery 异步轮询
task_id = (profile.metadata or {}).get("cosyvoice_task_id", "")
+3 -3
View File
@@ -287,7 +287,7 @@ def create_voice(
raise HTTPException(
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
detail=f"配音库配额已满({exc.used}/{exc.limit}),请升级套餐",
) from exc
)
return _to_response(item, sign_url)
@@ -317,8 +317,8 @@ def update_voice(
use_case = UpdateVoiceLibraryUseCase(voice_repository)
try:
item = use_case.execute(command)
except NotFoundError as _e:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Voice not found") from _e
except NotFoundError:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Voice not found")
return _to_response(item, sign_url)
+2 -2
View File
@@ -141,7 +141,7 @@ def safe_enqueue_generation_task(
global_pending_limit,
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))
raise exc
@@ -194,7 +194,7 @@ def safe_enqueue_generation_task(
if global_over or user_over:
if global_over:
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:
reason = f"用户 pending 超限(入队后): {user_after}/{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),
) -> TagRepository:
"""Provide the SQLAlchemy tag repository implementation."""
return SQLAlchemyTagRepository(session) # type: ignore[return-value]
return SQLAlchemyTagRepository(session)
def get_user_repository(
+1 -1
View File
@@ -105,7 +105,7 @@ class RateLimitMiddleware(BaseHTTPMiddleware):
self.max_requests = max_requests
self.window_seconds = window_seconds
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):
# 如果配置了路径过滤,只对指定路径限流
+1 -1
View File
@@ -61,7 +61,7 @@ class APIVersionMiddleware(BaseHTTPMiddleware):
class VersionNotFoundMiddleware(BaseHTTPMiddleware):
"""处理已下线的 API 版本"""
SUNSET_VERSIONS: list[str] = [] # 已下线的版本列表
SUNSET_VERSIONS = [] # 已下线的版本列表
async def dispatch(self, request: Request, call_next):
version = self._extract_version(request.url.path)
+1 -2
View File
@@ -12,7 +12,6 @@
from __future__ import annotations
import logging
from collections.abc import Mapping
from dataclasses import dataclass
from sqlalchemy.orm import Session
@@ -156,7 +155,7 @@ class AutoClipService:
self,
clip: EditPlanClip,
project_id: str,
config_map: Mapping[str, object],
config_map: dict[str, object],
) -> ClipAssignDetail:
"""为单个片段分配素材。"""
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)
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(
self,
plan_id: str,
@@ -169,10 +156,6 @@ class EditPlanService:
"""
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(
id=existing.id,
template_id=existing.template_id,
@@ -229,24 +212,8 @@ class EditPlanService:
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 = {
EditPlanStatus.EDITING: plan.start_editing,
EditPlanStatus.RENDERING: plan.start_rendering,
EditPlanStatus.COMPLETED: plan.mark_completed,
EditPlanStatus.FAILED: plan.mark_failed,
@@ -325,8 +292,6 @@ class EditPlanService:
"""
# 确保计划存在
self.get_plan_or_raise(plan_id)
# 自动从 completed/failed 切回 editing
self._auto_resume_editing(plan_id)
clip = EditPlanClip.create(
plan_id=plan_id,
@@ -374,9 +339,6 @@ class EditPlanService:
"""
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 <= 0:
@@ -419,8 +381,6 @@ class EditPlanService:
ValueError: 片段不存在或 asset_id 为空
"""
clip = self.get_clip_or_raise(clip_id)
# 自动从 completed/failed 切回 editing
self._auto_resume_editing(clip.plan_id)
clip.assign_asset(asset_id)
result = self._clip_repo.update(clip)
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)
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]:
@@ -527,8 +431,6 @@ class EditPlanService:
"clips": List[EditPlanClip],
"generation_task_id": Optional[str],
"generation_task_status": Optional[str],
"progress": float,
"error_message": str,
}
Raises:
@@ -540,23 +442,17 @@ class EditPlanService:
# 从 plan.config 中获取 generation_task_id
generation_task_id = plan.config.get("generation_task_id")
generation_task_status = None
progress = 0.0
error_message = ""
if generation_task_id:
task = self._generation_task_repo.get(generation_task_id)
if task:
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 {
"plan": plan,
"clips": clips,
"generation_task_id": generation_task_id,
"generation_task_status": generation_task_status,
"progress": progress,
"error_message": error_message,
}
def can_generate(self, plan_id: str) -> tuple[bool, str]:
@@ -607,9 +503,6 @@ class EditPlanService:
更新后的计划
"""
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}
updated = EditPlan(
@@ -626,85 +519,3 @@ class EditPlanService:
updated_at=plan.updated_at,
)
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
# 剩余为 overlay
for _ in range(1, n):
for i in range(1, n):
clips.append(
EditPlanClip.create(
plan_id=plan_id,
@@ -237,7 +237,7 @@ class PlanGeneratorService:
elif editing_mode == EditingMode.VOICE_OVER.value:
# N 个 main clipsB-roll
for _ in range(n):
for i in range(n):
clips.append(
EditPlanClip.create(
plan_id=plan_id,
@@ -271,7 +271,7 @@ class PlanGeneratorService:
)
order += 1
# 剩余为 b_roll
for _ in range(2, n):
for i in range(2, n):
clips.append(
EditPlanClip.create(
plan_id=plan_id,
@@ -284,7 +284,7 @@ class PlanGeneratorService:
else:
# ONE_TAKE: N 个 main clips
for _ in range(n):
for i in range(n):
clips.append(
EditPlanClip.create(
plan_id=plan_id,
-17
View File
@@ -33,7 +33,6 @@
"eslint-plugin-react-hooks": "^4.6.2",
"eslint-plugin-react-refresh": "^0.4.7",
"jsdom": "^24.1.0",
"prettier": "^3.0.0",
"typescript": "^5.5.3",
"vite": "^5.3.1",
"vitest": "^1.6.0"
@@ -4829,22 +4828,6 @@
"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": {
"version": "27.5.1",
"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-refresh": "^0.4.7",
"jsdom": "^24.1.0",
"prettier": "^3.0.0",
"typescript": "^5.5.3",
"vite": "^5.3.1",
"vitest": "^1.6.0"
+1 -1
View File
@@ -204,7 +204,7 @@ export const createAsset = async (data: {
/** 更新素材(名称、metadata 等) */
export const updateAsset = async (
assetId: string,
data: { name?: string; metadata?: AssetMetadata },
data: { name?: string; metadata?: Record<string, unknown> },
): Promise<AssetItem> => {
const response = await apiClient.put(`/assets/${assetId}`, data);
return response.data;
+1 -2
View File
@@ -129,8 +129,7 @@ apiClient.interceptors.response.use(
const safeExtractString = (val: unknown): string => {
if (typeof val === "string") return val;
if (typeof val === "object" && val !== null) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- 防御性错误提取,后端错误格式不固定
const obj = val as Record<string, any>;
const obj = val as Record<string, unknown>;
if (typeof obj.message === "string") return obj.message;
if (typeof obj.msg === "string") return obj.msg;
if (typeof obj.detail === "string") return obj.detail;
+14 -51
View File
@@ -97,30 +97,6 @@ export interface EditPlanConfig {
green_screen_config?: ChromaKeyConfig;
sticker_config?: StickerConfig;
cover_config?: CoverConfig;
/** 前端扩展:关联的素材 ID 列表 */
asset_ids?: string[];
/** 配音 ID */
voice_id?: string;
/** 克隆音色档案 ID */
voice_clone_profile_id?: string;
/** 自定义配音音频 URL */
custom_audio_url?: string;
/** 自定义配音文本 */
custom_text?: string;
/** 视频比例 */
ratio?: string;
/** 视频风格 */
style?: string;
/** 目标时长(秒) */
duration?: number;
/** 是否自动生成字幕 */
auto_subtitles?: boolean;
/** 是否启用 BGM */
bgm?: boolean;
/** 生成数量 */
generate_count?: number;
/** 素材模式 */
material_mode?: string;
}
/** 剪辑计划(后端响应) */
@@ -130,8 +106,6 @@ export interface EditPlan {
name: string;
status: EditPlanStatus;
total_duration: number;
/** 生成视频数量(后端 EditPlanResponse.result_count */
result_count: number;
config: EditPlanConfig;
created_at: string;
updated_at: string;
@@ -163,21 +137,14 @@ export interface GenerateResponse {
clip_count: number;
}
/** 剪辑计划关联的生成记录(实际是 GenerationTask 对象) */
/** 剪辑计划关联的生成记录 */
export interface EditPlanGeneration {
id: string; // 即 generation_task_id
source_edit_plan_id: string;
template_id: string;
asset_ids: string[];
id: string;
edit_plan_id: string;
generation_task_id: string;
status: EditPlanStatus;
progress: number;
result_count: number;
error_message: string;
error_info: Record<string, unknown>;
logs: Array<Record<string, unknown>>;
retry_count: number;
created_at?: string;
updated_at?: string;
created_at: string;
updated_at: string;
}
/** 片段生成状态 */
@@ -203,7 +170,7 @@ export interface GenerationStatusResponse {
export interface GeneratedVideo {
id: string;
project_id?: string;
generation_task_id?: string;
generation_task_id: string;
name: string;
file_url: string;
file_size?: number;
@@ -215,8 +182,6 @@ export interface GeneratedVideo {
status: string;
review_status?: string;
download_url?: string;
created_at?: string;
updated_at?: string;
}
/* ============================================================
@@ -261,15 +226,13 @@ export interface GenerateCoverRequest {
/** AI 封面生成响应 */
export interface GenerateCoverResponse {
plan_id: string;
cover: CoverResult;
}
/** 封面生成结果 */
export interface CoverResult {
scheme?: string;
asset_id?: string;
frame_time?: number;
thumbnail_url?: string;
cover: {
scheme?: string;
asset_id?: string;
frame_time?: number;
thumbnail_url?: string;
[key: string]: unknown;
};
}
/* ============================================================
+1 -13
View File
@@ -139,23 +139,11 @@ export interface GenerateFromTemplatePayload {
voiceover_duration: number;
}
/** 验证警告详情 */
export interface ValidationWarningDetails {
/** 相关字段名 */
field?: string;
/** 期望值 */
expected?: string | number;
/** 实际值 */
actual?: string | number;
/** 建议值 */
suggested?: string | number;
}
/** 验证/生成响应 */
export interface ValidateWarning {
code: string;
message: string;
details?: ValidationWarningDetails;
details?: Record<string, unknown>;
}
/** 使用模板生成响应 */
+21 -15
View File
@@ -1,6 +1,5 @@
/**
* 成品 / 视频相关 API
* 包含:列表查询、复核状态、批量下载
* 后端无 /products 路由,实际从 /generation/tasks 端点获取数据
*/
import apiClient from "./client";
@@ -61,26 +60,33 @@ export interface BatchDownloadStatus {
/**
* 将 generation task 数据映射为 ProductItem 格式
*/
function mapTaskToProductItem(task: GeneratedVideo): ProductItem {
function mapTaskToProductItem(
task: GeneratedVideo | Record<string, unknown>,
): ProductItem {
const video = task as GeneratedVideo;
return {
id: task.id,
title: task.name || "未命名视频",
video_url: task.file_url,
thumbnail_url: task.thumbnail_url,
duration_seconds: task.duration,
file_size: task.file_size,
id: video.id,
title: video.name || "未命名视频",
video_url: video.file_url,
thumbnail_url: video.thumbnail_url,
duration_seconds: video.duration,
file_size: video.file_size,
resolution:
task.width && task.height ? `${task.width}x${task.height}` : undefined,
video.width && video.height
? `${video.width}x${video.height}`
: undefined,
status:
task.status === "completed"
video.status === "completed"
? "completed"
: task.status === "failed"
: video.status === "failed"
? "failed"
: "processing",
review_status: task.review_status as ReviewStatus | undefined,
project_id: task.project_id,
created_at: task.created_at,
updated_at: task.updated_at,
review_status: video.review_status as ReviewStatus | undefined,
project_id: video.project_id,
created_at: (task as Record<string, unknown>).created_at as
string | undefined,
updated_at: (task as Record<string, unknown>).updated_at as
string | undefined,
};
}
+4 -9
View File
@@ -16,22 +16,17 @@ import type { EditPlanConfig } from "./editPlans";
/** 模板条目(后端 TemplateResponse */
export interface TemplateItem {
id: string;
user_id?: string;
name: string;
description?: string;
mode?: string;
description: string;
category: string;
tags?: string[];
/** 预估时长(后端字段名 estimated_duration */
estimated_duration?: number;
/** @deprecated 后端已改名为 estimated_duration,保留兼容 */
target_duration?: number;
clip_count?: number;
target_duration: number;
clip_count: number;
/** 使用次数 */
usage_count?: number;
thumbnail_url?: string;
preview_url?: string;
is_active?: boolean;
is_active: boolean;
is_favorite?: boolean;
/** 素材规则(片段配置) */
segments?: TemplateSegment[];
+2 -14
View File
@@ -8,18 +8,6 @@ import apiClient from "./client";
/* ── 类型定义 ──────────────────────────────────── */
/** TTS 元数据(合成时附带的扩展信息) */
export interface TTSMetadata {
/** 语音时长(秒) */
duration?: number;
/** 采样率(Hz */
sample_rate?: number;
/** 语言 */
language?: string;
/** 其他扩展字段 */
[key: string]: unknown;
}
/** TTS 合成请求参数 */
export interface TTSSynthesizeRequest {
text: string;
@@ -30,7 +18,7 @@ export interface TTSSynthesizeRequest {
voice_model?: string;
voice_clone_profile_id?: string;
format?: string;
metadata?: TTSMetadata;
metadata?: Record<string, unknown>;
}
/** TTS 合成创建响应 */
@@ -61,7 +49,7 @@ export interface TTSJob {
error_message: string | null;
retry_count: number;
max_retries: number;
metadata_: TTSMetadata | null;
metadata_: Record<string, unknown> | null;
created_at: string;
updated_at: string;
}
+2 -14
View File
@@ -36,18 +36,6 @@ export interface CreateVoiceCloneRequest {
/* ── 后端 API 类型 ────────────────────────────────────── */
/** 音色克隆元数据(克隆时附带的扩展信息) */
export interface VoiceCloneMetadata {
/** 语音时长(秒) */
duration?: number;
/** 采样率(Hz */
sample_rate?: number;
/** 音色 ID(克隆完成后分配) */
voice_id?: string;
/** 其他扩展字段 */
[key: string]: unknown;
}
/** 后端克隆档案响应 */
export interface VoiceCloneProfile {
id: string;
@@ -63,7 +51,7 @@ export interface VoiceCloneProfile {
error_message: string | null;
retry_count: number;
max_retries: number;
metadata_: VoiceCloneMetadata | null;
metadata_: Record<string, unknown> | null;
created_at: string;
updated_at: string;
}
@@ -92,7 +80,7 @@ export interface CreateVoiceCloneRequestFull {
language?: string;
gender?: string;
max_retries?: number;
metadata_?: VoiceCloneMetadata;
metadata_?: Record<string, unknown>;
}
/* ── 辅助函数 ─────────────────────────────────────────── */
+2 -4
View File
@@ -6,7 +6,7 @@ import React from "react";
import ReactDOM from "react-dom/client";
import { RouterProvider } from "react-router-dom";
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 router from "./router";
import "./index.css";
@@ -91,9 +91,7 @@ ReactDOM.createRoot(document.getElementById("root")!).render(
<React.StrictMode>
<QueryClientProvider client={queryClient}>
<ConfigProvider locale={zhCN} theme={theme}>
<AntApp>
<RouterProvider router={router} />
</AntApp>
<RouterProvider router={router} />
</ConfigProvider>
</QueryClientProvider>
</React.StrictMode>,
@@ -33,9 +33,8 @@ const formatSize = (bytes: number) => {
/** 格式化时长 */
const formatDuration = (seconds?: number) => {
if (!seconds) return "-";
const totalSec = Math.round(seconds);
const m = Math.floor(totalSec / 60);
const s = totalSec % 60;
const m = Math.floor(seconds / 60);
const s = seconds % 60;
return m > 0 ? `${m}${s}` : `${s}`;
};
@@ -59,9 +59,8 @@ const formatSize = (bytes: number) => {
/** 格式化时长 */
const formatDuration = (seconds?: number) => {
if (!seconds) return "-";
const totalSec = Math.round(seconds);
const m = Math.floor(totalSec / 60);
const s = totalSec % 60;
const m = Math.floor(seconds / 60);
const s = seconds % 60;
return m > 0 ? `${m}${s}` : `${s}`;
};
+2 -13
View File
@@ -86,9 +86,8 @@ const STATUS_CONFIG: Record<
/** 格式化时长 */
const formatDuration = (seconds: number): string => {
if (seconds <= 0) return "-";
const totalSec = Math.round(seconds);
const m = Math.floor(totalSec / 60);
const s = totalSec % 60;
const m = Math.floor(seconds / 60);
const s = seconds % 60;
if (m === 0) return `${s}`;
return `${m}${s > 0 ? `${s}` : ""}`;
};
@@ -254,16 +253,6 @@ export default function EditPlans() {
<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: "创建时间",
dataIndex: "created_at",
@@ -1812,27 +1812,7 @@
═══════════════════════════════════════ */
.ep-status-bar {
display: flex;
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;
display: none;
}
/* ═══════════════════════════════════════
@@ -3,8 +3,8 @@
* 四行布局:顶栏(42px) → 模式栏(48px) → 三栏主体 → 底栏(40px)
*/
import React, { useState, useCallback, useEffect, useRef } from "react";
import { useSearchParams } from "react-router-dom";
import { message, Modal, Progress, Button } from "antd";
import { useSearchParams, useNavigate } from "react-router-dom";
import { message } from "antd";
import { useQuery } from "@tanstack/react-query";
import type {
EditingTemplate,
@@ -20,23 +20,11 @@ import {
getTemplateCategories,
MODE_LABELS,
} from "@/api/editingPlanner";
import type {
EditPlanGeneration,
EditPlanConfig,
GeneratedVideo,
MediaAsset,
TransitionEffect,
} from "@/api/editPlans";
import type { EditPlanGeneration, MediaAsset } from "@/api/editPlans";
import {
getMediaAssets,
getEditPlanGenerations,
generateCover,
getEditPlan,
createEditPlan,
updateEditPlan,
generateEditPlan,
getGenerationStatus,
getGenerationTaskResults,
} from "@/api/editPlans";
import { useUndoRedo } from "./hooks/useUndoRedo";
import type {
@@ -45,7 +33,6 @@ import type {
TransitionConfig,
SpeedConfig,
TtsConfig,
TtsMode,
TrimConfig,
WatermarkConfig,
IntroOutroConfig,
@@ -120,8 +107,8 @@ const FILTER_CATEGORIES = ["全部", "种草", "知识", "日常", "推荐"];
const EditingPlanner: React.FC = () => {
const [searchParams] = useSearchParams();
const navigate = useNavigate();
const urlTemplateId = searchParams.get("templateId") || "";
const urlPlanId = searchParams.get("planId") || "";
/* ── 模板列表 ── */
const [templates, setTemplates] = useState<EditingTemplate[]>([]);
@@ -257,21 +244,6 @@ const EditingPlanner: React.FC = () => {
const [genHistory, setGenHistory] = useState<EditPlanGeneration[]>([]);
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 [currentTime, setCurrentTime] = useState(0);
@@ -402,85 +374,6 @@ const EditingPlanner: React.FC = () => {
.catch(() => message.error("加载模板详情失败"));
}, [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);
@@ -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 = () => {
setSaveModalOpen(true);
@@ -929,148 +763,94 @@ const EditingPlanner: React.FC = () => {
};
/**
* 剪辑计划生成
* 1. 有 planId → 更新计划配置 + 触发生成
* 2. 无 planId(从模板库直接进入)→ 先创建计划 + 触发生成
* 3. 触发生成后轮询状态,完成后获取视频结果
* 跳转到一键生成页面
* 通过 URL SearchParams 传递 edit_plan_id 和完整 planConfigJSON 序列化)
* 一键生成页面从 params 解析配置,无需重复请求接口
*/
const handleGoToGenerate = async () => {
if (!loadedTemplateId) {
message.warning("请先选择一个模板");
return;
}
if (clips.length === 0) {
message.warning("请先添加片段");
return;
}
setGenerating(true);
setGenerated(false);
setGeneratedVideos([]);
setGenError(null);
setGenProgress(0);
try {
const config = buildPlanConfig();
let planId = loadedPlanId;
if (planId) {
// 已有计划 → 先重置状态为 draftfailed/editing 等非 draft 状态会被后端拒绝更新和生成)
try {
await updateEditPlan(planId, { status: "draft" });
} catch (resetErr) {
console.warn("[状态重置跳过]", resetErr);
}
// 再更新配置
try {
await updateEditPlan(planId, {
config,
total_duration: totalDuration,
});
} catch (updateErr) {
console.warn("[计划更新跳过]", updateErr);
}
} else {
// 无计划 → 创建新计划
const plan = await createEditPlan({
template_id: loadedTemplateId,
name: draftName || "未命名计划",
config,
total_duration: totalDuration,
});
planId = plan.id;
setLoadedPlanId(planId);
// 更新 URL 参数(不刷新页面)
const params = new URLSearchParams(window.location.search);
params.set("planId", planId);
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);
const handleGoToGenerate = () => {
const planConfig = {
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,
bold: titleSettings.bold,
italic: titleSettings.italic,
stroke: titleSettings.stroke,
shadow: titleSettings.shadow,
},
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,
},
mode: currentMode,
total_duration: totalDuration,
segments: clips.map((c, i) => ({
order: i,
material_type: c.type === "voice" ? "voiceover" : "video",
duration: c.duration,
template_segment_id: c.template_segment_id,
script_text: c.script_text,
voice_asset_id: c.voice_asset_id,
voice_file_url: c.voice_file_url,
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,
}
}
message.success("视频生成完成!");
return; // 停止轮询
}
if (status.plan_status === "failed") {
setGenerating(false);
setGenError("生成失败,请重试");
return; // 停止轮询
}
// 继续轮询
genTimerRef.current = setTimeout(poll, 2000);
} catch (err) {
console.error("[轮询状态失败]", err);
genTimerRef.current = setTimeout(poll, 5000); // 出错后 5 秒重试
}
: 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 },
};
// 首次延迟 2 秒后开始
genTimerRef.current = setTimeout(poll, 2000);
const params = new URLSearchParams();
if (loadedTemplateId) {
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 targetId = loadedPlanId || loadedTemplateId;
if (!targetId) {
message.warning("请先加载一个模板或计划");
if (!loadedTemplateId) {
message.warning("请先加载一个模板");
return;
}
setGenHistoryOpen(true);
setGenHistoryLoading(true);
try {
const items = await getEditPlanGenerations(targetId);
const items = await getEditPlanGenerations(loadedTemplateId);
setGenHistory(items);
} catch {
message.error("加载生成历史失败");
@@ -1119,9 +899,8 @@ const EditingPlanner: React.FC = () => {
<button
className="ep-btn ep-btn-primary"
onClick={handleGoToGenerate}
disabled={generating}
>
{loadedPlanId ? "🎬 生成视频" : "🎬 创建计划并生成"}
🎬 使
</button>
</div>
</div>
@@ -1286,111 +1065,6 @@ const EditingPlanner: React.FC = () => {
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 ═══ */}
<BgmSelector
open={bgmDrawerOpen}
@@ -65,7 +65,7 @@ const GenerationHistoryModal: React.FC<GenerationHistoryModalProps> = ({
return (
<tr key={gen.id} className="ep-gh-table-row">
<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 className="ep-gh-td">
<span className={`ep-gh-status-tag ${statusClass}`}>
+7 -16
View File
@@ -202,8 +202,8 @@ const GeneratePage: React.FC = () => {
try {
const plan = await getEditPlan(editPlanId);
if (plan.name) setTitle(plan.name);
const cfg = plan.config;
if (cfg?.asset_ids) {
const cfg = plan.config as Record<string, unknown>;
if (cfg && Array.isArray(cfg.asset_ids)) {
setSelectedMaterials(
cfg.asset_ids.filter((v): v is string => typeof v === "string"),
);
@@ -477,13 +477,7 @@ const GeneratePage: React.FC = () => {
setGenerateError(null);
try {
const voiceConfig: Pick<
EditPlanConfig,
| "voice_id"
| "voice_clone_profile_id"
| "custom_audio_url"
| "custom_text"
> = {};
const voiceConfig: Record<string, unknown> = {};
if (voiceMode === "preset") {
voiceConfig.voice_id = selectedVoice || undefined;
} else if (voiceMode === "clone") {
@@ -508,7 +502,7 @@ const GeneratePage: React.FC = () => {
bgm,
generate_count: generateCount,
material_mode: materialMode,
},
} as EditPlanConfig,
total_duration: duration,
source_edit_plan_id: editPlanId || undefined,
});
@@ -567,8 +561,7 @@ const GeneratePage: React.FC = () => {
const safeExtract = (val: unknown): string => {
if (typeof val === "string") return val;
if (typeof val === "object" && val !== null) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- 防御性错误提取,后端错误格式不固定
const obj = val as Record<string, any>;
const obj = val as Record<string, unknown>;
if (typeof obj.message === "string") return obj.message;
if (typeof obj.msg === "string") return obj.msg;
if (typeof obj.detail === "string") return obj.detail;
@@ -628,8 +621,7 @@ const GeneratePage: React.FC = () => {
const extractString = (val: unknown): string => {
if (typeof val === "string") return val;
if (typeof val === "object" && val !== null) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- 防御性错误提取,后端错误格式不固定
const obj = val as Record<string, any>;
const obj = val as Record<string, unknown>;
if (typeof obj.message === "string") return obj.message;
if (typeof obj.msg === "string") return obj.msg;
if (typeof obj.detail === "string") return obj.detail;
@@ -659,8 +651,7 @@ const GeneratePage: React.FC = () => {
const safeExtractErr = (val: unknown): string => {
if (typeof val === "string") return val;
if (typeof val === "object" && val !== null) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- 防御性错误提取,后端错误格式不固定
const obj = val as Record<string, any>;
const obj = val as Record<string, unknown>;
if (typeof obj.message === "string") return obj.message;
if (typeof obj.msg === "string") return obj.msg;
if (typeof obj.detail === "string") return obj.detail;
@@ -23,6 +23,7 @@ import {
getTemplate,
toggleFavoriteTemplate,
copyTemplate,
generateFromTemplate,
type TemplateItem,
type TemplateListParams,
type TemplateSegment,
@@ -90,29 +91,18 @@ const gradientForCategory = (category: string): string => {
};
/** 格式化时长 */
const formatDuration = (seconds: number | undefined | null): string => {
if (!seconds || seconds <= 0) return "0秒";
const totalSec = Math.round(seconds);
const m = Math.floor(totalSec / 60);
const s = totalSec % 60;
const formatDuration = (seconds: number): string => {
if (seconds <= 0) return "0秒";
const m = Math.floor(seconds / 60);
const s = seconds % 60;
if (m === 0) return `${s}`;
return `${m}${s > 0 ? `${s}` : ""}`;
};
/** 配置展示字段(formatConfig 提取通用配置的可读属性) */
interface ConfigDisplayFields {
font_size?: string | number;
font_family?: string;
color?: string;
position?: string;
volume?: string | number;
name?: string;
}
/** 格式化配置对象为可读文本 */
const formatConfig = (config?: object): string => {
if (!config || Object.keys(config).length === 0) return "默认";
const c = config as ConfigDisplayFields;
const c = config as Record<string, unknown>;
const parts: string[] = [];
if (c.font_size) parts.push(`字号: ${c.font_size}`);
if (c.font_family) parts.push(`字体: ${c.font_family}`);
@@ -237,9 +227,7 @@ const TemplateDetailModal: React.FC<TemplateDetailModalProps> = ({
{
key: "duration",
label: "目标时长",
children: formatDuration(
template.estimated_duration ?? template.target_duration,
),
children: formatDuration(template.target_duration),
},
{
key: "clips",
@@ -411,9 +399,7 @@ const TemplateCard: React.FC<TemplateCardProps> = ({
<div className="xx-template-thumb-name">{template.name}</div>
<div className="xx-template-thumb-meta">
<span className="xx-template-thumb-duration">
{formatDuration(
template.estimated_duration ?? template.target_duration,
)}
{formatDuration(template.target_duration)}
</span>
</div>
<div className="xx-template-preview-hint"></div>
@@ -541,6 +527,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(
(id: string, e?: React.MouseEvent) => {
@@ -573,12 +572,15 @@ const TemplateLibrary: React.FC = () => {
[copyMutation],
);
/** 使用模板 → 进入剪辑编辑器配置 */
/** 使用模板 → 生成剪辑计划 */
const handleUse = useCallback(
(template: TemplateItem) => {
navigate(`/app/editing-planner?templateId=${template.id}`);
generateMutation.mutate({
templateId: template.id,
name: `基于「${template.name}」的剪辑计划`,
});
},
[navigate],
[generateMutation, navigate],
);
/** 搜索防抖处理 */
@@ -130,20 +130,12 @@ const mapAssetToMaterial = (asset: AssetItem): VoiceMaterial => {
};
};
/** 配音素材上传元数据(传递给 createAsset 的 metadata */
interface VoiceAssetMetadata {
gender: VoiceGender;
description: string;
duration: number;
[key: string]: unknown;
}
/** 前端表单数据 → 后端 metadata(标签走独立 API,不再写 metadata.style */
const buildMetadata = (data: {
gender: VoiceGender;
description: string;
duration?: number;
}): VoiceAssetMetadata => ({
}): Record<string, unknown> => ({
gender: data.gender,
description: data.description,
duration: data.duration || 0,
+2 -10
View File
@@ -623,20 +623,12 @@ const getAudioDuration = (file: File): Promise<number> =>
audio.src = url;
});
/** 音色上传元数据(传递给 createAsset 的 metadata */
interface VoiceUploadMetadata {
gender?: string;
description?: string;
duration?: number;
[key: string]: unknown;
}
const buildVoiceMetadata = (data: {
gender?: string;
description?: string;
duration?: number;
}): VoiceUploadMetadata => {
const metadata: VoiceUploadMetadata = {};
}): Record<string, unknown> => {
const metadata: Record<string, unknown> = {};
if (data.gender) metadata.gender = data.gender;
if (data.description) metadata.description = data.description;
if (data.duration) metadata.duration = Math.round(data.duration);
-1
View File
@@ -37,7 +37,6 @@ export default defineConfig({
},
},
build: {
cache: true,
rollupOptions: {
output: {
manualChunks: {
@@ -181,9 +181,9 @@ def _validate_video_path(video_path: str, work_dir: Path) -> None:
resolved_work_dir = work_dir.resolve()
try:
resolved_path.relative_to(resolved_work_dir)
except ValueError as _e:
except ValueError:
if not is_in_allowed_dirs(resolved_path):
raise PathSecurityError(f"视频路径不在允许目录内: {video_path[:80]}") from _e
raise PathSecurityError(f"视频路径不在允许目录内: {video_path[:80]}")
# URL类型路径不做本地路径校验(由下载阶段的SSRF防护负责)
# 但检查扩展名
else:
+1 -1
View File
@@ -356,7 +356,7 @@ def check_duplicate_task(self: Task, generated_video_id: str) -> dict:
except Exception as e:
logger.error(f"Duplicate check failed for {generated_video_id}: {str(e)}")
session.rollback()
raise self.retry(exc=e, countdown=60) from e
raise self.retry(exc=e, countdown=60)
finally:
session.close()
import shutil
+63 -14
View File
@@ -1,28 +1,23 @@
"""FFmpeg 工具函数 — Worker 层.
"""FFmpeg 工具函数 — 共享原语.
业务相关的滤镜构建视频探测视频标准化等能力放在这里
底层原语run_ffmpeg / 二进制路径 / 默认超时已下沉到 packages/shared/ffmpeg_utils.py
本模块 re-export 保持向后兼容
提供 FFmpeg / FFprobe 调用视频信息探测视频标准化xfade 转场滤镜构建
底层能力 UnifiedRenderServiceVideoComposeService 等复用
"""
from __future__ import annotations
import logging
import shutil
import subprocess # nosec B404
from pathlib import Path
from typing import Any
# 底层原语从 shared 层导入,application 层和 worker 层共用同一份实现
from shared.ffmpeg_utils import ( # noqa: F401
DEFAULT_FFMPEG_TIMEOUT,
FFMPEG_BIN,
FFPROBE_BIN,
run_ffmpeg,
)
logger = logging.getLogger(__name__)
# ── 常量Worker 层业务相关) ────────────────────────────────────────────────
# ── 常量 ──────────────────────────────────────────────────────────────────────
FFMPEG_BIN: str = shutil.which("ffmpeg") or "ffmpeg"
FFPROBE_BIN: str = shutil.which("ffprobe") or "ffprobe"
DEFAULT_OUTPUT_WIDTH = 1280
DEFAULT_OUTPUT_HEIGHT = 720
@@ -66,8 +61,62 @@ XFADE_TRANSITION_MAP: dict[str, str] = {
DEFAULT_TRANSITION_DURATION = 0.5
# FFmpeg 执行默认超时(秒),防止 FFmpeg hang 住导致 worker 永久阻塞
# 默认 30 分钟,足够处理大部分短视频渲染;超长视频可单独传参覆盖
DEFAULT_FFMPEG_TIMEOUT = 1800
# ── FFprobe 探测 ──────────────────────────────────────────────────────────────
# ── FFmpeg 执行 ───────────────────────────────────────────────────────────────
def run_ffmpeg(
command: list[str],
*,
capture_output: bool = True,
timeout: int | None = DEFAULT_FFMPEG_TIMEOUT,
) -> tuple[str, str]:
"""执行 FFmpeg 命令。
Args:
command: 完整的 ffmpeg 命令列表 "ffmpeg" 本身
capture_output: 是否捕获 stdout/stderr
timeout: 超时时间默认 1800s30分钟None 表示不设超时不推荐
Returns:
(stdout, stderr) 元组
Raises:
subprocess.CalledProcessError: 命令执行失败时抛出
异常信息包含完整 stderr 以便排查
subprocess.TimeoutExpired: 超时未完成时抛出FFmpeg 进程会被 kill
"""
try:
result = subprocess.run( # nosec B603
command,
check=True,
stdout=subprocess.PIPE if capture_output else None,
stderr=subprocess.PIPE if capture_output else None,
text=True,
timeout=timeout,
)
return (result.stdout or "", result.stderr or "")
except subprocess.TimeoutExpired:
logger.error(
"FFmpeg 命令超时 (%ds): command=%s",
timeout or -1,
" ".join(str(c) for c in command[:20]),
)
raise
except subprocess.CalledProcessError as e:
# 把完整 stderr 打到日志,方便排查 exit code 183 等问题
stderr_text = (e.stderr or "").strip()
logger.error(
"FFmpeg 命令失败: exit_code=%d command=%s\nstderr:\n%s",
e.returncode,
" ".join(str(c) for c in command[:20]), # 截断过长的命令
stderr_text[:5000], # 截断过长的 stderr
)
raise
def run_ffprobe(
@@ -191,9 +191,9 @@ def _validate_audio_path(audio_path: str, work_dir: Path) -> None:
resolved_work_dir = work_dir.resolve()
try:
resolved_path.relative_to(resolved_work_dir)
except ValueError as _e:
except ValueError:
if not is_in_allowed_dirs(resolved_path):
raise PathSecurityError(f"音频路径不在允许目录内: {audio_path[:80]}") from _e
raise PathSecurityError(f"音频路径不在允许目录内: {audio_path[:80]}")
# URL类型路径不做本地路径校验(由下载阶段的SSRF防护负责)
# 但检查扩展名
else:
@@ -129,8 +129,8 @@ def safe_resolve_path(
if not allow_outside:
try:
full_path.relative_to(base_dir)
except ValueError as _e:
raise PathSecurityError(f"路径遍历检测:路径 '{path_str}' 超出基路径 '{base_dir}' 范围") from _e
except ValueError:
raise PathSecurityError(f"路径遍历检测:路径 '{path_str}' 超出基路径 '{base_dir}' 范围")
# 扩展名校验
if allowed_extensions is not None:
+1 -1
View File
@@ -403,7 +403,7 @@ class PiPEngine:
input_args: list[str] = []
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)])
@@ -359,7 +359,7 @@ class StickerEngine:
image_stickers: list[ImageStickerConfig] = []
image_paths: list[str] = []
for _, s in enumerate(stickers):
for i, s in enumerate(stickers):
try:
sticker_type = s.get("type", "image")
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()
try:
resolved_path.relative_to(resolved_work_dir)
except ValueError as _e:
except ValueError:
if not is_in_allowed_dirs(resolved_path):
raise PathSecurityError(f"字幕路径不在允许目录内: {subtitle_path[:80]}") from _e
raise PathSecurityError(f"字幕路径不在允许目录内: {subtitle_path[:80]}")
+2 -2
View File
@@ -186,8 +186,8 @@ class TrimEngine:
FFmpeg filter 字符串 "[0:v]trim=start=10:duration=5,setpts=PTS-STARTPTS[v0_trimmed]"
"""
if trim.is_noop:
# 不裁剪,直接直通(仅重置时间戳)
return f"{input_label}setpts=PTS-STARTPTS{output_label}"
# 不裁剪,直接直通
return f"{input_label}copy{output_label}" if False else f"{input_label}setpts=PTS-STARTPTS{output_label}"
parts: list[str] = []
+19 -31
View File
@@ -640,9 +640,11 @@ class UnifiedRenderService:
return timeline
def _extract_audio(self, video_path: Path, output_path: Path) -> None:
"""从视频中提取音频为16kHz单声道wav(ASR友好格式)"""
"""从视频中提取音频为16kHz单声道wav(ASR友好格式)."""
from video_processing.ffmpeg_utils import run_ffmpeg
cmd = [
FFMPEG_BIN,
"ffmpeg",
"-y",
"-i",
str(video_path),
@@ -1363,37 +1365,23 @@ class UnifiedRenderService:
# 单 clip 层,直接使用预处理标签
layer_output_labels[layer.role] = layer_labels[0]
else:
# 多 clip 层,用 TransitionEngine 构建转场链
out_label = f"{layer.role}_merged"
# 判断是否全部为硬切:是则用 concat filter,否则用 xfade 转场链
all_cut = all(
t is None or t == "" or str(t).lower() == "cut"
for t in layer_transitions[1:] # 第一个 clip 的转场忽略
# 计算该层使用的转场时长(取首个非零值,否则用默认)
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 all_cut:
# 全硬切:用 concat filter,性能远优于 xfade
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)
if xfade_filter:
filter_parts.append(xfade_filter)
layer_output_labels[layer.role] = out_label
# Step 3: 合成各层
@@ -187,7 +187,7 @@ class AssetAnalyzer:
if self._frames is not None:
return self._frames
frames: list[np.ndarray] = []
frames = []
info = self.get_video_info()
if info.duration <= 0:
@@ -398,7 +398,7 @@ class AssetAnalyzer:
run_ffmpeg(cmd, timeout=30)
except Exception:
# 音频提取失败,返回默认分析结果
return AudioAnalysis( # type: ignore[call-arg]
return AudioAnalysis(
has_speech=False,
speech_ratio=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()
user_id = job.created_by_user_id or None
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":
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])
except Exception:
logger.exception("更新 Job 失败状态时出错")
raise self.retry(exc=exc, countdown=60) from exc
raise self.retry(exc=exc, countdown=60)
finally:
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
resolver = get_render_engine_resolver()
engine = 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
return resolver.get_engine(user_id=user_id)
except Exception as exc:
logger.warning("获取渲染引擎配置失败,fallback 到 legacy: %s", exc, exc_info=True)
logger.warning("获取渲染引擎配置失败,fallback 到 legacy: %s", exc)
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.error_message = error_msg
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)
@@ -168,13 +148,9 @@ def _finalize_render_success(
clip.mark_rendered()
clip_repo.update(clip)
# 更新 EditPlan 状态为 completed + 回写实际渲染时长 + 结果数
# 更新 EditPlan 状态为 completed
plan.config["rendered_url"] = output_url or ""
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_repo.update(plan)
@@ -184,15 +160,7 @@ def _finalize_render_success(
if gen_task:
gen_task.status = "completed"
gen_task.progress = 100.0
# 剪辑计划是多片段合成 1 个成片,result_count = 1
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.result_count = len(rendered_clip_ids)
gen_task.completed_at = datetime.now(timezone.utc)
gen_task_repo.update(gen_task)
@@ -397,13 +365,6 @@ def render_edit_plan(self, plan_id: str) -> dict:
if gen_task:
gen_task.status = "running"
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)
# 3. 下载素材并构建 asset_path_map
@@ -468,28 +429,9 @@ def render_edit_plan(self, plan_id: str) -> dict:
gen_task.status = "failed"
gen_task.error_message = "所有片段素材下载失败"
gen_task.completed_at = datetime.now(timezone.utc)
gen_task.append_log(
stage="download_failed",
message="所有片段素材下载失败",
level="ERROR",
)
gen_task_repo.update(gen_task)
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. 根据引擎选择渲染方式
if engine == "unified":
result = _render_with_unified(
@@ -541,15 +483,6 @@ def render_edit_plan(self, plan_id: str) -> dict:
gen_task.status = "failed"
gen_task.error_message = f"渲染异常: {type(exc).__name__}: {exc}"
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)
logger.info(
"GenerationTask 已标记为 failed: task_id=%s plan_id=%s",
@@ -560,6 +493,6 @@ def render_edit_plan(self, plan_id: str) -> dict:
logger.warning(
"更新 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": "数据库连接失败"}
+12 -75
View File
@@ -433,32 +433,24 @@ def _prepare_bgm_track(
return None
def _verify_url_accessible(
url: str,
timeout: float = 10.0,
retries: int = 2,
max_redirects: int = 5,
) -> bool:
def _verify_url_accessible(url: str, timeout: float = 10.0, retries: int = 2) -> bool:
"""HEAD 请求校验 URL 可访问(含重试,防止 OSS 抖动误报)。
安全增强
安全
- 请求前先做 SSRF 安全校验内网IP/回环地址/链路本地地址等
- scheme 仅允许 http/https
- 端口仅允许 80/443
- 手动跟随重定向每一跳 URL 都做 SSRF 校验避免重定向到内网地址绕过
Args:
url: 待校验的 URL
timeout: 单次请求超时时间
retries: 最大重试次数默认 2 首次失败后间隔 1s 重试
max_redirects: 最大重定向次数默认 5
Returns:
True 表示 URL 可访问HTTP 2xx/3xxFalse 表示所有尝试均失败或安全校验不通过
"""
import time
import urllib.request
from urllib.parse import urljoin
from video_processing.url_security import UrlSecurityError, validate_url_safety
@@ -470,56 +462,14 @@ def _verify_url_accessible(
return False
last_error: Exception | None = None
def _do_verify(current_url: str) -> bool:
"""单次校验:手动跟随重定向,每跳都做 SSRF 检查."""
redirect_count = 0
url_being_checked = current_url
# 禁止自动重定向的 handler,手动控制每一跳
class NoRedirect(urllib.request.HTTPRedirectHandler):
def redirect_request(self, req, fp, code, msg, headers, newurl): # noqa: N802
return None
opener = urllib.request.build_opener(NoRedirect())
while redirect_count <= max_redirects:
# 每一跳都做 SSRF 安全校验
try:
safe_url = validate_url_safety(url_being_checked, purpose="url_verify")
except UrlSecurityError as e:
logger.warning(
"URL校验跳转地址不安全: redirect=%d url=%s error=%s",
redirect_count,
url_being_checked,
e,
)
raise
req = urllib.request.Request(safe_url, method="HEAD")
req.add_header("User-Agent", "xiaoxia-saas-worker/1.0")
with opener.open(req, timeout=timeout) as resp: # noqa: S310
if 200 <= resp.status < 300:
return True
if resp.status in (301, 302, 303, 307, 308):
location = resp.headers.get("Location", "")
if not location:
raise Exception(f"HTTP {resp.status} 但无 Location 头")
# 相对路径转绝对
url_being_checked = urljoin(safe_url, location)
redirect_count += 1
continue
if resp.status < 400:
return True
raise Exception(f"HTTP {resp.status}")
raise Exception(f"重定向次数超过上限 ({max_redirects})")
for attempt in range(1 + retries):
try:
if _do_verify(url):
return True
req = urllib.request.Request(url, method="HEAD")
req.add_header("User-Agent", "xiaoxia-saas-worker/1.0")
with urllib.request.urlopen(req, timeout=timeout) as resp: # nosec B310
if resp.status < 400:
return True
last_error = Exception(f"HTTP {resp.status}")
except Exception as e:
last_error = e
@@ -787,23 +737,10 @@ def _resolve_render_engine(user_id: str) -> str:
from video_processing.render_engine_resolver import get_render_engine_resolver
resolver = get_render_engine_resolver()
engine = 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
return resolver.get_engine(user_id=user_id)
except Exception as exc:
# 异常时 fallback 到 legacy(保守策略,与 edit_plan_generation 一致)
logger.warning("获取渲染引擎配置失败,fallback 到 legacy: %s", exc, exc_info=True)
return ENGINE_LEGACY
logger.warning("获取渲染引擎配置失败,fallback 到 unified: %s", exc)
return ENGINE_UNIFIED
# ── 旧引擎渲染(FFmpeg filter_complex) ────────────────────────────────────────
@@ -1418,7 +1355,7 @@ def generate_video(self, task_id: str) -> dict:
_repo = SQLAlchemyGenerationTaskRepository(_session)
gen_task = _repo.get(task_id)
if gen_task:
gen_task.append_log( # type: ignore[misc]
gen_task.append_log(
"任务失败",
str(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["codec"] = stream.get("codec_name", "")
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
# 提取格式信息
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["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():
exif = img._getexif()
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:
logger.warning("Pillow not available for image metadata extraction")
except Exception as e:
@@ -63,7 +63,7 @@ def process_tts_synthesis(self: Task, job_id: str) -> dict:
if session is not None:
session.rollback()
# 超时重试,指数退避
raise self.retry(exc=e, countdown=30) from e
raise self.retry(exc=e, countdown=30)
except CosyVoiceError as 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}")
if session is not None:
session.rollback()
raise self.retry(exc=e, countdown=60) from e
raise self.retry(exc=e, countdown=60)
except CosyVoiceError as 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:
session.rollback()
# 超时属于临时性故障,延迟 30 秒后重试
raise self.retry(exc=e, countdown=30) from e
raise self.retry(exc=e, countdown=30)
except CosyVoiceError as 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:
logger.error(f"Voice extraction failed for {asset_id}: {str(e)}")
session.rollback()
raise self.retry(exc=e, countdown=60) from e
raise self.retry(exc=e, countdown=60)
finally:
session.close()
import shutil
@@ -141,7 +141,7 @@ def extract_background_task(self: Task, asset_id: str) -> dict:
except Exception as e:
logger.error(f"Background extraction failed for {asset_id}: {str(e)}")
session.rollback()
raise self.retry(exc=e, countdown=60) from e
raise self.retry(exc=e, countdown=60)
finally:
session.close()
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",
"unique": false
},
{
"index": false,
"name": "result_count",
"nullable": false,
"primary_key": false,
"type": "INTEGER",
"unique": false
},
{
"index": false,
"name": "config",
+31 -87
View File
@@ -1,104 +1,48 @@
# ============================================================
# Worker Dockerfile - 优化版(多阶段构建 + 镜像瘦身)
# 优化
# 1. 多阶段构建:builder 阶段安装编译依赖,runtime 阶段只保留运行时
# 2. ffmpeg 静态编译替换:从 apt 安装(457MB) 改为静态二进制(~80MB)
# 3. Python 依赖瘦身:strip .so 调试符号 + 清理测试文件 + 清理缓存
# Worker Dockerfile - 专门用于 Celery Worker
# 优化:依赖分层缓存,基础大包和业务依赖分开
# ============================================================
# ==================== Builder 阶段 ====================
FROM git.xiaoxiajianji.com/xiaoxia/base/python:3.12-slim AS builder
# 基础镜像:Python 3.12 + ffmpeg
FROM git.xiaoxiajianji.com/xiaoxia/base/python:3.12-slim
# 使用阿里云镜像加速
RUN sed -i 's|deb.debian.org|mirrors.aliyun.com|g' /etc/apt/sources.list.d/debian.sources 2>/dev/null || \
sed -i 's|deb.debian.org|mirrors.aliyun.com|g' /etc/apt/sources.list 2>/dev/null || true
# 安装编译工具(仅 builder 需要)
RUN apt-get update && apt-get install -y --no-install-recommends \
gcc \
g++ \
python3-dev \
binutils \
wget \
xz-utils \
&& rm -rf /var/lib/apt/lists/*
# ---- 下载静态编译 ffmpeg ----
# 使用 johnvansickle.com 的静态编译版本(业界标准)
RUN cd /tmp \
&& wget -q https://johnvansickle.com/ffmpeg/releases/ffmpeg-release-amd64-static.tar.xz \
&& tar xf ffmpeg-release-amd64-static.tar.xz \
&& cp ffmpeg-*-amd64-static/ffmpeg /usr/local/bin/ffmpeg \
&& cp ffmpeg-*-amd64-static/ffprobe /usr/local/bin/ffprobe \
&& chmod +x /usr/local/bin/ffmpeg /usr/local/bin/ffprobe \
&& rm -rf ffmpeg-*
# ---- 安装 Python 依赖 ----
WORKDIR /tmp
# 创建 venv
RUN python -m venv /opt/venv
ENV PATH="/opt/venv/bin:$PATH"
# 基础依赖
COPY requirements-base.txt /tmp/requirements-base.txt
RUN pip install --no-cache-dir -i https://mirrors.aliyun.com/pypi/simple/ --trusted-host mirrors.aliyun.com \
-r /tmp/requirements-base.txt \
&& rm /tmp/requirements-base.txt
# Worker 专属大包
COPY requirements-worker.txt /tmp/requirements-worker.txt
RUN pip install --no-cache-dir -i https://mirrors.aliyun.com/pypi/simple/ --trusted-host mirrors.aliyun.com \
-r /tmp/requirements-worker.txt \
&& rm /tmp/requirements-worker.txt
# 业务依赖
COPY requirements.txt /tmp/requirements.txt
RUN pip install --no-cache-dir -i https://mirrors.aliyun.com/pypi/simple/ --trusted-host mirrors.aliyun.com \
-r /tmp/requirements.txt \
&& rm /tmp/requirements.txt
# ---- Python 依赖瘦身 ----
# 1. strip .so 文件的调试符号(节省约 80-100MB)
RUN find /opt/venv -name "*.so" -type f -exec strip --strip-all {} \; 2>/dev/null || true
# 2. 清理测试文件(节省约 20MB)
RUN find /opt/venv -type d -name "tests" -exec rm -rf {} + 2>/dev/null; \
find /opt/venv -type d -name "test" -exec rm -rf {} + 2>/dev/null; \
find /opt/venv -name "test_*.py" -delete 2>/dev/null || true
# 3. 清理 .pyc 缓存和 __pycache__(节省约 10MB,运行时按需生成)
RUN find /opt/venv -type d -name "__pycache__" -exec rm -rf {} + 2>/dev/null; \
find /opt/venv -name "*.pyc" -delete 2>/dev/null || true
# 4. 清理 dist-info 中的文档
RUN find /opt/venv -name "*.dist-info" -type d -exec sh -c 'rm -f "$1"/DESCRIPTION.rst "$1"/INSTALLER "$1"/LICENSE* "$1"/WHEEL "$1"/entry_points.txt' _ {} \; 2>/dev/null || true
# ==================== Runtime 阶段 ====================
FROM git.xiaoxiajianji.com/xiaoxia/base/python:3.12-slim AS runtime
# 构建参数:版本号
# 构建参数:版本号(CI 传入 commit hash
ARG APP_VERSION=dev
# 使用阿里云镜像加速
RUN sed -i 's|deb.debian.org|mirrors.aliyun.com|g' /etc/apt/sources.list.d/debian.sources 2>/dev/null || \
sed -i 's|deb.debian.org|mirrors.aliyun.com|g' /etc/apt/sources.list 2>/dev/null || true
# 安装最小运行时依赖(opencv-python-headless 需要 libglib2.0-0
# 安装系统依赖
RUN apt-get update && apt-get install -y --no-install-recommends \
libglib2.0-0 \
ffmpeg \
libsm6 \
libxext6 \
libgl1 \
&& rm -rf /var/lib/apt/lists/*
# 从 builder 复制 ffmpeg 静态二进制
COPY --from=builder /usr/local/bin/ffmpeg /usr/local/bin/ffmpeg
COPY --from=builder /usr/local/bin/ffprobe /usr/local/bin/ffprobe
# 从 builder 复制 Python 虚拟环境
COPY --from=builder /opt/venv /opt/venv
# 设置工作目录
WORKDIR /app
# ---- 依赖分层:基础依赖(变化少,缓存命中率高)----
COPY requirements-base.txt /tmp/requirements-base.txt
RUN python -m venv /opt/venv \
&& /opt/venv/bin/pip install --no-cache-dir -i https://mirrors.aliyun.com/pypi/simple/ --trusted-host mirrors.aliyun.com -r /tmp/requirements-base.txt \
&& rm /tmp/requirements-base.txt
# ---- 依赖分层:Worker 专属大包(视频处理,变化极少)----
COPY requirements-worker.txt /tmp/requirements-worker.txt
RUN /opt/venv/bin/pip install --no-cache-dir -i https://mirrors.aliyun.com/pypi/simple/ --trusted-host mirrors.aliyun.com -r /tmp/requirements-worker.txt \
&& rm /tmp/requirements-worker.txt
# ---- 依赖分层:业务依赖(变化频繁)----
COPY requirements.txt /tmp/requirements.txt
RUN /opt/venv/bin/pip install --no-cache-dir -i https://mirrors.aliyun.com/pypi/simple/ --trusted-host mirrors.aliyun.com -r /tmp/requirements.txt \
&& rm /tmp/requirements.txt
# 复制应用代码
COPY apps/worker/ /app/apps/worker/
COPY apps/api/app/config.py /app/apps/api/app/config.py
@@ -107,13 +51,13 @@ COPY packages/ /app/packages/
COPY alembic.ini /app/alembic.ini
COPY migrations/ /app/migrations/
# 复制 Worker 启动脚本
# 复制 Worker 启动脚本(支持 WORKER_CONCURRENCY 环境变量)
COPY infra/docker/entrypoint-worker.sh /usr/local/bin/entrypoint-worker.sh
RUN chmod +x /usr/local/bin/entrypoint-worker.sh
# 设置 Python 路径
ENV PATH="/opt/venv/bin:$PATH"
ENV PYTHONPATH=/app:/app/packages
ENV PYTHONPATH=/app
ENV PYTHONUNBUFFERED=1
ENV APP_VERSION=$APP_VERSION
-3
View File
@@ -100,7 +100,6 @@ class SQLAlchemyEditPlanRepository:
name=plan.name,
status=plan.status,
total_duration=plan.total_duration,
result_count=plan.result_count,
source_edit_plan_id=plan.source_edit_plan_id or None,
project_id=plan.project_id or "",
created_by_user_id=plan.created_by_user_id or "",
@@ -120,7 +119,6 @@ class SQLAlchemyEditPlanRepository:
model.name = plan.name
model.status = plan.status
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.project_id = plan.project_id or ""
model.created_by_user_id = plan.created_by_user_id or ""
@@ -154,7 +152,6 @@ class SQLAlchemyEditPlanRepository:
name=model.name,
status=EditPlanStatus(model.status) if model.status else EditPlanStatus.DRAFT,
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 "",
project_id=model.project_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 typing import Any
from sqlalchemy import JSON, Boolean, Column, DateTime, Float, Integer, String, Text, UniqueConstraint
from sqlalchemy.orm import declarative_base
Base: Any = declarative_base()
Base = declarative_base()
class UserModel(Base):
@@ -149,7 +148,6 @@ class EditPlanModel(Base):
name = Column(String(200), nullable=False)
status = Column(String(20), nullable=False, default="draft", index=True)
total_duration = Column(Float, nullable=False, default=0.0)
result_count = Column(Integer, nullable=False, default=0)
config = Column(JSON, nullable=False, default=dict)
source_edit_plan_id = Column(String(36), nullable=True, 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]:
if not project_id.strip():
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:
@@ -33,4 +33,4 @@ class CreateAssetLibraryUseCase:
name=command.name,
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],
)
return payload
except ExpiredSignatureError as _e:
raise ExpiredSignatureError("Token has expired") from _e
except ExpiredSignatureError:
raise ExpiredSignatureError("Token has expired")
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]:
"""
+1 -1
View File
@@ -22,7 +22,7 @@ class SubmitClassificationJobUseCase:
id=uuid4().hex,
project_id=command.project_id,
asset_id=command.asset_id,
status="pending", # type: ignore[arg-type]
status="pending",
classification="",
confidence=0.0,
error_message="",
+4 -4
View File
@@ -15,7 +15,7 @@ from __future__ import annotations
import logging
import time
from dataclasses import dataclass
from typing import Any, Callable, Optional
from typing import Any, Optional
import httpx
@@ -102,7 +102,7 @@ class CosyVoiceService:
model: str = "",
clone_model: str = "",
http_client: Optional[httpx.Client] = None,
audio_url_signer: Optional[Callable[[str], str]] = None,
audio_url_signer: Optional[callable] = None,
) -> None:
"""初始化 CosyVoice 服务.
@@ -656,8 +656,8 @@ class CosyVoiceService:
code = body.get("code", "")
message = body.get("message", "")
raise CosyVoiceError(f"CosyVoice API 参数错误: HTTP 400, " f"code={code}, message={message}")
except ValueError as _e:
raise CosyVoiceError(f"CosyVoice API 调用失败: HTTP 400, body={body_text}") from _e
except ValueError:
raise CosyVoiceError(f"CosyVoice API 调用失败: HTTP 400, body={body_text}")
elif response.status_code >= 500:
# 服务端错误,可重试
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,
title_ids=command.title_ids,
voice_ids=command.voice_ids,
status="pending", # type: ignore[arg-type]
status="pending",
progress=0.0,
result_count=0,
error_message="",
+15 -12
View File
@@ -8,10 +8,8 @@ from __future__ import annotations
import logging
import os
import shutil
import subprocess
import tempfile
from subprocess import CalledProcessError, TimeoutExpired
from packages.shared.ffmpeg_utils import FFMPEG_BIN, run_ffmpeg
logger = logging.getLogger(__name__)
@@ -61,7 +59,7 @@ class AudioMerger:
output_path = os.path.join(temp_dir, f"merged.{output_format}")
cmd = [
FFMPEG_BIN,
"ffmpeg",
"-y",
"-f",
"concat",
@@ -74,20 +72,25 @@ class AudioMerger:
output_path,
]
try:
run_ffmpeg(cmd, timeout=120)
except CalledProcessError as e:
logger.error(f"FFmpeg 合并失败: stderr={e.stderr}")
raise AudioMergeError(f"FFmpeg 合并失败: {str(e)[:500]}") from e
result = subprocess.run(
cmd,
capture_output=True,
text=True,
timeout=120,
)
if result.returncode != 0:
logger.error(f"FFmpeg 合并失败: stderr={result.stderr}")
raise AudioMergeError(f"FFmpeg 合并失败: {result.stderr[:500]}")
with open(output_path, "rb") as f:
return f.read()
except TimeoutExpired as _e:
raise AudioMergeError("FFmpeg 合并超时(120 秒)") from _e
except subprocess.TimeoutExpired:
raise AudioMergeError("FFmpeg 合并超时(120 秒)")
except AudioMergeError:
raise
except Exception as e:
raise AudioMergeError(f"音频合并失败: {e}") from e
raise AudioMergeError(f"音频合并失败: {e}")
finally:
shutil.rmtree(temp_dir, ignore_errors=True)
Executable → Regular
-10
View File
@@ -42,7 +42,6 @@ class EditPlan:
name: str
status: EditPlanStatus = EditPlanStatus.DRAFT
total_duration: float = 0.0
result_count: int = 0
source_edit_plan_id: str = ""
project_id: str = ""
created_by_user_id: str = ""
@@ -58,7 +57,6 @@ class EditPlan:
*,
config: dict[str, Any] | None = None,
total_duration: float = 0.0,
result_count: int = 0,
source_edit_plan_id: str = "",
project_id: str = "",
created_by_user_id: str = "",
@@ -75,7 +73,6 @@ class EditPlan:
name=clean_name,
status=EditPlanStatus.DRAFT,
total_duration=total_duration,
result_count=result_count,
source_edit_plan_id=source_edit_plan_id.strip(),
project_id=project_id.strip(),
created_by_user_id=created_by_user_id.strip(),
@@ -110,13 +107,6 @@ class EditPlan:
self.status = EditPlanStatus.FAILED
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:
"""重置为草稿状态(仅从 failed 状态可重置)"""
if self.status != EditPlanStatus.FAILED:
+2 -2
View File
@@ -170,8 +170,8 @@ class GenerationTask:
if isinstance(new_status, str):
try:
new_status = GenerationTaskStatus(new_status)
except ValueError as _e:
raise ValueError(f"无效状态: {new_status}") from _e
except ValueError:
raise ValueError(f"无效状态: {new_status}")
allowed = _VALID_TRANSITIONS.get(self.status, set())
if new_status not in allowed:
+4 -4
View File
@@ -147,8 +147,8 @@ class Job:
if isinstance(job_type, str):
try:
job_type = JobType(job_type)
except ValueError as _e:
raise ValueError(f"不支持的任务类型: {job_type}") from _e
except ValueError:
raise ValueError(f"不支持的任务类型: {job_type}")
return cls(
id=uuid4().hex,
@@ -182,8 +182,8 @@ class Job:
if isinstance(new_status, str):
try:
new_status = JobStatus(new_status)
except ValueError as _e:
raise ValueError(f"无效状态: {new_status}") from _e
except ValueError:
raise ValueError(f"无效状态: {new_status}")
allowed = _VALID_TRANSITIONS.get(self.status, set())
if new_status not in allowed:
+2 -2
View File
@@ -193,8 +193,8 @@ class TTSJob:
if isinstance(new_status, str):
try:
new_status = TTSJobStatus(new_status)
except ValueError as _e:
raise ValueError(f"无效状态: {new_status}") from _e
except ValueError:
raise ValueError(f"无效状态: {new_status}")
allowed = _VALID_TRANSITIONS.get(self.status, set())
if new_status not in allowed:
+2 -2
View File
@@ -177,8 +177,8 @@ class VoiceCloneProfile:
if isinstance(new_status, str):
try:
new_status = VoiceCloneStatus(new_status)
except ValueError as _e:
raise ValueError(f"无效状态: {new_status}") from _e
except ValueError:
raise ValueError(f"无效状态: {new_status}")
allowed = _VALID_TRANSITIONS.get(self.status, set())
if new_status not in allowed:
-79
View File
@@ -1,79 +0,0 @@
"""FFmpeg 共享工具 — packages/shared 层.
仅包含与业务无关的底层原语FFmpeg/FFprobe 二进制路径run_ffmpeg 执行器
业务相关的滤镜构建视频探测等留在 apps/worker/video_processing/ffmpeg_utils.py
application 层和 worker 层都可以引用本模块避免跨层依赖
"""
from __future__ import annotations
import logging
import shutil
import subprocess # nosec B404
from pathlib import Path
from typing import Any
logger = logging.getLogger(__name__)
# ── 常量 ──────────────────────────────────────────────────────────────────────
FFMPEG_BIN: str = shutil.which("ffmpeg") or "ffmpeg"
FFPROBE_BIN: str = shutil.which("ffprobe") or "ffprobe"
# FFmpeg 执行默认超时(秒),防止 FFmpeg hang 住导致进程永久阻塞
# 默认 30 分钟,足够处理大部分短视频渲染;超长视频可单独传参覆盖
DEFAULT_FFMPEG_TIMEOUT = 1800
# ── FFmpeg 执行 ───────────────────────────────────────────────────────────────
def run_ffmpeg(
command: list[str],
*,
capture_output: bool = True,
timeout: int | None = DEFAULT_FFMPEG_TIMEOUT,
) -> tuple[str, str]:
"""执行 FFmpeg 命令(统一入口)。
Args:
command: 完整的 ffmpeg 命令列表 "ffmpeg" 本身
capture_output: 是否捕获 stdout/stderr
timeout: 超时时间默认 1800s30分钟None 表示不设超时不推荐
Returns:
(stdout, stderr) 元组
Raises:
subprocess.CalledProcessError: 命令执行失败时抛出
异常信息包含完整 stderr 以便排查
subprocess.TimeoutExpired: 超时未完成时抛出FFmpeg 进程会被 kill
"""
try:
result = subprocess.run( # nosec B603
command,
check=True,
stdout=subprocess.PIPE if capture_output else None,
stderr=subprocess.PIPE if capture_output else None,
text=True,
timeout=timeout,
)
return (result.stdout or "", result.stderr or "")
except subprocess.TimeoutExpired:
logger.error(
"FFmpeg 命令超时 (%ds): command=%s",
timeout or -1,
" ".join(str(c) for c in command[:20]),
)
raise
except subprocess.CalledProcessError as e:
# 把完整 stderr 打到日志,方便排查 exit code 183 等问题
stderr_text = (e.stderr or "").strip()
logger.error(
"FFmpeg 命令失败: exit_code=%d command=%s\nstderr:\n%s",
e.returncode,
" ".join(str(c) for c in command[:20]), # 截断过长的命令
stderr_text[:5000], # 截断过长的 stderr
)
raise
+2 -2
View File
@@ -114,7 +114,7 @@ class SharedStorageService:
self.bucket.put_object(storage_key, file_or_path, headers={"Content-Type": content_type})
return f"{self.public_url}/{storage_key}"
except Exception as e:
raise Exception(f"Failed to upload file to OSS: {e}") from e
raise Exception(f"Failed to upload file to OSS: {e}")
def get_url(self, storage_key: str) -> str:
"""Get public URL for a file."""
@@ -129,7 +129,7 @@ class SharedStorageService:
os.makedirs(os.path.dirname(local_path), exist_ok=True)
self.bucket.get_object_to_file(storage_key, local_path)
except Exception as e:
raise Exception(f"Failed to download file from OSS: {e}") 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:
"""Get signed download URL."""
Executable → Regular
-140
View File
@@ -92,141 +92,6 @@ _DOWNLOAD_CHUNK_SIZE = 8192
# 最大重定向次数
_MAX_REDIRECTS = 5
# 文件魔数(文件头签名)表 — 用于 MIME 白名单校验后的二次真实性校验
# key: MIME 类型,value: 签名列表,任一签名匹配即通过
# 每条签名: list of (offset, bytes),所有条目都匹配才算该签名命中(支持多处联合匹配如 RIFF+WAVE)
_MAGIC_NUMBERS: dict[str, list[list[tuple[int, bytes]]]] = {
# ── 音频 ──
"audio/mpeg": [
[(0, b"ID3")], # ID3v2 标签
[(0, b"\xff\xfb")], # MPEG1 Layer3
[(0, b"\xff\xf3")], # MPEG2 Layer3
[(0, b"\xff\xf2")], # MPEG2.5 Layer3
[(0, b"\xff\xfa")], # MPEG1 Layer2
[(0, b"\xff\xf9")], # 其他 MPEG ADTS
],
"audio/wav": [
[(0, b"RIFF"), (8, b"WAVE")], # RIFF + WAVE
],
"audio/x-wav": [
[(0, b"RIFF"), (8, b"WAVE")],
],
"audio/ogg": [
[(0, b"OggS")],
],
"application/ogg": [
[(0, b"OggS")],
],
"audio/flac": [
[(0, b"fLaC")],
],
"audio/aac": [
[(0, b"\xff\xf1")], # ADTS MPEG-4
[(0, b"\xff\xf9")], # ADTS MPEG-2
],
"audio/aacp": [
[(0, b"\xff\xf1")],
[(0, b"\xff\xf9")],
],
"audio/mp4": [
[(4, b"ftyp")], # ISO Base Media (M4A)
],
"audio/x-m4a": [
[(4, b"ftyp")],
],
# ── 视频 ──
"video/mp4": [
[(4, b"ftyp")], # ISO Base Media (MP4)
],
"video/quicktime": [
[(4, b"ftyp")],
],
"video/x-matroska": [
[(0, b"\x1a\x45\xdf\xa3")], # EBML header
],
"video/webm": [
[(0, b"\x1a\x45\xdf\xa3")],
],
"video/x-msvideo": [
[(0, b"RIFF"), (8, b"AVI ")],
],
# ── 图片 ──
"image/jpeg": [
[(0, b"\xff\xd8\xff")],
],
"image/png": [
[(0, b"\x89PNG\r\n\x1a\n")],
],
"image/gif": [
[(0, b"GIF87a")],
[(0, b"GIF89a")],
],
"image/webp": [
[(0, b"RIFF"), (8, b"WEBP")],
],
"image/bmp": [
[(0, b"BM")],
],
}
# 魔数校验最大读取字节数(文件头)
_MAGIC_CHECK_READ_SIZE = 256
def _validate_magic_number(file_path: str, allowed_mime_types: set[str]) -> None:
"""校验文件头魔数是否与允许的 MIME 类型匹配.
读取文件前 256 字节 allowed_mime_types 对应格式的魔数逐一比对
任一类型匹配即通过全部不匹配则抛出 UrlSecurityError
仅当 allowed_mime_types 非空时执行空文件视为不匹配
Args:
file_path: 本地文件路径
allowed_mime_types: 允许的 MIME 类型集合
Raises:
UrlSecurityError: 文件魔数与所有允许类型均不匹配
"""
# 收集所有允许类型对应的魔数签名
signatures: list[list[tuple[int, bytes]]] = []
for mime in allowed_mime_types:
sigs = _MAGIC_NUMBERS.get(mime)
if sigs:
signatures.extend(sigs)
# 如果没有已知魔数(比如自定义 MIME),跳过校验不阻断
if not signatures:
return
try:
with open(file_path, "rb") as f:
header = f.read(_MAGIC_CHECK_READ_SIZE)
except OSError as e:
raise UrlSecurityError(f"读取文件头失败: {e}") from e
if not header:
raise UrlSecurityError("文件为空,无法校验格式")
# 任一签名匹配即通过
for sig in signatures:
match = True
for offset, expected in sig:
if offset + len(expected) > len(header):
match = False
break
if header[offset : offset + len(expected)] != expected:
match = False
break
if match:
return
raise UrlSecurityError(
f"文件魔数与允许的 MIME 类型不匹配,"
f"允许类型: {sorted(allowed_mime_types)}"
f"文件头前16字节: {header[:16].hex()}"
)
class UrlSecurityError(ValueError):
"""URL 安全校验失败."""
@@ -429,7 +294,6 @@ def safe_download_file(
- 重定向次数限制 + 手动跟随避免重定向绕过 SSRF
- 文件大小限制流式读取超过立即中断
- MIME 类型白名单可选
- 文件头魔数校验配合 MIME 白名单做二次真实性校验
Args:
url: 下载 URL
@@ -498,10 +362,6 @@ def safe_download_file(
raise UrlSecurityError(f"下载超过大小限制: {total_bytes} bytes > {max_size} bytes")
f.write(chunk)
# 文件头魔数校验(MIME 白名单基础上的二次真实性校验)
if allowed_mime_types is not None:
_validate_magic_number(dest_path, allowed_mime_types)
return total_bytes
finally:
resp.close()
+13 -17
View File
@@ -63,44 +63,40 @@ exclude = [
".next",
"dist",
"build",
"hostexecutor",
]
[tool.ruff.lint]
# 正式替换 flake8规则集与原 flake8 完全对齐
# 当前阶段:摸底模式,规则集与原flake8对齐
# 后续迭代计划:
# Phase 2: 加入 B (flake8-bugbear),修完后升级为阻断级
# Phase 3: 启用 UP(pyupgrade) + SIM(simplify)
# Phase 4: 启用 RET(return) + ARG(unused-args)
# Phase 1: 修完 bugbear 后正式替换 flake8
# Phase 2: 启用 UP(pyupgrade) + SIM(simplify)
# Phase 3: 启用 RET(return) + ARG(unused-args)
select = [
"E", # pycodestyle errors(同 flake8
"F", # pyflakes(同 flake8
"W", # pycodestyle warnings(同 flake8
"B", # flake8-bugbearP0-5 Step 2 已完成修复
"E", # pycodestyle errors(同flake8
"F", # pyflakes(同flake8
"W", # pycodestyle warnings(同flake8
"B", # flake8-bugbear新增,摸底用
]
# 与原 setup.cfg + .flake8 的 flake8 配置完全对齐
# 注意:W503 在 ruff≥0.14 中已被移除(行为变默认),故不列入
# 与原 setup.cfg flake8 配置对齐,确保不新增阻断
ignore = [
"E203",
"W503",
"E501", # line-too-longblack管)
"E302",
"E402", # module-import-not-at-top(循环导入多)
"E722", # bare-except
"W291",
"W293",
"B008", # function-call-in-default-argumentFastAPI 依赖注入模式,大量使用)
"F401", # unused-import
"F403",
"F405",
"F841", # unused-variable
"B008", # do-not-perform-callback-from-argfastapi依赖注入)
]
[tool.ruff.lint.per-file-ignores]
"__init__.py" = ["F401", "F403", "F405"]
"tests/*" = ["E402", "F401", "F821", "F841"]
"packages/ports/*" = ["E301"] # E704 在 ruff≥0.14 已移除
"apps/api/app/api/routes/auth.py" = ["ALL"]
"apps/api/app/api/routes/workspaces.py" = ["ALL"]
"apps/api/app/middleware/auth.py" = ["ALL"]
"tests/*" = ["E402", "F401", "F841"]
"packages/ports/*" = ["E301", "E704"]
"apps/*/migrations/*" = ["ALL"]
"alembic/*" = ["ALL"]
Executable → Regular
+1 -1
View File
@@ -1,5 +1,5 @@
[pytest]
pythonpath = . apps/api apps/worker packages
pythonpath = . apps/api apps/worker
testpaths = tests
# ===== 覆盖率配置 =====
Executable → Regular
+1 -1
View File
@@ -3,7 +3,7 @@
# 代码质量
black==26.5.1
isort==8.0.1
ruff==0.14.0
flake8==7.3.0
bandit==1.9.4
# 测试
+11 -76
View File
@@ -1,108 +1,43 @@
#!/bin/bash
# 自动合并通过 CI 检查的 PR
# 用法: ./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}"
REPO="xiaoxia/xiaoxia-saas"
TARGET_BRANCH="${1:-develop}"
# 必需的 CI 检查项(context 名称前缀匹配,避免 pipeline 名称变化导致匹配失败)
REQUIRED_CHECKS=(
"Validate Code Quality And Tests"
"Frontend Lint"
)
echo "=== Checking open PRs targeting $TARGET_BRANCH ==="
# 获取所有 open PR
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
data = json.load(sys.stdin)
for pr in data:
if pr.get('base', {}).get('ref') == '$TARGET_BRANCH':
head_sha = pr.get('head', {}).get('sha', '')
print(f\"{pr['number']}|{pr['title']}|{head_sha}\")
if pr.get('mergeable', False):
print(f\"{pr['number']}|{pr['title']}|{pr.get('mergeable', 'unknown')}\")
")
if [ -z "$PRS" ]; then
echo "No open PRs found for $TARGET_BRANCH"
echo "No mergeable PRs found for $TARGET_BRANCH"
exit 0
fi
merge_count=0
skip_count=0
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..."
echo "$PRS" | while IFS='|' read -r number title mergeable; do
echo "Merging PR #$number: $title"
RESULT=$(curl -s -X POST \
-H "Authorization: token $TOKEN" \
-H "Content-Type: application/json" \
"$GITEA_API/repos/$REPO/pulls/$number/merge" \
-d '{"Do": "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
-d '{\"merge_method\": \"merge\"}')
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"
merge_count=$((merge_count + 1))
else
echo " ❌ PR #$number merge failed"
# 提取错误信息
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"
echo " ❌ PR #$number failed: $RESULT"
fi
done
echo ""
echo "=== Done ==="
echo "Merged: $merge_count | Skipped: $skip_count"
-12
View File
@@ -60,9 +60,6 @@ fi
# 默认只读不写,防止 feature 分支污染主缓存
# 只有 develop/main 分支才写回缓存
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
docker buildx build \
--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}"
fi
# 清理本地旧镜像,避免 buildx --load 报 already exists 错误
docker rmi -f "$IMG_NAME:$VERSION" 2>/dev/null || true
if [ "$USE_CACHE" -eq 1 ]; then
if [ -n "$CACHE_TO" ]; then
docker buildx build \
@@ -125,9 +119,6 @@ build_with_cache "api" "infra/docker/api.Dockerfile" \
docker tag "$API_IMAGE" "$API_LATEST"
echo "=== Building Worker image ==="
# 清理本地旧镜像
docker rmi -f "$WORKER_IMAGE" "$WORKER_LATEST" 2>/dev/null || true
if [ "$USE_CACHE" -eq 1 ]; then
docker buildx build \
--build-arg APP_VERSION="$VERSION" \
@@ -157,9 +148,6 @@ docker run --rm \
test -f apps/web/dist/index.html
# 清理本地旧镜像
docker rmi -f "$WEB_IMAGE" 2>/dev/null || true
if [ "$USE_CACHE" -eq 1 ]; then
docker buildx build \
--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
import argparse
import json
import os
import re
import subprocess
import sys
import time
import urllib.error
import urllib.request
from pathlib import Path
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:
"""
从迁移文件中提取 upgrade 函数的内容
@@ -110,137 +97,34 @@ def extract_upgrade_content(content: str) -> str:
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]:
"""
找出相对目标分支新增的迁移文件按优先级尝试多种方式
1. Gitea/GitHub Contents API最可靠不受本地 checkout 深度影响
2. git diffAPI 失败时的兜底
3. 全量扫描以上都失败时的最后兜底会输出警告
通过 git diff 对比目标分支/commit找出 alembic/versions/ 下新增的迁移文件
只包含新增文件A状态不包含修改或删除的文件
"""
print("🔍 尝试通过 API 获取新增迁移列表...")
result = get_new_migrations_via_api(diff_target)
if result is not None:
return result
print("🔍 API 不可用,尝试 git diff 方式...")
result = get_new_migrations_via_git(diff_target)
if result is not None:
return result
print("⚠️ 所有增量方式均失败,降级为检查所有迁移文件")
print(" 这可能导致历史迁移中的破坏性操作被误报")
print(" 建议检查 CI 环境变量配置(GITHUB_API_URL / GITHUB_REPOSITORY / GITHUB_TOKEN")
return sorted(ALEMBIC_VERSIONS_DIR.glob("*.py"))
try:
result = subprocess.run(
[
"git",
"diff",
"--name-only",
"--diff-filter=A",
diff_target,
"HEAD",
"--",
"alembic/versions/",
],
cwd=str(REPO_ROOT),
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(f" 降级为检查所有迁移文件")
return sorted(ALEMBIC_VERSIONS_DIR.glob("*.py"))
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
+26 -56
View File
@@ -2,7 +2,7 @@
"""
统一CI通知脚本 - 发送飞书卡片通知
支持三种模式: start / success / failure
包含: PR链接耗时失败阶段分支提交者Run链接Runner信息
包含: PR链接耗时失败阶段分支提交者Run链接
用法:
NOTIFY_MODE=start JOB_NAME="xxx" python3 scripts/ci_notify.py
@@ -23,12 +23,6 @@
GITHUB_EVENT_NAME - 事件类型 (pull_request / push / ...)
GITHUB_PR_NUMBER - PR编号 (PR事件时)
GITHUB_PR_TITLE - PR标题 (PR事件时)
RUNNER_NAME - Runner名称 (可选自动获取)
设计原则:
1. 通知失败永远不阻断CI主流程返回exit code 0
2. 标题包含"CI通知"/"CI告警"关键词适配飞书webhook关键词校验
3. 卡片信息尽量丰富方便快速定位问题
"""
import json
@@ -38,7 +32,6 @@ import urllib.request
def get_env(name, default=""):
"""读取环境变量"""
return os.environ.get(name, default)
@@ -55,20 +48,6 @@ def format_duration(seconds_str):
return seconds_str or "未知"
def classify_job(job_name):
"""根据Job名称判断所属阶段"""
name = job_name.lower()
if any(k in name for k in ["validate", "lint", "unit test", "integration test"]):
return "门禁检查"
if any(k in name for k in ["build", "image"]):
return "镜像构建"
if any(k in name for k in ["deploy", "staging", "production"]):
return "部署发布"
if any(k in name for k in ["e2e", "test", "smoke"]):
return "测试验证"
return "其他"
def main() -> int:
webhook = get_env("CI_NOTIFY_WEBHOOK")
if not webhook:
@@ -92,60 +71,49 @@ def main() -> int:
event_name = get_env("GITHUB_EVENT_NAME", "")
pr_number = get_env("GITHUB_PR_NUMBER", "")
pr_title = get_env("GITHUB_PR_TITLE", "")
runner_name = get_env("RUNNER_NAME", "")
run_url = f"https://git.xiaoxiajianji.com/{repo}/actions/runs/{run_id}"
job_stage = classify_job(job_name)
# 根据模式设置标题、状态、颜色
# 注意:标题中必须包含飞书webhook配置的关键词,否则会报"Key Words Not Found"
# 这里加入"CI通知"/"CI告警"关键词提高命中率
if mode == "start":
title = f"🔄 CI通知:{job_name} 开始构建"
title = "🔄 CI构建任务开始"
status = "blue"
button_text = "查看进度"
button_type = "primary"
elif mode == "success":
title = f"✅ CI通知:{job_name} 构建成功"
title = f"✅ CI构建成功 - {job_name}"
status = "green"
button_text = "查看详情"
button_type = "primary"
else: # failure
title = f"❌ CI告警:{job_name} 构建失败"
title = f"❌ CI构建失败 - {job_name}"
status = "red"
button_text = "查看失败日志"
button_type = "danger"
# 构建卡片内容 - 左侧标签+右侧值的结构化展示
fields = []
fields.append({"is_short": True, "text": {"tag": "lark_md", "content": f"**阶段**\n{job_stage}"}})
fields.append({"is_short": True, "text": {"tag": "lark_md", "content": f"**任务**\n{job_name}"}})
# 构建卡片内容(含关键词"CI构建"以通过飞书机器人关键词校验)
content_lines = []
content_lines.append(f"**CI构建任务**: {job_name}")
if mode != "start":
fields.append({"is_short": True, "text": {"tag": "lark_md", "content": f"**耗时**\n{duration}"}})
else:
fields.append({"is_short": True, "text": {"tag": "lark_md", "content": "**状态**\n进行中"}})
if runner_name:
fields.append({"is_short": True, "text": {"tag": "lark_md", "content": f"**Runner**\n{runner_name}"}})
content_lines.append(f"**耗时**: {duration}")
if mode == "failure" and failed_step:
fields.append({"is_short": False, "text": {"tag": "lark_md", "content": f"**失败步骤**\n{failed_step}"}})
content_lines.append(f"**失败阶段**: {failed_step}")
# PR/分支信息
# PR信息
if event_name == "pull_request" and pr_number:
pr_url = f"https://git.xiaoxiajianji.com/{repo}/pulls/{pr_number}"
pr_display = f"#{pr_number}"
if pr_title:
pr_display += f" {pr_title[:30]}"
fields.append({"is_short": False, "text": {"tag": "lark_md", "content": f"**PR**\n[{pr_display}]({pr_url})"}})
pr_display += f" {pr_title}"
content_lines.append(f"**PR**: [{pr_display}]({pr_url})")
elif event_name == "push":
fields.append({"is_short": True, "text": {"tag": "lark_md", "content": f"**分支**\n{branch}"}})
content_lines.append(f"**分支**: {branch}")
fields.append({"is_short": True, "text": {"tag": "lark_md", "content": f"**提交**\n`{commit}`"}})
fields.append({"is_short": True, "text": {"tag": "lark_md", "content": f"**提交者**\n{actor}"}})
fields.append({"is_short": True, "text": {"tag": "lark_md", "content": f"**Run ID**\n{run_id}"}})
content_lines.append(f"**提交**: `{commit}`")
content_lines.append(f"**提交者**: {actor}")
content_lines.append(f"**Run ID**: {run_id}")
payload = {
"msg_type": "interactive",
@@ -160,7 +128,10 @@ def main() -> int:
"elements": [
{
"tag": "div",
"fields": fields,
"text": {
"tag": "lark_md",
"content": "\n".join(content_lines),
},
},
{
"tag": "action",
@@ -187,20 +158,19 @@ def main() -> int:
try:
with urllib.request.urlopen(req, timeout=10) as resp:
resp_body = resp.read().decode("utf-8")
print(f"通知已发送 ({mode})")
# 飞书返回code=0表示成功
try:
result = json.loads(resp_body)
if result.get("code", 0) != 0:
print(f"通知发送告警: 飞书返回错误 - {result.get('msg', resp_body)}", file=sys.stderr)
print(f"通知已发送 ({mode}) - 飞书返回非0,但不阻断CI流程")
else:
print(f"通知已发送 ({mode})")
print(f"飞书返回错误: {result.get('msg', resp_body)}", file=sys.stderr)
return 1
except json.JSONDecodeError:
print(f"通知已发送 ({mode})")
pass
except Exception as e:
print(f"通知发送告警: {e}", file=sys.stderr)
print(f"通知发送失败: {e}", file=sys.stderr)
return 1
# 通知无论成功失败都不阻断CI主流程,统一返回0
return 0
-483
View File
@@ -1,483 +0,0 @@
#!/bin/bash
# ===========================================
# CI Production 健康检查 + 自动回滚脚本(SSH 部署模式)
# ===========================================
#
# 在 CI Runner 上执行,通过公网 URL 检查 Production 部署健康状态。
# 不健康则通过 SSH 自动回滚到上一个版本的镜像。
#
# 用法:
# ./ci_production_healthcheck.sh
#
# 环境变量:
# PROD_API_URL - Production API 公网地址 (默认 https://api.xiaoxiajianji.com)
# PROD_WEB_URL - Production Web 公网地址 (默认 https://saas.xiaoxiajianji.com)
# HEALTH_CHECK_TIMEOUT - 健康检查总超时秒数 (默认 180)
# SKIP_ROLLBACK - 失败时不自动回滚 (true/false, 默认 false)
# SKIP_NOTIFY - 跳过通知 (true/false, 默认 false)
# CI_NOTIFY_WEBHOOK - 通知 Webhook URL
#
# PRODUCTION_SSH_HOST - 生产服务器 SSH 地址
# PRODUCTION_SSH_USER - SSH 用户名 (默认 root)
# PRODUCTION_SSH_PORT - SSH 端口 (默认 22222)
# PRODUCTION_SSH_KEY - SSH 私钥内容
# REGISTRY_TOKEN - Registry Token(回滚时拉取旧镜像需要)
#
# GITHUB_SHA - 当前 commit SHA
# GITHUB_REF_NAME - tag 名 (如 v0.1.100)
# GITHUB_RUN_ID - CI Run ID
# GITHUB_REPOSITORY - 仓库名
# GITHUB_ACTOR - 提交者
set -eu
SCRIPT_DIR="$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)"
# 配置
PROD_API_URL="${PROD_API_URL:-https://api.xiaoxiajianji.com}"
PROD_WEB_URL="${PROD_WEB_URL:-https://saas.xiaoxiajianji.com}"
HEALTH_CHECK_TIMEOUT="${HEALTH_CHECK_TIMEOUT:-180}"
SKIP_ROLLBACK="${SKIP_ROLLBACK:-false}"
SKIP_NOTIFY="${SKIP_NOTIFY:-false}"
PRODUCTION_SSH_HOST="${PRODUCTION_SSH_HOST:-47.98.113.167}"
PRODUCTION_SSH_USER="${PRODUCTION_SSH_USER:-root}"
PRODUCTION_SSH_PORT="${PRODUCTION_SSH_PORT:-22222}"
REGISTRY="${REGISTRY:-git.xiaoxiajianji.com/xiaoxia/xiaoxia-saas}"
REGISTRY_USER="${REGISTRY_USER:-xiaoxia}"
# 颜色
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
NC='\033[0m'
log_info() { echo -e "${GREEN}[INFO]${NC} $1"; }
log_warn() { echo -e "${YELLOW}[WARN]${NC} $1"; }
log_error() { echo -e "${RED}[ERROR]${NC} $1"; }
log_step() { echo -e "${BLUE}[STEP]${NC} $1"; }
# ===========================================
# SSH 工具函数
# ===========================================
SSH_KEY_PATH=""
setup_ssh() {
# 查找或创建 SSH 密钥
if [ -f /root/.ssh/xiaoxia_runtime_builder ]; then
SSH_KEY_PATH="/root/.ssh/xiaoxia_runtime_builder"
elif [ -f "$HOME/.ssh/xiaoxia_runtime_builder" ]; then
SSH_KEY_PATH="$HOME/.ssh/xiaoxia_runtime_builder"
elif [ -n "${PRODUCTION_SSH_KEY:-}" ]; then
SSH_KEY_PATH="$HOME/.ssh/prod_deploy_key"
mkdir -p "$HOME/.ssh"
printf '%s\n' "$PRODUCTION_SSH_KEY" > "$SSH_KEY_PATH"
chmod 600 "$SSH_KEY_PATH"
else
log_error "没有可用的 SSH 密钥"
return 1
fi
ssh-keyscan -p "$PRODUCTION_SSH_PORT" -H "$PRODUCTION_SSH_HOST" >> ~/.ssh/known_hosts 2>/dev/null || true
log_info "SSH 已配置: ${PRODUCTION_SSH_USER}@${PRODUCTION_SSH_HOST}:${PRODUCTION_SSH_PORT}"
}
run_ssh() {
local cmd="$1"
ssh -p "$PRODUCTION_SSH_PORT" -i "$SSH_KEY_PATH" -o StrictHostKeyChecking=no \
"${PRODUCTION_SSH_USER}@${PRODUCTION_SSH_HOST}" "$cmd"
}
# ===========================================
# 1. 记录部署前各服务的镜像版本(用于回滚)
# ===========================================
ROLLBACK_API_TAG=""
ROLLBACK_WORKER_TAG=""
ROLLBACK_WEB_TAG=""
save_rollback_target() {
log_step "记录当前生产环境各服务镜像版本(回滚目标)..."
# 通过 SSH 获取当前运行的容器镜像
local api_image worker_image web_image
api_image=$(run_ssh "docker inspect --format '{{.Config.Image}}' xiaoxia-api-production 2>/dev/null || echo ''")
worker_image=$(run_ssh "docker inspect --format '{{.Config.Image}}' xiaoxia-worker-production 2>/dev/null || echo ''")
web_image=$(run_ssh "docker inspect --format '{{.Config.Image}}' xiaoxia-web-production 2>/dev/null || echo ''")
# 提取 tag(镜像名是 xiaoxia-saas-api:v0.1.100 格式)
ROLLBACK_API_TAG=$(echo "$api_image" | sed 's/.*://' || echo "")
ROLLBACK_WORKER_TAG=$(echo "$worker_image" | sed 's/.*://' || echo "")
ROLLBACK_WEB_TAG=$(echo "$web_image" | sed 's/.*://' || echo "")
log_info " API: ${ROLLBACK_API_TAG:-未知}"
log_info " Worker: ${ROLLBACK_WORKER_TAG:-未知}"
log_info " Web: ${ROLLBACK_WEB_TAG:-未知}"
# 验证三个服务版本是否一致
if [ -n "$ROLLBACK_API_TAG" ] && [ -n "$ROLLBACK_WORKER_TAG" ] && [ -n "$ROLLBACK_WEB_TAG" ]; then
if [ "$ROLLBACK_API_TAG" = "$ROLLBACK_WORKER_TAG" ] && [ "$ROLLBACK_API_TAG" = "$ROLLBACK_WEB_TAG" ]; then
log_info " ✅ 三个服务版本一致: $ROLLBACK_API_TAG"
export ROLLBACK_TAG="$ROLLBACK_API_TAG"
else
log_warn " ⚠️ 三个服务版本不一致,回滚时将分别使用各自版本"
export ROLLBACK_API_TAG ROLLBACK_WORKER_TAG ROLLBACK_WEB_TAG
export ROLLBACK_TAG_MIXED="true"
fi
else
log_warn " ⚠️ 未能获取全部服务版本,回滚功能可能受限"
fi
}
# ===========================================
# 2. 健康检查(公网视角)
# ===========================================
health_check() {
local timeout="$HEALTH_CHECK_TIMEOUT"
local start_time
start_time=$(date +%s)
log_step "公网健康检查(超时 ${timeout}s..."
log_info " API: ${PROD_API_URL}/health"
log_info " Web: ${PROD_WEB_URL}/"
local api_ok=false
local web_ok=false
local api_docs_ok=false
local login_api_ok=false
while [ $(( $(date +%s) - start_time )) -lt "$timeout" ]; do
# 检查 API health
if [ "$api_ok" = false ] && curl -sf --max-time 10 "${PROD_API_URL}/health" >/dev/null 2>&1; then
log_info "✅ API 健康检查通过"
api_ok=true
fi
# 检查 Web 首页
if [ "$web_ok" = false ] && curl -sf --max-time 10 "$PROD_WEB_URL/" >/dev/null 2>&1; then
log_info "✅ Web 前端检查通过"
web_ok=true
fi
# 检查 API docs
if [ "$api_docs_ok" = false ]; then
HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" --max-time 10 "${PROD_API_URL}/docs" 2>/dev/null || echo "000")
if [ "$HTTP_CODE" = "200" ]; then
log_info "✅ API Docs 检查通过"
api_docs_ok=true
fi
fi
# 检查登录 API
if [ "$login_api_ok" = false ]; then
HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" --max-time 10 -X POST \
"${PROD_API_URL}/api/v1/auth/login" \
-H "Content-Type: application/json" \
-d '{"email":"smoke@test.com","password":"wrong"}' 2>/dev/null || echo "000")
if [ "$HTTP_CODE" = "401" ] || [ "$HTTP_CODE" = "422" ]; then
log_info "✅ 登录 API 检查通过(HTTP $HTTP_CODE,符合预期)"
login_api_ok=true
fi
fi
# 都通过了就退出
if [ "$api_ok" = true ] && [ "$web_ok" = true ] && [ "$api_docs_ok" = true ] && [ "$login_api_ok" = true ]; then
log_info "🎉 所有健康检查通过!"
return 0
fi
sleep 5
done
# 超时了
log_error "❌ 健康检查超时 (${timeout}s)"
[ "$api_ok" = false ] && log_error " - API health 未通过"
[ "$web_ok" = false ] && log_error " - Web 前端未通过"
[ "$api_docs_ok" = false ] && log_error " - API Docs 未通过"
[ "$login_api_ok" = false ] && log_error " - 登录 API 未通过"
return 1
}
# ===========================================
# 3. 执行回滚(SSH 重新部署旧版本)
# ===========================================
do_rollback() {
log_step "执行回滚:通过 SSH 重新部署旧版本镜像..."
local rollback_tag="${ROLLBACK_TAG:-}"
if [ -z "$rollback_tag" ] && [ "${ROLLBACK_TAG_MIXED:-}" != "true" ]; then
log_error "没有可回滚的版本记录,无法自动回滚"
return 1
fi
# 如果版本不一致,用 API 的版本作为回滚目标
if [ -z "$rollback_tag" ]; then
rollback_tag="$ROLLBACK_API_TAG"
fi
if [ -z "$rollback_tag" ]; then
log_error "无法确定回滚版本"
return 1
fi
log_info "回滚目标版本: $rollback_tag"
# 通过 SSH 在生产服务器上执行回滚部署
# 复用 Registry 方式部署脚本的逻辑,用旧版本 tag 重新部署
local rollback_script=$(cat << 'ROLLBACK_EOF'
#!/bin/sh
set -eu
IMAGE_TAG="$1"
REGISTRY_TOKEN="$2"
REGISTRY="${REGISTRY:-git.xiaoxiajianji.com/xiaoxia/xiaoxia-saas}"
REGISTRY_USER="${REGISTRY_USER:-xiaoxia}"
ENV_FILE="${ENV_FILE:-/var/lib/xiaoxia-saas-production/.env}"
GENERATED_DIR="${GENERATED_DIR:-/var/lib/xiaoxia-saas-production/generated}"
LEGACY_ASSETS_DIR="${LEGACY_ASSETS_DIR:-/var/lib/xiaoxia-saas-production/legacy-assets}"
echo "=== Rollback to $IMAGE_TAG ==="
# 登录 Registry
if [ -n "$REGISTRY_TOKEN" ]; then
REGISTRY_HOST=$(echo "$REGISTRY" | cut -d/ -f1)
printf %s "$REGISTRY_TOKEN" | docker login "$REGISTRY_HOST" -u "$REGISTRY_USER" --password-stdin 2>/dev/null || true
fi
# Pull 旧版本镜像
LOCAL_API="xiaoxia-saas-api:${IMAGE_TAG}"
LOCAL_WORKER="xiaoxia-saas-worker:${IMAGE_TAG}"
LOCAL_WEB="xiaoxia-saas-web:${IMAGE_TAG}"
docker pull "${REGISTRY}/xiaoxia-saas-api:${IMAGE_TAG}"
docker pull "${REGISTRY}/xiaoxia-saas-worker:${IMAGE_TAG}"
docker pull "${REGISTRY}/xiaoxia-saas-web:${IMAGE_TAG}"
docker tag "${REGISTRY}/xiaoxia-saas-api:${IMAGE_TAG}" "$LOCAL_API"
docker tag "${REGISTRY}/xiaoxia-saas-worker:${IMAGE_TAG}" "$LOCAL_WORKER"
docker tag "${REGISTRY}/xiaoxia-saas-web:${IMAGE_TAG}" "$LOCAL_WEB"
echo "Rollback images pulled."
# 停止当前容器
docker rm -f xiaoxia-api-production 2>/dev/null || true
docker rm -f xiaoxia-worker-production 2>/dev/null || true
docker rm -f xiaoxia-web-production 2>/dev/null || true
LOG_OPTS="--log-driver json-file --log-opt max-size=50m --log-opt max-file=3"
# 启动 API(回滚不跑 migration,因为新版本可能加了字段,回滚后代码是旧的但数据还在)
echo "Starting API (rollback)..."
docker run -d \
--name xiaoxia-api-production \
--env-file "$ENV_FILE" \
--network xiaoxia-net-production \
-p 127.0.0.1:8001:8000 \
-e APP_ENV=production \
-e APP_VERSION="$IMAGE_TAG" \
-e GENERATED_FILES_DIR=/app/generated \
-e GENERATED_FILES_URL_PREFIX=/generated-files \
-e PUBLIC_API_BASE_URL=https://api.xiaoxiajianji.com \
-v "$GENERATED_DIR:/app/generated" \
--restart unless-stopped \
--cpus 2 \
--memory 2g \
--health-cmd "python -c \"import urllib.request; urllib.request.urlopen('http://localhost:8000/health', timeout=5)\"" \
--health-interval 30s \
--health-timeout 10s \
--health-retries 3 \
--health-start-period 40s \
$LOG_OPTS \
"$LOCAL_API"
# 启动 Worker
echo "Starting Worker (rollback)..."
docker run -d \
--name xiaoxia-worker-production \
--env-file "$ENV_FILE" \
--network xiaoxia-net-production \
-e APP_ENV=production \
-e APP_VERSION="$IMAGE_TAG" \
-e WORKER_CONCURRENCY=1 \
-e WORKER_MAX_TASKS_PER_CHILD=100 \
-e GENERATED_FILES_DIR=/app/generated \
-e GENERATED_FILES_URL_PREFIX=/generated-files \
-e PUBLIC_API_BASE_URL=https://api.xiaoxiajianji.com \
-v "$GENERATED_DIR:/app/generated" \
--restart unless-stopped \
--cpus 2 \
--memory 2g \
--health-cmd "sh -c \"grep -q celery /proc/1/cmdline || exit 1\"" \
--health-interval 30s \
--health-timeout 10s \
--health-retries 3 \
--health-start-period 30s \
$LOG_OPTS \
"$LOCAL_WORKER"
# 启动 Web
LEGACY_VOLUME=""
if [ -d "$LEGACY_ASSETS_DIR" ] && [ "$(ls -A "$LEGACY_ASSETS_DIR" 2>/dev/null)" ]; then
LEGACY_VOLUME="-v ${LEGACY_ASSETS_DIR}:/usr/share/nginx/html/assets-legacy/assets:ro"
fi
echo "Starting Web (rollback)..."
docker run -d \
--name xiaoxia-web-production \
--network xiaoxia-net-production \
-p 127.0.0.1:3002:80 \
--restart unless-stopped \
--cpus 0.5 \
--memory 512m \
$LEGACY_VOLUME \
--health-cmd "wget --spider -q http://127.0.0.1:80" \
--health-interval 30s \
--health-timeout 5s \
--health-retries 3 \
$LOG_OPTS \
"$LOCAL_WEB"
# 等待 API 健康
echo "Waiting for API (rollback)..."
i=0
while [ "$i" -lt 40 ]; do
if curl -sf --max-time 5 http://127.0.0.1:8001/health >/dev/null 2>&1; then
echo "API healthy (rollback)."
break
fi
i=$((i + 1))
sleep 3
done
# 等待 Web 健康
echo "Waiting for Web (rollback)..."
i=0
while [ "$i" -lt 15 ]; do
if curl -sf --max-time 5 http://127.0.0.1:3002/ >/dev/null 2>&1; then
echo "Web healthy (rollback)."
break
fi
i=$((i + 1))
sleep 2
done
echo "=== Rollback complete: $IMAGE_TAG ==="
docker ps --format "table {{.Names}}\t{{.Status}}\t{{.Image}}" | grep production
ROLLBACK_EOF
)
# 将脚本 base64 编码后通过 SSH 执行
local script_b64
script_b64=$(echo "$rollback_script" | base64 -w 0)
log_info "在生产服务器上执行回滚脚本..."
if run_ssh "echo '$script_b64' | base64 -d | sh -s -- '$rollback_tag' '${REGISTRY_TOKEN:-}'" 2>&1; then
log_info "✅ 回滚命令执行完成"
return 0
else
log_error "❌ 回滚命令执行失败"
return 1
fi
}
# ===========================================
# 4. 发送通知
# ===========================================
send_notification() {
local status="$1" # success / failure / rollback
local detail="$2"
if [ "${SKIP_NOTIFY:-false}" = "true" ]; then
log_info "跳过通知(SKIP_NOTIFY=true"
return 0
fi
local webhook="${CI_NOTIFY_WEBHOOK:-}"
if [ -z "$webhook" ]; then
log_warn "未配置 CI_NOTIFY_WEBHOOK,跳过通知"
return 0
fi
if [ -f "$SCRIPT_DIR/deploy_notify.py" ]; then
python3 "$SCRIPT_DIR/deploy_notify.py" \
--status "$status" \
--detail "$detail" \
--webhook "$webhook" \
--env production \
2>/dev/null || log_warn "通知发送失败(非致命)"
else
log_warn "找不到 deploy_notify.py,跳过通知"
fi
# 标记:通知已由健康检查脚本发出,避免 CI 兜底通知重复发送
echo "$status" > /tmp/prod_deploy_notification_sent
}
# ===========================================
# 主流程
# ===========================================
main() {
echo ""
echo "=========================================="
echo " CI Production 健康检查 + 自动回滚"
echo "=========================================="
echo ""
local deploy_status="success"
local deploy_detail=""
# 1. 设置 SSH
if ! setup_ssh; then
log_error "SSH 配置失败,无法执行回滚"
fi
# 2. 记录部署前状态(回滚目标)
save_rollback_target || true
# 3. 健康检查(公网视角)
if ! health_check; then
log_error "健康检查失败"
deploy_status="failure"
deploy_detail="公网健康检查超时,部署后服务未正常响应"
# 自动回滚
if [ "${SKIP_ROLLBACK:-false}" != "true" ]; then
log_warn "开始自动回滚..."
if do_rollback; then
deploy_status="rollback"
deploy_detail="健康检查失败,已自动回滚到上一版本 (${ROLLBACK_TAG:-未知})"
# 回滚后再检查一下公网状态
log_info "回滚完成,重新检查公网健康状态..."
if health_check; then
log_info "✅ 回滚后服务已恢复"
deploy_detail="${deploy_detail},回滚后服务已恢复"
else
log_error "⚠️ 回滚后健康检查仍未通过,请手动排查"
deploy_detail="${deploy_detail},但回滚后仍未恢复,请紧急排查"
fi
else
deploy_detail="健康检查失败且回滚失败,请手动排查"
fi
fi
send_notification "$deploy_status" "$deploy_detail"
# 失败时退出非零,让 CI Job 标记为失败
exit 1
fi
# 4. 成功
log_info ""
log_info "=================================="
log_info " ✅ Production 部署成功!"
log_info "=================================="
deploy_detail="部署成功,所有健康检查通过 (${GITHUB_REF_NAME:-未知版本})"
send_notification "success" "$deploy_detail"
}
main "$@"
-250
View File
@@ -1,250 +0,0 @@
#!/bin/sh
# ===========================================
# Staging 部署脚本(SSH 模式)
# ===========================================
# 通过 SSH 在 staging 服务器上执行
#
# 环境变量:
# IMAGE_TAG - 镜像版本 tag(如 commit SHA 或分支名)
# REGISTRY_TOKEN - Registry 访问令牌
# REGISTRY - Registry 地址(默认 xiaoxia-registry.cn-hangzhou.cr.aliyuncs.com/xiaoxiakeji
# REGISTRY_USER - Registry 用户名(默认 xiaoxia
# ENV_FILE - 环境变量文件路径
# GENERATED_DIR - 生成文件目录
# SKIP_MIGRATION - 跳过数据库迁移(true/false,默认 false
set -eu
IMAGE_TAG="${IMAGE_TAG:-}"
REGISTRY="${REGISTRY:-xiaoxia-registry.cn-hangzhou.cr.aliyuncs.com/xiaoxiakeji}"
REGISTRY_USER="${ACR_USERNAME:-${REGISTRY_USER:-nick0415343655}}"
REGISTRY_TOKEN="${ACR_PASSWORD:-${REGISTRY_TOKEN:-}}"
ENV_FILE="${ENV_FILE:-/var/lib/xiaoxia-saas-staging/.env}"
GENERATED_DIR="${GENERATED_DIR:-/var/lib/xiaoxia-saas-staging/generated}"
LEGACY_ASSETS_DIR="${LEGACY_ASSETS_DIR:-/var/lib/xiaoxia-saas-staging/legacy-assets}"
SKIP_MIGRATION="${SKIP_MIGRATION:-false}"
if [ -z "$IMAGE_TAG" ]; then
echo "ERROR: IMAGE_TAG is required"
exit 1
fi
test -f "$ENV_FILE"
mkdir -p "$GENERATED_DIR"
mkdir -p "$LEGACY_ASSETS_DIR"
echo "=========================================="
echo " Staging 部署 - $IMAGE_TAG"
echo "=========================================="
# ---- 登录 Registry ----
if [ -n "$REGISTRY_TOKEN" ]; then
echo "Logging in to registry: $REGISTRY"
REGISTRY_HOST=$(echo "$REGISTRY" | cut -d/ -f1)
printf %s "$REGISTRY_TOKEN" | docker login "$REGISTRY_HOST" -u "$REGISTRY_USER" --password-stdin 2>/dev/null || {
echo "WARN: docker login failed, will try to pull anyway"
}
fi
# ---- Pull 新版本镜像 ----
REGISTRY_API="${REGISTRY}/xiaoxia-saas-api:${IMAGE_TAG}"
REGISTRY_WORKER="${REGISTRY}/xiaoxia-saas-worker:${IMAGE_TAG}"
REGISTRY_WEB="${REGISTRY}/xiaoxia-saas-web:${IMAGE_TAG}"
LOCAL_API="xiaoxia-saas-api:${IMAGE_TAG}"
LOCAL_WORKER="xiaoxia-saas-worker:${IMAGE_TAG}"
LOCAL_WEB="xiaoxia-saas-web:${IMAGE_TAG}"
echo "Pulling API image..."
docker pull "$REGISTRY_API"
echo "Pulling Worker image..."
docker pull "$REGISTRY_WORKER"
echo "Pulling Web image..."
docker pull "$REGISTRY_WEB"
# Re-tag 成本地名
docker tag "$REGISTRY_API" "$LOCAL_API"
docker tag "$REGISTRY_WORKER" "$LOCAL_WORKER"
docker tag "$REGISTRY_WEB" "$LOCAL_WEB"
echo "All images pulled and tagged."
# ---- 备份 legacy assets ----
echo "Backing up legacy assets from current web container..."
if docker inspect xiaoxia-web-staging >/dev/null 2>&1; then
_tmpdir="/tmp/legacy-assets-$$"
rm -rf "$_tmpdir"
mkdir -p "$_tmpdir"
docker cp xiaoxia-web-staging:/usr/share/nginx/html/assets/. "$_tmpdir/" 2>/dev/null || true
# 只有目录非空才拷贝,避免覆盖有内容的 legacy assets
if [ -d "$_tmpdir" ] && [ "$(ls -A "$_tmpdir" 2>/dev/null)" ]; then
cp -an "$_tmpdir"/. "$LEGACY_ASSETS_DIR"/ 2>/dev/null || true
echo "Legacy assets backed up: $(ls "$_tmpdir" | wc -l) files"
fi
rm -rf "$_tmpdir"
else
echo "No existing web container, skipping legacy assets backup"
fi
# 清理 7 天前的 legacy assets
if [ -d "$LEGACY_ASSETS_DIR" ]; then
find "$LEGACY_ASSETS_DIR" -type f -mtime +7 -delete 2>/dev/null || true
echo "Legacy assets cleanup done (retain 7 days)"
fi
# ---- 检查基础设施容器 ----
echo "Checking infrastructure containers..."
for c in xiaoxia-postgres-staging xiaoxia-redis-staging; do
if ! docker inspect "$c" >/dev/null 2>&1; then
echo "ERROR: Required container not found: $c"
exit 1
fi
state=$(docker inspect -f '{{.State.Status}}' "$c")
if [ "$state" != "running" ]; then
echo "ERROR: Container not running: $c ($state)"
exit 1
fi
done
# ---- 创建网络(不存在则创建) ----
docker network create xiaoxia-net-staging 2>/dev/null || true
# ---- 数据库迁移 ----
if [ "$SKIP_MIGRATION" != "true" ]; then
echo "Running database migrations..."
docker run --rm \
--env-file "$ENV_FILE" \
--network xiaoxia-net-staging \
-e APP_ENV=staging \
"$LOCAL_API" sh -c "cd /app && alembic upgrade head"
echo "Migrations completed."
else
echo "Skipping migrations (SKIP_MIGRATION=true)"
fi
# ---- 停止旧容器 ----
echo "Stopping old containers..."
docker rm -f xiaoxia-api-staging 2>/dev/null || true
docker rm -f xiaoxia-worker-staging 2>/dev/null || true
docker rm -f xiaoxia-web-staging 2>/dev/null || true
LOG_OPTS="--log-driver json-file --log-opt max-size=50m --log-opt max-file=3"
# ---- 启动 API ----
echo "Starting API container..."
docker run -d \
--name xiaoxia-api-staging \
--env-file "$ENV_FILE" \
--network xiaoxia-net-staging \
-p 127.0.0.1:8000:8000 \
-e APP_ENV=staging \
-e APP_VERSION="$IMAGE_TAG" \
-e GENERATED_FILES_DIR=/app/generated \
-e GENERATED_FILES_URL_PREFIX=/generated-files \
-e PUBLIC_API_BASE_URL=https://staging-api.xiaoxiajianji.com \
-v "$GENERATED_DIR:/app/generated" \
--restart unless-stopped \
--health-cmd "python -c \"import urllib.request; urllib.request.urlopen('http://localhost:8000/health', timeout=5)\"" \
--health-interval 30s \
--health-timeout 10s \
--health-retries 3 \
--health-start-period 40s \
$LOG_OPTS \
"$LOCAL_API"
# ---- 启动 Worker ----
echo "Starting Worker container..."
docker run -d \
--name xiaoxia-worker-staging \
--env-file "$ENV_FILE" \
--network xiaoxia-net-staging \
-e APP_ENV=staging \
-e APP_VERSION="$IMAGE_TAG" \
-e WORKER_CONCURRENCY=1 \
-e WORKER_MAX_TASKS_PER_CHILD=100 \
-e GENERATED_FILES_DIR=/app/generated \
-e GENERATED_FILES_URL_PREFIX=/generated-files \
-e PUBLIC_API_BASE_URL=https://staging-api.xiaoxiajianji.com \
-v "$GENERATED_DIR:/app/generated" \
--restart unless-stopped \
--health-cmd "sh -c \"grep -q celery /proc/1/cmdline || exit 1\"" \
--health-interval 30s \
--health-timeout 10s \
--health-retries 3 \
--health-start-period 30s \
$LOG_OPTS \
"$LOCAL_WORKER"
# ---- 启动 Web ----
LEGACY_VOLUME=""
if [ -d "$LEGACY_ASSETS_DIR" ] && [ "$(ls -A "$LEGACY_ASSETS_DIR" 2>/dev/null)" ]; then
LEGACY_VOLUME="-v ${LEGACY_ASSETS_DIR}:/usr/share/nginx/html/assets-legacy/assets:ro"
echo "Web container: legacy assets mounted (fallback)"
else
echo "Web container: no legacy assets to mount"
fi
echo "Starting Web container..."
docker run -d \
--name xiaoxia-web-staging \
--network xiaoxia-net-staging \
-p 127.0.0.1:3001:80 \
--restart unless-stopped \
$LEGACY_VOLUME \
--health-cmd "wget --spider -q http://127.0.0.1:80" \
--health-interval 30s \
--health-timeout 5s \
--health-retries 3 \
$LOG_OPTS \
"$LOCAL_WEB"
# ---- 等待 API 健康 ----
echo "Waiting for API to become healthy..."
i=0
while [ "$i" -lt 40 ]; do
if curl -sf --max-time 5 http://127.0.0.1:8000/health >/dev/null 2>&1; then
echo "API is healthy!"
break
fi
i=$((i + 1))
echo " Waiting... ($i/40)"
sleep 3
done
if [ "$i" -ge 40 ]; then
echo "ERROR: API did not become healthy within 120s"
docker logs --tail 50 xiaoxia-api-staging
exit 1
fi
# ---- 等待 Web 健康 ----
echo "Waiting for Web to become healthy..."
i=0
while [ "$i" -lt 15 ]; do
if curl -sf --max-time 5 http://127.0.0.1:3001/ >/dev/null 2>&1; then
echo "Web is healthy!"
break
fi
i=$((i + 1))
echo " Waiting... ($i/15)"
sleep 2
done
if [ "$i" -ge 15 ]; then
echo "ERROR: Web did not become healthy within 30s"
docker logs --tail 30 xiaoxia-web-staging
exit 1
fi
# ---- 清理旧镜像 ----
echo "Cleaning up old images..."
docker image prune -af --filter "until=168h" 2>/dev/null || true
docker builder prune -af --filter "until=168h" 2>/dev/null || true
echo ""
echo "=== Staging deployment complete ==="
echo "API: http://127.0.0.1:8000"
echo "Web: http://127.0.0.1:3001"
echo "Version: $IMAGE_TAG"
docker ps --format "table {{.Names}}\t{{.Status}}\t{{.Image}}" | grep staging
-473
View File
@@ -1,473 +0,0 @@
#!/bin/bash
# ===========================================
# CI Staging 健康检查 + 自动回滚脚本(SSH 部署模式)
# ===========================================
#
# 在 CI Runner 上执行,通过公网 URL 检查 Staging 部署健康状态。
# 不健康则通过 SSH 自动回滚到上一个版本的镜像。
#
# 用法:
# ./ci_staging_healthcheck.sh
#
# 环境变量:
# STAGING_API_URL - Staging API 地址 (默认 https://staging-api.xiaoxiajianji.com)
# STAGING_WEB_URL - Staging Web 地址 (默认 https://staging.xiaoxiajianji.com)
# HEALTH_CHECK_TIMEOUT - 健康检查总超时秒数 (默认 120)
# SKIP_ROLLBACK - 失败时不自动回滚 (true/false, 默认 false)
# SKIP_NOTIFY - 跳过通知 (true/false, 默认 false)
# CI_NOTIFY_WEBHOOK - 通知 Webhook URL
#
# STAGING_SSH_HOST - Staging 服务器 SSH 地址 (默认 47.98.113.167)
# STAGING_SSH_USER - SSH 用户名 (默认 root)
# STAGING_SSH_PORT - SSH 端口 (默认 22222)
# STAGING_SSH_KEY - SSH 私钥内容
# REGISTRY_TOKEN - Registry Token(回滚时拉取旧镜像需要)
#
# GITHUB_SHA - 当前 commit SHA
# GITHUB_REF_NAME - 分支名
# GITHUB_RUN_ID - CI Run ID
# GITHUB_REPOSITORY - 仓库名
# GITHUB_ACTOR - 提交者
set -eu
SCRIPT_DIR="$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)"
# 配置
STAGING_API_URL="${STAGING_API_URL:-https://staging-api.xiaoxiajianji.com}"
STAGING_WEB_URL="${STAGING_WEB_URL:-https://staging.xiaoxiajianji.com}"
HEALTH_CHECK_TIMEOUT="${HEALTH_CHECK_TIMEOUT:-120}"
SKIP_ROLLBACK="${SKIP_ROLLBACK:-false}"
SKIP_NOTIFY="${SKIP_NOTIFY:-false}"
STAGING_SSH_HOST="${STAGING_SSH_HOST:-47.98.113.167}"
STAGING_SSH_USER="${STAGING_SSH_USER:-root}"
STAGING_SSH_PORT="${STAGING_SSH_PORT:-22222}"
REGISTRY="${REGISTRY:-git.xiaoxiajianji.com/xiaoxia/xiaoxia-saas}"
REGISTRY_USER="${REGISTRY_USER:-xiaoxia}"
# 颜色
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
NC='\033[0m'
log_info() { echo -e "${GREEN}[INFO]${NC} $1"; }
log_warn() { echo -e "${YELLOW}[WARN]${NC} $1"; }
log_error() { echo -e "${RED}[ERROR]${NC} $1"; }
log_step() { echo -e "${BLUE}[STEP]${NC} $1"; }
# ===========================================
# SSH 工具函数
# ===========================================
SSH_KEY_PATH=""
setup_ssh() {
# 查找或创建 SSH 密钥
if [ -f /root/.ssh/xiaoxia_runtime_builder ]; then
SSH_KEY_PATH="/root/.ssh/xiaoxia_runtime_builder"
elif [ -f "$HOME/.ssh/xiaoxia_runtime_builder" ]; then
SSH_KEY_PATH="$HOME/.ssh/xiaoxia_runtime_builder"
elif [ -n "${STAGING_SSH_KEY:-}" ]; then
SSH_KEY_PATH="$HOME/.ssh/staging_deploy_key"
mkdir -p "$HOME/.ssh"
printf '%s\n' "$STAGING_SSH_KEY" > "$SSH_KEY_PATH"
chmod 600 "$SSH_KEY_PATH"
else
log_error "没有可用的 SSH 密钥"
return 1
fi
ssh-keyscan -p "$STAGING_SSH_PORT" -H "$STAGING_SSH_HOST" >> ~/.ssh/known_hosts 2>/dev/null || true
log_info "SSH 已配置: ${STAGING_SSH_USER}@${STAGING_SSH_HOST}:${STAGING_SSH_PORT}"
}
run_ssh() {
local cmd="$1"
ssh -p "$STAGING_SSH_PORT" -i "$SSH_KEY_PATH" -o StrictHostKeyChecking=no \
"${STAGING_SSH_USER}@${STAGING_SSH_HOST}" "$cmd"
}
# ===========================================
# 1. 记录部署前各服务的镜像版本(用于回滚)
# ===========================================
ROLLBACK_API_TAG=""
ROLLBACK_WORKER_TAG=""
ROLLBACK_WEB_TAG=""
save_rollback_target() {
log_step "记录当前 staging 各服务镜像版本(回滚目标)..."
# 通过 SSH 获取当前运行的容器镜像
local api_image worker_image web_image
api_image=$(run_ssh "docker inspect --format '{{.Config.Image}}' xiaoxia-api-staging 2>/dev/null || echo ''")
worker_image=$(run_ssh "docker inspect --format '{{.Config.Image}}' xiaoxia-worker-staging 2>/dev/null || echo ''")
web_image=$(run_ssh "docker inspect --format '{{.Config.Image}}' xiaoxia-web-staging 2>/dev/null || echo ''")
# 提取 tag(镜像名是 xiaoxia-saas-api:abc123 或 git.xiaoxiajianji.com/.../xiaoxia-saas-api:staging 格式)
ROLLBACK_API_TAG=$(echo "$api_image" | sed 's/.*://' || echo "")
ROLLBACK_WORKER_TAG=$(echo "$worker_image" | sed 's/.*://' || echo "")
ROLLBACK_WEB_TAG=$(echo "$web_image" | sed 's/.*://' || echo "")
log_info " API: ${ROLLBACK_API_TAG:-未知}"
log_info " Worker: ${ROLLBACK_WORKER_TAG:-未知}"
log_info " Web: ${ROLLBACK_WEB_TAG:-未知}"
# 验证三个服务版本是否一致
if [ -n "$ROLLBACK_API_TAG" ] && [ -n "$ROLLBACK_WORKER_TAG" ] && [ -n "$ROLLBACK_WEB_TAG" ]; then
if [ "$ROLLBACK_API_TAG" = "$ROLLBACK_WORKER_TAG" ] && [ "$ROLLBACK_API_TAG" = "$ROLLBACK_WEB_TAG" ]; then
log_info " ✅ 三个服务版本一致: $ROLLBACK_API_TAG"
export ROLLBACK_TAG="$ROLLBACK_API_TAG"
else
log_warn " ⚠️ 三个服务版本不一致,回滚时将分别使用各自版本"
export ROLLBACK_API_TAG ROLLBACK_WORKER_TAG ROLLBACK_WEB_TAG
export ROLLBACK_TAG_MIXED="true"
fi
else
log_warn " ⚠️ 未能获取全部服务版本,回滚功能可能受限"
fi
}
# ===========================================
# 2. 健康检查(公网视角)
# ===========================================
health_check() {
local timeout="$HEALTH_CHECK_TIMEOUT"
local start_time
start_time=$(date +%s)
log_step "公网健康检查(超时 ${timeout}s..."
log_info " API: ${STAGING_API_URL}/health"
log_info " Web: ${STAGING_WEB_URL}/"
local api_ok=false
local web_ok=false
local api_docs_ok=false
local login_api_ok=false
while [ $(( $(date +%s) - start_time )) -lt "$timeout" ]; do
# 检查 API health
if [ "$api_ok" = false ] && curl -sf --max-time 10 "${STAGING_API_URL}/health" >/dev/null 2>&1; then
log_info "✅ API 健康检查通过"
api_ok=true
fi
# 检查 Web 首页
if [ "$web_ok" = false ] && curl -sf --max-time 10 "$STAGING_WEB_URL/" >/dev/null 2>&1; then
log_info "✅ Web 前端检查通过"
web_ok=true
fi
# 检查 API docs(服务完全启动的标志)
if [ "$api_docs_ok" = false ]; then
HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" --max-time 10 "${STAGING_API_URL}/docs" 2>/dev/null || echo "000")
if [ "$HTTP_CODE" = "200" ]; then
log_info "✅ API Docs 检查通过"
api_docs_ok=true
fi
fi
# 检查登录 API(业务逻辑正常的标志)
if [ "$login_api_ok" = false ]; then
HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" --max-time 10 -X POST \
"${STAGING_API_URL}/api/v1/auth/login" \
-H "Content-Type: application/json" \
-d '{"email":"smoke@test.com","password":"wrong"}' 2>/dev/null || echo "000")
if [ "$HTTP_CODE" = "401" ] || [ "$HTTP_CODE" = "422" ]; then
log_info "✅ 登录 API 检查通过(HTTP $HTTP_CODE,符合预期)"
login_api_ok=true
fi
fi
# 都通过了就退出
if [ "$api_ok" = true ] && [ "$web_ok" = true ] && [ "$api_docs_ok" = true ] && [ "$login_api_ok" = true ]; then
log_info "🎉 所有健康检查通过!"
return 0
fi
sleep 5
done
# 超时了
log_error "❌ 健康检查超时 (${timeout}s)"
[ "$api_ok" = false ] && log_error " - API health 未通过"
[ "$web_ok" = false ] && log_error " - Web 前端未通过"
[ "$api_docs_ok" = false ] && log_error " - API Docs 未通过"
[ "$login_api_ok" = false ] && log_error " - 登录 API 未通过"
return 1
}
# ===========================================
# 3. 执行回滚(SSH 重新部署旧版本)
# ===========================================
do_rollback() {
log_step "执行回滚:通过 SSH 重新部署旧版本镜像..."
local rollback_tag="${ROLLBACK_TAG:-}"
if [ -z "$rollback_tag" ] && [ "${ROLLBACK_TAG_MIXED:-}" != "true" ]; then
log_error "没有可回滚的版本记录,无法自动回滚"
return 1
fi
# 如果版本不一致,用 API 的版本作为回滚目标
if [ -z "$rollback_tag" ]; then
rollback_tag="$ROLLBACK_API_TAG"
fi
if [ -z "$rollback_tag" ]; then
log_error "无法确定回滚版本"
return 1
fi
log_info "回滚目标版本: $rollback_tag"
# 构建回滚脚本(直接部署旧版本镜像,不跑 migration)
local rollback_script=$(cat << 'ROLLBACK_EOF'
#!/bin/sh
set -eu
IMAGE_TAG="$1"
REGISTRY_TOKEN="$2"
REGISTRY="${REGISTRY:-git.xiaoxiajianji.com/xiaoxia/xiaoxia-saas}"
REGISTRY_USER="${REGISTRY_USER:-xiaoxia}"
ENV_FILE="${ENV_FILE:-/var/lib/xiaoxia-saas-staging/.env}"
GENERATED_DIR="${GENERATED_DIR:-/var/lib/xiaoxia-saas-staging/generated}"
LEGACY_ASSETS_DIR="${LEGACY_ASSETS_DIR:-/var/lib/xiaoxia-saas-staging/legacy-assets}"
echo "=== Rollback to $IMAGE_TAG ==="
# 登录 Registry
if [ -n "$REGISTRY_TOKEN" ]; then
REGISTRY_HOST=$(echo "$REGISTRY" | cut -d/ -f1)
printf %s "$REGISTRY_TOKEN" | docker login "$REGISTRY_HOST" -u "$REGISTRY_USER" --password-stdin 2>/dev/null || true
fi
# Pull 旧版本镜像
LOCAL_API="xiaoxia-saas-api:${IMAGE_TAG}"
LOCAL_WORKER="xiaoxia-saas-worker:${IMAGE_TAG}"
LOCAL_WEB="xiaoxia-saas-web:${IMAGE_TAG}"
docker pull "${REGISTRY}/xiaoxia-saas-api:${IMAGE_TAG}"
docker pull "${REGISTRY}/xiaoxia-saas-worker:${IMAGE_TAG}"
docker pull "${REGISTRY}/xiaoxia-saas-web:${IMAGE_TAG}"
docker tag "${REGISTRY}/xiaoxia-saas-api:${IMAGE_TAG}" "$LOCAL_API"
docker tag "${REGISTRY}/xiaoxia-saas-worker:${IMAGE_TAG}" "$LOCAL_WORKER"
docker tag "${REGISTRY}/xiaoxia-saas-web:${IMAGE_TAG}" "$LOCAL_WEB"
echo "Rollback images pulled."
# 停止当前容器(回滚不跑 migration,避免数据问题)
docker rm -f xiaoxia-api-staging 2>/dev/null || true
docker rm -f xiaoxia-worker-staging 2>/dev/null || true
docker rm -f xiaoxia-web-staging 2>/dev/null || true
LOG_OPTS="--log-driver json-file --log-opt max-size=50m --log-opt max-file=3"
# 启动 API(回滚不跑 migration
echo "Starting API (rollback)..."
docker run -d \
--name xiaoxia-api-staging \
--env-file "$ENV_FILE" \
--network xiaoxia-net-staging \
-p 127.0.0.1:8000:8000 \
-e APP_ENV=staging \
-e APP_VERSION="$IMAGE_TAG" \
-e GENERATED_FILES_DIR=/app/generated \
-e GENERATED_FILES_URL_PREFIX=/generated-files \
-e PUBLIC_API_BASE_URL=https://staging-api.xiaoxiajianji.com \
-v "$GENERATED_DIR:/app/generated" \
--restart unless-stopped \
--health-cmd "python -c \"import urllib.request; urllib.request.urlopen('http://localhost:8000/health', timeout=5)\"" \
--health-interval 30s \
--health-timeout 10s \
--health-retries 3 \
--health-start-period 40s \
$LOG_OPTS \
"$LOCAL_API"
# 启动 Worker
echo "Starting Worker (rollback)..."
docker run -d \
--name xiaoxia-worker-staging \
--env-file "$ENV_FILE" \
--network xiaoxia-net-staging \
-e APP_ENV=staging \
-e APP_VERSION="$IMAGE_TAG" \
-e WORKER_CONCURRENCY=1 \
-e WORKER_MAX_TASKS_PER_CHILD=100 \
-e GENERATED_FILES_DIR=/app/generated \
-e GENERATED_FILES_URL_PREFIX=/generated-files \
-e PUBLIC_API_BASE_URL=https://staging-api.xiaoxiajianji.com \
-v "$GENERATED_DIR:/app/generated" \
--restart unless-stopped \
--health-cmd "sh -c \"grep -q celery /proc/1/cmdline || exit 1\"" \
--health-interval 30s \
--health-timeout 10s \
--health-retries 3 \
--health-start-period 30s \
$LOG_OPTS \
"$LOCAL_WORKER"
# 启动 Web
LEGACY_VOLUME=""
if [ -d "$LEGACY_ASSETS_DIR" ] && [ "$(ls -A "$LEGACY_ASSETS_DIR" 2>/dev/null)" ]; then
LEGACY_VOLUME="-v ${LEGACY_ASSETS_DIR}:/usr/share/nginx/html/assets-legacy/assets:ro"
fi
echo "Starting Web (rollback)..."
docker run -d \
--name xiaoxia-web-staging \
--network xiaoxia-net-staging \
-p 127.0.0.1:3001:80 \
--restart unless-stopped \
$LEGACY_VOLUME \
--health-cmd "wget --spider -q http://127.0.0.1:80" \
--health-interval 30s \
--health-timeout 5s \
--health-retries 3 \
$LOG_OPTS \
"$LOCAL_WEB"
# 等待 API 健康
echo "Waiting for API (rollback)..."
i=0
while [ "$i" -lt 40 ]; do
if curl -sf --max-time 5 http://127.0.0.1:8000/health >/dev/null 2>&1; then
echo "API healthy (rollback)."
break
fi
i=$((i + 1))
sleep 3
done
# 等待 Web 健康
echo "Waiting for Web (rollback)..."
i=0
while [ "$i" -lt 15 ]; do
if curl -sf --max-time 5 http://127.0.0.1:3001/ >/dev/null 2>&1; then
echo "Web healthy (rollback)."
break
fi
i=$((i + 1))
sleep 2
done
echo "=== Rollback complete: $IMAGE_TAG ==="
docker ps --format "table {{.Names}}\t{{.Status}}\t{{.Image}}" | grep staging
ROLLBACK_EOF
)
# 将脚本 base64 编码后通过 SSH 执行
local script_b64
script_b64=$(echo "$rollback_script" | base64 -w 0)
log_info "在 staging 服务器上执行回滚脚本..."
if run_ssh "echo '$script_b64' | base64 -d | sh -s -- '$rollback_tag' '${REGISTRY_TOKEN:-}'" 2>&1; then
log_info "✅ 回滚命令执行完成"
return 0
else
log_error "❌ 回滚命令执行失败"
return 1
fi
}
# ===========================================
# 4. 发送通知
# ===========================================
send_notification() {
local status="$1" # success / failure / rollback
local detail="$2"
if [ "${SKIP_NOTIFY:-false}" = "true" ]; then
log_info "跳过通知(SKIP_NOTIFY=true"
return 0
fi
local webhook="${CI_NOTIFY_WEBHOOK:-}"
if [ -z "$webhook" ]; then
log_warn "未配置 CI_NOTIFY_WEBHOOK,跳过通知"
return 0
fi
if [ -f "$SCRIPT_DIR/deploy_notify.py" ]; then
python3 "$SCRIPT_DIR/deploy_notify.py" \
--status "$status" \
--detail "$detail" \
--webhook "$webhook" \
--env staging \
2>/dev/null || log_warn "通知发送失败(非致命)"
else
log_warn "找不到 deploy_notify.py,跳过通知"
fi
}
# ===========================================
# 主流程
# ===========================================
main() {
echo ""
echo "==========================================="
echo " CI Staging 健康检查 + 自动回滚(SSH模式)"
echo "==========================================="
echo ""
local deploy_status="success"
local deploy_detail=""
# 1. 设置 SSH
if ! setup_ssh; then
log_error "SSH 配置失败,无法执行回滚"
fi
# 2. 记录部署前状态(回滚目标)
save_rollback_target || true
# 3. 健康检查(公网视角)
if ! health_check; then
log_error "健康检查失败"
deploy_status="failure"
deploy_detail="公网健康检查超时,部署后服务未正常响应"
# 自动回滚
if [ "${SKIP_ROLLBACK:-false}" != "true" ]; then
log_warn "开始自动回滚..."
if do_rollback; then
deploy_status="rollback"
deploy_detail="健康检查失败,已自动回滚到上一版本 (${ROLLBACK_TAG:-未知})"
# 回滚后再检查一下公网状态
log_info "回滚完成,重新检查公网健康状态..."
if health_check; then
log_info "✅ 回滚后服务已恢复"
deploy_detail="${deploy_detail},回滚后服务已恢复"
else
log_error "⚠️ 回滚后健康检查仍未通过,请手动排查"
deploy_detail="${deploy_detail},但回滚后仍未恢复,请紧急排查"
fi
else
deploy_detail="健康检查失败且回滚失败,请手动排查"
fi
fi
send_notification "$deploy_status" "$deploy_detail"
# 失败时退出非零,让 CI Job 标记为失败
exit 1
fi
# 4. 成功
log_info ""
log_info "=================================="
log_info " ✅ Staging 部署成功!"
log_info "=================================="
deploy_detail="部署成功,所有健康检查通过 (${GITHUB_SHA:-未知版本})"
send_notification "success" "$deploy_detail"
}
main "$@"

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