diff --git a/apps/api/app/api/routes/auth.py b/apps/api/app/api/routes/auth.py index 5f178a313..f37381406 100755 --- a/apps/api/app/api/routes/auth.py +++ b/apps/api/app/api/routes/auth.py @@ -237,8 +237,6 @@ async def reset_password( return MessageResponse(message="密码重置成功") - - @router.post("/logout") async def logout( credentials: HTTPAuthorizationCredentials | None = Depends(bearer_scheme), @@ -246,6 +244,7 @@ async def logout( ): """登出 - 将当前 token 加入黑名单""" from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer + if credentials: try: payload = jwt.decode(credentials.credentials, settings.JWT_SECRET_KEY, algorithms=["HS256"]) @@ -255,6 +254,7 @@ async def logout( logger.warning(f"Operation failed in apps/api/app/api/routes/auth.py: {e}", exc_info=True) return MessageResponse(message="已登出") + @router.get("/me", response_model=CurrentUserResponse) async def get_current_user_info( authenticated_user: AuthenticatedUser = Depends(get_current_user), diff --git a/apps/api/app/api/routes/edit_plans.py b/apps/api/app/api/routes/edit_plans.py index 513c6b860..4412d598d 100644 --- a/apps/api/app/api/routes/edit_plans.py +++ b/apps/api/app/api/routes/edit_plans.py @@ -161,10 +161,7 @@ def list_plans( except ValueError: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, - detail=( - f"无效的状态值: {status_filter}," - f"可选值: draft, editing, rendering, completed, failed" - ), + detail=(f"无效的状态值: {status_filter}," f"可选值: draft, editing, rendering, completed, failed"), ) skip = (page - 1) * page_size @@ -261,10 +258,7 @@ def update_plan( except ValueError: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, - detail=( - f"无效的状态值: {body.status}," - f"可选值: draft, editing, rendering, completed, failed" - ), + detail=(f"无效的状态值: {body.status}," f"可选值: draft, editing, rendering, completed, failed"), ) svc.transition_status(plan_id, target_status) except ValueError as exc: @@ -419,7 +413,7 @@ class TimelineSceneResponse(BaseModel): """时间线场景""" scene: str = Field(..., description="场景描述") - time: str = Field(..., description="时间范围,如 \"0:00 - 0:05\"") + time: str = Field(..., description='时间范围,如 "0:00 - 0:05"') duration: float = Field(..., ge=0, description="时长(秒)") color: str = Field(..., description="展示颜色") clip_id: str = Field(default="", description="关联的片段 ID") diff --git a/apps/api/app/api/routes/jobs.py b/apps/api/app/api/routes/jobs.py index 18f5d4a92..8aa812f1b 100755 --- a/apps/api/app/api/routes/jobs.py +++ b/apps/api/app/api/routes/jobs.py @@ -97,8 +97,7 @@ def create_job( except ValueError: raise HTTPException( status_code=400, - detail=f"不支持的任务类型: {request.job_type}," - f"可选值: {[t.value for t in JobType]}", + detail=f"不支持的任务类型: {request.job_type}," f"可选值: {[t.value for t in JobType]}", ) use_case = CreateJobUseCase(job_repo) diff --git a/apps/api/app/api/routes/subscription.py b/apps/api/app/api/routes/subscription.py index 74e774414..46fb7411d 100644 --- a/apps/api/app/api/routes/subscription.py +++ b/apps/api/app/api/routes/subscription.py @@ -205,8 +205,6 @@ async def cancel_subscription( ) - - @router.post("/payment-callback") async def payment_callback( user_id: str, @@ -234,14 +232,16 @@ async def payment_callback( # 创建账单记录 record_id = uuid.uuid4().hex - record = repo.create({ - "id": record_id, - "user_id": user_id, - "plan_name": _get_plan_name(plan), - "amount": amount, - "billing_cycle": billing_cycle, - "status": "pending", - }) + record = repo.create( + { + "id": record_id, + "user_id": user_id, + "plan_name": _get_plan_name(plan), + "amount": amount, + "billing_cycle": billing_cycle, + "status": "pending", + } + ) # 在事务中标记支付成功并更新订阅 repo.mark_paid(record_id, payment_method, payment_id) @@ -258,6 +258,7 @@ async def payment_callback( finally: session.close() + @router.post("/toggle-auto-renew", response_model=SimpleResponse) async def toggle_auto_renew( request: ToggleAutoRenewRequest, diff --git a/apps/api/app/api/routes/tts.py b/apps/api/app/api/routes/tts.py index 21823a3d7..fffecc959 100644 --- a/apps/api/app/api/routes/tts.py +++ b/apps/api/app/api/routes/tts.py @@ -88,7 +88,8 @@ def synthesize( # 提交 CosyVoice 合成任务 workflow = TTSWorkflowService( - repository=repository, cosyvoice_service=cosyvoice_service, + repository=repository, + cosyvoice_service=cosyvoice_service, ) job = workflow.start_synthesis(job.id) @@ -98,12 +99,11 @@ def synthesize( if task_id: try: from worker_app.tasks import process_tts_synthesis + process_tts_synthesis.delay(job.id) except Exception as e: # Celery 调度失败,标记 job 为 failed - workflow.process_synthesis_failure( - job.id, f"Celery 任务调度失败: {e}" - ) + workflow.process_synthesis_failure(job.id, f"Celery 任务调度失败: {e}") return TTSSynthesizeResponse( job_id=job.id, @@ -124,9 +124,7 @@ def list_tts_jobs( user_id = authenticated_user.user.id use_case = ListTTSJobsUseCase(repository) skip = (page - 1) * page_size - items, total = use_case.execute( - user_id, status=status_filter, skip=skip, limit=page_size - ) + items, total = use_case.execute(user_id, status=status_filter, skip=skip, limit=page_size) return ListTTSJobResponse( items=[_to_response(j) for j in items], total=total, diff --git a/apps/api/app/api/routes/voice_clones.py b/apps/api/app/api/routes/voice_clones.py index e148f6308..011faaf5f 100644 --- a/apps/api/app/api/routes/voice_clones.py +++ b/apps/api/app/api/routes/voice_clones.py @@ -59,14 +59,10 @@ def _to_response(profile) -> VoiceCloneProfileResponse: def _get_workflow_service( - repository: SQLAlchemyVoiceCloneProfileRepository = Depends( - get_voice_clone_profile_repository - ), + repository: SQLAlchemyVoiceCloneProfileRepository = Depends(get_voice_clone_profile_repository), cosyvoice_service: CosyVoiceService = Depends(get_cosyvoice_service), ) -> VoiceCloneWorkflowService: - return VoiceCloneWorkflowService( - repository=repository, cosyvoice_service=cosyvoice_service - ) + return VoiceCloneWorkflowService(repository=repository, cosyvoice_service=cosyvoice_service) @router.post( @@ -109,13 +105,9 @@ def create_voice_clone( logger.error(f"Failed to dispatch Celery task: {e}") # P2-3: Celery 调度失败时标记 profile 为 failed,避免永久卡在 processing try: - workflow.process_clone_failure( - profile.id, f"Celery 任务调度失败: {e}" - ) + workflow.process_clone_failure(profile.id, f"Celery 任务调度失败: {e}") except Exception as inner_e: - logger.error( - f"Failed to mark profile as failed after dispatch error: {inner_e}" - ) + logger.error(f"Failed to mark profile as failed after dispatch error: {inner_e}") return _to_response(profile) @@ -126,16 +118,12 @@ def list_voice_clones( skip: int = Query(0, ge=0), limit: int = Query(50, ge=1, le=200), authenticated_user: AuthenticatedUser = Depends(get_current_user), - repository: SQLAlchemyVoiceCloneProfileRepository = Depends( - get_voice_clone_profile_repository - ), + repository: SQLAlchemyVoiceCloneProfileRepository = Depends(get_voice_clone_profile_repository), ) -> ListVoiceCloneResponse: """获取用户的音色克隆列表。""" user_id = authenticated_user.user.id use_case = ListVoiceClonesUseCase(repository) - items, total = use_case.execute( - user_id, status=status_filter, skip=skip, limit=limit - ) + items, total = use_case.execute(user_id, status=status_filter, skip=skip, limit=limit) return ListVoiceCloneResponse( items=[_to_response(p) for p in items], total=total, @@ -146,9 +134,7 @@ def list_voice_clones( def get_voice_clone( clone_id: str, authenticated_user: AuthenticatedUser = Depends(get_current_user), - repository: SQLAlchemyVoiceCloneProfileRepository = Depends( - get_voice_clone_profile_repository - ), + repository: SQLAlchemyVoiceCloneProfileRepository = Depends(get_voice_clone_profile_repository), ) -> VoiceCloneProfileResponse: """获取音色克隆详情。""" user_id = authenticated_user.user.id @@ -156,9 +142,7 @@ def get_voice_clone( try: profile = use_case.execute(clone_id, user_id) except VoiceCloneNotFoundError: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, detail="Voice clone not found" - ) + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Voice clone not found") return _to_response(profile) @@ -166,9 +150,7 @@ def get_voice_clone( def get_voice_clone_status( clone_id: str, authenticated_user: AuthenticatedUser = Depends(get_current_user), - repository: SQLAlchemyVoiceCloneProfileRepository = Depends( - get_voice_clone_profile_repository - ), + repository: SQLAlchemyVoiceCloneProfileRepository = Depends(get_voice_clone_profile_repository), ) -> VoiceCloneStatusResponse: """查询音色克隆状态(用于前端轮询)。""" user_id = authenticated_user.user.id @@ -176,9 +158,7 @@ def get_voice_clone_status( try: profile = use_case.execute(clone_id, user_id) except VoiceCloneNotFoundError: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, detail="Voice clone not found" - ) + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Voice clone not found") return VoiceCloneStatusResponse( id=profile.id, status=profile.status, @@ -195,18 +175,14 @@ def get_voice_clone_status( def delete_voice_clone( clone_id: str, authenticated_user: AuthenticatedUser = Depends(get_current_user), - repository: SQLAlchemyVoiceCloneProfileRepository = Depends( - get_voice_clone_profile_repository - ), + repository: SQLAlchemyVoiceCloneProfileRepository = Depends(get_voice_clone_profile_repository), ) -> Response: """删除音色克隆档案。""" user_id = authenticated_user.user.id use_case = DeleteVoiceCloneUseCase(repository) deleted = use_case.execute(clone_id, user_id) if not deleted: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, detail="Voice clone not found" - ) + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Voice clone not found") return Response(status_code=204) @@ -224,9 +200,7 @@ def retry_voice_clone( try: profile = workflow.retry_clone(clone_id, user_id) except VoiceCloneNotFoundError: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, detail="Voice clone not found" - ) + 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, @@ -245,12 +219,8 @@ def retry_voice_clone( logger.error(f"Failed to dispatch Celery task: {e}") # P2-3: Celery 调度失败时标记 profile 为 failed,避免永久卡在 processing try: - workflow.process_clone_failure( - profile.id, f"Celery 任务调度失败: {e}" - ) + workflow.process_clone_failure(profile.id, f"Celery 任务调度失败: {e}") except Exception as inner_e: - logger.error( - f"Failed to mark profile as failed after dispatch error: {inner_e}" - ) + logger.error(f"Failed to mark profile as failed after dispatch error: {inner_e}") return _to_response(profile) diff --git a/apps/api/app/auth.py b/apps/api/app/auth.py index 3b04a7356..a174c11d9 100644 --- a/apps/api/app/auth.py +++ b/apps/api/app/auth.py @@ -27,6 +27,7 @@ class AuthenticatedUser: def _get_redis_client(): """获取 Redis 客户端用于 JWT 黑名单""" import redis as redis_lib + return redis_lib.from_url(settings.REDIS_URL, decode_responses=True) @@ -38,6 +39,7 @@ def _token_fingerprint(token: str) -> str: def blacklist_token(token: str, exp: int) -> None: """将 token 加入黑名单,TTL 为 token 剩余有效期""" import time + redis_client = _get_redis_client() key = f"jwt:blacklist:{_token_fingerprint(token)}" ttl = max(exp - int(time.time()), 1) diff --git a/apps/api/app/db.py b/apps/api/app/db.py index caf8f8102..f334ce700 100644 --- a/apps/api/app/db.py +++ b/apps/api/app/db.py @@ -17,4 +17,3 @@ engine, SessionLocal = build_session_factory( assert_auto_create_schema_allowed(settings.ENVIRONMENT, settings.AUTO_CREATE_SCHEMA) if settings.AUTO_CREATE_SCHEMA: initialize_database(engine) - diff --git a/apps/api/app/middleware/versioning.py b/apps/api/app/middleware/versioning.py index 72b6d01f1..48078fd7f 100644 --- a/apps/api/app/middleware/versioning.py +++ b/apps/api/app/middleware/versioning.py @@ -2,7 +2,6 @@ API 版本管理中间件 """ - from fastapi import Request from starlette.middleware.base import BaseHTTPMiddleware diff --git a/apps/api/app/schemas/tts.py b/apps/api/app/schemas/tts.py index b681c310c..bfad312e1 100644 --- a/apps/api/app/schemas/tts.py +++ b/apps/api/app/schemas/tts.py @@ -19,9 +19,7 @@ class TTSSynthesizeRequest(BaseModel): voice_model: str = Field("", description="语音模型名称") voice_clone_profile_id: str = Field("", description="关联的音色克隆档案 ID") format: str = Field("mp3", description="输出格式(mp3/wav/pcm)") - metadata_: Optional[Dict[str, Any]] = Field( - default=None, alias="metadata", description="额外元数据" - ) + metadata_: Optional[Dict[str, Any]] = Field(default=None, alias="metadata", description="额外元数据") class Config: populate_by_name = True @@ -47,9 +45,7 @@ class TTSJobResponse(BaseModel): error_message: str = "" retry_count: int = 0 max_retries: int = 3 - metadata_: Optional[Dict[str, Any]] = Field( - default=None, alias="metadata", description="额外元数据" - ) + metadata_: Optional[Dict[str, Any]] = Field(default=None, alias="metadata", description="额外元数据") started_at: Optional[datetime] = None completed_at: Optional[datetime] = None created_at: datetime diff --git a/apps/api/app/schemas/voice_clone.py b/apps/api/app/schemas/voice_clone.py index a1f7b7de5..1ae0fc40c 100644 --- a/apps/api/app/schemas/voice_clone.py +++ b/apps/api/app/schemas/voice_clone.py @@ -18,9 +18,7 @@ class CreateVoiceCloneRequest(BaseModel): language: str = Field("zh-CN", description="语言") gender: str = Field("unknown", description="性别") max_retries: int = Field(3, ge=1, le=10, description="最大重试次数") - metadata_: Optional[Dict[str, Any]] = Field( - default=None, alias="metadata", description="额外元数据" - ) + metadata_: Optional[Dict[str, Any]] = Field(default=None, alias="metadata", description="额外元数据") class Config: populate_by_name = True @@ -42,9 +40,7 @@ class VoiceCloneProfileResponse(BaseModel): error_message: str = "" retry_count: int = 0 max_retries: int = 3 - metadata_: Optional[Dict[str, Any]] = Field( - default=None, alias="metadata", description="额外元数据" - ) + metadata_: Optional[Dict[str, Any]] = Field(default=None, alias="metadata", description="额外元数据") created_at: datetime updated_at: datetime diff --git a/apps/api/app/services/edit_plan_service.py b/apps/api/app/services/edit_plan_service.py index f4a91ccc4..f60f91d61 100644 --- a/apps/api/app/services/edit_plan_service.py +++ b/apps/api/app/services/edit_plan_service.py @@ -319,7 +319,9 @@ class EditPlanService: text_content=text_content.strip() if text_content is not None else existing.text_content, start_time=start_time if start_time is not None else existing.start_time, duration=duration if duration is not None else existing.duration, - transition_effect=transition_effect.strip() if transition_effect is not None else existing.transition_effect, + transition_effect=( + transition_effect.strip() if transition_effect is not None else existing.transition_effect + ), status=existing.status, config=config if config is not None else existing.config, created_at=existing.created_at, diff --git a/apps/api/app/services/edit_template_service.py b/apps/api/app/services/edit_template_service.py index 0ee6ea35a..a99ed3e48 100644 --- a/apps/api/app/services/edit_template_service.py +++ b/apps/api/app/services/edit_template_service.py @@ -156,11 +156,7 @@ class EditTemplateService: if name is not None and new_name != existing.name: all_templates = self._template_repo.list_all(skip=0, limit=1000) for t in all_templates: - if ( - t.id != template_id - and t.name == new_name - and t.status == EditTemplateStatus.ACTIVE - ): + if t.id != template_id and t.name == new_name and t.status == EditTemplateStatus.ACTIVE: raise ValueError(f"模板名称已存在: {new_name}") # 构建更新后的实体 @@ -288,9 +284,7 @@ class EditTemplateService: # 解析枚举类型 new_clip_type = ClipType(clip_type) if clip_type is not None else existing.clip_type new_transition = ( - TransitionEffect(transition_effect) - if transition_effect is not None - else existing.transition_effect + TransitionEffect(transition_effect) if transition_effect is not None else existing.transition_effect ) updated = TemplateClipConfig( @@ -301,7 +295,9 @@ class EditTemplateService: min_duration=min_duration if min_duration is not None else existing.min_duration, max_duration=max_duration if max_duration is not None else existing.max_duration, text_template=text_template.strip() if text_template is not None else existing.text_template, - material_requirements=material_requirements if material_requirements is not None else existing.material_requirements, + material_requirements=( + material_requirements if material_requirements is not None else existing.material_requirements + ), transition_effect=new_transition, config=config if config is not None else existing.config, created_at=existing.created_at, diff --git a/apps/api/app/services/video_compose_service.py b/apps/api/app/services/video_compose_service.py index b7befdd87..aeea24f0a 100644 --- a/apps/api/app/services/video_compose_service.py +++ b/apps/api/app/services/video_compose_service.py @@ -154,9 +154,7 @@ class VideoComposeService: # 状态检查 if plan.status not in (EditPlanStatus.EDITING, EditPlanStatus.RENDERING): - errors.append( - f"计划状态不正确,需要 editing 或 rendering,当前: {plan.status.value}" - ) + errors.append(f"计划状态不正确,需要 editing 或 rendering,当前: {plan.status.value}") # 加载片段 clips = self._clip_repo.list_by_plan(plan_id, skip=0, limit=10000) @@ -183,9 +181,7 @@ class VideoComposeService: errors.append(f"片段 {clip.id} (order={clip.order}) 没有分配素材") no_asset_count += 1 if clip.duration <= 0: - warnings.append( - f"片段 {clip.id} (order={clip.order}) 时长为 0,将使用默认时长" - ) + warnings.append(f"片段 {clip.id} (order={clip.order}) 时长为 0,将使用默认时长") no_duration_count += 1 elif clip.status == EditPlanClipStatus.PENDING: pending_count += 1 @@ -237,10 +233,7 @@ class VideoComposeService: raise ValueError(f"剪辑计划没有片段: {plan_id}") # 只处理 ready 且有 asset_id 的片段 - ready_clips = [ - c for c in clips - if c.status == EditPlanClipStatus.READY and c.asset_id - ] + ready_clips = [c for c in clips if c.status == EditPlanClipStatus.READY and c.asset_id] ready_clips.sort(key=lambda c: c.order) if not ready_clips: @@ -286,13 +279,20 @@ class VideoComposeService: command.extend(["-map", "[outa]"]) # 编码参数 - command.extend([ - "-c:v", codec, - "-crf", str(crf), - "-preset", preset, - "-c:a", "aac", - "-b:a", "192k", - ]) + command.extend( + [ + "-c:v", + codec, + "-crf", + str(crf), + "-preset", + preset, + "-c:a", + "aac", + "-b:a", + "192k", + ] + ) # 输出 command.append(output_path) @@ -333,13 +333,20 @@ class VideoComposeService: # 简单命令:input → filter → output filter_str = ",".join(chain.filters) command = [ - "ffmpeg", "-y", - "-i", clip.asset_id, - "-filter_complex", f"{filter_str}[outv]", - "-map", "[outv]", - "-c:v", DEFAULT_CODEC, - "-crf", str(DEFAULT_CRF), - "-preset", DEFAULT_PRESET, + "ffmpeg", + "-y", + "-i", + clip.asset_id, + "-filter_complex", + f"{filter_str}[outv]", + "-map", + "[outv]", + "-c:v", + DEFAULT_CODEC, + "-crf", + str(DEFAULT_CRF), + "-preset", + DEFAULT_PRESET, output_path, ] @@ -376,7 +383,9 @@ class VideoComposeService: "rendered_clips": len(rendered_clips), "failed_clips": len(failed_clips), "total_duration": total_duration, - "can_compose": len(ready_clips) > 0 and plan.status in ( + "can_compose": len(ready_clips) > 0 + and plan.status + in ( EditPlanStatus.EDITING, EditPlanStatus.RENDERING, ), @@ -408,10 +417,7 @@ class VideoComposeService: filters: list[str] = [] # 1. scale: 等比缩放,保证覆盖目标区域(scale to larger, then crop) - filters.append( - f"scale={output_width}:{output_height}" - f":force_original_aspect_ratio=increase" - ) + filters.append(f"scale={output_width}:{output_height}" f":force_original_aspect_ratio=increase") # 2. crop: 居中裁剪 filters.append(f"crop={output_width}:{output_height}") @@ -476,10 +482,7 @@ class VideoComposeService: return filter_str, total_duration # ── 检查是否有转场 ───────────────────────────────────────────── - has_transitions = any( - t != TransitionEffect.CUT and t != "cut" - for t in transitions - ) + has_transitions = any(t != TransitionEffect.CUT and t != "cut" for t in transitions) if not has_transitions: return _build_concat_filter(clip_chains) @@ -534,18 +537,14 @@ def _build_concat_filter( audio_parts: list[str] = [] for idx, chain in enumerate(clip_chains): if chain.audio_label: - audio_parts.append( - f"[{idx}:a]atrim=0:{chain.duration},asetpts=PTS-STARTPTS[{chain.audio_label}]" - ) + audio_parts.append(f"[{idx}:a]atrim=0:{chain.duration},asetpts=PTS-STARTPTS[{chain.audio_label}]") if audio_parts: parts.extend(audio_parts) audio_inputs = "".join(f"[{c.audio_label}]" for c in clip_chains if c.audio_label) audio_count = sum(1 for c in clip_chains if c.audio_label) if audio_count > 0: - parts.append( - f"{audio_inputs}concat=n={audio_count}:v=0:a=1[outa]" - ) + parts.append(f"{audio_inputs}concat=n={audio_count}:v=0:a=1[outa]") return ";".join(parts), total_duration @@ -618,9 +617,7 @@ def _build_xfade_filter( if len(audio_labels) >= 2: # 简单拼接音频(不做 crossfade) audio_inputs = "".join(f"[{label}]" for label in audio_labels) - parts.append( - f"{audio_inputs}concat=n={len(audio_labels)}:v=0:a=1[outa]" - ) + parts.append(f"{audio_inputs}concat=n={len(audio_labels)}:v=0:a=1[outa]") elif len(audio_labels) == 1: parts.append(f"[{audio_labels[0]}]acopy[outa]") diff --git a/apps/web/.eslintrc.cjs b/apps/web/.eslintrc.cjs index 33b56621b..7f61d4abd 100644 --- a/apps/web/.eslintrc.cjs +++ b/apps/web/.eslintrc.cjs @@ -5,19 +5,19 @@ module.exports = { root: true, env: { browser: true, es2020: true }, extends: [ - 'eslint:recommended', - 'plugin:@typescript-eslint/recommended', - 'plugin:react-hooks/recommended', + "eslint:recommended", + "plugin:@typescript-eslint/recommended", + "plugin:react-hooks/recommended", ], - ignorePatterns: ['dist', '.eslintrc.cjs'], - parser: '@typescript-eslint/parser', - plugins: ['react-refresh'], + ignorePatterns: ["dist", ".eslintrc.cjs"], + parser: "@typescript-eslint/parser", + plugins: ["react-refresh"], rules: { - 'react-refresh/only-export-components': [ - 'warn', + "react-refresh/only-export-components": [ + "warn", { allowConstantExport: true }, ], - '@typescript-eslint/no-explicit-any': 'warn', - '@typescript-eslint/no-unused-vars': ['warn', { argsIgnorePattern: '^_' }], + "@typescript-eslint/no-explicit-any": "warn", + "@typescript-eslint/no-unused-vars": ["warn", { argsIgnorePattern: "^_" }], }, -} +}; diff --git a/apps/web/README.md b/apps/web/README.md index bda73eae0..066fc4ceb 100644 --- a/apps/web/README.md +++ b/apps/web/README.md @@ -91,7 +91,7 @@ VITE_API_URL=http://localhost:8000 使用 Zustand 创建 Store: ```typescript -import { create } from 'zustand'; +import { create } from "zustand"; interface MyStore { data: any; diff --git a/apps/web/e2e/auth-guard.spec.ts b/apps/web/e2e/auth-guard.spec.ts index 5a2478f47..5d0156beb 100755 --- a/apps/web/e2e/auth-guard.spec.ts +++ b/apps/web/e2e/auth-guard.spec.ts @@ -1,8 +1,8 @@ -import { expect, test } from '@playwright/test'; +import { expect, test } from "@playwright/test"; -test.describe('App route guard', () => { - test('redirects anonymous users to login', async ({ page }) => { - await page.goto('/projects'); +test.describe("App route guard", () => { + test("redirects anonymous users to login", async ({ page }) => { + await page.goto("/projects"); await expect(page).toHaveURL(/\/login/); }); }); diff --git a/apps/web/e2e/auth.spec.ts b/apps/web/e2e/auth.spec.ts index 9759903cd..06a56fe12 100644 --- a/apps/web/e2e/auth.spec.ts +++ b/apps/web/e2e/auth.spec.ts @@ -1,10 +1,10 @@ -import { expect, test } from '@playwright/test'; +import { expect, test } from "@playwright/test"; -test.describe('Authentication page', () => { - test('renders login form', async ({ page }) => { - await page.goto('/login'); - await expect(page.getByPlaceholder('邮箱')).toBeVisible(); - await expect(page.getByPlaceholder('密码')).toBeVisible(); - await expect(page.getByRole('button', { name: /登\s*录/ })).toBeVisible(); +test.describe("Authentication page", () => { + test("renders login form", async ({ page }) => { + await page.goto("/login"); + await expect(page.getByPlaceholder("邮箱")).toBeVisible(); + await expect(page.getByPlaceholder("密码")).toBeVisible(); + await expect(page.getByRole("button", { name: /登\s*录/ })).toBeVisible(); }); }); diff --git a/apps/web/e2e/core-generation.spec.ts b/apps/web/e2e/core-generation.spec.ts index 507d452ca..a1469d1a7 100755 --- a/apps/web/e2e/core-generation.spec.ts +++ b/apps/web/e2e/core-generation.spec.ts @@ -1,32 +1,61 @@ -import { fileURLToPath } from 'node:url'; -import { expect, test } from '@playwright/test'; -import fs from 'node:fs'; -import path from 'node:path'; +import { fileURLToPath } from "node:url"; +import { expect, test } from "@playwright/test"; +import fs from "node:fs"; +import path from "node:path"; const currentDir = path.dirname(fileURLToPath(import.meta.url)); -const PASSWORD = 'SmokePass123!'; -const apiBase = process.env.E2E_API_BASE || '/api/v1'; -const apiOrigin = apiBase.endsWith('/api/v1') ? apiBase.slice(0, -'/api/v1'.length) : ''; +const PASSWORD = "SmokePass123!"; +const apiBase = process.env.E2E_API_BASE || "/api/v1"; +const apiOrigin = apiBase.endsWith("/api/v1") + ? apiBase.slice(0, -"/api/v1".length) + : ""; -const routeBrowserApiToTestApi = async (page: import('@playwright/test').Page) => { +const routeBrowserApiToTestApi = async ( + page: import("@playwright/test").Page, +) => { if (!apiOrigin) return; - await page.route('**/api/v1/**', async (route) => { + await page.route("**/api/v1/**", async (route) => { const sourceUrl = new URL(route.request().url()); - const response = await route.fetch({ url: `${apiOrigin}${sourceUrl.pathname}${sourceUrl.search}` }); + const response = await route.fetch({ + url: `${apiOrigin}${sourceUrl.pathname}${sourceUrl.search}`, + }); await route.fulfill({ response }); }); }; type ProjectResponse = { id: string }; type LibraryResponse = { id: string }; -type AssetListResponse = { items: Array<{ name: string; status: string; mime_type?: string; file_type?: string }> }; -type GenerationTaskResponse = { id: string; status: string; progress: number; result_count: number; error_message?: string | null; strategy_id?: string | null; edit_plan_id?: string | null }; +type AssetListResponse = { + items: Array<{ + name: string; + status: string; + mime_type?: string; + file_type?: string; + }>; +}; +type GenerationTaskResponse = { + id: string; + status: string; + progress: number; + result_count: number; + error_message?: string | null; + strategy_id?: string | null; + edit_plan_id?: string | null; +}; type ProjectTitleResponse = { id: string; text: string; usage_count: number }; -type GeneratedVideoResponse = { id: string; name: string; file_url: string; file_size: number }; +type GeneratedVideoResponse = { + id: string; + name: string; + file_url: string; + file_size: number; +}; -test.describe('Core generation and download flow', () => { - test('generates an MP4 from the browser and exposes a playable download', async ({ page, request }) => { +test.describe("Core generation and download flow", () => { + test("generates an MP4 from the browser and exposes a playable download", async ({ + page, + request, + }) => { test.setTimeout(180_000); await routeBrowserApiToTestApi(page); @@ -58,28 +87,33 @@ test.describe('Core generation and download flow', () => { const library = await request.post(`${apiBase}/asset-libraries`, { headers, - data: { project_id: projectData.id, name: libraryName, kind: 'video' }, + data: { project_id: projectData.id, name: libraryName, kind: "video" }, }); expect(library.status(), await library.text()).toBe(200); const libraryData = (await library.json()) as LibraryResponse; const projectTitleText = `E2E 生成标题 ${suffix}`; - const title = await request.post(`${apiBase}/projects/${projectData.id}/titles`, { - headers, - data: { text: projectTitleText, category: 'marketing', favorite: true }, - }); + const title = await request.post( + `${apiBase}/projects/${projectData.id}/titles`, + { + headers, + data: { text: projectTitleText, category: "marketing", favorite: true }, + }, + ); expect(title.status(), await title.text()).toBe(200); const titleData = (await title.json()) as ProjectTitleResponse; - const fixture = fs.readFileSync(path.join(currentDir, 'fixtures', 'sample.mp4')); + const fixture = fs.readFileSync( + path.join(currentDir, "fixtures", "sample.mp4"), + ); const upload = await request.post(`${apiBase}/upload`, { headers, multipart: { project_id: projectData.id, library_id: libraryData.id, file: { - name: 'e2e-generation-source.mp4', - mimeType: 'video/mp4', + name: "e2e-generation-source.mp4", + mimeType: "video/mp4", buffer: fixture, }, }, @@ -97,17 +131,27 @@ test.describe('Core generation and download flow', () => { return `http_${assets.status()}`; } const data = (await assets.json()) as AssetListResponse; - const asset = data.items.find((item) => item.name === 'e2e-generation-source.mp4'); - return asset ? `${asset.mime_type || asset.file_type || ''}:${asset.status}` : 'missing'; + const asset = data.items.find( + (item) => item.name === "e2e-generation-source.mp4", + ); + return asset + ? `${asset.mime_type || asset.file_type || ""}:${asset.status}` + : "missing"; }, - { timeout: 90_000, intervals: [1_000, 2_000, 3_000, 5_000] } + { timeout: 90_000, intervals: [1_000, 2_000, 3_000, 5_000] }, ) .toMatch(/^(video\/quicktime|video\/mp4|video)?:ready$/); await page.addInitScript( ({ token, user, projectId }) => { - localStorage.setItem('access_token', token); - localStorage.setItem('auth-storage', JSON.stringify({ state: { user, isAuthenticated: true }, version: 0 })); + localStorage.setItem("access_token", token); + localStorage.setItem( + "auth-storage", + JSON.stringify({ + state: { user, isAuthenticated: true }, + version: 0, + }), + ); }, { token: loginData.access_token, @@ -121,81 +165,139 @@ test.describe('Core generation and download flow', () => { is_email_verified: true, email_verified: true, }, - } + }, ); await page.goto(`/projects/${projectData.id}/generation`); - await expect(page.getByText('剪辑参数')).toBeVisible({ timeout: 20_000 }); - await page.locator('.ant-select-selector').first().click(); + await expect(page.getByText("剪辑参数")).toBeVisible({ timeout: 20_000 }); + await page.locator(".ant-select-selector").first().click(); await page.getByText(`${libraryName} (video)`).click(); - await page.locator('.ant-select-selector').nth(1).click(); + await page.locator(".ant-select-selector").nth(1).click(); await page.getByText(projectTitleText).click(); - await expect(page.getByText(/素材就绪度:/)).toBeVisible({ timeout: 20_000 }); - await expect(page.getByRole('button', { name: '重新生成计划' })).toBeEnabled({ timeout: 20_000 }); - await page.getByRole('button', { name: '重新生成计划' }).click(); - await expect(page.getByText('剪辑计划预览')).toBeVisible({ timeout: 20_000 }); + await expect(page.getByText(/素材就绪度:/)).toBeVisible({ + timeout: 20_000, + }); + await expect( + page.getByRole("button", { name: "重新生成计划" }), + ).toBeEnabled({ timeout: 20_000 }); + await page.getByRole("button", { name: "重新生成计划" }).click(); + await expect(page.getByText("剪辑计划预览")).toBeVisible({ + timeout: 20_000, + }); await expect(page.getByText(/自动选择/)).toBeVisible({ timeout: 20_000 }); - await expect(page.getByText(/e2e-generation-source\.mp4/)).toBeVisible({ timeout: 20_000 }); - const confirmGenerationButton = page.getByRole('complementary').getByRole('button', { name: '确认计划并生成' }); + await expect(page.getByText(/e2e-generation-source\.mp4/)).toBeVisible({ + timeout: 20_000, + }); + const confirmGenerationButton = page + .getByRole("complementary") + .getByRole("button", { name: "确认计划并生成" }); await expect(confirmGenerationButton).toBeEnabled({ timeout: 20_000 }); const createTaskResponsePromise = page.waitForResponse( - (response) => response.url().includes('/api/v1/generation/tasks') && response.request().method() === 'POST', - { timeout: 30_000 } + (response) => + response.url().includes("/api/v1/generation/tasks") && + response.request().method() === "POST", + { timeout: 30_000 }, ); await confirmGenerationButton.click(); const createTaskResponse = await createTaskResponsePromise; - expect(createTaskResponse.status(), await createTaskResponse.text()).toBe(200); - const createdTask = (await createTaskResponse.json()) as GenerationTaskResponse; - expect(createdTask.edit_plan_id || '').not.toBe(''); + expect(createTaskResponse.status(), await createTaskResponse.text()).toBe( + 200, + ); + const createdTask = + (await createTaskResponse.json()) as GenerationTaskResponse; + expect(createdTask.edit_plan_id || "").not.toBe(""); - await expect(page.getByText(/生成状态:生成完成/)).toBeVisible({ timeout: 90_000 }); - await expect(page.getByText(/生成失败|生成任务加载失败|生成结果加载失败/)).toHaveCount(0); + await expect(page.getByText(/生成状态:生成完成/)).toBeVisible({ + timeout: 90_000, + }); + await expect( + page.getByText(/生成失败|生成任务加载失败|生成结果加载失败/), + ).toHaveCount(0); await expect .poll( async () => { - const task = await request.get(`${apiBase}/generation/tasks/${createdTask.id}`, { headers }); + const task = await request.get( + `${apiBase}/generation/tasks/${createdTask.id}`, + { headers }, + ); if (!task.ok()) { return `http_${task.status()}`; } const data = (await task.json()) as GenerationTaskResponse; - return `${data.status}:${data.result_count}:${data.strategy_id || ''}:${data.error_message || ''}`; + return `${data.status}:${data.result_count}:${data.strategy_id || ""}:${data.error_message || ""}`; }, - { timeout: 90_000, intervals: [1_000, 2_000, 5_000] } + { timeout: 90_000, intervals: [1_000, 2_000, 5_000] }, ) .toMatch(new RegExp(`^completed:[1-9]\\d*:${titleData.id}:`)); - const results = await request.get(`${apiBase}/generation/tasks/${createdTask.id}/results`, { headers }); + const results = await request.get( + `${apiBase}/generation/tasks/${createdTask.id}/results`, + { headers }, + ); expect(results.status(), await results.text()).toBe(200); - const resultsData = (await results.json()) as { items: GeneratedVideoResponse[] }; + const resultsData = (await results.json()) as { + items: GeneratedVideoResponse[]; + }; expect(resultsData.items.length).toBeGreaterThan(0); const generatedVideo = resultsData.items[0]; expect(generatedVideo.name).toMatch(/\.mp4$/); expect(generatedVideo.file_size).toBeGreaterThan(0); await page.goto(`/projects/${projectData.id}/results`); - const resultCard = page.locator('.xx-vertical-card').filter({ hasText: generatedVideo.name }); + const resultCard = page + .locator(".xx-vertical-card") + .filter({ hasText: generatedVideo.name }); await expect(resultCard).toBeVisible({ timeout: 20_000 }); - await expect(resultCard.getByText('待复核')).toBeVisible({ timeout: 20_000 }); - await expect(resultCard.getByRole('button', { name: /下载/ })).toBeVisible(); - await expect(page.getByRole('button', { name: '批量获取下载地址' })).toBeEnabled(); - await resultCard.getByRole('button', { name: '可发布' }).click(); - await expect(page.getByText('成片复核状态已更新')).toBeVisible({ timeout: 10_000 }); - await expect(resultCard.locator('.xx-pill.ok', { hasText: '可发布' })).toBeVisible({ timeout: 20_000 }); - const reviewedVideo = await request.get(`${apiBase}/generated-videos/${generatedVideo.id}`, { headers }); + await expect(resultCard.getByText("待复核")).toBeVisible({ + timeout: 20_000, + }); + await expect( + resultCard.getByRole("button", { name: /下载/ }), + ).toBeVisible(); + await expect( + page.getByRole("button", { name: "批量获取下载地址" }), + ).toBeEnabled(); + await resultCard.getByRole("button", { name: "可发布" }).click(); + await expect(page.getByText("成片复核状态已更新")).toBeVisible({ + timeout: 10_000, + }); + await expect( + resultCard.locator(".xx-pill.ok", { hasText: "可发布" }), + ).toBeVisible({ timeout: 20_000 }); + const reviewedVideo = await request.get( + `${apiBase}/generated-videos/${generatedVideo.id}`, + { headers }, + ); expect(reviewedVideo.status(), await reviewedVideo.text()).toBe(200); - const reviewedVideoData = (await reviewedVideo.json()) as { review_status: string; generation_params: Record }; - expect(reviewedVideoData.review_status).toBe('approved'); + const reviewedVideoData = (await reviewedVideo.json()) as { + review_status: string; + generation_params: Record; + }; + expect(reviewedVideoData.review_status).toBe("approved"); expect(reviewedVideoData.generation_params.title_id).toBe(titleData.id); - expect(reviewedVideoData.generation_params.edit_plan_id).toBe(createdTask.edit_plan_id); - const downloadUrlResponse = await request.get(`${apiBase}/generated-videos/${generatedVideo.id}/download-url`, { headers }); - expect(downloadUrlResponse.status(), await downloadUrlResponse.text()).toBe(200); - const downloadData = (await downloadUrlResponse.json()) as { download_url: string }; - const videoResponse = await request.get(downloadData.download_url, { timeout: 30_000 }); + expect(reviewedVideoData.generation_params.edit_plan_id).toBe( + createdTask.edit_plan_id, + ); + const downloadUrlResponse = await request.get( + `${apiBase}/generated-videos/${generatedVideo.id}/download-url`, + { headers }, + ); + expect(downloadUrlResponse.status(), await downloadUrlResponse.text()).toBe( + 200, + ); + const downloadData = (await downloadUrlResponse.json()) as { + download_url: string; + }; + const videoResponse = await request.get(downloadData.download_url, { + timeout: 30_000, + }); expect(videoResponse.status(), await videoResponse.text()).toBe(200); - expect(videoResponse.headers()['content-type'] || '').toContain('video/mp4'); + expect(videoResponse.headers()["content-type"] || "").toContain( + "video/mp4", + ); const videoBody = await videoResponse.body(); expect(videoBody.length).toBeGreaterThan(1024); @@ -203,20 +305,43 @@ test.describe('Core generation and download flow', () => { headers, params: { library_id: libraryData.id }, }); - expect(assetsAfterGeneration.status(), await assetsAfterGeneration.text()).toBe(200); - const assetsAfterGenerationData = (await assetsAfterGeneration.json()) as { items: Array<{ name: string; metadata: Record }> }; - const sourceAsset = assetsAfterGenerationData.items.find((item) => item.name === 'e2e-generation-source.mp4'); + expect( + assetsAfterGeneration.status(), + await assetsAfterGeneration.text(), + ).toBe(200); + const assetsAfterGenerationData = (await assetsAfterGeneration.json()) as { + items: Array<{ name: string; metadata: Record }>; + }; + const sourceAsset = assetsAfterGenerationData.items.find( + (item) => item.name === "e2e-generation-source.mp4", + ); expect(sourceAsset?.metadata.generation_use_count).toBe(1); - expect(sourceAsset?.metadata.review_status).toBe('pending_review'); - const titleAfterGeneration = await request.get(`${apiBase}/projects/${projectData.id}/titles`, { headers }); - expect(titleAfterGeneration.status(), await titleAfterGeneration.text()).toBe(200); - const titlesData = (await titleAfterGeneration.json()) as { items: ProjectTitleResponse[] }; - expect(titlesData.items.find((item) => item.id === titleData.id)?.usage_count).toBe(1); + expect(sourceAsset?.metadata.review_status).toBe("pending_review"); + const titleAfterGeneration = await request.get( + `${apiBase}/projects/${projectData.id}/titles`, + { headers }, + ); + expect( + titleAfterGeneration.status(), + await titleAfterGeneration.text(), + ).toBe(200); + const titlesData = (await titleAfterGeneration.json()) as { + items: ProjectTitleResponse[]; + }; + expect( + titlesData.items.find((item) => item.id === titleData.id)?.usage_count, + ).toBe(1); await page.goto(`/projects/${projectData.id}/tasks`); - await expect(page.getByText('项目任务中心')).toBeVisible({ timeout: 20_000 }); - await expect(page.getByText('视频生成')).toBeVisible({ timeout: 20_000 }); - await expect(page.getByText('已完成').first()).toBeVisible({ timeout: 20_000 }); - await expect(page.getByText(createdTask.id)).toBeVisible({ timeout: 20_000 }); + await expect(page.getByText("项目任务中心")).toBeVisible({ + timeout: 20_000, + }); + await expect(page.getByText("视频生成")).toBeVisible({ timeout: 20_000 }); + await expect(page.getByText("已完成").first()).toBeVisible({ + timeout: 20_000, + }); + await expect(page.getByText(createdTask.id)).toBeVisible({ + timeout: 20_000, + }); }); }); diff --git a/apps/web/e2e/core-titles.spec.ts b/apps/web/e2e/core-titles.spec.ts index 555d9712d..29bfaa7cd 100755 --- a/apps/web/e2e/core-titles.spec.ts +++ b/apps/web/e2e/core-titles.spec.ts @@ -1,20 +1,29 @@ -import { expect, test } from '@playwright/test'; +import { expect, test } from "@playwright/test"; -const PASSWORD = 'SmokePass123!'; -const apiBase = process.env.E2E_API_BASE || '/api/v1'; -const apiOrigin = apiBase.endsWith('/api/v1') ? apiBase.slice(0, -'/api/v1'.length) : ''; +const PASSWORD = "SmokePass123!"; +const apiBase = process.env.E2E_API_BASE || "/api/v1"; +const apiOrigin = apiBase.endsWith("/api/v1") + ? apiBase.slice(0, -"/api/v1".length) + : ""; -const routeBrowserApiToTestApi = async (page: import('@playwright/test').Page) => { +const routeBrowserApiToTestApi = async ( + page: import("@playwright/test").Page, +) => { if (!apiOrigin) return; - await page.route('**/api/v1/**', async (route) => { + await page.route("**/api/v1/**", async (route) => { const sourceUrl = new URL(route.request().url()); - const response = await route.fetch({ url: `${apiOrigin}${sourceUrl.pathname}${sourceUrl.search}` }); + const response = await route.fetch({ + url: `${apiOrigin}${sourceUrl.pathname}${sourceUrl.search}`, + }); await route.fulfill({ response }); }); }; -test.describe('Project title library flow', () => { - test('creates a reusable title from the browser', async ({ page, request }) => { +test.describe("Project title library flow", () => { + test("creates a reusable title from the browser", async ({ + page, + request, + }) => { await routeBrowserApiToTestApi(page); const suffix = Date.now().toString(36); const email = `e2e-title-${suffix}@example.com`; @@ -27,22 +36,33 @@ test.describe('Project title library flow', () => { const registerData = (await register.json()) as { user_id: string }; - const login = await request.post(`${apiBase}/auth/login`, { data: { email, password: PASSWORD } }); + const login = await request.post(`${apiBase}/auth/login`, { + data: { email, password: PASSWORD }, + }); expect(login.status(), await login.text()).toBe(200); const loginData = (await login.json()) as { access_token: string }; const headers = { Authorization: `Bearer ${loginData.access_token}` }; const project = await request.post(`${apiBase}/projects`, { headers, - data: { name: `E2E Title Project ${suffix}`, description: 'Playwright title smoke' }, + data: { + name: `E2E Title Project ${suffix}`, + description: "Playwright title smoke", + }, }); expect(project.status(), await project.text()).toBe(200); const projectData = (await project.json()) as { id: string }; await page.addInitScript( ({ token, user }) => { - localStorage.setItem('access_token', token); - localStorage.setItem('auth-storage', JSON.stringify({ state: { user, isAuthenticated: true }, version: 0 })); + localStorage.setItem("access_token", token); + localStorage.setItem( + "auth-storage", + JSON.stringify({ + state: { user, isAuthenticated: true }, + version: 0, + }), + ); }, { token: loginData.access_token, @@ -55,23 +75,36 @@ test.describe('Project title library flow', () => { is_email_verified: true, email_verified: true, }, - } + }, ); await page.goto(`/projects/${projectData.id}/titles`); - await expect(page.getByRole('heading', { name: '标题库' })).toBeVisible({ timeout: 20_000 }); - const titleText = `E2E 标题 ${suffix}`; - await page.getByPlaceholder('例如:3 秒抓住注意力,30 秒讲清卖点').fill(titleText); - const title = await request.post(`${apiBase}/projects/${projectData.id}/titles`, { - headers, - data: { text: titleText, category: 'default', favorite: true }, + await expect(page.getByRole("heading", { name: "标题库" })).toBeVisible({ + timeout: 20_000, }); + const titleText = `E2E 标题 ${suffix}`; + await page + .getByPlaceholder("例如:3 秒抓住注意力,30 秒讲清卖点") + .fill(titleText); + const title = await request.post( + `${apiBase}/projects/${projectData.id}/titles`, + { + headers, + data: { text: titleText, category: "default", favorite: true }, + }, + ); expect(title.status(), await title.text()).toBe(200); await page.reload(); await expect(page.getByText(titleText)).toBeVisible({ timeout: 20_000 }); - await expect(page.locator('.xx-title-row').filter({ hasText: titleText }).getByText('常用').first()).toBeVisible(); - await page.getByPlaceholder('搜索标题').fill(titleText); + await expect( + page + .locator(".xx-title-row") + .filter({ hasText: titleText }) + .getByText("常用") + .first(), + ).toBeVisible(); + await page.getByPlaceholder("搜索标题").fill(titleText); await expect(page.getByText(titleText)).toBeVisible(); - await expect(page.getByText('使用次数:0')).toBeVisible(); + await expect(page.getByText("使用次数:0")).toBeVisible(); }); }); diff --git a/apps/web/e2e/core-upload.spec.ts b/apps/web/e2e/core-upload.spec.ts index 6a9c285a4..7767f4276 100755 --- a/apps/web/e2e/core-upload.spec.ts +++ b/apps/web/e2e/core-upload.spec.ts @@ -1,14 +1,20 @@ -import { expect, test } from '@playwright/test'; +import { expect, test } from "@playwright/test"; -const PASSWORD = 'SmokePass123!'; -const apiBase = process.env.E2E_API_BASE || '/api/v1'; -const apiOrigin = apiBase.endsWith('/api/v1') ? apiBase.slice(0, -'/api/v1'.length) : ''; +const PASSWORD = "SmokePass123!"; +const apiBase = process.env.E2E_API_BASE || "/api/v1"; +const apiOrigin = apiBase.endsWith("/api/v1") + ? apiBase.slice(0, -"/api/v1".length) + : ""; -const routeBrowserApiToTestApi = async (page: import('@playwright/test').Page) => { +const routeBrowserApiToTestApi = async ( + page: import("@playwright/test").Page, +) => { if (!apiOrigin) return; - await page.route('**/api/v1/**', async (route) => { + await page.route("**/api/v1/**", async (route) => { const sourceUrl = new URL(route.request().url()); - const response = await route.fetch({ url: `${apiOrigin}${sourceUrl.pathname}${sourceUrl.search}` }); + const response = await route.fetch({ + url: `${apiOrigin}${sourceUrl.pathname}${sourceUrl.search}`, + }); await route.fulfill({ response }); }); }; @@ -16,8 +22,11 @@ const routeBrowserApiToTestApi = async (page: import('@playwright/test').Page) = type ProjectResponse = { id: string }; type LibraryResponse = { id: string }; -test.describe('Core media upload flow', () => { - test('uploads a MOV asset from the browser and shows it as ready', async ({ page, request }) => { +test.describe("Core media upload flow", () => { + test("uploads a MOV asset from the browser and shows it as ready", async ({ + page, + request, + }) => { test.setTimeout(120_000); await routeBrowserApiToTestApi(page); @@ -48,7 +57,7 @@ test.describe('Core media upload flow', () => { headers, data: { name: `E2E Project ${suffix}`, - description: 'Playwright upload smoke', + description: "Playwright upload smoke", }, }); expect(project.status(), await project.text()).toBe(200); @@ -59,7 +68,7 @@ test.describe('Core media upload flow', () => { data: { project_id: projectData.id, name: `E2E Video Library ${suffix}`, - kind: 'video', + kind: "video", }, }); expect(library.status(), await library.text()).toBe(200); @@ -67,8 +76,14 @@ test.describe('Core media upload flow', () => { await page.addInitScript( ({ token, user, projectId }) => { - localStorage.setItem('access_token', token); - localStorage.setItem('auth-storage', JSON.stringify({ state: { user, isAuthenticated: true }, version: 0 })); + localStorage.setItem("access_token", token); + localStorage.setItem( + "auth-storage", + JSON.stringify({ + state: { user, isAuthenticated: true }, + version: 0, + }), + ); }, { token: loginData.access_token, @@ -82,11 +97,13 @@ test.describe('Core media upload flow', () => { is_email_verified: true, email_verified: true, }, - } + }, ); await page.goto(`/projects/${projectData.id}/assets`); - await expect(page.getByText('点击或拖拽素材到这里上传')).toBeEnabled({ timeout: 20_000 }); + await expect(page.getByText("点击或拖拽素材到这里上传")).toBeEnabled({ + timeout: 20_000, + }); const upload = await request.post(`${apiBase}/upload`, { headers, @@ -94,15 +111,17 @@ test.describe('Core media upload flow', () => { project_id: projectData.id, library_id: libraryData.id, file: { - name: 'e2e-sample.MOV', - mimeType: 'video/quicktime', - buffer: Buffer.from('playwright mov upload smoke'), + name: "e2e-sample.MOV", + mimeType: "video/quicktime", + buffer: Buffer.from("playwright mov upload smoke"), }, }, }); expect(upload.status(), await upload.text()).toBe(200); - await expect(page.getByText(/上传失败|素材列表加载失败|素材库加载失败/)).toHaveCount(0, { timeout: 5_000 }); + await expect( + page.getByText(/上传失败|素材列表加载失败|素材库加载失败/), + ).toHaveCount(0, { timeout: 5_000 }); await expect .poll( @@ -114,21 +133,46 @@ test.describe('Core media upload flow', () => { if (!assets.ok()) { return `http_${assets.status()}`; } - const data = (await assets.json()) as { items: Array<{ name: string; status: string; file_type?: string; mime_type?: string }> }; - const asset = data.items.find((item) => item.name === 'e2e-sample.MOV'); - return asset ? `${asset.mime_type || asset.file_type || ''}:${asset.status}` : 'missing'; + const data = (await assets.json()) as { + items: Array<{ + name: string; + status: string; + file_type?: string; + mime_type?: string; + }>; + }; + const asset = data.items.find( + (item) => item.name === "e2e-sample.MOV", + ); + return asset + ? `${asset.mime_type || asset.file_type || ""}:${asset.status}` + : "missing"; }, - { timeout: 30_000, intervals: [1_000, 2_000, 3_000] } + { timeout: 30_000, intervals: [1_000, 2_000, 3_000] }, ) .toMatch(/^(video\/quicktime|video\/mp4|video)?:ready$/); await page.reload(); - await expect(page.getByText(/素材就绪度|Ready/)).toBeVisible({ timeout: 20_000 }); - await expect(page.getByText(/预计成片|视频素材数量偏少|素材准备度良好/)).toBeVisible({ timeout: 20_000 }); - await expect(page.getByText('e2e-sample.MOV', { exact: true })).toBeVisible({ timeout: 20_000 }); - await page.locator('.xx-vertical-card').filter({ hasText: 'e2e-sample.MOV' }).getByRole('button', { name: /通\s*过/ }).click(); - await expect(page.getByText('复核状态已更新')).toBeVisible({ timeout: 10_000 }); - await expect(page.getByText(/已通过|approved/)).toBeVisible({ timeout: 20_000 }); + await expect(page.getByText(/素材就绪度|Ready/)).toBeVisible({ + timeout: 20_000, + }); + await expect( + page.getByText(/预计成片|视频素材数量偏少|素材准备度良好/), + ).toBeVisible({ timeout: 20_000 }); + await expect(page.getByText("e2e-sample.MOV", { exact: true })).toBeVisible( + { timeout: 20_000 }, + ); + await page + .locator(".xx-vertical-card") + .filter({ hasText: "e2e-sample.MOV" }) + .getByRole("button", { name: /通\s*过/ }) + .click(); + await expect(page.getByText("复核状态已更新")).toBeVisible({ + timeout: 10_000, + }); + await expect(page.getByText(/已通过|approved/)).toBeVisible({ + timeout: 20_000, + }); await expect(page.getByText(/素材加载失败|上传失败/)).toHaveCount(0); }); }); diff --git a/apps/web/e2e/subscription.spec.ts b/apps/web/e2e/subscription.spec.ts index 13436a11a..c0980eaea 100755 --- a/apps/web/e2e/subscription.spec.ts +++ b/apps/web/e2e/subscription.spec.ts @@ -3,10 +3,10 @@ * * 覆盖:路由守卫、订阅降级、过期处理、订阅状态检查 */ -import { expect, test } from '@playwright/test'; +import { expect, test } from "@playwright/test"; -const PASSWORD = 'Test123456!'; -const apiBase = process.env.E2E_API_BASE || '/api/v1'; +const PASSWORD = "Test123456!"; +const apiBase = process.env.E2E_API_BASE || "/api/v1"; function uniqueEmail(prefix: string): string { return `${prefix}_${Date.now()}_${Math.random().toString(36).slice(2, 8)}@example.com`; @@ -39,72 +39,83 @@ async function createAuthedUser(request: any, label: string) { }; } -test.describe('Subscription route guard', () => { - test('redirects anonymous users to login', async ({ page }) => { - await page.goto('/subscription'); +test.describe("Subscription route guard", () => { + test("redirects anonymous users to login", async ({ page }) => { + await page.goto("/subscription"); await expect(page).toHaveURL(/\/login/); }); }); -test.describe('订阅信息查看', () => { - test('获取当前订阅信息 - 正向', async ({ request }) => { - const { headers } = await createAuthedUser(request, 'sub-info'); +test.describe("订阅信息查看", () => { + test("获取当前订阅信息 - 正向", async ({ request }) => { + const { headers } = await createAuthedUser(request, "sub-info"); - const response = await request.get(`${apiBase}/subscription/current`, { headers }); + const response = await request.get(`${apiBase}/subscription/current`, { + headers, + }); - expect(response.ok(), `获取订阅信息应返回 2xx,实际: ${response.status()} ${await response.text()}`).toBeTruthy(); + expect( + response.ok(), + `获取订阅信息应返回 2xx,实际: ${response.status()} ${await response.text()}`, + ).toBeTruthy(); const data = await response.json(); - expect(data.plan_id, '应返回 plan_id').toBeTruthy(); - expect(data.status, '应返回 status').toBeTruthy(); + expect(data.plan_id, "应返回 plan_id").toBeTruthy(); + expect(data.status, "应返回 status").toBeTruthy(); }); - test('未登录获取订阅信息 - 反向', async ({ request }) => { + test("未登录获取订阅信息 - 反向", async ({ request }) => { const response = await request.get(`${apiBase}/subscription/current`); expect([401, 403]).toContain(response.status()); }); }); -test.describe('订阅降级', () => { - test('Pro 用户降级到 Standard - 正向', async ({ request }) => { - const { headers } = await createAuthedUser(request, 'sub-downgrade'); +test.describe("订阅降级", () => { + test("Pro 用户降级到 Standard - 正向", async ({ request }) => { + const { headers } = await createAuthedUser(request, "sub-downgrade"); // 先升级到 Pro const upgrade = await request.post(`${apiBase}/subscription/change-plan`, { headers, data: { - target_plan_id: 'pro', - billing_cycle: 'monthly', + target_plan_id: "pro", + billing_cycle: "monthly", }, }); - expect(upgrade.ok(), `升级到 Pro 应成功: ${await upgrade.text()}`).toBeTruthy(); + expect( + upgrade.ok(), + `升级到 Pro 应成功: ${await upgrade.text()}`, + ).toBeTruthy(); // 降级到 Standard - const downgrade = await request.post(`${apiBase}/subscription/change-plan`, { - headers, - data: { - target_plan_id: 'standard', - billing_cycle: 'monthly', + const downgrade = await request.post( + `${apiBase}/subscription/change-plan`, + { + headers, + data: { + target_plan_id: "standard", + billing_cycle: "monthly", + }, }, - }); + ); // 降级应成功或返回提示信息(某些业务可能限制降级) - expect(downgrade.status(), '降级请求应返回 2xx 或 4xx').toBeLessThan(500); + expect(downgrade.status(), "降级请求应返回 2xx 或 4xx").toBeLessThan(500); const data = await downgrade.json(); // 成功或失败都应有明确响应 expect(data).toBeTruthy(); }); - test('降级到相同套餐 - 反向', async ({ request }) => { - const { headers } = await createAuthedUser(request, 'sub-same'); + test("降级到相同套餐 - 反向", async ({ request }) => { + const { headers } = await createAuthedUser(request, "sub-same"); // 用户默认为 free,再次选择 free const response = await request.post(`${apiBase}/subscription/change-plan`, { headers, data: { - target_plan_id: 'free', - billing_cycle: 'monthly', + target_plan_id: "free", + billing_cycle: "monthly", }, }); @@ -117,86 +128,111 @@ test.describe('订阅降级', () => { } }); - test('降级到无效套餐 - 反向', async ({ request }) => { - const { headers } = await createAuthedUser(request, 'sub-badplan'); + test("降级到无效套餐 - 反向", async ({ request }) => { + const { headers } = await createAuthedUser(request, "sub-badplan"); const response = await request.post(`${apiBase}/subscription/change-plan`, { headers, data: { - target_plan_id: 'nonexistent_plan', - billing_cycle: 'monthly', + target_plan_id: "nonexistent_plan", + billing_cycle: "monthly", }, }); - expect(response.status(), '无效套餐应返回 4xx').toBeGreaterThanOrEqual(400); + expect(response.status(), "无效套餐应返回 4xx").toBeGreaterThanOrEqual(400); expect(response.status()).toBeLessThan(500); }); }); -test.describe('订阅过期处理', () => { - test('取消订阅 - 反向(免费用户)', async ({ request }) => { - const { headers } = await createAuthedUser(request, 'sub-cancel'); +test.describe("订阅过期处理", () => { + test("取消订阅 - 反向(免费用户)", async ({ request }) => { + const { headers } = await createAuthedUser(request, "sub-cancel"); // 免费用户取消订阅应返回错误 - const response = await request.post(`${apiBase}/subscription/cancel`, { headers }); + const response = await request.post(`${apiBase}/subscription/cancel`, { + headers, + }); // 免费用户可能不需要取消,返回 400 或类似错误 if (!response.ok()) { const data = await response.json(); - expect(data.detail || data.message, '应返回错误信息').toBeTruthy(); + expect(data.detail || data.message, "应返回错误信息").toBeTruthy(); } }); - test('未登录取消订阅 - 反向', async ({ request }) => { + test("未登录取消订阅 - 反向", async ({ request }) => { const response = await request.post(`${apiBase}/subscription/cancel`); expect([401, 403]).toContain(response.status()); }); - test('切换自动续费 - 正向', async ({ request }) => { - const { headers } = await createAuthedUser(request, 'sub-autorenew'); + test("切换自动续费 - 正向", async ({ request }) => { + const { headers } = await createAuthedUser(request, "sub-autorenew"); // 关闭自动续费 - const disableResp = await request.post(`${apiBase}/subscription/toggle-auto-renew`, { - headers, - data: { enabled: false }, - }); - expect(disableResp.ok(), `关闭自动续费应成功: ${await disableResp.text()}`).toBeTruthy(); + const disableResp = await request.post( + `${apiBase}/subscription/toggle-auto-renew`, + { + headers, + data: { enabled: false }, + }, + ); + expect( + disableResp.ok(), + `关闭自动续费应成功: ${await disableResp.text()}`, + ).toBeTruthy(); // 重新开启自动续费 - const enableResp = await request.post(`${apiBase}/subscription/toggle-auto-renew`, { - headers, - data: { enabled: true }, - }); - expect(enableResp.ok(), `开启自动续费应成功: ${await enableResp.text()}`).toBeTruthy(); + const enableResp = await request.post( + `${apiBase}/subscription/toggle-auto-renew`, + { + headers, + data: { enabled: true }, + }, + ); + expect( + enableResp.ok(), + `开启自动续费应成功: ${await enableResp.text()}`, + ).toBeTruthy(); }); - test('无效参数切换自动续费 - 反向', async ({ request }) => { - const { headers } = await createAuthedUser(request, 'sub-autoren-bad'); + test("无效参数切换自动续费 - 反向", async ({ request }) => { + const { headers } = await createAuthedUser(request, "sub-autoren-bad"); // 缺少 enabled 字段 - const response = await request.post(`${apiBase}/subscription/toggle-auto-renew`, { - headers, - data: {}, - }); + const response = await request.post( + `${apiBase}/subscription/toggle-auto-renew`, + { + headers, + data: {}, + }, + ); expect([400, 422]).toContain(response.status()); }); }); -test.describe('账单记录', () => { - test('获取账单记录 - 正向', async ({ request }) => { - const { headers } = await createAuthedUser(request, 'sub-bills'); +test.describe("账单记录", () => { + test("获取账单记录 - 正向", async ({ request }) => { + const { headers } = await createAuthedUser(request, "sub-bills"); - const response = await request.get(`${apiBase}/subscription/billing-records`, { headers }); + const response = await request.get( + `${apiBase}/subscription/billing-records`, + { headers }, + ); - expect(response.ok(), `获取账单记录应返回 2xx,实际: ${response.status()}`).toBeTruthy(); + expect( + response.ok(), + `获取账单记录应返回 2xx,实际: ${response.status()}`, + ).toBeTruthy(); const data = await response.json(); - expect(Array.isArray(data), '账单记录应为数组').toBeTruthy(); + expect(Array.isArray(data), "账单记录应为数组").toBeTruthy(); }); - test('未登录获取账单记录 - 反向', async ({ request }) => { - const response = await request.get(`${apiBase}/subscription/billing-records`); + test("未登录获取账单记录 - 反向", async ({ request }) => { + const response = await request.get( + `${apiBase}/subscription/billing-records`, + ); expect([401, 403]).toContain(response.status()); }); }); diff --git a/apps/web/e2e/test_asset.spec.ts b/apps/web/e2e/test_asset.spec.ts index be6be26fc..9d498be63 100755 --- a/apps/web/e2e/test_asset.spec.ts +++ b/apps/web/e2e/test_asset.spec.ts @@ -4,10 +4,10 @@ * 覆盖:创建素材库、列出素材库、创建素材记录 * 每个测试独立,先注册登录获取 auth token。 */ -import { expect, test } from '@playwright/test'; +import { expect, test } from "@playwright/test"; -const PASSWORD = 'Test123456!'; -const apiBase = process.env.E2E_API_BASE || '/api/v1'; +const PASSWORD = "Test123456!"; +const apiBase = process.env.E2E_API_BASE || "/api/v1"; function uniqueEmail(prefix: string): string { return `${prefix}_${Date.now()}_${Math.random().toString(36).slice(2, 8)}@example.com`; @@ -43,49 +43,64 @@ async function createAuthedUser(request: any, label: string) { } /** 创建一个项目并返回 project id */ -async function createProject(request: any, headers: Record, suffix: string): Promise { +async function createProject( + request: any, + headers: Record, + suffix: string, +): Promise { const resp = await request.post(`${apiBase}/projects`, { headers, - data: { name: `Asset Test Proj ${suffix}`, description: 'E2E asset test' }, + data: { name: `Asset Test Proj ${suffix}`, description: "E2E asset test" }, }); expect(resp.ok(), `创建项目应成功: ${await resp.text()}`).toBeTruthy(); const data = await resp.json(); return data.id; } -test.describe('素材库流程', () => { - test('创建素材库', async ({ request }) => { - const { headers } = await createAuthedUser(request, 'lib-create'); - const projectId = await createProject(request, headers, Date.now().toString()); +test.describe("素材库流程", () => { + test("创建素材库", async ({ request }) => { + const { headers } = await createAuthedUser(request, "lib-create"); + const projectId = await createProject( + request, + headers, + Date.now().toString(), + ); const response = await request.post(`${apiBase}/asset-libraries`, { headers, data: { project_id: projectId, name: `视频素材库 ${Date.now()}`, - kind: 'video', + kind: "video", }, }); - expect(response.ok(), `创建素材库应返回 2xx,实际: ${response.status()} ${await response.text()}`).toBeTruthy(); + expect( + response.ok(), + `创建素材库应返回 2xx,实际: ${response.status()} ${await response.text()}`, + ).toBeTruthy(); const data = await response.json(); - expect(data.id, '应返回素材库 ID').toBeTruthy(); - expect(data.name).toContain('视频素材库'); - expect(data.kind).toBe('video'); + expect(data.id, "应返回素材库 ID").toBeTruthy(); + expect(data.name).toContain("视频素材库"); + expect(data.kind).toBe("video"); expect(data.project_id).toBe(projectId); }); - test('创建素材库 - 无效 kind 反向', async ({ request }) => { - const { headers } = await createAuthedUser(request, 'lib-badkind'); - const projectId = await createProject(request, headers, Date.now().toString()); + test("创建素材库 - 无效 kind 反向", async ({ request }) => { + const { headers } = await createAuthedUser(request, "lib-badkind"); + const projectId = await createProject( + request, + headers, + Date.now().toString(), + ); const response = await request.post(`${apiBase}/asset-libraries`, { headers, data: { project_id: projectId, - name: 'Bad Kind Library', - kind: 'invalid_kind', + name: "Bad Kind Library", + kind: "invalid_kind", }, }); @@ -93,33 +108,45 @@ test.describe('素材库流程', () => { expect([400, 422]).toContain(response.status()); }); - test('创建素材库 - 不存在的项目反向', async ({ request }) => { - const { headers } = await createAuthedUser(request, 'lib-nopj'); + test("创建素材库 - 不存在的项目反向", async ({ request }) => { + const { headers } = await createAuthedUser(request, "lib-nopj"); const response = await request.post(`${apiBase}/asset-libraries`, { headers, data: { - project_id: 'nonexistent-project-999', - name: 'Orphan Library', - kind: 'video', + project_id: "nonexistent-project-999", + name: "Orphan Library", + kind: "video", }, }); - expect(response.status(), '不存在的项目应返回 404').toBe(404); + expect(response.status(), "不存在的项目应返回 404").toBe(404); }); - test('列出素材库', async ({ request }) => { - const { headers } = await createAuthedUser(request, 'lib-list'); - const projectId = await createProject(request, headers, Date.now().toString()); + test("列出素材库", async ({ request }) => { + const { headers } = await createAuthedUser(request, "lib-list"); + const projectId = await createProject( + request, + headers, + Date.now().toString(), + ); // 创建 2 个不同类型的素材库 await request.post(`${apiBase}/asset-libraries`, { headers, - data: { project_id: projectId, name: `Video Lib ${Date.now()}`, kind: 'video' }, + data: { + project_id: projectId, + name: `Video Lib ${Date.now()}`, + kind: "video", + }, }); await request.post(`${apiBase}/asset-libraries`, { headers, - data: { project_id: projectId, name: `Image Lib ${Date.now()}`, kind: 'image' }, + data: { + project_id: projectId, + name: `Image Lib ${Date.now()}`, + kind: "image", + }, }); // 列出(按 project_id 过滤) @@ -128,25 +155,36 @@ test.describe('素材库流程', () => { params: { project_id: projectId }, }); - expect(response.ok(), `列出素材库应返回 2xx,实际: ${response.status()} ${await response.text()}`).toBeTruthy(); + expect( + response.ok(), + `列出素材库应返回 2xx,实际: ${response.status()} ${await response.text()}`, + ).toBeTruthy(); const data = await response.json(); const items = data.items || []; - expect(items.length, '应至少有 2 个素材库').toBeGreaterThanOrEqual(2); + expect(items.length, "应至少有 2 个素材库").toBeGreaterThanOrEqual(2); const kinds = items.map((i: any) => i.kind); - expect(kinds).toContain('video'); - expect(kinds).toContain('image'); + expect(kinds).toContain("video"); + expect(kinds).toContain("image"); }); - test('创建素材记录', async ({ request }) => { - const { headers, userId } = await createAuthedUser(request, 'asset-create'); - const projectId = await createProject(request, headers, Date.now().toString()); + test("创建素材记录", async ({ request }) => { + const { headers, userId } = await createAuthedUser(request, "asset-create"); + const projectId = await createProject( + request, + headers, + Date.now().toString(), + ); // 创建素材库 const lib = await request.post(`${apiBase}/asset-libraries`, { headers, - data: { project_id: projectId, name: `Asset Lib ${Date.now()}`, kind: 'video' }, + data: { + project_id: projectId, + name: `Asset Lib ${Date.now()}`, + kind: "video", + }, }); expect(lib.ok()).toBeTruthy(); const libData = await lib.json(); @@ -159,31 +197,42 @@ test.describe('素材库流程', () => { library_id: libData.id, name: `test_video_${Date.now()}.mp4`, storage_key: `uploads/e2e/test_${Date.now()}.mp4`, - mime_type: 'video/mp4', - metadata: { duration: 15.5, resolution: '1080p' }, + mime_type: "video/mp4", + metadata: { duration: 15.5, resolution: "1080p" }, file_size: 1024000, - status: 'ready', + status: "ready", uploaded_by_user_id: userId, }, }); - expect(response.ok(), `创建素材应返回 2xx,实际: ${response.status()} ${await response.text()}`).toBeTruthy(); + expect( + response.ok(), + `创建素材应返回 2xx,实际: ${response.status()} ${await response.text()}`, + ).toBeTruthy(); const data = await response.json(); - expect(data.id, '应返回素材 ID').toBeTruthy(); - expect(data.name).toContain('test_video'); - expect(data.mime_type).toBe('video/mp4'); + expect(data.id, "应返回素材 ID").toBeTruthy(); + expect(data.name).toContain("test_video"); + expect(data.mime_type).toBe("video/mp4"); expect(data.library_id).toBe(libData.id); }); - test('列出素材', async ({ request }) => { - const { headers, userId } = await createAuthedUser(request, 'asset-list'); - const projectId = await createProject(request, headers, Date.now().toString()); + test("列出素材", async ({ request }) => { + const { headers, userId } = await createAuthedUser(request, "asset-list"); + const projectId = await createProject( + request, + headers, + Date.now().toString(), + ); // 创建素材库 const lib = await request.post(`${apiBase}/asset-libraries`, { headers, - data: { project_id: projectId, name: `List Lib ${Date.now()}`, kind: 'video' }, + data: { + project_id: projectId, + name: `List Lib ${Date.now()}`, + kind: "video", + }, }); expect(lib.ok(), `创建素材库应成功: ${await lib.text()}`).toBeTruthy(); const libData = await lib.json(); @@ -196,8 +245,8 @@ test.describe('素材库流程', () => { library_id: libData.id, name: `clip_a_${Date.now()}.mp4`, storage_key: `uploads/e2e/clip_a.mp4`, - mime_type: 'video/mp4', - status: 'ready', + mime_type: "video/mp4", + status: "ready", uploaded_by_user_id: userId, }, }); @@ -208,8 +257,8 @@ test.describe('素材库流程', () => { library_id: libData.id, name: `clip_b_${Date.now()}.mp4`, storage_key: `uploads/e2e/clip_b.mp4`, - mime_type: 'video/mp4', - status: 'ready', + mime_type: "video/mp4", + status: "ready", uploaded_by_user_id: userId, }, }); @@ -220,19 +269,22 @@ test.describe('素材库流程', () => { params: { library_id: libData.id }, }); - expect(response.ok(), `列出素材应返回 2xx,实际: ${response.status()} ${await response.text()}`).toBeTruthy(); + expect( + response.ok(), + `列出素材应返回 2xx,实际: ${response.status()} ${await response.text()}`, + ).toBeTruthy(); const data = await response.json(); const items = data.items || []; - expect(items.length, '应至少有 2 个素材').toBeGreaterThanOrEqual(2); + expect(items.length, "应至少有 2 个素材").toBeGreaterThanOrEqual(2); }); - test('未登录创建素材库 - 反向', async ({ request }) => { + test("未登录创建素材库 - 反向", async ({ request }) => { const response = await request.post(`${apiBase}/asset-libraries`, { data: { - project_id: 'some-project', - name: 'Unauthorized Library', - kind: 'video', + project_id: "some-project", + name: "Unauthorized Library", + kind: "video", }, }); diff --git a/apps/web/e2e/test_auth.spec.ts b/apps/web/e2e/test_auth.spec.ts index 27fdda5c6..ddef80af1 100755 --- a/apps/web/e2e/test_auth.spec.ts +++ b/apps/web/e2e/test_auth.spec.ts @@ -4,10 +4,10 @@ * 覆盖:注册(正向/反向)、登录(正向/反向)、登出、获取当前用户信息 * 每个测试独立,使用随机邮箱避免冲突。 */ -import { expect, test } from '@playwright/test'; +import { expect, test } from "@playwright/test"; -const PASSWORD = 'Test123456!'; -const apiBase = process.env.E2E_API_BASE || '/api/v1'; +const PASSWORD = "Test123456!"; +const apiBase = process.env.E2E_API_BASE || "/api/v1"; function uniqueEmail(prefix: string): string { return `${prefix}_${Date.now()}_${Math.random().toString(36).slice(2, 8)}@example.com`; @@ -17,65 +17,85 @@ function uniqueUsername(prefix: string): string { return `${prefix}_${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`; } -test.describe('认证流程', () => { +test.describe("认证流程", () => { // ─── 注册 ──────────────────────────────────────────── - test('注册新用户 - 正向', async ({ request }) => { - const email = uniqueEmail('reg-ok'); - const username = uniqueUsername('regok'); + test("注册新用户 - 正向", async ({ request }) => { + const email = uniqueEmail("reg-ok"); + const username = uniqueUsername("regok"); const response = await request.post(`${apiBase}/auth/register`, { data: { email, password: PASSWORD, username, - display_name: 'E2E 注册测试', + display_name: "E2E 注册测试", }, }); - expect(response.ok(), `注册应返回 2xx,实际: ${response.status()} ${await response.text()}`).toBeTruthy(); + expect( + response.ok(), + `注册应返回 2xx,实际: ${response.status()} ${await response.text()}`, + ).toBeTruthy(); const data = await response.json(); - expect(data.user_id, '应返回 user_id').toBeTruthy(); + expect(data.user_id, "应返回 user_id").toBeTruthy(); expect(data.email).toBe(email); expect(data.username).toBe(username); }); - test('注册已存在邮箱 - 反向', async ({ request }) => { - const email = uniqueEmail('reg-dup'); - const username1 = uniqueUsername('regdup1'); - const username2 = uniqueUsername('regdup2'); + test("注册已存在邮箱 - 反向", async ({ request }) => { + const email = uniqueEmail("reg-dup"); + const username1 = uniqueUsername("regdup1"); + const username2 = uniqueUsername("regdup2"); // 第一次注册 const first = await request.post(`${apiBase}/auth/register`, { - data: { email, password: PASSWORD, username: username1, display_name: 'User 1' }, + data: { + email, + password: PASSWORD, + username: username1, + display_name: "User 1", + }, }); - expect(first.ok(), '第一次注册应成功').toBeTruthy(); + expect(first.ok(), "第一次注册应成功").toBeTruthy(); // 第二次使用相同邮箱 const second = await request.post(`${apiBase}/auth/register`, { - data: { email, password: PASSWORD, username: username2, display_name: 'User 2' }, + data: { + email, + password: PASSWORD, + username: username2, + display_name: "User 2", + }, }); - expect(second.status(), '重复邮箱注册应返回 4xx').toBeGreaterThanOrEqual(400); + expect(second.status(), "重复邮箱注册应返回 4xx").toBeGreaterThanOrEqual( + 400, + ); expect(second.status()).toBeLessThan(500); const body = await second.json(); // 错误信息应包含"已注册"或"exists"相关提示 - const detail = (body.detail || body.message || body.error || '').toString().toLowerCase(); + const detail = (body.detail || body.message || body.error || "") + .toString() + .toLowerCase(); expect( - detail.includes('已') || detail.includes('exist') || detail.includes('registered') || detail.includes('duplicate'), + detail.includes("已") || + detail.includes("exist") || + detail.includes("registered") || + detail.includes("duplicate"), `错误信息应提示邮箱已注册,实际: "${detail}"`, ).toBeTruthy(); }); - test('注册无效邮箱格式 - 反向', async ({ request }) => { + test("注册无效邮箱格式 - 反向", async ({ request }) => { const response = await request.post(`${apiBase}/auth/register`, { data: { - email: 'not-an-email', + email: "not-an-email", password: PASSWORD, - username: uniqueUsername('bademail'), - display_name: 'Bad Email', + username: uniqueUsername("bademail"), + display_name: "Bad Email", }, }); @@ -83,13 +103,13 @@ test.describe('认证流程', () => { expect([400, 422]).toContain(response.status()); }); - test('注册弱密码 - 反向', async ({ request }) => { + test("注册弱密码 - 反向", async ({ request }) => { const response = await request.post(`${apiBase}/auth/register`, { data: { - email: uniqueEmail('weakpwd'), - password: '123', - username: uniqueUsername('weakpwd'), - display_name: 'Weak', + email: uniqueEmail("weakpwd"), + password: "123", + username: uniqueUsername("weakpwd"), + display_name: "Weak", }, }); @@ -98,63 +118,71 @@ test.describe('认证流程', () => { // ─── 登录 ──────────────────────────────────────────── - test('登录成功 - 正向', async ({ request }) => { - const email = uniqueEmail('login-ok'); - const username = uniqueUsername('loginok'); + test("登录成功 - 正向", async ({ request }) => { + const email = uniqueEmail("login-ok"); + const username = uniqueUsername("loginok"); // 先注册 const reg = await request.post(`${apiBase}/auth/register`, { - data: { email, password: PASSWORD, username, display_name: 'Login Test' }, + data: { email, password: PASSWORD, username, display_name: "Login Test" }, }); - expect(reg.ok(), '注册应成功').toBeTruthy(); + expect(reg.ok(), "注册应成功").toBeTruthy(); // 登录 const response = await request.post(`${apiBase}/auth/login`, { data: { email, password: PASSWORD }, }); - expect(response.ok(), `登录应返回 2xx,实际: ${response.status()} ${await response.text()}`).toBeTruthy(); + expect( + response.ok(), + `登录应返回 2xx,实际: ${response.status()} ${await response.text()}`, + ).toBeTruthy(); const data = await response.json(); - expect(data.access_token, '应返回 access_token').toBeTruthy(); - expect(data.token_type).toBe('bearer'); + expect(data.access_token, "应返回 access_token").toBeTruthy(); + expect(data.token_type).toBe("bearer"); expect(data.email).toBe(email); }); - test('登录错误密码 - 反向', async ({ request }) => { - const email = uniqueEmail('login-bad'); - const username = uniqueUsername('loginbad'); + test("登录错误密码 - 反向", async ({ request }) => { + const email = uniqueEmail("login-bad"); + const username = uniqueUsername("loginbad"); // 先注册 await request.post(`${apiBase}/auth/register`, { - data: { email, password: PASSWORD, username, display_name: 'Bad Login' }, + data: { email, password: PASSWORD, username, display_name: "Bad Login" }, }); // 使用错误密码登录 const response = await request.post(`${apiBase}/auth/login`, { - data: { email, password: 'WrongPassword999!' }, + data: { email, password: "WrongPassword999!" }, }); - expect(response.status(), '错误密码应返回 401').toBe(401); + expect(response.status(), "错误密码应返回 401").toBe(401); }); - test('登录不存在的邮箱 - 反向', async ({ request }) => { + test("登录不存在的邮箱 - 反向", async ({ request }) => { const response = await request.post(`${apiBase}/auth/login`, { data: { email: `ghost_${Date.now()}@nonexist.com`, password: PASSWORD }, }); - expect(response.status(), '不存在的用户应返回 401').toBe(401); + expect(response.status(), "不存在的用户应返回 401").toBe(401); }); // ─── 登出 ──────────────────────────────────────────── - test('登出成功', async ({ request }) => { - const email = uniqueEmail('logout'); - const username = uniqueUsername('logout'); + test("登出成功", async ({ request }) => { + const email = uniqueEmail("logout"); + const username = uniqueUsername("logout"); // 注册 & 登录 await request.post(`${apiBase}/auth/register`, { - data: { email, password: PASSWORD, username, display_name: 'Logout Test' }, + data: { + email, + password: PASSWORD, + username, + display_name: "Logout Test", + }, }); const login = await request.post(`${apiBase}/auth/login`, { data: { email, password: PASSWORD }, @@ -164,7 +192,10 @@ test.describe('认证流程', () => { // 登出 const logout = await request.post(`${apiBase}/auth/logout`, { headers }); - expect(logout.ok(), `登出应返回 2xx,实际: ${logout.status()}`).toBeTruthy(); + expect( + logout.ok(), + `登出应返回 2xx,实际: ${logout.status()}`, + ).toBeTruthy(); const body = await logout.json(); expect(body.message).toBeTruthy(); @@ -176,12 +207,12 @@ test.describe('认证流程', () => { // ─── 获取当前用户信息 ───────────────────────────────── - test('获取当前用户信息 - 正向', async ({ request }) => { - const email = uniqueEmail('me-ok'); - const username = uniqueUsername('meok'); + test("获取当前用户信息 - 正向", async ({ request }) => { + const email = uniqueEmail("me-ok"); + const username = uniqueUsername("meok"); await request.post(`${apiBase}/auth/register`, { - data: { email, password: PASSWORD, username, display_name: 'Me Test' }, + data: { email, password: PASSWORD, username, display_name: "Me Test" }, }); const login = await request.post(`${apiBase}/auth/login`, { data: { email, password: PASSWORD }, @@ -192,7 +223,10 @@ test.describe('认证流程', () => { headers: { Authorization: `Bearer ${access_token}` }, }); - expect(response.ok(), `获取用户信息应返回 2xx,实际: ${response.status()}`).toBeTruthy(); + expect( + response.ok(), + `获取用户信息应返回 2xx,实际: ${response.status()}`, + ).toBeTruthy(); const data = await response.json(); expect(data.user_id).toBeTruthy(); @@ -200,25 +234,25 @@ test.describe('认证流程', () => { expect(data.username).toBe(username); }); - test('无 token 获取用户信息 - 反向', async ({ request }) => { + test("无 token 获取用户信息 - 反向", async ({ request }) => { const response = await request.get(`${apiBase}/auth/me`); // HTTPBearer 无凭证返回 403 expect([401, 403]).toContain(response.status()); }); - test('无效 token 获取用户信息 - 反向', async ({ request }) => { + test("无效 token 获取用户信息 - 反向", async ({ request }) => { const response = await request.get(`${apiBase}/auth/me`, { - headers: { Authorization: 'Bearer invalid.token.here' }, + headers: { Authorization: "Bearer invalid.token.here" }, }); expect(response.status()).toBe(401); }); - test('过期 token 获取用户信息 - 反向', async ({ request }) => { + test("过期 token 获取用户信息 - 反向", async ({ request }) => { // 使用一个伪造的过期 JWT(header.payload.signature) // eyJhbGciOiJIUzI1NiJ9 = {"alg":"HS256"} // eyJleHAiOjF9 = {"exp":1} (1970-01-01 过期) const expiredToken = - 'eyJhbGciOiJIUzI1NiJ9.eyJleHAiOjEsInN1YiI6InRlc3QtdXNlciJ9.expired_signature'; + "eyJhbGciOiJIUzI1NiJ9.eyJleHAiOjEsInN1YiI6InRlc3QtdXNlciJ9.expired_signature"; const response = await request.get(`${apiBase}/auth/me`, { headers: { Authorization: `Bearer ${expiredToken}` }, @@ -227,17 +261,17 @@ test.describe('认证流程', () => { expect([401, 403]).toContain(response.status()); }); - test('token 格式错误 - 反向', async ({ request }) => { + test("token 格式错误 - 反向", async ({ request }) => { const response = await request.get(`${apiBase}/auth/me`, { - headers: { Authorization: 'Bearer not-a-jwt' }, + headers: { Authorization: "Bearer not-a-jwt" }, }); expect([401, 403]).toContain(response.status()); }); - test('空 Bearer token - 反向', async ({ request }) => { + test("空 Bearer token - 反向", async ({ request }) => { const response = await request.get(`${apiBase}/auth/me`, { - headers: { Authorization: 'Bearer ' }, + headers: { Authorization: "Bearer " }, }); expect([401, 403]).toContain(response.status()); diff --git a/apps/web/e2e/test_project.spec.ts b/apps/web/e2e/test_project.spec.ts index 0eb864d96..58b212a8c 100755 --- a/apps/web/e2e/test_project.spec.ts +++ b/apps/web/e2e/test_project.spec.ts @@ -4,10 +4,10 @@ * 覆盖:创建项目、列出项目、获取项目详情 * 每个测试独立,先注册登录获取 auth token。 */ -import { expect, test } from '@playwright/test'; +import { expect, test } from "@playwright/test"; -const PASSWORD = 'Test123456!'; -const apiBase = process.env.E2E_API_BASE || '/api/v1'; +const PASSWORD = "Test123456!"; +const apiBase = process.env.E2E_API_BASE || "/api/v1"; function uniqueEmail(prefix: string): string { return `${prefix}_${Date.now()}_${Math.random().toString(36).slice(2, 8)}@example.com`; @@ -42,41 +42,44 @@ async function createAuthedUser(request: any, label: string) { }; } -test.describe('项目流程', () => { - test('创建项目', async ({ request }) => { - const { headers } = await createAuthedUser(request, 'proj-create'); +test.describe("项目流程", () => { + test("创建项目", async ({ request }) => { + const { headers } = await createAuthedUser(request, "proj-create"); const projectName = `E2E 测试项目 ${Date.now()}`; const response = await request.post(`${apiBase}/projects`, { headers, data: { name: projectName, - description: 'Playwright E2E 回归测试创建', + description: "Playwright E2E 回归测试创建", }, }); - expect(response.ok(), `创建项目应返回 2xx,实际: ${response.status()} ${await response.text()}`).toBeTruthy(); + expect( + response.ok(), + `创建项目应返回 2xx,实际: ${response.status()} ${await response.text()}`, + ).toBeTruthy(); const data = await response.json(); - expect(data.id, '应返回项目 ID').toBeTruthy(); + expect(data.id, "应返回项目 ID").toBeTruthy(); expect(data.name).toBe(projectName); - expect(data.owner_user_id, '应返回所有者 ID').toBeTruthy(); + expect(data.owner_user_id, "应返回所有者 ID").toBeTruthy(); }); - test('创建项目名称为空 - 反向', async ({ request }) => { - const { headers } = await createAuthedUser(request, 'proj-empty'); + test("创建项目名称为空 - 反向", async ({ request }) => { + const { headers } = await createAuthedUser(request, "proj-empty"); const response = await request.post(`${apiBase}/projects`, { headers, - data: { name: '', description: 'Should fail' }, + data: { name: "", description: "Should fail" }, }); // name 有 min_length=1 约束,应返回 422 expect([400, 422]).toContain(response.status()); }); - test('列出项目', async ({ request }) => { - const { headers } = await createAuthedUser(request, 'proj-list'); + test("列出项目", async ({ request }) => { + const { headers } = await createAuthedUser(request, "proj-list"); // 先创建 2 个项目 await request.post(`${apiBase}/projects`, { @@ -91,29 +94,37 @@ test.describe('项目流程', () => { // 列出 const response = await request.get(`${apiBase}/projects`, { headers }); - expect(response.ok(), `列出项目应返回 2xx,实际: ${response.status()} ${await response.text()}`).toBeTruthy(); + expect( + response.ok(), + `列出项目应返回 2xx,实际: ${response.status()} ${await response.text()}`, + ).toBeTruthy(); const data = await response.json(); const items = data.items || data.projects || data || []; expect(Array.isArray(items)).toBeTruthy(); - expect(items.length, '应至少有 2 个项目').toBeGreaterThanOrEqual(2); + expect(items.length, "应至少有 2 个项目").toBeGreaterThanOrEqual(2); }); - test('获取项目详情', async ({ request }) => { - const { headers } = await createAuthedUser(request, 'proj-detail'); + test("获取项目详情", async ({ request }) => { + const { headers } = await createAuthedUser(request, "proj-detail"); // 先创建 const created = await request.post(`${apiBase}/projects`, { headers, - data: { name: `Detail Proj ${Date.now()}`, description: 'Detail test' }, + data: { name: `Detail Proj ${Date.now()}`, description: "Detail test" }, }); expect(created.ok(), `创建应成功: ${await created.text()}`).toBeTruthy(); const { id: projectId } = await created.json(); // 获取详情 - const response = await request.get(`${apiBase}/projects/${projectId}`, { headers }); + const response = await request.get(`${apiBase}/projects/${projectId}`, { + headers, + }); - expect(response.ok(), `获取详情应返回 2xx,实际: ${response.status()} ${await response.text()}`).toBeTruthy(); + expect( + response.ok(), + `获取详情应返回 2xx,实际: ${response.status()} ${await response.text()}`, + ).toBeTruthy(); const data = await response.json(); expect(data.id).toBe(projectId); @@ -121,32 +132,38 @@ test.describe('项目流程', () => { expect(data.owner_user_id).toBeTruthy(); }); - test('获取不存在的项目 - 反向', async ({ request }) => { - const { headers } = await createAuthedUser(request, 'proj-404'); + test("获取不存在的项目 - 反向", async ({ request }) => { + const { headers } = await createAuthedUser(request, "proj-404"); - const response = await request.get(`${apiBase}/projects/nonexistent-project-id-999`, { headers }); + const response = await request.get( + `${apiBase}/projects/nonexistent-project-id-999`, + { headers }, + ); - expect(response.status(), '不存在的项目应返回 404').toBe(404); + expect(response.status(), "不存在的项目应返回 404").toBe(404); }); - test('未登录列出项目 - 反向', async ({ request }) => { + test("未登录列出项目 - 反向", async ({ request }) => { const response = await request.get(`${apiBase}/projects`); expect([401, 403]).toContain(response.status()); }); - test('未授权访问他人项目 - 反向', async ({ request }) => { + test("未授权访问他人项目 - 反向", async ({ request }) => { // 用户 A 创建项目 - const { headers: headersA } = await createAuthedUser(request, 'proj-owner'); + const { headers: headersA } = await createAuthedUser(request, "proj-owner"); const created = await request.post(`${apiBase}/projects`, { headers: headersA, - data: { name: `Owner Proj ${Date.now()}`, description: 'Owner test' }, + data: { name: `Owner Proj ${Date.now()}`, description: "Owner test" }, }); - expect(created.ok(), '用户 A 创建项目应成功').toBeTruthy(); + expect(created.ok(), "用户 A 创建项目应成功").toBeTruthy(); const { id: projectId } = await created.json(); // 用户 B 尝试访问用户 A 的项目 - const { headers: headersB } = await createAuthedUser(request, 'proj-intruder'); + const { headers: headersB } = await createAuthedUser( + request, + "proj-intruder", + ); const response = await request.get(`${apiBase}/projects/${projectId}`, { headers: headersB, }); @@ -155,18 +172,24 @@ test.describe('项目流程', () => { expect([403, 404]).toContain(response.status()); }); - test('未授权删除他人项目 - 反向', async ({ request }) => { + test("未授权删除他人项目 - 反向", async ({ request }) => { // 用户 A 创建项目 - const { headers: headersA } = await createAuthedUser(request, 'proj-del-owner'); + const { headers: headersA } = await createAuthedUser( + request, + "proj-del-owner", + ); const created = await request.post(`${apiBase}/projects`, { headers: headersA, data: { name: `Delete Test Proj ${Date.now()}` }, }); - expect(created.ok(), '用户 A 创建项目应成功').toBeTruthy(); + expect(created.ok(), "用户 A 创建项目应成功").toBeTruthy(); const { id: projectId } = await created.json(); // 用户 B 尝试删除用户 A 的项目 - const { headers: headersB } = await createAuthedUser(request, 'proj-del-attempt'); + const { headers: headersB } = await createAuthedUser( + request, + "proj-del-attempt", + ); const response = await request.delete(`${apiBase}/projects/${projectId}`, { headers: headersB, }); @@ -174,8 +197,8 @@ test.describe('项目流程', () => { expect([403, 404]).toContain(response.status()); }); - test('使用无效项目 ID 获取详情 - 反向', async ({ request }) => { - const { headers } = await createAuthedUser(request, 'proj-badid'); + test("使用无效项目 ID 获取详情 - 反向", async ({ request }) => { + const { headers } = await createAuthedUser(request, "proj-badid"); const response = await request.get(`${apiBase}/projects/`, { headers }); diff --git a/apps/web/playwright.config.ts b/apps/web/playwright.config.ts index 9bb522e47..b7a93b241 100644 --- a/apps/web/playwright.config.ts +++ b/apps/web/playwright.config.ts @@ -1,59 +1,65 @@ /** * Playwright E2E 测试配置 */ -import { defineConfig, devices } from '@playwright/test'; +import { defineConfig, devices } from "@playwright/test"; const externalBaseURL = process.env.E2E_BASE_URL; export default defineConfig({ - testDir: './e2e', + testDir: "./e2e", fullyParallel: true, forbidOnly: !!process.env.CI, retries: process.env.CI ? 2 : 0, workers: process.env.CI ? 1 : undefined, - reporter: [['html'], ['list']], + reporter: [["html"], ["list"]], use: { - baseURL: externalBaseURL || 'http://localhost:3000', - trace: 'retain-on-failure', - screenshot: 'only-on-failure', - video: process.env.E2E_VIDEO ? 'retain-on-failure' : 'off', + baseURL: externalBaseURL || "http://localhost:3000", + trace: "retain-on-failure", + screenshot: "only-on-failure", + video: process.env.E2E_VIDEO ? "retain-on-failure" : "off", }, projects: process.env.E2E_ALL_BROWSERS ? [ { - name: 'chromium', - use: { ...devices['Desktop Chrome'], channel: process.env.E2E_BROWSER_CHANNEL || 'msedge' }, + name: "chromium", + use: { + ...devices["Desktop Chrome"], + channel: process.env.E2E_BROWSER_CHANNEL || "msedge", + }, }, { - name: 'firefox', - use: { ...devices['Desktop Firefox'] }, + name: "firefox", + use: { ...devices["Desktop Firefox"] }, }, { - name: 'webkit', - use: { ...devices['Desktop Safari'] }, + name: "webkit", + use: { ...devices["Desktop Safari"] }, }, { - name: 'Mobile Chrome', - use: { ...devices['Pixel 5'] }, + name: "Mobile Chrome", + use: { ...devices["Pixel 5"] }, }, { - name: 'Mobile Safari', - use: { ...devices['iPhone 12'] }, + name: "Mobile Safari", + use: { ...devices["iPhone 12"] }, }, ] : [ { - name: 'chromium', - use: { ...devices['Desktop Chrome'], channel: process.env.E2E_BROWSER_CHANNEL || 'msedge' }, + name: "chromium", + use: { + ...devices["Desktop Chrome"], + channel: process.env.E2E_BROWSER_CHANNEL || "msedge", + }, }, ], webServer: externalBaseURL ? undefined : { - command: 'npm run dev -- --host 127.0.0.1 --port 3000', - url: 'http://localhost:3000', + command: "npm run dev -- --host 127.0.0.1 --port 3000", + url: "http://localhost:3000", reuseExistingServer: !process.env.CI, }, }); diff --git a/apps/web/src/api/duplication.ts b/apps/web/src/api/duplication.ts index e45ba5637..e88fea881 100644 --- a/apps/web/src/api/duplication.ts +++ b/apps/web/src/api/duplication.ts @@ -6,10 +6,7 @@ import apiClient from "./client"; /** 查重记录状态 */ export type DuplicationStatus = - | "pending" - | "processing" - | "completed" - | "failed"; + "pending" | "processing" | "completed" | "failed"; /** 查重记录 */ export interface DuplicationRecord { diff --git a/apps/web/src/api/editPlans.ts b/apps/web/src/api/editPlans.ts index 453412d32..303facc1a 100644 --- a/apps/web/src/api/editPlans.ts +++ b/apps/web/src/api/editPlans.ts @@ -11,11 +11,7 @@ import type { AssetItem } from "./assets"; /** 剪辑计划状态枚举 */ export type EditPlanStatus = - | "draft" - | "editing" - | "rendering" - | "completed" - | "failed"; + "draft" | "editing" | "rendering" | "completed" | "failed"; /** 剪辑计划(后端响应) */ export interface EditPlan { @@ -218,7 +214,8 @@ function mapAssetToMediaAsset(asset: AssetItem): MediaAsset { id: asset.id, name: asset.name, type: inferMediaType(asset.mime_type || ""), - thumbnail_url: typeof ext.thumbnail_url === "string" ? ext.thumbnail_url : undefined, + thumbnail_url: + typeof ext.thumbnail_url === "string" ? ext.thumbnail_url : undefined, duration: typeof ext.duration === "number" ? ext.duration @@ -229,7 +226,8 @@ function mapAssetToMediaAsset(asset: AssetItem): MediaAsset { tags: [], created_at: asset.created_at ?? "", quality_score: asset.quality_score ?? undefined, - classification_status: (asset.classification_status ?? undefined) as MediaAsset["classification_status"], + classification_status: (asset.classification_status ?? + undefined) as MediaAsset["classification_status"], }; } diff --git a/apps/web/src/api/tts.ts b/apps/web/src/api/tts.ts index 6010d8c84..d060e115e 100644 --- a/apps/web/src/api/tts.ts +++ b/apps/web/src/api/tts.ts @@ -113,7 +113,8 @@ export const getTTSJobs = async ( const searchParams = new URLSearchParams(); if (params?.status) searchParams.set("status", params.status); if (params?.skip !== undefined) searchParams.set("skip", String(params.skip)); - if (params?.limit !== undefined) searchParams.set("limit", String(params.limit)); + if (params?.limit !== undefined) + searchParams.set("limit", String(params.limit)); const qs = searchParams.toString(); const response = await apiClient.get( `/tts/jobs${qs ? `?${qs}` : ""}`, diff --git a/apps/web/src/api/voiceClone.ts b/apps/web/src/api/voiceClone.ts index 19c4998bb..43072d812 100644 --- a/apps/web/src/api/voiceClone.ts +++ b/apps/web/src/api/voiceClone.ts @@ -128,7 +128,8 @@ export const getVoiceClones = async ( const searchParams = new URLSearchParams(); if (params?.status) searchParams.set("status", params.status); if (params?.skip !== undefined) searchParams.set("skip", String(params.skip)); - if (params?.limit !== undefined) searchParams.set("limit", String(params.limit)); + if (params?.limit !== undefined) + searchParams.set("limit", String(params.limit)); const qs = searchParams.toString(); const response = await apiClient.get( `/voice-clones${qs ? `?${qs}` : ""}`, @@ -143,7 +144,8 @@ export const getVoiceClonesWithTotal = async ( const searchParams = new URLSearchParams(); if (params?.status) searchParams.set("status", params.status); if (params?.skip !== undefined) searchParams.set("skip", String(params.skip)); - if (params?.limit !== undefined) searchParams.set("limit", String(params.limit)); + if (params?.limit !== undefined) + searchParams.set("limit", String(params.limit)); const qs = searchParams.toString(); const response = await apiClient.get( `/voice-clones${qs ? `?${qs}` : ""}`, @@ -155,7 +157,9 @@ export const getVoiceClonesWithTotal = async ( export const getVoiceCloneDetail = async ( id: string, ): Promise => { - const response = await apiClient.get(`/voice-clones/${id}`); + const response = await apiClient.get( + `/voice-clones/${id}`, + ); return response.data; }; @@ -186,8 +190,14 @@ export const updateVoiceClone = async ( data: Partial>, ): Promise => { // 后端暂未提供更新端点,暂用详情接口模拟 - const response = await apiClient.get(`/voice-clones/${id}`); - return toVoiceClone({ ...response.data, ...data, updated_at: new Date().toISOString() }); + const response = await apiClient.get( + `/voice-clones/${id}`, + ); + return toVoiceClone({ + ...response.data, + ...data, + updated_at: new Date().toISOString(), + }); }; /** 获取克隆状态 */ diff --git a/apps/web/src/api/voices.ts b/apps/web/src/api/voices.ts index 1c3aa2095..f9defedf4 100644 --- a/apps/web/src/api/voices.ts +++ b/apps/web/src/api/voices.ts @@ -72,7 +72,8 @@ export const fetchVoices = async ( if (params?.type) searchParams.set("type", params.type); if (params?.status) searchParams.set("status", params.status); if (params?.skip !== undefined) searchParams.set("skip", String(params.skip)); - if (params?.limit !== undefined) searchParams.set("limit", String(params.limit)); + if (params?.limit !== undefined) + searchParams.set("limit", String(params.limit)); const qs = searchParams.toString(); const response = await apiClient.get( `/voices${qs ? `?${qs}` : ""}`, @@ -82,7 +83,8 @@ export const fetchVoices = async ( /** 获取预设音色列表(无需鉴权) */ export const fetchPresetVoices = async (): Promise => { - const response = await apiClient.get("/voices/presets"); + const response = + await apiClient.get("/voices/presets"); return response.data; }; diff --git a/apps/web/src/components/AssetSelector/AssetSelector.tsx b/apps/web/src/components/AssetSelector/AssetSelector.tsx index 3e926a78b..d5e7b338b 100644 --- a/apps/web/src/components/AssetSelector/AssetSelector.tsx +++ b/apps/web/src/components/AssetSelector/AssetSelector.tsx @@ -9,7 +9,13 @@ * - 筛选增强:类型筛选 + 质量分筛选 * - 视图切换:网格视图 / 列表视图 */ -import React, { useState, useMemo, useCallback, useRef, useEffect } from "react"; +import React, { + useState, + useMemo, + useCallback, + useRef, + useEffect, +} from "react"; import "./AssetSelector.css"; import { Input, Select, Button } from "@/components/ui"; import type { MediaAsset } from "@/api/editPlans"; @@ -167,7 +173,6 @@ const AssetSelector: React.FC = ({ [onSelectionChange, selectedIds, filteredAssets], ); - const clearSelection = useCallback(() => { onSelectionChange?.([]); }, [onSelectionChange]); @@ -184,7 +189,10 @@ const AssetSelector: React.FC = ({ JSON.stringify(filteredAssets[idx]), ); // 批量拖拽:如果有多个选中素材,一起携带 - if (selectedIds.length > 1 && selectedIds.includes(filteredAssets[idx].id)) { + if ( + selectedIds.length > 1 && + selectedIds.includes(filteredAssets[idx].id) + ) { const batchAssets = filteredAssets.filter((a) => selectedIds.includes(a.id), ); @@ -423,9 +431,7 @@ const AssetSelector: React.FC = ({

{asset.name}

-
- {formatSize(asset.size)} -
+
{formatSize(asset.size)}
); @@ -486,7 +492,8 @@ const AssetSelector: React.FC = ({
{asset.name}
{MATERIAL_TYPE_LABELS[asset.type]} - {asset.duration != null && ` · ${formatDuration(asset.duration)}`} + {asset.duration != null && + ` · ${formatDuration(asset.duration)}`} {asset.size != null && ` · ${formatSize(asset.size)}`}
@@ -515,10 +522,7 @@ const AssetSelector: React.FC = ({ >
{previewAsset.thumbnail_url ? ( - {previewAsset.name} + {previewAsset.name} ) : ( {MATERIAL_TYPE_ICONS[previewAsset.type]} @@ -535,9 +539,7 @@ const AssetSelector: React.FC = ({ 大小: {formatSize(previewAsset.size)} )} {previewAsset.quality_score != null && ( - - 质量分: {previewAsset.quality_score} - + 质量分: {previewAsset.quality_score} )} {previewAsset.tags.length > 0 && ( 标签: {previewAsset.tags.join(", ")} diff --git a/apps/web/src/components/modals/VoiceCloneModal.tsx b/apps/web/src/components/modals/VoiceCloneModal.tsx index 12337b69b..271bc1db0 100644 --- a/apps/web/src/components/modals/VoiceCloneModal.tsx +++ b/apps/web/src/components/modals/VoiceCloneModal.tsx @@ -49,7 +49,8 @@ const getNextDefaultName = (): string => { /* ── 支持的文件扩展名 ─────────────────────────────────── */ const ACCEPTED_EXTENSIONS = ["mp3", "wav", "m4a", "aac", "ogg"]; -const ACCEPTED_MIME = ".mp3,.wav,.m4a,.aac,.ogg,audio/mpeg,audio/wav,audio/mp4,audio/aac,audio/ogg"; +const ACCEPTED_MIME = + ".mp3,.wav,.m4a,.aac,.ogg,audio/mpeg,audio/wav,audio/mp4,audio/aac,audio/ogg"; /* ── 组件 ───────────────────────────────────────────────── */ @@ -372,9 +373,7 @@ const VoiceCloneModal: React.FC = ({
{isDone ? "✓" : step.icon}
- - {step.label} - + {step.label}
); @@ -395,9 +394,7 @@ const VoiceCloneModal: React.FC = ({ {phase === "cloning" && ( <>
-

- AI 正在克隆你的声音… -

+

AI 正在克隆你的声音…

正在分析声音特征,生成专属音色模型

diff --git a/apps/web/src/components/modals/voice-clone-modal.css b/apps/web/src/components/modals/voice-clone-modal.css index 2761bced6..2b79260f3 100644 --- a/apps/web/src/components/modals/voice-clone-modal.css +++ b/apps/web/src/components/modals/voice-clone-modal.css @@ -41,7 +41,9 @@ font-size: var(--font-size-base); color: var(--text-primary); outline: none; - transition: border-color 0.2s ease, box-shadow 0.2s ease; + transition: + border-color 0.2s ease, + box-shadow 0.2s ease; } .xx-vcmodal-input::placeholder { @@ -66,7 +68,9 @@ border-radius: var(--radius-md); background: var(--bg-secondary); cursor: pointer; - transition: border-color 0.2s ease, background 0.2s ease; + transition: + border-color 0.2s ease, + background 0.2s ease; } .xx-vcmodal-upload-zone:hover { @@ -167,15 +171,34 @@ animation: vcmodal-wave 0.8s ease-in-out infinite alternate; } -.xx-vcmodal-record-wave-bar:nth-child(1) { height: 40%; animation-delay: 0s; } -.xx-vcmodal-record-wave-bar:nth-child(2) { height: 70%; animation-delay: 0.15s; } -.xx-vcmodal-record-wave-bar:nth-child(3) { height: 100%; animation-delay: 0.3s; } -.xx-vcmodal-record-wave-bar:nth-child(4) { height: 60%; animation-delay: 0.45s; } -.xx-vcmodal-record-wave-bar:nth-child(5) { height: 30%; animation-delay: 0.6s; } +.xx-vcmodal-record-wave-bar:nth-child(1) { + height: 40%; + animation-delay: 0s; +} +.xx-vcmodal-record-wave-bar:nth-child(2) { + height: 70%; + animation-delay: 0.15s; +} +.xx-vcmodal-record-wave-bar:nth-child(3) { + height: 100%; + animation-delay: 0.3s; +} +.xx-vcmodal-record-wave-bar:nth-child(4) { + height: 60%; + animation-delay: 0.45s; +} +.xx-vcmodal-record-wave-bar:nth-child(5) { + height: 30%; + animation-delay: 0.6s; +} @keyframes vcmodal-wave { - from { transform: scaleY(0.4); } - to { transform: scaleY(1); } + from { + transform: scaleY(0.4); + } + to { + transform: scaleY(1); + } } /* 录制按钮 */ @@ -188,7 +211,10 @@ box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08); font-size: 20px; cursor: pointer; - transition: background 0.2s ease, transform 0.15s ease, box-shadow 0.2s ease; + transition: + background 0.2s ease, + transform 0.15s ease, + box-shadow 0.2s ease; display: flex; align-items: center; justify-content: center; @@ -212,8 +238,13 @@ } @keyframes vcmodal-pulse { - 0%, 100% { box-shadow: 0 0 0 0 rgba(239, 68, 68, 0.4); } - 50% { box-shadow: 0 0 0 8px rgba(239, 68, 68, 0); } + 0%, + 100% { + box-shadow: 0 0 0 0 rgba(239, 68, 68, 0.4); + } + 50% { + box-shadow: 0 0 0 8px rgba(239, 68, 68, 0); + } } /* ── 提示 ─────────────────────────────────────────────────── */ @@ -357,7 +388,9 @@ } @keyframes vcmodal-spin { - to { transform: rotate(360deg); } + to { + transform: rotate(360deg); + } } .xx-vcmodal-progress-text { @@ -390,9 +423,17 @@ } @keyframes vcmodal-bounce { - 0% { transform: scale(0); opacity: 0; } - 60% { transform: scale(1.2); opacity: 1; } - 100% { transform: scale(1); } + 0% { + transform: scale(0); + opacity: 0; + } + 60% { + transform: scale(1.2); + opacity: 1; + } + 100% { + transform: scale(1); + } } .xx-vcmodal-success-title { diff --git a/apps/web/src/components/voice/CloneModal.tsx b/apps/web/src/components/voice/CloneModal.tsx index 479c8a75b..95fc6a1d0 100644 --- a/apps/web/src/components/voice/CloneModal.tsx +++ b/apps/web/src/components/voice/CloneModal.tsx @@ -39,7 +39,11 @@ const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB /* ── 组件 ───────────────────────────────────────────────── */ -const CloneModal: React.FC = ({ open, onClose, onSuccess }) => { +const CloneModal: React.FC = ({ + open, + onClose, + onSuccess, +}) => { const [phase, setPhase] = useState("input"); const [voiceName, setVoiceName] = useState(""); const [voiceDescription, setVoiceDescription] = useState(""); diff --git a/apps/web/src/components/voice/clone-modal.css b/apps/web/src/components/voice/clone-modal.css index 2ae00c1b6..b88eed8d6 100644 --- a/apps/web/src/components/voice/clone-modal.css +++ b/apps/web/src/components/voice/clone-modal.css @@ -49,7 +49,8 @@ flex-shrink: 0; } -.xx-clonemodal-step:not(.xx-clonemodal-step--active) .xx-clonemodal-step-number { +.xx-clonemodal-step:not(.xx-clonemodal-step--active) + .xx-clonemodal-step-number { background: var(--xx-color-border, #e5e7eb); color: var(--xx-color-text-secondary, #6b7280); } @@ -98,7 +99,9 @@ color: var(--xx-color-text, #111827); background: var(--xx-color-bg-secondary, #f9fafb); outline: none; - transition: border-color 0.2s ease, box-shadow 0.2s ease; + transition: + border-color 0.2s ease, + box-shadow 0.2s ease; box-sizing: border-box; } @@ -122,7 +125,9 @@ outline: none; resize: vertical; font-family: inherit; - transition: border-color 0.2s ease, box-shadow 0.2s ease; + transition: + border-color 0.2s ease, + box-shadow 0.2s ease; box-sizing: border-box; } diff --git a/apps/web/src/config/navigation.ts b/apps/web/src/config/navigation.ts index fbd8c185d..49e2939b5 100644 --- a/apps/web/src/config/navigation.ts +++ b/apps/web/src/config/navigation.ts @@ -35,17 +35,72 @@ export interface NavGroup { /** 全量导航项(Header 扁平列表使用) */ export const NAV_ITEMS: NavItem[] = [ - { key: "dashboard", label: "概览", path: "/dashboard", icon: React.createElement(DashboardOutlined) }, - { key: "assets", label: "素材库", path: "/assets", icon: React.createElement(FileOutlined) }, - { key: "titles", label: "标题库", path: "/titles", icon: React.createElement(FileTextOutlined) }, - { key: "voices", label: "配音库", path: "/voices", icon: React.createElement(AudioOutlined) }, - { key: "templates", label: "模板库", path: "/templates", icon: React.createElement(AppstoreOutlined) }, - { key: "editing-planner", label: "剪辑编辑器", path: "/editing-planner", icon: React.createElement(EditOutlined) }, - { key: "my-templates", label: "我的模板", path: "/my-templates", icon: React.createElement(FolderOutlined) }, - { key: "generate", label: "一键生成", path: "/generate", icon: React.createElement(VideoCameraOutlined) }, - { key: "history", label: "任务历史", path: "/history", icon: React.createElement(HistoryOutlined) }, - { key: "products", label: "成品库", path: "/products", icon: React.createElement(TrophyOutlined) }, - { key: "duplication", label: "查重", path: "/duplication", icon: React.createElement(ScanOutlined) }, + { + key: "dashboard", + label: "概览", + path: "/dashboard", + icon: React.createElement(DashboardOutlined), + }, + { + key: "assets", + label: "素材库", + path: "/assets", + icon: React.createElement(FileOutlined), + }, + { + key: "titles", + label: "标题库", + path: "/titles", + icon: React.createElement(FileTextOutlined), + }, + { + key: "voices", + label: "配音库", + path: "/voices", + icon: React.createElement(AudioOutlined), + }, + { + key: "templates", + label: "模板库", + path: "/templates", + icon: React.createElement(AppstoreOutlined), + }, + { + key: "editing-planner", + label: "剪辑编辑器", + path: "/editing-planner", + icon: React.createElement(EditOutlined), + }, + { + key: "my-templates", + label: "我的模板", + path: "/my-templates", + icon: React.createElement(FolderOutlined), + }, + { + key: "generate", + label: "一键生成", + path: "/generate", + icon: React.createElement(VideoCameraOutlined), + }, + { + key: "history", + label: "任务历史", + path: "/history", + icon: React.createElement(HistoryOutlined), + }, + { + key: "products", + label: "成品库", + path: "/products", + icon: React.createElement(TrophyOutlined), + }, + { + key: "duplication", + label: "查重", + path: "/duplication", + icon: React.createElement(ScanOutlined), + }, ]; /** 侧边栏导航分组(Sidebar 分组列表使用) */ @@ -53,29 +108,94 @@ export const NAV_GROUPS: NavGroup[] = [ { title: "创作工具", items: [ - { key: "dashboard", label: "概览", path: "/dashboard", icon: React.createElement(DashboardOutlined) }, - { key: "generate", label: "一键生成", path: "/generate", icon: React.createElement(VideoCameraOutlined) }, - { key: "editing-planner", label: "剪辑编辑器", path: "/editing-planner", icon: React.createElement(EditOutlined) }, + { + key: "dashboard", + label: "概览", + path: "/dashboard", + icon: React.createElement(DashboardOutlined), + }, + { + key: "generate", + label: "一键生成", + path: "/generate", + icon: React.createElement(VideoCameraOutlined), + }, + { + key: "editing-planner", + label: "剪辑编辑器", + path: "/editing-planner", + icon: React.createElement(EditOutlined), + }, ], }, { title: "资源管理", items: [ - { key: "assets", label: "素材库", path: "/assets", icon: React.createElement(FileOutlined) }, - { key: "voices", label: "配音库", path: "/voices", icon: React.createElement(AudioOutlined) }, - { key: "titles", label: "标题库", path: "/titles", icon: React.createElement(FileTextOutlined) }, - { key: "products", label: "成品库", path: "/products", icon: React.createElement(TrophyOutlined) }, - { key: "templates", label: "模板库", path: "/templates", icon: React.createElement(AppstoreOutlined) }, - { key: "my-templates", label: "我的模板", path: "/my-templates", icon: React.createElement(FolderOutlined) }, + { + key: "assets", + label: "素材库", + path: "/assets", + icon: React.createElement(FileOutlined), + }, + { + key: "voices", + label: "配音库", + path: "/voices", + icon: React.createElement(AudioOutlined), + }, + { + key: "titles", + label: "标题库", + path: "/titles", + icon: React.createElement(FileTextOutlined), + }, + { + key: "products", + label: "成品库", + path: "/products", + icon: React.createElement(TrophyOutlined), + }, + { + key: "templates", + label: "模板库", + path: "/templates", + icon: React.createElement(AppstoreOutlined), + }, + { + key: "my-templates", + label: "我的模板", + path: "/my-templates", + icon: React.createElement(FolderOutlined), + }, ], }, { title: "系统", items: [ - { key: "history", label: "任务历史", path: "/history", icon: React.createElement(HistoryOutlined) }, - { key: "duplication", label: "查重", path: "/duplication", icon: React.createElement(ScanOutlined) }, - { key: "admin", label: "控制台", path: "/admin", icon: React.createElement(ControlOutlined) }, - { key: "subscription", label: "订阅管理", path: "/subscription", icon: React.createElement(CrownOutlined) }, + { + key: "history", + label: "任务历史", + path: "/history", + icon: React.createElement(HistoryOutlined), + }, + { + key: "duplication", + label: "查重", + path: "/duplication", + icon: React.createElement(ScanOutlined), + }, + { + key: "admin", + label: "控制台", + path: "/admin", + icon: React.createElement(ControlOutlined), + }, + { + key: "subscription", + label: "订阅管理", + path: "/subscription", + icon: React.createElement(CrownOutlined), + }, ], }, ]; diff --git a/apps/web/src/hooks/useCloneProgress.ts b/apps/web/src/hooks/useCloneProgress.ts index 169bbbe81..55ee0d080 100644 --- a/apps/web/src/hooks/useCloneProgress.ts +++ b/apps/web/src/hooks/useCloneProgress.ts @@ -72,9 +72,7 @@ export const useCloneProgress = () => { /** 更新某条克隆(如改名) */ const updateClone = useCallback((updated: VoiceClone) => { - setClones((prev) => - prev.map((c) => (c.id === updated.id ? updated : c)), - ); + setClones((prev) => prev.map((c) => (c.id === updated.id ? updated : c))); }, []); return { diff --git a/apps/web/src/pages/assets/AssetLibrary.tsx b/apps/web/src/pages/assets/AssetLibrary.tsx index 67e0c8a69..bfe3b3b5c 100644 --- a/apps/web/src/pages/assets/AssetLibrary.tsx +++ b/apps/web/src/pages/assets/AssetLibrary.tsx @@ -73,7 +73,10 @@ const inferStatus = ( score?: number, classificationStatus?: string, ): { status: StatusType; label: string } => { - if (classificationStatus === "processing" || classificationStatus === "pending") { + if ( + classificationStatus === "processing" || + classificationStatus === "pending" + ) { return { status: "info", label: "处理中" }; } if (score == null) return { status: "info", label: "待诊断" }; @@ -111,7 +114,10 @@ const mapAsset = (item: ApiAssetItem): AssetItem => { thumbUrl: metadata.thumbnail_url as string | undefined, status, statusLabel: label, - duration: metadata.duration != null ? formatDuration(metadata.duration as number) : undefined, + duration: + metadata.duration != null + ? formatDuration(metadata.duration as number) + : undefined, size: item.file_size ? +(item.file_size / (1024 * 1024)).toFixed(1) : 0, createdAt: item.created_at ? new Date(item.created_at).toISOString().slice(0, 10) @@ -243,10 +249,10 @@ const AssetLibrary: React.FC = () => { const queryClient = useQueryClient(); /* ── 获取素材库列表 ── */ - const { - data: apiLibraries = [], - isLoading: libLoading, - } = useQuery({ + const { data: apiLibraries = [], isLoading: libLoading } = useQuery< + AssetLibraryItem[], + Error + >({ queryKey: ["asset-libraries"], queryFn: getAssetLibraries, staleTime: 60_000, @@ -399,7 +405,10 @@ const AssetLibrary: React.FC = () => { return; } try { - const newLib = await createLibMutation.mutateAsync({ name: newLibName.trim(), kind: newLibKind }); + const newLib = await createLibMutation.mutateAsync({ + name: newLibName.trim(), + kind: newLibKind, + }); setActiveLibId(newLib.id); setCreateModalOpen(false); setNewLibName(""); diff --git a/apps/web/src/pages/editing-planner/EditingPlanner.css b/apps/web/src/pages/editing-planner/EditingPlanner.css index cc2eddbdd..5f5b6b1ab 100644 --- a/apps/web/src/pages/editing-planner/EditingPlanner.css +++ b/apps/web/src/pages/editing-planner/EditingPlanner.css @@ -505,8 +505,12 @@ } @keyframes ep-drop-pulse { - from { opacity: 0.6; } - to { opacity: 1; } + from { + opacity: 0.6; + } + to { + opacity: 1; + } } /* 片段卡片 */ @@ -1198,7 +1202,9 @@ fill: none; stroke-width: 8; stroke-linecap: round; - transition: stroke-dashoffset 0.5s ease, stroke 0.3s ease; + transition: + stroke-dashoffset 0.5s ease, + stroke 0.3s ease; } .ep-gen-progress-pct { @@ -1229,7 +1235,9 @@ .ep-gen-progress-bar-fill { height: 100%; border-radius: 3px; - transition: width 0.5s ease, background-color 0.3s ease; + transition: + width 0.5s ease, + background-color 0.3s ease; } .ep-gen-task-id { diff --git a/apps/web/src/pages/editing-planner/EditingPlanner.tsx b/apps/web/src/pages/editing-planner/EditingPlanner.tsx index accc33f1f..c9df24ec2 100644 --- a/apps/web/src/pages/editing-planner/EditingPlanner.tsx +++ b/apps/web/src/pages/editing-planner/EditingPlanner.tsx @@ -181,7 +181,11 @@ const EditingPlanner: React.FC = () => { data, }: { id: string; - data: { name?: string; config?: Record; total_duration?: number }; + data: { + name?: string; + config?: Record; + total_duration?: number; + }; }) => updateEditPlan(id, data), onSuccess: () => { showToast("剪辑计划已更新", "success"); @@ -193,7 +197,7 @@ const EditingPlanner: React.FC = () => { const { data: taskData } = useQuery({ queryKey: ["task", taskId], queryFn: () => getTask(taskId!), - enabled: !!taskId && (genPhase === "progress"), + enabled: !!taskId && genPhase === "progress", refetchInterval: (query) => { const data = query.state.data; if (!data) return 2000; @@ -214,12 +218,7 @@ const EditingPlanner: React.FC = () => { }, [taskData]); const generateMutation = useMutation({ - mutationFn: ({ - templateId, - }: { - templateId: string; - duration: number; - }) => + mutationFn: ({ templateId }: { templateId: string; duration: number }) => createGenerationTask({ template_id: templateId, asset_ids: clips @@ -278,17 +277,14 @@ const EditingPlanner: React.FC = () => { [selectedClipId], ); - const handleReorderClips = useCallback( - (fromIdx: number, toIdx: number) => { - setClips((prev) => { - const next = [...prev]; - const [moved] = next.splice(fromIdx, 1); - next.splice(toIdx, 0, moved); - return next.map((c, i) => ({ ...c, order: i })); - }); - }, - [], - ); + const handleReorderClips = useCallback((fromIdx: number, toIdx: number) => { + setClips((prev) => { + const next = [...prev]; + const [moved] = next.splice(fromIdx, 1); + next.splice(toIdx, 0, moved); + return next.map((c, i) => ({ ...c, order: i })); + }); + }, []); const handleAssetDrop = useCallback( (asset: MediaAsset, insertIdx: number) => { @@ -373,7 +369,8 @@ const EditingPlanner: React.FC = () => { const newClips: EditPlanClip[] = tpl.segments.map((seg, i) => ({ id: newClipId(), template_segment_id: seg.id || `seg-${i}`, - material_type: (seg.material_type as EditPlanClip["material_type"]) || "video", + material_type: + (seg.material_type as EditPlanClip["material_type"]) || "video", script_text: "", duration: Math.round((seg.duration_min + seg.duration_max) / 2), transition: { type: "none", duration: 0 }, @@ -489,7 +486,8 @@ const EditingPlanner: React.FC = () => { segment_order: c.order + 1, duration_min: Math.max(1, c.duration - 3), duration_max: c.duration + 3, - material_type: c.material_type === "voiceover" ? null : c.material_type, + material_type: + c.material_type === "voiceover" ? null : c.material_type, })), }; updateMutation.mutate({ id: loadedTemplateId, data: payload }); @@ -519,12 +517,14 @@ const EditingPlanner: React.FC = () => { const handleRetry = () => { if (!taskId) return; - retryTask(taskId).then(() => { - setGenPhase("progress"); - showToast("任务已重新提交", "success"); - }).catch(() => { - showToast("重试失败", "error"); - }); + retryTask(taskId) + .then(() => { + setGenPhase("progress"); + showToast("任务已重新提交", "success"); + }) + .catch(() => { + showToast("重试失败", "error"); + }); }; const handleCloseProgressModal = () => { @@ -546,18 +546,18 @@ const EditingPlanner: React.FC = () => {
- {(["pip", "voice_over", "one_take", "voice_pip"] as TemplateMode[]).map( - (mode) => ( - - ), - )} + {( + ["pip", "voice_over", "one_take", "voice_pip"] as TemplateMode[] + ).map((mode) => ( + + ))}
@@ -568,9 +568,7 @@ const EditingPlanner: React.FC = () => { "未命名模板"} )} - {editPlanId && ( - 已保存 - )} + {editPlanId && 已保存}
@@ -178,10 +181,7 @@ const MediaPanel: React.FC = ({ {/* 底部操作 */} {loadedTemplateId && (
-
diff --git a/apps/web/src/pages/editing-planner/components/PreviewPlayer.css b/apps/web/src/pages/editing-planner/components/PreviewPlayer.css index 8b7d22e5b..3b6d3a93d 100644 --- a/apps/web/src/pages/editing-planner/components/PreviewPlayer.css +++ b/apps/web/src/pages/editing-planner/components/PreviewPlayer.css @@ -45,8 +45,13 @@ } @keyframes ep-preview-float { - 0%, 100% { transform: translateY(0); } - 50% { transform: translateY(-6px); } + 0%, + 100% { + transform: translateY(0); + } + 50% { + transform: translateY(-6px); + } } /* 文案字幕 */ diff --git a/apps/web/src/pages/editing-planner/components/PreviewPlayer.tsx b/apps/web/src/pages/editing-planner/components/PreviewPlayer.tsx index 17285dddb..a399d891b 100644 --- a/apps/web/src/pages/editing-planner/components/PreviewPlayer.tsx +++ b/apps/web/src/pages/editing-planner/components/PreviewPlayer.tsx @@ -34,10 +34,7 @@ const formatTime = (seconds: number): string => { }; /* ── 根据播放进度计算当前片段索引 ── */ -const getClipIndexAtTime = ( - clips: EditPlanClip[], - time: number, -): number => { +const getClipIndexAtTime = (clips: EditPlanClip[], time: number): number => { let elapsed = 0; for (let i = 0; i < clips.length; i++) { elapsed += clips[i].duration; @@ -47,10 +44,7 @@ const getClipIndexAtTime = ( }; /* ── 根据片段索引计算起始时间 ── */ -const getClipStartTime = ( - clips: EditPlanClip[], - clipIndex: number, -): number => { +const getClipStartTime = (clips: EditPlanClip[], clipIndex: number): number => { let time = 0; for (let i = 0; i < clipIndex; i++) { time += clips[i].duration; @@ -71,7 +65,8 @@ const PreviewPlayer: React.FC = ({ const progressRef = useRef(null); const wasPlayingRef = useRef(false); - const currentClipIndex = clips.length > 0 ? getClipIndexAtTime(clips, currentTime) : -1; + const currentClipIndex = + clips.length > 0 ? getClipIndexAtTime(clips, currentTime) : -1; const currentClip = currentClipIndex >= 0 ? clips[currentClipIndex] : null; const clipStartTime = currentClipIndex >= 0 ? getClipStartTime(clips, currentClipIndex) : 0; @@ -120,7 +115,14 @@ const PreviewPlayer: React.FC = ({ } startPlayback(); } - }, [isPlaying, currentTime, totalDuration, clips.length, startPlayback, stopPlayback]); + }, [ + isPlaying, + currentTime, + totalDuration, + clips.length, + startPlayback, + stopPlayback, + ]); /* ── 停止/重置 ── */ const handleStop = useCallback(() => { @@ -150,7 +152,10 @@ const PreviewPlayer: React.FC = ({ (clientX: number) => { if (!progressRef.current || totalDuration === 0) return; const rect = progressRef.current.getBoundingClientRect(); - const ratio = Math.max(0, Math.min(1, (clientX - rect.left) / rect.width)); + const ratio = Math.max( + 0, + Math.min(1, (clientX - rect.left) / rect.width), + ); setCurrentTime(ratio * totalDuration); }, [totalDuration], @@ -247,7 +252,9 @@ const PreviewPlayer: React.FC = ({ > {/* 片段类型图标 */}
- {currentClip ? MATERIAL_TYPE_ICONS[currentClip.material_type] || "📄" : "🎬"} + {currentClip + ? MATERIAL_TYPE_ICONS[currentClip.material_type] || "📄" + : "🎬"}
{/* 文案字幕 */} @@ -302,11 +309,7 @@ const PreviewPlayer: React.FC = ({ > {isPlaying ? "⏸" : "▶"} -
diff --git a/apps/web/src/pages/my-voices/MyVoices.tsx b/apps/web/src/pages/my-voices/MyVoices.tsx index c74316ec7..b3aa90de9 100644 --- a/apps/web/src/pages/my-voices/MyVoices.tsx +++ b/apps/web/src/pages/my-voices/MyVoices.tsx @@ -31,13 +31,20 @@ import "./my-voices.css"; * ============================================================ */ function formatDate(isoStr: string): string { const d = new Date(isoStr); - return d.toLocaleDateString("zh-CN", { year: "numeric", month: "2-digit", day: "2-digit" }); + return d.toLocaleDateString("zh-CN", { + year: "numeric", + month: "2-digit", + day: "2-digit", + }); } /* ============================================================ * 状态配置 * ============================================================ */ -const STATUS_CONFIG: Record = { +const STATUS_CONFIG: Record< + VoiceCloneStatus, + { label: string; dotClass: string } +> = { ready: { label: "就绪", dotClass: "xx-mv-status-dot--ready" }, processing: { label: "克隆中", dotClass: "xx-mv-status-dot--processing" }, failed: { label: "失败", dotClass: "xx-mv-status-dot--failed" }, @@ -118,7 +125,15 @@ const VoiceCard: React.FC = ({ buttonSize="sm" onClick={() => onTogglePlay(voice)} > - {isPlaying ? <> 暂停 : <> 试听} + {isPlaying ? ( + <> + 暂停 + + ) : ( + <> + 试听 + + )} ) : voice.status === "failed" ? (
diff --git a/apps/web/src/pages/templates/TemplateLibrary.tsx b/apps/web/src/pages/templates/TemplateLibrary.tsx index f17d1907e..69ed9bb8e 100644 --- a/apps/web/src/pages/templates/TemplateLibrary.tsx +++ b/apps/web/src/pages/templates/TemplateLibrary.tsx @@ -33,13 +33,7 @@ interface TemplateClipConfig { } /** 模板类型 */ -type EditTemplateType = - | "口播" - | "种草" - | "产品" - | "品牌" - | "混剪" - | "Vlog"; +type EditTemplateType = "口播" | "种草" | "产品" | "品牌" | "混剪" | "Vlog"; /** 模板数据(UI 层,映射自后端 TemplateItem) */ interface EditTemplate { @@ -211,10 +205,7 @@ const TemplatePreviewModal: React.FC = ({ return (
-
e.stopPropagation()} - > +
e.stopPropagation()}> {/* 关闭按钮 */}
@@ -208,20 +244,19 @@ const VoiceCard: React.FC = ({
)} {status === "failed" && ( -
- 克隆失败 -
+
克隆失败
)} - {status === "ready" && ( -
- )} + {status === "ready" &&
} {status === "ready" && (
@@ -315,7 +361,10 @@ const CloneVoiceCard: React.FC = ({ @@ -325,11 +374,15 @@ const CloneVoiceCard: React.FC = ({ {/* 头部:头像 + 名称 + 状态 */}
-
+
-

{voice.name}

+

+ {voice.name} +

{statusCfg.label} @@ -347,12 +400,11 @@ const CloneVoiceCard: React.FC = ({ {(voice.gender || voice.language) && ( - {genderText}{voice.language ? ` · ${voice.language}` : ""} + {genderText} + {voice.language ? ` · ${voice.language}` : ""} )} - - {voice.createdAt} - + {voice.createdAt}
{/* 错误信息 */} @@ -370,7 +422,10 @@ const CloneVoiceCard: React.FC = ({ @@ -400,7 +462,10 @@ const CloneVoiceCard: React.FC = ({ @@ -465,7 +539,10 @@ const CloneDetailModal: React.FC<{ {voice.createdAt}
{voice.errorMessage && ( -
+
错误 {voice.errorMessage}
@@ -474,11 +551,21 @@ const CloneDetailModal: React.FC<{
{voice.status === "failed" && ( - )} - {voice.status === "ready" && ( @@ -520,7 +607,9 @@ const VoiceLibrary: React.FC = () => { const intervalRef = useRef(null); const queryClient = useQueryClient(); - const [detailVoice, setDetailVoice] = useState(null); + const [detailVoice, setDetailVoice] = useState( + null, + ); const [toasts, setToasts] = useState([]); const [cloneModalOpen, setCloneModalOpen] = useState(false); @@ -581,7 +670,8 @@ const VoiceLibrary: React.FC = () => { [presetData], ); const clonedVoices = useMemo( - () => (cloneData?.items ?? []).map((p) => mapCloneToDisplay(toVoiceClone(p))), + () => + (cloneData?.items ?? []).map((p) => mapCloneToDisplay(toVoiceClone(p))), [cloneData], ); const presetCount = unifiedStats?.preset_count ?? presetData?.total ?? 0; @@ -607,27 +697,30 @@ const VoiceLibrary: React.FC = () => { return list; }, [presetVoices, filterGender, filterLang, searchText]); - const handlePlay = useCallback((voiceId: string, duration: number) => { - if (playingId === voiceId) return; - if (intervalRef.current) { - clearInterval(intervalRef.current); - } - setPlayingId(voiceId); - setCurrentTime(0); - intervalRef.current = window.setInterval(() => { - setCurrentTime((prev) => { - if (prev >= duration) { - if (intervalRef.current) { - clearInterval(intervalRef.current); - intervalRef.current = null; + const handlePlay = useCallback( + (voiceId: string, duration: number) => { + if (playingId === voiceId) return; + if (intervalRef.current) { + clearInterval(intervalRef.current); + } + setPlayingId(voiceId); + setCurrentTime(0); + intervalRef.current = window.setInterval(() => { + setCurrentTime((prev) => { + if (prev >= duration) { + if (intervalRef.current) { + clearInterval(intervalRef.current); + intervalRef.current = null; + } + setPlayingId(null); + return 0; } - setPlayingId(null); - return 0; - } - return prev + 0.1; - }); - }, 100); - }, [playingId]); + return prev + 0.1; + }); + }, 100); + }, + [playingId], + ); const handlePause = useCallback(() => { if (intervalRef.current) { @@ -637,12 +730,15 @@ const VoiceLibrary: React.FC = () => { setPlayingId(null); }, []); - const handleSeek = useCallback((voiceId: string, time: number, duration: number) => { - setCurrentTime(time); - if (playingId !== voiceId) { - handlePlay(voiceId, duration); - } - }, [playingId, handlePlay]); + const handleSeek = useCallback( + (voiceId: string, time: number, duration: number) => { + setCurrentTime(time); + if (playingId !== voiceId) { + handlePlay(voiceId, duration); + } + }, + [playingId, handlePlay], + ); useEffect(() => { return () => { @@ -720,7 +816,10 @@ const VoiceLibrary: React.FC = () => {
@@ -850,22 +964,29 @@ const VoiceLibrary: React.FC = () => { {/* 空状态 */} {!cloneLoading && clonedVoices.length === 0 && (
-
+
+ +

暂无克隆音色

上传音频素材即可克隆专属音色

-
)} {/* 处理中提示 */} - {!cloneLoading && clonedVoices.some((v) => v.status === "processing") && ( -
- - 部分音色正在克隆处理中,完成后将自动出现在列表中。 -
- )} + {!cloneLoading && + clonedVoices.some((v) => v.status === "processing") && ( +
+ + 部分音色正在克隆处理中,完成后将自动出现在列表中。 +
+ )}
)} diff --git a/apps/web/src/pages/voices/voices.css b/apps/web/src/pages/voices/voices.css index aefcfa3a9..8d813e616 100644 --- a/apps/web/src/pages/voices/voices.css +++ b/apps/web/src/pages/voices/voices.css @@ -138,19 +138,39 @@ /* 头像背景色(CSS 变量,无硬编码) */ .xx-voice-card.xx-voice-gender--male .xx-voice-avatar { - background: linear-gradient(135deg, var(--color-primary-700) 0%, var(--primary-color) 50%, var(--color-primary-400) 100%); + background: linear-gradient( + 135deg, + var(--color-primary-700) 0%, + var(--primary-color) 50%, + var(--color-primary-400) 100% + ); } .xx-voice-card.xx-voice-gender--female .xx-voice-avatar { - background: linear-gradient(135deg, var(--color-secondary-700, #9d174d) 0%, var(--color-secondary-500, #db2777) 50%, var(--color-secondary-400, #ec4899) 100%); + background: linear-gradient( + 135deg, + var(--color-secondary-700, #9d174d) 0%, + var(--color-secondary-500, #db2777) 50%, + var(--color-secondary-400, #ec4899) 100% + ); } .xx-voice-card.xx-voice-gender--child .xx-voice-avatar { - background: linear-gradient(135deg, var(--color-accent-700, #065f46) 0%, var(--secondary-color) 50%, var(--color-secondary-400, #34d399) 100%); + background: linear-gradient( + 135deg, + var(--color-accent-700, #065f46) 0%, + var(--secondary-color) 50%, + var(--color-secondary-400, #34d399) 100% + ); } .xx-voice-card.xx-voice-gender--elderly .xx-voice-avatar { - background: linear-gradient(135deg, var(--color-accent-700, #92400e) 0%, var(--accent-color) 50%, var(--color-accent-400, #fbbf24) 100%); + background: linear-gradient( + 135deg, + var(--color-accent-700, #92400e) 0%, + var(--accent-color) 50%, + var(--color-accent-400, #fbbf24) 100% + ); } .xx-voice-info { @@ -257,8 +277,13 @@ } @keyframes xx-pulse { - 0%, 100% { opacity: 1; } - 50% { opacity: 0.3; } + 0%, + 100% { + opacity: 1; + } + 50% { + opacity: 0.3; + } } /* 波形 */ @@ -266,17 +291,31 @@ grid-column: 1 / -1; height: 20px; border-radius: var(--radius-full); - background: repeating-linear-gradient(90deg, var(--primary-color) 0 4px, transparent 4px 8px); + background: repeating-linear-gradient( + 90deg, + var(--primary-color) 0 4px, + transparent 4px 8px + ); opacity: 0.15; transition: var(--transition-all); } -.xx-voice-card:hover .xx-voice-wave { opacity: 0.3; } -.xx-voice-card.playing .xx-voice-wave { opacity: 0.5; animation: xx-wave-pulse 0.8s ease-in-out infinite; } +.xx-voice-card:hover .xx-voice-wave { + opacity: 0.3; +} +.xx-voice-card.playing .xx-voice-wave { + opacity: 0.5; + animation: xx-wave-pulse 0.8s ease-in-out infinite; +} @keyframes xx-wave-pulse { - 0%, 100% { opacity: 0.5; } - 50% { opacity: 0.3; } + 0%, + 100% { + opacity: 0.5; + } + 50% { + opacity: 0.3; + } } /* 播放控制 */ @@ -302,8 +341,13 @@ flex-shrink: 0; } -.xx-voice-play-btn:hover { transform: scale(1.1); box-shadow: var(--shadow-primary); } -.xx-voice-play-btn:active { transform: scale(0.95); } +.xx-voice-play-btn:hover { + transform: scale(1.1); + box-shadow: var(--shadow-primary); +} +.xx-voice-play-btn:active { + transform: scale(0.95); +} .xx-voice-progress { flex: 1; @@ -314,7 +358,9 @@ cursor: pointer; } -.xx-voice-progress:hover { height: 6px; } +.xx-voice-progress:hover { + height: 6px; +} .xx-voice-progress-bar { height: 100%; @@ -452,7 +498,12 @@ width: 44px; height: 44px; border-radius: var(--radius-full); - background: linear-gradient(135deg, var(--color-primary-700) 0%, var(--primary-color) 50%, var(--color-primary-400) 100%); + background: linear-gradient( + 135deg, + var(--color-primary-700) 0%, + var(--primary-color) 50%, + var(--color-primary-400) 100% + ); display: grid; place-items: center; color: var(--text-inverse); @@ -461,13 +512,22 @@ } .xx-clone-avatar--processing { - background: linear-gradient(135deg, var(--text-tertiary), var(--text-secondary)); + background: linear-gradient( + 135deg, + var(--text-tertiary), + var(--text-secondary) + ); animation: xx-clone-pulse 2s ease-in-out infinite; } @keyframes xx-clone-pulse { - 0%, 100% { opacity: 1; } - 50% { opacity: 0.5; } + 0%, + 100% { + opacity: 1; + } + 50% { + opacity: 0.5; + } } .xx-clone-header-info { @@ -524,8 +584,13 @@ } @keyframes xx-clone-blink { - 0%, 100% { opacity: 1; } - 50% { opacity: 0.3; } + 0%, + 100% { + opacity: 1; + } + 50% { + opacity: 0.3; + } } /* 描述 */ @@ -684,8 +749,12 @@ } @keyframes xx-clone-fade-in { - from { opacity: 0; } - to { opacity: 1; } + from { + opacity: 0; + } + to { + opacity: 1; + } } .xx-clone-detail { @@ -703,8 +772,14 @@ } @keyframes xx-clone-scale-in { - from { opacity: 0; transform: scale(0.95); } - to { opacity: 1; transform: scale(1); } + from { + opacity: 0; + transform: scale(0.95); + } + to { + opacity: 1; + transform: scale(1); + } } .xx-clone-detail-close { @@ -809,38 +884,97 @@ animation: xx-skeleton-shimmer 1.5s ease-in-out infinite; } -.xx-skeleton-clone-line--name { width: 60%; } -.xx-skeleton-clone-line--status { width: 30%; height: 20px; } -.xx-skeleton-clone-line--desc { width: 90%; } -.xx-skeleton-clone-line--meta { width: 45%; } -.xx-skeleton-clone-line--footer { width: 100%; height: 32px; margin-top: auto; } +.xx-skeleton-clone-line--name { + width: 60%; +} +.xx-skeleton-clone-line--status { + width: 30%; + height: 20px; +} +.xx-skeleton-clone-line--desc { + width: 90%; +} +.xx-skeleton-clone-line--meta { + width: 45%; +} +.xx-skeleton-clone-line--footer { + width: 100%; + height: 32px; + margin-top: auto; +} @keyframes xx-skeleton-shimmer { - 0% { opacity: 0.5; } - 50% { opacity: 1; } - 100% { opacity: 0.5; } + 0% { + opacity: 0.5; + } + 50% { + opacity: 1; + } + 100% { + opacity: 0.5; + } } /* 响应式 */ @media (max-width: 1200px) { - .xx-voice-grid { grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); } + .xx-voice-grid { + grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); + } } @media (max-width: 768px) { - .xx-voices-page { padding: var(--space-md); gap: var(--space-md); } - .xx-voice-grid { grid-template-columns: 1fr; } - .xx-voice-card { padding: var(--space-sm); gap: var(--space-xs) var(--space-sm); } - .xx-voices-filters { flex-direction: column; align-items: stretch; } - .xx-voices-filters .xx-input, .xx-voices-filters .xx-select { width: 100% !important; } - .xx-voices-tab { padding: var(--space-xs) var(--space-md); font-size: var(--font-size-sm); } + .xx-voices-page { + padding: var(--space-md); + gap: var(--space-md); + } + .xx-voice-grid { + grid-template-columns: 1fr; + } + .xx-voice-card { + padding: var(--space-sm); + gap: var(--space-xs) var(--space-sm); + } + .xx-voices-filters { + flex-direction: column; + align-items: stretch; + } + .xx-voices-filters .xx-input, + .xx-voices-filters .xx-select { + width: 100% !important; + } + .xx-voices-tab { + padding: var(--space-xs) var(--space-md); + font-size: var(--font-size-sm); + } } @media (max-width: 480px) { - .xx-voices-page { padding: var(--space-sm); } - .xx-voice-avatar { width: 40px; height: 40px; font-size: 16px; } - .xx-voice-card { grid-template-columns: 40px 1fr; } - .xx-voice-wave { height: 14px; } - .xx-voice-play-btn { width: 28px; height: 28px; font-size: var(--font-size-sm); } - .xx-voices-tab { padding: var(--space-xs) var(--space-sm); font-size: var(--font-size-xs); } - .xx-voices-tab-count { min-width: 16px; height: 16px; font-size: 10px; } + .xx-voices-page { + padding: var(--space-sm); + } + .xx-voice-avatar { + width: 40px; + height: 40px; + font-size: 16px; + } + .xx-voice-card { + grid-template-columns: 40px 1fr; + } + .xx-voice-wave { + height: 14px; + } + .xx-voice-play-btn { + width: 28px; + height: 28px; + font-size: var(--font-size-sm); + } + .xx-voices-tab { + padding: var(--space-xs) var(--space-sm); + font-size: var(--font-size-xs); + } + .xx-voices-tab-count { + min-width: 16px; + height: 16px; + font-size: 10px; + } } diff --git a/apps/web/vite.config.ts b/apps/web/vite.config.ts index 61df8dfcc..bd23a065c 100644 --- a/apps/web/vite.config.ts +++ b/apps/web/vite.config.ts @@ -1,9 +1,9 @@ /** * 性能优化配置 */ -import { defineConfig, configDefaults } from 'vitest/config'; -import react from '@vitejs/plugin-react'; -import path from 'path'; +import { defineConfig, configDefaults } from "vitest/config"; +import react from "@vitejs/plugin-react"; +import path from "path"; // https://vitejs.dev/config/ @@ -21,14 +21,14 @@ export default defineConfig({ ], resolve: { alias: { - '@': path.resolve(__dirname, './src'), + "@": path.resolve(__dirname, "./src"), }, }, server: { port: 3000, proxy: { - '/api': { - target: 'http://localhost:8000', + "/api": { + target: "http://localhost:8000", changeOrigin: true, }, }, @@ -40,13 +40,13 @@ export default defineConfig({ rollupOptions: { output: { manualChunks: { - 'react-vendor': ['react', 'react-dom', 'react-router-dom'], - 'antd-vendor': ['antd', '@ant-design/icons'], - 'state-vendor': ['zustand', '@tanstack/react-query', 'axios'], + "react-vendor": ["react", "react-dom", "react-router-dom"], + "antd-vendor": ["antd", "@ant-design/icons"], + "state-vendor": ["zustand", "@tanstack/react-query", "axios"], }, }, }, - minify: 'esbuild', + minify: "esbuild", sourcemap: false, chunkSizeWarningLimit: 1000, }, @@ -59,14 +59,14 @@ export default defineConfig({ }, optimizeDeps: { include: [ - 'react', - 'react-dom', - 'react-router-dom', - 'antd', - '@ant-design/icons', - 'zustand', - '@tanstack/react-query', - 'axios', + "react", + "react-dom", + "react-router-dom", + "antd", + "@ant-design/icons", + "zustand", + "@tanstack/react-query", + "axios", ], }, }); diff --git a/apps/web/vitest.config.ts b/apps/web/vitest.config.ts index 39cbd9f58..aea7ef72d 100644 --- a/apps/web/vitest.config.ts +++ b/apps/web/vitest.config.ts @@ -1,31 +1,31 @@ /** * Vitest 配置文件 */ -import { defineConfig } from 'vitest/config'; -import react from '@vitejs/plugin-react'; -import path from 'path'; +import { defineConfig } from "vitest/config"; +import react from "@vitejs/plugin-react"; +import path from "path"; export default defineConfig({ plugins: [react()], test: { globals: true, - environment: 'jsdom', - setupFiles: './src/test/setup.ts', + environment: "jsdom", + setupFiles: "./src/test/setup.ts", coverage: { - provider: 'v8', - reporter: ['text', 'json', 'html'], + provider: "v8", + reporter: ["text", "json", "html"], exclude: [ - 'node_modules/', - 'src/test/', - '**/*.d.ts', - '**/*.config.*', - '**/mockData', + "node_modules/", + "src/test/", + "**/*.d.ts", + "**/*.config.*", + "**/mockData", ], }, }, resolve: { alias: { - '@': path.resolve(__dirname, './src'), + "@": path.resolve(__dirname, "./src"), }, }, }); diff --git a/apps/worker/video_processing/dedup.py b/apps/worker/video_processing/dedup.py index 978c0e648..137585563 100644 --- a/apps/worker/video_processing/dedup.py +++ b/apps/worker/video_processing/dedup.py @@ -197,14 +197,17 @@ class VideoDeduplicator: phash_similarity = 1.0 - (avg_distance / 64) - return {"duplicate": True, "duplicate_of": existing.id, "reason": "phash_similar", "similarity": phash_similarity} + return { + "duplicate": True, + "duplicate_of": existing.id, + "reason": "phash_similar", + "similarity": phash_similarity, + } return None @staticmethod - def _average_histogram_similarity( - histograms_a: list[list[float]], histograms_b: list[list[float]] - ) -> float: + def _average_histogram_similarity(histograms_a: list[list[float]], histograms_b: list[list[float]]) -> float: """ 计算两组颜色直方图之间的平均余弦相似度。 diff --git a/apps/worker/video_processing/editing_modes.py b/apps/worker/video_processing/editing_modes.py index 73dbce39f..006037248 100644 --- a/apps/worker/video_processing/editing_modes.py +++ b/apps/worker/video_processing/editing_modes.py @@ -439,7 +439,9 @@ class EditingModeProcessor: try: os.remove(temp_file) except Exception as e: - logger.warning(f"Operation failed in apps/worker/video_processing/editing_modes.py: {e}", exc_info=True) + logger.warning( + f"Operation failed in apps/worker/video_processing/editing_modes.py: {e}", exc_info=True + ) return output_path diff --git a/apps/worker/video_processing/video_compose_service.py b/apps/worker/video_processing/video_compose_service.py index 56e13d42e..3c95f5572 100755 --- a/apps/worker/video_processing/video_compose_service.py +++ b/apps/worker/video_processing/video_compose_service.py @@ -23,7 +23,17 @@ ALLOWED_OUTPUT_DIRS = ["/tmp/video_output", "/var/app/rendered"] ALLOWED_INPUT_PREFIXES = ("s3://", "oss://", "local://", "/var/storage/") # 允许的转场效果白名单 -ALLOWED_TRANSITIONS = {"fade", "slideleft", "slideright", "dissolve", "wipeleft", "wiperight", "cut", "slideup", "slidedown"} +ALLOWED_TRANSITIONS = { + "fade", + "slideleft", + "slideright", + "dissolve", + "wipeleft", + "wiperight", + "cut", + "slideup", + "slidedown", +} # 转场效果映射 _XFADE_TRANSITION_MAP = { @@ -41,6 +51,7 @@ _XFADE_TRANSITION_MAP = { class VideoComposeError(Exception): """视频合成服务异常""" + pass @@ -56,6 +67,7 @@ class PIPPosition(StrEnum): @dataclass class Clip: """视频片段""" + asset_id: str # 资源ID,对应输入路径 start_time: float = 0.0 duration: float = 0.0 @@ -97,15 +109,15 @@ class VideoComposeService: def _validate_output_path(self, path: str) -> str: """ 校验输出路径是否在允许范围内 (P0 修复) - + 防止路径穿越攻击,如 /app/config/../../../etc/passwd - + Args: path: 用户提供的输出路径 - + Returns: 标准化后的绝对路径 - + Raises: ValueError: 路径不在允许范围内 """ @@ -119,10 +131,10 @@ class VideoComposeService: def _validate_input_path(self, path: str) -> bool: """ 校验输入路径格式是否合法 (P1-1 修复) - + Args: path: 输入文件路径 - + Returns: 是否合法 """ @@ -131,10 +143,10 @@ class VideoComposeService: def _validate_transition(self, transition: str) -> str: """ 校验转场效果是否在白名单内 (P1-2 修复) - + Args: transition: 转场效果名称 - + Returns: 安全的转场效果名称 """ @@ -150,11 +162,11 @@ class VideoComposeService: def compose(self, clips: list[Clip], output_path: Optional[str] = None) -> str: """ 合成视频 - + Args: clips: 视频片段列表,每个片段包含 asset_id 和转场配置 output_path: 输出文件路径 - + Returns: 输出文件路径 """ @@ -169,7 +181,7 @@ class VideoComposeService: # 生成默认输出路径并校验 if output_path is None: output_path = self._generate_output_path() - + # P0: 校验输出路径 validated_output = self._validate_output_path(output_path) @@ -177,7 +189,7 @@ class VideoComposeService: # 获取输入路径列表 input_paths = [clip.asset_id for clip in clips] - + try: if self.config.mode == EditingMode.ONE_TAKE: return self._one_take(input_paths, validated_output, clips) @@ -231,14 +243,24 @@ class VideoComposeService: try: result = subprocess.run( [ - self._ffprobe_bin, "-v", "error", - "-show_entries", "stream=width,height,r_frame_rate,duration,codec_name", - "-show_entries", "format=duration,size", - "-of", "json", video_path, + self._ffprobe_bin, + "-v", + "error", + "-show_entries", + "stream=width,height,r_frame_rate,duration,codec_name", + "-show_entries", + "format=duration,size", + "-of", + "json", + video_path, ], - check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, + check=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, ) import json + data = json.loads(result.stdout) streams = data.get("streams", [{}]) video_stream = next((s for s in streams if s.get("codec_type") == "video"), streams[0] if streams else {}) @@ -260,7 +282,9 @@ class VideoComposeService: logger.warning(f"获取视频信息失败 {video_path}: {e}") return {"width": 0, "height": 0, "fps": 25, "duration": 0, "codec": "unknown", "size": 0} - def _get_pip_position_offset(self, main_width: int, main_height: int, pip_width: int, pip_height: int) -> tuple[int, int]: + def _get_pip_position_offset( + self, main_width: int, main_height: int, pip_width: int, pip_height: int + ) -> tuple[int, int]: """获取画中画位置偏移量""" margin = 10 position_offsets = { @@ -274,16 +298,28 @@ class VideoComposeService: def _normalize_video(self, input_path: str, output_path: str) -> dict: """标准化视频格式""" command = [ - self._ffmpeg_bin, "-y", "-i", input_path, - "-r", str(self.config.output_fps), - "-vf", f"scale={self.config.output_width}:{self.config.output_height}:force_original_aspect_ratio=decrease,pad={self.config.output_width}:{self.config.output_height}:(ow-iw)/2:(oh-ih)/2,setsar=1", - "-r", str(self.config.output_fps), - "-c:v", self.config.output_codec, - "-preset", self.config.output_preset, - "-crf", str(self.config.output_crf), - "-pix_fmt", "yuv420p", - "-movflags", "+faststart", - "-an", output_path, + self._ffmpeg_bin, + "-y", + "-i", + input_path, + "-r", + str(self.config.output_fps), + "-vf", + f"scale={self.config.output_width}:{self.config.output_height}:force_original_aspect_ratio=decrease,pad={self.config.output_width}:{self.config.output_height}:(ow-iw)/2:(oh-ih)/2,setsar=1", + "-r", + str(self.config.output_fps), + "-c:v", + self.config.output_codec, + "-preset", + self.config.output_preset, + "-crf", + str(self.config.output_crf), + "-pix_fmt", + "yuv420p", + "-movflags", + "+faststart", + "-an", + output_path, ] self._run_ffmpeg(command) return self._get_video_info(output_path) @@ -311,27 +347,45 @@ class VideoComposeService: if p != output_path: os.remove(p) except Exception as e: - logger.warning(f"Operation failed in apps/worker/video_processing/video_compose_service.py: {e}", exc_info=True) + logger.warning( + f"Operation failed in apps/worker/video_processing/video_compose_service.py: {e}", exc_info=True + ) return output_path - def _one_take_with_xfade(self, normalized_paths: list[str], durations: list[float], output_path: str, clips: list[Clip]) -> str: + def _one_take_with_xfade( + self, normalized_paths: list[str], durations: list[float], output_path: str, clips: list[Clip] + ) -> str: """使用 xfade 滤镜实现转场 (P1-2: 转场参数白名单校验)""" if len(normalized_paths) == 2: # 获取当前片段的转场效果并校验白名单 transition = "fade" if len(clips) > 1: transition = self._get_validated_transition(clips[1].transition) - + trans_duration = self.config.transition_duration offset1 = durations[0] - trans_duration / 2 command = [ - self._ffmpeg_bin, "-y", "-i", normalized_paths[0], "-i", normalized_paths[1], - "-filter_complex", f"[0:v][1:v]xfade=transition={transition}:duration={trans_duration}:offset={offset1}[v]", - "-map", "[v]", - "-c:v", self.config.output_codec, "-preset", self.config.output_preset, - "-crf", str(self.config.output_crf), "-pix_fmt", "yuv420p", output_path, + self._ffmpeg_bin, + "-y", + "-i", + normalized_paths[0], + "-i", + normalized_paths[1], + "-filter_complex", + f"[0:v][1:v]xfade=transition={transition}:duration={trans_duration}:offset={offset1}[v]", + "-map", + "[v]", + "-c:v", + self.config.output_codec, + "-preset", + self.config.output_preset, + "-crf", + str(self.config.output_crf), + "-pix_fmt", + "yuv420p", + output_path, ] self._run_ffmpeg(command) return output_path @@ -346,15 +400,26 @@ class VideoComposeService: f.write(f"file '{os.path.abspath(path)}'\n") command = [ - self._ffmpeg_bin, "-y", "-f", "concat", "-safe", "0", - "-i", concat_file, "-c", "copy", output_path, + self._ffmpeg_bin, + "-y", + "-f", + "concat", + "-safe", + "0", + "-i", + concat_file, + "-c", + "copy", + output_path, ] self._run_ffmpeg(command) try: os.remove(concat_file) except Exception as e: - logger.warning(f"Operation failed in apps/worker/video_processing/video_compose_service.py: {e}", exc_info=True) + logger.warning( + f"Operation failed in apps/worker/video_processing/video_compose_service.py: {e}", exc_info=True + ) return output_path @@ -373,7 +438,9 @@ class VideoComposeService: pip_width = int(self.config.output_width * self.config.pip_scale) pip_height = int(self.config.output_height * self.config.pip_scale) - x_offset, y_offset = self._get_pip_position_offset(self.config.output_width, self.config.output_height, pip_width, pip_height) + x_offset, y_offset = self._get_pip_position_offset( + self.config.output_width, self.config.output_height, pip_width, pip_height + ) pip_normalized = os.path.join(self.work_dir, f"pip_{os.getpid()}.mp4") pip_info = self._get_video_info(video_paths[1]) @@ -381,19 +448,43 @@ class VideoComposeService: if pip_info["duration"] > main_info["duration"]: temp_pip = os.path.join(self.work_dir, f"pip_temp_{os.getpid()}.mp4") command = [ - self._ffmpeg_bin, "-y", "-i", video_paths[1], "-t", str(main_info["duration"]), - "-vf", f"scale={pip_width}:{pip_height}", - "-c:v", self.config.output_codec, "-preset", self.config.output_preset, - "-crf", str(self.config.output_crf), "-pix_fmt", "yuv420p", temp_pip, + self._ffmpeg_bin, + "-y", + "-i", + video_paths[1], + "-t", + str(main_info["duration"]), + "-vf", + f"scale={pip_width}:{pip_height}", + "-c:v", + self.config.output_codec, + "-preset", + self.config.output_preset, + "-crf", + str(self.config.output_crf), + "-pix_fmt", + "yuv420p", + temp_pip, ] self._run_ffmpeg(command) pip_normalized_input = temp_pip else: command = [ - self._ffmpeg_bin, "-y", "-i", video_paths[1], - "-vf", f"scale={pip_width}:{pip_height}", - "-c:v", self.config.output_codec, "-preset", self.config.output_preset, - "-crf", str(self.config.output_crf), "-pix_fmt", "yuv420p", pip_normalized, + self._ffmpeg_bin, + "-y", + "-i", + video_paths[1], + "-vf", + f"scale={pip_width}:{pip_height}", + "-c:v", + self.config.output_codec, + "-preset", + self.config.output_preset, + "-crf", + str(self.config.output_crf), + "-pix_fmt", + "yuv420p", + pip_normalized, ] self._run_ffmpeg(command) pip_normalized_input = pip_normalized @@ -401,21 +492,49 @@ class VideoComposeService: if main_info["duration"] > pip_info["duration"]: looped_pip = os.path.join(self.work_dir, f"pip_looped_{os.getpid()}.mp4") command = [ - self._ffmpeg_bin, "-y", "-stream_loop", "-1", "-i", pip_normalized_input, - "-t", str(main_info["duration"]), - "-vf", f"scale={pip_width}:{pip_height}", - "-c:v", self.config.output_codec, "-preset", self.config.output_preset, - "-crf", str(self.config.output_crf), "-pix_fmt", "yuv420p", looped_pip, + self._ffmpeg_bin, + "-y", + "-stream_loop", + "-1", + "-i", + pip_normalized_input, + "-t", + str(main_info["duration"]), + "-vf", + f"scale={pip_width}:{pip_height}", + "-c:v", + self.config.output_codec, + "-preset", + self.config.output_preset, + "-crf", + str(self.config.output_crf), + "-pix_fmt", + "yuv420p", + looped_pip, ] self._run_ffmpeg(command) pip_normalized_input = looped_pip command = [ - self._ffmpeg_bin, "-y", "-i", main_normalized, "-i", pip_normalized_input, - "-filter_complex", f"[0:v][1:v]overlay={x_offset}:{y_offset}[v]", - "-map", "[v]", - "-c:v", self.config.output_codec, "-preset", self.config.output_preset, - "-crf", str(self.config.output_crf), "-pix_fmt", "yuv420p", output_path, + self._ffmpeg_bin, + "-y", + "-i", + main_normalized, + "-i", + pip_normalized_input, + "-filter_complex", + f"[0:v][1:v]overlay={x_offset}:{y_offset}[v]", + "-map", + "[v]", + "-c:v", + self.config.output_codec, + "-preset", + self.config.output_preset, + "-crf", + str(self.config.output_crf), + "-pix_fmt", + "yuv420p", + output_path, ] self._run_ffmpeg(command) @@ -424,7 +543,9 @@ class VideoComposeService: try: os.remove(temp_file) except Exception as e: - logger.warning(f"Operation failed in apps/worker/video_processing/video_compose_service.py: {e}", exc_info=True) + logger.warning( + f"Operation failed in apps/worker/video_processing/video_compose_service.py: {e}", exc_info=True + ) return output_path @@ -445,38 +566,87 @@ class VideoComposeService: if bg_info["duration"] < audio_duration: looped_bg = os.path.join(self.work_dir, f"bg_looped_{os.getpid()}.mp4") command = [ - self._ffmpeg_bin, "-y", "-stream_loop", "-1", "-i", bg_normalized, - "-t", str(audio_duration), - "-vf", f"scale={self.config.output_width}:{self.config.output_height}", - "-c:v", self.config.output_codec, "-preset", self.config.output_preset, - "-crf", str(self.config.output_crf), "-pix_fmt", "yuv420p", looped_bg, + self._ffmpeg_bin, + "-y", + "-stream_loop", + "-1", + "-i", + bg_normalized, + "-t", + str(audio_duration), + "-vf", + f"scale={self.config.output_width}:{self.config.output_height}", + "-c:v", + self.config.output_codec, + "-preset", + self.config.output_preset, + "-crf", + str(self.config.output_crf), + "-pix_fmt", + "yuv420p", + looped_bg, ] self._run_ffmpeg(command) bg_normalized = looped_bg elif bg_info["duration"] > audio_duration: temp_bg = os.path.join(self.work_dir, f"bg_trimmed_{os.getpid()}.mp4") command = [ - self._ffmpeg_bin, "-y", "-i", bg_normalized, "-t", str(audio_duration), - "-c:v", "copy", temp_bg, + self._ffmpeg_bin, + "-y", + "-i", + bg_normalized, + "-t", + str(audio_duration), + "-c:v", + "copy", + temp_bg, ] self._run_ffmpeg(command) bg_normalized = temp_bg blurred_bg = os.path.join(self.work_dir, f"bg_blurred_{os.getpid()}.mp4") command = [ - self._ffmpeg_bin, "-y", "-i", bg_normalized, - "-vf", f"boxblur=5:5,scale={self.config.output_width}:{self.config.output_height}", - "-c:v", self.config.output_codec, "-preset", self.config.output_preset, - "-crf", str(self.config.output_crf), "-pix_fmt", "yuv420p", blurred_bg, + self._ffmpeg_bin, + "-y", + "-i", + bg_normalized, + "-vf", + f"boxblur=5:5,scale={self.config.output_width}:{self.config.output_height}", + "-c:v", + self.config.output_codec, + "-preset", + self.config.output_preset, + "-crf", + str(self.config.output_crf), + "-pix_fmt", + "yuv420p", + blurred_bg, ] self._run_ffmpeg(command) command = [ - self._ffmpeg_bin, "-y", "-i", blurred_bg, "-i", audio_path, - "-filter_complex", "[0:v]drawbox=x=0:y=0:w=iw:h=ih:color=black@0.3:t=fill[v]", - "-map", "[v]", "-map", "1:a", - "-c:v", self.config.output_codec, "-preset", self.config.output_preset, - "-crf", str(self.config.output_crf), "-pix_fmt", "yuv420p", "-shortest", output_path, + self._ffmpeg_bin, + "-y", + "-i", + blurred_bg, + "-i", + audio_path, + "-filter_complex", + "[0:v]drawbox=x=0:y=0:w=iw:h=ih:color=black@0.3:t=fill[v]", + "-map", + "[v]", + "-map", + "1:a", + "-c:v", + self.config.output_codec, + "-preset", + self.config.output_preset, + "-crf", + str(self.config.output_crf), + "-pix_fmt", + "yuv420p", + "-shortest", + output_path, ] self._run_ffmpeg(command) @@ -485,7 +655,9 @@ class VideoComposeService: if temp_file != output_path: os.remove(temp_file) except Exception as e: - logger.warning(f"Operation failed in apps/worker/video_processing/video_compose_service.py: {e}", exc_info=True) + logger.warning( + f"Operation failed in apps/worker/video_processing/video_compose_service.py: {e}", exc_info=True + ) return output_path @@ -510,39 +682,97 @@ class VideoComposeService: pip_width = int(self.config.output_width * self.config.pip_scale) pip_height = int(self.config.output_height * self.config.pip_scale) - x_offset, y_offset = self._get_pip_position_offset(self.config.output_width, self.config.output_height, pip_width, pip_height) + x_offset, y_offset = self._get_pip_position_offset( + self.config.output_width, self.config.output_height, pip_width, pip_height + ) voice_adjusted = os.path.join(self.work_dir, f"voice_adj_{os.getpid()}.mp4") command = [ - self._ffmpeg_bin, "-y", "-i", voice_normalized, "-t", str(final_duration), - "-vf", f"scale={pip_width}:{pip_height}", - "-c:v", self.config.output_codec, "-preset", self.config.output_preset, - "-crf", str(self.config.output_crf), "-pix_fmt", "yuv420p", voice_adjusted, + self._ffmpeg_bin, + "-y", + "-i", + voice_normalized, + "-t", + str(final_duration), + "-vf", + f"scale={pip_width}:{pip_height}", + "-c:v", + self.config.output_codec, + "-preset", + self.config.output_preset, + "-crf", + str(self.config.output_crf), + "-pix_fmt", + "yuv420p", + voice_adjusted, ] self._run_ffmpeg(command) bg_adjusted = os.path.join(self.work_dir, f"bg_adj_{os.getpid()}.mp4") command = [ - self._ffmpeg_bin, "-y", "-i", bg_normalized, "-t", str(final_duration), - "-c:v", "copy", bg_adjusted, + self._ffmpeg_bin, + "-y", + "-i", + bg_normalized, + "-t", + str(final_duration), + "-c:v", + "copy", + bg_adjusted, ] self._run_ffmpeg(command) if audio_path: command = [ - self._ffmpeg_bin, "-y", "-i", bg_adjusted, "-i", voice_adjusted, "-i", audio_path, - "-filter_complex", f"[0:v][1:v]overlay={x_offset}:{y_offset}[v]", - "-map", "[v]", "-map", "2:a", "-shortest", - "-c:v", self.config.output_codec, "-preset", self.config.output_preset, - "-crf", str(self.config.output_crf), "-pix_fmt", "yuv420p", output_path, + self._ffmpeg_bin, + "-y", + "-i", + bg_adjusted, + "-i", + voice_adjusted, + "-i", + audio_path, + "-filter_complex", + f"[0:v][1:v]overlay={x_offset}:{y_offset}[v]", + "-map", + "[v]", + "-map", + "2:a", + "-shortest", + "-c:v", + self.config.output_codec, + "-preset", + self.config.output_preset, + "-crf", + str(self.config.output_crf), + "-pix_fmt", + "yuv420p", + output_path, ] else: command = [ - self._ffmpeg_bin, "-y", "-i", bg_adjusted, "-i", voice_adjusted, - "-filter_complex", f"[0:v][1:v]overlay={x_offset}:{y_offset}[v]", - "-map", "[v]", "-map", "1:a", "-shortest", - "-c:v", self.config.output_codec, "-preset", self.config.output_preset, - "-crf", str(self.config.output_crf), "-pix_fmt", "yuv420p", output_path, + self._ffmpeg_bin, + "-y", + "-i", + bg_adjusted, + "-i", + voice_adjusted, + "-filter_complex", + f"[0:v][1:v]overlay={x_offset}:{y_offset}[v]", + "-map", + "[v]", + "-map", + "1:a", + "-shortest", + "-c:v", + self.config.output_codec, + "-preset", + self.config.output_preset, + "-crf", + str(self.config.output_crf), + "-pix_fmt", + "yuv420p", + output_path, ] self._run_ffmpeg(command) @@ -551,7 +781,9 @@ class VideoComposeService: if temp_file != output_path: os.remove(temp_file) except Exception as e: - logger.warning(f"Operation failed in apps/worker/video_processing/video_compose_service.py: {e}", exc_info=True) + logger.warning( + f"Operation failed in apps/worker/video_processing/video_compose_service.py: {e}", exc_info=True + ) return output_path diff --git a/apps/worker/worker_app/tasks/edit_plan_generation.py b/apps/worker/worker_app/tasks/edit_plan_generation.py index 9d31c2710..b77eb4f58 100644 --- a/apps/worker/worker_app/tasks/edit_plan_generation.py +++ b/apps/worker/worker_app/tasks/edit_plan_generation.py @@ -108,9 +108,12 @@ def _probe_duration(local_path: Path) -> float: result = subprocess.run( # nosec B603 [ FFPROBE_BIN, - "-v", "error", - "-show_entries", "format=duration", - "-of", "default=noprint_wrappers=1:nokey=1", + "-v", + "error", + "-show_entries", + "format=duration", + "-of", + "default=noprint_wrappers=1:nokey=1", str(local_path), ], check=True, @@ -152,10 +155,14 @@ def _concatenate_clips( command = [ FFMPEG_BIN, "-y", - "-f", "concat", - "-safe", "0", - "-i", str(concat_file), - "-c", "copy", + "-f", + "concat", + "-safe", + "0", + "-i", + str(concat_file), + "-c", + "copy", str(output_path), ] _run_ffmpeg(command) @@ -345,7 +352,9 @@ def render_edit_plan(self, plan_id: str) -> dict: plan.mark_failed() plan_repo.update(plan) except Exception as e: - logger.warning(f"Operation failed in apps/worker/worker_app/tasks/edit_plan_generation.py: {e}", exc_info=True) + logger.warning( + f"Operation failed in apps/worker/worker_app/tasks/edit_plan_generation.py: {e}", exc_info=True + ) raise self.retry(exc=exc, countdown=60) return {"status": "error", "message": "数据库连接失败"} diff --git a/apps/worker/worker_app/tasks/tts_synthesis.py b/apps/worker/worker_app/tasks/tts_synthesis.py index e8ce6c816..e8f5d5e1f 100644 --- a/apps/worker/worker_app/tasks/tts_synthesis.py +++ b/apps/worker/worker_app/tasks/tts_synthesis.py @@ -40,16 +40,14 @@ def process_tts_synthesis(self: Task, job_id: str) -> dict: session = SessionLocal() repo = SQLAlchemyTTSJobRepository(session) workflow = TTSWorkflowService( - repository=repo, cosyvoice_service=CosyVoiceService(), + repository=repo, + cosyvoice_service=CosyVoiceService(), ) updated_job = workflow.poll_and_process_synthesis(job_id, timeout=120) session.commit() - logger.info( - f"TTS synthesis completed: job_id={job_id}, " - f"audio_url={updated_job.output_audio_url}" - ) + logger.info(f"TTS synthesis completed: job_id={job_id}, " f"audio_url={updated_job.output_audio_url}") return { "ok": True, "job_id": job_id, diff --git a/apps/worker/worker_app/tasks/voice_clone.py b/apps/worker/worker_app/tasks/voice_clone.py index 8375054cf..ecf3abab6 100644 --- a/apps/worker/worker_app/tasks/voice_clone.py +++ b/apps/worker/worker_app/tasks/voice_clone.py @@ -47,16 +47,14 @@ def process_voice_clone(self: Task, profile_id: str) -> dict: session = SessionLocal() repo = SQLAlchemyVoiceCloneProfileRepository(session) workflow = VoiceCloneWorkflowService( - repository=repo, cosyvoice_service=CosyVoiceService(), + repository=repo, + cosyvoice_service=CosyVoiceService(), ) updated_profile = workflow.poll_and_process_clone(profile_id, timeout=300) session.commit() - logger.info( - f"Voice clone completed: profile_id={profile_id}, " - f"voice_id={updated_profile.voice_id}" - ) + logger.info(f"Voice clone completed: profile_id={profile_id}, " f"voice_id={updated_profile.voice_id}") return { "ok": True, "profile_id": profile_id, diff --git a/packages/adapters/sqlalchemy_impl/asset_repository.py b/packages/adapters/sqlalchemy_impl/asset_repository.py index a8cd61a07..e0eef97a9 100644 --- a/packages/adapters/sqlalchemy_impl/asset_repository.py +++ b/packages/adapters/sqlalchemy_impl/asset_repository.py @@ -153,11 +153,7 @@ class SQLAlchemyAssetRepository: query = query.filter(AssetModel.status == status) if classification_category is not None: # classification_result 是 JSON Text,用 LIKE 匹配 category 字段 - query = query.filter( - AssetModel.classification_result.like( - f'%"{classification_category}"%' - ) - ) + query = query.filter(AssetModel.classification_result.like(f'%"{classification_category}"%')) query = query.order_by(AssetModel.quality_score.desc().nullslast()) if limit > 0: query = query.limit(limit) @@ -166,10 +162,7 @@ class SQLAlchemyAssetRepository: # 内存中过滤 tags(tags 存在 metadata 中) if tags: tag_set = set(tags) - candidates = [ - a for a in candidates - if tag_set.issubset(set(a.metadata.get("tags", []))) - ] + candidates = [a for a in candidates if tag_set.issubset(set(a.metadata.get("tags", [])))] return candidates def _to_domain(self, model: AssetModel) -> Asset: diff --git a/packages/adapters/sqlalchemy_impl/billing_repository.py b/packages/adapters/sqlalchemy_impl/billing_repository.py index afee7cb61..56e749eee 100644 --- a/packages/adapters/sqlalchemy_impl/billing_repository.py +++ b/packages/adapters/sqlalchemy_impl/billing_repository.py @@ -43,6 +43,7 @@ class SQLAlchemyBillingRepository: def update_subscription_on_payment(self, user_id: str, plan: str, expires_at: datetime) -> None: """在支付成功后更新用户订阅状态(事务内调用)""" from packages.adapters.sqlalchemy_impl.models import UserModel + model = self.session.get(UserModel, user_id) if model: model.subscription_plan = plan diff --git a/packages/adapters/sqlalchemy_impl/edit_plan_clip_repository.py b/packages/adapters/sqlalchemy_impl/edit_plan_clip_repository.py index 286aa4e53..5e51b9dcc 100644 --- a/packages/adapters/sqlalchemy_impl/edit_plan_clip_repository.py +++ b/packages/adapters/sqlalchemy_impl/edit_plan_clip_repository.py @@ -36,11 +36,7 @@ class SQLAlchemyEditPlanClipRepository: def get(self, clip_id: str) -> Optional[EditPlanClip]: """根据 ID 获取片段""" - model = ( - self.session.query(EditPlanClipModel) - .filter(EditPlanClipModel.id == clip_id) - .first() - ) + model = self.session.query(EditPlanClipModel).filter(EditPlanClipModel.id == clip_id).first() if model is None: return None return self._model_to_entity(model) @@ -68,11 +64,7 @@ class SQLAlchemyEditPlanClipRepository: def update(self, clip: EditPlanClip) -> EditPlanClip: """更新片段""" - model = ( - self.session.query(EditPlanClipModel) - .filter(EditPlanClipModel.id == clip.id) - .first() - ) + model = self.session.query(EditPlanClipModel).filter(EditPlanClipModel.id == clip.id).first() if model is None: raise ValueError(f"EditPlanClip {clip.id} not found") model.plan_id = clip.plan_id @@ -93,11 +85,7 @@ class SQLAlchemyEditPlanClipRepository: def delete(self, clip_id: str) -> bool: """删除片段""" - model = ( - self.session.query(EditPlanClipModel) - .filter(EditPlanClipModel.id == clip_id) - .first() - ) + model = self.session.query(EditPlanClipModel).filter(EditPlanClipModel.id == clip_id).first() if model is None: return False self.session.delete(model) @@ -106,11 +94,7 @@ class SQLAlchemyEditPlanClipRepository: def delete_by_plan(self, plan_id: str) -> int: """删除计划下所有片段,返回删除数量""" - count = ( - self.session.query(EditPlanClipModel) - .filter(EditPlanClipModel.plan_id == plan_id) - .delete() - ) + count = self.session.query(EditPlanClipModel).filter(EditPlanClipModel.plan_id == plan_id).delete() self.session.commit() return count diff --git a/packages/adapters/sqlalchemy_impl/edit_plan_repository.py b/packages/adapters/sqlalchemy_impl/edit_plan_repository.py index 9cb9e5d16..8dc930a40 100644 --- a/packages/adapters/sqlalchemy_impl/edit_plan_repository.py +++ b/packages/adapters/sqlalchemy_impl/edit_plan_repository.py @@ -51,11 +51,7 @@ class SQLAlchemyEditPlanRepository: def get(self, plan_id: str) -> Optional[EditPlan]: """根据 ID 获取计划""" - model = ( - self.session.query(EditPlanModel) - .filter(EditPlanModel.id == plan_id) - .first() - ) + model = self.session.query(EditPlanModel).filter(EditPlanModel.id == plan_id).first() if model is None: return None return self._model_to_entity(model) @@ -77,11 +73,7 @@ class SQLAlchemyEditPlanRepository: def update(self, plan: EditPlan) -> EditPlan: """更新计划""" - model = ( - self.session.query(EditPlanModel) - .filter(EditPlanModel.id == plan.id) - .first() - ) + model = self.session.query(EditPlanModel).filter(EditPlanModel.id == plan.id).first() if model is None: raise ValueError(f"EditPlan {plan.id} not found") model.template_id = plan.template_id @@ -96,11 +88,7 @@ class SQLAlchemyEditPlanRepository: def delete(self, plan_id: str) -> bool: """删除计划""" - model = ( - self.session.query(EditPlanModel) - .filter(EditPlanModel.id == plan_id) - .first() - ) + model = self.session.query(EditPlanModel).filter(EditPlanModel.id == plan_id).first() if model is None: return False self.session.delete(model) diff --git a/packages/adapters/sqlalchemy_impl/edit_template_repository.py b/packages/adapters/sqlalchemy_impl/edit_template_repository.py index 40463e8ef..f0c0e4691 100644 --- a/packages/adapters/sqlalchemy_impl/edit_template_repository.py +++ b/packages/adapters/sqlalchemy_impl/edit_template_repository.py @@ -59,11 +59,7 @@ class SQLAlchemyEditTemplateRepository: def get(self, template_id: str) -> Optional[EditTemplate]: """根据 ID 获取模板""" - model = ( - self.session.query(EditTemplateModel) - .filter(EditTemplateModel.id == template_id) - .first() - ) + model = self.session.query(EditTemplateModel).filter(EditTemplateModel.id == template_id).first() if model is None: return None return self._model_to_entity(model) @@ -87,11 +83,7 @@ class SQLAlchemyEditTemplateRepository: def update(self, template: EditTemplate) -> EditTemplate: """更新模板""" - model = ( - self.session.query(EditTemplateModel) - .filter(EditTemplateModel.id == template.id) - .first() - ) + model = self.session.query(EditTemplateModel).filter(EditTemplateModel.id == template.id).first() if model is None: raise ValueError(f"EditTemplate {template.id} not found") model.name = template.name @@ -108,11 +100,7 @@ class SQLAlchemyEditTemplateRepository: def delete(self, template_id: str) -> bool: """删除模板""" - model = ( - self.session.query(EditTemplateModel) - .filter(EditTemplateModel.id == template_id) - .first() - ) + model = self.session.query(EditTemplateModel).filter(EditTemplateModel.id == template_id).first() if model is None: return False self.session.delete(model) diff --git a/packages/adapters/sqlalchemy_impl/job_repository.py b/packages/adapters/sqlalchemy_impl/job_repository.py index ee2276846..3603ccfd5 100755 --- a/packages/adapters/sqlalchemy_impl/job_repository.py +++ b/packages/adapters/sqlalchemy_impl/job_repository.py @@ -125,9 +125,7 @@ class SQLAlchemyJobRepository: limit: int = 50, offset: int = 0, ) -> list[Job]: - query = self.session.query(JobModel).filter( - JobModel.created_by_user_id == user_id - ) + query = self.session.query(JobModel).filter(JobModel.created_by_user_id == user_id) if job_type is not None: jt = job_type.value if isinstance(job_type, JobType) else job_type query = query.filter(JobModel.job_type == jt) diff --git a/packages/adapters/sqlalchemy_impl/models.py b/packages/adapters/sqlalchemy_impl/models.py index 966b598f7..2e6ea73a1 100755 --- a/packages/adapters/sqlalchemy_impl/models.py +++ b/packages/adapters/sqlalchemy_impl/models.py @@ -408,6 +408,7 @@ class TemplateCategoryModel(Base): name = Column(String(100), nullable=False) created_at = Column(DateTime, nullable=False, default=lambda: datetime.now(timezone.utc)) + class JobModel(Base): """Phase 8 任务 2.10 — 统一异步任务 ORM 模型。""" @@ -461,8 +462,10 @@ class TTSJobModel(Base): created_at = Column(DateTime, nullable=False, default=lambda: datetime.now(timezone.utc)) updated_at = Column(DateTime, nullable=False, default=lambda: datetime.now(timezone.utc)) + class BillingRecordModel(Base): """账单记录""" + __tablename__ = "billing_records" id = Column(String(36), primary_key=True) diff --git a/packages/adapters/sqlalchemy_impl/template_clip_config_repository.py b/packages/adapters/sqlalchemy_impl/template_clip_config_repository.py index 0452e2da4..4e06c01a8 100644 --- a/packages/adapters/sqlalchemy_impl/template_clip_config_repository.py +++ b/packages/adapters/sqlalchemy_impl/template_clip_config_repository.py @@ -40,11 +40,7 @@ class SQLAlchemyTemplateClipConfigRepository: def get(self, config_id: str) -> Optional[TemplateClipConfig]: """根据 ID 获取配置""" - model = ( - self.session.query(TemplateClipConfigModel) - .filter(TemplateClipConfigModel.id == config_id) - .first() - ) + model = self.session.query(TemplateClipConfigModel).filter(TemplateClipConfigModel.id == config_id).first() if model is None: return None return self._model_to_entity(model) @@ -70,11 +66,7 @@ class SQLAlchemyTemplateClipConfigRepository: def update(self, config: TemplateClipConfig) -> TemplateClipConfig: """更新配置""" - model = ( - self.session.query(TemplateClipConfigModel) - .filter(TemplateClipConfigModel.id == config.id) - .first() - ) + model = self.session.query(TemplateClipConfigModel).filter(TemplateClipConfigModel.id == config.id).first() if model is None: raise ValueError(f"TemplateClipConfig {config.id} not found") model.template_id = config.template_id @@ -93,11 +85,7 @@ class SQLAlchemyTemplateClipConfigRepository: def delete(self, config_id: str) -> bool: """删除配置""" - model = ( - self.session.query(TemplateClipConfigModel) - .filter(TemplateClipConfigModel.id == config_id) - .first() - ) + model = self.session.query(TemplateClipConfigModel).filter(TemplateClipConfigModel.id == config_id).first() if model is None: return False self.session.delete(model) @@ -132,9 +120,9 @@ class SQLAlchemyTemplateClipConfigRepository: max_duration=model.max_duration or 0.0, text_template=model.text_template or "", material_requirements=model.material_requirements or {}, - transition_effect=TransitionEffect(model.transition_effect) - if model.transition_effect - else TransitionEffect.CUT, + transition_effect=( + TransitionEffect(model.transition_effect) if model.transition_effect else TransitionEffect.CUT + ), config=model.config or {}, created_at=model.created_at, updated_at=model.updated_at, diff --git a/packages/adapters/sqlalchemy_impl/tts_job_repository.py b/packages/adapters/sqlalchemy_impl/tts_job_repository.py index be4cba6bf..e70c84a45 100644 --- a/packages/adapters/sqlalchemy_impl/tts_job_repository.py +++ b/packages/adapters/sqlalchemy_impl/tts_job_repository.py @@ -58,11 +58,7 @@ class SQLAlchemyTTSJobRepository: return self._model_to_entity(model) def update(self, job: TTSJob) -> TTSJob: - model = ( - self.session.query(TTSJobModel) - .filter(TTSJobModel.id == job.id) - .first() - ) + model = self.session.query(TTSJobModel).filter(TTSJobModel.id == job.id).first() if model is None: raise ValueError(f"TTSJob {job.id} not found") model.input_text = job.input_text @@ -88,11 +84,7 @@ class SQLAlchemyTTSJobRepository: return self._model_to_entity(model) def delete(self, job_id: str) -> bool: - model = ( - self.session.query(TTSJobModel) - .filter(TTSJobModel.id == job_id) - .first() - ) + model = self.session.query(TTSJobModel).filter(TTSJobModel.id == job_id).first() if model is None: return False model.status = "deleted" @@ -118,12 +110,9 @@ class SQLAlchemyTTSJobRepository: return [self._model_to_entity(m) for m in models] def count_by_user(self, user_id: str, *, status: Optional[str] = None) -> int: - query = ( - self.session.query(TTSJobModel) - .filter( - TTSJobModel.user_id == user_id, - TTSJobModel.status != "deleted", - ) + query = self.session.query(TTSJobModel).filter( + TTSJobModel.user_id == user_id, + TTSJobModel.status != "deleted", ) if status: query = query.filter(TTSJobModel.status == status) diff --git a/packages/adapters/sqlalchemy_impl/voice_clone_profile_repository.py b/packages/adapters/sqlalchemy_impl/voice_clone_profile_repository.py index 821cb125c..9f1d20be8 100644 --- a/packages/adapters/sqlalchemy_impl/voice_clone_profile_repository.py +++ b/packages/adapters/sqlalchemy_impl/voice_clone_profile_repository.py @@ -52,11 +52,7 @@ class SQLAlchemyVoiceCloneProfileRepository: return self._model_to_entity(model) def update(self, profile: VoiceCloneProfile) -> VoiceCloneProfile: - model = ( - self.session.query(VoiceCloneProfileModel) - .filter(VoiceCloneProfileModel.id == profile.id) - .first() - ) + model = self.session.query(VoiceCloneProfileModel).filter(VoiceCloneProfileModel.id == profile.id).first() if model is None: raise ValueError(f"VoiceCloneProfile {profile.id} not found") model.name = profile.name @@ -76,11 +72,7 @@ class SQLAlchemyVoiceCloneProfileRepository: return self._model_to_entity(model) def delete(self, profile_id: str) -> bool: - model = ( - self.session.query(VoiceCloneProfileModel) - .filter(VoiceCloneProfileModel.id == profile_id) - .first() - ) + model = self.session.query(VoiceCloneProfileModel).filter(VoiceCloneProfileModel.id == profile_id).first() if model is None: return False model.status = "deleted" @@ -106,12 +98,9 @@ class SQLAlchemyVoiceCloneProfileRepository: return [self._model_to_entity(m) for m in models] def count_by_user(self, user_id: str, *, status: Optional[str] = None) -> int: - query = ( - self.session.query(VoiceCloneProfileModel) - .filter( - VoiceCloneProfileModel.user_id == user_id, - VoiceCloneProfileModel.status != "deleted", - ) + query = self.session.query(VoiceCloneProfileModel).filter( + VoiceCloneProfileModel.user_id == user_id, + VoiceCloneProfileModel.status != "deleted", ) if status: query = query.filter(VoiceCloneProfileModel.status == status) diff --git a/packages/adapters/sqlalchemy_impl/voice_library_repository.py b/packages/adapters/sqlalchemy_impl/voice_library_repository.py index 673d438c8..636c5c7c2 100644 --- a/packages/adapters/sqlalchemy_impl/voice_library_repository.py +++ b/packages/adapters/sqlalchemy_impl/voice_library_repository.py @@ -111,12 +111,9 @@ class SQLAlchemyVoiceLibraryRepository: return True def count_by_user(self, user_id: str, *, status: Optional[str] = None) -> int: - query = ( - self.session.query(VoiceLibraryModel) - .filter( - VoiceLibraryModel.user_id == user_id, - VoiceLibraryModel.status != "deleted", - ) + query = self.session.query(VoiceLibraryModel).filter( + VoiceLibraryModel.user_id == user_id, + VoiceLibraryModel.status != "deleted", ) if status: query = query.filter(VoiceLibraryModel.status == status) diff --git a/packages/application/auth/jwt_service.py b/packages/application/auth/jwt_service.py index 08eae9e24..0a17c8c90 100644 --- a/packages/application/auth/jwt_service.py +++ b/packages/application/auth/jwt_service.py @@ -191,6 +191,7 @@ class JWTService: return payload + # 全局实例(生产环境必须从配置读取有效的 secret_key) # jwt_service = JWTService() # 不再允许无参数实例化 diff --git a/packages/application/cosyvoice_service.py b/packages/application/cosyvoice_service.py index 79c72a6f4..265c298da 100644 --- a/packages/application/cosyvoice_service.py +++ b/packages/application/cosyvoice_service.py @@ -196,9 +196,7 @@ class CosyVoiceService: request_id = response.get("request_id", "") if not task_id and not voice_id: - raise CosyVoiceError( - f"CosyVoice API 未返回 task_id 或 voice_id: {response}" - ) + raise CosyVoiceError(f"CosyVoice API 未返回 task_id 或 voice_id: {response}") return { "task_id": task_id, @@ -331,9 +329,7 @@ class CosyVoiceService: request_id=response.get("request_id", ""), ) else: - raise CosyVoiceError( - f"CosyVoice API 未返回 task_id 或 voice_id: {response}" - ) + raise CosyVoiceError(f"CosyVoice API 未返回 task_id 或 voice_id: {response}") def _poll_clone_task(self, task_id: str, timeout: float) -> dict: """轮询音色克隆任务状态。 @@ -355,9 +351,7 @@ class CosyVoiceService: while attempts < self.MAX_POLL_ATTEMPTS: elapsed = time.time() - start_time if elapsed > timeout: - raise CosyVoiceTimeoutError( - f"音色克隆任务超时({timeout}秒): task_id={task_id}" - ) + raise CosyVoiceTimeoutError(f"音色克隆任务超时({timeout}秒): task_id={task_id}") response = self._call_api( method="GET", @@ -371,9 +365,7 @@ class CosyVoiceService: if status == "SUCCEEDED": voice_id = output.get("voice_id", "") if not voice_id: - raise CosyVoiceError( - f"音色克隆任务成功但未返回 voice_id: {response}" - ) + raise CosyVoiceError(f"音色克隆任务成功但未返回 voice_id: {response}") return {"voice_id": voice_id} elif status == "FAILED": error_msg = output.get("message", "未知错误") @@ -385,9 +377,7 @@ class CosyVoiceService: else: raise CosyVoiceError(f"未知的任务状态: {status}") - raise CosyVoiceTimeoutError( - f"音色克隆任务轮询次数超限: task_id={task_id}" - ) + raise CosyVoiceTimeoutError(f"音色克隆任务轮询次数超限: task_id={task_id}") # ── 语音合成 ───────────────────────────────────────── @@ -455,9 +445,7 @@ class CosyVoiceService: request_id = response.get("request_id", "") if not task_id and not audio_url: - raise CosyVoiceError( - f"CosyVoice API 未返回 task_id 或 audio_url: {response}" - ) + raise CosyVoiceError(f"CosyVoice API 未返回 task_id 或 audio_url: {response}") return { "task_id": task_id, @@ -571,9 +559,7 @@ class CosyVoiceService: request_id=response.get("request_id", ""), ) else: - raise CosyVoiceError( - f"CosyVoice API 未返回 audio_url 或 task_id: {response}" - ) + raise CosyVoiceError(f"CosyVoice API 未返回 audio_url 或 task_id: {response}") def _poll_synthesize_task(self, task_id: str, timeout: float) -> dict: """轮询语音合成任务状态。 @@ -595,9 +581,7 @@ class CosyVoiceService: while attempts < self.MAX_POLL_ATTEMPTS: elapsed = time.time() - start_time if elapsed > timeout: - raise CosyVoiceTimeoutError( - f"语音合成任务超时({timeout}秒): task_id={task_id}" - ) + raise CosyVoiceTimeoutError(f"语音合成任务超时({timeout}秒): task_id={task_id}") response = self._call_api( method="GET", @@ -611,9 +595,7 @@ class CosyVoiceService: if status == "SUCCEEDED": audio_url = output.get("audio_url", "") if not audio_url: - raise CosyVoiceError( - f"语音合成任务成功但未返回 audio_url: {response}" - ) + raise CosyVoiceError(f"语音合成任务成功但未返回 audio_url: {response}") return { "audio_url": audio_url, "duration": output.get("duration", 0.0), @@ -629,9 +611,7 @@ class CosyVoiceService: else: raise CosyVoiceError(f"未知的任务状态: {status}") - raise CosyVoiceTimeoutError( - f"语音合成任务轮询次数超限: task_id={task_id}" - ) + raise CosyVoiceTimeoutError(f"语音合成任务轮询次数超限: task_id={task_id}") # ── 内部方法 ───────────────────────────────────────── @@ -682,35 +662,25 @@ class CosyVoiceService: if response.status_code == 200: return response.json() elif response.status_code in (401, 403): - raise CosyVoiceAuthError( - f"CosyVoice API 认证失败: HTTP {response.status_code}" - ) + raise CosyVoiceAuthError(f"CosyVoice API 认证失败: HTTP {response.status_code}") elif response.status_code >= 500: # 服务端错误,可重试 - last_error = CosyVoiceError( - f"CosyVoice API 服务端错误: HTTP {response.status_code}" - ) + last_error = CosyVoiceError(f"CosyVoice API 服务端错误: HTTP {response.status_code}") logger.warning( - f"CosyVoice API 失败 (尝试 {attempt + 1}/{self.MAX_RETRIES}): " - f"HTTP {response.status_code}" + f"CosyVoice API 失败 (尝试 {attempt + 1}/{self.MAX_RETRIES}): " f"HTTP {response.status_code}" ) else: # 客户端错误,不重试 raise CosyVoiceError( - f"CosyVoice API 调用失败: HTTP {response.status_code}, " - f"body={response.text}" + f"CosyVoice API 调用失败: HTTP {response.status_code}, " f"body={response.text}" ) except httpx.TimeoutException as e: last_error = CosyVoiceTimeoutError(f"请求超时: {e}") - logger.warning( - f"CosyVoice API 超时 (尝试 {attempt + 1}/{self.MAX_RETRIES})" - ) + logger.warning(f"CosyVoice API 超时 (尝试 {attempt + 1}/{self.MAX_RETRIES})") except httpx.RequestError as e: last_error = CosyVoiceError(f"请求错误: {e}") - logger.warning( - f"CosyVoice API 请求错误 (尝试 {attempt + 1}/{self.MAX_RETRIES}): {e}" - ) + logger.warning(f"CosyVoice API 请求错误 (尝试 {attempt + 1}/{self.MAX_RETRIES}): {e}") # 指数退避 if attempt < self.MAX_RETRIES - 1: diff --git a/packages/application/jobs.py b/packages/application/jobs.py index ffec662f3..44a0127d0 100755 --- a/packages/application/jobs.py +++ b/packages/application/jobs.py @@ -80,7 +80,9 @@ class CreateJobUseCase: ) logger.info( "创建任务: job_id=%s type=%s project=%s", - job.id, job.job_type.value, job.project_id, + job.id, + job.job_type.value, + job.project_id, ) return self._job_repo.create(job) @@ -223,13 +225,19 @@ class ListJobsUseCase: ) -> list[Job]: if project_id: return self._job_repo.list_by_project( - project_id, job_type=job_type, status=status, - limit=limit, offset=offset, + project_id, + job_type=job_type, + status=status, + limit=limit, + offset=offset, ) if user_id: return self._job_repo.list_by_user( - user_id, job_type=job_type, status=status, - limit=limit, offset=offset, + user_id, + job_type=job_type, + status=status, + limit=limit, + offset=offset, ) raise ValueError("必须指定 project_id 或 user_id") diff --git a/packages/application/tts_job/use_cases.py b/packages/application/tts_job/use_cases.py index 063acbc40..ab943f49c 100644 --- a/packages/application/tts_job/use_cases.py +++ b/packages/application/tts_job/use_cases.py @@ -64,9 +64,7 @@ class ListTTSJobsUseCase: skip: int = 0, limit: int = 50, ) -> tuple[List[TTSJob], int]: - items = self.repository.list_by_user( - user_id, status=status, limit=limit, offset=skip - ) + items = self.repository.list_by_user(user_id, status=status, limit=limit, offset=skip) total = self.repository.count_by_user(user_id, status=status) return items, total diff --git a/packages/application/tts_job/workflow.py b/packages/application/tts_job/workflow.py index 682b99e2e..b49e60eef 100644 --- a/packages/application/tts_job/workflow.py +++ b/packages/application/tts_job/workflow.py @@ -103,17 +103,12 @@ class TTSWorkflowService: ) job.metadata = job_metadata job = self.repository.update(job) - logger.info( - f"TTS 合成同步完成: job_id={job.id}, audio_url={audio_url}" - ) + logger.info(f"TTS 合成同步完成: job_id={job.id}, audio_url={audio_url}") return job job.metadata = job_metadata job = self.repository.update(job) - logger.info( - f"TTS 合成任务已提交: job_id={job.id}, " - f"task_id={submit_result.get('task_id')}" - ) + logger.info(f"TTS 合成任务已提交: job_id={job.id}, " f"task_id={submit_result.get('task_id')}") except (CosyVoiceError, CosyVoiceAuthError) as e: job.mark_failed(str(e)) @@ -126,9 +121,7 @@ class TTSWorkflowService: return job - def poll_and_process_synthesis( - self, job_id: str, timeout: float = 120.0 - ) -> TTSJob: + def poll_and_process_synthesis(self, job_id: str, timeout: float = 120.0) -> TTSJob: """轮询 CosyVoice 合成任务并处理结果。 从 job.metadata 获取 task_id,调用 CosyVoiceService.poll_synthesize_task() @@ -142,9 +135,7 @@ class TTSWorkflowService: task_id = (job.metadata or {}).get("cosyvoice_task_id", "") if not task_id: - raise ValueError( - f"TTSJob {job_id} has no cosyvoice_task_id in metadata" - ) + raise ValueError(f"TTSJob {job_id} has no cosyvoice_task_id in metadata") result = self.cosyvoice_service.poll_synthesize_task(task_id, timeout=timeout) return self.process_synthesis_result( @@ -189,9 +180,7 @@ class TTSWorkflowService: logger.info(f"TTS 合成成功: job_id={job_id}, audio_url={audio_url}") return job - def process_synthesis_failure( - self, job_id: str, error_message: str - ) -> TTSJob: + def process_synthesis_failure(self, job_id: str, error_message: str) -> TTSJob: """处理合成失败结果。 Args: diff --git a/packages/application/voice_clone/use_cases.py b/packages/application/voice_clone/use_cases.py index f8adc4e48..90e5ba39b 100644 --- a/packages/application/voice_clone/use_cases.py +++ b/packages/application/voice_clone/use_cases.py @@ -68,9 +68,7 @@ class ListVoiceClonesUseCase: skip: int = 0, limit: int = 50, ) -> tuple[List[VoiceCloneProfile], int]: - items = self.repository.list_by_user( - user_id, status=status, limit=limit, offset=skip - ) + items = self.repository.list_by_user(user_id, status=status, limit=limit, offset=skip) total = self.repository.count_by_user(user_id, status=status) return items, total @@ -125,8 +123,6 @@ class RetryVoiceCloneUseCase: if profile is None or profile.user_id != user_id: raise VoiceCloneNotFoundError(f"Voice clone {clone_id} not found") if not profile.is_retryable: - raise VoiceCloneNotRetryableError( - f"Voice clone {clone_id} is not retryable (status={profile.status})" - ) + raise VoiceCloneNotRetryableError(f"Voice clone {clone_id} is not retryable (status={profile.status})") profile.prepare_retry() return self.repository.update(profile) diff --git a/packages/application/voice_clone/workflow.py b/packages/application/voice_clone/workflow.py index 52dcd52fa..2aa4e14b7 100644 --- a/packages/application/voice_clone/workflow.py +++ b/packages/application/voice_clone/workflow.py @@ -118,9 +118,7 @@ class VoiceCloneWorkflowService: # 4. 保存 task_id / voice_id 到 metadata task_metadata = dict(profile.metadata) task_metadata["cosyvoice_task_id"] = submit_result.get("task_id", "") - task_metadata["cosyvoice_request_id"] = submit_result.get( - "request_id", "" - ) + task_metadata["cosyvoice_request_id"] = submit_result.get("request_id", "") # 如果 CosyVoice 同步返回了 voice_id,直接标记 ready voice_id = submit_result.get("voice_id", "") @@ -128,17 +126,12 @@ class VoiceCloneWorkflowService: profile.mark_ready(voice_id) profile.metadata = task_metadata profile = self.repository.update(profile) - logger.info( - f"音色克隆同步完成: profile_id={profile.id}, voice_id={voice_id}" - ) + logger.info(f"音色克隆同步完成: profile_id={profile.id}, voice_id={voice_id}") return profile profile.metadata = task_metadata profile = self.repository.update(profile) - logger.info( - f"音色克隆任务已提交: profile_id={profile.id}, " - f"task_id={submit_result.get('task_id')}" - ) + logger.info(f"音色克隆任务已提交: profile_id={profile.id}, " f"task_id={submit_result.get('task_id')}") except (CosyVoiceError, CosyVoiceAuthError) as e: # CosyVoice 提交失败,标记为 failed @@ -153,15 +146,11 @@ class VoiceCloneWorkflowService: return profile else: # 没有音频 URL,保持 pending 状态等待用户上传 - logger.info( - f"音色克隆已创建但无音频URL,保持pending: profile_id={profile.id}" - ) + logger.info(f"音色克隆已创建但无音频URL,保持pending: profile_id={profile.id}") return profile - def poll_and_process_clone( - self, profile_id: str, timeout: float = 300.0 - ) -> VoiceCloneProfile: + def poll_and_process_clone(self, profile_id: str, timeout: float = 300.0) -> VoiceCloneProfile: """轮询 CosyVoice 克隆任务并处理结果。 从 profile.metadata 获取 task_id,调用 CosyVoiceService.poll_clone_task() @@ -174,9 +163,7 @@ class VoiceCloneWorkflowService: raise VoiceCloneNotFoundError(f"Voice clone {profile_id} not found") task_id = (profile.metadata or {}).get("cosyvoice_task_id", "") if not task_id: - raise ValueError( - f"VoiceCloneProfile {profile_id} has no cosyvoice_task_id in metadata" - ) + raise ValueError(f"VoiceCloneProfile {profile_id} has no cosyvoice_task_id in metadata") result = self.cosyvoice_service.poll_clone_task(task_id, timeout=timeout) return self.process_clone_result(profile_id, result["voice_id"]) @@ -202,9 +189,7 @@ class VoiceCloneWorkflowService: logger.info(f"音色克隆成功: profile_id={profile_id}, voice_id={voice_id}") return profile - def process_clone_failure( - self, profile_id: str, error_message: str - ) -> VoiceCloneProfile: + def process_clone_failure(self, profile_id: str, error_message: str) -> VoiceCloneProfile: """处理克隆失败结果。 Args: @@ -264,9 +249,7 @@ class VoiceCloneWorkflowService: task_metadata = dict(profile.metadata) task_metadata["cosyvoice_task_id"] = submit_result.get("task_id", "") - task_metadata["cosyvoice_request_id"] = submit_result.get( - "request_id", "" - ) + task_metadata["cosyvoice_request_id"] = submit_result.get("request_id", "") voice_id = submit_result.get("voice_id", "") if voice_id: diff --git a/packages/application/voice_library/use_cases.py b/packages/application/voice_library/use_cases.py index c81ee51a6..a63bc81f2 100644 --- a/packages/application/voice_library/use_cases.py +++ b/packages/application/voice_library/use_cases.py @@ -28,7 +28,9 @@ class ListVoiceLibraryUseCase: ) -> tuple[List[VoiceLibraryItem], int]: """返回 (items, total_count),避免调用方再单独查一次 count。""" items = self.repository.list_by_user(user_id, status=status, skip=skip, limit=limit) - total = self.repository.count_by_user(user_id, status=status) if status else self.repository.count_by_user(user_id) + total = ( + self.repository.count_by_user(user_id, status=status) if status else self.repository.count_by_user(user_id) + ) return items, total diff --git a/packages/domain/edit_plan_clip.py b/packages/domain/edit_plan_clip.py index b725b4643..15171c23c 100644 --- a/packages/domain/edit_plan_clip.py +++ b/packages/domain/edit_plan_clip.py @@ -85,9 +85,7 @@ class EditPlanClip: plan_id=plan_id.strip(), clip_type=clip_type.strip(), order=order, - template_clip_config_id=template_clip_config_id.strip() - if template_clip_config_id - else "", + template_clip_config_id=template_clip_config_id.strip() if template_clip_config_id else "", asset_id=asset_id.strip() if asset_id else "", text_content=text_content.strip(), start_time=start_time, @@ -107,27 +105,21 @@ class EditPlanClip: def mark_ready(self) -> None: """标记为就绪""" if self.status != EditPlanClipStatus.PENDING: - raise ValueError( - f"只有 pending 状态的片段可以标记就绪,当前状态: {self.status}" - ) + raise ValueError(f"只有 pending 状态的片段可以标记就绪,当前状态: {self.status}") self.status = EditPlanClipStatus.READY self.updated_at = datetime.now(timezone.utc) def mark_rendered(self) -> None: """标记为已渲染""" if self.status != EditPlanClipStatus.READY: - raise ValueError( - f"只有 ready 状态的片段可以标记已渲染,当前状态: {self.status}" - ) + raise ValueError(f"只有 ready 状态的片段可以标记已渲染,当前状态: {self.status}") self.status = EditPlanClipStatus.RENDERED self.updated_at = datetime.now(timezone.utc) def mark_failed(self) -> None: """标记为失败""" if self.status != EditPlanClipStatus.READY: - raise ValueError( - f"只有 ready 状态的片段可以标记失败,当前状态: {self.status}" - ) + raise ValueError(f"只有 ready 状态的片段可以标记失败,当前状态: {self.status}") self.status = EditPlanClipStatus.FAILED self.updated_at = datetime.now(timezone.utc) diff --git a/tests/integration/test_api.py b/tests/integration/test_api.py index c6a88cb4b..60c59c029 100755 --- a/tests/integration/test_api.py +++ b/tests/integration/test_api.py @@ -15,6 +15,7 @@ _HAS_PG = False try: if os.environ.get("USE_IN_MEMORY_DB", "").lower() != "true": import psycopg + conn = psycopg.connect( os.environ.get( "DATABASE_URL", diff --git a/tests/integration/test_auth.py b/tests/integration/test_auth.py index fad9467a8..fb6ea7350 100755 --- a/tests/integration/test_auth.py +++ b/tests/integration/test_auth.py @@ -16,6 +16,7 @@ _HAS_PG = False try: if os.environ.get("USE_IN_MEMORY_DB", "").lower() != "true": import psycopg + conn = psycopg.connect( os.environ.get( "DATABASE_URL", diff --git a/tests/integration/test_duplication_api.py b/tests/integration/test_duplication_api.py index fff29d1b7..76485e334 100644 --- a/tests/integration/test_duplication_api.py +++ b/tests/integration/test_duplication_api.py @@ -24,7 +24,6 @@ import pytest from fastapi import FastAPI from fastapi.testclient import TestClient - # --------------------------------------------------------------------------- # 1. 安装 mock 模块(复用 test_duplication_upload_error_handling 的模式) # --------------------------------------------------------------------------- @@ -183,7 +182,9 @@ def _install_mocks(): sys.modules[name] = types.ModuleType(name) sys.modules["packages.adapters.sqlalchemy_impl.user_repository"].SQLAlchemyUserRepository = MagicMock - sys.modules["packages.adapters.sqlalchemy_impl.duplication_repository"].SQLAlchemyDuplicationRecordRepository = MagicMock + sys.modules["packages.adapters.sqlalchemy_impl.duplication_repository"].SQLAlchemyDuplicationRecordRepository = ( + MagicMock + ) sys.modules["packages.adapters.sqlalchemy_impl.session"].build_session_factory = MagicMock( return_value=(MagicMock(), MagicMock()) ) @@ -535,6 +536,7 @@ def client(repo): def _override_storage(): from app.core.storage import OSSStorageService + return OSSStorageService() app.dependency_overrides[duplication.get_current_user] = _override_current_user diff --git a/tests/integration/test_duplication_upload_error_handling.py b/tests/integration/test_duplication_upload_error_handling.py index a3dd7a94b..5a9f225ef 100644 --- a/tests/integration/test_duplication_upload_error_handling.py +++ b/tests/integration/test_duplication_upload_error_handling.py @@ -334,7 +334,9 @@ def _install_mocks(): sys.modules.setdefault("app.schemas", types.ModuleType("app.schemas")) sys.modules["app.schemas"].duplication = dup_schemas_mod except Exception as e: - logger.warning(f"Operation failed in tests/integration/test_duplication_upload_error_handling.py: {e}", exc_info=True) + logger.warning( + f"Operation failed in tests/integration/test_duplication_upload_error_handling.py: {e}", exc_info=True + ) return User, AuthenticatedUser diff --git a/tests/integration/test_error_scenarios.py b/tests/integration/test_error_scenarios.py index 193131386..65324bc8b 100755 --- a/tests/integration/test_error_scenarios.py +++ b/tests/integration/test_error_scenarios.py @@ -201,9 +201,7 @@ class TestForbidden403: f"/api/v1/projects/{project_id}", headers=other_auth_headers, ) - assert response.status_code in [403, 404], ( - f"访问他人项目应返回 403 或 404,实际: {response.status_code}" - ) + assert response.status_code in [403, 404], f"访问他人项目应返回 403 或 404,实际: {response.status_code}" def test_delete_other_user_project(self, auth_headers, other_auth_headers): """删除他人项目应返回 403 或 404。""" @@ -448,9 +446,7 @@ class TestLargeDataRequests: headers=auth_headers, ) # 应返回 422(超过长度限制)或 400 - assert response.status_code in [400, 413, 422], ( - f"超长名称应被拒绝,实际: {response.status_code}" - ) + assert response.status_code in [400, 413, 422], f"超长名称应被拒绝,实际: {response.status_code}" def test_create_project_with_large_description(self, auth_headers): """超大描述应能处理(或拒绝)。""" @@ -461,9 +457,7 @@ class TestLargeDataRequests: headers=auth_headers, ) # 可能被接受或被拒绝,但不应 500 - assert response.status_code < 500, ( - f"超大描述不应导致 500,实际: {response.status_code}" - ) + assert response.status_code < 500, f"超大描述不应导致 500,实际: {response.status_code}" def test_register_with_oversized_payload(self): """超大注册请求体应返回 413 或 422,而非 500。""" @@ -477,9 +471,7 @@ class TestLargeDataRequests: "/api/v1/auth/register", json=huge_payload, ) - assert response.status_code < 500, ( - f"超大请求体不应导致 500,实际: {response.status_code}" - ) + assert response.status_code < 500, f"超大请求体不应导致 500,实际: {response.status_code}" def test_rapid_sequential_requests(self, auth_headers): """快速连续请求不应触发限流导致 500。""" @@ -489,9 +481,7 @@ class TestLargeDataRequests: statuses.append(resp.status_code) # 所有请求应返回正常状态码(200 或限流 429),不应 500 - assert all(s < 500 for s in statuses), ( - f"快速连续请求不应产生 500,状态码: {statuses}" - ) + assert all(s < 500 for s in statuses), f"快速连续请求不应产生 500,状态码: {statuses}" if __name__ == "__main__": diff --git a/tests/unit/test_auto_clip_service.py b/tests/unit/test_auto_clip_service.py index f21c03d7b..3c3d88ef4 100644 --- a/tests/unit/test_auto_clip_service.py +++ b/tests/unit/test_auto_clip_service.py @@ -16,7 +16,6 @@ import pytest from app.services.auto_clip_service import AutoClipService, ClipAssignDetail - # ── Stub 实体 ───────────────────────────────────────────────────────────────── @@ -297,7 +296,8 @@ class TestParseMaterialRequirements: def test_extracts_file_type(self) -> None: config = _StubConfig( - id="c1", template_id="t1", + id="c1", + template_id="t1", material_requirements={"type": "video"}, ) result = AutoClipService._parse_material_requirements(config) @@ -305,7 +305,8 @@ class TestParseMaterialRequirements: def test_extracts_min_quality(self) -> None: config = _StubConfig( - id="c1", template_id="t1", + id="c1", + template_id="t1", material_requirements={"min_quality_score": 60}, ) result = AutoClipService._parse_material_requirements(config) @@ -313,7 +314,8 @@ class TestParseMaterialRequirements: def test_extracts_category(self) -> None: config = _StubConfig( - id="c1", template_id="t1", + id="c1", + template_id="t1", material_requirements={"category": "scenic"}, ) result = AutoClipService._parse_material_requirements(config) @@ -321,7 +323,8 @@ class TestParseMaterialRequirements: def test_invalid_category_ignored(self) -> None: config = _StubConfig( - id="c1", template_id="t1", + id="c1", + template_id="t1", material_requirements={"category": "nonexistent"}, ) result = AutoClipService._parse_material_requirements(config) @@ -329,8 +332,10 @@ class TestParseMaterialRequirements: def test_duration_range(self) -> None: config = _StubConfig( - id="c1", template_id="t1", - min_duration=5.0, max_duration=15.0, + id="c1", + template_id="t1", + min_duration=5.0, + max_duration=15.0, material_requirements={}, ) result = AutoClipService._parse_material_requirements(config) @@ -340,7 +345,8 @@ class TestParseMaterialRequirements: def test_tags_extracted(self) -> None: config = _StubConfig( - id="c1", template_id="t1", + id="c1", + template_id="t1", material_requirements={"tags": ["outdoor", "sunset"]}, ) result = AutoClipService._parse_material_requirements(config) diff --git a/tests/unit/test_cosyvoice_service.py b/tests/unit/test_cosyvoice_service.py index bd29365f8..db1fae828 100644 --- a/tests/unit/test_cosyvoice_service.py +++ b/tests/unit/test_cosyvoice_service.py @@ -223,9 +223,7 @@ class TestCloneVoice: def test_clone_client_error_no_retry(self) -> None: """客户端错误(400)不重试。""" mock_client = MagicMock(spec=httpx.Client) - mock_client.request.return_value = _mock_response( - status_code=400, text="Bad Request" - ) + mock_client.request.return_value = _mock_response(status_code=400, text="Bad Request") service = _make_service(http_client=mock_client) @@ -265,9 +263,7 @@ class TestCloneVoice: def test_clone_with_voice_name(self) -> None: """带 voice_name 参数。""" mock_client = MagicMock(spec=httpx.Client) - mock_client.request.return_value = _mock_response( - json_data={"output": {"voice_id": "v-001"}} - ) + mock_client.request.return_value = _mock_response(json_data={"output": {"voice_id": "v-001"}}) service = _make_service(http_client=mock_client) service.clone_voice( @@ -282,9 +278,7 @@ class TestCloneVoice: def test_clone_no_task_id_or_voice_id_raises(self) -> None: """API 返回无效响应(无 task_id 也无 voice_id)。""" mock_client = MagicMock(spec=httpx.Client) - mock_client.request.return_value = _mock_response( - json_data={"output": {}} - ) + mock_client.request.return_value = _mock_response(json_data={"output": {}}) service = _make_service(http_client=mock_client) @@ -355,9 +349,7 @@ class TestSubmitCloneTask: def test_submit_no_task_id_or_voice_id_raises(self) -> None: """API 返回无效响应时抛出 CosyVoiceError。""" mock_client = MagicMock(spec=httpx.Client) - mock_client.request.return_value = _mock_response( - json_data={"output": {}} - ) + mock_client.request.return_value = _mock_response(json_data={"output": {}}) service = _make_service(http_client=mock_client) @@ -367,9 +359,7 @@ class TestSubmitCloneTask: def test_submit_with_voice_name_in_payload(self) -> None: """voice_name 参数包含在请求体中。""" mock_client = MagicMock(spec=httpx.Client) - mock_client.request.return_value = _mock_response( - json_data={"output": {"task_id": "task-001"}} - ) + mock_client.request.return_value = _mock_response(json_data={"output": {"task_id": "task-001"}}) service = _make_service(http_client=mock_client) service.submit_clone_task( @@ -452,9 +442,7 @@ class TestCheckTaskStatus: def test_check_uses_correct_path(self) -> None: """请求路径包含 task_id。""" mock_client = MagicMock(spec=httpx.Client) - mock_client.request.return_value = _mock_response( - json_data={"output": {"task_status": "PENDING"}} - ) + mock_client.request.return_value = _mock_response(json_data={"output": {"task_status": "PENDING"}}) service = _make_service(http_client=mock_client) service.check_task_status("task-xyz-123") @@ -533,9 +521,7 @@ class TestSynthesizeSpeech: """异步合成任务失败。""" mock_client = MagicMock(spec=httpx.Client) - submit_response = _mock_response( - json_data={"output": {"task_id": "task-tts-fail"}} - ) + submit_response = _mock_response(json_data={"output": {"task_id": "task-tts-fail"}}) failed_response = _mock_response( json_data={ "output": { @@ -603,9 +589,7 @@ class TestSynthesizeSpeech: def test_synthesize_no_url_or_task_id_raises(self) -> None: """API 返回无效响应(无 audio_url 也无 task_id)。""" mock_client = MagicMock(spec=httpx.Client) - mock_client.request.return_value = _mock_response( - json_data={"output": {}} - ) + mock_client.request.return_value = _mock_response(json_data={"output": {}}) service = _make_service(http_client=mock_client) @@ -652,9 +636,7 @@ class TestRetryLogic: # 第一次:服务端错误 error_response = _mock_response(status_code=500) # 第二次:成功 - success_response = _mock_response( - json_data={"output": {"voice_id": "v-retry-ok"}} - ) + success_response = _mock_response(json_data={"output": {"voice_id": "v-retry-ok"}}) mock_client.request.side_effect = [error_response, success_response] diff --git a/tests/unit/test_dedup_engine.py b/tests/unit/test_dedup_engine.py index 36dcebfae..d14352aae 100644 --- a/tests/unit/test_dedup_engine.py +++ b/tests/unit/test_dedup_engine.py @@ -53,6 +53,7 @@ _mock_if_absent("packages.adapters.sqlalchemy_impl.generated_video_repository") _HAS_CV2 = False try: import cv2 as _cv2 + if not isinstance(_cv2, MagicMock): _HAS_CV2 = True except (ImportError, ModuleNotFoundError) as e: @@ -227,6 +228,7 @@ class TestVideoDeduplicatorCheckDuplicate: def _patch_repo(self, mock_repo): """Patch SQLAlchemyGeneratedVideoRepository。""" import apps.worker.video_processing.dedup as dedup_module + original = dedup_module.SQLAlchemyGeneratedVideoRepository dedup_module.SQLAlchemyGeneratedVideoRepository = MagicMock(return_value=mock_repo) return original, dedup_module @@ -426,7 +428,8 @@ class TestVideoDeduplicatorCheckDuplicate: """多帧 phash 使用平均最小距离。""" # 已有视频有 2 帧 phash existing = self._make_existing_video( - "vid-1", "md5_a", + "vid-1", + "md5_a", phashes=["0000000000000000", "ffffffffffffffff"], ) mock_repo = MagicMock() diff --git a/tests/unit/test_duplication_domain.py b/tests/unit/test_duplication_domain.py index 9223c1e2a..e083d60fe 100644 --- a/tests/unit/test_duplication_domain.py +++ b/tests/unit/test_duplication_domain.py @@ -272,4 +272,3 @@ class TestDuplicateSegmentCreate: matched_end=5.0, similarity=-1.0, ) - diff --git a/tests/unit/test_edit_plan_generation_api.py b/tests/unit/test_edit_plan_generation_api.py index 6299a0da4..4cf64171f 100644 --- a/tests/unit/test_edit_plan_generation_api.py +++ b/tests/unit/test_edit_plan_generation_api.py @@ -28,7 +28,6 @@ sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "apps", " from packages.domain.edit_plan import EditPlan, EditPlanStatus from packages.domain.edit_plan_clip import EditPlanClip, EditPlanClipStatus - # ── Stub Repositories ───────────────────────────────────────────────────────── diff --git a/tests/unit/test_edit_plan_service.py b/tests/unit/test_edit_plan_service.py index 25ae58095..8802ac94c 100644 --- a/tests/unit/test_edit_plan_service.py +++ b/tests/unit/test_edit_plan_service.py @@ -28,7 +28,6 @@ sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "apps" / "api")) from packages.domain.edit_plan import EditPlan, EditPlanStatus from packages.domain.edit_plan_clip import EditPlanClip, EditPlanClipStatus - # --------------------------------------------------------------------------- # Stub Repositories # --------------------------------------------------------------------------- diff --git a/tests/unit/test_edit_template_service.py b/tests/unit/test_edit_template_service.py index 99692083f..f467ba5a7 100644 --- a/tests/unit/test_edit_template_service.py +++ b/tests/unit/test_edit_template_service.py @@ -29,7 +29,6 @@ sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "apps" / "api")) from packages.domain.edit_template import EditTemplate, EditTemplateStatus from packages.domain.template_clip_config import ClipType, TemplateClipConfig, TransitionEffect - # --------------------------------------------------------------------------- # Stub Repositories # --------------------------------------------------------------------------- diff --git a/tests/unit/test_edit_templates_api.py b/tests/unit/test_edit_templates_api.py index 2af173599..c026c4583 100644 --- a/tests/unit/test_edit_templates_api.py +++ b/tests/unit/test_edit_templates_api.py @@ -30,7 +30,6 @@ sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "apps", " from packages.domain.edit_template import EditTemplate, EditTemplateStatus - # ── Stub Repository ─────────────────────────────────────────────────────────── @@ -63,9 +62,7 @@ class StubEditTemplateRepository: skip: int = 0, limit: int = 50, ) -> list[EditTemplate]: - return self.list_all( - template_type=template_type, status=EditTemplateStatus.ACTIVE, skip=skip, limit=limit - ) + return self.list_all(template_type=template_type, status=EditTemplateStatus.ACTIVE, skip=skip, limit=limit) def get(self, template_id: str) -> Optional[EditTemplate]: return self._store.get(template_id) @@ -339,9 +336,7 @@ class TestUpdateTemplate: resp = client.put("/api/v1/edit-templates/nonexistent", json={"name": "x"}) assert resp.status_code == 404 - def test_partial_update_preserves_others( - self, client: TestClient, stub_repo: StubEditTemplateRepository - ) -> None: + def test_partial_update_preserves_others(self, client: TestClient, stub_repo: StubEditTemplateRepository) -> None: t = _make_template("原名", description="原描述", template_type="vlog") stub_repo.create(t) resp = client.put(f"/api/v1/edit-templates/{t.id}", json={"name": "新名"}) @@ -377,9 +372,7 @@ class TestDeleteTemplate: resp = client.delete("/api/v1/edit-templates/nonexistent") assert resp.status_code == 404 - def test_soft_delete_idempotent( - self, client: TestClient, stub_repo: StubEditTemplateRepository - ) -> None: + def test_soft_delete_idempotent(self, client: TestClient, stub_repo: StubEditTemplateRepository) -> None: t = _make_template("模板") stub_repo.create(t) # 第一次删除 @@ -389,9 +382,7 @@ class TestDeleteTemplate: resp2 = client.delete(f"/api/v1/edit-templates/{t.id}") assert resp2.status_code == 204 - def test_deleted_not_in_active_list( - self, client: TestClient, stub_repo: StubEditTemplateRepository - ) -> None: + def test_deleted_not_in_active_list(self, client: TestClient, stub_repo: StubEditTemplateRepository) -> None: t = _make_template("模板") stub_repo.create(t) client.delete(f"/api/v1/edit-templates/{t.id}") @@ -404,17 +395,22 @@ class TestDeleteTemplate: class TestResponseSchema: - def test_response_has_all_fields( - self, client: TestClient, stub_repo: StubEditTemplateRepository - ) -> None: + def test_response_has_all_fields(self, client: TestClient, stub_repo: StubEditTemplateRepository) -> None: t = _make_template("模板", description="描述", template_type="vlog") stub_repo.create(t) resp = client.get(f"/api/v1/edit-templates/{t.id}") data = resp.json() expected_keys = { - "id", "name", "description", "template_type", - "config", "preview_url", "sort_weight", "status", - "created_at", "updated_at", + "id", + "name", + "description", + "template_type", + "config", + "preview_url", + "sort_weight", + "status", + "created_at", + "updated_at", } assert set(data.keys()) == expected_keys diff --git a/tests/unit/test_job_service.py b/tests/unit/test_job_service.py index aceb69c3e..7f074e17d 100755 --- a/tests/unit/test_job_service.py +++ b/tests/unit/test_job_service.py @@ -34,7 +34,6 @@ from packages.application.jobs import ( UpdateJobProgressUseCase, ) - # ── Fixtures ────────────────────────────────────────────────────────────────── @@ -59,7 +58,8 @@ class FakeJobRepo: def list_by_project(self, project_id, *, job_type=None, status=None, limit=50, offset=0): results = [ - j for j in self._store.values() + j + for j in self._store.values() if j.project_id == project_id and (job_type is None or j.job_type == job_type or j.job_type == JobType(job_type)) and (status is None or j.status == status or j.status == JobStatus(status)) @@ -68,7 +68,8 @@ class FakeJobRepo: def list_by_user(self, user_id, *, job_type=None, status=None, limit=50, offset=0): results = [ - j for j in self._store.values() + j + for j in self._store.values() if j.created_by_user_id == user_id and (job_type is None or j.job_type == job_type or j.job_type == JobType(job_type)) and (status is None or j.status == status or j.status == JobStatus(status)) @@ -76,16 +77,23 @@ class FakeJobRepo: return results[offset : offset + limit] def count_by_project(self, project_id, *, status=None): - return len([ - j for j in self._store.values() - if j.project_id == project_id - and (status is None or j.status == status or j.status == JobStatus(status)) - ]) + return len( + [ + j + for j in self._store.values() + if j.project_id == project_id + and (status is None or j.status == status or j.status == JobStatus(status)) + ] + ) def find_active_by_source(self, source_id, job_type): jt = job_type.value if isinstance(job_type, JobType) else job_type for j in self._store.values(): - if j.source_id == source_id and j.job_type.value == jt and j.status in (JobStatus.PENDING, JobStatus.RUNNING): + if ( + j.source_id == source_id + and j.job_type.value == jt + and j.status in (JobStatus.PENDING, JobStatus.RUNNING) + ): return j return None @@ -289,9 +297,7 @@ class TestUpdateJobProgressUseCase: repo.update(job) progress_uc = UpdateJobProgressUseCase(repo) - updated = progress_uc.execute( - UpdateJobProgressCommand(job_id=job.id, progress=75.0, current_stage="渲染中") - ) + updated = progress_uc.execute(UpdateJobProgressCommand(job_id=job.id, progress=75.0, current_stage="渲染中")) assert updated.progress == 75.0 assert updated.current_stage == "渲染中" @@ -301,9 +307,7 @@ class TestUpdateJobProgressUseCase: progress_uc = UpdateJobProgressUseCase(repo) with pytest.raises(ValueError, match="只有 running 状态"): - progress_uc.execute( - UpdateJobProgressCommand(job_id=job.id, progress=50.0) - ) + progress_uc.execute(UpdateJobProgressCommand(job_id=job.id, progress=50.0)) class TestCompleteJobUseCase: @@ -314,9 +318,7 @@ class TestCompleteJobUseCase: repo.update(job) complete_uc = CompleteJobUseCase(repo) - completed = complete_uc.execute( - CompleteJobCommand(job_id=job.id, result={"url": "https://example.com/v.mp4"}) - ) + completed = complete_uc.execute(CompleteJobCommand(job_id=job.id, result={"url": "https://example.com/v.mp4"})) assert completed.status == JobStatus.SUCCESS assert completed.progress == 100.0 assert completed.result == {"url": "https://example.com/v.mp4"} @@ -438,8 +440,12 @@ class TestListJobsUseCase: def test_list_by_user(self, repo): create_uc = CreateJobUseCase(repo) - create_uc.execute(CreateJobCommand(project_id="proj-1", job_type=JobType.VIDEO_COMPOSE, created_by_user_id="user-1")) - create_uc.execute(CreateJobCommand(project_id="proj-1", job_type=JobType.VIDEO_COMPOSE, created_by_user_id="user-2")) + create_uc.execute( + CreateJobCommand(project_id="proj-1", job_type=JobType.VIDEO_COMPOSE, created_by_user_id="user-1") + ) + create_uc.execute( + CreateJobCommand(project_id="proj-1", job_type=JobType.VIDEO_COMPOSE, created_by_user_id="user-2") + ) list_uc = ListJobsUseCase(repo) jobs = list_uc.execute(user_id="user-1") diff --git a/tests/unit/test_phase8_clip_models.py b/tests/unit/test_phase8_clip_models.py index bbb0fac70..85a13befc 100644 --- a/tests/unit/test_phase8_clip_models.py +++ b/tests/unit/test_phase8_clip_models.py @@ -12,7 +12,6 @@ from packages.domain.edit_plan_clip import ( EditPlanClipStatus, ) - # ── TemplateClipConfig 领域实体测试 ───────────────────────────────────────── @@ -81,16 +80,12 @@ class TestTemplateClipConfig: def test_create_negative_min_duration_raises(self): """负数 min_duration 报错""" with pytest.raises(ValueError, match="min_duration 不能为负数"): - TemplateClipConfig.create( - template_id="tpl_001", clip_type=ClipType.INTRO, order=0, min_duration=-1.0 - ) + TemplateClipConfig.create(template_id="tpl_001", clip_type=ClipType.INTRO, order=0, min_duration=-1.0) def test_create_negative_max_duration_raises(self): """负数 max_duration 报错""" with pytest.raises(ValueError, match="max_duration 不能为负数"): - TemplateClipConfig.create( - template_id="tpl_001", clip_type=ClipType.INTRO, order=0, max_duration=-1.0 - ) + TemplateClipConfig.create(template_id="tpl_001", clip_type=ClipType.INTRO, order=0, max_duration=-1.0) def test_create_min_greater_than_max_raises(self): """min_duration > max_duration 报错""" @@ -105,9 +100,7 @@ class TestTemplateClipConfig: def test_has_duration_range(self): """has_duration_range 属性""" - config_no_range = TemplateClipConfig.create( - template_id="tpl_001", clip_type=ClipType.INTRO, order=0 - ) + config_no_range = TemplateClipConfig.create(template_id="tpl_001", clip_type=ClipType.INTRO, order=0) assert config_no_range.has_duration_range is False config_with_range = TemplateClipConfig.create( @@ -118,9 +111,7 @@ class TestTemplateClipConfig: def test_default_duration(self): """default_duration 属性""" # 无时长范围 - config_no_range = TemplateClipConfig.create( - template_id="tpl_001", clip_type=ClipType.INTRO, order=0 - ) + config_no_range = TemplateClipConfig.create(template_id="tpl_001", clip_type=ClipType.INTRO, order=0) assert config_no_range.default_duration == 0.0 # 只有 min @@ -161,9 +152,7 @@ class TestTemplateClipConfig: def test_timestamps_auto_set(self): """创建时自动设置时间戳""" - config = TemplateClipConfig.create( - template_id="tpl_001", clip_type=ClipType.INTRO, order=0 - ) + config = TemplateClipConfig.create(template_id="tpl_001", clip_type=ClipType.INTRO, order=0) assert config.created_at is not None assert config.updated_at is not None @@ -284,9 +273,7 @@ class TestEditPlanClip: def test_end_time_property(self): """end_time 属性""" - clip = EditPlanClip.create( - plan_id="plan_001", clip_type="main", order=0, start_time=5.0, duration=10.0 - ) + clip = EditPlanClip.create(plan_id="plan_001", clip_type="main", order=0, start_time=5.0, duration=10.0) assert clip.end_time == 15.0 def test_has_asset_property(self): @@ -294,9 +281,7 @@ class TestEditPlanClip: clip_no_asset = EditPlanClip.create(plan_id="plan_001", clip_type="main", order=0) assert clip_no_asset.has_asset is False - clip_with_asset = EditPlanClip.create( - plan_id="plan_001", clip_type="main", order=0, asset_id="asset_001" - ) + clip_with_asset = EditPlanClip.create(plan_id="plan_001", clip_type="main", order=0, asset_id="asset_001") assert clip_with_asset.has_asset is True def test_edit_plan_clip_status_enum(self): @@ -338,9 +323,7 @@ class TestTemplateClipConfigRepository: def test_create_and_get(self, db_session): """创建并获取""" repo = SQLAlchemyTemplateClipConfigRepository(db_session) - config = TemplateClipConfig.create( - template_id="tpl_001", clip_type=ClipType.INTRO, order=0, min_duration=2.0 - ) + config = TemplateClipConfig.create(template_id="tpl_001", clip_type=ClipType.INTRO, order=0, min_duration=2.0) created = repo.create(config) assert created.id == config.id @@ -354,14 +337,8 @@ class TestTemplateClipConfigRepository: """按模板列出""" repo = SQLAlchemyTemplateClipConfigRepository(db_session) for i in range(3): - repo.create( - TemplateClipConfig.create( - template_id="tpl_001", clip_type=ClipType.MAIN, order=i - ) - ) - repo.create( - TemplateClipConfig.create(template_id="tpl_002", clip_type=ClipType.INTRO, order=0) - ) + repo.create(TemplateClipConfig.create(template_id="tpl_001", clip_type=ClipType.MAIN, order=i)) + repo.create(TemplateClipConfig.create(template_id="tpl_002", clip_type=ClipType.INTRO, order=0)) results = repo.list_by_template("tpl_001") assert len(results) == 3 @@ -409,9 +386,7 @@ class TestTemplateClipConfigRepository: """按模板批量删除""" repo = SQLAlchemyTemplateClipConfigRepository(db_session) for i in range(3): - repo.create( - TemplateClipConfig.create(template_id="tpl_001", clip_type=ClipType.MAIN, order=i) - ) + repo.create(TemplateClipConfig.create(template_id="tpl_001", clip_type=ClipType.MAIN, order=i)) deleted = repo.delete_by_template("tpl_001") assert deleted == 3 assert repo.count(template_id="tpl_001") == 0 @@ -433,9 +408,7 @@ class TestEditPlanClipRepository: def test_create_and_get(self, db_session): """创建并获取""" repo = SQLAlchemyEditPlanClipRepository(db_session) - clip = EditPlanClip.create( - plan_id="plan_001", clip_type="main", order=0, duration=5.0 - ) + clip = EditPlanClip.create(plan_id="plan_001", clip_type="main", order=0, duration=5.0) created = repo.create(clip) assert created.id == clip.id @@ -450,12 +423,8 @@ class TestEditPlanClipRepository: """按计划列出""" repo = SQLAlchemyEditPlanClipRepository(db_session) for i in range(3): - repo.create( - EditPlanClip.create(plan_id="plan_001", clip_type="main", order=i) - ) - repo.create( - EditPlanClip.create(plan_id="plan_002", clip_type="intro", order=0) - ) + repo.create(EditPlanClip.create(plan_id="plan_001", clip_type="main", order=i)) + repo.create(EditPlanClip.create(plan_id="plan_002", clip_type="intro", order=0)) results = repo.list_by_plan("plan_001") assert len(results) == 3 @@ -504,9 +473,7 @@ class TestEditPlanClipRepository: """按计划批量删除""" repo = SQLAlchemyEditPlanClipRepository(db_session) for i in range(3): - repo.create( - EditPlanClip.create(plan_id="plan_001", clip_type="main", order=i) - ) + repo.create(EditPlanClip.create(plan_id="plan_001", clip_type="main", order=i)) deleted = repo.delete_by_plan("plan_001") assert deleted == 3 assert repo.count(plan_id="plan_001") == 0 diff --git a/tests/unit/test_video_compose_security.py b/tests/unit/test_video_compose_security.py index 23d963d7d..671b1ffd4 100755 --- a/tests/unit/test_video_compose_security.py +++ b/tests/unit/test_video_compose_security.py @@ -180,7 +180,7 @@ class TestComposeSecurityIntegration: Clip(asset_id="s3://bucket/video1.mp4"), Clip(asset_id="s3://bucket/video2.mp4"), ] - + with pytest.raises(ValueError, match="输出路径不在允许范围内"): self.service.compose(clips, output_path="/etc/passwd") @@ -189,7 +189,7 @@ class TestComposeSecurityIntegration: clips = [ Clip(asset_id="/etc/shadow"), # 非法路径 ] - + with pytest.raises(ValueError, match="不合法的输入路径"): self.service.compose(clips) @@ -199,15 +199,15 @@ class TestComposeSecurityIntegration: # 创建临时视频文件 video_path = os.path.join(tmpdir, "input.mp4") output_path = os.path.join("/tmp/video_output", "output.mp4") - + # 创建空的测试文件(实际测试需要真实视频) with open(video_path, "wb") as f: f.write(b"fake video data") - + clips = [ Clip(asset_id=f"local://{video_path}"), ] - + # 验证输入校验通过 assert self.service._validate_input_path(f"local://{video_path}") is True diff --git a/tests/unit/test_video_compose_service.py b/tests/unit/test_video_compose_service.py index 316a3aeae..76c8c6538 100644 --- a/tests/unit/test_video_compose_service.py +++ b/tests/unit/test_video_compose_service.py @@ -23,7 +23,6 @@ from app.services.video_compose_service import ( from packages.domain.edit_plan import EditPlan, EditPlanStatus from packages.domain.edit_plan_clip import EditPlanClip, EditPlanClipStatus - # ── Stub 实体 ───────────────────────────────────────────────────────────────── @@ -533,9 +532,7 @@ class TestBuildXfadeFilter(TestCase): duration=8.0, ), ] - filter_str, duration = _build_xfade_filter( - chains, transition_duration=0.5, transitions=["cut", "fade"] - ) + filter_str, duration = _build_xfade_filter(chains, transition_duration=0.5, transitions=["cut", "fade"]) self.assertIn("xfade=transition=fade", filter_str) self.assertIn("duration=0.5", filter_str) self.assertIn("[outv]", filter_str) @@ -594,24 +591,16 @@ class TestHasAudioTitleSubtitleFix(TestCase): def test_has_audio_false_when_only_title_subtitle(self): """当所有片段都是 title/subtitle 时,_has_audio 应返回 False。""" chains = [ - VideoComposeService._build_clip_filter( - self._make_clip("c1", "title"), 0, 1280, 720, 25 - ), - VideoComposeService._build_clip_filter( - self._make_clip("c2", "subtitle"), 1, 1280, 720, 25 - ), + VideoComposeService._build_clip_filter(self._make_clip("c1", "title"), 0, 1280, 720, 25), + VideoComposeService._build_clip_filter(self._make_clip("c2", "subtitle"), 1, 1280, 720, 25), ] self.assertFalse(VideoComposeService._has_audio(chains)) def test_has_audio_true_when_mixed_clips(self): """混合片段(含 main)时,_has_audio 应返回 True。""" chains = [ - VideoComposeService._build_clip_filter( - self._make_clip("c1", "title"), 0, 1280, 720, 25 - ), - VideoComposeService._build_clip_filter( - self._make_clip("c2", "main"), 1, 1280, 720, 25 - ), + VideoComposeService._build_clip_filter(self._make_clip("c1", "title"), 0, 1280, 720, 25), + VideoComposeService._build_clip_filter(self._make_clip("c2", "main"), 1, 1280, 720, 25), ] self.assertTrue(VideoComposeService._has_audio(chains)) @@ -624,4 +613,5 @@ class TestHasAudioTitleSubtitleFix(TestCase): if __name__ == "__main__": import unittest + unittest.main() diff --git a/tests/unit/test_voice_clone_task.py b/tests/unit/test_voice_clone_task.py index 30020c542..03f74e8fc 100644 --- a/tests/unit/test_voice_clone_task.py +++ b/tests/unit/test_voice_clone_task.py @@ -64,9 +64,7 @@ class TestProcessVoiceCloneSuccess: @patch("worker_app.tasks.voice_clone.SQLAlchemyVoiceCloneProfileRepository") @patch("worker_app.tasks.voice_clone.CosyVoiceService") - def test_process_voice_clone_success( - self, mock_service_cls: MagicMock, mock_repo_cls: MagicMock - ) -> None: + def test_process_voice_clone_success(self, mock_service_cls: MagicMock, mock_repo_cls: MagicMock) -> None: """克隆成功:轮询返回 voice_id,profile 标记为 ready。""" mock_session = MagicMock() mock_repo = MagicMock() @@ -95,9 +93,7 @@ class TestProcessVoiceCloneSuccess: @patch("worker_app.tasks.voice_clone.SQLAlchemyVoiceCloneProfileRepository") @patch("worker_app.tasks.voice_clone.CosyVoiceService") - def test_process_voice_clone_profile_not_found( - self, mock_service_cls: MagicMock, mock_repo_cls: MagicMock - ) -> None: + def test_process_voice_clone_profile_not_found(self, mock_service_cls: MagicMock, mock_repo_cls: MagicMock) -> None: """profile 不存在时返回 failed。""" mock_session = MagicMock() mock_repo = MagicMock() @@ -123,9 +119,7 @@ class TestProcessVoiceCloneTimeout: @patch("worker_app.tasks.voice_clone.SQLAlchemyVoiceCloneProfileRepository") @patch("worker_app.tasks.voice_clone.CosyVoiceService") - def test_process_voice_clone_timeout_retries( - self, mock_service_cls: MagicMock, mock_repo_cls: MagicMock - ) -> None: + def test_process_voice_clone_timeout_retries(self, mock_service_cls: MagicMock, mock_repo_cls: MagicMock) -> None: """超时时调用 self.retry() 进行重试,Retry 异常向上传播。""" mock_session = MagicMock() mock_repo = MagicMock() @@ -159,9 +153,7 @@ class TestProcessVoiceCloneFailure: @patch("worker_app.tasks.voice_clone.SQLAlchemyVoiceCloneProfileRepository") @patch("worker_app.tasks.voice_clone.CosyVoiceService") - def test_process_voice_clone_cosyvoice_error( - self, mock_service_cls: MagicMock, mock_repo_cls: MagicMock - ) -> None: + def test_process_voice_clone_cosyvoice_error(self, mock_service_cls: MagicMock, mock_repo_cls: MagicMock) -> None: """CosyVoice 错误:profile 标记为 failed。""" mock_session = MagicMock() mock_repo = MagicMock() @@ -187,9 +179,7 @@ class TestProcessVoiceCloneFailure: @patch("worker_app.tasks.voice_clone.SQLAlchemyVoiceCloneProfileRepository") @patch("worker_app.tasks.voice_clone.CosyVoiceService") - def test_process_voice_clone_unexpected_error( - self, mock_service_cls: MagicMock, mock_repo_cls: MagicMock - ) -> None: + def test_process_voice_clone_unexpected_error(self, mock_service_cls: MagicMock, mock_repo_cls: MagicMock) -> None: """意外异常:profile 标记为 failed。""" mock_session = MagicMock() mock_repo = MagicMock() @@ -215,9 +205,7 @@ class TestProcessVoiceCloneFailure: @patch("worker_app.tasks.voice_clone.SQLAlchemyVoiceCloneProfileRepository") @patch("worker_app.tasks.voice_clone.CosyVoiceService") - def test_process_voice_clone_no_task_id( - self, mock_service_cls: MagicMock, mock_repo_cls: MagicMock - ) -> None: + def test_process_voice_clone_no_task_id(self, mock_service_cls: MagicMock, mock_repo_cls: MagicMock) -> None: """metadata 中没有 cosyvoice_task_id 时返回 failed。""" mock_session = MagicMock() mock_repo = MagicMock() diff --git a/tests/unit/test_voice_clone_workflow.py b/tests/unit/test_voice_clone_workflow.py index f5eb7cc5b..fa6bddba6 100644 --- a/tests/unit/test_voice_clone_workflow.py +++ b/tests/unit/test_voice_clone_workflow.py @@ -48,9 +48,7 @@ def _make_service( """创建测试用 VoiceCloneWorkflowService。""" mock_repo = repo or MagicMock() mock_cosyvoice = cosyvoice or MagicMock(spec=CosyVoiceService) - return VoiceCloneWorkflowService( - repository=mock_repo, cosyvoice_service=mock_cosyvoice - ) + return VoiceCloneWorkflowService(repository=mock_repo, cosyvoice_service=mock_cosyvoice) # ── start_clone ────────────────────────────────────────── @@ -241,9 +239,7 @@ class TestRetryClone: mock_repo = MagicMock() mock_cosyvoice = MagicMock(spec=CosyVoiceService) - profile = _make_profile( - status=VoiceCloneStatus.FAILED, retry_count=1, max_retries=3 - ) + profile = _make_profile(status=VoiceCloneStatus.FAILED, retry_count=1, max_retries=3) mock_repo.get.return_value = profile mock_repo.update.side_effect = lambda p: p @@ -265,9 +261,7 @@ class TestRetryClone: mock_repo = MagicMock() mock_cosyvoice = MagicMock(spec=CosyVoiceService) - profile = _make_profile( - status=VoiceCloneStatus.FAILED, retry_count=0, max_retries=3 - ) + profile = _make_profile(status=VoiceCloneStatus.FAILED, retry_count=0, max_retries=3) mock_repo.get.return_value = profile mock_repo.update.side_effect = lambda p: p @@ -307,9 +301,7 @@ class TestRetryClone: mock_repo = MagicMock() mock_cosyvoice = MagicMock(spec=CosyVoiceService) - profile = _make_profile( - status=VoiceCloneStatus.FAILED, retry_count=0, max_retries=3 - ) + profile = _make_profile(status=VoiceCloneStatus.FAILED, retry_count=0, max_retries=3) mock_repo.get.return_value = profile mock_repo.update.side_effect = lambda p: p