diff --git a/.env.example b/.env.example index 01139df98..9256a77e4 100755 --- a/.env.example +++ b/.env.example @@ -196,3 +196,10 @@ DOUBAO_MODEL=doubao-seed-1-6-250615 DOUBAO_BASE_URL=https://ark.cn-beijing.volces.com/api/v3 DOUBAO_TIMEOUT=30 DOUBAO_MAX_RETRIES=2 + +# ==================== 积分/会员系统 (#1895) ==================== +# 积分扣点总开关:默认 false(对现有用户零影响)。 +# P2 阶段各业务路由逐个接入 @points_gate 时,用 +# `if settings.points_enabled: ...` +# 包裹扣点逻辑;所有路由接入完成并验证通过后再在 staging/prod 打开。 +POINTS_ENABLED=false diff --git a/apps/api/app/api/router.py b/apps/api/app/api/router.py index 3dd8f9afc..ab8df9da4 100755 --- a/apps/api/app/api/router.py +++ b/apps/api/app/api/router.py @@ -6,6 +6,7 @@ from app.api.routes.assets import router as assets_router from app.api.routes.auth import router as auth_router from app.api.routes.chunked_upload import router as chunked_upload_router from app.api.routes.classification_jobs import router as classification_jobs_router +from app.api.routes.clips_standalone import router as clips_standalone_router from app.api.routes.cover_templates import router as cover_templates_router from app.api.routes.duplication import router as duplication_router from app.api.routes.feature_flags import router as feature_flags_router @@ -17,8 +18,10 @@ from app.api.routes.health import router as health_check_router from app.api.routes.ingest_jobs import router as ingest_jobs_router from app.api.routes.internal_render import router as internal_render_router from app.api.routes.lipsync import router as lipsync_router +from app.api.routes.points import points_router, usage_router from app.api.routes.projects import router as projects_router from app.api.routes.scripts import router as scripts_router +from app.api.routes.scripts_ai import router as scripts_ai_router from app.api.routes.share import router as share_router from app.api.routes.subscription import router as subscription_router from app.api.routes.tags import router as tags_router @@ -156,6 +159,10 @@ api_router.include_router( prefix="/templates", tags=["Template"], ) +api_router.include_router( + clips_standalone_router, + tags=["Clips"], +) api_router.include_router( templates_editor_router, prefix="/templates/{template_id}/editor", @@ -184,8 +191,23 @@ api_router.include_router( prefix="/scripts", tags=["ScriptLibrary"], ) +api_router.include_router( + scripts_ai_router, + prefix="/scripts", + tags=["ScriptLibrary AI"], +) api_router.include_router( ai_avatar_render_router, prefix="/ai-avatar/render", tags=["AI Avatar Render"], ) +api_router.include_router( + points_router, + prefix="/points", + tags=["Points"], +) +api_router.include_router( + usage_router, + prefix="/usage", + tags=["Usage"], +) diff --git a/apps/api/app/api/routes/_default_template.py b/apps/api/app/api/routes/_default_template.py new file mode 100644 index 000000000..3b51e3a40 --- /dev/null +++ b/apps/api/app/api/routes/_default_template.py @@ -0,0 +1,91 @@ +"""默认模板兜底共享逻辑(P0 #1922). + +提供 get_or_create_default_template_id(db, user_id) 共享函数, +供 templates.py 列表查询、clips_standalone.py 独立端点、dependencies.py +resolve_draft_plan_id 三处复用,避免三处各写一套兜底逻辑产生分叉。 + +根因:PR#1918 清理模板管理 API 时误删了 GET /templates 自动创建默认模板 +兜底,前端 PR#1913 去掉空 tid 拦截后首次进入生成页拼出 +/templates//editor/clips/from-assets(双斜杠)→ FastAPI 404,阻断新用户首次 +生成。 +""" + +from __future__ import annotations + +import logging +from typing import Optional + +from sqlalchemy.orm import Session + +logger = logging.getLogger(__name__) + + +def get_or_create_default_template_id(db: Session, user_id: str) -> Optional[str]: + """获取或自动创建默认配音模板的 id。 + + 判定逻辑(不做异常降级,只有确实创建失败时才回滚重查): + 1. 查用户名下 is_active=True 且有 TemplateClipConfig 的模板 → 返回其 id; + 2. 无则调用 CreateTemplateUseCase 创建一条默认 voice_over 模板; + 3. 创建异常时 rollback 再重查一次(防并发唯一键冲突),重查仍无返回 None。 + """ + from packages.adapters.sqlalchemy_impl.models import ( + TemplateClipConfigModel, + TemplateModel, + ) + from packages.adapters.sqlalchemy_impl.template_repository import ( + SQLAlchemyTemplateRepository, + ) + from packages.application.template.commands import ( + CreateTemplateCommand, + SegmentCommand, + ) + from packages.application.template.use_cases import CreateTemplateUseCase + + existing = ( + db.query(TemplateModel) + .filter(TemplateModel.user_id == user_id, TemplateModel.is_active.is_(True)) + .order_by(TemplateModel.created_at.asc()) + .first() + ) + if existing is not None: + has_seg = ( + db.query(TemplateClipConfigModel.id).filter(TemplateClipConfigModel.template_id == existing.id).first() + ) + if has_seg: + return existing.id + + try: + repo = SQLAlchemyTemplateRepository(db) + cmd = CreateTemplateCommand( + user_id=user_id, + name="默认配音模板", + mode="voice_over", + category="default", + tags=[], + title_config={}, + subtitle_config={}, + bgm_config={}, + estimated_duration=0.0, + segments=[SegmentCommand(segment_order=0, duration_min=1.0, duration_max=30.0)], + ) + tpl = CreateTemplateUseCase(repo).execute(cmd) + db.commit() + logger.info("auto-created default voice_over template: id=%s user=%s", tpl.id, user_id) + return tpl.id + except Exception: + db.rollback() + # 重查:可能并发请求已建好 + existing = ( + db.query(TemplateModel) + .filter(TemplateModel.user_id == user_id, TemplateModel.is_active.is_(True)) + .order_by(TemplateModel.created_at.asc()) + .first() + ) + if existing is not None: + has_seg = ( + db.query(TemplateClipConfigModel.id).filter(TemplateClipConfigModel.template_id == existing.id).first() + ) + if has_seg: + return existing.id + logger.exception("failed to auto-create default template user=%s", user_id) + return None diff --git a/apps/api/app/api/routes/_helpers.py b/apps/api/app/api/routes/_helpers.py index a17a2a6d3..a97dcb2c8 100755 --- a/apps/api/app/api/routes/_helpers.py +++ b/apps/api/app/api/routes/_helpers.py @@ -1,6 +1,6 @@ """路由层共享辅助函数 — 消除跨文件重复定义。""" -from datetime import datetime, timezone +from datetime import UTC, datetime from typing import Any from fastapi import HTTPException, status @@ -138,4 +138,4 @@ def format_utc_datetime(dt: datetime | None) -> str: return dt if dt.tzinfo is None: return dt.isoformat() + "Z" - return dt.astimezone(timezone.utc).isoformat().replace("+00:00", "Z") + return dt.astimezone(UTC).isoformat().replace("+00:00", "Z") diff --git a/apps/api/app/api/routes/ai.py b/apps/api/app/api/routes/ai.py index 8373177b2..3de6c13b3 100755 --- a/apps/api/app/api/routes/ai.py +++ b/apps/api/app/api/routes/ai.py @@ -5,7 +5,7 @@ from __future__ import annotations -from typing import List, Literal +from typing import Literal from app.services.ai_service import TITLE_STYLES, generate_smart_titles, semantic_match_assets from fastapi import APIRouter @@ -31,7 +31,7 @@ class GenerateTitlesRequest(BaseModel): class GenerateTitlesResponse(BaseModel): """智能标题生成响应.""" - titles: List[str] = Field(..., description="生成的标题列表") + titles: list[str] = Field(..., description="生成的标题列表") style: str = Field(..., description="实际使用的风格") source: str = Field(..., description="来源:doubao 或 fallback") description: str = Field(..., description="原始描述") @@ -53,7 +53,7 @@ class AssetMatchItem(BaseModel): id: str = Field(..., description="素材ID") name: str = Field(default="", description="素材名称") - tags: List[str] = Field(default_factory=list, description="标签列表") + tags: list[str] = Field(default_factory=list, description="标签列表") description: str = Field(default="", description="素材描述") @@ -61,7 +61,7 @@ class SemanticMatchRequest(BaseModel): """语义匹配请求.""" description: str = Field(..., min_length=1, max_length=500, description="目标视频内容描述") - assets: List[AssetMatchItem] = Field(..., min_length=1, max_length=100, description="待匹配素材列表") + assets: list[AssetMatchItem] = Field(..., min_length=1, max_length=100, description="待匹配素材列表") top_k: int = Field(default=0, ge=0, le=100, description="返回前K个,0返回全部") @@ -75,7 +75,7 @@ class SemanticMatchResultItem(AssetMatchItem): class SemanticMatchResponse(BaseModel): """语义匹配响应.""" - matches: List[SemanticMatchResultItem] = Field(..., description="按匹配度降序排列的素材列表") + matches: list[SemanticMatchResultItem] = Field(..., description="按匹配度降序排列的素材列表") source: str = Field(..., description="来源:doubao / fallback") description: str = Field(..., description="原始描述") total: int = Field(..., description="输入素材总数") @@ -99,7 +99,7 @@ def generate_titles(request: GenerateTitlesRequest): return GenerateTitlesResponse(**result) -@router.get("/titles/styles", response_model=List[TitleStyleInfo]) +@router.get("/titles/styles", response_model=list[TitleStyleInfo]) def list_title_styles(): """获取支持的标题风格列表.""" return [ diff --git a/apps/api/app/api/routes/ai_avatar_render.py b/apps/api/app/api/routes/ai_avatar_render.py index 3277ccb45..52fc98abb 100644 --- a/apps/api/app/api/routes/ai_avatar_render.py +++ b/apps/api/app/api/routes/ai_avatar_render.py @@ -11,7 +11,7 @@ from __future__ import annotations import logging -from datetime import datetime, timezone +from datetime import UTC, datetime from app.auth import AuthenticatedUser, get_current_user from app.dependencies import get_db_session @@ -29,6 +29,8 @@ from app.services.ai_avatar_render_service import ( from fastapi import APIRouter, Depends, HTTPException, Query from sqlalchemy.orm import Session +from packages.middleware.points_gate import points_gate + logger = logging.getLogger(__name__) router = APIRouter() @@ -42,10 +44,12 @@ def _get_service(db: Session = Depends(get_db_session)) -> AiAvatarRenderService @router.post("", response_model=AiAvatarRenderJobResponse, status_code=201) +@points_gate("ai_digital_human", per_unit=15) def create_render_job( body: CreateAiAvatarRenderRequest, current_user: AuthenticatedUser = Depends(get_current_user), svc: AiAvatarRenderService = Depends(_get_service), + db: Session = Depends(get_db_session), ): """提交 AI 数字人渲染任务. @@ -82,7 +86,7 @@ def create_render_job( logger.exception("Celery 任务投递失败(创建): job_id=%s err=%s", job.id, exc) job.status = "failed" job.error_message = f"任务提交失败:{exc}" - job.updated_at = datetime.now(timezone.utc) + job.updated_at = datetime.now(UTC) svc.db.commit() svc.db.refresh(job) return AiAvatarRenderJobResponse.model_validate(job) @@ -183,7 +187,7 @@ def retry_render_job( logger.exception("Celery 任务投递失败(重试): job_id=%s err=%s", job.id, exc) job.status = "failed" job.error_message = f"任务提交失败:{exc}" - job.updated_at = datetime.now(timezone.utc) + job.updated_at = datetime.now(UTC) svc.db.commit() svc.db.refresh(job) return AiAvatarRenderJobResponse.model_validate(job) @@ -246,7 +250,7 @@ def generate_render_smart_cover( "url": cover_url, } job.output_cover_url = cover_url - job.updated_at = datetime.now(timezone.utc) + job.updated_at = datetime.now(UTC) db.commit() except Exception as exc: logger.warning("更新 render_job 封面字段失败(不影响返回): job_id=%s err=%s", job_id, exc) diff --git a/apps/api/app/api/routes/assets.py b/apps/api/app/api/routes/assets.py index 89fd775b1..107a4dba9 100755 --- a/apps/api/app/api/routes/assets.py +++ b/apps/api/app/api/routes/assets.py @@ -1,5 +1,5 @@ import logging -from typing import Any, List, Optional +from typing import Any, Optional from app.api.routes._helpers import check_project_access, format_utc_datetime from app.auth import AuthenticatedUser, get_current_user @@ -390,7 +390,7 @@ def update_asset_review_status( return _to_asset_response(updated) -@router.post("/batch", response_model=List[AssetResponse]) +@router.post("/batch", response_model=list[AssetResponse]) def batch_get_assets( request: BatchGetRequest, authenticated_user: AuthenticatedUser = Depends(get_current_user), diff --git a/apps/api/app/api/routes/auth.py b/apps/api/app/api/routes/auth.py index 44cdaa6b8..5403c6809 100755 --- a/apps/api/app/api/routes/auth.py +++ b/apps/api/app/api/routes/auth.py @@ -13,7 +13,7 @@ from typing import Optional import jwt from app.auth import AuthenticatedUser, blacklist_token, get_current_user from app.config import settings -from app.dependencies import get_auth_email_service, get_auth_session_store, get_user_repository +from app.dependencies import get_auth_email_service, get_auth_session_store, get_db_session, get_user_repository from fastapi import APIRouter, Depends, Header, HTTPException, Request, status from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer from pydantic import BaseModel, EmailStr, field_validator @@ -126,6 +126,7 @@ async def register( request: RegisterRequest, user_repository: UserRepository = Depends(get_user_repository), email_service=Depends(get_auth_email_service), + db=Depends(get_db_session), ) -> RegisterResponse: use_case = RegisterUserUseCase( user_repository=user_repository, @@ -143,6 +144,22 @@ async def register( if error or response is None: raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=_translate_auth_error(error)) + # 新用户注册赠送 50 积分(失败不影响注册) + if settings.points_enabled: + try: + from packages.domain.points_service import PointsService + _svc = PointsService() + _svc.add_points( + user_id=response.user_id, + amount=50, + source="task_reward", + db=db, + description="新用户注册赠送", + ) + except Exception as _bonus_err: + import logging + logging.getLogger(__name__).warning("注册送积分失败: user_id=%s err=%s", response.user_id, _bonus_err) + return RegisterResponse( user_id=response.user_id, email=response.email, diff --git a/apps/api/app/api/routes/chunked_upload.py b/apps/api/app/api/routes/chunked_upload.py index 8407c842d..2cd4a50ab 100644 --- a/apps/api/app/api/routes/chunked_upload.py +++ b/apps/api/app/api/routes/chunked_upload.py @@ -8,7 +8,7 @@ import json import logging import shutil import tempfile -from datetime import datetime, timedelta, timezone +from datetime import UTC, datetime, timedelta from pathlib import Path from typing import Any from uuid import uuid4 @@ -156,7 +156,7 @@ def _cleanup_expired_uploads() -> int: if not CHUNK_STORAGE_ROOT.exists(): return 0 - now = datetime.now(timezone.utc) + now = datetime.now(UTC) cleaned = 0 for meta_file in CHUNK_STORAGE_ROOT.glob("*.meta.json"): @@ -166,7 +166,7 @@ def _cleanup_expired_uploads() -> int: expires_at = datetime.fromisoformat(meta["expires_at"]) if expires_at.tzinfo is None: - expires_at = expires_at.replace(tzinfo=timezone.utc) + expires_at = expires_at.replace(tzinfo=UTC) # Only cleanup uploads that are not actively being uploaded if expires_at < now and meta.get("status") != "uploading": @@ -226,7 +226,7 @@ async def init_chunked_upload( # Generate upload ID upload_id = uuid4().hex - now = datetime.now(timezone.utc) + now = datetime.now(UTC) expires_at = now + timedelta(hours=CHUNK_EXPIRY_HOURS) # Create chunk directory @@ -421,9 +421,9 @@ async def upload_chunk( # Check expiry expires_at = datetime.fromisoformat(meta["expires_at"]) if expires_at.tzinfo is None: - expires_at = expires_at.replace(tzinfo=timezone.utc) + expires_at = expires_at.replace(tzinfo=UTC) - if expires_at < datetime.now(timezone.utc): + if expires_at < datetime.now(UTC): raise HTTPException(status_code=status.HTTP_410_GONE, detail="Upload has expired") # Validate chunk index diff --git a/apps/api/app/api/routes/clips_standalone.py b/apps/api/app/api/routes/clips_standalone.py new file mode 100644 index 000000000..5b9c5df70 --- /dev/null +++ b/apps/api/app/api/routes/clips_standalone.py @@ -0,0 +1,90 @@ +"""独立的从素材创建片段端点(不依赖 template_id 路径参数). + +POST /api/v1/clips/from-assets +- 与 /api/v1/templates/{template_id}/editor/clips/from-assets 功能一致 +- 区别:template_id 从 body 传入(可选),为空时后端自动创建/查找默认模板 +- 解决前端首次加载时 templateId 为空导致双斜杠 404 的问题(P0 #1922) +- 内部复用 resolve_draft_plan_id 和 create_clips_from_assets_editor 的核心逻辑 +""" + +from __future__ import annotations + +import logging + +from app.auth import AuthenticatedUser, get_current_user +from app.dependencies import get_asset_repository, get_db_session +from app.services.edit_plan_service import EditPlanService +from app.services.edit_template_service import EditTemplateService +from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, status +from sqlalchemy.orm import Session + +from packages.adapters.sqlalchemy_impl.asset_repository import SQLAlchemyAssetRepository + +from ._default_template import get_or_create_default_template_id +from .templates_editor.clips import create_clips_from_assets_editor +from .templates_editor.dependencies import resolve_draft_plan_id +from .templates_editor.schemas import ClipsFromAssetsRequest, ClipsFromAssetsResponse + +logger = logging.getLogger(__name__) +router = APIRouter(tags=["Clips"]) + + +class StandaloneClipsRequest(ClipsFromAssetsRequest): + """扩展请求:template_id 可选(不传则后端自动兜底默认模板)。""" + + template_id: str | None = None + + +def _get_editor_services_direct(db: Session) -> tuple[EditTemplateService, EditPlanService]: + """直接构造服务实例(非 Depends 版本,供独立端点内部调用)。""" + return EditTemplateService(db), EditPlanService(db) + + +@router.post("/clips/from-assets", response_model=ClipsFromAssetsResponse) +def create_clips_from_assets( + body: StandaloneClipsRequest, + background_tasks: BackgroundTasks, + db: Session = Depends(get_db_session), + current_user: AuthenticatedUser = Depends(get_current_user), + asset_repo: SQLAlchemyAssetRepository = Depends(get_asset_repository), +) -> ClipsFromAssetsResponse: + """从素材批量创建片段(template_id 可选,为空自动兜底)。""" + user_id = str(current_user.user.id) + services = _get_editor_services_direct(db) + + # 1. 解析/兜底 template_id,拿到 plan_id + template_id = (body.template_id or "").strip() + if not template_id: + template_id = get_or_create_default_template_id(db, user_id) + if not template_id: + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="无法自动创建默认模板,请刷新页面重试", + ) + plan_id = resolve_draft_plan_id( + template_id=template_id, + services=services, + current_user=current_user, + db=db, + auto_create_default=False, # 上面已兜底过 + ) + + # 2. 构造标准化请求(去除独立端扩展字段),复用原端点核心逻辑 + core_body = ClipsFromAssetsRequest( + asset_ids=body.asset_ids, + clip_type=body.clip_type, + clip_count=body.clip_count, + required_clips_count=body.required_clips_count, + ) + + # 3. 直接调用原端点函数(此时所有 Depends 依赖已手动传入) + return create_clips_from_assets_editor( + template_id=template_id, + body=core_body, + background_tasks=background_tasks, + plan_id=plan_id, + services=services, + asset_repo=asset_repo, + db=db, + current_user=current_user, + ) diff --git a/apps/api/app/api/routes/generation_cover.py b/apps/api/app/api/routes/generation_cover.py index 258c9b786..658189880 100644 --- a/apps/api/app/api/routes/generation_cover.py +++ b/apps/api/app/api/routes/generation_cover.py @@ -11,7 +11,7 @@ from __future__ import annotations import ipaddress import logging import re -from typing import Any, List, Optional +from typing import Any, Optional from urllib.parse import urlparse from app.auth import AuthenticatedUser, get_current_user @@ -27,6 +27,7 @@ from packages.adapters.sqlalchemy_impl.generation_task_repository import ( ) from packages.application import ListGeneratedVideosByTaskUseCase from packages.domain.config_schemas import normalize_plan_config +from packages.middleware.points_gate import points_gate from packages.shared.storage import get_shared_storage_service from .templates_editor.dependencies import get_draft_plan_id, get_editor_services @@ -41,7 +42,7 @@ router = APIRouter(tags=["Generation"]) class GenerateCoverRequest(BaseModel): """AI 封面生成请求体""" - asset_ids: List[str] = Field(default_factory=list, description="素材 ID 列表(确定视频来源)") + asset_ids: list[str] = Field(default_factory=list, description="素材 ID 列表(确定视频来源)") cover_type: str = Field( default="ai_frame", description="封面类型: ai_frame / manual / upload / ai_regenerate", @@ -331,6 +332,7 @@ def _is_trusted_media_url(url: str) -> bool: @router.post("/generate-cover", response_model=GenerateCoverResponse) +@points_gate("ai_cover") def generate_cover( body: GenerateCoverRequest, template_id: str = Query(..., description="模板 ID"), diff --git a/apps/api/app/api/routes/generation_preview.py b/apps/api/app/api/routes/generation_preview.py index 8c961e7c8..b518b16d4 100755 --- a/apps/api/app/api/routes/generation_preview.py +++ b/apps/api/app/api/routes/generation_preview.py @@ -43,6 +43,7 @@ from packages.application import ( GetGenerationTaskUseCase, ListGeneratedVideosByTaskUseCase, ) +from packages.middleware.points_gate import points_gate logger = logging.getLogger(__name__) @@ -271,6 +272,7 @@ def _variant_value(values: list[str], index: int, fallback: str = "") -> str: @router.post("/preview", response_model=BatchPreviewGenerationTaskResponse, status_code=201) +@points_gate("ai_video", quantity_field="preview_count") def create_preview_generation_task( request: CreatePreviewGenerationTaskRequest, authenticated_user: AuthenticatedUser = Depends(get_current_user), @@ -355,20 +357,7 @@ def create_preview_generation_task( ) strategy_id = _resolve_strategy_id_from_template(request.template_id, db, user_id) - - # #1894: 从 script 读取标题配置作为基础配置 - base_title_config: dict = {} - if request.script_id: - try: - from app.services.script_service import ScriptService - _script_svc = ScriptService(db) - base_title_config = _script_svc.get_title_config_for_script(request.script_id, user_id) - logger.info("[预览生成] 从 script 读取标题配置: script_id=%s title_config=%s", request.script_id, base_title_config) - except Exception: - logger.warning("[预览生成] 从 script 读取标题配置失败(不影响主流程): script_id=%s", request.script_id, exc_info=True) - # request.title_config 优先级高于 script 中的配置 - if request.title_config: - base_title_config.update(request.title_config) + base_title_config = request.title_config or {} use_case = CreateGenerationTaskUseCase(generation_task_repository) diff --git a/apps/api/app/api/routes/generation_tasks.py b/apps/api/app/api/routes/generation_tasks.py index d17eb0c2a..ac4ae0d6b 100755 --- a/apps/api/app/api/routes/generation_tasks.py +++ b/apps/api/app/api/routes/generation_tasks.py @@ -42,6 +42,7 @@ from packages.application import ( ListGeneratedVideosByTaskUseCase, ) from packages.domain.smart_match import smart_select_assets +from packages.middleware.points_gate import points_gate logger = logging.getLogger(__name__) @@ -211,6 +212,7 @@ def _resolve_project_and_library( @router.post("/tasks", response_model=BatchGenerationTaskResponse) +@points_gate("ai_video", quantity_field="count") def create_generation_task( request: CreateGenerationTaskRequest, authenticated_user: AuthenticatedUser = Depends(get_current_user), @@ -571,17 +573,6 @@ def create_generation_task( except Exception: logger.exception("[生成任务] N=1 配音分配兜底异常(不阻断)") - # #1894: 从 script 读取标题配置(优先级低于 request.title_config) - _script_title_config: dict = {} - if request.script_id: - try: - from app.services.script_service import ScriptService - _script_svc = ScriptService(db) - _script_title_config = _script_svc.get_title_config_for_script(request.script_id, user_id) - logger.info("[生成任务] 从 script 读取标题配置: script_id=%s title_config=%s", request.script_id, _script_title_config) - except Exception: - logger.warning("[生成任务] 从 script 读取标题配置失败(不影响主流程): script_id=%s", request.script_id, exc_info=True) - try: for task_index in range(count): # #1749:count>1 时每个变体(含变体0)都关联各自独立 plan(clone/reselect/variant-plans)。 @@ -593,9 +584,7 @@ def create_generation_task( # 变体级独立配置:titles[]/voice_library_ids[]/cover_urls[] # 长度1=所有变体共用,长度=count=每个变体独立,空数组=回退单值字段 variant_title_text = _variant_value(request.titles, task_index, "") - # #1894: script 标题配置作为基础,request.title_config 优先级更高 - variant_title_config = dict(_script_title_config or {}) - variant_title_config.update(request.title_config or {}) + variant_title_config = dict(request.title_config or {}) if variant_title_text.strip(): variant_title_config["text"] = variant_title_text.strip() variant_voice_library_id = _variant_value(request.voice_library_ids, task_index, request.voice_library_id) diff --git a/apps/api/app/api/routes/generation_variant_plans.py b/apps/api/app/api/routes/generation_variant_plans.py index bd62fd804..f89e61663 100644 --- a/apps/api/app/api/routes/generation_variant_plans.py +++ b/apps/api/app/api/routes/generation_variant_plans.py @@ -30,6 +30,74 @@ logger = logging.getLogger(__name__) router = APIRouter() +def _get_or_create_default_template_id(db: Session, user_id: str) -> str | None: + """为用户查找一个有效模板;若不存在则自动创建默认配音模板。 + + 前端 #1911 删除了模板选择 UI,当调用方未传 template_id/source_edit_plan_id + 时(如剪辑页首次进入直接选片),后端兜底查找/创建默认模板,避免 400。 + + Returns: + template_id(字符串);失败时返回 None。 + """ + + from packages.adapters.sqlalchemy_impl.models import TemplateClipConfigModel, TemplateModel + from packages.adapters.sqlalchemy_impl.template_repository import SQLAlchemyTemplateRepository + from packages.application.template.commands import CreateTemplateCommand, SegmentCommand + from packages.application.template.use_cases import CreateTemplateUseCase + + # 1. 先查已有有效模板(is_active=True 且存在片段配置) + existing = ( + db.query(TemplateModel) + .filter( + TemplateModel.user_id == user_id, + TemplateModel.is_active.is_(True), + ) + .order_by(TemplateModel.created_at.asc()) + .first() + ) + if existing is not None: + # 验证该模板是否有片段配置;若没有继续尝试创建默认 + has_seg = ( + db.query(TemplateClipConfigModel.id).filter(TemplateClipConfigModel.template_id == existing.id).first() + ) + if has_seg: + return existing.id + + # 2. 无有效模板 → 自动创建默认配音模板 + try: + repo = SQLAlchemyTemplateRepository(db) + cmd = CreateTemplateCommand( + user_id=user_id, + name="默认配音模板", + mode="voice_over", + category="default", + tags=[], + title_config={}, + subtitle_config={}, + bgm_config={}, + estimated_duration=0.0, + segments=[ + SegmentCommand( + segment_order=0, + duration_min=1.0, + duration_max=30.0, + material_type=None, + ), + ], + ) + use_case = CreateTemplateUseCase(repo) + tpl = use_case.execute(cmd) + logger.info( + "[variant-plans] 自动创建默认模板: user=%s tpl=%s", + user_id, + tpl.id, + ) + return tpl.id + except Exception: + logger.exception("[variant-plans] 自动创建默认模板失败: user=%s", user_id) + return None + + class VariantPlanRequest(BaseModel): """轻量选片请求体(与前端 variantPlans.ts 契约一致)。""" @@ -43,8 +111,8 @@ class VariantPlanRequest(BaseModel): @model_validator(mode="after") def _validate(self) -> "VariantPlanRequest": - if not self.template_id.strip() and not self.source_edit_plan_id.strip(): - raise ValueError("template_id 与 source_edit_plan_id 至少需要提供一个") + # 不再强制要求 template_id / source_edit_plan_id: + # 后端在路由内会自动查找/创建默认模板兜底(#1911 后前端不再显式选模板)。 try: resolve_variant_voice_ids( count=self.count, @@ -94,8 +162,15 @@ def create_variant_plans( from app.services.generation_common import resolve_latest_plan_by_template source_plan_id = request.source_edit_plan_id.strip() - if not source_plan_id and request.template_id.strip(): - source_plan_id = resolve_latest_plan_by_template(db, template_id=request.template_id, user_id=user_id) or "" + template_id = request.template_id.strip() + + # P0 兜底:前端 #1911 已删除模板选择 UI,调用方可能不传 template_id; + # 此时自动为该用户查找/创建默认模板。 + if not source_plan_id and not template_id: + template_id = _get_or_create_default_template_id(db, user_id) or "" + + if not source_plan_id and template_id: + source_plan_id = resolve_latest_plan_by_template(db, template_id=template_id, user_id=user_id) or "" if not source_plan_id: raise HTTPException( diff --git a/apps/api/app/api/routes/health.py b/apps/api/app/api/routes/health.py index f16465695..cd436df6a 100644 --- a/apps/api/app/api/routes/health.py +++ b/apps/api/app/api/routes/health.py @@ -1,4 +1,4 @@ -from datetime import datetime, timezone +from datetime import UTC, datetime import psycopg import redis @@ -13,7 +13,7 @@ router = APIRouter(tags=["Health"]) async def health_check(): return { "status": "healthy", - "timestamp": datetime.now(timezone.utc).isoformat(), + "timestamp": datetime.now(UTC).isoformat(), "version": settings.APP_VERSION, } @@ -33,7 +33,7 @@ async def startup_check(): all_ready = all(check["status"] == "healthy" for check in checks.values()) response = { "status": "started" if all_ready else "starting", - "timestamp": datetime.now(timezone.utc).isoformat(), + "timestamp": datetime.now(UTC).isoformat(), "checks": checks, } if not all_ready: diff --git a/apps/api/app/api/routes/lipsync.py b/apps/api/app/api/routes/lipsync.py index f4944ec62..d13ce50dd 100644 --- a/apps/api/app/api/routes/lipsync.py +++ b/apps/api/app/api/routes/lipsync.py @@ -12,8 +12,11 @@ from __future__ import annotations import logging +import math +from datetime import UTC from app.auth import AuthenticatedUser, get_current_user +from app.config import settings from app.dependencies import ( get_db_session, get_voice_clone_profile_repository, @@ -29,6 +32,9 @@ from app.services.mediakit_client import MediaKitError from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Query from sqlalchemy.orm import Session +from packages.domain.points_rules import calculate_points_cost +from packages.domain.points_service import PointsService + logger = logging.getLogger(__name__) router = APIRouter() @@ -52,8 +58,40 @@ def _get_service( def create_lipsync_job( body: CreateLipsyncJobRequest, current_user: AuthenticatedUser = Depends(get_current_user), + db: Session = Depends(get_db_session), svc: LipsyncService = Depends(_get_service), ): + user_id = current_user.user.id + + # ── 积分扣点(#1895 P2) ── + _points_deducted = 0 + _points_scene = "ai_digital_human" + _points_svc = PointsService() if settings.points_enabled else None + if _points_svc is not None: + # 口型同步:TTS 模式按 script_text 估时长(240字/分钟);音频直传按 audio_duration(秒→分钟) + if body.audio_url and body.audio_duration and body.audio_duration > 0: + est_minutes = max(1.0, math.ceil(body.audio_duration / 60.0)) + elif body.script_text: + est_minutes = max(1.0, math.ceil(len(body.script_text) / 240)) + else: + est_minutes = 1.0 + _points_deducted = calculate_points_cost( + _points_scene, + is_member=getattr(current_user.user, "is_member", False), + duration_minutes=est_minutes, + member_type=getattr(current_user.user, "member_type", None), + ) + _deduct_res = _points_svc.deduct_points(user_id, _points_deducted, _points_scene, db) + if not _deduct_res["success"]: + raise HTTPException( + status_code=402, + detail={ + "code": "INSUFFICIENT_POINTS", + "message": f"积分不足,需要 {_points_deducted} 积分,当前余额 {_deduct_res['balance']}", + "required": _points_deducted, + "balance": _deduct_res["balance"], + }, + ) """提交对口型任务. 三种模式: @@ -65,7 +103,7 @@ def create_lipsync_job( """ try: job = svc.create_job( - user_id=current_user.user.id, + user_id=user_id, video_url=body.video_url, audio_url=body.audio_url, audio_duration=body.audio_duration, @@ -78,8 +116,18 @@ def create_lipsync_job( project_id=body.project_id, ) except ValueError as exc: + if _points_deducted > 0 and _points_svc is not None: + try: + _points_svc.refund_points(user_id, _points_deducted, _points_scene, db) + except Exception as refund_err: + logger.warning(f"对口型 ValueError 退积分异常: err={refund_err}") raise HTTPException(status_code=400, detail=str(exc)) from exc except MediaKitError as exc: + if _points_deducted > 0 and _points_svc is not None: + try: + _points_svc.refund_points(user_id, _points_deducted, _points_scene, db) + except Exception as refund_err: + logger.warning(f"对口型 MediaKitError 退积分异常: err={refund_err}") status_code = 502 if exc.code in ("VoiceForbidden",): status_code = 403 @@ -95,11 +143,24 @@ def create_lipsync_job( ) from exc except Exception as exc: logger.error("创建对口型任务异常: %s", exc, exc_info=True) + if _points_deducted > 0 and _points_svc is not None: + try: + _points_svc.refund_points(user_id, _points_deducted, _points_scene, db) + except Exception as refund_err: + logger.warning(f"对口型异常退积分异常: err={refund_err}") raise HTTPException( status_code=400, detail=f"创建对口型任务失败: {exc}", ) from exc + # 创建成功但状态为 failed(同步路径失败已抛异常到上面 except;此处处理 Celery 调度失败等) + # 若任务已创建且状态为 failed,退费 + if _points_deducted > 0 and _points_svc is not None and getattr(job, "status", None) == "failed": + try: + _points_svc.refund_points(user_id, _points_deducted, _points_scene, db, ref_id=job.id) + except Exception as refund_err: + logger.warning(f"对口型任务失败退积分异常: job_id={job.id}, err={refund_err}") + return job @@ -110,8 +171,34 @@ def create_lipsync_job( def preview_tts( body: AiAvatarTtsPreviewRequest, current_user: AuthenticatedUser = Depends(get_current_user), + db: Session = Depends(get_db_session), svc: LipsyncService = Depends(_get_service), ): + user_id = current_user.user.id + + # ── 积分扣点(#1895 P2) ── + _points_deducted = 0 + _points_scene = "ai_digital_human" + _points_svc = PointsService() if settings.points_enabled else None + if _points_svc is not None: + est_minutes = max(1.0, math.ceil(len(body.script_text or "") / 240)) if body.script_text else 1.0 + _points_deducted = calculate_points_cost( + _points_scene, + is_member=getattr(current_user.user, "is_member", False), + duration_minutes=est_minutes, + member_type=getattr(current_user.user, "member_type", None), + ) + _deduct_res = _points_svc.deduct_points(user_id, _points_deducted, _points_scene, db) + if not _deduct_res["success"]: + raise HTTPException( + status_code=402, + detail={ + "code": "INSUFFICIENT_POINTS", + "message": f"积分不足,需要 {_points_deducted} 积分,当前余额 {_deduct_res['balance']}", + "required": _points_deducted, + "balance": _deduct_res["balance"], + }, + ) """步骤1「生成配音」同步 TTS 预合成. 同步执行 TTS 合成 → 下载音频 → ffprobe 时长 → 句子时间戳计算, @@ -120,13 +207,18 @@ def preview_tts( """ try: result = svc.preview_tts( - user_id=current_user.user.id, + user_id=user_id, voice_id=body.voice_id, script_text=body.script_text, speed=body.speed, emotion=body.emotion, ) except MediaKitError as exc: + if _points_deducted > 0 and _points_svc is not None: + try: + _points_svc.refund_points(user_id, _points_deducted, _points_scene, db) + except Exception as refund_err: + logger.warning(f"TTS 预合成 MediaKitError 退积分异常: err={refund_err}") status_code = 400 if exc.code in ("VoiceForbidden",): status_code = 403 @@ -141,6 +233,11 @@ def preview_tts( ) from exc except Exception as exc: logger.error("TTS 预合成异常: %s", exc, exc_info=True) + if _points_deducted > 0 and _points_svc is not None: + try: + _points_svc.refund_points(user_id, _points_deducted, _points_scene, db) + except Exception as refund_err: + logger.warning(f"TTS 预合成异常退积分异常: err={refund_err}") raise HTTPException( status_code=400, detail=f"TTS 合成失败: {exc}", @@ -193,7 +290,22 @@ def get_lipsync_job( raise HTTPException(status_code=404, detail="任务不存在") if job.status not in ("completed", "failed"): - background.add_task(svc.refresh_job_status, job_id, current_user.user.id) + # 三层防御 ①:如果距上次更新超过 30 秒,同步刷新一次(避免 background task + # 静默失败导致前端永远看到 running);否则挂后台异步刷新(避免阻塞轮询)。 + from datetime import datetime as _dt + + _now = _dt.now(UTC) + _stale = job.updated_at is None or (_now - job.updated_at).total_seconds() > 30 + if _stale: + try: + refreshed = svc.refresh_job_status(job_id, current_user.user.id) + if refreshed is not None: + job = refreshed + except Exception as exc: # noqa: BLE001 + logger.error("同步刷新对口型状态失败 job_id=%s err=%s", job_id, exc, exc_info=True) + background.add_task(svc.refresh_job_status, job_id, current_user.user.id) + else: + background.add_task(svc.refresh_job_status, job_id, current_user.user.id) return job diff --git a/apps/api/app/api/routes/points.py b/apps/api/app/api/routes/points.py new file mode 100644 index 000000000..9bdfb48d3 --- /dev/null +++ b/apps/api/app/api/routes/points.py @@ -0,0 +1,321 @@ +"""积分 & 会员 API 路由 (#1895) + +导出两个 router: +- points_router: 积分相关路由,前缀 /points +- usage_router: 每日额度路由,前缀 /usage +""" + +from __future__ import annotations + +import logging +from datetime import datetime +from typing import Optional + +from app.auth import AuthenticatedUser, get_current_user +from app.dependencies import get_db_session +from app.schemas.points import ( + DailyUsageResponse, + MembershipStatusResponse, + PointRuleItem, + PointsBalanceResponse, + PointsCheckRequest, + PointsCheckResponse, + PointsDeductRequest, + PointsOrderResponse, + PointsPackageItem, + PointsPackagesResponse, + PointsRechargeRequest, + PointsRefundRequest, + PointsRulesResponse, + PointsTransactionsResponse, + SimpleMessageResponse, +) +from fastapi import APIRouter, Depends, HTTPException, Query +from sqlalchemy.orm import Session + +from packages.domain.points_rules import ( + FREE_USER_MULTIPLIER, + MEMBER_DISCOUNT, + POINTS_PACKAGES, + POINTS_SCENES, + calculate_points_cost, +) +from packages.domain.points_service import PointsService + +logger = logging.getLogger(__name__) + +# ── 两个 router ── +points_router = APIRouter() +usage_router = APIRouter() + + +def _get_service() -> PointsService: + return PointsService() + + +def _is_member(user: AuthenticatedUser) -> bool: + """判断用户是否为付费会员。""" + return getattr(user.user, "is_member", False) + + +def _member_type(user: AuthenticatedUser) -> str | None: + return getattr(user.user, "member_type", None) + + +# ════════════════════════════════════════════════════════════════ +# 积分相关路由 (prefix=/points) +# ════════════════════════════════════════════════════════════════ + + +@points_router.get("/balance", response_model=PointsBalanceResponse) +def get_balance( + current_user: AuthenticatedUser = Depends(get_current_user), + db: Session = Depends(get_db_session), +): + """查询当前用户积分余额 + 会员状态。""" + svc = _get_service() + account = svc.get_or_create_account(current_user.user.id, db) + return PointsBalanceResponse( + balance=account["balance"], + total_earned=account["total_earned"], + total_spent=account["total_spent"], + is_member=_is_member(current_user), + member_type=_member_type(current_user), + member_expires_at=getattr(current_user.user, "member_expires_at", None), + ) + + +@points_router.get("/transactions", response_model=PointsTransactionsResponse) +def get_transactions( + page: int = Query(1, ge=1), + page_size: int = Query(20, ge=1, le=100), + type: Optional[str] = Query(None, description="筛选类型: add/deduct"), + source: Optional[str] = Query(None, description="筛选来源场景"), + start_date: Optional[datetime] = Query(None), + end_date: Optional[datetime] = Query(None), + current_user: AuthenticatedUser = Depends(get_current_user), + db: Session = Depends(get_db_session), +): + """查询积分流水(分页+筛选)。""" + svc = _get_service() + result = svc.get_transactions( + user_id=current_user.user.id, + db=db, + page=page, + page_size=page_size, + type_filter=type, + source_filter=source, + start_date=start_date, + end_date=end_date, + ) + return PointsTransactionsResponse(**result) + + +@points_router.get("/rules", response_model=PointsRulesResponse) +def get_rules( + _current_user: AuthenticatedUser = Depends(get_current_user), +): + """查询所有积分消耗规则。""" + rules = [] + for scene_key, scene_data in POINTS_SCENES.items(): + rules.append( + PointRuleItem( + scene_key=scene_key, + name=scene_data["name"], + base_points=scene_data["base_points"], + unit=scene_data["unit"], + extra_per_30s=scene_data.get("extra_per_30s"), + ) + ) + return PointsRulesResponse( + rules=rules, + free_user_multiplier=FREE_USER_MULTIPLIER, + ) + + +@points_router.get("/packages", response_model=PointsPackagesResponse) +def get_packages( + current_user: AuthenticatedUser = Depends(get_current_user), +): + """查询可购买的积分包列表。""" + packages = [] + for code, pkg in POINTS_PACKAGES.items(): + unit_price = f"¥{pkg['price_cents'] / 100 / pkg['points']:.3f}/积分" + packages.append( + PointsPackageItem( + code=code, + name=pkg["name"], + points=pkg["points"], + price_cents=pkg["price_cents"], + unit_price=unit_price, + ) + ) + mt = _member_type(current_user) + discount = MEMBER_DISCOUNT.get(mt) if mt else None + return PointsPackagesResponse(packages=packages, user_discount=discount) + + +@points_router.post("/check", response_model=PointsCheckResponse) +def check_points( + body: PointsCheckRequest, + current_user: AuthenticatedUser = Depends(get_current_user), + db: Session = Depends(get_db_session), +): + """消费前检查余额是否足够。""" + is_mem = _is_member(current_user) + mt = _member_type(current_user) + + # 混剪场景先检查免费额度 + is_free_quota = False + if body.scene_key == "ai_video" and not is_mem: + svc = _get_service() + if svc.check_daily_free_clip(current_user.user.id, db): + is_free_quota = True + + required = calculate_points_cost( + body.scene_key, + is_mem, + quantity=body.quantity or 1, + duration_minutes=body.duration_minutes or 0, + member_type=mt, + ) + + svc = _get_service() + account = svc.get_or_create_account(current_user.user.id, db) + balance = account["balance"] + + return PointsCheckResponse( + allowed=is_free_quota or balance >= required, + required_points=required, + current_balance=balance, + remaining_after=balance - required, + is_free_quota=is_free_quota, + ) + + +@points_router.post("/deduct", response_model=SimpleMessageResponse) +def deduct_points( + body: PointsDeductRequest, + current_user: AuthenticatedUser = Depends(get_current_user), + db: Session = Depends(get_db_session), +): + """积分扣减(内部服务调用)。""" + svc = _get_service() + result = svc.deduct_points( + user_id=current_user.user.id, + amount=body.amount, + source=body.scene_key, + db=db, + description=body.description or "", + ref_id=body.ref_id or "", + ) + if not result["success"]: + raise HTTPException( + status_code=402, + detail={ + "code": "INSUFFICIENT_POINTS", + "message": f"积分不足,需要 {body.amount},余额 {result['balance']}", + }, + ) + return SimpleMessageResponse( + success=True, + message=f"扣减 {body.amount} 积分成功", + data={"transaction_id": result["transaction_id"], "balance": result["balance"]}, + ) + + +@points_router.post("/refund", response_model=SimpleMessageResponse) +def refund_points( + body: PointsRefundRequest, + current_user: AuthenticatedUser = Depends(get_current_user), + db: Session = Depends(get_db_session), +): + """积分退还(内部服务调用)。""" + from packages.adapters.sqlalchemy_impl.models import PointsTransactionModel + + txn = ( + db.query(PointsTransactionModel) + .filter(PointsTransactionModel.id == body.transaction_id) + .first() + ) + if txn is None: + raise HTTPException(status_code=404, detail="交易记录不存在") + if txn.user_id != current_user.user.id: + raise HTTPException(status_code=403, detail="无权退还他人积分") + + svc = _get_service() + result = svc.refund_points( + user_id=current_user.user.id, + amount=txn.amount, + source=txn.source, + db=db, + ref_id=body.transaction_id, + description=body.reason or f"退还: {txn.description}", + ) + if not result["success"]: + raise HTTPException(status_code=500, detail="退还失败") + return SimpleMessageResponse( + success=True, + message=f"退还 {txn.amount} 积分成功", + data={"transaction_id": result["transaction_id"], "balance": result["balance"]}, + ) + + +@points_router.post("/recharge", response_model=PointsOrderResponse) +def create_recharge_order( + body: PointsRechargeRequest, + current_user: AuthenticatedUser = Depends(get_current_user), + db: Session = Depends(get_db_session), +): + """创建积分充值订单。""" + svc = _get_service() + try: + order = svc.create_order( + user_id=current_user.user.id, + order_type="points", + product_code=body.package_id, + db=db, + ) + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) from None + return PointsOrderResponse(**order) + + +@points_router.get("/subscription/membership", response_model=MembershipStatusResponse) +def get_membership_status( + current_user: AuthenticatedUser = Depends(get_current_user), + db: Session = Depends(get_db_session), +): + """获取当前用户会员状态(聚合信息)。""" + svc = _get_service() + account = svc.get_or_create_account(current_user.user.id, db) + is_mem = _is_member(current_user) + max_resolution = "1080p" if is_mem else "720p" + + return MembershipStatusResponse( + is_member=is_mem, + member_type=_member_type(current_user), + member_expires_at=getattr(current_user.user, "member_expires_at", None), + points_balance=account["balance"], + max_resolution=max_resolution, + ) + + +# ════════════════════════════════════════════════════════════════ +# 每日额度路由 (prefix=/usage) +# ════════════════════════════════════════════════════════════════ + + +@usage_router.get("/daily", response_model=DailyUsageResponse) +def get_daily_usage( + current_user: AuthenticatedUser = Depends(get_current_user), + db: Session = Depends(get_db_session), +): + """查询今日免费混剪额度使用情况。""" + svc = _get_service() + result = svc.get_daily_usage(current_user.user.id, db) + return DailyUsageResponse(**result) + + +# 为了向后兼容,也导出一个不带后缀的 router(方便旧引用) +router = points_router diff --git a/apps/api/app/api/routes/scripts.py b/apps/api/app/api/routes/scripts.py index 11f7276ee..ef30fd3f0 100644 --- a/apps/api/app/api/routes/scripts.py +++ b/apps/api/app/api/routes/scripts.py @@ -1,4 +1,4 @@ -"""Script (口播文案库) CRUD routes — Issue #1795 & #1894.""" +"""Script (口播文案库) CRUD routes — Issue #1795.""" from __future__ import annotations @@ -73,9 +73,9 @@ def create_script( content=request.content, segments=[s.model_dump() for s in request.segments], tags=request.tags, - title_text=request.title_text, - title_category=request.title_category, - title_config=request.title_config, + title_text=request.title_text or "", + title_category=request.title_category or "", + title_config=request.title_config or {}, ) return _to_response(script) diff --git a/apps/api/app/api/routes/scripts_ai.py b/apps/api/app/api/routes/scripts_ai.py new file mode 100644 index 000000000..78a999942 --- /dev/null +++ b/apps/api/app/api/routes/scripts_ai.py @@ -0,0 +1,243 @@ +"""Scripts AI 能力路由 — Issue #1893. + +三个 AI 工具接口(均挂载在 /api/v1/scripts 前缀下): +- POST /extract-from-douyin 从抖音视频提取文案(yt-dlp 下载 + ASR 转写) +- POST /ai-rewrite AI 文案改写(复用豆包 LLM) +- POST /ai-generate-titles AI 标题生成(复用 generate_smart_titles) +""" + +from __future__ import annotations + +import logging +import re +import tempfile + +from app.auth import AuthenticatedUser, get_current_user +from app.dependencies import get_db_session +from app.schemas.scripts_ai import ( + AiGenerateTitlesRequest, + AiGenerateTitlesResponse, + AiRewriteRequest, + AiRewriteResponse, + ExtractFromDouyinRequest, + ExtractFromDouyinResponse, +) +from app.services.script_asr_service import ( + ASRNotConfiguredError, + ASRTranscriptionError, + transcribe_to_text, +) +from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy.orm import Session + +from packages.middleware.points_gate import points_gate +from packages.shared.ai_client import get_doubao_client + +logger = logging.getLogger(__name__) + +router = APIRouter() + +# 抖音 URL 校验:支持短链 v.douyin.com 和长链 www.douyin.com/video/ +_DOUYIN_URL_RE = re.compile( + r"^(https?://)?(v\.douyin\.com/\S+|www\.douyin\.com/video/\S+)$", + re.IGNORECASE, +) + + +def _validate_douyin_url(url: str) -> None: + """校验抖音 URL 格式,不合法时抛 HTTPException(400).""" + if not url or not url.strip(): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="链接不能为空", + ) + if not _DOUYIN_URL_RE.match(url.strip()): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="无效的抖音链接,仅支持 v.douyin.com 短链或 www.douyin.com/video/ 长链", + ) + + +# ── 1. 从抖音视频提取文案 ───────────────────────────────────────────────────── + + +@router.post( + "/extract-from-douyin", + response_model=ExtractFromDouyinResponse, +) +@points_gate("douyin_extract") +def extract_from_douyin( + request: ExtractFromDouyinRequest, + current_user: AuthenticatedUser = Depends(get_current_user), + db: Session = Depends(get_db_session), +) -> ExtractFromDouyinResponse: + """从抖音视频下载无水印视频并通过 ASR 提取文案.""" + source_url = request.url.strip() + _validate_douyin_url(source_url) + + # 确保 URL 有 scheme(yt-dlp 需要完整 URL) + url_for_download = source_url + if not re.match(r"^https?://", url_for_download, re.IGNORECASE): + url_for_download = "https://" + url_for_download + + # 使用临时目录下载视频,退出时自动清理 + try: + with tempfile.TemporaryDirectory(prefix="douyin_extract_") as temp_dir: + import yt_dlp + + ydl_opts = { + "format": "best[ext=mp4]/best", + "outtmpl": f"{temp_dir}/%(id)s.%(ext)s", + "quiet": True, + "no_warnings": True, + "noplaylist": True, + } + + try: + ydl = yt_dlp.YoutubeDL(ydl_opts) + info = ydl.extract_info(url_for_download, download=True) + except Exception as exc: + logger.error("抖音视频下载失败: url=%s error=%s", source_url, exc) + raise HTTPException( + status_code=status.HTTP_502_BAD_GATEWAY, + detail=f"视频下载失败: {exc}", + ) from exc + + if info is None: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="无法解析该抖音链接", + ) + + video_path = ydl.prepare_filename(info) + duration = float(info.get("duration") or 0) + + # ASR 转写 + try: + text = transcribe_to_text(video_path) + except ASRNotConfiguredError as exc: + raise HTTPException( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + detail=str(exc), + ) from exc + except ASRTranscriptionError as exc: + raise HTTPException( + status_code=status.HTTP_502_BAD_GATEWAY, + detail=str(exc), + ) from exc + + except HTTPException: + raise + + return ExtractFromDouyinResponse( + text=text, + duration_seconds=duration, + source_url=source_url, + ) + + +# ── 2. AI 文案改写 ─────────────────────────────────────────────────────────── + + +@router.post( + "/ai-rewrite", + response_model=AiRewriteResponse, +) +@points_gate("ai_rewrite") +def ai_rewrite( + request: AiRewriteRequest, + current_user: AuthenticatedUser = Depends(get_current_user), + db: Session = Depends(get_db_session), +) -> AiRewriteResponse: + """使用豆包大模型改写文案.""" + content = (request.content or "").strip() + if not content: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="文案内容不能为空", + ) + + style = request.style or "口语化" + + client = get_doubao_client() + if not client.is_available: + raise HTTPException( + status_code=status.HTTP_502_BAD_GATEWAY, + detail="AI 服务不可用,请联系管理员配置豆包大模型 API Key", + ) + + system_prompt = ( + "你是一个专业的短视频文案改写专家。请对以下文案进行改写," + "要求:保留原意、口语化、适合短视频口播、调整语序避免查重。" + ) + if style: + system_prompt += f"\n风格要求:{style}" + + user_prompt = f"请改写以下文案:\n\n{content}" + + messages = [ + {"role": "system", "content": system_prompt}, + {"role": "user", "content": user_prompt}, + ] + + try: + rewritten = client.chat_completion( + messages=messages, + temperature=0.8, + max_tokens=2048, + ) + except Exception as exc: + logger.error("AI 改写调用失败: %s", exc) + raise HTTPException( + status_code=status.HTTP_502_BAD_GATEWAY, + detail=f"AI 改写失败: {exc}", + ) from exc + + if not rewritten: + raise HTTPException( + status_code=status.HTTP_502_BAD_GATEWAY, + detail="AI 改写未返回有效结果", + ) + + return AiRewriteResponse( + original=content, + rewritten=rewritten.strip(), + style=style, + ) + + +# ── 3. AI 标题生成 ─────────────────────────────────────────────────────────── + + +@router.post( + "/ai-generate-titles", + response_model=AiGenerateTitlesResponse, +) +@points_gate("ai_title") +def ai_generate_titles( + request: AiGenerateTitlesRequest, + current_user: AuthenticatedUser = Depends(get_current_user), + db: Session = Depends(get_db_session), +) -> AiGenerateTitlesResponse: + """使用现有 generate_smart_titles 生成标题.""" + content = (request.content or "").strip() + if not content: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="文案内容不能为空", + ) + + # count 限制在 1-5(Pydantic ge=1 le=5 已校验),但为兼容直接调用场景截断 + count = max(1, min(5, request.count)) + + from app.services.ai_service import generate_smart_titles + + result = generate_smart_titles( + description=content, + style="viral", + count=count, + ) + + titles = result.get("titles", [])[:count] + + return AiGenerateTitlesResponse(titles=titles) diff --git a/apps/api/app/api/routes/subscription.py b/apps/api/app/api/routes/subscription.py index ae7944a74..e66fac515 100755 --- a/apps/api/app/api/routes/subscription.py +++ b/apps/api/app/api/routes/subscription.py @@ -4,8 +4,7 @@ from __future__ import annotations import logging from dataclasses import replace -from datetime import datetime, timezone -from typing import List +from datetime import UTC, datetime from app.auth import AuthenticatedUser, get_current_user from app.dependencies import get_user_repository @@ -67,7 +66,7 @@ def _get_plan_price(plan_id: str, billing_cycle: str) -> float: def _build_subscription_info(user: AuthenticatedUser) -> SubscriptionInfo: """构建订阅信息响应""" - now = datetime.now(timezone.utc) + now = datetime.now(UTC) if user.user.subscription_expires_at: period_end = user.user.subscription_expires_at.isoformat() period_start = now.isoformat() @@ -100,10 +99,10 @@ async def get_current_subscription( return _build_subscription_info(current_user) -@router.get("/billing-records", response_model=List[BillingRecord]) +@router.get("/billing-records", response_model=list[BillingRecord]) async def get_billing_records( current_user: AuthenticatedUser = Depends(get_current_user), -) -> List[BillingRecord]: +) -> list[BillingRecord]: """获取账单记录列表""" from packages.adapters.sqlalchemy_impl.billing_repository import SQLAlchemyBillingRepository from packages.adapters.sqlalchemy_impl.session import SessionLocal @@ -251,7 +250,7 @@ async def payment_callback( # 计算到期时间 days = 365 if billing_cycle == "yearly" else 30 - expires_at = datetime.now(timezone.utc) + timedelta(days=days) + expires_at = datetime.now(UTC) + timedelta(days=days) repo.update_subscription_on_payment(user_id, plan, expires_at) return {"success": True, "message": "支付成功", "record_id": record_id} diff --git a/apps/api/app/api/routes/templates.py b/apps/api/app/api/routes/templates.py index 05bbd8355..d11beef4e 100644 --- a/apps/api/app/api/routes/templates.py +++ b/apps/api/app/api/routes/templates.py @@ -1,4 +1,13 @@ -"""Template CRUD + generate + category routes.""" +"""Template 列表路由(供生成页自动选模板). + +保留: +- GET /templates:列表查询(生成页使用) +- 默认模板自动创建兜底逻辑(复用 _default_template.get_or_create_default_template_id) + +其他模板 CRUD / 分类 / 标签 / 收藏 / 复制 / 校验 / 使用统计等 HTTP 端点 +已在 PR#1918 中删除(前端 PR#1911 已删除 my-templates / editing-planner / +templates 管理页面)。 +""" from __future__ import annotations @@ -7,53 +16,19 @@ import logging from app.auth import AuthenticatedUser, get_current_user from app.dependencies import get_db_session from app.schemas.template import ( - CategoryResponse, - CopyTemplateRequest, - CreateCategoryRequest, - CreateTemplateRequest, - GenerateWarningResponse, - ListCategoriesResponse, - ListTagsResponse, ListTemplatesResponse, - SegmentResponse, TemplateResponse, - TemplateUsageResponse, - ToggleFavoriteResponse, - UpdateTemplateRequest, - ValidateTemplateRequest, - ValidateTemplateResponse, ) -from fastapi import APIRouter, Depends, HTTPException, Query, Response, status +from fastapi import APIRouter, Depends, Query from sqlalchemy.orm import Session logger = logging.getLogger(__name__) from packages.adapters.sqlalchemy_impl.template_repository import SQLAlchemyTemplateRepository -from packages.application.template.commands import ( - CopyTemplateCommand, - CreateCategoryCommand, - CreateTemplateCommand, - ListTemplatesFilter, - SegmentCommand, - UpdateTemplateCommand, - ValidateTemplateCommand, -) -from packages.application.template.use_cases import ( - CopyTemplateUseCase, - CountTemplatesUseCase, - CreateCategoryUseCase, - CreateTemplateUseCase, - DeleteCategoryUseCase, - DeleteTemplateUseCase, - GetTemplateUseCase, - ListCategoriesUseCase, - ListTagsUseCase, - ListTemplatesUseCase, - NotFoundError, - UpdateTemplateUseCase, - ValidateTemplateUseCase, - ValidationError, -) +from packages.application.template.commands import ListTemplatesFilter +from packages.application.template.use_cases import CountTemplatesUseCase, ListTemplatesUseCase + +from ._default_template import get_or_create_default_template_id router = APIRouter() @@ -62,354 +37,32 @@ def _get_template_repository(session: Session = Depends(get_db_session)) -> SQLA return SQLAlchemyTemplateRepository(session) -def _segment_to_response(seg) -> SegmentResponse: - return SegmentResponse( - id=seg.id, - template_id=seg.template_id, - segment_order=seg.segment_order, - duration_min=seg.duration_min, - duration_max=seg.duration_max, - material_type=seg.material_type, - created_at=seg.created_at, - updated_at=seg.updated_at, - ) - - -def _to_response(template, usage_count: int = 0) -> TemplateResponse: - return TemplateResponse( - id=template.id, - user_id=template.user_id, - name=template.name, - mode=template.mode, - category=template.category, - tags=template.tags, - title_config=template.title_config, - subtitle_config=template.subtitle_config, - bgm_config=template.bgm_config, - estimated_duration=template.estimated_duration, - segments=[_segment_to_response(s) for s in getattr(template, "segments", [])], - is_active=template.is_active, - usage_count=usage_count, - created_at=template.created_at, - updated_at=template.updated_at, - ) - - -# ── Template CRUD ── - - -@router.get("", response_model=ListTemplatesResponse) +@router.get("", response_model=ListTemplatesResponse, summary="获取模板列表") def list_templates( - skip: int = Query(0, ge=0), - limit: int = Query(50, ge=1, le=200), + mode: str | None = Query(None, description="编辑模式:generic/vlog/storyboard,不传返回全部"), category: str | None = Query(None, description="按分类筛选"), tag: str | None = Query(None, description="按标签筛选"), - keyword: str | None = Query(None, description="按名称关键词搜索"), - mode: str | None = Query(None, description="按剪辑模式筛选"), - valid_only: bool = Query( - False, - description="仅返回已配置片段的模板(剪辑页传 true;模板编辑器不传,可查看全部模板含草稿)", - ), - authenticated_user: AuthenticatedUser = Depends(get_current_user), - template_repository: SQLAlchemyTemplateRepository = Depends(_get_template_repository), -) -> ListTemplatesResponse: - user_id = authenticated_user.user.id - try: - tpl_filter = ListTemplatesFilter( - category=category, - tag=tag, - keyword=keyword, - mode=mode, - valid_only=valid_only, - ) - use_case = ListTemplatesUseCase(template_repository) - templates = use_case.execute(user_id, skip=skip, limit=limit, filter=tpl_filter) - count_use_case = CountTemplatesUseCase(template_repository) - total = count_use_case.execute(user_id, filter=tpl_filter) + page: int = Query(1, ge=1, description="页码,从 1 开始"), + page_size: int = Query(20, ge=1, le=100, description="每页条数,默认 20"), + current_user: AuthenticatedUser = Depends(get_current_user), + repo: SQLAlchemyTemplateRepository = Depends(_get_template_repository), + db: Session = Depends(get_db_session), +): + """获取用户可用的模板列表(仅返回 active 状态)。""" + user_id = str(current_user.user.id) + # P0 兜底:无有效模板时自动创建默认配音模板(解决新用户首次进入生成页 404) + get_or_create_default_template_id(db, user_id) - # 批量查询使用次数 - items = [] - for t in templates: - usage = template_repository.get_usage_count(t.id) - items.append(_to_response(t, usage_count=usage)) - except Exception: - logger.exception("list_templates 查询失败: user_id=%s", user_id) - return ListTemplatesResponse(items=[], total=0) - return ListTemplatesResponse( - items=items, - total=total, + list_uc = ListTemplatesUseCase(repo) + count_uc = CountTemplatesUseCase(repo) + filters = ListTemplatesFilter( + category=category, + tag=tag, + mode=mode, + valid_only=True, # 仅返回 active + 有片段配置 ) - - -@router.get("/{template_id}", response_model=TemplateResponse) -def get_template( - template_id: str, - authenticated_user: AuthenticatedUser = Depends(get_current_user), - template_repository: SQLAlchemyTemplateRepository = Depends(_get_template_repository), -) -> TemplateResponse: - user_id = authenticated_user.user.id - try: - use_case = GetTemplateUseCase(template_repository) - template = use_case.execute(template_id, user_id) - usage = template_repository.get_usage_count(template_id) - except Exception as _e: - logger.exception("get_template 查询失败: template_id=%s", template_id) - raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="模板查询失败") from _e - if template is None: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Template not found") - return _to_response(template, usage_count=usage) - - -@router.post("", response_model=TemplateResponse, status_code=status.HTTP_201_CREATED) -def create_template( - request: CreateTemplateRequest, - authenticated_user: AuthenticatedUser = Depends(get_current_user), - template_repository: SQLAlchemyTemplateRepository = Depends(_get_template_repository), -) -> TemplateResponse: - user_id = authenticated_user.user.id - command = CreateTemplateCommand( - user_id=user_id, - name=request.name, - mode=request.mode, - category=request.category, - tags=request.tags, - title_config=request.title_config, - subtitle_config=request.subtitle_config, - bgm_config=request.bgm_config, - estimated_duration=request.estimated_duration, - segments=[ - SegmentCommand( - segment_order=s.segment_order, - duration_min=s.duration_min, - duration_max=s.duration_max, - material_type=s.material_type, - ) - for s in request.segments - ], - ) - use_case = CreateTemplateUseCase(template_repository) - try: - template = use_case.execute(command) - except ValidationError as exc: - raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc)) from exc - return _to_response(template) - - -@router.patch("/{template_id}", response_model=TemplateResponse) -def update_template( - template_id: str, - request: UpdateTemplateRequest, - authenticated_user: AuthenticatedUser = Depends(get_current_user), - template_repository: SQLAlchemyTemplateRepository = Depends(_get_template_repository), -) -> TemplateResponse: - user_id = authenticated_user.user.id - command = UpdateTemplateCommand( - template_id=template_id, - user_id=user_id, - name=request.name, - mode=request.mode, - category=request.category, - tags=request.tags, - title_config=request.title_config, - subtitle_config=request.subtitle_config, - bgm_config=request.bgm_config, - estimated_duration=request.estimated_duration, - segments=( - [ - SegmentCommand( - segment_order=s.segment_order, - duration_min=s.duration_min, - duration_max=s.duration_max, - material_type=s.material_type, - ) - for s in request.segments - ] - if request.segments is not None - else None - ), - ) - use_case = UpdateTemplateUseCase(template_repository) - try: - template = use_case.execute(command) - except NotFoundError as _e: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Template not found") from _e - except ValidationError as exc: - raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc)) from exc - return _to_response(template) - - -@router.delete("/{template_id}", status_code=status.HTTP_204_NO_CONTENT, response_model=None, response_class=Response) -def delete_template( - template_id: str, - authenticated_user: AuthenticatedUser = Depends(get_current_user), - template_repository: SQLAlchemyTemplateRepository = Depends(_get_template_repository), -) -> Response: - user_id = authenticated_user.user.id - use_case = DeleteTemplateUseCase(template_repository) - deleted = use_case.execute(template_id, user_id) - if not deleted: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Template not found") - return - - -@router.post("/{template_id}/copy", response_model=TemplateResponse, status_code=status.HTTP_201_CREATED) -def copy_template( - template_id: str, - request: CopyTemplateRequest, - authenticated_user: AuthenticatedUser = Depends(get_current_user), - template_repository: SQLAlchemyTemplateRepository = Depends(_get_template_repository), -) -> TemplateResponse: - """复制模板(含所有片段配置)""" - user_id = authenticated_user.user.id - command = CopyTemplateCommand( - template_id=template_id, - user_id=user_id, - new_name=request.new_name, - ) - use_case = CopyTemplateUseCase(template_repository) - try: - template = use_case.execute(command) - except NotFoundError as _e: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Template not found") from _e - except ValidationError as exc: - raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc)) from exc - return _to_response(template) - - -@router.get("/{template_id}/usage", response_model=TemplateUsageResponse) -def get_template_usage( - template_id: str, - authenticated_user: AuthenticatedUser = Depends(get_current_user), - template_repository: SQLAlchemyTemplateRepository = Depends(_get_template_repository), -) -> TemplateUsageResponse: - """获取模板使用次数(关联的剪辑计划数量)""" - user_id = authenticated_user.user.id - # 鉴权:确保模板存在且属于当前用户 - use_case = GetTemplateUseCase(template_repository) - template = use_case.execute(template_id, user_id) - if template is None: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Template not found") - usage = template_repository.get_usage_count(template_id) - return TemplateUsageResponse(template_id=template_id, usage_count=usage) - - -@router.post("/{template_id}/toggle-favorite", response_model=ToggleFavoriteResponse) -def toggle_favorite( - template_id: str, - authenticated_user: AuthenticatedUser = Depends(get_current_user), - template_repository: SQLAlchemyTemplateRepository = Depends(_get_template_repository), -) -> ToggleFavoriteResponse: - """切换模板收藏状态(当前为兼容端点,始终返回 false)""" - user_id = authenticated_user.user.id - use_case = GetTemplateUseCase(template_repository) - try: - template = use_case.execute(template_id, user_id) - except Exception as _e: - logger.exception("toggle_favorite 查询失败: template_id=%s", template_id) - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Template not found") from _e - if template is None: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Template not found") - return ToggleFavoriteResponse(id=template_id, is_favorite=False) - - -# ── Validate template ── - - -@router.post("/{template_id}/validate", response_model=ValidateTemplateResponse) -def validate_template( - template_id: str, - request: ValidateTemplateRequest = ValidateTemplateRequest(), - authenticated_user: AuthenticatedUser = Depends(get_current_user), - template_repository: SQLAlchemyTemplateRepository = Depends(_get_template_repository), -) -> ValidateTemplateResponse: - user_id = authenticated_user.user.id - command = ValidateTemplateCommand( - template_id=template_id, - user_id=user_id, - voiceover_duration=request.voiceover_duration, - ) - use_case = ValidateTemplateUseCase(template_repository) - try: - result = use_case.execute(command) - except NotFoundError as _e: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Template not found") from _e - except ValidationError as exc: - raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc)) from exc - - return ValidateTemplateResponse( - template=_to_response(result.template), - warnings=[GenerateWarningResponse(code=w.code, message=w.message, details=w.details) for w in result.warnings], - ) - - -# ── Category CRUD ── - - -@router.get("/categories/list", response_model=ListCategoriesResponse) -def list_categories( - authenticated_user: AuthenticatedUser = Depends(get_current_user), - template_repository: SQLAlchemyTemplateRepository = Depends(_get_template_repository), -) -> ListCategoriesResponse: - user_id = authenticated_user.user.id - try: - use_case = ListCategoriesUseCase(template_repository) - categories = use_case.execute(user_id) - except Exception: - logger.exception("list_categories 查询失败: user_id=%s", user_id) - return ListCategoriesResponse(items=[]) - return ListCategoriesResponse( - items=[CategoryResponse(id=c.id, user_id=c.user_id, name=c.name, created_at=c.created_at) for c in categories], - ) - - -@router.post("/categories", response_model=CategoryResponse, status_code=status.HTTP_201_CREATED) -def create_category( - request: CreateCategoryRequest, - authenticated_user: AuthenticatedUser = Depends(get_current_user), - template_repository: SQLAlchemyTemplateRepository = Depends(_get_template_repository), -) -> CategoryResponse: - user_id = authenticated_user.user.id - command = CreateCategoryCommand(user_id=user_id, name=request.name) - use_case = CreateCategoryUseCase(template_repository) - category = use_case.execute(command) - return CategoryResponse( - id=category.id, - user_id=category.user_id, - name=category.name, - created_at=category.created_at, - ) - - -@router.delete( - "/categories/{category_id}", status_code=status.HTTP_204_NO_CONTENT, response_model=None, response_class=Response -) -def delete_category( - category_id: str, - authenticated_user: AuthenticatedUser = Depends(get_current_user), - template_repository: SQLAlchemyTemplateRepository = Depends(_get_template_repository), -) -> Response: - user_id = authenticated_user.user.id - use_case = DeleteCategoryUseCase(template_repository) - deleted = use_case.execute(category_id, user_id) - if not deleted: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Category not found") - return Response(status_code=204) - - -# ── Tags ── - - -@router.get("/tags/list", response_model=ListTagsResponse) -def list_tags( - authenticated_user: AuthenticatedUser = Depends(get_current_user), - template_repository: SQLAlchemyTemplateRepository = Depends(_get_template_repository), -) -> ListTagsResponse: - """获取用户所有模板标签(去重排序)""" - user_id = authenticated_user.user.id - try: - use_case = ListTagsUseCase(template_repository) - tags = use_case.execute(user_id) - except Exception: - logger.exception("list_tags 查询失败: user_id=%s", user_id) - return ListTagsResponse(items=[]) - return ListTagsResponse(items=tags) + skip = (page - 1) * page_size + templates = list_uc.execute(user_id, skip=skip, limit=page_size, filter=filters) + total = count_uc.execute(user_id, filter=filters) + items = [TemplateResponse.model_validate(tpl, from_attributes=True) for tpl in templates] + return ListTemplatesResponse(items=items, total=total) diff --git a/apps/api/app/api/routes/templates_editor/__init__.py b/apps/api/app/api/routes/templates_editor/__init__.py index c8f1c2944..62c682e1e 100755 --- a/apps/api/app/api/routes/templates_editor/__init__.py +++ b/apps/api/app/api/routes/templates_editor/__init__.py @@ -28,7 +28,7 @@ from .adjustments import router as adjustments_router from .ai_features import router as ai_features_router from .bgm import router as bgm_router from .clips import router as clips_router -from .dependencies import get_draft_plan_id, get_editor_services # noqa: F401 +from .dependencies import get_draft_plan_id, get_editor_services, resolve_draft_plan_id # noqa: F401 from .draft import router as draft_router from .effects import router as effects_router from .export import router as export_router diff --git a/apps/api/app/api/routes/templates_editor/clips.py b/apps/api/app/api/routes/templates_editor/clips.py index fb774321d..c3513ff08 100755 --- a/apps/api/app/api/routes/templates_editor/clips.py +++ b/apps/api/app/api/routes/templates_editor/clips.py @@ -622,8 +622,10 @@ def create_clips_from_assets_editor( """从素材批量创建片段(按模板segment配置创建,MediaKit异步更新). 逻辑: - 1. 从模板读取 segments,片段数量 = segment 数量(忽略前端传的 required_clips_count) - 2. 每个片段时长在 segment 的 duration_min ~ duration_max 之间随机取值(保留一位小数) + 1. 从模板读取 segments,片段数量优先级:显式 clip_count(1-10)→ 旧字段 + required_clips_count(兼容,超10截断)→ 默认 3(产品默认 3 段)。 + 片段数大于模板 segment 数时按顺序循环复用 segment 配置。 + 2. 每个片段时长在对应 segment 的 duration_min ~ duration_max 之间随机取值(保留一位小数) 3. 素材按片段顺序轮询分配,素材不够时同一素材切多个片段 4. 使用 replace_all_clips_transactional 原子性地清空旧片段并创建新的(随机起始时间) 5. 立即返回响应(目标 <1秒) @@ -648,6 +650,22 @@ def create_clips_from_assets_editor( detail="模板未配置片段", ) + # 1.5 归一化片段数量: + # 优先级:显式 clip_count → 旧字段 required_clips_count(由 schema 归一化到 clip_count) + # → 默认 3(产品默认 3 段)。按 N 循环复用 segment 配置;N <= len(segments) 时截取前 N 个 + # (保持向后兼容:原模板有 N 个 segment、前端不传 clip_count 且 N<=10 时按模板段数创建; + # 默认模板仅有 1 个通用 segment 时按 clip_count=3 循环生成 3 段)。 + requested_clip_count = getattr(body, "clip_count", None) + if requested_clip_count is None: + # schema 未显式传 clip_count 且无 legacy:使用模板 segments 数量,若超出 10 则截断 + requested_clip_count = len(segments) if 1 <= len(segments) <= 10 else 3 + requested_clip_count = max(1, min(int(requested_clip_count), 10)) + effective_segments: list[tuple[int, float, float]] = [] + for i in range(requested_clip_count): + src = segments[i % len(segments)] + effective_segments.append((i, float(src[1]), float(src[2]))) + segments = effective_segments + # 防御:schema validator 已过滤 null/空串,这里再归一化一次, # 避免异常入参(undefined → null)导致后续 /assets/{id} 404 / 422 asset_ids = [str(aid).strip() for aid in (body.asset_ids or []) if isinstance(aid, str) and aid.strip()] diff --git a/apps/api/app/api/routes/templates_editor/dependencies.py b/apps/api/app/api/routes/templates_editor/dependencies.py index b23959ce6..1f70dd25f 100755 --- a/apps/api/app/api/routes/templates_editor/dependencies.py +++ b/apps/api/app/api/routes/templates_editor/dependencies.py @@ -2,13 +2,16 @@ 核心依赖: - get_editor_services: 获取模板+计划服务 -- get_draft_plan_id: 根据 template_id 获取或创建草稿,返回 plan_id +- get_draft_plan_id: Depends 形式的路径依赖(template_id 路径参数必填) +- resolve_draft_plan_id: 纯函数版本,供 clips_standalone 等非路径参数场景复用 + (支持空 tid 时自动兜底创建默认模板) """ from __future__ import annotations import logging +from app.api.routes._default_template import get_or_create_default_template_id from app.auth import AuthenticatedUser, get_current_user from app.dependencies import get_db_session from app.services.edit_plan_service import EditPlanService @@ -30,50 +33,61 @@ def get_editor_services( return EditTemplateService(db), EditPlanService(db) -def get_draft_plan_id( +def resolve_draft_plan_id( template_id: str, - services: tuple[EditTemplateService, EditPlanService] = Depends(get_editor_services), - current_user: AuthenticatedUser = Depends(get_current_user), - db: Session = Depends(get_db_session), + services: tuple[EditTemplateService, EditPlanService], + current_user: AuthenticatedUser, + db: Session, + auto_create_default: bool = True, ) -> str: - """路径依赖:根据 template_id 获取或创建草稿,返回 plan_id. + """根据 template_id 获取或创建草稿,返回 plan_id(纯函数,不带 Depends)。 - 这是模板编辑器路由的核心依赖——所有编辑器端点都先经过这里, - 确保 template_id → plan_id 的映射始终存在。 - - 模板读取遵循单一数据源、显式判定(不使用异常降级): - - 用户自建模板在旧表 ``templates``(归属 user_id,is_active=True); - - 全局模板在新表 ``edit_templates``(无 user_id,全局可读)。 - 模板不存在、已删除或不归属于当前用户时,一律返回 404。 + 当 auto_create_default=True 且 template_id 为空时,自动调用 + get_or_create_default_template_id 创建默认模板(用于 clips_standalone + 等非路径参数场景)。 """ tpl_svc, plan_svc = services user_id = str(current_user.user.id) - # 0. 门禁:校验模板存在且可访问(即使草稿已缓存命中也要校验, - # 避免模板被删除/无权访问后仍可通过既有草稿 plan 继续操作)。 + # 0. 空 tid 兜底 + if not template_id: + if auto_create_default: + tid = get_or_create_default_template_id(db, user_id) + if not tid: + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="无法自动创建默认模板,请刷新页面重试", + ) + template_id = tid + else: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="template_id 不能为空", + ) + + # 1. 门禁:校验模板存在且可访问 old_repo = SQLAlchemyTemplateRepository(db) old_template = old_repo.get_active(template_id, user_id) is_global_template = tpl_svc.get_template(template_id) is not None if old_template is None and not is_global_template: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="模板不存在") - # 1. 草稿已存在 → 直接返回 + # 2. 草稿已存在 → 直接返回 draft = tpl_svc.get_template_draft(template_id) if draft is not None: return draft.id - # 2. 全局模板(新系统)→ 用新服务创建草稿 + # 3. 全局模板(新系统)→ 用新服务创建草稿 if is_global_template: draft = tpl_svc.create_template_draft(template_id, user_id=user_id) return draft.id - # 3. 旧模板(templates 表)→ 基于旧模板创建草稿计划 + # 4. 旧模板(templates 表)→ 基于旧模板创建草稿计划 from app.services.plan_generator_service import PlanGeneratorService from packages.domain.edit_template import EditTemplate, EditTemplateStatus from packages.domain.template_clip_config import ClipType, TemplateClipConfig - # 构造伪 EditTemplate 对象(只填 generate_from_template 需要的字段) pseudo_template = EditTemplate( id=old_template.id, name=old_template.name, @@ -81,7 +95,6 @@ def get_draft_plan_id( status=EditTemplateStatus.ACTIVE, ) - # 将旧模板 segments 转换为 clip_configs clip_configs: list[TemplateClipConfig] = [] for seg in old_template.segments or []: clip_configs.append( @@ -105,7 +118,6 @@ def get_draft_plan_id( ) plan = result["plan"] - # 标记为模板草稿(后续可复用 tpl_svc.get_template_draft 的查找逻辑) plan_svc.update_plan_config(plan.id, {"is_template_draft": True}) logger.info( @@ -115,3 +127,23 @@ def get_draft_plan_id( user_id, ) return plan.id + + +def get_draft_plan_id( + template_id: str, + services: tuple[EditTemplateService, EditPlanService] = Depends(get_editor_services), + current_user: AuthenticatedUser = Depends(get_current_user), + db: Session = Depends(get_db_session), +) -> str: + """路径依赖:根据 template_id 获取或创建草稿,返回 plan_id. + + Depends 版本:路径参数 template_id 由 FastAPI 保证非空,不自动兜底。 + 兜底逻辑走 resolve_draft_plan_id(auto_create_default=False)。 + """ + return resolve_draft_plan_id( + template_id=template_id, + services=services, + current_user=current_user, + db=db, + auto_create_default=False, + ) diff --git a/apps/api/app/api/routes/templates_editor/schemas.py b/apps/api/app/api/routes/templates_editor/schemas.py index 5757c8866..a8ac79589 100755 --- a/apps/api/app/api/routes/templates_editor/schemas.py +++ b/apps/api/app/api/routes/templates_editor/schemas.py @@ -6,9 +6,9 @@ from __future__ import annotations import re as _re -from typing import Any, List, Optional +from typing import Any, Optional -from pydantic import BaseModel, Field, validator +from pydantic import BaseModel, Field, model_validator, validator _EXPORT_RESOLUTION_PATTERN = _re.compile(r"^\d+x\d+$") _EXPORT_VALID_QUALITY_PRESETS = {"ultra_fast", "fast", "balanced", "high", "best"} @@ -21,7 +21,7 @@ _EXPORT_VALID_FORMATS = {"mp4", "mov"} class AIRecommendRequest(BaseModel): """AI 推荐片段方案请求体""" - asset_ids: List[str] = Field(default_factory=list, description="素材 ID 列表") + asset_ids: list[str] = Field(default_factory=list, description="素材 ID 列表") editing_mode: str = Field(default="one_take", description="剪辑模式: one_take / pip / voice_over / voice_pip") target_duration: float = Field(default=30.0, ge=1.0, le=600.0, description="目标时长(秒)") @@ -44,7 +44,7 @@ class AIRecommendResponse(BaseModel): """AI 推荐片段方案响应体""" plan_id: str = Field(..., description="剪辑计划 ID") - clips: List[AIRecommendClipItem] = Field(..., description="推荐的片段列表") + clips: list[AIRecommendClipItem] = Field(..., description="推荐的片段列表") config: dict[str, Any] = Field(..., description="推荐的 plan config(cover/title/subtitle/bgm)") total_duration: float = Field(..., ge=0.0, description="推荐方案总时长(秒)") confidence: float = Field(..., ge=0.0, le=1.0, description="AI 推荐置信度 (0~1)") @@ -137,7 +137,7 @@ class ClipReorderItem(BaseModel): class ClipReorderRequest(BaseModel): """片段重排序请求""" - items: List[ClipReorderItem] = Field(..., min_length=1, max_length=500, description="重排序条目列表") + items: list[ClipReorderItem] = Field(..., min_length=1, max_length=500, description="重排序条目列表") class ClipReorderResponse(BaseModel): @@ -151,7 +151,7 @@ class ClipReorderResponse(BaseModel): class ClipBatchDeleteRequest(BaseModel): """批量删除片段请求""" - clip_ids: List[str] = Field(..., min_length=1, max_length=500, description="要删除的片段ID列表") + clip_ids: list[str] = Field(..., min_length=1, max_length=500, description="要删除的片段ID列表") class ClipBatchDeleteResponse(BaseModel): @@ -162,13 +162,26 @@ class ClipBatchDeleteResponse(BaseModel): message: str = "" +# sentinel:区分「前端未传 clip_count」和「显式传 0/None」 +_UNSET = object() + + class ClipsFromAssetsRequest(BaseModel): """从素材批量创建片段请求""" - asset_ids: List[str] = Field(..., min_length=1, max_length=200, description="素材 ID 列表,按顺序追加到时间线末尾") + asset_ids: list[str] = Field(..., min_length=1, max_length=200, description="素材 ID 列表,按顺序追加到时间线末尾") clip_type: str = Field(default="main", description="片段类型,默认 main") + clip_count: Optional[int] = Field( + default=None, + ge=1, + le=10, + description="片段数量(1-10);不传时使用旧字段 required_clips_count;两者都不传时回退为模板 segments 数量(默认 3 段)。", + ) required_clips_count: Optional[int] = Field( - default=None, ge=1, le=200, description="要求创建的片段数量;不传则等于素材数量" + default=None, + ge=1, + le=200, + description="[已废弃] 旧字段,请使用 clip_count;仅作向后兼容——clip_count 未显式传入时才回退本字段(超10截断到10)。", ) @validator("asset_ids", pre=True) @@ -180,6 +193,23 @@ class ClipsFromAssetsRequest(BaseModel): return v return [x for x in v if isinstance(x, str) and x.strip()] + @model_validator(mode="before") + @classmethod + def _backfill_clip_count(cls, data: Any) -> Any: + """兼容旧字段 required_clips_count:仅当新字段 clip_count 未显式传入时才回退旧字段; + 两者都没传时保持 clip_count=None,路由层按模板 segments 数量兜底。旧字段超 10 截断到 10。""" + if not isinstance(data, dict): + return data + has_new = "clip_count" in data and data["clip_count"] is not None + if not has_new: + legacy = data.get("required_clips_count") + if legacy is not None: + try: + data["clip_count"] = max(1, min(int(legacy), 10)) + except (TypeError, ValueError): + pass + return data + class ClipsFromAssetsResponse(BaseModel): """从素材批量创建片段响应""" @@ -188,7 +218,7 @@ class ClipsFromAssetsResponse(BaseModel): created_count: int plan_id: str = "" message: str = "" - clip_ids: List[str] = Field(default_factory=list, description="创建的片段ID列表") + clip_ids: list[str] = Field(default_factory=list, description="创建的片段ID列表") duplicate_warning: Optional[str] = Field(default=None, description="查重率超标警告") exhaustion_warning: Optional[str] = Field(default=None, description="素材耗尽警告") @@ -272,7 +302,7 @@ class ExportPresetItem(BaseModel): class ExportPresetListResponse(BaseModel): """导出预设列表响应""" - items: List[ExportPresetItem] + items: list[ExportPresetItem] total: int @@ -286,7 +316,7 @@ class FilterPresetResponse(BaseModel): name: str category: str description: str - tags: List[str] = Field(default_factory=list) + tags: list[str] = Field(default_factory=list) class FilterConfigResponse(BaseModel): @@ -316,7 +346,7 @@ class FilterUpdateRequest(BaseModel): class FilterPresetListResponse(BaseModel): """滤镜预设列表响应""" - items: List[FilterPresetResponse] + items: list[FilterPresetResponse] total: int @@ -330,7 +360,7 @@ class TransitionPresetResponse(BaseModel): name: str category: str description: str - tags: List[str] = Field(default_factory=list) + tags: list[str] = Field(default_factory=list) default_duration: float min_duration: float max_duration: float @@ -372,7 +402,7 @@ class BatchTransitionResponse(BaseModel): class TransitionPresetListResponse(BaseModel): """转场预设列表响应""" - items: List[TransitionPresetResponse] + items: list[TransitionPresetResponse] total: int @@ -428,7 +458,7 @@ class EditorClipResponse(BaseModel): class EditorClipListResponse(BaseModel): """片段列表响应""" - items: List[EditorClipResponse] + items: list[EditorClipResponse] total: int @@ -466,7 +496,7 @@ class EditorClipBatchItem(BaseModel): class EditorClipBatchUpdateRequest(BaseModel): """批量替换clips请求(全量覆盖)""" - clips: List[EditorClipBatchItem] = Field(default_factory=list) + clips: list[EditorClipBatchItem] = Field(default_factory=list) class EditorClipBatchUpdateResponse(BaseModel): @@ -554,4 +584,4 @@ class EditorTimelineResponse(BaseModel): plan_id: str total_duration: float - scenes: List[EditorTimelineSceneResponse] + scenes: list[EditorTimelineSceneResponse] diff --git a/apps/api/app/api/routes/tts.py b/apps/api/app/api/routes/tts.py old mode 100755 new mode 100644 index 400b012e2..a01c33d12 --- a/apps/api/app/api/routes/tts.py +++ b/apps/api/app/api/routes/tts.py @@ -4,12 +4,14 @@ from __future__ import annotations import json import logging +import math import subprocess import tempfile from pathlib import Path from typing import Any, Optional from app.auth import AuthenticatedUser, get_current_user +from app.config import settings from app.core.celery_app import celery_app from app.core.storage import get_storage_service from app.dependencies import ( @@ -51,6 +53,8 @@ from packages.application.tts_job.use_cases import ( ) from packages.application.tts_job.workflow import TTSWorkflowService from packages.domain import Asset, AssetLibrary, AssetLibraryKind, AssetStatus, ClassificationStatus +from packages.domain.points_rules import calculate_points_cost +from packages.domain.points_service import PointsService from packages.domain.voice_presets import list_voices from packages.ports.asset_library_repository import AssetLibraryRepository from packages.ports.asset_repository import AssetRepository @@ -128,6 +132,7 @@ def _to_response(job, sign_url=None) -> TTSJobResponse: def synthesize( request: TTSSynthesizeRequest, authenticated_user: AuthenticatedUser = Depends(get_current_user), + db: Session = Depends(get_db_session), repository: SQLAlchemyTTSJobRepository = Depends(_get_repository), cosyvoice_service: CosyVoiceService = Depends(get_cosyvoice_service), voice_clone_repo=Depends(get_voice_clone_profile_repository), @@ -139,6 +144,31 @@ def synthesize( """ user_id = authenticated_user.user.id + # ── 积分扣点(#1895 P2) ── + _points_deducted = 0 + _points_scene = "ai_voice" + _points_svc = PointsService() if settings.points_enabled else None + if _points_svc is not None: + # 中文按 ~240 字/分钟粗估时长,至少按 1 分钟扣 1 分 + est_minutes = max(1.0, math.ceil(len(request.text) / 240)) + _points_deducted = calculate_points_cost( + _points_scene, + is_member=getattr(authenticated_user.user, "is_member", False), + duration_minutes=est_minutes, + member_type=getattr(authenticated_user.user, "member_type", None), + ) + _deduct_res = _points_svc.deduct_points(user_id, _points_deducted, _points_scene, db) + if not _deduct_res["success"]: + raise HTTPException( + status_code=402, + detail={ + "code": "INSUFFICIENT_POINTS", + "message": f"积分不足,需要 {_points_deducted} 积分,当前余额 {_deduct_res['balance']}", + "required": _points_deducted, + "balance": _deduct_res["balance"], + }, + ) + # 解析 voice_id:前端可能传克隆音色 profile UUID(而非 CosyVoice voice_id), # 与 /tts/preview 保持一致:命中 profile → 校验归属 → 取 CosyVoice voice_id actual_voice_id = request.voice_id @@ -177,6 +207,7 @@ def synthesize( synthesis_meta = { "speed": request.speed, "emotion": request.emotion or "", + "language": request.language or "zh-CN", } if request.metadata_: synthesis_meta.update(request.metadata_) @@ -197,6 +228,7 @@ def synthesize( cosyvoice_service=cosyvoice_service, ) + synthesis_error: Exception | None = None try: job = workflow.start_synthesis(job.id) except Exception as e: @@ -204,10 +236,17 @@ def synthesize( # 但 DB 异常、网络异常等意外错误可能逃逸。 # 与音色克隆接口保持一致:标记 failed,返回 201,不抛 500。 logger.error(f"TTS 合成异常: job_id={job.id}, error={e}", exc_info=True) + synthesis_error = e try: job = workflow.process_synthesis_failure(job.id, str(e)) except Exception as inner_e: logger.error(f"标记 TTS job 失败时出错: job_id={job.id}, error={inner_e}") + # 合成失败且已扣积分 → 退费 + if synthesis_error is not None and _points_deducted > 0 and _points_svc is not None: + try: + _points_svc.refund_points(user_id, _points_deducted, _points_scene, db, ref_id=job.id) + except Exception as refund_err: + logger.warning(f"TTS 合失败退积分异常: job_id={job.id}, err={refund_err}") # 若任务处于 processing 状态(异步模式),触发 Celery 后台轮询 if job.status.value == "processing": @@ -222,10 +261,17 @@ def synthesize( celery_app.send_task("worker.process_tts_synthesis", args=[job.id]) except Exception as e: # Celery 调度失败,标记 job 为 failed + # e used below for refund context try: workflow.process_synthesis_failure(job.id, f"Celery 任务调度失败: {e}") except Exception as inner_e: logger.error(f"Celery 调度后标记失败时出错: job_id={job.id}, error={inner_e}") + # 调度失败退费 + if _points_deducted > 0 and _points_svc is not None: + try: + _points_svc.refund_points(user_id, _points_deducted, _points_scene, db, ref_id=job.id) + except Exception as refund_err: + logger.warning(f"Celery 调度失败退积分异常: job_id={job.id}, err={refund_err}") return TTSSynthesizeResponse( job_id=job.id, @@ -462,10 +508,17 @@ def save_tts_job_to_library( try: proc = subprocess.run( [ - "ffprobe", "-v", "quiet", "-print_format", "json", - "-show_format", str(tmp_path), + "ffprobe", + "-v", + "quiet", + "-print_format", + "json", + "-show_format", + str(tmp_path), ], - capture_output=True, text=True, timeout=10, + capture_output=True, + text=True, + timeout=10, ) if proc.returncode == 0: fmt = json.loads(proc.stdout).get("format", {}) @@ -545,6 +598,7 @@ def save_tts_job_to_library( def preview_tts( request: TTSPreviewRequest, authenticated_user: AuthenticatedUser = Depends(get_current_user), + db: Session = Depends(get_db_session), cosyvoice_service: CosyVoiceService = Depends(get_cosyvoice_service), voice_clone_repo=Depends(get_voice_clone_profile_repository), ) -> TTSPreviewResponse: @@ -553,6 +607,31 @@ def preview_tts( 用于前端预览配音效果,限制文本长度 200 字以内。 支持预设音色和克隆音色:克隆音色传的是 profile UUID,需解析为 CosyVoice voice_id。 """ + user_id = authenticated_user.user.id + # ── 积分扣点(#1895 P2) ── + _points_deducted = 0 + _points_scene = "ai_voice" + _points_svc = PointsService() if settings.points_enabled else None + if _points_svc is not None: + est_minutes = max(1.0, math.ceil(len(request.text) / 240)) + _points_deducted = calculate_points_cost( + _points_scene, + is_member=getattr(authenticated_user.user, "is_member", False), + duration_minutes=est_minutes, + member_type=getattr(authenticated_user.user, "member_type", None), + ) + _deduct_res = _points_svc.deduct_points(user_id, _points_deducted, _points_scene, db) + if not _deduct_res["success"]: + raise HTTPException( + status_code=402, + detail={ + "code": "INSUFFICIENT_POINTS", + "message": f"积分不足,需要 {_points_deducted} 积分,当前余额 {_deduct_res['balance']}", + "required": _points_deducted, + "balance": _deduct_res["balance"], + }, + ) + # 解析 voice_id:前端可能传 VoiceCloneProfile UUID 或预设音色 ID actual_voice_id = request.voice_id profile = voice_clone_repo.get(request.voice_id) @@ -576,17 +655,18 @@ def preview_tts( voice_id=actual_voice_id, speed=request.speed, emotion=request.emotion, + language=getattr(request, "language", "zh-CN"), ) - except CosyVoiceError as e: - raise HTTPException( - status_code=status.HTTP_502_BAD_GATEWAY, - detail=f"TTS 合成失败: {e}", - ) from e - except ValueError as e: - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail=str(e), - ) from e + except (CosyVoiceError, ValueError) as e: + # 合成失败退费 + if _points_deducted > 0 and _points_svc is not None: + try: + _points_svc.refund_points(user_id, _points_deducted, _points_scene, db) + except Exception as refund_err: + logger.warning(f"TTS 预览失败退积分异常: {refund_err}") + if isinstance(e, CosyVoiceError): + raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail=f"TTS 合成失败: {e}") from e + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) from e return TTSPreviewResponse( audio_url=result.audio_url, diff --git a/apps/api/app/api/routes/voice_clones.py b/apps/api/app/api/routes/voice_clones.py index 0a2a5afc5..25a35e041 100755 --- a/apps/api/app/api/routes/voice_clones.py +++ b/apps/api/app/api/routes/voice_clones.py @@ -3,14 +3,17 @@ from __future__ import annotations import logging +import math from typing import Optional from app.auth import AuthenticatedUser, get_current_user +from app.config import settings from app.core.celery_app import celery_app from app.core.storage import get_storage_service from app.dependencies import ( get_asset_repository, get_cosyvoice_service, + get_db_session, get_project_repository, get_voice_clone_profile_repository, ) @@ -22,6 +25,7 @@ from app.schemas.voice_clone import ( VoiceCloneStatusResponse, ) from fastapi import APIRouter, Depends, HTTPException, Query, Response, status +from sqlalchemy.orm import Session from packages.adapters.sqlalchemy_impl.voice_clone_profile_repository import ( SQLAlchemyVoiceCloneProfileRepository, @@ -38,6 +42,11 @@ from packages.application.voice_clone.use_cases import ( from packages.application.voice_clone.workflow import ( VoiceCloneWorkflowService, ) +from packages.domain.points_rules import calculate_points_cost +from packages.domain.points_service import PointsService + +# remove duplicate +_DUMMY_DELETED = () from packages.ports.asset_repository import AssetRepository from packages.ports.project_repository import ProjectRepository from packages.shared.storage import SharedStorageService @@ -287,7 +296,46 @@ def retry_voice_clone( return _to_response(profile) -_ALLOWED_PREVIEW_EMOTIONS = {"", "natural", "excited", "calm", "friendly"} +_ALLOWED_PREVIEW_EMOTIONS = { + "", + # 7 种标准英文枚举(CosyVoice v3 官方值) + "neutral", + "happy", + "sad", + "angry", + "surprised", + "fearful", + "disgusted", + # 前端中文 7 标签 + "中立", + "开心", + "难过", + "生气", + "惊讶", + "恐惧", + "厌恶", + # 旧英文 4 枚举 + 常见中文别名兼容 + "natural", + "excited", + "calm", + "friendly", + "自然", + "愉快", + "高兴", + "快乐", + "兴奋", + "悲伤", + "愤怒", + "惊奇", + "吃惊", + "害怕", + "讨厌", + # 灵应 P1 指定别名 + "中性", + "伤心", + "沉稳", + "亲切", +} @router.get("/{clone_id}/preview", response_model=VoiceClonePreviewResponse) @@ -295,8 +343,12 @@ def get_voice_clone_preview( clone_id: str, text: str = Query("", description="自定义试听文本,为空则使用默认示例"), speed: float = Query(1.0, ge=0.5, le=2.0, description="语速,0.5-2.0,默认 1.0"), - emotion: str = Query("", description="情绪:natural/excited/calm/friendly,空字符串为默认自然"), + emotion: str = Query( + "", + description="情绪:neutral/happy/sad/angry/surprised/fearful/disgusted,兼容旧值 natural/excited/calm/friendly,空为默认自然", + ), authenticated_user: AuthenticatedUser = Depends(get_current_user), + db: Session = Depends(get_db_session), repository: SQLAlchemyVoiceCloneProfileRepository = Depends(get_voice_clone_profile_repository), cosyvoice: CosyVoiceService = Depends(get_cosyvoice_service), ) -> VoiceClonePreviewResponse: @@ -308,10 +360,35 @@ def get_voice_clone_preview( """ import time + user_id = authenticated_user.user.id + _points_deducted = 0 + _points_scene = "voice_clone_synth" + _points_svc = PointsService() if settings.points_enabled else None + _preview_text_for_points = text.strip() or CLONE_PREVIEW_TEMPLATE + if _points_svc is not None: + est_minutes = max(1.0, math.ceil(len(_preview_text_for_points) / 240)) + _points_deducted = calculate_points_cost( + _points_scene, + is_member=getattr(authenticated_user.user, "is_member", False), + duration_minutes=est_minutes, + member_type=getattr(authenticated_user.user, "member_type", None), + ) + _deduct_res = _points_svc.deduct_points(user_id, _points_deducted, _points_scene, db) + if not _deduct_res["success"]: + raise HTTPException( + status_code=402, + detail={ + "code": "INSUFFICIENT_POINTS", + "message": f"积分不足,需要 {_points_deducted} 积分,当前余额 {_deduct_res['balance']}", + "required": _points_deducted, + "balance": _deduct_res["balance"], + }, + ) + if emotion not in _ALLOWED_PREVIEW_EMOTIONS: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, - detail=f"不支持的 emotion 值: {emotion},可选: natural/excited/calm/friendly 或留空", + detail=f"不支持的 emotion 值: {emotion},可选: neutral/happy/sad/angry/surprised/fearful/disgusted 或中文 中立/中性/开心/难过/伤心/生气/愤怒/惊讶/吃惊/恐惧/害怕/厌恶/讨厌 或留空", ) use_case = GetVoiceCloneUseCase(repository) @@ -351,9 +428,14 @@ def get_voice_clone_preview( speed=speed, emotion=emotion, ) - except CosyVoiceError as e: - raise HTTPException(status_code=502, detail=f"TTS 合成失败: {e}") from e - except ValueError as e: + except (CosyVoiceError, ValueError) as e: + if _points_deducted > 0 and _points_svc is not None: + try: + _points_svc.refund_points(user_id, _points_deducted, _points_scene, db) + except Exception as refund_err: + logger.warning(f"克隆音色试听失败退积分异常: clone_id={clone_id}, err={refund_err}") + if isinstance(e, CosyVoiceError): + raise HTTPException(status_code=502, detail=f"TTS 合成失败: {e}") from e raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) from e # 缓存(仅默认参数组合) diff --git a/apps/api/app/dependencies.py b/apps/api/app/dependencies.py index 682d62699..ce622df26 100644 --- a/apps/api/app/dependencies.py +++ b/apps/api/app/dependencies.py @@ -6,7 +6,7 @@ ensuring proper lifecycle management and testability. from __future__ import annotations -from typing import Generator +from collections.abc import Generator import redis from app.config import settings diff --git a/apps/api/app/middleware/monitoring.py b/apps/api/app/middleware/monitoring.py index 69a64a43d..f0a794af7 100644 --- a/apps/api/app/middleware/monitoring.py +++ b/apps/api/app/middleware/monitoring.py @@ -4,7 +4,7 @@ import logging import time -from typing import Callable +from collections.abc import Callable from fastapi import Request from starlette.middleware.base import BaseHTTPMiddleware diff --git a/apps/api/app/middleware/prometheus_metrics.py b/apps/api/app/middleware/prometheus_metrics.py index 18601209a..736e04fea 100644 --- a/apps/api/app/middleware/prometheus_metrics.py +++ b/apps/api/app/middleware/prometheus_metrics.py @@ -10,7 +10,7 @@ Exposes: import re import time -from typing import Callable +from collections.abc import Callable from fastapi import Request, Response from prometheus_client import ( diff --git a/apps/api/app/schemas/generation_task.py b/apps/api/app/schemas/generation_task.py index 3951f5eb7..b8ce60828 100755 --- a/apps/api/app/schemas/generation_task.py +++ b/apps/api/app/schemas/generation_task.py @@ -43,11 +43,6 @@ class CreateGenerationTaskRequest(BaseModel): default_factory=list, description="POST /generation/variant-plans 返回的各变体 plan_id(长度须=count);为空则走服务端选片", ) - # ── 文案库关联(#1894 标题库整合到文案库)── - script_id: str = Field( - default="", - description="关联的文案(script)ID;非空时从 script 读取 title_text/title_config 作为标题配置", - ) # ── 标题配置(结构化)── title_config: dict | None = Field( default=None, @@ -242,11 +237,6 @@ class CreatePreviewGenerationTaskRequest(BaseModel): default_factory=dict, description="标题配置(可选),渲染时烧录到预览视频中。支持字段: text/font/font_size/font_color/position/bold/stroke/shadow。N个变体时样式全局共用", ) - # ── #1894: 关联文案,从 script 读取标题配置 ── - script_id: str = Field( - default="", - description="关联的文案(script)ID;非空时从 script 读取 title_text/title_config 作为标题配置", - ) # ── 多变体独立配置(preview_count > 1)── # 长度 1 = 所有变体共用;长度 = preview_count = 每个变体独立;空数组 = 回退单值字段 titles: list[str] = Field( diff --git a/apps/api/app/schemas/lipsync.py b/apps/api/app/schemas/lipsync.py index 3f0e838f0..8bd9ef9bc 100644 --- a/apps/api/app/schemas/lipsync.py +++ b/apps/api/app/schemas/lipsync.py @@ -67,7 +67,10 @@ class CreateLipsyncJobRequest(BaseModel): voice_id: str = Field("", description="音色 ID(预置音色或克隆音色 profile UUID)") script_text: str = Field("", description="要合成的文案(直生模式必填,最长 5000 字符)") speed: float = Field(1.0, ge=0.5, le=2.0, description="语速(0.5-2.0),默认 1.0") - emotion: str = Field("", description="情绪(natural/excited/calm/friendly 或中文 自然/兴奋/沉稳/亲切)") + emotion: str = Field( + "", + description="情绪(英文枚举 neutral/happy/sad/angry/surprised/fearful/disgusted,或中文 中立/开心/难过/生气/惊讶/恐惧/厌恶;空为默认自然)", + ) enable_video_loop: bool = Field( True, description="音频长于视频时是否循环画面(AI数字人默认开启,防止音频长于视频被截断)" @@ -120,7 +123,11 @@ class AiAvatarTtsPreviewRequest(BaseModel): voice_id: str = Field(..., min_length=1, max_length=128, description="音色 ID") script_text: str = Field(..., min_length=1, max_length=5000, description="要合成的文案") speed: float = Field(1.0, ge=0.5, le=2.0, description="语速(0.5-2.0),默认 1.0") - emotion: str = Field("natural", max_length=32, description="情绪") + emotion: str = Field( + "neutral", + max_length=32, + description="情绪(英文枚举 neutral/happy/sad/angry/surprised/fearful/disgusted,或中文 中立/开心/难过/生气/惊讶/恐惧/厌恶;默认 neutral)", + ) class AiAvatarTtsPreviewResponse(BaseModel): diff --git a/apps/api/app/schemas/points.py b/apps/api/app/schemas/points.py new file mode 100644 index 000000000..0ab25b8df --- /dev/null +++ b/apps/api/app/schemas/points.py @@ -0,0 +1,182 @@ +"""积分 & 会员相关 Pydantic Schema (#1895)""" + +from __future__ import annotations + +from datetime import datetime +from typing import Any, Optional + +from pydantic import BaseModel, Field + +# ============ 余额 & 账户 ============ + + +class PointsBalanceResponse(BaseModel): + """积分余额 + 会员状态""" + + balance: int = Field(..., description="当前积分余额") + total_earned: int = Field(..., description="累计获得积分") + total_spent: int = Field(..., description="累计消耗积分") + is_member: bool = Field(default=False, description="是否付费会员") + member_type: Optional[str] = Field(None, description="会员类型: monthly/quarterly/yearly") + member_expires_at: Optional[datetime] = Field(None, description="会员到期时间") + + +# ============ 流水 ============ + + +class PointsTransactionItem(BaseModel): + """单条积分流水""" + + id: str + type: str = Field(..., description="类型: add/deduct") + source: str = Field(..., description="来源场景") + amount: int + balance_after: int + description: str = "" + ref_id: str = "" + created_at: Optional[str] = None + + +class PointsTransactionsResponse(BaseModel): + """积分流水分页响应""" + + items: list[PointsTransactionItem] + total: int + page: int + page_size: int + + +# ============ 规则 & 积分包 ============ + + +class PointRuleItem(BaseModel): + """单条积分规则""" + + scene_key: str + name: str + base_points: int + unit: str + extra_per_30s: Optional[int] = None + + +class PointsRulesResponse(BaseModel): + """所有积分消耗规则""" + + rules: list[PointRuleItem] + free_user_multiplier: float = Field(..., description="免费用户积分上浮系数") + + +class PointsPackageItem(BaseModel): + """积分包信息""" + + code: str + name: str + points: int + price_cents: int + unit_price: str = Field("", description="单价描述,如 ¥0.099/积分") + + +class PointsPackagesResponse(BaseModel): + """可购买的积分包列表""" + + packages: list[PointsPackageItem] + user_discount: Optional[float] = Field(None, description="当前用户折扣(会员)") + + +# ============ 消费前检查 ============ + + +class PointsCheckRequest(BaseModel): + """消费前余额检查请求""" + + scene_key: str + duration_minutes: Optional[float] = None + quantity: Optional[int] = 1 + + +class PointsCheckResponse(BaseModel): + """消费前余额检查响应""" + + allowed: bool + required_points: int + current_balance: int + remaining_after: int + is_free_quota: bool = False + + +# ============ 手动扣减 / 退还(内部接口) ============ + + +class PointsDeductRequest(BaseModel): + """积分扣减请求""" + + scene_key: str + amount: int + description: Optional[str] = "" + ref_id: Optional[str] = "" + + +class PointsRefundRequest(BaseModel): + """积分退还请求""" + + transaction_id: str + reason: Optional[str] = "" + + +class PointsRechargeRequest(BaseModel): + """积分充值请求""" + + package_id: str = Field(..., description="积分包 code,如 starter_pack") + + +# ============ 订单 ============ + + +class PointsOrderResponse(BaseModel): + """订单信息""" + + id: str + order_type: str + product_code: str + amount_cents: int + status: str + created_at: Optional[str] = None + + +# ============ 每日额度 ============ + + +class DailyUsageResponse(BaseModel): + """今日免费额度使用情况""" + + free_clips_used: int + free_clips_limit: int + free_clips_remaining: int + reset_at: str + + +# ============ 会员状态(聚合) ============ + + +class MembershipStatusResponse(BaseModel): + """当前用户会员状态(聚合信息)""" + + is_member: bool + member_type: Optional[str] = None + member_expires_at: Optional[datetime] = None + points_balance: int + max_resolution: str = Field( + default="1080p", + description="可用最高分辨率: 720p(free) / 1080p(paid)", + ) + + +# ============ 通用响应 ============ + + +class SimpleMessageResponse(BaseModel): + """简单消息响应""" + + success: bool + message: str + data: Optional[dict[str, Any]] = None diff --git a/apps/api/app/schemas/script.py b/apps/api/app/schemas/script.py index f4acb9ecb..56408b2c2 100644 --- a/apps/api/app/schemas/script.py +++ b/apps/api/app/schemas/script.py @@ -1,9 +1,9 @@ -"""Script (口播文案库) Pydantic schemas — Issue #1795 & #1894.""" +"""Script (口播文案库) Pydantic schemas — Issue #1795.""" from __future__ import annotations from datetime import datetime -from typing import Any, Dict, List, Optional +from typing import Any, Dict, Optional from pydantic import BaseModel, Field @@ -20,9 +20,8 @@ class ScriptResponse(BaseModel): user_id: str title: str content: str - segments: List[ScriptSegment] = Field(default_factory=list) - tags: List[str] = Field(default_factory=list) - # #1894: 标题配置字段 + segments: list[ScriptSegment] = Field(default_factory=list) + tags: list[str] = Field(default_factory=list) title_text: str = "" title_category: str = "" title_config: Dict[str, Any] = Field(default_factory=dict) @@ -38,20 +37,18 @@ class ScriptListResponse(BaseModel): class CreateScriptRequest(BaseModel): title: str = Field(..., min_length=1, max_length=255) content: str = "" - segments: List[ScriptSegment] = Field(default_factory=list) - tags: List[str] = Field(default_factory=list) - # #1894: 标题配置字段 + segments: list[ScriptSegment] = Field(default_factory=list) + tags: list[str] = Field(default_factory=list) title_text: str = "" title_category: str = "" - title_config: Dict[str, Any] = Field(default_factory=dict) + title_config: Optional[Dict[str, Any]] = None class UpdateScriptRequest(BaseModel): title: Optional[str] = Field(None, min_length=1, max_length=255) content: Optional[str] = None - segments: Optional[List[ScriptSegment]] = None - tags: Optional[List[str]] = None - # #1894: 标题配置字段 + segments: Optional[list[ScriptSegment]] = None + tags: Optional[list[str]] = None title_text: Optional[str] = None title_category: Optional[str] = None title_config: Optional[Dict[str, Any]] = None diff --git a/apps/api/app/schemas/scripts_ai.py b/apps/api/app/schemas/scripts_ai.py new file mode 100644 index 000000000..916632f04 --- /dev/null +++ b/apps/api/app/schemas/scripts_ai.py @@ -0,0 +1,60 @@ +"""Scripts AI 能力 Pydantic schemas — Issue #1893. + +抖音文案提取、AI 改写、AI 标题生成的请求/响应模型。 +""" + +from __future__ import annotations + +from typing import List, Optional + +from pydantic import BaseModel, Field + +# ── 抖音文案提取 ───────────────────────────────────────────────────────────── + + +class ExtractFromDouyinRequest(BaseModel): + """从抖音视频提取文案请求.""" + + url: str = Field(..., description="抖音视频链接(短链或长链)") + + +class ExtractFromDouyinResponse(BaseModel): + """从抖音视频提取文案响应.""" + + text: str = Field(..., description="ASR 识别出的文案文本") + duration_seconds: float = Field(..., description="视频时长(秒)") + source_url: str = Field(..., description="原始视频链接") + + +# ── AI 改写 ───────────────────────────────────────────────────────────────── + + +class AiRewriteRequest(BaseModel): + """AI 文案改写请求.""" + + content: str = Field(..., description="原文内容") + style: Optional[str] = Field("口语化", description="改写风格,如 口语化/正式/活泼") + + +class AiRewriteResponse(BaseModel): + """AI 文案改写响应.""" + + original: str = Field(..., description="原文") + rewritten: str = Field(..., description="改写后的文案") + style: str = Field(..., description="使用的改写风格") + + +# ── AI 标题生成 ────────────────────────────────────────────────────────────── + + +class AiGenerateTitlesRequest(BaseModel): + """AI 标题生成请求.""" + + content: str = Field(..., description="文案内容") + count: int = Field(3, ge=1, le=5, description="生成标题数量(1-5,默认3)") + + +class AiGenerateTitlesResponse(BaseModel): + """AI 标题生成响应.""" + + titles: List[str] = Field(..., description="生成的标题列表") diff --git a/apps/api/app/schemas/template.py b/apps/api/app/schemas/template.py index 6255f9d84..dd10d493a 100755 --- a/apps/api/app/schemas/template.py +++ b/apps/api/app/schemas/template.py @@ -1,9 +1,14 @@ -"""Template API schemas.""" +"""Template API schemas(精简版:仅保留列表接口 + 默认模板自动兜底所需字段). + +前端 PR#1911 删除 my-templates / editing-planner / templates 管理页后, +模板 CRUD / 分类 / 标签 / 收藏 / 复制 / 校验 / 使用统计等端点全部下线, +对应 Request/Response 模型也一并清理。 +""" from __future__ import annotations from datetime import datetime -from typing import Any, Dict, List, Optional +from typing import Any, Optional from pydantic import BaseModel, Field @@ -37,12 +42,12 @@ class TemplateResponse(BaseModel): name: str mode: str category: str = "" - tags: List[str] = Field(default_factory=list) - title_config: Dict[str, Any] = Field(default_factory=dict) - subtitle_config: Dict[str, Any] = Field(default_factory=dict) - bgm_config: Dict[str, Any] = Field(default_factory=dict) + tags: list[str] = Field(default_factory=list) + title_config: dict[str, Any] = Field(default_factory=dict) + subtitle_config: dict[str, Any] = Field(default_factory=dict) + bgm_config: dict[str, Any] = Field(default_factory=dict) estimated_duration: float = 0.0 - segments: List[SegmentResponse] = Field(default_factory=list) + segments: list[SegmentResponse] = Field(default_factory=list) is_active: bool = True is_favorite: bool = False usage_count: int = 0 @@ -50,96 +55,29 @@ class TemplateResponse(BaseModel): updated_at: datetime -class ToggleFavoriteResponse(BaseModel): - id: str - is_favorite: bool - - class ListTemplatesResponse(BaseModel): - items: List[TemplateResponse] + items: list[TemplateResponse] total: int = 0 -# ── Template Request ── +# ── Template Request(保留给内部 _get_or_create_default_template_id 兜底创建默认模板使用)── class CreateTemplateRequest(BaseModel): name: str mode: str category: str = "" - tags: List[str] = Field(default_factory=list) - title_config: Dict[str, Any] = Field(default_factory=dict) - subtitle_config: Dict[str, Any] = Field(default_factory=dict) - bgm_config: Dict[str, Any] = Field(default_factory=dict) + tags: list[str] = Field(default_factory=list) + title_config: dict[str, Any] = Field(default_factory=dict) + subtitle_config: dict[str, Any] = Field(default_factory=dict) + bgm_config: dict[str, Any] = Field(default_factory=dict) estimated_duration: float = 0.0 - segments: List[SegmentRequest] = Field(default_factory=list) - - -class UpdateTemplateRequest(BaseModel): - name: Optional[str] = None - mode: Optional[str] = None - category: Optional[str] = None - tags: Optional[List[str]] = None - title_config: Optional[Dict[str, Any]] = None - subtitle_config: Optional[Dict[str, Any]] = None - bgm_config: Optional[Dict[str, Any]] = None - estimated_duration: Optional[float] = None - segments: Optional[List[SegmentRequest]] = None - - -# ── Validate ── - - -class ValidateTemplateRequest(BaseModel): - voiceover_duration: Optional[float] = None # 配音实际时长(秒) + segments: list[SegmentRequest] = Field(default_factory=list) class GenerateWarningResponse(BaseModel): + """兼容老 import(如校验逻辑内部复用);模板管理页已下线,可按需进一步清理。""" + code: str message: str - details: Dict[str, Any] = Field(default_factory=dict) - - -class ValidateTemplateResponse(BaseModel): - template: TemplateResponse - warnings: List[GenerateWarningResponse] = Field(default_factory=list) - - -# ── Category ── - - -class CategoryResponse(BaseModel): - id: str - user_id: str - name: str - created_at: datetime - - -class CreateCategoryRequest(BaseModel): - name: str - - -class ListCategoriesResponse(BaseModel): - items: List[CategoryResponse] - - -# ── Copy Template ── - - -class CopyTemplateRequest(BaseModel): - new_name: str - - -# ── Tags ── - - -class ListTagsResponse(BaseModel): - items: List[str] - - -# ── Usage Stats ── - - -class TemplateUsageResponse(BaseModel): - template_id: str - usage_count: int + details: dict[str, Any] = Field(default_factory=dict) diff --git a/apps/api/app/schemas/title_library.py b/apps/api/app/schemas/title_library.py index b955d1169..37e197ff5 100644 --- a/apps/api/app/schemas/title_library.py +++ b/apps/api/app/schemas/title_library.py @@ -3,7 +3,7 @@ from __future__ import annotations from datetime import datetime -from typing import List, Optional +from typing import Optional from pydantic import BaseModel, Field @@ -15,7 +15,7 @@ class TitleLibraryItemResponse(BaseModel): text: str category: str = "default" description: str = "" - tags: List[str] = Field(default_factory=list) + tags: list[str] = Field(default_factory=list) usage_count: int = 0 is_active: bool = True created_at: datetime @@ -32,7 +32,7 @@ class CreateTitleLibraryRequest(BaseModel): text: str = Field(..., min_length=1, max_length=500) category: str = "default" description: str = "" - tags: List[str] = Field(default_factory=list) + tags: list[str] = Field(default_factory=list) class UpdateTitleLibraryRequest(BaseModel): @@ -40,4 +40,4 @@ class UpdateTitleLibraryRequest(BaseModel): text: Optional[str] = Field(None, min_length=1, max_length=500) category: Optional[str] = None description: Optional[str] = None - tags: Optional[List[str]] = None + tags: Optional[list[str]] = None diff --git a/apps/api/app/schemas/tts.py b/apps/api/app/schemas/tts.py index 535f03ba2..39dd4f380 100644 --- a/apps/api/app/schemas/tts.py +++ b/apps/api/app/schemas/tts.py @@ -3,7 +3,7 @@ from __future__ import annotations from datetime import datetime -from typing import Any, Dict, List, Optional +from typing import Any, Optional from pydantic import BaseModel, Field @@ -16,11 +16,14 @@ class TTSSynthesizeRequest(BaseModel): output_name: str = Field("", description="输出文件名") language: str = Field("zh-CN", description="语言") speed: float = Field(1.0, ge=0.5, le=2.0, description="语速") - emotion: str = Field("", description="情绪(natural/excited/calm/friendly,或中文 自然/兴奋/沉稳/亲切)") + emotion: str = Field( + "", + description="情绪(中文/英文:自然/兴奋/沉稳/亲切/开心/悲伤/愤怒/惊讶/恐惧/厌恶 等;通过 instruction 自然语言指令控制)", + ) 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 @@ -46,7 +49,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 @@ -80,7 +83,7 @@ class TTSSynthesizeResponse(BaseModel): class ListTTSJobResponse(BaseModel): """TTS 任务列表响应。""" - items: List[TTSJobResponse] + items: list[TTSJobResponse] total: int page: int page_size: int @@ -110,7 +113,8 @@ class TTSPreviewRequest(BaseModel): text: str = Field(..., min_length=1, max_length=200, description="合成文本,限制 200 字") voice_id: str = Field(..., min_length=1, description="音色 ID") speed: float = Field(1.0, ge=0.5, le=2.0, description="语速") - emotion: str = Field("", description="情绪(natural/excited/calm/friendly,或中文)") + emotion: str = Field("", description="情绪(中文/英文:自然/兴奋/沉稳/亲切/开心/悲伤/愤怒/惊讶/恐惧/厌恶 等)") + language: str = Field("zh-CN", description="语言(zh-CN/en-US 等)") pitch: float = Field(1.0, ge=0.5, le=2.0, description="音调(预留,当前未使用)") diff --git a/apps/api/app/schemas/video_share.py b/apps/api/app/schemas/video_share.py index df0590422..e8dc51251 100755 --- a/apps/api/app/schemas/video_share.py +++ b/apps/api/app/schemas/video_share.py @@ -3,7 +3,7 @@ from __future__ import annotations from datetime import datetime -from typing import List, Optional +from typing import Optional from pydantic import BaseModel, Field @@ -61,7 +61,7 @@ class ShareResponse(BaseModel): class ShareListResponse(BaseModel): """分享列表响应.""" - items: List[ShareResponse] + items: list[ShareResponse] total: int = 0 skip: int = 0 limit: int = 20 diff --git a/apps/api/app/schemas/voice.py b/apps/api/app/schemas/voice.py index 41d4424c1..38bf5382d 100644 --- a/apps/api/app/schemas/voice.py +++ b/apps/api/app/schemas/voice.py @@ -6,7 +6,7 @@ from __future__ import annotations from datetime import datetime -from typing import List, Literal, Optional +from typing import Literal, Optional from pydantic import BaseModel, Field @@ -56,7 +56,7 @@ class UnifiedVoiceItemResponse(BaseModel): status: str = "completed" """状态""" - tags: List[str] = Field(default_factory=list) + tags: list[str] = Field(default_factory=list) """标签列表""" # 克隆音色特有字段 @@ -113,7 +113,7 @@ class PresetVoiceItemResponse(BaseModel): preview_url: str = "" """预览音频 URL""" - tags: List[str] = Field(default_factory=list) + tags: list[str] = Field(default_factory=list) """标签列表""" diff --git a/apps/api/app/schemas/voice_clone.py b/apps/api/app/schemas/voice_clone.py index 261fbfaf2..e9d82a802 100755 --- a/apps/api/app/schemas/voice_clone.py +++ b/apps/api/app/schemas/voice_clone.py @@ -3,7 +3,7 @@ from __future__ import annotations from datetime import datetime -from typing import Any, Dict, List, Optional +from typing import Any, Optional from pydantic import BaseModel, Field @@ -19,7 +19,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 @@ -41,7 +41,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 @@ -62,7 +62,7 @@ class VoiceCloneStatusResponse(BaseModel): class ListVoiceCloneResponse(BaseModel): """音色克隆列表响应。""" - items: List[VoiceCloneProfileResponse] + items: list[VoiceCloneProfileResponse] total: int diff --git a/apps/api/app/schemas/voice_library.py b/apps/api/app/schemas/voice_library.py index b21ea4362..9d3c25be3 100644 --- a/apps/api/app/schemas/voice_library.py +++ b/apps/api/app/schemas/voice_library.py @@ -3,7 +3,7 @@ from __future__ import annotations from datetime import datetime -from typing import List, Optional +from typing import Optional from pydantic import BaseModel, Field @@ -21,7 +21,7 @@ class VoiceLibraryItemResponse(BaseModel): file_size: int = 0 status: str = "completed" project_id: Optional[str] = None - tags: List[str] = Field(default_factory=list) + tags: list[str] = Field(default_factory=list) created_at: datetime updated_at: datetime @@ -42,7 +42,7 @@ class CreateVoiceLibraryRequest(BaseModel): file_size: int = 0 status: str = "completed" project_id: Optional[str] = None - tags: List[str] = Field(default_factory=list) + tags: list[str] = Field(default_factory=list) class UpdateVoiceLibraryRequest(BaseModel): @@ -55,4 +55,4 @@ class UpdateVoiceLibraryRequest(BaseModel): duration: Optional[float] = None file_size: Optional[int] = None status: Optional[str] = None - tags: Optional[List[str]] = None + tags: Optional[list[str]] = None diff --git a/apps/api/app/services/ai_avatar_render_service.py b/apps/api/app/services/ai_avatar_render_service.py index ca6965b0c..0e84914e5 100644 --- a/apps/api/app/services/ai_avatar_render_service.py +++ b/apps/api/app/services/ai_avatar_render_service.py @@ -16,7 +16,7 @@ import os import subprocess import tempfile import uuid -from datetime import datetime, timezone +from datetime import UTC, datetime from typing import Any, Optional from sqlalchemy.orm import Session @@ -117,7 +117,7 @@ class AiAvatarRenderService: self.db.add(job) self.db.flush() - job.submitted_at = datetime.now(timezone.utc) + job.submitted_at = datetime.now(UTC) self.db.commit() self.db.refresh(job) return job @@ -164,7 +164,7 @@ class AiAvatarRenderService: return None if job.status in ("pending", "submitted"): job.status = "cancelled" - job.updated_at = datetime.now(timezone.utc) + job.updated_at = datetime.now(UTC) self.db.commit() self.db.refresh(job) return job @@ -186,7 +186,7 @@ class AiAvatarRenderService: job.output_duration = 0.0 job.started_at = None job.completed_at = None - job.updated_at = datetime.now(timezone.utc) + job.updated_at = datetime.now(UTC) self.db.commit() self.db.refresh(job) return job @@ -215,9 +215,9 @@ class AiAvatarRenderService: try: # 更新状态为 processing job.status = "processing" - job.started_at = datetime.now(timezone.utc) + job.started_at = datetime.now(UTC) job.progress = 5 - job.updated_at = datetime.now(timezone.utc) + job.updated_at = datetime.now(UTC) self.db.commit() # 获取对口型任务信息 @@ -394,8 +394,8 @@ class AiAvatarRenderService: # 6. 完成 job.status = "completed" job.progress = 100 - job.completed_at = datetime.now(timezone.utc) - job.updated_at = datetime.now(timezone.utc) + job.completed_at = datetime.now(UTC) + job.updated_at = datetime.now(UTC) self.db.commit() logger.info("渲染任务完成: %s", job_id) @@ -406,14 +406,14 @@ class AiAvatarRenderService: except AiAvatarRenderError as exc: job.status = "failed" job.error_message = str(exc) - job.updated_at = datetime.now(timezone.utc) + job.updated_at = datetime.now(UTC) self.db.commit() logger.error("渲染任务失败 [%s]: %s", job_id, exc) raise except Exception as exc: job.status = "failed" job.error_message = f"渲染异常: {str(exc)}" - job.updated_at = datetime.now(timezone.utc) + job.updated_at = datetime.now(UTC) self.db.commit() logger.exception("渲染任务异常 [%s]", job_id) raise @@ -494,7 +494,7 @@ class AiAvatarRenderService: # 同步更新 cover_config,保持 smart-cover 路径一致 if isinstance(job.cover_config, dict): job.cover_config = {**job.cover_config, "mode": "auto_frame", "url": cover_url.strip()} - job.updated_at = datetime.now(timezone.utc) + job.updated_at = datetime.now(UTC) self.db.commit() return self._persist_to_library(job, cover_url=cover_url) diff --git a/apps/api/app/services/ai_service.py b/apps/api/app/services/ai_service.py index 6e1291a11..e25c72b9a 100755 --- a/apps/api/app/services/ai_service.py +++ b/apps/api/app/services/ai_service.py @@ -13,7 +13,7 @@ from __future__ import annotations import logging -from typing import Any, Dict, List, Optional +from typing import Any, Optional from packages.domain.ai_parsing import generate_titles_fallback as _generate_titles_fallback_base from packages.domain.ai_parsing import keyword_match_fallback as _semantic_match_fallback_base @@ -64,7 +64,7 @@ def _generate_titles_fallback( description: str, style: str = "viral", count: int = 5, -) -> List[str]: +) -> list[str]: """本地降级:基于模板规则生成标题(薄包装,转发到 ai_parsing 模块).""" style_info = TITLE_STYLES.get(style, TITLE_STYLES["viral"]) return _generate_titles_fallback_base(description, style_info, count) @@ -74,7 +74,7 @@ def generate_smart_titles( description: str, style: str = "viral", count: int = 5, -) -> Dict[str, Any]: +) -> dict[str, Any]: """生成智能标题. Args: @@ -164,16 +164,16 @@ def generate_smart_titles( def _semantic_match_fallback( description: str, - assets: List[Dict[str, Any]], -) -> List[Dict[str, Any]]: + assets: list[dict[str, Any]], +) -> list[dict[str, Any]]: """本地降级:基于关键词的简单匹配(薄包装,转发到 ai_parsing 模块).""" return _semantic_match_fallback_base(description, assets) def _parse_semantic_match_response( content: str, - asset_ids: List[str], -) -> Optional[Dict[str, float]]: + asset_ids: list[str], +) -> Optional[dict[str, float]]: """从模型返回中解析素材匹配度(薄包装,转发到 ai_parsing 模块).""" result = _parse_semantic_match_base(content, asset_ids) if result is None: @@ -183,9 +183,9 @@ def _parse_semantic_match_response( def semantic_match_assets( description: str, - assets: List[Dict[str, Any]], + assets: list[dict[str, Any]], top_k: int = 0, -) -> Dict[str, Any]: +) -> dict[str, Any]: """智能素材语义匹配. 根据用户描述,评估每个素材的语义匹配度并排序。 @@ -336,13 +336,13 @@ class AIService: description: str, style: str = "viral", count: int = 5, - ) -> Dict[str, Any]: + ) -> dict[str, Any]: return generate_smart_titles(description, style, count) def semantic_match( self, description: str, - assets: List[Dict[str, Any]], + assets: list[dict[str, Any]], top_k: int = 0, - ) -> Dict[str, Any]: + ) -> dict[str, Any]: return semantic_match_assets(description, assets, top_k) diff --git a/apps/api/app/services/asset_segment_tracker.py b/apps/api/app/services/asset_segment_tracker.py index c9122bb02..07af35daa 100644 --- a/apps/api/app/services/asset_segment_tracker.py +++ b/apps/api/app/services/asset_segment_tracker.py @@ -28,8 +28,8 @@ from __future__ import annotations import json import logging -from datetime import datetime, timezone -from typing import Callable +from collections.abc import Callable +from datetime import UTC, datetime from sqlalchemy.orm import Session @@ -57,7 +57,7 @@ _REUSE_OVERLAP_RATIO = 0.6 def _now_iso() -> str: - return datetime.now(timezone.utc).isoformat() + return datetime.now(UTC).isoformat() def _read_meta(model) -> dict: @@ -156,7 +156,7 @@ def record_used_segments( r["plan_id"] = plan_id meta[USED_RANGES_KEY] = ranges model.classification_result = json.dumps(meta, ensure_ascii=False) - model.updated_at = datetime.now(timezone.utc) + model.updated_at = datetime.now(UTC) return ranges.append( @@ -171,7 +171,7 @@ def record_used_segments( ) meta[USED_RANGES_KEY] = ranges model.classification_result = json.dumps(meta, ensure_ascii=False) - model.updated_at = datetime.now(timezone.utc) + model.updated_at = datetime.now(UTC) def remove_used_segment( @@ -215,7 +215,7 @@ def remove_used_segment( if removed: meta[USED_RANGES_KEY] = remaining model.classification_result = json.dumps(meta, ensure_ascii=False) - model.updated_at = datetime.now(timezone.utc) + model.updated_at = datetime.now(UTC) return removed @@ -231,7 +231,7 @@ def reset_used_segments(db: Session, asset_id: str) -> None: if meta.get(USED_RANGES_KEY): meta[USED_RANGES_KEY] = [] model.classification_result = json.dumps(meta, ensure_ascii=False) - model.updated_at = datetime.now(timezone.utc) + model.updated_at = datetime.now(UTC) logger.info("[片段追踪] 素材区间记录手动清空: asset_id=%s", asset_id) diff --git a/apps/api/app/services/edit_plan_service.py b/apps/api/app/services/edit_plan_service.py index f9f752404..24944060a 100755 --- a/apps/api/app/services/edit_plan_service.py +++ b/apps/api/app/services/edit_plan_service.py @@ -7,7 +7,7 @@ from __future__ import annotations import logging -from typing import Any, Dict, List, Optional +from typing import Any, Optional from app.services.asset_segment_tracker import ( REUSE_RATIO_LIMIT, @@ -206,7 +206,7 @@ class EditPlanService: status: Optional[EditPlanClipStatus] = None, skip: int = 0, limit: int = 100, - ) -> List[EditPlanClip]: + ) -> list[EditPlanClip]: """列出计划的片段""" # 确保计划存在 self.get_plan_or_raise(plan_id) @@ -503,7 +503,7 @@ class EditPlanService: source = self.get_plan_or_raise(source_plan_id) # 分页读取源 plan 全部片段 - clips: List[EditPlanClip] = [] + clips: list[EditPlanClip] = [] skip, page = 0, 500 while True: batch = self._clip_repo.list_by_plan(source_plan_id, skip=skip, limit=page) @@ -665,7 +665,7 @@ class EditPlanService: source = self.get_plan_or_raise(source_plan_id) # 分页读取源 plan 全部片段 - clips: List[EditPlanClip] = [] + clips: list[EditPlanClip] = [] skip, page = 0, 500 while True: batch = self._clip_repo.list_by_plan(source_plan_id, skip=skip, limit=page) @@ -797,7 +797,7 @@ class EditPlanService: except Exception: pass - clips: List[EditPlanClip] = [] + clips: list[EditPlanClip] = [] skip, page = 0, 500 while True: batch = self._clip_repo.list_by_plan(plan_id, skip=skip, limit=page) @@ -1063,7 +1063,7 @@ class EditPlanService: # ── 片段分割与合并 ────────────────────────────────────────────────────── - def split_clip(self, clip_id: str, split_time: float) -> Dict[str, Any]: + def split_clip(self, clip_id: str, split_time: float) -> dict[str, Any]: """将一个片段从指定位置分割为两个片段 Args: @@ -1151,7 +1151,7 @@ class EditPlanService: "right_clip": created_right, } - def merge_clips(self, clip_ids: List[str]) -> EditPlanClip: + def merge_clips(self, clip_ids: list[str]) -> EditPlanClip: """合并多个连续片段为一个片段 Args: @@ -1217,7 +1217,7 @@ class EditPlanService: # ── 渲染生成流程 ──────────────────────────────────────────────────────── - def get_generation_status(self, plan_id: str) -> Dict[str, Any]: + def get_generation_status(self, plan_id: str) -> dict[str, Any]: """获取渲染进度状态 Returns: @@ -1364,7 +1364,7 @@ class EditPlanService: ) return count - def update_plan_config(self, plan_id: str, config_updates: Dict[str, Any]) -> EditPlan: + def update_plan_config(self, plan_id: str, config_updates: dict[str, Any]) -> EditPlan: """更新计划配置(合并更新) Args: diff --git a/apps/api/app/services/edit_template_service.py b/apps/api/app/services/edit_template_service.py index fe3057c50..cb607ec66 100755 --- a/apps/api/app/services/edit_template_service.py +++ b/apps/api/app/services/edit_template_service.py @@ -7,7 +7,7 @@ from __future__ import annotations import logging -from typing import Any, List, Optional +from typing import Any, Optional from sqlalchemy.orm import Session @@ -76,7 +76,7 @@ class EditTemplateService: active_only: bool = False, skip: int = 0, limit: int = 50, - ) -> List[EditTemplate]: + ) -> list[EditTemplate]: """列出模板 Args: @@ -227,7 +227,7 @@ class EditTemplateService: clip_type: Optional[ClipType] = None, skip: int = 0, limit: int = 100, - ) -> List[TemplateClipConfig]: + ) -> list[TemplateClipConfig]: """列出模板的片段配置 注意:本方法要求模板存在于新表 ``edit_templates``(全局模板库), @@ -253,7 +253,7 @@ class EditTemplateService: clip_type: Optional[ClipType] = None, skip: int = 0, limit: int = 100, - ) -> List[TemplateClipConfig]: + ) -> list[TemplateClipConfig]: """编辑器读取模板片段配置的单一数据源入口. 片段配置主表是 ``template_clip_configs``(直接读取,不抛异常、不降级)。 @@ -404,8 +404,8 @@ class EditTemplateService: def reorder_clip_configs( self, template_id: str, - config_ids: List[str], - ) -> List[TemplateClipConfig]: + config_ids: list[str], + ) -> list[TemplateClipConfig]: """重新排序片段配置 Args: @@ -560,7 +560,7 @@ class EditTemplateService: ) # 5. 转换每个片段为模板片段配置 - created_configs: List[TemplateClipConfig] = [] + created_configs: list[TemplateClipConfig] = [] for clip_config_obj in clips_to_template_clip_configs(created_template.id, clips): created = self._clip_config_repo.create(clip_config_obj) created_configs.append(created) diff --git a/apps/api/app/services/lipsync_service.py b/apps/api/app/services/lipsync_service.py index a5f3fb12c..10110d27d 100644 --- a/apps/api/app/services/lipsync_service.py +++ b/apps/api/app/services/lipsync_service.py @@ -17,7 +17,7 @@ from __future__ import annotations import io import logging import uuid -from datetime import datetime, timezone +from datetime import UTC, datetime from typing import Optional from urllib.parse import urlparse @@ -35,7 +35,7 @@ from app.tasks.lipsync_tts import tts_synthesize_and_submit from sqlalchemy.orm import Session from packages.adapters.sqlalchemy_impl.models import LipsyncJobModel -from packages.application.cosyvoice_service import CosyVoiceError, normalize_emotion +from packages.application.cosyvoice_service import CosyVoiceError from packages.domain.sentence_timings import ( compute_sentence_timings, probe_audio_duration, @@ -121,7 +121,8 @@ class LipsyncService: text=script_text, voice_id=actual_voice_id, speed=speed, - emotion=normalize_emotion(emotion), + emotion=emotion, # normalize 在 CosyVoiceService 内部完成 + language="zh", ) except CosyVoiceError as exc: raise MediaKitError(f"TTS 合成失败: {exc}", code="TTSSynthesisFailed") from exc @@ -228,7 +229,7 @@ class LipsyncService: ) job.mediakit_task_id = result["task_id"] job.status = "submitted" - job.submitted_at = datetime.now(timezone.utc) + job.submitted_at = datetime.now(UTC) self.db.commit() logger.info( "[lipsync] 直传音频已提交 MediaKit: job_id=%s task_id=%s", @@ -304,7 +305,7 @@ class LipsyncService: voice_id=voice_id or "", script_text=script_text or "", speed=speed, - emotion=normalize_emotion(emotion) if is_tts_mode else (emotion or ""), + emotion=emotion or "", # 音频直传(含预合成)直接进入 pending(后续同步改为 submitted);TTS 模式进入 tts_processing status="tts_processing" if is_tts_mode else "pending", ) @@ -325,7 +326,7 @@ class LipsyncService: voice_id, script_text, speed, - normalize_emotion(emotion), + emotion or "", ) ) except Exception as exc: @@ -337,7 +338,7 @@ class LipsyncService: job.status = "failed" job.error_message = f"Celery 任务投递失败: {exc}" job.error_code = "AsyncDispatchFailed" - job.updated_at = datetime.now(timezone.utc) + job.updated_at = datetime.now(UTC) self.db.commit() else: # 2b/2c. 直接音频 / 预合成音频:同步路径 @@ -359,7 +360,7 @@ class LipsyncService: voice_id: str, script_text: str, speed: float = 1.0, - emotion: str = "natural", + emotion: str = "neutral", ) -> dict: """同步做 TTS 合成 + 下载 + ffprobe + 句子时间戳计算. @@ -382,7 +383,8 @@ class LipsyncService: text=script_text, voice_id=actual_voice_id, speed=speed, - emotion=normalize_emotion(emotion), + emotion=emotion, # normalize 在 CosyVoiceService 内部完成 + language="zh", ) except CosyVoiceError as exc: raise MediaKitError(f"TTS 合成失败: {exc}", code="TTSSynthesisFailed") from exc @@ -489,39 +491,52 @@ class LipsyncService: mk_status = status_data.get("status", STATUS_RUNNING) logger.info("MediaKit 对口型状态 [%s]: %s", job_id, mk_status) - if mk_status == STATUS_COMPLETED: - result = status_data.get("result", {}) - job.status = STATUS_COMPLETED - temp_url = result.get("video_url", "") - job.output_video_url = temp_url - job.output_duration = result.get("duration", 0.0) - job.completed_at = datetime.now(timezone.utc) - job.updated_at = datetime.now(timezone.utc) - self.db.commit() - # 异步转存自家 OSS - try: - from app.tasks.lipsync_tts import persist_output_video_task + try: + if mk_status == STATUS_COMPLETED: + result = status_data.get("result", {}) + job.status = STATUS_COMPLETED + temp_url = result.get("video_url", "") + job.output_video_url = temp_url + job.output_duration = result.get("duration", 0.0) + job.completed_at = datetime.now(UTC) + job.updated_at = datetime.now(UTC) + self.db.commit() + # 异步转存自家 OSS + try: + from app.tasks.lipsync_tts import persist_output_video_task - persist_output_video_task.apply_async(args=(job_id, user_id, temp_url)) - except Exception as exc: - logger.warning( - "提交输出视频异步转存任务失败,保留临时 URL: job_id=%s err=%s", - job_id, - exc, - ) - self.db.refresh(job) - return job - elif mk_status == STATUS_FAILED: - error = status_data.get("error", {}) - job.status = "failed" - job.error_message = error.get("message", "任务执行失败") - job.error_code = error.get("code", "TaskFailed") - job.completed_at = datetime.now(timezone.utc) - else: - if isinstance(mk_status, str) and mk_status: - job.status = mk_status - job.updated_at = datetime.now(timezone.utc) - self.db.commit() + persist_output_video_task.apply_async(args=(job_id, user_id, temp_url)) + except Exception as exc: + logger.warning( + "提交输出视频异步转存任务失败,保留临时 URL: job_id=%s err=%s", + job_id, + exc, + ) + elif mk_status == STATUS_FAILED: + error = status_data.get("error", {}) + job.status = "failed" + job.error_message = error.get("message", "任务执行失败") + job.error_code = error.get("code", "TaskFailed") + job.completed_at = datetime.now(UTC) + else: + # 中间状态(running/processing/queued 等)同步到 DB,避免前端永远卡在 submitted + if isinstance(mk_status, str) and mk_status: + job.status = mk_status + job.updated_at = datetime.now(UTC) + self.db.commit() + except Exception as exc: # noqa: BLE001 - DB 提交失败必须记录日志并重试,否则后台任务静默失败 + logger.error( + "refresh_job_status 提交 DB 失败 job_id=%s mk_status=%s err=%s", + job_id, + mk_status, + exc, + exc_info=True, + ) + try: + self.db.rollback() + except Exception: + pass + # DB commit 失败不 raise,返回当前 job 对象让下次轮询再试 self.db.refresh(job) return job @@ -574,7 +589,7 @@ class LipsyncService: if job.status in ("pending", "tts_processing", "submitted"): job.status = "cancelled" - job.updated_at = datetime.now(timezone.utc) + job.updated_at = datetime.now(UTC) self.db.commit() self.db.refresh(job) diff --git a/apps/api/app/services/plan_generator_service.py b/apps/api/app/services/plan_generator_service.py index 619c8abd0..04418ae2b 100755 --- a/apps/api/app/services/plan_generator_service.py +++ b/apps/api/app/services/plan_generator_service.py @@ -14,7 +14,7 @@ from __future__ import annotations import logging import random -from typing import Any, List +from typing import Any from sqlalchemy.orm import Session @@ -62,8 +62,8 @@ class PlanGeneratorService: def generate_from_template( self, template: EditTemplate, - clip_configs: List[TemplateClipConfig], - asset_ids: List[str], + clip_configs: list[TemplateClipConfig], + asset_ids: list[str], *, project_id: str = "", created_by_user_id: str = "", @@ -135,7 +135,7 @@ class PlanGeneratorService: ) # 5. 持久化所有 clips 并计算总时长 - created_clips: List[EditPlanClip] = [] + created_clips: list[EditPlanClip] = [] total_duration = 0.0 for clip in clips: saved = self._clip_repo.create(clip) @@ -184,15 +184,15 @@ class PlanGeneratorService: def _create_clips_from_configs( self, plan_id: str, - clip_configs: List[TemplateClipConfig], - ) -> List[EditPlanClip]: + clip_configs: list[TemplateClipConfig], + ) -> list[EditPlanClip]: """从 TemplateClipConfig 列表创建 EditPlanClip 列表(未持久化). 委托给 plan_generator_utils.create_clips_from_configs 纯函数。 """ return create_clips_from_configs(plan_id, clip_configs) - def _map_clip_types_for_mode(self, clips: List[EditPlanClip], editing_mode: str) -> None: + def _map_clip_types_for_mode(self, clips: list[EditPlanClip], editing_mode: str) -> None: """将 MAIN 类型片段按 editing_mode 映射为对应角色类型. 委托给 plan_generator_utils.map_clip_types_for_mode 纯函数。 @@ -204,7 +204,7 @@ class PlanGeneratorService: plan_id: str, editing_mode: str, asset_count: int, - ) -> List[EditPlanClip]: + ) -> list[EditPlanClip]: """无 clip_configs 时,根据 editing_mode 生成默认 clip 结构. 委托给 plan_generator_utils.generate_default_clips 纯函数。 @@ -213,8 +213,8 @@ class PlanGeneratorService: def _distribute_assets( self, - clips: List[EditPlanClip], - asset_ids: List[str], + clips: list[EditPlanClip], + asset_ids: list[str], editing_mode: str, *, random_selection: bool = False, @@ -259,7 +259,7 @@ class PlanGeneratorService: external_used_segments=external_used_segments, ) - def _fetch_asset_scene_points(self, asset_ids: List[str]) -> dict[str, list[float]]: + def _fetch_asset_scene_points(self, asset_ids: list[str]) -> dict[str, list[float]]: """从素材 metadata 读取场景切换点缓存(无缓存的素材不包含在结果中)。""" points_map: dict[str, list[float]] = {} if not self._asset_repo: @@ -272,7 +272,7 @@ class PlanGeneratorService: points_map[asset_id] = points return points_map - def _sort_assets_by_smart_score(self, asset_ids: List[str]) -> List[str]: + def _sort_assets_by_smart_score(self, asset_ids: list[str]) -> list[str]: """按 smart_match 综合评分降序排列素材 ID(注入随机噪声)。 评分高的素材(质量好、时长合适、新鲜、使用次数少)倾向排在前面; @@ -295,7 +295,7 @@ class PlanGeneratorService: ) return [aid for aid, _ in scored] - def _fetch_asset_durations(self, asset_ids: List[str]) -> dict[str, float]: + def _fetch_asset_durations(self, asset_ids: list[str]) -> dict[str, float]: """从数据库获取素材时长信息. Args: diff --git a/apps/api/app/services/script_asr_service.py b/apps/api/app/services/script_asr_service.py new file mode 100644 index 000000000..cdba1cc7f --- /dev/null +++ b/apps/api/app/services/script_asr_service.py @@ -0,0 +1,59 @@ +"""文案提取 ASR 服务封装 — Issue #1893. + +将已有的 ASR 服务工厂封装为面向文案提取场景的简单接口: +- transcribe_to_text(video_path) -> str:将视频/音频转写为纯文本 +- 未配置 ASR 时抛 ASRNotConfiguredError(路由层映射为 503) +- ASR 调用失败时抛 ASRTranscriptionError(路由层映射为 502) +""" + +from __future__ import annotations + +import logging +from pathlib import Path + +from packages.ports.asr_service import ASRServiceError + +logger = logging.getLogger(__name__) + + +class ASRNotConfiguredError(Exception): + """ASR 服务未配置.""" + + +class ASRTranscriptionError(Exception): + """ASR 转写失败.""" + + +def transcribe_to_text(media_path: str | Path) -> str: + """将视频/音频文件转写为纯文本. + + Args: + media_path: 媒体文件路径 + + Returns: + 转写出的文本 + + Raises: + ASRNotConfiguredError: ASR 服务未配置 + ASRTranscriptionError: ASR 调用失败 + """ + # 延迟导入,避免循环依赖和启动时副作用 + from apps.worker.services.asr_service_factory import get_asr_service + + asr = get_asr_service() + if asr is None: + raise ASRNotConfiguredError("ASR 服务未配置,请联系管理员配置火山 MediaKit 或阿里云 ASR 密钥") + + try: + timeline = asr.transcribe(Path(media_path)) + # 拼接所有分段的文本 + text = "".join(seg.text for seg in timeline.segments) + return text.strip() + except ASRNotConfiguredError: + raise + except ASRServiceError as exc: + logger.error("ASR 转写失败: %s", exc) + raise ASRTranscriptionError(f"语音识别失败: {exc}") from exc + except Exception as exc: + logger.error("ASR 转写异常: %s", exc) + raise ASRTranscriptionError(f"语音识别失败: {exc}") from exc diff --git a/apps/api/app/services/script_service.py b/apps/api/app/services/script_service.py index 8b069d57e..f9c03f3fb 100644 --- a/apps/api/app/services/script_service.py +++ b/apps/api/app/services/script_service.py @@ -1,4 +1,4 @@ -"""ScriptService — Issue #1795 口播文案库 CRUD + #1894 标题配置整合. +"""ScriptService — Issue #1795 口播文案库 CRUD. 纯 Service 层封装,routes 直接调用。 """ @@ -6,7 +6,7 @@ from __future__ import annotations import uuid -from datetime import datetime, timezone +from datetime import UTC, datetime from typing import Optional from sqlalchemy.orm import Session @@ -62,8 +62,8 @@ class ScriptService: content=content, segments=segments if segments is not None else [], tags=tags if tags is not None else [], - title_text=title_text, - title_category=title_category, + title_text=title_text or "", + title_category=title_category or "", title_config=title_config if title_config is not None else {}, ) self.db.add(script) @@ -108,11 +108,21 @@ class ScriptService: script.title_category = title_category if title_config is not None: script.title_config = title_config - script.updated_at = datetime.now(timezone.utc) + script.updated_at = datetime.now(UTC) self.db.commit() self.db.refresh(script) return script + # ── title config ───────────────────────────────────────────────────── + + def get_title_config_for_script(self, script_id: str, user_id: str) -> dict: + """从 script 读取标题配置,返回可直接用于渲染的 title_config dict.""" + script = self.get_script(script_id, user_id) + config = dict(script.title_config or {}) + if not config.get("text") and script.title_text: + config["text"] = script.title_text + return config + # ── delete ──────────────────────────────────────────────────────────── def delete_script(self, script_id: str, user_id: str) -> bool: @@ -122,19 +132,3 @@ class ScriptService: self.db.delete(script) self.db.commit() return True - - # ── #1894: 获取 script 的标题配置(供视频生成流程使用)─────────────── - - def get_title_config_for_script(self, script_id: str, user_id: str) -> dict: - """从 script 读取标题配置,返回可直接用于渲染的 title_config dict. - - 规则: - - script 存在且属于当前用户时,返回其 title_config(若为空则用 title_text 构造基础配置) - - script 不存在或不属于当前用户时,抛出 ScriptNotFoundError - """ - script = self.get_script(script_id, user_id) - config = dict(script.title_config or {}) - # 如果 title_config 里没有 text,用 title_text 填充 - if not config.get("text") and script.title_text: - config["text"] = script.title_text - return config diff --git a/apps/api/app/tasks/lipsync_tts.py b/apps/api/app/tasks/lipsync_tts.py index 2f75e9b41..dce535485 100644 --- a/apps/api/app/tasks/lipsync_tts.py +++ b/apps/api/app/tasks/lipsync_tts.py @@ -20,7 +20,7 @@ _estimate_sentence_timings_by_chars 等内部函数已复用共享实现,避 import io import logging -from datetime import datetime, timezone +from datetime import UTC, datetime from urllib.parse import urlparse from celery import shared_task @@ -160,13 +160,14 @@ def tts_synthesize_and_submit( voice_id=voice_id, speed=speed, emotion=emotion, + language="zh", ) except CosyVoiceError as exc: logger.error("[lipsync_tts] TTS 合成失败: job_id=%s err=%s", job_id, exc) job.status = "failed" job.error_message = f"TTS 合成失败: {exc}" job.error_code = "TTSSynthesisFailed" - job.updated_at = datetime.now(timezone.utc) + job.updated_at = datetime.now(UTC) db.commit() return except ValueError as exc: @@ -174,7 +175,7 @@ def tts_synthesize_and_submit( job.status = "failed" job.error_message = f"TTS 参数错误: {exc}" job.error_code = "TTSInvalidParam" - job.updated_at = datetime.now(timezone.utc) + job.updated_at = datetime.now(UTC) db.commit() return @@ -184,7 +185,7 @@ def tts_synthesize_and_submit( job.status = "failed" job.error_message = "TTS 未返回音频 URL" job.error_code = "TTSNoAudio" - job.updated_at = datetime.now(timezone.utc) + job.updated_at = datetime.now(UTC) db.commit() return @@ -270,7 +271,7 @@ def tts_synthesize_and_submit( ) job.mediakit_task_id = mk_result["task_id"] job.status = "submitted" - job.submitted_at = datetime.now(timezone.utc) + job.submitted_at = datetime.now(UTC) logger.info( "[lipsync_tts] 已提交 MediaKit: job_id=%s task_id=%s", job_id, @@ -282,6 +283,18 @@ def tts_synthesize_and_submit( job.error_code = exc.code logger.error("[lipsync_tts] 提交 MediaKit 失败: job_id=%s err=%s", job_id, exc) + # 三层防御 ③:链式触发 Celery 兜底轮询——MediaKit 提交成功后由 worker + # 主动拉取状态到终态,不依赖前端轮询触发的 FastAPI background task + # (background task 可能静默失败导致永久卡 running)。 + if job.status == "submitted" and job.mediakit_task_id: + try: + poll_mediakit_status.apply_async( + kwargs={"job_id": job_id, "user_id": user_id}, + countdown=10, # 10 秒后开始轮询,给 MediaKit 一点处理时间 + ) + except Exception as exc: # noqa: BLE001 + logger.warning("[lipsync_tts] 提交兜底轮询任务失败(不影响主流程): job_id=%s err=%s", job_id, exc) + db.commit() except Exception: @@ -292,7 +305,7 @@ def tts_synthesize_and_submit( job.status = "failed" job.error_message = "TTS 异步任务执行异常" job.error_code = "AsyncTaskError" - job.updated_at = datetime.now(timezone.utc) + job.updated_at = datetime.now(UTC) db.commit() except Exception: logger.exception("[lipsync_tts] 回写失败状态时异常: job_id=%s", job_id) @@ -300,6 +313,99 @@ def tts_synthesize_and_submit( db.close() +@shared_task( + bind=True, + name="lipsync_tts.poll_mediakit_status", + max_retries=60, # 最多轮询 60 次 + default_retry_delay=10, # 每次间隔 10 秒(总兜底时长 10 分钟) +) +def poll_mediakit_status(self, job_id: str, user_id: str): + """Celery 兜底轮询:TTS 提交 MediaKit 后,由 worker 主动拉取状态直到终态。 + + 不依赖前端轮询,避免 background task 静默失败导致任务永久卡 running/submitted。 + """ + from sqlalchemy.orm import Session as DBSession + + from packages.adapters.sqlalchemy_impl.models import LipsyncJobModel + + try: + from worker_app.db import SessionLocal # type: ignore + except Exception: # noqa: BLE001 + from app.db import SessionLocal # type: ignore + + db: DBSession = SessionLocal() + try: + job = db.query(LipsyncJobModel).filter(LipsyncJobModel.id == job_id, LipsyncJobModel.user_id == user_id).first() + if job is None: + logger.warning("[lipsync_poll] Job not found: job_id=%s", job_id) + return + + # 已终态,不需要再轮询 + if job.status in ("completed", "failed", "cancelled"): + return + + if not job.mediakit_task_id: + logger.warning("[lipsync_poll] Job has no mediakit_task_id: job_id=%s status=%s", job_id, job.status) + return + + from app.services.lipsync_service import STATUS_COMPLETED as _SC + from app.services.lipsync_service import STATUS_FAILED as _SF + from app.services.lipsync_service import LipsyncService + from app.services.mediakit_client import MediaKitError, get_mediakit_client + + client = get_mediakit_client() + try: + status_data = client.get_task_status(job.mediakit_task_id) + except MediaKitError as exc: + logger.warning("[lipsync_poll] 拉取 MediaKit 状态失败,将重试: job_id=%s err=%s", job_id, exc) + raise self.retry(exc=exc) from exc + + mk_status = status_data.get("status", "running") + + if mk_status in ("succeeded", _SC): + + svc = LipsyncService(db) + result = status_data.get("result", {}) + job.status = "completed" + output_url = result.get("video_url", "") + try: + job.output_video_url = svc._persist_output_video(output_url, job_id, user_id) + except Exception as exc: # noqa: BLE001 + logger.warning("[lipsync_poll] 转存 OSS 失败,保留临时 URL: job_id=%s err=%s", job_id, exc) + job.output_video_url = output_url + job.output_duration = result.get("duration", 0.0) + job.completed_at = datetime.now(UTC) + job.updated_at = datetime.now(UTC) + db.commit() + logger.info("[lipsync_poll] 任务完成: job_id=%s", job_id) + elif mk_status in ("failed", "error", _SF): + error = status_data.get("error", {}) + job.status = "failed" + job.error_message = error.get("message", "任务执行失败") + job.error_code = error.get("code", "TaskFailed") + job.completed_at = datetime.now(UTC) + job.updated_at = datetime.now(UTC) + db.commit() + logger.info("[lipsync_poll] 任务失败: job_id=%s err=%s", job_id, job.error_message) + else: + # 中间状态,更新时间戳,继续重试 + job.updated_at = datetime.now(UTC) + if isinstance(mk_status, str) and mk_status: + job.status = mk_status + db.commit() + logger.debug("[lipsync_poll] 任务仍在 %s,继续轮询: job_id=%s", mk_status, job_id) + raise self.retry() + except Exception as exc: + logger.exception("[lipsync_poll] 未预期异常: job_id=%s", job_id) + try: + db.rollback() + except Exception: + pass + raise self.retry(exc=exc) from exc + finally: + db.close() + + @shared_task( name="lipsync_tts.persist_output_video", max_retries=2, @@ -340,7 +446,7 @@ def persist_output_video_task(job_id: str, user_id: str, temp_url: str): permanent_url = storage.upload_file(io.BytesIO(data), storage_key, content_type="video/mp4") final_url = _sign_media_url(permanent_url) if permanent_url else temp_url job.output_video_url = final_url - job.updated_at = datetime.now(timezone.utc) + job.updated_at = datetime.now(UTC) db.commit() logger.info("[lipsync_tts.persist] 输出视频已转存 OSS: job_id=%s key=%s", job_id, storage_key) except Exception as exc: diff --git a/apps/web/e2e/core-generation.spec.ts b/apps/web/e2e/core-generation.spec.ts index c282269f3..57881e185 100755 --- a/apps/web/e2e/core-generation.spec.ts +++ b/apps/web/e2e/core-generation.spec.ts @@ -40,7 +40,6 @@ async function loginWithRetry( type ProjectResponse = { id: string } type LibraryResponse = { id: string } -type TemplateResponse = { id: string } type AssetListResponse = { items: Array<{ id: string @@ -126,28 +125,17 @@ test.describe("Core generation flow", () => { ) .toBe("ready") - // Create an editing template so the generate page has at least one template - // (templates are now loaded from API; new users have none by default) - const template = await request.post(`${apiBase}/templates`, { - headers, - data: { - name: `E2E 测试模板 ${suffix}`, - mode: "pip", - estimated_duration: 30, - segments: [ - { - segment_order: 1, - duration_min: 5, - duration_max: 30, - material_type: "video", - }, - ], - tags: ["e2e"], - }, - }) - expect(template.status(), await template.text()).toBe(201) - const templateData = (await template.json()) as TemplateResponse - expect(templateData.id).toBeTruthy() + // #1926 P0 fix: POST /templates CRUD endpoint removed; GET /templates + // now auto-creates a default template for new users. Use the first one. + const templatesResp = await request.get(`${apiBase}/templates`, { headers }) + expect(templatesResp.status(), await templatesResp.text()).toBe(200) + const templatesData = (await templatesResp.json()) as { + items: Array<{ id: string }> + } + expect(Array.isArray(templatesData.items)).toBe(true) + expect(templatesData.items.length).toBeGreaterThan(0) + const templateId = templatesData.items[0].id + expect(templateId).toBeTruthy() // Set auth in localStorage await page.addInitScript( diff --git a/apps/web/src/api/points/index.ts b/apps/web/src/api/points/index.ts new file mode 100644 index 000000000..8344b97af --- /dev/null +++ b/apps/web/src/api/points/index.ts @@ -0,0 +1,477 @@ +/** + * 积分 & 会员 API 封装(v2 两档会员制) + * 后端接口未就绪阶段使用 mock 数据;接口就绪后把 MOCK 开关关掉即可。 + */ +import apiClient from "../client" +import type { + PointsBalance, + PointsTransaction, + PointsTransactionsParams, + PointsTransactionsResponse, + PointsPackagesResponse, + PointsRechargeRequest, + PointsOrder, + PointsRulesResponse, + PointsCheckRequest, + PointsCheckResponse, + SubscriptionCurrent, + SubscribeRequest, + SubscriptionPlan, +} from "./types" + +/** + * 是否启用 mock 数据(后端 PR 合入前为 true;对接真实接口后改为 false) + */ +export const POINTS_API_MOCK = true + +// ==================== Mock 数据 ==================== +const MOCK_BALANCE: PointsBalance = { + balance: 580, + total_earned: 1200, + total_spent: 620, + is_member: false, + member_type: null, + member_expires_at: null, + free_clips_used: 1, + free_clips_limit: 2, + free_clips_remaining: 1, +} + +const MOCK_PACKAGES: PointsPackagesResponse = { + packages: [ + { + id: "starter_pack", + name: "体验包", + points: 100, + price: 990, + discounted_price_for_free: 990, + discounted_price_for_monthly: 891, + discounted_price_for_quarterly: 861, + discounted_price_for_yearly: 792, + }, + { + id: "basic_pack", + name: "基础包", + points: 500, + price: 3900, + discounted_price_for_free: 3900, + discounted_price_for_monthly: 3510, + discounted_price_for_quarterly: 3393, + discounted_price_for_yearly: 3120, + }, + { + id: "pro_pack", + name: "专业包", + points: 2000, + price: 12900, + discounted_price_for_free: 12900, + discounted_price_for_monthly: 11610, + discounted_price_for_quarterly: 11223, + discounted_price_for_yearly: 10320, + }, + ], + user_member_type: "free", + unit_price_yuan: 0.1, +} + +const MOCK_RULES: PointsRulesResponse = { + rules: [ + { + scene_key: "ai_voice", + scene_name: "AI 配音", + points_per_use: 1, + unit: "分钟", + description: "每生成 1 分钟配音", + }, + { + scene_key: "ai_video", + scene_name: "智能混剪", + points_per_use: 3, + unit: "条", + extra_per_30s: 1, + description: "每条 ≤30s 3 积分,每加 30s +1", + }, + { + scene_key: "ai_digital_human", + scene_name: "AI 数字人", + points_per_use: 15, + unit: "分钟", + description: "每生成 1 分钟口播", + }, + { + scene_key: "voice_clone_train", + scene_name: "声音克隆训练", + points_per_use: 0, + unit: "次", + description: "训练免费", + }, + { scene_key: "voice_clone_synth", scene_name: "声音克隆合成", points_per_use: 1, unit: "分钟" }, + { scene_key: "douyin_extract", scene_name: "抖音链接提取", points_per_use: 1, unit: "次" }, + { scene_key: "ai_rewrite", scene_name: "AI 改写文案", points_per_use: 1, unit: "次" }, + { scene_key: "ai_title", scene_name: "AI 标题生成", points_per_use: 1, unit: "次" }, + { scene_key: "ai_cover", scene_name: "AI 封面生成", points_per_use: 1, unit: "张" }, + ], + free_user_multiplier: 1.15, + note: "免费用户消耗 = 会员消耗 × 1.15,向上取整", +} + +function genMockTransactions(): PointsTransactionsResponse { + const now = new Date() + const list = [ + { + src: "ai_voice", + name: "AI 配音", + type: "spend" as const, + amt: 1, + desc: "生成配音 1 分钟", + days: 0, + hours: 0, + mins: 30, + }, + { + src: "ai_video", + name: "智能混剪", + type: "spend" as const, + amt: 5, + desc: "生成 1 分钟视频(基础3+30s*2)", + days: 0, + hours: 1, + mins: 15, + }, + { + src: "task_reward", + name: "任务奖励", + type: "earn" as const, + amt: 20, + desc: "首次生成视频奖励", + days: 1, + hours: 0, + mins: 0, + }, + { + src: "recharge", + name: "充值", + type: "earn" as const, + amt: 500, + desc: "基础包充值", + days: 15, + hours: 0, + mins: 0, + }, + { + src: "ai_rewrite", + name: "AI 改写文案", + type: "spend" as const, + amt: 2, + desc: "免费用户价(1×1.15 向上取整)", + days: 16, + hours: 2, + mins: 10, + }, + { + src: "ai_title", + name: "AI 标题生成", + type: "spend" as const, + amt: 2, + desc: "免费用户价", + days: 16, + hours: 3, + mins: 0, + }, + { + src: "douyin_extract", + name: "抖音链接提取", + type: "spend" as const, + amt: 2, + desc: "提取 3 分钟文案", + days: 18, + hours: 0, + mins: 0, + }, + { + src: "ai_digital_human", + name: "AI 数字人", + type: "spend" as const, + amt: 18, + desc: "数字人口播 1 分钟(免费用户价)", + days: 20, + hours: 0, + mins: 0, + }, + { + src: "task_reward", + name: "任务奖励", + type: "earn" as const, + amt: 50, + desc: "注册赠送", + days: 30, + hours: 0, + mins: 0, + }, + ] + let bal = MOCK_BALANCE.balance + const items = list + .map((t, i) => { + const signed = t.type === "earn" ? t.amt : -t.amt + const balance_after = bal // 按时间倒序:earliest 先算 + // adjust running bal + bal = t.type === "earn" ? bal - t.amt : bal + t.amt + const d = new Date(now) + d.setDate(d.getDate() - t.days) + d.setHours(d.getHours() - t.hours) + d.setMinutes(d.getMinutes() - t.mins) + return { + id: `tx_${i + 1}`, + type: t.type, + source: t.src as PointsBalance extends never ? never : string, + source_name: t.name, + amount: t.amt, + signed_amount: signed, + balance_after, + description: t.desc, + ref_id: null, + created_at: d.toISOString(), + } + }) + .reverse() + // Rebuild balance_after going forward + let running = 50 + 0 // after registration gift + for (let i = items.length - 1; i >= 0; i--) { + const it = items[i] as PointsTransaction & { balance_after?: number } + if (it.source === "task_reward" && it.description.includes("注册")) running = 50 + } + running = 50 + const fwd = [...items].reverse() as Array + for (const it of fwd) { + running += it.signed_amount + it.balance_after = running + } + return { items: fwd, total: fwd.length, page: 1, page_size: 20 } as PointsTransactionsResponse +} + +// ==================== 真实 API ==================== + +/** 查询积分余额 + 会员状态 */ +export async function getPointsBalance(): Promise { + if (POINTS_API_MOCK) { + return new Promise((r) => setTimeout(() => r({ ...MOCK_BALANCE }), 180)) + } + const res = await apiClient.get("/points/balance") + return res.data +} + +/** 查询积分流水(分页) */ +export async function getPointsTransactions( + params: PointsTransactionsParams = {}, +): Promise { + if (POINTS_API_MOCK) { + return new Promise((r) => setTimeout(() => r(genMockTransactions()), 200)) + } + const res = await apiClient.get("/points/transactions", { params }) + return res.data +} + +/** 查询积分包列表 */ +export async function getPointsPackages(): Promise { + if (POINTS_API_MOCK) { + return new Promise((r) => setTimeout(() => r({ ...MOCK_PACKAGES }), 150)) + } + const res = await apiClient.get("/points/packages") + return res.data +} + +/** 创建积分充值订单(mock 阶段返回 "pending" 订单,前端弹"支付开发中") */ +export async function createPointsOrder(req: PointsRechargeRequest): Promise { + if (POINTS_API_MOCK) { + const pkg = MOCK_PACKAGES.packages.find((p) => p.id === req.package_id) + const mt = MOCK_PACKAGES.user_member_type + type DiscountKey = + | "discounted_price_for_free" + | "discounted_price_for_monthly" + | "discounted_price_for_quarterly" + | "discounted_price_for_yearly" + const discountKey = `discounted_price_for_${mt}` as DiscountKey + const price = pkg?.[discountKey] ?? pkg?.price ?? 0 + return new Promise((r) => + setTimeout( + () => + r({ + id: `mock_order_${Date.now()}`, + package_id: req.package_id, + package_name: pkg?.name ?? "", + points_amount: pkg?.points ?? 0, + price_cents: price, + original_price_cents: pkg?.price ?? 0, + discount: price / (pkg?.price || 1), + currency: "CNY", + status: "pending", + payment_method: null, + payment_id: null, + paid_at: null, + expire_at: null, + created_at: new Date().toISOString(), + }), + 300, + ), + ) + } + const res = await apiClient.post("/points/recharge", req) + return res.data +} + +/** 查询积分消耗规则 */ +export async function getPointsRules(): Promise { + if (POINTS_API_MOCK) { + return new Promise((r) => setTimeout(() => r({ ...MOCK_RULES }), 120)) + } + const res = await apiClient.get("/points/rules") + return res.data +} + +/** 消费前余额预检查 */ +export async function checkPoints(req: PointsCheckRequest): Promise { + if (POINTS_API_MOCK) { + const rule = MOCK_RULES.rules.find((r) => r.scene_key === req.scene_key) + if (!rule) { + return { + allowed: false, + required_points: 0, + current_balance: MOCK_BALANCE.balance, + remaining_after: MOCK_BALANCE.balance, + is_free_quota: false, + code: "SCENE_NOT_FOUND", + message: "未知场景", + recharge_url: "/app/points", + } + } + const units = req.units ?? 1 + let base = rule.points_per_use * units + if (rule.extra_per_30s && units > 1) { + // ai_video extra_per_30s: base already covers first 30s, subtract + base = rule.points_per_use + rule.extra_per_30s * (units - 1) + } + const isFree = + MOCK_BALANCE.is_member === false && + req.scene_key === "ai_video" && + (MOCK_BALANCE.free_clips_remaining ?? 0) > 0 + const needed = isFree + ? 0 + : MOCK_BALANCE.is_member + ? base + : Math.ceil(base * MOCK_RULES.free_user_multiplier) + const allowed = isFree || MOCK_BALANCE.balance >= needed + return { + allowed, + required_points: needed, + current_balance: MOCK_BALANCE.balance, + remaining_after: MOCK_BALANCE.balance - needed, + is_free_quota: isFree, + code: allowed ? undefined : "INSUFFICIENT_POINTS", + message: allowed + ? undefined + : `积分不足,需要 ${needed} 积分,当前余额 ${MOCK_BALANCE.balance}`, + recharge_url: "/app/points", + } + } + const res = await apiClient.post("/points/check", req) + return res.data +} + +// ==================== 订阅相关 ==================== + +/** 订阅套餐(定价常量,前端硬编码;折扣由后端会员类型决定) */ +export const SUBSCRIPTION_PLANS: SubscriptionPlan[] = [ + { + id: "monthly", + name: "月卡", + price_cents: 1990, + price_yuan: 19.9, + per_month_yuan: 19.9, + billing_label: "/月", + }, + { + id: "quarterly", + name: "季卡", + price_cents: 3990, + price_yuan: 39.9, + per_month_yuan: 13.3, + savings_percent: 33, + recommended: true, + billing_label: "/季", + }, + { + id: "yearly", + name: "年卡", + price_cents: 15900, + price_yuan: 159, + per_month_yuan: 13.25, + savings_percent: 33, + billing_label: "/年", + }, +] + +/** 查询当前订阅 */ +export async function getCurrentSubscription(): Promise { + if (POINTS_API_MOCK) { + return new Promise((r) => + setTimeout( + () => + r({ + is_member: MOCK_BALANCE.is_member, + member_type: MOCK_BALANCE.member_type, + member_type_name: MOCK_BALANCE.is_member ? "付费会员" : "免费会员", + status: MOCK_BALANCE.is_member ? "active" : "none", + current_period_start: null, + current_period_end: MOCK_BALANCE.member_expires_at, + auto_renew: false, + points_discount: MOCK_BALANCE.is_member ? 0.9 : 1.0, + }), + 150, + ), + ) + } + const res = await apiClient.get("/subscription/current") + return res.data +} + +/** 开通/续费会员 */ +export async function subscribe(req: SubscribeRequest): Promise { + if (POINTS_API_MOCK) { + const plan = SUBSCRIPTION_PLANS.find((p) => p.id === req.member_type)! + return new Promise((r) => + setTimeout( + () => + r({ + id: `mock_sub_${Date.now()}`, + package_id: plan.id, + package_name: plan.name, + points_amount: 0, + price_cents: plan.price_cents, + original_price_cents: plan.price_cents, + discount: 1, + currency: "CNY", + status: "pending", + payment_method: null, + payment_id: null, + paid_at: null, + expire_at: null, + created_at: new Date().toISOString(), + }), + 300, + ), + ) + } + const res = await apiClient.post("/subscription/subscribe", req) + return res.data +} + +/** 取消自动续费 */ +export async function cancelAutoRenew(): Promise<{ success: boolean; message: string }> { + if (POINTS_API_MOCK) { + return new Promise((r) => + setTimeout(() => r({ success: true, message: "已取消自动续费" }), 200), + ) + } + const res = await apiClient.post("/subscription/cancel") + return res.data +} diff --git a/apps/web/src/api/points/types.ts b/apps/web/src/api/points/types.ts new file mode 100644 index 000000000..577c26ef1 --- /dev/null +++ b/apps/web/src/api/points/types.ts @@ -0,0 +1,217 @@ +/** + * 积分 & 会员系统 API 类型定义(v2 两档会员制) + * 接口契约对齐后端设计文档 membership-points-design-v2.md + */ + +/** 会员类型 */ +export type MemberType = "free" | "monthly" | "quarterly" | "yearly" + +/** 积分流水类型 */ +export type PointsTxType = "earn" | "spend" | "refund" + +/** 积分来源/消耗场景 */ +export type PointsSource = + | "recharge" // 充值 + | "task_reward" // 任务奖励 + | "ai_voice" // AI 配音 + | "ai_digital_human" // AI 数字人 + | "ai_video" // 智能混剪 + | "voice_clone_train" // 声音克隆训练 + | "voice_clone_synth" // 声音克隆合成 + | "douyin_extract" // 抖音链接提取 + | "ai_rewrite" // AI 改写文案 + | "ai_title" // AI 标题生成 + | "ai_cover" // AI 封面生成 + | "subscription_bonus" // 会员赠送 + | "admin_adjust" // 管理员调整 + | "refund" // 失败退还 + +/** 会员 & 积分余额响应 */ +export interface PointsBalance { + /** 当前可用积分 */ + balance: number + /** 累计获得 */ + total_earned: number + /** 累计消耗 */ + total_spent: number + /** 是否付费会员(free 用户为 false) */ + is_member: boolean + /** 会员类型:monthly / quarterly / yearly;free 用户为 null */ + member_type: Extract | null + /** 会员到期时间 ISO 字符串 */ + member_expires_at: string | null + /** 今日免费混剪已用次数 */ + free_clips_used?: number + /** 今日免费混剪额度上限 */ + free_clips_limit?: number + /** 今日免费混剪剩余 */ + free_clips_remaining?: number +} + +/** 积分流水记录 */ +export interface PointsTransaction { + id: string + /** earn / spend / refund */ + type: PointsTxType + /** 来源场景 */ + source: PointsSource + /** 场景中文名称 */ + source_name: string + /** 变动数量(正数) */ + amount: number + /** 带符号的变动数(收入+,支出-) */ + signed_amount: number + /** 变动后余额 */ + balance_after: number + /** 备注描述 */ + description: string + /** 关联业务 ID */ + ref_id: string | null + created_at: string +} + +/** 积分流水分页响应 */ +export interface PointsTransactionsResponse { + items: PointsTransaction[] + total: number + page: number + page_size: number +} + +/** 积分流水查询参数 */ +export interface PointsTransactionsParams { + page?: number + page_size?: number + type?: PointsTxType + source?: PointsSource + start_date?: string + end_date?: string +} + +/** 积分包 */ +export interface PointsPackage { + id: "starter_pack" | "basic_pack" | "pro_pack" | string + /** 中文名称 */ + name: string + /** 积分数量 */ + points: number + /** 原价(分) */ + price: number + /** 各会员类型折扣价(分) */ + discounted_price_for_free: number + discounted_price_for_monthly: number + discounted_price_for_quarterly: number + discounted_price_for_yearly: number +} + +/** 积分包列表响应 */ +export interface PointsPackagesResponse { + packages: PointsPackage[] + /** 当前用户会员类型,用于前端计算折后价 */ + user_member_type: MemberType + /** 积分单价(元/积分,按会员价计) */ + unit_price_yuan: number +} + +/** 创建充值订单请求 */ +export interface PointsRechargeRequest { + package_id: string + payment_method?: "wechat_pay" | "alipay" +} + +/** 订单状态 */ +export type OrderStatus = "pending" | "paid" | "failed" | "refunded" | "expired" + +/** 充值订单响应 */ +export interface PointsOrder { + id: string + package_id: string + package_name: string + points_amount: number + price_cents: number + original_price_cents: number + discount: number + currency: "CNY" + status: OrderStatus + payment_method: string | null + payment_id: string | null + paid_at: string | null + expire_at: string | null + created_at: string + /** 微信/支付宝支付参数(mock 阶段前端自行处理) */ + pay_params?: Record +} + +/** 订阅套餐(月/季/年) */ +export interface SubscriptionPlan { + id: "monthly" | "quarterly" | "yearly" + name: string + price_cents: number + price_yuan: number + per_month_yuan: number + savings_percent?: number + recommended?: boolean + billing_label: string +} + +/** 当前订阅详情 */ +export interface SubscriptionCurrent { + is_member: boolean + member_type: Extract | null + member_type_name: string + status: "active" | "expired" | "cancelled" | "none" + current_period_start: string | null + current_period_end: string | null + auto_renew: boolean + /** 订阅会员对应的积分折扣 */ + points_discount: number +} + +/** 开通/续费订阅请求 */ +export interface SubscribeRequest { + member_type: "monthly" | "quarterly" | "yearly" + payment_method?: "wechat_pay" | "alipay" +} + +/** 积分消耗规则 */ +export interface PointsRule { + scene_key: PointsSource + scene_name: string + /** 每次消耗基础积分(会员价) */ + points_per_use: number + /** 计量单位:条/分钟/次/张 */ + unit: string + /** 额外每 30s 加积分(ai_video 用) */ + extra_per_30s?: number + /** 说明文案 */ + description?: string +} + +export interface PointsRulesResponse { + rules: PointsRule[] + /** 免费用户消耗倍率 */ + free_user_multiplier: number + note: string +} + +/** 消费前余额检查请求 */ +export interface PointsCheckRequest { + scene_key: PointsSource + /** 单位数量(时长/条数),默认 1 */ + units?: number +} + +/** 消费前余额检查响应 */ +export interface PointsCheckResponse { + allowed: boolean + required_points: number + current_balance: number + remaining_after: number + /** 是否走免费额度(混剪场景) */ + is_free_quota: boolean + /** 拒绝原因代码 */ + code?: "INSUFFICIENT_POINTS" | "FREE_QUOTA_EXCEEDED" | "SCENE_NOT_FOUND" + message?: string + /** 充值页跳转 URL */ + recharge_url?: string +} diff --git a/apps/web/src/api/scripts/index.ts b/apps/web/src/api/scripts/index.ts index f9d5b5405..2cdf16109 100644 --- a/apps/web/src/api/scripts/index.ts +++ b/apps/web/src/api/scripts/index.ts @@ -1,2 +1,3 @@ export * from "./scripts" export * from "./types" +export * from "./scripts-ai" diff --git a/apps/web/src/api/scripts/scripts-ai.ts b/apps/web/src/api/scripts/scripts-ai.ts new file mode 100644 index 000000000..4b8a1f1f5 --- /dev/null +++ b/apps/web/src/api/scripts/scripts-ai.ts @@ -0,0 +1,87 @@ +/** + * 文案库 AI 能力 API(#1893) + * 三个端点均走真实后端,不参与 SCRIPTS_API_MOCK 开关。 + */ +import apiClient from "../client" + +/** ── 1. 从抖音视频提取文案(下载 + ASR) */ +export interface ExtractFromDouyinRequest { + url: string +} +export interface ExtractFromDouyinResponse { + text: string + duration_seconds?: number + source_url?: string +} + +export async function extractScriptFromDouyin( + body: ExtractFromDouyinRequest, + opts?: { signal?: AbortSignal }, +): Promise { + const res = await apiClient.post( + "/scripts/extract-from-douyin", + body, + { + // ASR 可能较慢,给足超时 + timeout: 60_000, + signal: opts?.signal, + }, + ) + return res.data +} + +/** ── 2. AI 改写文案 */ +export type RewriteStyle = "口语化" | "正式" | "活泼" | "治愈" | "励志" + +export const REWRITE_STYLE_OPTIONS: { value: RewriteStyle; label: string }[] = [ + { value: "口语化", label: "口语化" }, + { value: "正式", label: "正式" }, + { value: "活泼", label: "活泼" }, + { value: "治愈", label: "治愈" }, + { value: "励志", label: "励志" }, +] + +export interface AiRewriteRequest { + content: string + style?: RewriteStyle +} +export interface AiRewriteResponse { + original: string + rewritten: string + style: RewriteStyle +} + +export async function aiRewriteScript( + body: AiRewriteRequest, + opts?: { signal?: AbortSignal }, +): Promise { + const res = await apiClient.post("/scripts/ai-rewrite", body, { + timeout: 60_000, + signal: opts?.signal, + }) + return res.data +} + +/** ── 3. AI 生成标题 */ +export interface AiGenerateTitlesRequest { + content: string + count?: number +} +export interface AiGenerateTitlesResponse { + titles: string[] +} + +export async function aiGenerateTitles( + body: AiGenerateTitlesRequest, + opts?: { signal?: AbortSignal }, +): Promise { + const res = await apiClient.post( + "/scripts/ai-generate-titles", + { content: body.content, count: body.count ?? 3 }, + { + timeout: 30_000, + signal: opts?.signal, + }, + ) + return res.data +} diff --git a/apps/web/src/api/scripts/scripts.ts b/apps/web/src/api/scripts/scripts.ts index 4ee28f146..d3862dcfc 100644 --- a/apps/web/src/api/scripts/scripts.ts +++ b/apps/web/src/api/scripts/scripts.ts @@ -1,37 +1,195 @@ /** - * 文案库 API - * 对接后端 /api/v1/scripts(CRUD + 列表解包) + * 文案库 API(#1811 v2) + * CRUD + 搜索/分类/分页;后端未就绪时使用 mock 数据(SCRIPTS_API_MOCK=true) */ import apiClient from "../client" import type { ScriptItem, + ScriptListParams, ScriptListResponse, - CreateScriptRequest, - UpdateScriptRequest, + ScriptUpsertRequest, + ScriptCategory, } from "./types" -/** 获取文案列表 — 必须解包 items(后端返回 {items,total})*/ -export const getScripts = async (): Promise => { - const response = await apiClient.get("/scripts") - const data = response.data as unknown - if (Array.isArray(data)) return data - const items = (data as { items?: ScriptItem[] })?.items - return Array.isArray(items) ? items : [] +/** 是否启用 mock(后端合入后改为 false) */ +export const SCRIPTS_API_MOCK = true + +// ==================== Mock 数据 ==================== + +const MOCK_CATEGORIES: ScriptCategory[] = [ + "promo", + "vlog", + "knowledge", + "story", + "emotion", + "other", +] + +const SAMPLE_TITLES: Record = { + promo: ["新品上市限时特惠", "618大促开场", "品牌故事宣传片"], + vlog: ["周末citywalk记录", "打工人的一天", "探店vlog"], + knowledge: ["3分钟学会XX", "冷知识科普", "行业深度解读"], + story: ["励志小故事", "情感故事一则", "反转剧情"], + emotion: ["深夜emo时刻", "治愈系文案", "朋友圈金句"], + other: ["通用开场白", "节日祝福", "万能结尾"], +} + +const SAMPLE_TAGS = ["热门", "新品", "节日", "情感", "干货", "搞笑", "治愈", "励志"] + +function genMockScripts(): ScriptItem[] { + const items: ScriptItem[] = [] + const now = Date.now() + let idx = 0 + for (const cat of MOCK_CATEGORIES) { + const titles = SAMPLE_TITLES[cat] + for (let i = 0; i < titles.length; i++) { + idx++ + const title = titles[i] + const content = `这是一条【${cat}】分类下的示例文案,标题为「${title}」。\n\n正文可以包含多段落,每段对应一个片段(segments)。\n\n此为 mock 数据,后端接口就绪后会自动切换为真实数据。` + const segments = content.split(/\n\n+/).filter(Boolean) + const tagCount = 1 + (idx % 3) + const tags: string[] = [] + for (let t = 0; t < tagCount; t++) { + tags.push(SAMPLE_TAGS[(idx + t) % SAMPLE_TAGS.length]) + } + items.push({ + id: `mock_${idx}`, + title, + content, + segments, + tags, + title_text: title, + title_category: cat, + title_config: { + font: "default", + color: "#ffffff", + stroke: "#000000", + position: (["top", "center", "bottom"] as const)[idx % 3], + size: 48, + bold: idx % 2 === 0, + italic: false, + }, + char_count: content.length, + use_count: Math.floor(Math.random() * 50), + created_at: new Date(now - idx * 86400_000 * 2).toISOString(), + updated_at: new Date(now - idx * 86400_000).toISOString(), + }) + } + } + return items +} + +const MOCK_SCRIPTS = genMockScripts() + +// ==================== 真实 API ==================== + +/** 获取文案列表(支持分页/搜索/分类) */ +export async function getScripts(params: ScriptListParams = {}): Promise { + if (SCRIPTS_API_MOCK) { + const page = params.page ?? 1 + const pageSize = params.page_size ?? 20 + let items = [...MOCK_SCRIPTS] + if (params.keyword) { + const kw = params.keyword.toLowerCase() + items = items.filter( + (s) => s.title.toLowerCase().includes(kw) || s.content.toLowerCase().includes(kw), + ) + } + if (params.category && params.category !== "all") { + items = items.filter((s) => s.title_category === params.category) + } + if (params.tag) { + items = items.filter((s) => s.tags?.includes(params.tag as string)) + } + const total = items.length + const start = (page - 1) * pageSize + const pageItems = items.slice(start, start + pageSize) + return new Promise((r) => + setTimeout(() => r({ items: pageItems, total, page, page_size: pageSize }), 200), + ) + } + const res = await apiClient.get("/scripts", { params }) + return res.data +} + +/** 获取单条文案详情 */ +export async function getScript(id: string): Promise { + if (SCRIPTS_API_MOCK) { + const item = MOCK_SCRIPTS.find((s) => s.id === id) + return new Promise((r) => setTimeout(() => r(item ?? MOCK_SCRIPTS[0]), 120)) + } + const res = await apiClient.get(`/scripts/${id}`) + return res.data } /** 新建文案 */ -export const createScript = async (data: CreateScriptRequest): Promise => { - const response = await apiClient.post("/scripts", data) - return response.data +export async function createScript(data: ScriptUpsertRequest): Promise { + if (SCRIPTS_API_MOCK) { + const segments = + data.segments && data.segments.length > 0 + ? data.segments + : data.content.split(/\n\n+/).filter(Boolean) + const item: ScriptItem = { + id: `mock_${Date.now()}`, + ...data, + segments, + char_count: data.content.length, + use_count: 0, + tags: data.tags ?? [], + created_at: new Date().toISOString(), + updated_at: new Date().toISOString(), + } + MOCK_SCRIPTS.unshift(item) + return new Promise((r) => setTimeout(() => r(item), 200)) + } + const res = await apiClient.post("/scripts", data) + return res.data } /** 更新文案 */ -export const updateScript = async (id: string, data: UpdateScriptRequest): Promise => { - const response = await apiClient.put(`/scripts/${id}`, data) - return response.data +export async function updateScript(id: string, data: ScriptUpsertRequest): Promise { + if (SCRIPTS_API_MOCK) { + const idx = MOCK_SCRIPTS.findIndex((s) => s.id === id) + const segments = + data.segments && data.segments.length > 0 + ? data.segments + : data.content.split(/\n\n+/).filter(Boolean) + const updated: ScriptItem = { + ...MOCK_SCRIPTS[idx], + ...data, + segments, + char_count: data.content.length, + tags: data.tags ?? MOCK_SCRIPTS[idx]?.tags ?? [], + updated_at: new Date().toISOString(), + } + if (idx >= 0) MOCK_SCRIPTS[idx] = updated + return new Promise((r) => setTimeout(() => r(updated), 200)) + } + const res = await apiClient.put(`/scripts/${id}`, data) + return res.data } /** 删除文案 */ -export const deleteScript = async (id: string): Promise => { +export async function deleteScript(id: string): Promise { + if (SCRIPTS_API_MOCK) { + const idx = MOCK_SCRIPTS.findIndex((s) => s.id === id) + if (idx >= 0) MOCK_SCRIPTS.splice(idx, 1) + return new Promise((r) => setTimeout(r, 150)) + } await apiClient.delete(`/scripts/${id}`) } + +/** 复制文案(返回新副本) */ +export async function duplicateScript(id: string): Promise { + const orig = await getScript(id) + const copy = await createScript({ + title: `${orig.title}(副本)`, + content: orig.content, + segments: orig.segments, + tags: orig.tags, + title_text: orig.title_text, + title_category: orig.title_category, + title_config: orig.title_config, + }) + return copy +} diff --git a/apps/web/src/api/scripts/types.ts b/apps/web/src/api/scripts/types.ts index 8fa0f30e4..58741dc36 100644 --- a/apps/web/src/api/scripts/types.ts +++ b/apps/web/src/api/scripts/types.ts @@ -1,24 +1,97 @@ /** - * 文案库 API — 类型定义 - * 对接后端 /api/v1/scripts + * 文案库 API — 类型定义(#1811 v2 完整字段版) + * 字段对齐后端契约:title / content / segments / tags / title_text / title_category / title_config + * 同时保留 char_count / use_count / timestamps 等展示字段 */ + +/** 标题配置(字体、颜色、位置、字号) */ +export interface ScriptTitleConfig { + /** 字体预设 key,如 "default" / "bold" / "handwritten" */ + font?: string + /** 文字颜色(CSS color) */ + color?: string + /** 描边色 */ + stroke?: string + /** 位置:top / center / bottom */ + position?: "top" | "center" | "bottom" + /** 字号(px) */ + size?: number + /** 是否加粗 */ + bold?: boolean + /** 是否斜体 */ + italic?: boolean +} + +/** 文案分类(可枚举,也支持自定义) */ +export type ScriptCategory = + | "promo" // 营销推广 + | "vlog" // Vlog/日常 + | "knowledge" // 知识科普 + | "story" // 故事剧情 + | "emotion" // 情感语录 + | "other" // 其他 + +export const SCRIPT_CATEGORY_LABEL: Record = { + promo: "营销推广", + vlog: "Vlog 日常", + knowledge: "知识科普", + story: "故事剧情", + emotion: "情感语录", + other: "其他", +} + +/** 文案条目 */ export interface ScriptItem { id: string + /** 名称(标题) */ title: string + /** 正文 */ content: string - char_count: number + /** 分段(按段落切分,供后端/生成步骤逐段使用) */ + segments?: string[] + /** 标签(逗号分隔或数组,列表展示用 Tag) */ + tags?: string[] + /** 配套标题文本(选填,"使用"跳创作页时会预填到标题) */ + title_text?: string + /** 分类 */ + title_category?: ScriptCategory + /** 标题样式配置(字体/颜色/位置/字号) */ + title_config?: ScriptTitleConfig + /** 正文字符数(后端返回,前端用于展示) */ + char_count?: number + /** 使用次数(后端返回) */ + use_count?: number created_at: string updated_at?: string } +/** 列表查询参数(支持搜索/分类/分页) */ +export interface ScriptListParams { + page?: number + page_size?: number + /** 标题/正文模糊搜索 */ + keyword?: string + /** 分类筛选 */ + category?: ScriptCategory | "all" + /** 标签筛选 */ + tag?: string +} + +/** 列表响应 */ export interface ScriptListResponse { items: ScriptItem[] total: number + page: number + page_size: number } -export interface CreateScriptRequest { +/** 创建/编辑请求 */ +export interface ScriptUpsertRequest { title: string content: string + segments?: string[] + tags?: string[] + title_text?: string + title_category?: ScriptCategory + title_config?: ScriptTitleConfig } - -export type UpdateScriptRequest = Partial diff --git a/apps/web/src/api/template-editor/clips.ts b/apps/web/src/api/template-editor/clips.ts index 7a63ba843..8a3a70f0f 100644 --- a/apps/web/src/api/template-editor/clips.ts +++ b/apps/web/src/api/template-editor/clips.ts @@ -85,9 +85,13 @@ export async function batchDeleteEditPlanClips( return response.data } -/** 从素材批量创建片段(追加到时间线末尾) */ +/** + * 从素材批量创建片段(追加到时间线末尾)。 + * #1921 修复:templateId 为空时调用新端点 POST /clips/from-assets,避免拼出双斜杠 + * `/templates//editor/clips/from-assets` 导致 404;有 templateId 时保持原路径向后兼容。 + */ export async function createClipsFromAssets( - templateId: string, + templateId: string | undefined | null, assetIds: string[], clipType = "main", requiredClipsCount?: number, @@ -100,16 +104,15 @@ export async function createClipsFromAssets( if (requiredClipsCount !== undefined) { body.required_clips_count = requiredClipsCount } + // 新端点(#1921):templateId 为空时,body 不传 template_id,由后端兜底创建默认模板 + const hasTid = !!templateId + const url = hasTid ? `/templates/${templateId}/editor/clips/from-assets` : "/clips/from-assets" // from-assets 后端会调用 MediaKit 智能选片(最长 60s),单独延长超时 - const response = await apiClient.post( - `/templates/${templateId}/editor/clips/from-assets`, - body, - { - timeout: 60000, - signal: opts?.signal, - // _silentErrorToast 由 api/client.ts 响应拦截器读取(抑制全局错误 toast,#1777) - ...(opts?.silentErrorToast ? ({ _silentErrorToast: true } as Record) : {}), - }, - ) + const response = await apiClient.post(url, body, { + timeout: 60000, + signal: opts?.signal, + // _silentErrorToast 由 api/client.ts 响应拦截器读取(抑制全局错误 toast,#1777) + ...(opts?.silentErrorToast ? ({ _silentErrorToast: true } as Record) : {}), + }) return response.data } diff --git a/apps/web/src/api/tts/types.ts b/apps/web/src/api/tts/types.ts index ab4dda8b8..9bfec5498 100644 --- a/apps/web/src/api/tts/types.ts +++ b/apps/web/src/api/tts/types.ts @@ -105,7 +105,7 @@ export interface TTSPreviewRequest { speed?: number pitch?: number language?: string - emotion?: string // 情绪参数:natural/excited/calm/friendly + emotion?: string // 情绪参数:neutral/happy/sad/angry/surprised/fearful/disgusted(后端 normalize_emotion() 兼容旧 natural/excited/calm/friendly 与中文标签) } /** TTS 试听响应 */ diff --git a/apps/web/src/components/common/PointsBadge/PointsBadge.css b/apps/web/src/components/common/PointsBadge/PointsBadge.css new file mode 100644 index 000000000..fe6d7aa44 --- /dev/null +++ b/apps/web/src/components/common/PointsBadge/PointsBadge.css @@ -0,0 +1,116 @@ +/* Header 积分徽章 & Popover 样式 */ +.xx-points-badge { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 5px 12px; + border-radius: 999px; + border: 1px solid var(--border-color, #e5e7eb); + background: var(--bg-tertiary, #fafafa); + color: var(--text-secondary, #6b7280); + font-size: 13px; + font-weight: 600; + cursor: pointer; + transition: all 0.15s ease; + line-height: 1; + height: 32px; +} + +.xx-points-badge:hover { + border-color: var(--primary-color, #6366f1); + color: var(--primary-color, #6366f1); + background: var(--primary-soft, #eef2ff); +} + +.xx-points-badge.zero { + color: #faad14; + border-color: #ffe58f; + background: #fffbe6; +} + +.xx-points-badge.low { + color: #fa8c16; + border-color: #ffd591; + background: #fff7e6; +} + +.xx-points-badge-icon { + font-size: 14px; + color: #8b5cf6; +} + +.xx-points-badge.zero .xx-points-badge-icon, +.xx-points-badge.low .xx-points-badge-icon { + color: inherit; +} + +.xx-points-badge-val { + font-variant-numeric: tabular-nums; +} + +/* Popover */ +.xx-points-popover { + width: 260px; + display: flex; + flex-direction: column; + gap: 12px; +} + +.xx-points-popover-top { + display: flex; + align-items: center; + justify-content: space-between; +} + +.xx-points-popover-balance { + display: flex; + align-items: baseline; + gap: 6px; +} + +.xx-points-icon { + color: #8b5cf6; + font-size: 18px; +} + +.xx-points-value { + font-size: 22px; + font-weight: 800; + color: var(--text-primary, #111827); + font-variant-numeric: tabular-nums; +} + +.xx-points-unit { + color: var(--text-secondary, #6b7280); + font-size: 12px; +} + +.xx-points-warn { + margin: 0 !important; + padding: 8px 10px; + background: #fffbe6; + border-radius: var(--radius-sm, 6px); + font-size: 12px; +} + +.xx-points-expire { + font-size: 12px; + display: block; +} + +.xx-points-popover-stats { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 12px; + padding: 10px; + background: var(--bg-tertiary, #fafafa); + border-radius: var(--radius-sm, 6px); +} + +.xx-points-stat-val { + font-size: 16px; + font-weight: 700; + color: var(--text-primary, #111827); + margin-top: 2px; + font-variant-numeric: tabular-nums; +} diff --git a/apps/web/src/components/common/PointsBadge/index.tsx b/apps/web/src/components/common/PointsBadge/index.tsx new file mode 100644 index 000000000..3c26fff21 --- /dev/null +++ b/apps/web/src/components/common/PointsBadge/index.tsx @@ -0,0 +1,115 @@ +/** + * Header 右上角的积分徽章(💎 580) + * - 余额 <10 时橙色告警 + * - 点击弹出 Popover:余额、会员信息、充值入口、积分明细入口 + */ +import React, { useEffect } from "react" +import { Popover, Button, Tag, Space, Typography, Badge } from "antd" +import { + ThunderboltOutlined, + CrownOutlined, + RightOutlined, + WarningOutlined, +} from "@ant-design/icons" +import { useNavigate } from "react-router-dom" +import { usePointsStore } from "@/store/pointsStore" +import "./PointsBadge.css" + +const { Text, Paragraph } = Typography + +const PointsBadge: React.FC = () => { + const navigate = useNavigate() + const { balance, init, loading } = usePointsStore() + + useEffect(() => { + if (!balance) init() + }, [balance, init]) + + const bal = balance?.balance ?? 0 + const lowBalance = bal > 0 && bal < 10 + const zero = bal === 0 + const isMember = !!balance?.is_member + const memberLabel = isMember + ? balance?.member_type === "yearly" + ? "年卡会员" + : balance?.member_type === "quarterly" + ? "季卡会员" + : balance?.member_type === "monthly" + ? "月卡会员" + : "付费会员" + : "免费会员" + + const popContent = ( +
+
+
+ + {loading ? "…" : bal} + 积分 +
+ }> + {memberLabel} + +
+ + {(zero || lowBalance) && ( + + 积分不足,充值后可继续使用 AI 功能 + + )} + + {balance?.member_expires_at && ( + + 会员到期:{new Date(balance.member_expires_at).toLocaleDateString("zh-CN")} + + )} + +
+
+ 累计获得 +
+{balance?.total_earned ?? 0}
+
+
+ 累计消耗 +
-{balance?.total_spent ?? 0}
+
+
+ + + + + {!isMember && ( + + )} + +
+ ) + + return ( + + + + ) +} + +export default PointsBadge diff --git a/apps/web/src/components/common/PointsCost/PointsCost.css b/apps/web/src/components/common/PointsCost/PointsCost.css new file mode 100644 index 000000000..b0440be3e --- /dev/null +++ b/apps/web/src/components/common/PointsCost/PointsCost.css @@ -0,0 +1,67 @@ +/* 功能按钮旁的积分消耗提示 */ +.xx-points-cost { + display: inline-flex; + align-items: center; + gap: 4px; + font-size: 12px; + color: var(--text-secondary, #6b7280); + font-weight: 500; + white-space: nowrap; +} + +.xx-points-cost.free, +.xx-points-cost.free-quota { + color: #10b981; +} + +.xx-points-cost.insufficient { + color: #ef4444; +} + +.xx-points-gem { + font-size: 12px; + line-height: 1; +} + +.xx-points-num { + font-weight: 700; + font-variant-numeric: tabular-nums; + color: inherit; +} + +.xx-points-unit-label { + color: var(--text-tertiary, #9ca3af); +} + +.xx-points-tag-free { + display: inline-flex; + align-items: center; + padding: 1px 6px; + border-radius: 4px; + background: #d1fae5; + color: #059669; + font-size: 11px; + font-weight: 600; +} + +.xx-points-free-user-tag { + display: inline-flex; + align-items: center; + padding: 1px 6px; + border-radius: 4px; + background: #fef3c7; + color: #d97706; + font-size: 10px; + font-weight: 600; + margin-left: 4px; +} + +.xx-points-desc { + color: var(--text-tertiary, #9ca3af); + font-size: 11px; +} + +.xx-points-warn-icon { + color: #ef4444; + margin-left: 2px; +} diff --git a/apps/web/src/components/common/PointsCost/index.tsx b/apps/web/src/components/common/PointsCost/index.tsx new file mode 100644 index 000000000..ba8fac4d5 --- /dev/null +++ b/apps/web/src/components/common/PointsCost/index.tsx @@ -0,0 +1,162 @@ +/** + * 功能操作按钮旁的"消耗积分"提示 + * 例:[生成配音] 💎 -1 积分 + * - 根据 scene_key 自动读取规则 + * - 免费用户自动计算 ×1.15 向上取整 + * - 余额不足时显示红色告警 + 充值提示 + * + * 使用: + */ +import React, { useEffect, useMemo, useState } from "react" +import { Tooltip } from "antd" +import { WarningOutlined } from "@ant-design/icons" +import { usePointsStore } from "@/store/pointsStore" +import type { PointsSource } from "@/api/points/types" +import { getPointsRules } from "@/api/points" +import "./PointsCost.css" + +interface Props { + /** 消耗场景 key */ + scene: PointsSource + /** 单位数(分钟数/条数/张数),默认 1 */ + units?: number + /** 是否显示为紧凑模式(仅图标+数字,不显示单位文字) */ + compact?: boolean + /** 余额不足时,是否显示充值提示 */ + showRechargeHint?: boolean + /** 自定义 class */ + className?: string +} + +/** 单位中文 */ +const UNIT_LABEL: Record = { + 分钟: "分钟", + 条: "条", + 次: "次", + 张: "张", +} + +const PointsCost: React.FC = ({ + scene, + units = 1, + compact = false, + showRechargeHint = true, + className = "", +}) => { + const { balance, init } = usePointsStore() + const [rules, setRules] = useState> | null>(null) + + useEffect(() => { + if (!balance) init() + if (!rules) { + getPointsRules() + .then(setRules) + .catch(() => {}) + } + }, [balance, init, rules]) + + const { cost, isFreeQuota, rule, isFreeUser, insufficient } = useMemo(() => { + if (!rules || !balance) { + return { + cost: 0, + isFreeQuota: false, + rule: null, + isFreeUser: !balance?.is_member, + insufficient: false, + } + } + const rule = rules.rules.find((r) => r.scene_key === scene) + if (!rule) + return { + cost: 0, + isFreeQuota: false, + rule: null, + isFreeUser: !balance.is_member, + insufficient: false, + } + // 免费训练不扣费 + if (rule.points_per_use === 0) { + return { + cost: 0, + isFreeQuota: false, + rule, + isFreeUser: !balance.is_member, + insufficient: false, + } + } + + // 智能混剪:首条30s=3分,每+30s +1 + let baseCost: number + if (scene === "ai_video" && rule.extra_per_30s) { + // units 当作"30s 段数"更简单;按分钟算:minutes 个 30s 段 - 1 + const segments = Math.max(1, Math.ceil(units * 2)) // 1min = 2 segments + baseCost = rule.points_per_use + rule.extra_per_30s * (segments - 1) + } else { + baseCost = rule.points_per_use * Math.max(1, units) + } + + // 混剪 + 免费用户 + 今日有免费额度 → 免费 + const isFree = + scene === "ai_video" && !balance.is_member && (balance.free_clips_remaining ?? 0) > 0 + + const isFreeUser = !balance.is_member + const cost = isFree + ? 0 + : isFreeUser + ? Math.ceil(baseCost * rules.free_user_multiplier) + : baseCost + const insufficient = !isFree && balance.balance < cost + return { cost, isFreeQuota: isFree, rule, isFreeUser, insufficient } + }, [rules, balance, scene, units]) + + if (!rule || !balance) { + return + } + + if (rule.points_per_use === 0) { + return ( + + 免费 + + ) + } + + if (isFreeQuota) { + return ( + + + 免费 + {!compact && ( + (今日剩余 {balance.free_clips_remaining} 条) + )} + + + ) + } + + const unitLabel = compact + ? "" + : ` /${units > 1 ? `${units}${UNIT_LABEL[rule.unit] ?? rule.unit}` : rule.unit}` + + return ( + + 💎 + -{cost} + {unitLabel && 积分{unitLabel}} + {isFreeUser && !compact && ( + + 非会员 + + )} + {insufficient && showRechargeHint && ( + + + + )} + + ) +} + +export default PointsCost diff --git a/apps/web/src/components/layout/Header.css b/apps/web/src/components/layout/Header.css index e74730978..588847fb5 100644 --- a/apps/web/src/components/layout/Header.css +++ b/apps/web/src/components/layout/Header.css @@ -1,4 +1,5 @@ -/* Phase 1 Header 样式 + 手机端适配 */ +/* Phase 1 Header 样式 + 手机端适配 + v2: 增加积分徽章、升级按钮、会员金冠 */ .xx-top-nav { height: 68px; position: sticky; @@ -77,10 +78,40 @@ .xx-right-section { display: flex; align-items: center; - gap: 12px; + gap: 10px; flex-shrink: 0; } +/* v2: 顶部积分徽章 */ +.xx-header-points { + cursor: pointer; +} + +/* v2: 升级会员按钮 */ +.xx-upgrade-btn { + background: linear-gradient(135deg, #f59e0b, #ef4444); + border: none; + font-weight: 700; + box-shadow: 0 2px 8px rgba(245, 158, 11, 0.3); +} + +.xx-upgrade-btn:hover { + background: linear-gradient(135deg, #d97706, #dc2626) !important; + color: #fff !important; +} + +/* v2: 会员金冠按钮 */ +.xx-crown-btn { + font-weight: 700; + color: #d97706; + padding: 2px 8px; +} + +.xx-crown-btn:hover { + background: rgba(245, 158, 11, 0.1) !important; + color: #b45309 !important; +} + .xx-user-menu { display: flex; align-items: center; @@ -115,6 +146,29 @@ gap: var(--space-xs); } +/* v2: 手机端积分/会员行 */ +.xx-mobile-points-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + padding: 8px 4px 16px; + margin-bottom: 12px; + border-bottom: 1px solid var(--border-color); +} + +.xx-mobile-member-tag { + display: inline-flex; + align-items: center; + gap: 4px; + font-size: 12px; + font-weight: 700; + color: #d97706; + background: #fef3c7; + padding: 4px 10px; + border-radius: 999px; +} + .xx-mobile-nav-item { display: flex; align-items: center; @@ -157,6 +211,7 @@ .xx-top-nav-inner { padding: 0 12px; + gap: 6px; } .xx-brand-text { @@ -177,6 +232,19 @@ .xx-username { display: none; } + + /* 手机端:缩小升级按钮文字 */ + .xx-upgrade-btn { + padding: 0 8px !important; + font-size: 12px !important; + height: 28px; + } + + .xx-crown-btn { + padding: 0 4px !important; + font-size: 12px !important; + height: 28px; + } } /* 小屏幕平板:导航文字缩小 */ @@ -188,4 +256,14 @@ .xx-nav-links button { font-size: 13px; } + + .xx-header-points { + transform: scale(0.9); + transform-origin: center; + } + + .xx-upgrade-btn { + font-size: 12px !important; + padding: 0 10px !important; + } } diff --git a/apps/web/src/components/layout/Header.tsx b/apps/web/src/components/layout/Header.tsx index a90377c4f..301ee1a7c 100644 --- a/apps/web/src/components/layout/Header.tsx +++ b/apps/web/src/components/layout/Header.tsx @@ -1,15 +1,27 @@ /** * Phase 1 Header 重构 * 扁平化导航菜单 + 手机端汉堡菜单 + * v2: 增加积分徽章、升级会员入口、我的积分入口 */ -import React, { useState } from "react" -import { Avatar, Dropdown, Drawer, Space } from "antd" -import { LogoutOutlined, SettingOutlined, UserOutlined, MenuOutlined } from "@ant-design/icons" +import React, { useEffect, useState } from "react" +import { Avatar, Badge, Button, Dropdown, Drawer, Space, Tooltip } from "antd" +import { + LogoutOutlined, + SettingOutlined, + UserOutlined, + MenuOutlined, + CrownFilled, + ThunderboltOutlined, + HistoryOutlined, + WalletOutlined, +} from "@ant-design/icons" import { useLocation, useNavigate } from "react-router-dom" import { useAuthStore } from "@/store/authStore" import { useLogout } from "@/hooks/useAuth" import type { MenuProps } from "antd" import { NAV_ITEMS } from "@/config/navigation" +import PointsBadge from "@/components/common/PointsBadge" +import { usePointsStore } from "@/store/pointsStore" import "./Header.css" const Header: React.FC = () => { @@ -19,6 +31,18 @@ const Header: React.FC = () => { const logoutMutation = useLogout() const [mobileMenuOpen, setMobileMenuOpen] = useState(false) + // 积分与会员状态 + const { balance, init } = usePointsStore() + useEffect(() => { + init() + // 每 60s 刷新一次(余额可能变化) + const t = setInterval(() => init(), 60_000) + return () => clearInterval(t) + }, [init]) + + const isMember = !!balance?.is_member + const memberType = balance?.member_type ?? null + /** 用户下拉菜单 */ const menuItems: MenuProps["items"] = [ { @@ -33,6 +57,30 @@ const Header: React.FC = () => { label: "订阅管理", onClick: () => navigate("/app/subscription"), }, + // v2: 我的积分入口 + { + key: "points-center", + icon: , + label: ( + + 我的积分 + {balance && {balance.balance}} + + ), + onClick: () => navigate("/app/points"), + }, + { + key: "points-history", + icon: , + label: "积分明细", + onClick: () => navigate("/app/points/transactions"), + }, + { + key: "recharge", + icon: , + label: "充值积分", + onClick: () => navigate("/app/points/recharge"), + }, { type: "divider" }, { key: "logout", @@ -44,7 +92,6 @@ const Header: React.FC = () => { /** 判断导航项是否激活 */ const isActive = (path: string) => { - // 首页特殊处理:/ 和 /app/dashboard 都算激活 if (path === "/app/dashboard") { return ( location.pathname === "/" || @@ -78,6 +125,47 @@ const Header: React.FC = () => {
+ {/* v2: 积分徽章(顶部显示) */} + + + {/* v2: 升级会员入口(仅免费用户显示) */} + {!isMember && ( + + + + )} + + {/* v2: 付费会员显示金冠标识 */} + {isMember && ( + + + + + + )} + {/* 手机端汉堡菜单按钮 */} + ) : ( + + {" "} + {memberType === "yearly" ? "年卡" : memberType === "quarterly" ? "季卡" : "月卡"} + + )} +
+ )} +
{NAV_ITEMS.map((item) => (