Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| adc2693d58 |
@@ -212,28 +212,9 @@ COSYVOICE_CLONE_MODEL=voice-enrollment
|
||||
|
||||
DOUBAO_API_KEY=your-doubao-api-key
|
||||
DOUBAO_MODEL=doubao-seed-1-6-250615
|
||||
DOUBAO_FAST_MODEL=doubao-1-5-pro-32k-250115
|
||||
DOUBAO_BASE_URL=https://ark.cn-beijing.volces.com/api/v3
|
||||
DOUBAO_TIMEOUT=30
|
||||
DOUBAO_MAX_RETRIES=2
|
||||
# 视觉模型:pro 精度高,lite 速度快(viral-video 商品识别默认用 lite 提速)
|
||||
DOUBAO_VISION_MODEL=doubao-1-5-vision-pro-250328
|
||||
DOUBAO_VISION_LITE_MODEL=doubao-1-5-vision-lite-250315
|
||||
DOUBAO_VISION_USE_LITE=true
|
||||
# Embedding 向量化模型(原 large-text-240915 已下线,用多模态 embedding)
|
||||
DOUBAO_EMBEDDING_MODEL=doubao-embedding-vision-251215
|
||||
|
||||
# ==================== 即梦(Jimeng)视觉 API —— 真人参考图兜底通道 (#2169) ====
|
||||
# 方舟 Seedance 走 B 端审核,真人参考图会被 50411 拦截;即梦走 C 端审核,普通真人照片可过审。
|
||||
# 需要在火山控制台开通即梦 cvtob 服务,使用 AK/SK(Region=cn-north-1, Service=cv)
|
||||
# 留空则真人拦截后直接返回错误提示,不会走即梦兜底。
|
||||
JIMENG_AK=
|
||||
JIMENG_SK=
|
||||
JIMENG_BASE_URL=https://visual.volcengineapi.com
|
||||
# 视频3.0 720P 首帧图生视频(1张图,稳定版;1080P标注下线中)
|
||||
JIMENG_REQ_KEY=jimeng_i2v_first_v30
|
||||
JIMENG_VIDEO_TIMEOUT=600
|
||||
JIMENG_VIDEO_POLL_INTERVAL=5
|
||||
|
||||
# ==================== 积分/会员系统 (#1895) ====================
|
||||
# 积分系统总开关:默认 false(暂停积分系统)。
|
||||
|
||||
@@ -1,51 +0,0 @@
|
||||
"""viral video add copy_result + voice/video columns
|
||||
|
||||
Revision ID: 088_viral_video_copy_result
|
||||
Revises: 087_viral_video_image_analysis
|
||||
Create Date: 2026-10-01
|
||||
|
||||
v1.6 爆款视频字段补齐:
|
||||
- copy_result JSON: 编导分镜脚本完整结构(overview/scene_and_lighting/shots/hard_constraints/negative_prompts/voiceover_script)
|
||||
- voice_id/voice_source: TTS 音色参数
|
||||
- video_ratio/video_model: Seedance 视频比例/模型
|
||||
注意:线上启动也有幂等 ADD COLUMN 补列逻辑 (_ensure_viral_video_columns),本 migration 提供标准 Alembic 路径,
|
||||
两套机制互不冲突(IF NOT EXISTS 等价行为)。
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision = "088_viral_video_copy_result"
|
||||
down_revision = "087_viral_video_image_analysis"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# 幂等添加列(通过单独执行 + 异常忽略兼容已由 backfill 补上的环境)
|
||||
cols = [
|
||||
("voice_id", "VARCHAR(200) NOT NULL DEFAULT ''"),
|
||||
("voice_source", "VARCHAR(20) NOT NULL DEFAULT ''"),
|
||||
("video_ratio", "VARCHAR(10) NOT NULL DEFAULT '9:16'"),
|
||||
("video_model", "VARCHAR(100) NOT NULL DEFAULT ''"),
|
||||
("copy_result", "JSON"),
|
||||
]
|
||||
conn = op.get_bind()
|
||||
for name, ddl in cols:
|
||||
try:
|
||||
conn.execute(sa.text(f"ALTER TABLE viral_video_jobs ADD COLUMN IF NOT EXISTS {name} {ddl}"))
|
||||
except Exception:
|
||||
# 不支持 IF NOT EXISTS 的库(如老版本 SQLite)直接尝试 ADD COLUMN,失败则忽略
|
||||
try:
|
||||
conn.execute(sa.text(f"ALTER TABLE viral_video_jobs ADD COLUMN {name} {ddl}"))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
for name in ("copy_result", "video_model", "video_ratio", "voice_source", "voice_id"):
|
||||
try:
|
||||
op.drop_column("viral_video_jobs", name)
|
||||
except Exception:
|
||||
pass
|
||||
@@ -1,62 +0,0 @@
|
||||
"""viral video add storyboard + generated_copy_text (complement 088)
|
||||
|
||||
Revision ID: 089_viral_video_cols
|
||||
Revises: 088_viral_video_copy_result
|
||||
Create Date: 2026-10-01
|
||||
|
||||
#2129 兜底迁移:补齐 _VIRAL_VIDEO_BACKFILL_COLS 中所有列,覆盖
|
||||
# watchtower 自动部署未跑历史 migration、且 AUTO_CREATE_SCHEMA=false 时
|
||||
# _ensure_viral_video_columns 未执行的场景。
|
||||
# 幂等 ADD COLUMN IF NOT EXISTS,已存在则跳过。
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision = "089_viral_video_cols"
|
||||
down_revision = "088_viral_video_copy_result"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# 扩展 alembic_version.version_num 字段长度(原来 VARCHAR(32) 装不下长 revision id)
|
||||
conn = op.get_bind()
|
||||
try:
|
||||
conn.execute(sa.text("ALTER TABLE alembic_version ALTER COLUMN version_num TYPE VARCHAR(256)"))
|
||||
except Exception:
|
||||
pass
|
||||
cols = [
|
||||
("storyboard", "JSON"),
|
||||
("generated_copy_text", "TEXT NOT NULL DEFAULT ''"),
|
||||
("voice_id", "VARCHAR(200) NOT NULL DEFAULT ''"),
|
||||
("voice_source", "VARCHAR(20) NOT NULL DEFAULT ''"),
|
||||
("video_ratio", "VARCHAR(10) NOT NULL DEFAULT '9:16'"),
|
||||
("video_model", "VARCHAR(100) NOT NULL DEFAULT ''"),
|
||||
("copy_result", "JSON"),
|
||||
]
|
||||
for name, ddl in cols:
|
||||
try:
|
||||
conn.execute(sa.text(f"ALTER TABLE viral_video_jobs ADD COLUMN IF NOT EXISTS {name} {ddl}"))
|
||||
except Exception:
|
||||
try:
|
||||
conn.execute(sa.text(f"ALTER TABLE viral_video_jobs ADD COLUMN {name} {ddl}"))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
for name in (
|
||||
"copy_result",
|
||||
"video_model",
|
||||
"video_ratio",
|
||||
"voice_source",
|
||||
"voice_id",
|
||||
"generated_copy_text",
|
||||
"storyboard",
|
||||
):
|
||||
try:
|
||||
op.drop_column("viral_video_jobs", name)
|
||||
except Exception:
|
||||
pass
|
||||
@@ -1,35 +0,0 @@
|
||||
"""viral video add phase_message column (#2134)
|
||||
|
||||
Revision ID: 090_viral_video_phase_msg
|
||||
Revises: 089_viral_video_cols
|
||||
Create Date: 2026-10-02
|
||||
|
||||
#2134 阶段细粒度提示:viral_video 表新增 phase_message 列(中文阶段提示文案)。
|
||||
current_stage 列已在之前版本存在,本迁移只补 phase_message。
|
||||
幂等 ADD COLUMN IF NOT EXISTS。
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision = "090_viral_video_phase_msg"
|
||||
down_revision = "089_viral_video_cols"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# SQLite/PostgreSQL 兼容的幂等添加列
|
||||
conn = op.get_bind()
|
||||
inspector = sa.inspect(conn)
|
||||
cols = {c["name"] for c in inspector.get_columns("viral_video_jobs")}
|
||||
if "phase_message" not in cols:
|
||||
op.add_column(
|
||||
"viral_video_jobs",
|
||||
sa.Column("phase_message", sa.String(length=500), nullable=False, server_default=""),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("viral_video_jobs", "phase_message")
|
||||
@@ -1,49 +0,0 @@
|
||||
"""viral video add current_stage column (#2137 follow-up)
|
||||
|
||||
Revision ID: 091_viral_video_stage
|
||||
Revises: 090_viral_video_phase_msg
|
||||
Create Date: 2026-10-02
|
||||
|
||||
#2137 follow-up fix: 090 migration missed current_stage column on viral_video_jobs,
|
||||
causing UndefinedColumn errors and 500s on all authenticated viral-video endpoints.
|
||||
Idempotently add current_stage and double-check phase_message.
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision = "091_viral_video_stage"
|
||||
down_revision = "090_viral_video_phase_msg"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
conn = op.get_bind()
|
||||
inspector = sa.inspect(conn)
|
||||
cols = {c["name"] for c in inspector.get_columns("viral_video_jobs")}
|
||||
if "current_stage" not in cols:
|
||||
op.add_column(
|
||||
"viral_video_jobs",
|
||||
sa.Column(
|
||||
"current_stage",
|
||||
sa.String(length=200),
|
||||
nullable=False,
|
||||
server_default="",
|
||||
),
|
||||
)
|
||||
if "phase_message" not in cols:
|
||||
op.add_column(
|
||||
"viral_video_jobs",
|
||||
sa.Column(
|
||||
"phase_message",
|
||||
sa.String(length=500),
|
||||
nullable=False,
|
||||
server_default="",
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("viral_video_jobs", "current_stage")
|
||||
@@ -1,42 +0,0 @@
|
||||
"""viral_video_jobs 增加 heartbeat_at 列(worker 心跳,用于僵尸任务超时回收)
|
||||
|
||||
Revision ID: 092_viral_video_heartbeat
|
||||
Revises: 091_viral_video_stage
|
||||
Create Date: 2026-10-02
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision = "092_viral_video_heartbeat"
|
||||
down_revision = "091_viral_video_stage"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
conn = op.get_bind()
|
||||
inspector = sa.inspect(conn)
|
||||
cols = {c["name"] for c in inspector.get_columns("viral_video_jobs")}
|
||||
if "heartbeat_at" not in cols:
|
||||
op.add_column("viral_video_jobs", sa.Column("heartbeat_at", sa.DateTime(), nullable=True))
|
||||
op.execute(
|
||||
"UPDATE viral_video_jobs SET heartbeat_at = updated_at " "WHERE status = 'running' AND heartbeat_at IS NULL"
|
||||
)
|
||||
try:
|
||||
op.create_index("ix_viral_video_jobs_heartbeat_at", "viral_video_jobs", ["heartbeat_at"])
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
conn = op.get_bind()
|
||||
inspector = sa.inspect(conn)
|
||||
cols = {c["name"] for c in inspector.get_columns("viral_video_jobs")}
|
||||
if "heartbeat_at" in cols:
|
||||
try:
|
||||
op.drop_index("ix_viral_video_jobs_heartbeat_at", table_name="viral_video_jobs")
|
||||
except Exception:
|
||||
pass
|
||||
op.drop_column("viral_video_jobs", "heartbeat_at")
|
||||
@@ -1,87 +0,0 @@
|
||||
"""viral_video 动态积分定价 + 积分字段从 Integer 改为 Float (#2151)
|
||||
|
||||
Revision ID: 093
|
||||
Revises: 092_viral_video_heartbeat
|
||||
Create Date: 2026-10-02
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision = "093"
|
||||
down_revision = "092_viral_video_heartbeat"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
conn = op.get_bind()
|
||||
inspector = sa.inspect(conn)
|
||||
|
||||
# 1) points_accounts 三列 Integer -> Float
|
||||
pa_cols = {c["name"]: c for c in inspector.get_columns("points_accounts")}
|
||||
for col in ("balance", "total_earned", "total_spent"):
|
||||
if col in pa_cols:
|
||||
op.alter_column(
|
||||
"points_accounts",
|
||||
col,
|
||||
existing_type=sa.Integer(),
|
||||
type_=sa.Float(),
|
||||
existing_nullable=False,
|
||||
)
|
||||
|
||||
# 2) points_transactions amount/balance_after Integer -> Float
|
||||
pt_cols = {c["name"]: c for c in inspector.get_columns("points_transactions")}
|
||||
for col in ("amount", "balance_after"):
|
||||
if col in pt_cols:
|
||||
op.alter_column(
|
||||
"points_transactions",
|
||||
col,
|
||||
existing_type=sa.Integer(),
|
||||
type_=sa.Float(),
|
||||
existing_nullable=False,
|
||||
)
|
||||
|
||||
# 3) users.points_balance Integer -> Float
|
||||
user_cols = {c["name"]: c for c in inspector.get_columns("users")}
|
||||
if "points_balance" in user_cols:
|
||||
op.alter_column(
|
||||
"users",
|
||||
"points_balance",
|
||||
existing_type=sa.Integer(),
|
||||
type_=sa.Float(),
|
||||
existing_nullable=False,
|
||||
)
|
||||
|
||||
# 4) viral_video_jobs.credits_cost Integer -> Float
|
||||
vv_cols = {c["name"]: c for c in inspector.get_columns("viral_video_jobs")}
|
||||
if "credits_cost" in vv_cols:
|
||||
op.alter_column(
|
||||
"viral_video_jobs",
|
||||
"credits_cost",
|
||||
existing_type=sa.Integer(),
|
||||
type_=sa.Float(),
|
||||
existing_nullable=False,
|
||||
)
|
||||
|
||||
# 5) viral_video_jobs 新增列
|
||||
if "video_resolution" not in vv_cols:
|
||||
op.add_column(
|
||||
"viral_video_jobs",
|
||||
sa.Column("video_resolution", sa.String(20), nullable=False, server_default="720p"),
|
||||
)
|
||||
if "credits_prepaid" not in vv_cols:
|
||||
op.add_column(
|
||||
"viral_video_jobs",
|
||||
sa.Column("credits_prepaid", sa.Float(), nullable=False, server_default="0"),
|
||||
)
|
||||
if "credits_transaction_id" not in vv_cols:
|
||||
op.add_column(
|
||||
"viral_video_jobs",
|
||||
sa.Column("credits_transaction_id", sa.String(36), nullable=False, server_default=""),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
pass
|
||||
@@ -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,6 +44,7 @@ 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),
|
||||
|
||||
@@ -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
|
||||
@@ -345,6 +346,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"),
|
||||
|
||||
@@ -41,6 +41,7 @@ from packages.application import (
|
||||
GetGenerationTaskUseCase,
|
||||
ListGeneratedVideosByTaskUseCase,
|
||||
)
|
||||
from packages.middleware.points_gate import points_gate
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -269,6 +270,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),
|
||||
|
||||
@@ -163,6 +163,7 @@ def _infer_expected_categories(script_tags: set[str] | None) -> set[str] | None:
|
||||
return matched or None
|
||||
|
||||
|
||||
from packages.middleware.points_gate import points_gate
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -464,6 +465,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),
|
||||
|
||||
@@ -9,11 +9,6 @@ from fastapi.responses import JSONResponse
|
||||
router = APIRouter(tags=["Health"])
|
||||
|
||||
|
||||
|
||||
def _pg_url(url: str) -> str:
|
||||
"""Convert SQLAlchemy URL (postgresql+psycopg://...) to libpq connection string."""
|
||||
return url.replace("postgresql+psycopg://", "postgresql://", 1).replace("postgresql+psycopg2://", "postgresql://", 1)
|
||||
|
||||
@router.get("/health", status_code=status.HTTP_200_OK)
|
||||
async def health_check():
|
||||
return {
|
||||
@@ -54,7 +49,7 @@ async def _check_database() -> dict:
|
||||
"message": "Using in-memory database",
|
||||
}
|
||||
try:
|
||||
conn = psycopg.connect(_pg_url(settings.DATABASE_URL), connect_timeout=3)
|
||||
conn = psycopg.connect(settings.DATABASE_URL, connect_timeout=3)
|
||||
with conn.cursor() as cur:
|
||||
cur.execute("SELECT 1")
|
||||
cur.fetchone()
|
||||
@@ -129,7 +124,7 @@ async def _check_migrations() -> dict:
|
||||
"message": "Using in-memory database, no migrations needed",
|
||||
}
|
||||
try:
|
||||
conn = psycopg.connect(_pg_url(settings.DATABASE_URL), connect_timeout=3)
|
||||
conn = psycopg.connect(settings.DATABASE_URL, connect_timeout=3)
|
||||
with conn.cursor() as cur:
|
||||
cur.execute("""
|
||||
SELECT COUNT(*) FROM information_schema.tables
|
||||
@@ -142,5 +137,3 @@ async def _check_migrations() -> dict:
|
||||
return {"status": "unhealthy", "message": f"Missing tables, found {count}/5"}
|
||||
except Exception as error:
|
||||
return {"status": "unhealthy", "message": f"Migration check failed: {error}"}
|
||||
|
||||
|
||||
|
||||
@@ -12,9 +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,
|
||||
@@ -30,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()
|
||||
@@ -56,6 +61,37 @@ def create_lipsync_job(
|
||||
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,8 +101,6 @@ def create_lipsync_job(
|
||||
- 预合成音频(#1845 新主路径):传 {video_url, audio_url, audio_duration, sentence_timings},
|
||||
后端同步ffprobe+写入timings+直接提交MediaKit(~2-3s)。
|
||||
"""
|
||||
user_id = current_user.user.id
|
||||
|
||||
try:
|
||||
job = svc.create_job(
|
||||
user_id=user_id,
|
||||
@@ -84,8 +118,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
|
||||
@@ -101,11 +145,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
|
||||
|
||||
|
||||
@@ -119,14 +176,37 @@ def preview_tts(
|
||||
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 时长 → 句子时间戳计算,
|
||||
不创建 LipsyncJob、不转存 OSS,直接返回 CosyVoice 临时 URL(~24h 有效)。
|
||||
耗时约 2-3 秒。
|
||||
"""
|
||||
user_id = current_user.user.id
|
||||
|
||||
try:
|
||||
result = svc.preview_tts(
|
||||
user_id=user_id,
|
||||
@@ -138,6 +218,11 @@ def preview_tts(
|
||||
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
|
||||
@@ -152,6 +237,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}",
|
||||
|
||||
@@ -145,22 +145,19 @@ def get_rules(
|
||||
def get_packages(
|
||||
current_user: AuthenticatedUser = Depends(get_current_user),
|
||||
):
|
||||
"""查询可购买的积分包列表(读管理后台 credit_packages 表真实数据)。
|
||||
|
||||
仅返回 is_active=true;后台改价/启停后最多 30 秒生效。
|
||||
"""
|
||||
from packages.application.catalog.admin_catalog import get_points_packages
|
||||
|
||||
packages = [
|
||||
PointsPackageItem(
|
||||
code=row["code"],
|
||||
name=row["name"],
|
||||
points=row["points"],
|
||||
price_cents=row["price_cents"],
|
||||
unit_price=row["unit_price"],
|
||||
"""查询可购买的积分包列表。"""
|
||||
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,
|
||||
)
|
||||
)
|
||||
for row in get_points_packages()
|
||||
]
|
||||
mt = _member_type(current_user)
|
||||
discount = MEMBER_DISCOUNT.get(mt) if mt else None
|
||||
return PointsPackagesResponse(packages=packages, user_discount=discount)
|
||||
@@ -172,7 +169,17 @@ def check_points(
|
||||
current_user: AuthenticatedUser = Depends(get_current_user),
|
||||
db: Session = Depends(get_db_session),
|
||||
):
|
||||
"""消费前检查余额是否足够。已下线/未知场景返回 cost=0(免费)。"""
|
||||
"""消费前检查余额是否足够。未知 scene_key 返回 400(而非 500)。"""
|
||||
if body.scene_key not in POINTS_SCENES:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail={
|
||||
"code": "UNKNOWN_SCENE",
|
||||
"message": f"未知场景: {body.scene_key}",
|
||||
"valid_scenes": sorted(POINTS_SCENES.keys()),
|
||||
},
|
||||
)
|
||||
|
||||
# 积分系统暂停(ENABLE_CREDIT_SYSTEM=false):所有场景直接放行,需 0 积分
|
||||
if not _credits_enabled():
|
||||
svc = _get_service()
|
||||
@@ -188,6 +195,13 @@ def check_points(
|
||||
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,
|
||||
@@ -201,11 +215,11 @@ def check_points(
|
||||
balance = account["balance"]
|
||||
|
||||
return PointsCheckResponse(
|
||||
allowed=balance >= required,
|
||||
allowed=is_free_quota or balance >= required,
|
||||
required_points=required,
|
||||
current_balance=balance,
|
||||
remaining_after=balance - required,
|
||||
is_free_quota=False,
|
||||
is_free_quota=is_free_quota,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -44,6 +44,7 @@ from app.services.script_asr_service import (
|
||||
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__)
|
||||
@@ -372,6 +373,7 @@ def douyin_diag():
|
||||
|
||||
|
||||
@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),
|
||||
@@ -495,6 +497,7 @@ def extract_from_douyin(
|
||||
|
||||
|
||||
@router.post("/ai-rewrite", response_model=AiRewriteResponse)
|
||||
@points_gate("ai_rewrite")
|
||||
def ai_rewrite(
|
||||
request: AiRewriteRequest,
|
||||
current_user: AuthenticatedUser = Depends(get_current_user),
|
||||
@@ -534,6 +537,7 @@ def ai_rewrite(
|
||||
|
||||
|
||||
@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),
|
||||
|
||||
@@ -86,13 +86,33 @@ async def get_current_subscription(
|
||||
def list_membership_plans(
|
||||
current_user: AuthenticatedUser = Depends(get_current_user),
|
||||
) -> dict[str, list[dict[str, Any]]]:
|
||||
"""查询可购买的会员套餐(读管理后台 plans 表真实数据)。
|
||||
"""查询所有会员档位(供前端会员购买页展示)。
|
||||
|
||||
仅返回 is_enabled=true 的套餐;后台启停/改价后最多 30 秒生效。
|
||||
返回 points 积分体系下的会员档位(月卡/季卡/年卡),含价格、时长、积分折扣等信息。
|
||||
"""
|
||||
from packages.application.catalog.admin_catalog import get_membership_plans
|
||||
from packages.domain.points_rules import MEMBER_DISCOUNT, MEMBERSHIP_PRICES
|
||||
|
||||
return {"plans": get_membership_plans()}
|
||||
plans: list[dict[str, Any]] = []
|
||||
for plan_id, info in MEMBERSHIP_PRICES.items():
|
||||
days = info["duration_days"]
|
||||
monthly_cents = round(info["price_cents"] * 30 / days)
|
||||
features: dict[str, Any] = {"max_resolution": "1080p"}
|
||||
if plan_id == MembershipType.MONTHLY:
|
||||
features.update({"free_clips_daily": 2})
|
||||
elif plan_id == MembershipType.QUARTERLY:
|
||||
features.update({"free_clips_daily": 5})
|
||||
elif plan_id == MembershipType.YEARLY:
|
||||
features.update({"free_clips_daily": "unlimited"})
|
||||
plans.append({
|
||||
"plan_id": plan_id,
|
||||
"name": info["name"],
|
||||
"price_cents": info["price_cents"],
|
||||
"monthly_price_cents": monthly_cents,
|
||||
"duration_days": days,
|
||||
"points_discount": MEMBER_DISCOUNT.get(plan_id, 1.0),
|
||||
"features": features,
|
||||
})
|
||||
return {"plans": plans}
|
||||
|
||||
|
||||
@router.get("/billing-records", response_model=list[BillingRecord])
|
||||
|
||||
@@ -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
|
||||
@@ -140,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
|
||||
@@ -202,6 +231,7 @@ def synthesize(
|
||||
cosyvoice_service=cosyvoice_service,
|
||||
)
|
||||
|
||||
synthesis_error: Exception | None = None
|
||||
try:
|
||||
job = workflow.start_synthesis(job.id)
|
||||
except Exception as e:
|
||||
@@ -209,10 +239,18 @@ 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":
|
||||
# 分段合成任务 vs 普通单段任务
|
||||
@@ -231,6 +269,13 @@ def synthesize(
|
||||
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,
|
||||
status=job.status,
|
||||
@@ -565,6 +610,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)
|
||||
@@ -594,6 +664,12 @@ def preview_tts(
|
||||
language=getattr(request, "language", "zh-CN"),
|
||||
)
|
||||
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
|
||||
|
||||
@@ -1,21 +1,14 @@
|
||||
"""爆款视频 API 路由。
|
||||
|
||||
v1.6 三步分步流水线端点(单次 Seedance 出片版):
|
||||
POST /api/v1/viral-video/analyze-images 阶段1:创建任务 + 仅做图片/视频分析,暂停在 image_analyzed
|
||||
POST /api/v1/viral-video/{job_id}/generate-copy 阶段2:用户填完参数后跑意图+文案+分镜+审核,暂停在 copy_generated
|
||||
POST /api/v1/viral-video/{job_id}/confirm-copy 阶段3:用户确认/编辑文案后跑渲染,直到完成
|
||||
|
||||
旧端点(兼容保留,旧前端/一键生成模式):
|
||||
POST /api/v1/viral-video/generate 一键入队,前半段跑到 wait_user_confirm
|
||||
POST /api/v1/viral-video/{job_id}/confirm-intent 旧的意图确认后继续渲染
|
||||
|
||||
通用:
|
||||
GET /api/v1/viral-video/{job_id} 查询任务状态(含 image_analysis/copy_result 编导脚本)
|
||||
GET /api/v1/viral-video/history 历史记录
|
||||
POST /api/v1/viral-video/{job_id}/retry 重试失败任务
|
||||
POST /api/v1/viral-video/{job_id}/analyze-style 触发风格分析
|
||||
GET /api/v1/viral-video/style-templates 风格模板列表
|
||||
WS /api/v1/viral-video/ws/{job_id}?token= WebSocket 进度推送
|
||||
端点:
|
||||
POST /api/v1/viral-video/generate 创建爆款视频任务
|
||||
GET /api/v1/viral-video/{job_id} 查询任务状态
|
||||
GET /api/v1/viral-video/history 历史记录
|
||||
POST /api/v1/viral-video/{job_id}/retry 重试失败任务
|
||||
POST /api/v1/viral-video/{job_id}/confirm-intent 确认意图文案
|
||||
POST /api/v1/viral-video/{job_id}/analyze-style 触发风格分析
|
||||
GET /api/v1/viral-video/style-templates 获取风格模板列表
|
||||
WS /api/v1/viral-video/ws/{job_id}?token= WebSocket 进度推送(订阅 Redis pub/sub)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -26,17 +19,10 @@ from app.auth import AuthenticatedUser, get_current_user
|
||||
from app.core.celery_app import celery_app
|
||||
from app.dependencies import get_db_session
|
||||
from app.schemas.viral_video import (
|
||||
AnalyzeImagesRequest,
|
||||
AnalyzeStyleRequest,
|
||||
AnalyzeStyleResponse,
|
||||
ConfirmCopyRequest,
|
||||
ConfirmIntentRequest,
|
||||
CreateViralVideoRequest,
|
||||
CreditsFormulaBreakdown,
|
||||
EstimateCreditsRequest,
|
||||
EstimateCreditsResponse,
|
||||
GenerateCopyRequest,
|
||||
RetryViralVideoRequest,
|
||||
StyleTemplateListResponse,
|
||||
StyleTemplateResponse,
|
||||
ViralVideoHistoryResponse,
|
||||
@@ -49,9 +35,7 @@ from packages.adapters.sqlalchemy_impl.viral_video_repository import (
|
||||
SQLAlchemyViralVideoJobRepository,
|
||||
SQLAlchemyViralVideoStyleTemplateRepository,
|
||||
)
|
||||
from packages.domain.points_rules import list_viral_video_models
|
||||
from packages.domain.viral_video import ViralVideoStatus
|
||||
from packages.shared.dashscope_client import get_dashscope_client
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -61,59 +45,6 @@ router = APIRouter()
|
||||
# ── Helpers ──────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _build_copy_result(job) -> dict | None:
|
||||
"""v1.6: 返回编导分镜脚本 CopyResult 结构(给前端/Seedance 使用)。
|
||||
|
||||
- 若 job.copy_result 已持久化(v1.6 worker 生成),直接返回(补 final_copy 兜底)。
|
||||
- 否则从老字段(generated_copy_text=口播, storyboard=分镜列表, intent_result)拼装兼容结构。
|
||||
"""
|
||||
cr = getattr(job, "copy_result", None)
|
||||
if isinstance(cr, dict) and cr:
|
||||
out = dict(cr)
|
||||
# 向后兼容字段
|
||||
voiceover = out.get("voiceover_script", "") or ""
|
||||
out.setdefault("final_copy", voiceover)
|
||||
out.setdefault("suggested_copy", voiceover)
|
||||
out.setdefault("title", "")
|
||||
return out
|
||||
# 兼容 v1.5 老数据:storyboard 是老格式 [{order,type,description,text,duration,...}]
|
||||
copy_text = getattr(job, "generated_copy_text", "") or ""
|
||||
sb = getattr(job, "storyboard", None) or []
|
||||
intent = getattr(job, "intent_result", None) or {}
|
||||
if not copy_text and not sb:
|
||||
return None
|
||||
title = ""
|
||||
if isinstance(intent, dict):
|
||||
title = intent.get("suggested_title") or intent.get("intent", "") or ""
|
||||
shots = []
|
||||
for seg in sb:
|
||||
if isinstance(seg, dict):
|
||||
shots.append(
|
||||
{
|
||||
"time_range": "",
|
||||
"shot_type_angle_movement": seg.get("ken_burns", ""),
|
||||
"scene_and_dialogue": (seg.get("text") or "")
|
||||
+ (" " + seg.get("description", "") if seg.get("description") else ""),
|
||||
"action_details": "",
|
||||
"audio_bgm": "",
|
||||
"transition": seg.get("transition", "硬切"),
|
||||
"reference_image_index": None,
|
||||
}
|
||||
)
|
||||
ratio = getattr(job, "video_ratio", None) or "9:16"
|
||||
return {
|
||||
"overview": {"theme": title, "total_duration": getattr(job, "duration", 15), "aspect_ratio": ratio},
|
||||
"scene_and_lighting": "",
|
||||
"shots": shots,
|
||||
"hard_constraints": ["无字幕", "无水印", "人物一致性"],
|
||||
"negative_prompts": ["字幕", "水印", "错误文字", "五官崩坏"],
|
||||
"voiceover_script": copy_text,
|
||||
"final_copy": copy_text,
|
||||
"suggested_copy": copy_text,
|
||||
"title": title,
|
||||
}
|
||||
|
||||
|
||||
def _to_response(job) -> ViralVideoJobResponse:
|
||||
return ViralVideoJobResponse(
|
||||
id=job.id,
|
||||
@@ -125,7 +56,7 @@ def _to_response(job) -> ViralVideoJobResponse:
|
||||
viral_structure=job.viral_structure,
|
||||
marketing_purpose=job.marketing_purpose,
|
||||
bgm_preference=job.bgm_preference,
|
||||
duration=job.duration or 15,
|
||||
duration=job.duration,
|
||||
user_copy_text=job.user_copy_text,
|
||||
fusion_level=job.fusion_level,
|
||||
reference_audio_path=job.reference_audio_path,
|
||||
@@ -134,21 +65,9 @@ def _to_response(job) -> ViralVideoJobResponse:
|
||||
style_guide=job.style_guide,
|
||||
style_template_id=job.style_template_id,
|
||||
status=job.status,
|
||||
current_stage=getattr(job, "current_stage", "") or "",
|
||||
phase_message=getattr(job, "phase_message", "") or "",
|
||||
image_analysis=getattr(job, "image_analysis", None),
|
||||
storyboard=getattr(job, "storyboard", None),
|
||||
generated_copy_text=getattr(job, "generated_copy_text", "") or "",
|
||||
copy_result=_build_copy_result(job),
|
||||
voice_id=getattr(job, "voice_id", "") or "",
|
||||
voice_source=getattr(job, "voice_source", "") or "",
|
||||
video_ratio=getattr(job, "video_ratio", "9:16") or "9:16",
|
||||
video_model=getattr(job, "video_model", "") or "",
|
||||
intent_result=job.intent_result,
|
||||
result_video_url=job.result_video_url,
|
||||
video_resolution=getattr(job, "video_resolution", "720p") or "720p",
|
||||
credits_prepaid=float(getattr(job, "credits_prepaid", 0) or 0),
|
||||
credits_cost=float(getattr(job, "credits_cost", 0) or 0),
|
||||
credits_cost=job.credits_cost,
|
||||
error_msg=job.error_msg,
|
||||
retry_count=job.retry_count,
|
||||
started_at=job.started_at,
|
||||
@@ -190,19 +109,13 @@ def create_viral_video(
|
||||
viral_structure=request.viral_structure,
|
||||
marketing_purpose=request.marketing_purpose,
|
||||
bgm_preference=request.bgm_preference,
|
||||
duration=request.duration or 15,
|
||||
duration=request.duration,
|
||||
user_copy_text=request.user_copy_text,
|
||||
fusion_level=request.fusion_level,
|
||||
reference_audio_path=request.reference_audio_path,
|
||||
reference_video_url=request.reference_video_url,
|
||||
style_strength=request.style_strength,
|
||||
style_template_id=request.style_template_id,
|
||||
voice_id=getattr(request, "voice_id", "") or "",
|
||||
voice_source=getattr(request, "voice_source", "") or "",
|
||||
video_ratio=getattr(request, "video_ratio", "9:16") or "9:16",
|
||||
video_model=getattr(request, "video_model", "") or "",
|
||||
video_resolution=getattr(request, "video_resolution", "720p") or "720p",
|
||||
copy_result=None,
|
||||
)
|
||||
|
||||
# 持久化
|
||||
@@ -220,208 +133,6 @@ def create_viral_video(
|
||||
return _to_response(job)
|
||||
|
||||
|
||||
@router.post("/analyze-images", response_model=ViralVideoJobResponse)
|
||||
def analyze_images(
|
||||
request: AnalyzeImagesRequest,
|
||||
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
||||
session: Session = Depends(get_db_session),
|
||||
) -> ViralVideoJobResponse:
|
||||
"""v1.5 阶段1:创建任务并仅做图片/视频 VLM 分析,跑完后状态=image_analyzed。
|
||||
|
||||
前端拿到 image_analysis(商品名/品牌/特征/颜色/材质等结构化结果)展示给用户;
|
||||
用户填完营销参数后再调 /{id}/generate-copy 进入阶段2。
|
||||
"""
|
||||
from packages.domain.viral_video import ViralVideoJob
|
||||
|
||||
repo = _get_job_repo(session)
|
||||
job = ViralVideoJob(
|
||||
user_id=authenticated_user.user.id,
|
||||
images=list(request.images),
|
||||
reference_video_url=request.reference_video_url or "",
|
||||
style_template_id=request.style_template_id or "",
|
||||
style_strength=request.style_strength or "medium",
|
||||
voice_id=request.voice_id or "",
|
||||
voice_source=request.voice_source or "",
|
||||
video_ratio=request.video_ratio or "9:16",
|
||||
video_model=request.video_model or "",
|
||||
video_resolution=getattr(request, "video_resolution", "720p") or "720p",
|
||||
duration=request.duration or 15,
|
||||
)
|
||||
repo.save(job)
|
||||
|
||||
try:
|
||||
celery_app.send_task("worker.run_viral_video_analyze", args=[job.id])
|
||||
logger.info("[爆款视频][阶段1] analyze-images 入队: job_id=%s", job.id)
|
||||
except Exception as e:
|
||||
logger.error("[爆款视频][阶段1] analyze-images 入队失败: %s", e, exc_info=True)
|
||||
job.mark_failed(f"任务入队失败: {e}")
|
||||
repo.update(job)
|
||||
|
||||
return _to_response(job)
|
||||
|
||||
|
||||
@router.post("/{job_id}/generate-copy", response_model=ViralVideoJobResponse)
|
||||
def generate_copy(
|
||||
job_id: str,
|
||||
request: GenerateCopyRequest,
|
||||
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
||||
session: Session = Depends(get_db_session),
|
||||
) -> ViralVideoJobResponse:
|
||||
"""v1.6 阶段2:用户填完营销参数后,跑 意图解析 → 编导分镜脚本生成 → 合规审核。
|
||||
|
||||
跑完后状态=copy_generated,响应 copy_result(含 overview/scene_and_lighting/shots/
|
||||
hard_constraints/negative_prompts/voiceover_script),前端展示脚本与口播供用户编辑;
|
||||
确认/编辑后调 /{id}/confirm-copy 进入阶段3(TTS + 单次 Seedance 出片)。
|
||||
"""
|
||||
repo = _get_job_repo(session)
|
||||
job = repo.get(job_id)
|
||||
if job is None:
|
||||
raise HTTPException(status_code=404, detail="任务不存在")
|
||||
if job.user_id != authenticated_user.user.id:
|
||||
raise HTTPException(status_code=403, detail="无权操作此任务")
|
||||
if job.status not in (ViralVideoStatus.IMAGE_ANALYZED, ViralVideoStatus.PENDING, ViralVideoStatus.FAILED):
|
||||
raise HTTPException(status_code=409, detail=f"任务当前状态 {job.status} 不能生成文案")
|
||||
|
||||
# 允许失败任务重试:重置
|
||||
if job.status == ViralVideoStatus.FAILED:
|
||||
job.retry_count += 1
|
||||
job.error_msg = ""
|
||||
|
||||
# 把用户填的营销参数写到 job 上
|
||||
job.industry = request.industry or job.industry
|
||||
job.target_customer = request.target_customer or job.target_customer
|
||||
job.persona_id = request.persona_id or job.persona_id
|
||||
job.viral_structure = request.viral_structure or job.viral_structure
|
||||
job.marketing_purpose = request.marketing_purpose or job.marketing_purpose
|
||||
job.bgm_preference = request.bgm_preference or job.bgm_preference
|
||||
if request.duration:
|
||||
job.duration = max(5, min(30, int(request.duration)))
|
||||
job.user_copy_text = request.user_copy_text if request.user_copy_text else job.user_copy_text
|
||||
job.fusion_level = request.fusion_level or job.fusion_level
|
||||
job.reference_audio_path = request.reference_audio_path or job.reference_audio_path
|
||||
job.reference_video_url = request.reference_video_url or job.reference_video_url
|
||||
job.style_strength = request.style_strength or job.style_strength
|
||||
job.style_template_id = request.style_template_id or job.style_template_id
|
||||
if request.style_guide is not None:
|
||||
job.style_guide = request.style_guide
|
||||
job.voice_id = request.voice_id or job.voice_id
|
||||
job.voice_source = request.voice_source or job.voice_source
|
||||
job.video_ratio = request.video_ratio or job.video_ratio or "9:16"
|
||||
job.video_model = request.video_model or job.video_model or ""
|
||||
job.video_resolution = getattr(request, "video_resolution", "") or job.video_resolution or "720p"
|
||||
|
||||
job.resume_from_image_analyzed()
|
||||
repo.update(job)
|
||||
|
||||
try:
|
||||
celery_app.send_task("worker.run_viral_video_generate_copy", args=[job.id])
|
||||
logger.info("[爆款视频][阶段2] generate-copy 入队: job_id=%s", job.id)
|
||||
except Exception as e:
|
||||
logger.error("[爆款视频][阶段2] generate-copy 入队失败: %s", e, exc_info=True)
|
||||
job.mark_failed(f"任务入队失败: {e}")
|
||||
repo.update(job)
|
||||
|
||||
return _to_response(job)
|
||||
|
||||
|
||||
@router.post("/{job_id}/confirm-copy", response_model=ViralVideoJobResponse)
|
||||
def confirm_copy(
|
||||
job_id: str,
|
||||
request: ConfirmCopyRequest,
|
||||
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
||||
session: Session = Depends(get_db_session),
|
||||
) -> ViralVideoJobResponse:
|
||||
"""v1.6 阶段3:用户确认/编辑口播后开始 TTS + 单次 Seedance 生成 + 上传。"""
|
||||
repo = _get_job_repo(session)
|
||||
job = repo.get(job_id)
|
||||
if job is None:
|
||||
raise HTTPException(status_code=404, detail="任务不存在")
|
||||
if job.user_id != authenticated_user.user.id:
|
||||
raise HTTPException(status_code=403, detail="无权操作此任务")
|
||||
if job.status != ViralVideoStatus.COPY_GENERATED:
|
||||
raise HTTPException(status_code=409, detail=f"任务当前状态 {job.status} 不能确认文案(需 copy_generated)")
|
||||
|
||||
# 积分预扣(已扣过/重试任务跳过)
|
||||
from app.config import settings as _settings
|
||||
|
||||
if _settings.points_enabled:
|
||||
already_paid = (float(getattr(job, "credits_prepaid", 0) or 0) > 0) or (
|
||||
float(getattr(job, "credits_cost", 0) or 0) > 0
|
||||
)
|
||||
if not already_paid:
|
||||
from packages.domain.points_rules import calculate_viral_video_credits, resolve_video_dimensions
|
||||
from packages.domain.points_service import PointsService
|
||||
|
||||
w, h = resolve_video_dimensions(
|
||||
getattr(job, "video_resolution", "720p") or "720p",
|
||||
job.video_ratio or "9:16",
|
||||
)
|
||||
est_credits = calculate_viral_video_credits(
|
||||
int(job.duration or 15), w, h, job.video_model or "seedance-2.5"
|
||||
)
|
||||
svc = PointsService()
|
||||
res = svc.deduct_viral_video(authenticated_user.user.id, est_credits, job.id, session)
|
||||
if not res.get("success"):
|
||||
balance = res.get("balance", 0)
|
||||
raise HTTPException(
|
||||
status_code=402,
|
||||
detail={
|
||||
"code": "INSUFFICIENT_POINTS",
|
||||
"message": f"积分不足,需要 {est_credits} 积分,当前余额 {balance}",
|
||||
"required": est_credits,
|
||||
"balance": balance,
|
||||
},
|
||||
)
|
||||
job.credits_prepaid = est_credits
|
||||
job.credits_transaction_id = res.get("transaction_id", "") or ""
|
||||
repo.update(job)
|
||||
|
||||
job.resume_from_copy_generated(edited_copy=request.edited_copy or None)
|
||||
repo.update(job)
|
||||
|
||||
try:
|
||||
celery_app.send_task("worker.run_viral_video_render", args=[job.id])
|
||||
logger.info("[爆款视频][阶段3] confirm-copy 入队: job_id=%s", job.id)
|
||||
except Exception as e:
|
||||
logger.error("[爆款视频][阶段3] confirm-copy 入队失败: %s", e, exc_info=True)
|
||||
job.mark_failed(f"任务入队失败: {e}")
|
||||
repo.update(job)
|
||||
|
||||
return _to_response(job)
|
||||
|
||||
|
||||
@router.post("/estimate-credits", response_model=EstimateCreditsResponse)
|
||||
def estimate_credits(
|
||||
request: EstimateCreditsRequest,
|
||||
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
||||
) -> EstimateCreditsResponse:
|
||||
"""爆款视频积分预估(纯计算,不扣费、不创建任务)。
|
||||
|
||||
返回 estimated_credits 与 formula_breakdown(tokens / video_cost / fixed_cost /
|
||||
profit_multiplier / model_price / width / height / fps),便于前端展示计费明细。
|
||||
同时兼容前端传 model 或 video_model、resolution 或 video_resolution、ratio 或 video_ratio。
|
||||
"""
|
||||
from packages.domain.points_rules import (
|
||||
calculate_viral_video_credits_with_breakdown,
|
||||
resolve_video_dimensions,
|
||||
)
|
||||
|
||||
model = (request.model or "").strip() or "seedance-2.5"
|
||||
resolution = (request.resolution or "").strip() or "720p"
|
||||
ratio = (request.ratio or "").strip() or "9:16"
|
||||
duration = int(request.duration or 15)
|
||||
|
||||
w, h = resolve_video_dimensions(resolution, ratio)
|
||||
credits, bd = calculate_viral_video_credits_with_breakdown(
|
||||
duration,
|
||||
w,
|
||||
h,
|
||||
model,
|
||||
)
|
||||
breakdown = CreditsFormulaBreakdown(**bd)
|
||||
return EstimateCreditsResponse(estimated_credits=credits, formula_breakdown=breakdown)
|
||||
|
||||
|
||||
@router.get("/history", response_model=ViralVideoHistoryResponse)
|
||||
def list_viral_video_history(
|
||||
limit: int = 50,
|
||||
@@ -456,17 +167,6 @@ def list_style_templates(
|
||||
return StyleTemplateListResponse(items=items)
|
||||
|
||||
|
||||
@router.get("/models")
|
||||
def list_available_models() -> dict:
|
||||
"""返回爆款视频可用模型列表(供前端模型选择器使用)。"""
|
||||
dashscope_available = get_dashscope_client() is not None
|
||||
models = list_viral_video_models(
|
||||
include_placeholder=False,
|
||||
dashscope_available=dashscope_available,
|
||||
)
|
||||
return {"models": models}
|
||||
|
||||
|
||||
@router.get("/{job_id}", response_model=ViralVideoJobResponse)
|
||||
def get_viral_video_job(
|
||||
job_id: str,
|
||||
@@ -486,156 +186,31 @@ def get_viral_video_job(
|
||||
@router.post("/{job_id}/retry", response_model=ViralVideoJobResponse)
|
||||
def retry_viral_video_job(
|
||||
job_id: str,
|
||||
request: RetryViralVideoRequest | None = None,
|
||||
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
||||
session: Session = Depends(get_db_session),
|
||||
) -> ViralVideoJobResponse:
|
||||
"""重试失败的爆款视频任务(也支持对僵尸/超时 running 任务强制重置后重试)。
|
||||
|
||||
可选 body (RetryViralVideoRequest):若传入新的 duration/video_resolution/video_ratio/
|
||||
video_model,会重新预估积分并与原 credits_prepaid 做差额多退少补(不足抛 402 阻止重试);
|
||||
不传 body 或参数无变化时,保持原参数、原预扣金额不变,仅重置状态并入队。
|
||||
credits_prepaid 为 0 的老任务首次重试会走预扣流程(与 confirm-copy 一致)。
|
||||
"""
|
||||
from datetime import datetime, timezone
|
||||
|
||||
"""重试失败的爆款视频任务。"""
|
||||
repo = _get_job_repo(session)
|
||||
job = repo.get(job_id)
|
||||
if job is None:
|
||||
raise HTTPException(status_code=404, detail="任务不存在")
|
||||
if job.user_id != authenticated_user.user.id:
|
||||
raise HTTPException(status_code=403, detail="无权操作此任务")
|
||||
|
||||
# 判定是否为僵尸 running 任务:running 超过 10 分钟且心跳停止超过 2 分钟
|
||||
now = datetime.now(timezone.utc)
|
||||
is_stale_running = False
|
||||
if job.status == ViralVideoStatus.RUNNING and job.started_at is not None:
|
||||
hb = getattr(job, "heartbeat_at", None) or job.updated_at
|
||||
if (now - job.started_at).total_seconds() > 10 * 60 and hb is not None and (now - hb).total_seconds() > 2 * 60:
|
||||
is_stale_running = True
|
||||
|
||||
if job.status != ViralVideoStatus.FAILED and not is_stale_running:
|
||||
raise HTTPException(status_code=409, detail="只有失败或超时的任务可以重试")
|
||||
|
||||
# ── 参数变更检测 + 积分多退少补 ──────────────────────────────────────
|
||||
req = request or RetryViralVideoRequest()
|
||||
new_duration = req.duration
|
||||
new_resolution = (req.video_resolution or "").strip() or None
|
||||
new_ratio = (req.video_ratio or "").strip() or None
|
||||
new_model = (req.video_model or "").strip() or None
|
||||
|
||||
old_duration = int(getattr(job, "duration", 15) or 15)
|
||||
old_resolution = (getattr(job, "video_resolution", "720p") or "720p").strip() or "720p"
|
||||
old_ratio = (getattr(job, "video_ratio", "9:16") or "9:16").strip() or "9:16"
|
||||
old_model = (getattr(job, "video_model", "") or "").strip()
|
||||
|
||||
# 仅当有任意字段传入且值不同才算"参数变更"
|
||||
param_changed = bool(
|
||||
(new_duration is not None and int(new_duration) != old_duration)
|
||||
or (new_resolution is not None and new_resolution != old_resolution)
|
||||
or (new_ratio is not None and new_ratio != old_ratio)
|
||||
or (new_model is not None and new_model != old_model)
|
||||
)
|
||||
|
||||
from app.config import settings as _settings
|
||||
|
||||
need_points_settle = False
|
||||
new_est = 0.0
|
||||
if _settings.points_enabled and param_changed:
|
||||
from packages.domain.points_rules import (
|
||||
calculate_viral_video_credits_with_breakdown,
|
||||
resolve_video_dimensions,
|
||||
)
|
||||
|
||||
eff_dur = int(new_duration if new_duration is not None else old_duration)
|
||||
eff_res = new_resolution if new_resolution is not None else old_resolution
|
||||
eff_ratio = new_ratio if new_ratio is not None else old_ratio
|
||||
eff_model = new_model if new_model is not None else (old_model or "seedance-2.5")
|
||||
w, h = resolve_video_dimensions(eff_res, eff_ratio)
|
||||
new_est, _ = calculate_viral_video_credits_with_breakdown(eff_dur, w, h, eff_model or "seedance-2.5")
|
||||
need_points_settle = True
|
||||
|
||||
# 写入新参数(即使不开 points 也要允许用户重试时改参数)
|
||||
if new_duration is not None:
|
||||
job.duration = max(5, min(30, int(new_duration)))
|
||||
if new_resolution is not None:
|
||||
job.video_resolution = new_resolution
|
||||
if new_ratio is not None:
|
||||
job.video_ratio = new_ratio
|
||||
if new_model is not None:
|
||||
job.video_model = new_model
|
||||
|
||||
if need_points_settle:
|
||||
from packages.domain.points_service import PointsService
|
||||
|
||||
old_prepaid = float(getattr(job, "credits_prepaid", 0) or 0)
|
||||
svc = PointsService()
|
||||
diff = round(new_est - old_prepaid, 2)
|
||||
if abs(diff) >= 0.01:
|
||||
if diff > 0:
|
||||
# 新预扣更多:补扣差额
|
||||
res = svc.deduct_viral_video(authenticated_user.user.id, diff, job.id, session)
|
||||
if not res.get("success"):
|
||||
balance = res.get("balance", 0)
|
||||
raise HTTPException(
|
||||
status_code=402,
|
||||
detail={
|
||||
"code": "INSUFFICIENT_POINTS",
|
||||
"message": f"重试参数变更后需补扣 {diff} 积分,余额不足(当前 {balance},需 {new_est})",
|
||||
"required": new_est,
|
||||
"balance": balance,
|
||||
"delta": diff,
|
||||
},
|
||||
)
|
||||
job.credits_prepaid = round(old_prepaid + diff, 2)
|
||||
logger.info(
|
||||
"[爆款视频][retry] 补扣差额 job_id=%s diff=%.2f new_prepaid=%.2f",
|
||||
job.id,
|
||||
diff,
|
||||
job.credits_prepaid,
|
||||
)
|
||||
else:
|
||||
# 新预扣更少:退还差额
|
||||
refund = round(-diff, 2)
|
||||
txn_id = getattr(job, "credits_transaction_id", "") or ""
|
||||
svc.refund_points(
|
||||
user_id=authenticated_user.user.id,
|
||||
amount=refund,
|
||||
source="viral_video",
|
||||
db=session,
|
||||
ref_id=txn_id or job.id,
|
||||
description="爆款视频重试参数变更退费",
|
||||
)
|
||||
job.credits_prepaid = round(old_prepaid - refund, 2)
|
||||
logger.info(
|
||||
"[爆款视频][retry] 退还差额 job_id=%s refund=%.2f new_prepaid=%.2f",
|
||||
job.id,
|
||||
refund,
|
||||
job.credits_prepaid,
|
||||
)
|
||||
# 差额为 0 则不调整
|
||||
if job.status != ViralVideoStatus.FAILED:
|
||||
raise HTTPException(status_code=409, detail="只有失败的任务可以重试")
|
||||
|
||||
# 重置状态
|
||||
job.retry_count += 1
|
||||
job.status = ViralVideoStatus.PENDING
|
||||
job.error_msg = "" if not is_stale_running else "任务执行超时,已重置重试"
|
||||
job.error_msg = ""
|
||||
job.started_at = None
|
||||
job.completed_at = None
|
||||
job.current_stage = ""
|
||||
job.phase_message = ""
|
||||
job.heartbeat_at = None
|
||||
repo.update(job)
|
||||
|
||||
# 重新入队
|
||||
try:
|
||||
celery_app.send_task("worker.run_viral_video_pipeline", args=[job.id])
|
||||
logger.info(
|
||||
"[爆款视频] 重试入队: job_id=%s retry_count=%d stale=%s params_changed=%s",
|
||||
job.id,
|
||||
job.retry_count,
|
||||
is_stale_running,
|
||||
param_changed,
|
||||
)
|
||||
logger.info("[爆款视频] 重试入队: job_id=%s retry_count=%d", job.id, job.retry_count)
|
||||
except Exception as e:
|
||||
logger.error("[爆款视频] 重试入队失败: %s", e, exc_info=True)
|
||||
job.mark_failed(f"重试入队失败: {e}")
|
||||
@@ -930,8 +505,6 @@ def _job_status(job) -> str:
|
||||
_STATUS_STAGE = {
|
||||
"pending": "",
|
||||
"running": "",
|
||||
"image_analyzed": "image_analysis",
|
||||
"copy_generated": "review",
|
||||
"wait_user_confirm": "intent_parsing",
|
||||
"completed": "uploading",
|
||||
"failed": "",
|
||||
@@ -941,8 +514,6 @@ _STATUS_STAGE = {
|
||||
_STATUS_PROGRESS = {
|
||||
"pending": 0.0,
|
||||
"running": 5.0,
|
||||
"image_analyzed": 15.0,
|
||||
"copy_generated": 70.0,
|
||||
"wait_user_confirm": 35.0,
|
||||
"completed": 100.0,
|
||||
"failed": 0.0,
|
||||
@@ -952,8 +523,6 @@ _STATUS_PROGRESS = {
|
||||
_STATUS_MESSAGE = {
|
||||
"pending": "任务已创建,等待执行",
|
||||
"running": "任务执行中",
|
||||
"image_analyzed": "图片分析完成,等待填写营销参数",
|
||||
"copy_generated": "文案与分镜已生成,等待确认文案",
|
||||
"wait_user_confirm": "等待用户确认意图文案",
|
||||
"completed": "视频生成完成",
|
||||
"failed": "任务失败",
|
||||
|
||||
@@ -13,9 +13,9 @@ from pydantic import BaseModel, Field
|
||||
class PointsBalanceResponse(BaseModel):
|
||||
"""积分余额 + 会员状态"""
|
||||
|
||||
balance: float = Field(..., description="当前积分余额")
|
||||
total_earned: float = Field(..., description="累计获得积分")
|
||||
total_spent: float = Field(..., description="累计消耗积分")
|
||||
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="会员到期时间")
|
||||
@@ -30,8 +30,8 @@ class PointsTransactionItem(BaseModel):
|
||||
id: str
|
||||
type: str = Field(..., description="类型: add/deduct")
|
||||
source: str = Field(..., description="来源场景")
|
||||
amount: float
|
||||
balance_after: float
|
||||
amount: int
|
||||
balance_after: int
|
||||
description: str = ""
|
||||
ref_id: str = ""
|
||||
created_at: Optional[str] = None
|
||||
@@ -99,9 +99,9 @@ class PointsCheckResponse(BaseModel):
|
||||
"""消费前余额检查响应"""
|
||||
|
||||
allowed: bool
|
||||
required_points: float
|
||||
current_balance: float
|
||||
remaining_after: float
|
||||
required_points: int
|
||||
current_balance: int
|
||||
remaining_after: int
|
||||
is_free_quota: bool = False
|
||||
|
||||
|
||||
@@ -112,7 +112,7 @@ class PointsDeductRequest(BaseModel):
|
||||
"""积分扣减请求"""
|
||||
|
||||
scene_key: str
|
||||
amount: float
|
||||
amount: int
|
||||
description: Optional[str] = ""
|
||||
ref_id: Optional[str] = ""
|
||||
|
||||
@@ -170,7 +170,7 @@ class MembershipStatusResponse(BaseModel):
|
||||
is_member: bool
|
||||
member_type: Optional[str] = None
|
||||
member_expires_at: Optional[datetime] = None
|
||||
points_balance: float
|
||||
points_balance: int
|
||||
max_resolution: str = Field(
|
||||
default="1080p",
|
||||
description="可用最高分辨率: 720p(free) / 1080p(paid)",
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""爆款视频 API schemas (v1.6 单次 Seedance 出片版)。"""
|
||||
"""爆款视频 API schemas。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -6,7 +6,7 @@ from datetime import datetime
|
||||
|
||||
from pydantic import BaseModel, Field, field_validator
|
||||
|
||||
# -- 枚举常量 --
|
||||
# ── 枚举常量 ─────────────────────────────────────────────────────────────
|
||||
|
||||
VALID_FUSION_LEVELS = ("ai_full", "full_ai", "ai_polish", "user_primary")
|
||||
VALID_STYLE_STRENGTHS = ("light", "medium", "strict")
|
||||
@@ -14,178 +14,76 @@ VALID_STAGES = (
|
||||
"image_analysis",
|
||||
"video_analysis",
|
||||
"intent_parsing",
|
||||
"script_generation",
|
||||
"copy_fusion",
|
||||
"storyboard",
|
||||
"review",
|
||||
"tts",
|
||||
"bgm_select",
|
||||
"rendering",
|
||||
"musetalk",
|
||||
"uploading",
|
||||
)
|
||||
VALID_VIDEO_RATIOS = ("9:16", "16:9", "1:1", "4:3", "3:4", "21:9")
|
||||
VALID_DURATIONS = (5, 10, 15, 20, 25, 30)
|
||||
VALID_VIDEO_RESOLUTIONS = ("480p", "720p", "1080p", "普清", "高清", "超清")
|
||||
|
||||
|
||||
# -- 编导脚本结构(v1.6) --
|
||||
|
||||
|
||||
class ShotScript(BaseModel):
|
||||
"""逐镜头分镜。"""
|
||||
|
||||
time_range: str = Field(default="", description="时间区间,如 0-3秒")
|
||||
shot_type_angle_movement: str = Field(default="", description="景别/角度/运镜,如『近景俯拍45度,缓慢推镜』")
|
||||
scene_and_dialogue: str = Field(default="", description="场景描述+口播台词")
|
||||
action_details: str = Field(default="", description="人物动作、表情、物品操作细节")
|
||||
audio_bgm: str = Field(default="", description="环境音+BGM提示")
|
||||
transition: str = Field(default="硬切", description="转场方式:硬切/淡入淡出/叠化")
|
||||
reference_image_index: int | None = Field(
|
||||
default=None, description="参考图片索引(0-based,对应上传的第几张产品图)"
|
||||
)
|
||||
|
||||
|
||||
class CopyResultOverview(BaseModel):
|
||||
theme: str = ""
|
||||
total_duration: int = 15
|
||||
aspect_ratio: str = "9:16"
|
||||
|
||||
|
||||
class CopyResult(BaseModel):
|
||||
"""v1.6 编导分镜脚本结构(给前端 + Seedance 用)。"""
|
||||
|
||||
overview: CopyResultOverview = Field(default_factory=CopyResultOverview)
|
||||
scene_and_lighting: str = ""
|
||||
shots: list[ShotScript] = Field(default_factory=list)
|
||||
hard_constraints: list[str] = Field(default_factory=list)
|
||||
negative_prompts: list[str] = Field(default_factory=list)
|
||||
voiceover_script: str = Field(
|
||||
default="", description="纯口播对白,从各镜 scene_and_dialogue 的对白部分拼接,供 TTS 使用"
|
||||
)
|
||||
# 向后兼容:final_copy = voiceover_script
|
||||
final_copy: str = ""
|
||||
suggested_copy: str = ""
|
||||
title: str = ""
|
||||
|
||||
|
||||
# -- Request Schemas --
|
||||
# ── Request Schemas ────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class CreateViralVideoRequest(BaseModel):
|
||||
"""旧接口:一键创建(保留兼容)。"""
|
||||
"""创建爆款视频任务请求。"""
|
||||
|
||||
images: list[str] = Field(..., min_length=1, max_length=20)
|
||||
industry: str = ""
|
||||
target_customer: str = ""
|
||||
persona_id: str = ""
|
||||
viral_structure: str = ""
|
||||
marketing_purpose: str = ""
|
||||
bgm_preference: str = ""
|
||||
duration: int = Field(default=15, ge=5, le=30, description="视频时长(秒),5-30")
|
||||
user_copy_text: str = ""
|
||||
fusion_level: str = "ai_polish"
|
||||
reference_audio_path: str = ""
|
||||
reference_video_url: str = ""
|
||||
style_strength: str = "medium"
|
||||
style_template_id: str = ""
|
||||
voice_id: str = ""
|
||||
voice_source: str = ""
|
||||
video_ratio: str = "9:16"
|
||||
video_model: str = ""
|
||||
video_resolution: str = "720p"
|
||||
images: list[str] = Field(..., min_length=1, max_length=20, description="产品图片 URL 列表")
|
||||
industry: str = Field(default="", description="行业")
|
||||
target_customer: str = Field(default="", description="目标客户描述")
|
||||
persona_id: str = Field(default="", description="人设 ID")
|
||||
viral_structure: str = Field(default="", description="爆款结构类型")
|
||||
marketing_purpose: str = Field(default="", description="营销目的")
|
||||
bgm_preference: str = Field(default="", description="BGM 偏好")
|
||||
duration: int = Field(default=30, ge=5, le=180, description="视频时长(秒)")
|
||||
user_copy_text: str = Field(default="", description="用户原始文案(我说你写)")
|
||||
fusion_level: str = Field(default="ai_polish", description="文案融合级别: ai_full/ai_polish/user_primary")
|
||||
reference_audio_path: str = Field(default="", description="参考音频路径")
|
||||
# v1.3 新增
|
||||
reference_video_url: str = Field(default="", description="参考爆款视频 URL")
|
||||
style_strength: str = Field(default="medium", description="风格强度: light/medium/strict")
|
||||
style_template_id: str = Field(default="", description="风格模板 ID")
|
||||
|
||||
@field_validator("fusion_level")
|
||||
@classmethod
|
||||
def _v_fl(cls, v: str) -> str:
|
||||
def _validate_fusion_level(cls, v: str) -> str:
|
||||
# 兼容前端历史写法 full_ai(等价 ai_full)
|
||||
if v == "full_ai":
|
||||
return "ai_full"
|
||||
if v not in VALID_FUSION_LEVELS:
|
||||
raise ValueError(f"fusion_level must be one of {VALID_FUSION_LEVELS}")
|
||||
raise ValueError(f"fusion_level 必须是 {VALID_FUSION_LEVELS} 之一")
|
||||
return v
|
||||
|
||||
@field_validator("style_strength")
|
||||
@classmethod
|
||||
def _v_ss(cls, v: str) -> str:
|
||||
def _validate_style_strength(cls, v: str) -> str:
|
||||
if v not in VALID_STYLE_STRENGTHS:
|
||||
raise ValueError(f"style_strength must be one of {VALID_STYLE_STRENGTHS}")
|
||||
raise ValueError(f"style_strength 必须是 {VALID_STYLE_STRENGTHS} 之一")
|
||||
return v
|
||||
|
||||
|
||||
class AnalyzeImagesRequest(BaseModel):
|
||||
"""v1.5+ 阶段1:创建任务 + 图片/视频分析。"""
|
||||
|
||||
images: list[str] = Field(..., min_length=1, max_length=30)
|
||||
reference_video_url: str = ""
|
||||
style_template_id: str = ""
|
||||
style_strength: str = "medium"
|
||||
voice_id: str = ""
|
||||
voice_source: str = ""
|
||||
video_ratio: str = "9:16"
|
||||
video_model: str = ""
|
||||
video_resolution: str = "720p"
|
||||
duration: int = Field(default=15, ge=5, le=30)
|
||||
|
||||
|
||||
class GenerateCopyRequest(BaseModel):
|
||||
"""v1.5+ 阶段2:填完营销参数,生成编导脚本。"""
|
||||
|
||||
industry: str = ""
|
||||
target_customer: str = ""
|
||||
persona_id: str = ""
|
||||
viral_structure: str = ""
|
||||
marketing_purpose: str = ""
|
||||
bgm_preference: str = ""
|
||||
duration: int = Field(default=15, ge=5, le=30)
|
||||
user_copy_text: str = ""
|
||||
fusion_level: str = "ai_polish"
|
||||
reference_audio_path: str = ""
|
||||
reference_video_url: str = ""
|
||||
style_strength: str = "medium"
|
||||
style_template_id: str = ""
|
||||
style_guide: dict | None = None
|
||||
voice_id: str = ""
|
||||
voice_source: str = ""
|
||||
video_ratio: str = "9:16"
|
||||
video_model: str = ""
|
||||
video_resolution: str = "720p"
|
||||
|
||||
@field_validator("fusion_level")
|
||||
@classmethod
|
||||
def _v_fl(cls, v: str) -> str:
|
||||
if v == "full_ai":
|
||||
return "ai_full"
|
||||
if v not in VALID_FUSION_LEVELS:
|
||||
raise ValueError(f"fusion_level must be one of {VALID_FUSION_LEVELS}")
|
||||
return v
|
||||
|
||||
@field_validator("style_strength")
|
||||
@classmethod
|
||||
def _v_ss(cls, v: str) -> str:
|
||||
if v not in VALID_STYLE_STRENGTHS:
|
||||
raise ValueError(f"style_strength must be one of {VALID_STYLE_STRENGTHS}")
|
||||
return v
|
||||
|
||||
|
||||
class ConfirmCopyRequest(BaseModel):
|
||||
"""v1.5+ 阶段3:用户确认/编辑口播后开始渲染(TTS+单次Seedance)。"""
|
||||
|
||||
edited_copy: str = Field(default="", description="用户编辑后的口播文案;为空则用 AI 生成的 voiceover_script")
|
||||
|
||||
|
||||
class ConfirmIntentRequest(BaseModel):
|
||||
"""旧 confirm-intent(兼容)。"""
|
||||
"""确认意图请求(confirm-intent)。"""
|
||||
|
||||
confirmed_copy: str = ""
|
||||
adjustments: str = ""
|
||||
confirmed_copy: str = Field(default="", description="用户确认/修改后的文案,为空表示使用 AI 生成的文案")
|
||||
adjustments: str = Field(default="", description="用户对 AI 文案的调整意见")
|
||||
|
||||
|
||||
class AnalyzeStyleRequest(BaseModel):
|
||||
"""触发参考视频风格分析请求。"""
|
||||
|
||||
reference_video_url: str = Field(..., description="参考视频 URL")
|
||||
style_template_id: str = ""
|
||||
style_template_id: str = Field(default="", description="风格模板 ID(可选覆盖)")
|
||||
|
||||
|
||||
# -- Response Schemas --
|
||||
# ── Response Schemas ───────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class ViralVideoJobResponse(BaseModel):
|
||||
"""爆款视频任务响应(v1.6 包含 copy_result 编导脚本结构)。"""
|
||||
"""爆款视频任务响应。"""
|
||||
|
||||
id: str
|
||||
user_id: str
|
||||
@@ -196,7 +94,7 @@ class ViralVideoJobResponse(BaseModel):
|
||||
viral_structure: str = ""
|
||||
marketing_purpose: str = ""
|
||||
bgm_preference: str = ""
|
||||
duration: int = 15
|
||||
duration: int = 30
|
||||
user_copy_text: str = ""
|
||||
fusion_level: str = "ai_polish"
|
||||
reference_audio_path: str = ""
|
||||
@@ -205,26 +103,9 @@ class ViralVideoJobResponse(BaseModel):
|
||||
style_guide: dict | None = None
|
||||
style_template_id: str = ""
|
||||
status: str
|
||||
current_stage: str = (
|
||||
"" # 细粒度阶段 snake_case(analyzing_images/parsing_intent/generating_script/reviewing/tts_synthesizing/rendering_video/uploading)
|
||||
)
|
||||
phase_message: str = "" # 中文阶段提示文案(前端轮询/SSE 直接展示)
|
||||
image_analysis: dict | None = None
|
||||
# v1.6 编导脚本(推荐前端使用)
|
||||
copy_result: dict | None = None
|
||||
# v1.5 兼容字段
|
||||
storyboard: list | None = None
|
||||
generated_copy_text: str = ""
|
||||
# 音色/视频参数
|
||||
voice_id: str = ""
|
||||
voice_source: str = ""
|
||||
video_ratio: str = "9:16"
|
||||
video_model: str = ""
|
||||
intent_result: dict | None = None
|
||||
result_video_url: str = ""
|
||||
video_resolution: str = "720p"
|
||||
credits_prepaid: float = 0.0
|
||||
credits_cost: float = 0.0
|
||||
credits_cost: int = 0
|
||||
error_msg: str = ""
|
||||
retry_count: int = 0
|
||||
started_at: datetime | None = None
|
||||
@@ -234,11 +115,15 @@ class ViralVideoJobResponse(BaseModel):
|
||||
|
||||
|
||||
class ViralVideoHistoryResponse(BaseModel):
|
||||
"""历史记录列表响应。"""
|
||||
|
||||
items: list[ViralVideoJobResponse]
|
||||
total: int
|
||||
|
||||
|
||||
class StyleTemplateResponse(BaseModel):
|
||||
"""风格模板响应。"""
|
||||
|
||||
id: str
|
||||
name: str
|
||||
description: str = ""
|
||||
@@ -247,70 +132,25 @@ class StyleTemplateResponse(BaseModel):
|
||||
|
||||
|
||||
class StyleTemplateListResponse(BaseModel):
|
||||
"""风格模板列表响应。"""
|
||||
|
||||
items: list[StyleTemplateResponse]
|
||||
|
||||
|
||||
class AnalyzeStyleResponse(BaseModel):
|
||||
"""风格分析结果响应。"""
|
||||
|
||||
job_id: str
|
||||
status: str
|
||||
style_guide: dict | None = None
|
||||
|
||||
|
||||
# -- 积分预估 --
|
||||
|
||||
|
||||
class EstimateCreditsRequest(BaseModel):
|
||||
"""爆款视频积分预估请求。
|
||||
|
||||
前端可传 model 或 video_model(兼容老字段);resolution/ratio/duration 为预估所需参数。
|
||||
"""
|
||||
|
||||
model: str = Field(default="", alias="video_model")
|
||||
resolution: str = Field(default="720p", alias="video_resolution")
|
||||
ratio: str = Field(default="9:16", alias="video_ratio")
|
||||
duration: int = Field(default=15, ge=5, le=30)
|
||||
|
||||
model_config = {"populate_by_name": True}
|
||||
|
||||
|
||||
class CreditsFormulaBreakdown(BaseModel):
|
||||
"""爆款视频积分计费公式明细(前端展示用)。"""
|
||||
|
||||
tokens: float = Field(..., description="估算视频 tokens 数 (duration*width*height*fps/1024)")
|
||||
video_cost: float = Field(..., description="视频生成成本(元)= tokens/1e6 * model_price")
|
||||
fixed_cost: float = Field(..., description="固定成本(元),含 VLM/LLM/TTS/OSS/服务器")
|
||||
profit_multiplier: float = Field(..., description="利润系数(默认 1.3)")
|
||||
model_price: float = Field(..., description="模型单价(元/百万 tokens)")
|
||||
width: int = Field(..., description="视频宽度像素")
|
||||
height: int = Field(..., description="视频高度像素")
|
||||
fps: int = Field(..., description="视频帧率")
|
||||
|
||||
|
||||
class EstimateCreditsResponse(BaseModel):
|
||||
"""爆款视频积分预估响应。"""
|
||||
|
||||
estimated_credits: float
|
||||
formula_breakdown: CreditsFormulaBreakdown = Field(..., description="计费公式明细")
|
||||
|
||||
|
||||
class RetryViralVideoRequest(BaseModel):
|
||||
"""重试爆款视频任务的请求体(可选,允许改参数重新预估积分多退少补)。
|
||||
|
||||
不传 body 或字段全缺省:保持原参数、不重新扣点,走默认重置+入队逻辑。
|
||||
传入新的 duration/video_resolution/video_ratio/video_model:重新预估积分,
|
||||
与原 credits_prepaid 比较后多退少补(差额补扣不足抛 402)。
|
||||
"""
|
||||
|
||||
duration: int | None = Field(default=None, ge=5, le=30, description="重试时新的视频时长(秒)")
|
||||
video_resolution: str | None = Field(default=None, description="重试时新的分辨率,如 720p/1080p")
|
||||
video_ratio: str | None = Field(default=None, description="重试时新的画幅比,如 9:16/16:9")
|
||||
video_model: str | None = Field(default=None, description="重试时新的视频模型,如 seedance-2.5")
|
||||
|
||||
|
||||
# -- WebSocket 事件 Schema --
|
||||
# ── WebSocket 事件 Schema ──────────────────────────────────────────────────
|
||||
|
||||
|
||||
class WSProgressEvent(BaseModel):
|
||||
"""WebSocket 进度推送事件。"""
|
||||
|
||||
type: str = "viral_video:progress"
|
||||
job_id: str
|
||||
stage: str
|
||||
|
||||
@@ -11,12 +11,14 @@
|
||||
存储路径与元信息约定),返回 asset_id —— 下游仍以 voice_library_id(实为
|
||||
audio asset id)消费,渲染链路零改动。
|
||||
|
||||
积分扣点与 /tts 合成端点保持一致(ai_voice 场景),失败退费。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import math
|
||||
import subprocess
|
||||
import tempfile
|
||||
from dataclasses import dataclass
|
||||
@@ -30,10 +32,13 @@ from packages.application.cosyvoice_service import CosyVoiceService
|
||||
from packages.application.tts_job.use_cases import CreateTTSJobUseCase
|
||||
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.shared.storage import SharedStorageService
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_POINTS_SCENE = "ai_voice"
|
||||
_SYNTH_TIMEOUT = 180.0 # 叙事配音在 HTTP 请求内同步等待,长文案分段合成时留出余量
|
||||
_CONTENT_TYPE_MAP = {"mp3": "audio/mpeg", "wav": "audio/wav", "pcm": "audio/pcm", "opus": "audio/opus"}
|
||||
|
||||
@@ -268,6 +273,24 @@ def prepare_narrative_voice(
|
||||
voice_clone_repository=voice_clone_repository,
|
||||
)
|
||||
|
||||
# 积分扣点(与 /tts 合成端点同口径),失败时在合成失败分支退费
|
||||
points_svc = PointsService() if points_enabled else None
|
||||
points_deducted = 0
|
||||
if points_svc is not None:
|
||||
est_minutes = max(1.0, math.ceil(len(content) / 240))
|
||||
points_deducted = calculate_points_cost(
|
||||
_POINTS_SCENE,
|
||||
is_member=is_member,
|
||||
duration_minutes=est_minutes,
|
||||
member_type=member_type,
|
||||
)
|
||||
deduct_res = points_svc.deduct_points(user_id, points_deducted, _POINTS_SCENE, db)
|
||||
if not deduct_res["success"]:
|
||||
raise NarrativeError(
|
||||
f"积分不足,需要 {points_deducted} 积分,当前余额 {deduct_res['balance']}",
|
||||
status_code=402,
|
||||
)
|
||||
|
||||
use_case = CreateTTSJobUseCase(tts_repository)
|
||||
job = use_case.execute(
|
||||
user_id=user_id,
|
||||
@@ -288,9 +311,19 @@ def prepare_narrative_voice(
|
||||
workflow.process_synthesis_failure(job.id, str(e))
|
||||
except Exception: # noqa: BLE001
|
||||
logger.warning("标记叙事 TTS job 失败出错: job_id=%s", job.id, exc_info=True)
|
||||
if points_deducted and points_svc is not None:
|
||||
try:
|
||||
points_svc.refund_points(user_id, points_deducted, _POINTS_SCENE, db, ref_id=job.id)
|
||||
except Exception: # noqa: BLE001
|
||||
logger.warning("叙事 TTS 失败退积分异常: job_id=%s", job.id, exc_info=True)
|
||||
raise NarrativeError(f"配音合成失败:{e}", status_code=502) from e
|
||||
|
||||
if not job.is_completed:
|
||||
if points_deducted and points_svc is not None:
|
||||
try:
|
||||
points_svc.refund_points(user_id, points_deducted, _POINTS_SCENE, db, ref_id=job.id)
|
||||
except Exception: # noqa: BLE001
|
||||
logger.warning("叙事 TTS 未完成退积分异常: job_id=%s", job.id, exc_info=True)
|
||||
raise NarrativeError("配音合成未完成,请稍后重试", status_code=504)
|
||||
|
||||
asset = _save_tts_job_as_voice_asset(
|
||||
|
||||
@@ -6,11 +6,6 @@ import type {
|
||||
ViralVideoJob,
|
||||
ImageAnalysisResult,
|
||||
CopyResult,
|
||||
AnalyzeImagesRequest,
|
||||
GenerateCopyRequest,
|
||||
ConfirmCopyRequest,
|
||||
ViralVideoModel,
|
||||
ViralVideoModelsResponse,
|
||||
} from "./types"
|
||||
|
||||
/** 创建爆款视频任务 */
|
||||
@@ -53,29 +48,6 @@ export function analyzeViralStyle(id: string) {
|
||||
return apiClient.post<ViralVideoJob>(`/viral-video/${id}/analyze-style`).then((r) => r.data)
|
||||
}
|
||||
|
||||
/** 动态预估积分消耗(STEP3 参数变化时调用) */
|
||||
export function estimateViralVideoCredits(params: {
|
||||
video_model: string
|
||||
resolution: string
|
||||
video_ratio: string
|
||||
duration: number
|
||||
}) {
|
||||
return apiClient
|
||||
.post<{ estimated_credits: number }>("/viral-video/estimate-credits", params)
|
||||
.then((r) => r.data)
|
||||
}
|
||||
|
||||
/** 获取支持的视频模型列表(GET /viral-video/models)。后端返回 {models: [...]} 包装 */
|
||||
export function getViralVideoModels() {
|
||||
return apiClient.get<ViralVideoModelsResponse>("/viral-video/models").then((r) => {
|
||||
const data = r.data as ViralVideoModelsResponse | ViralVideoModel[] | null | undefined
|
||||
if (Array.isArray(data)) return data
|
||||
if (data && Array.isArray((data as ViralVideoModelsResponse).models)) {
|
||||
return (data as ViralVideoModelsResponse).models
|
||||
}
|
||||
return []
|
||||
})
|
||||
}
|
||||
/** ── 三步拆分:前端 mock 辅助函数(后端新接口上线后可替换) ── */
|
||||
|
||||
/**
|
||||
@@ -133,24 +105,3 @@ export function mockGenerateCopy(params: {
|
||||
}, 2200)
|
||||
})
|
||||
}
|
||||
|
||||
/** ── 三步拆分 v1.5 真实后端 API(PR #2117 合入后启用,前端可替换 mock 调用) ── */
|
||||
|
||||
/** 阶段1:上传图片后仅做 VLM 图片分析 + 可选参考视频风格分析,完成后状态=image_analyzed */
|
||||
export function analyzeViralImages(payload: AnalyzeImagesRequest) {
|
||||
return apiClient.post<ViralVideoJob>("/viral-video/analyze-images", payload).then((r) => r.data)
|
||||
}
|
||||
|
||||
/** 阶段2:用户填完营销参数后生成文案+分镜+合规审核,完成后状态=copy_generated,返回 copy_result */
|
||||
export function generateViralCopy(id: string, payload: GenerateCopyRequest) {
|
||||
return apiClient
|
||||
.post<ViralVideoJob>(`/viral-video/${id}/generate-copy`, payload)
|
||||
.then((r) => r.data)
|
||||
}
|
||||
|
||||
/** 阶段3:用户确认/编辑文案后开始 TTS→渲染→上传,完成后状态=completed */
|
||||
export function confirmViralCopy(id: string, payload: ConfirmCopyRequest = {}) {
|
||||
return apiClient
|
||||
.post<ViralVideoJob>(`/viral-video/${id}/confirm-copy`, payload)
|
||||
.then((r) => r.data)
|
||||
}
|
||||
|
||||
@@ -12,14 +12,6 @@ export const STYLE_STRENGTHS: { value: StyleStrength; label: string }[] = [
|
||||
{ value: "strict", label: "像素级复刻" },
|
||||
]
|
||||
|
||||
/** v1.6 前端时长下拉选项(5/10/15/20/25/30秒) */
|
||||
export const VALID_DURATIONS = [5, 10, 15, 20, 25, 30] as const
|
||||
export type VideoDuration = (typeof VALID_DURATIONS)[number]
|
||||
|
||||
/** v1.6 支持的画幅比例 */
|
||||
export const VALID_RATIOS = ["9:16", "16:9", "1:1"] as const
|
||||
export type VideoRatio = (typeof VALID_RATIOS)[number]
|
||||
|
||||
export type ViralVideoStatus =
|
||||
| "pending"
|
||||
| "running"
|
||||
@@ -31,25 +23,39 @@ export type ViralVideoStatus =
|
||||
| "cancelled"
|
||||
|
||||
/**
|
||||
* v1.6 后端流水线阶段。单次 Seedance 出片版:
|
||||
* image_analysis → video_analysis(可选) → intent_parsing → script_generation → review → tts → rendering → uploading
|
||||
* 后端流水线阶段字符串。前端不展示逐阶段进度列表,仅保留类型
|
||||
* 用于轮询时判断当前在哪个大阶段(分析中 vs 文案生成 vs 视频生成)。
|
||||
*/
|
||||
export type ViralVideoStage =
|
||||
| "image_analysis"
|
||||
| "video_analysis"
|
||||
| "intent_parsing"
|
||||
| "script_generation"
|
||||
| "copy_fusion"
|
||||
| "storyboard"
|
||||
| "review"
|
||||
| "tts"
|
||||
| "bgm_select"
|
||||
| "rendering"
|
||||
| "musetalk"
|
||||
| "uploading"
|
||||
|
||||
/** 图片+视频分析阶段:属于「分析图片」按钮的范围 */
|
||||
const IMAGE_ANALYSIS_STAGES = new Set<ViralVideoStage>(["image_analysis", "video_analysis"])
|
||||
/** 编导脚本阶段:属于「生成文案」按钮的范围 */
|
||||
const COPY_STAGES = new Set<ViralVideoStage>(["intent_parsing", "script_generation", "review"])
|
||||
/** 视频生成阶段:属于「开始生成视频」按钮的范围(v1.6: TTS+单次Seedance+上传) */
|
||||
const VIDEO_STAGES = new Set<ViralVideoStage>(["tts", "rendering", "uploading"])
|
||||
/** 文案相关阶段:属于「生成文案」按钮的范围 */
|
||||
const COPY_STAGES = new Set<ViralVideoStage>([
|
||||
"intent_parsing",
|
||||
"copy_fusion",
|
||||
"storyboard",
|
||||
"review",
|
||||
])
|
||||
/** 视频相关阶段:属于「开始生成视频」按钮的范围 */
|
||||
const VIDEO_STAGES = new Set<ViralVideoStage>([
|
||||
"tts",
|
||||
"bgm_select",
|
||||
"rendering",
|
||||
"musetalk",
|
||||
"uploading",
|
||||
])
|
||||
|
||||
export function isImageAnalysisStage(stage: ViralVideoStage | undefined): boolean {
|
||||
return !!stage && IMAGE_ANALYSIS_STAGES.has(stage)
|
||||
@@ -60,7 +66,7 @@ export function isCopyStage(stage: ViralVideoStage | undefined): boolean {
|
||||
export function isVideoStage(stage: ViralVideoStage | undefined): boolean {
|
||||
return !!stage && VIDEO_STAGES.has(stage)
|
||||
}
|
||||
/** 兼容旧调用:分析图片+生成文案 的所有前置阶段 */
|
||||
/** 兼容旧调用:旧的 isAnalysisStage 视为「图片分析+文案」的所有前置阶段 */
|
||||
export function isAnalysisStage(stage: ViralVideoStage | undefined): boolean {
|
||||
return isImageAnalysisStage(stage) || isCopyStage(stage)
|
||||
}
|
||||
@@ -68,20 +74,12 @@ export function isAnalysisStage(stage: ViralVideoStage | undefined): boolean {
|
||||
/** 单张图片 VLM 识别出的商品信息 */
|
||||
export interface ImageProductAnalysis {
|
||||
name?: string
|
||||
category?: string
|
||||
brand?: string
|
||||
colors?: string[]
|
||||
material_or_texture?: string
|
||||
key_features?: string[]
|
||||
visual_style?: string
|
||||
scene?: string
|
||||
target_audience_hint?: string
|
||||
text_on_image?: string
|
||||
/** 旧字段兼容 */
|
||||
spec?: string
|
||||
brand?: string
|
||||
features?: string[] | string
|
||||
label_text?: string
|
||||
selling_points?: string
|
||||
selling_points?: string[]
|
||||
scene?: string
|
||||
image_index?: number
|
||||
}
|
||||
|
||||
@@ -89,50 +87,10 @@ export interface ImageAnalysisResult {
|
||||
products?: ImageProductAnalysis[]
|
||||
}
|
||||
|
||||
/** v1.6 编导分镜脚本 - 单镜头 */
|
||||
export interface ShotScript {
|
||||
/** 时间区间,如 "0-3秒" */
|
||||
time_range?: string
|
||||
/** 景别/角度/运镜,如 "近景俯拍45度,缓慢推镜" */
|
||||
shot_type_angle_movement?: string
|
||||
/** 场景描述+对白 */
|
||||
scene_and_dialogue?: string
|
||||
/** 人物动作/表情/物品操作细节 */
|
||||
action_details?: string
|
||||
/** 环境音+BGM提示 */
|
||||
audio_bgm?: string
|
||||
/** 转场方式(硬切/淡入淡出/叠化/结束) */
|
||||
transition?: string
|
||||
/** 参考图片索引(0-based,对应上传产品图数组) */
|
||||
reference_image_index?: number | null
|
||||
}
|
||||
|
||||
/** v1.6 编导分镜脚本 - 总览 */
|
||||
export interface CopyResultOverview {
|
||||
theme?: string
|
||||
total_duration?: number
|
||||
aspect_ratio?: string
|
||||
}
|
||||
|
||||
/** v1.6 编导分镜脚本(核心输出结构,给 Seedance 做 prompt,给 TTS 取 voiceover_script) */
|
||||
export interface CopyResult {
|
||||
overview?: CopyResultOverview
|
||||
/** 整体场景+光线描述 */
|
||||
scene_and_lighting?: string
|
||||
/** 逐镜头时间轴 */
|
||||
shots?: ShotScript[]
|
||||
/** 硬性约束(禁止字幕/水印/变形等) */
|
||||
hard_constraints?: string[]
|
||||
/** 负面提示词 */
|
||||
negative_prompts?: string[]
|
||||
/** 完整口播稿(纯文本,用于 TTS 合成) */
|
||||
voiceover_script?: string
|
||||
/** 向后兼容:= voiceover_script */
|
||||
final_copy?: string
|
||||
/** 向后兼容:= voiceover_script */
|
||||
suggested_copy?: string
|
||||
title?: string
|
||||
/** v1.5 旧字段兼容(老数据降级时可能出现) */
|
||||
scenes?: Array<{ shot: string; narration: string; duration?: number }>
|
||||
}
|
||||
|
||||
@@ -146,18 +104,13 @@ export interface StyleTemplate {
|
||||
}
|
||||
|
||||
export interface IntentResult {
|
||||
intent?: string
|
||||
key_messages?: string[]
|
||||
tone?: string
|
||||
target_emotion?: string
|
||||
call_to_action?: string
|
||||
product: string
|
||||
selling_points: string[]
|
||||
target_audience: string
|
||||
tone: string
|
||||
structure: string
|
||||
duration: number
|
||||
suggested_title?: string
|
||||
/** v1.5 旧字段兼容 */
|
||||
product?: string
|
||||
selling_points?: string[]
|
||||
target_audience?: string
|
||||
structure?: string
|
||||
duration?: number
|
||||
suggested_copy?: string
|
||||
}
|
||||
|
||||
@@ -170,9 +123,7 @@ export interface ViralVideoJob {
|
||||
style_template_id?: string
|
||||
style_guide?: string | Record<string, unknown>
|
||||
user_copy_text?: string
|
||||
/** v1.6: = copy_result.voiceover_script(从 copy_result 派生,向后兼容) */
|
||||
final_copy_text?: string
|
||||
generated_copy_text?: string
|
||||
fusion_level?: FusionLevel
|
||||
voice_id?: string
|
||||
voice_mode?: "global" | "per_video"
|
||||
@@ -180,17 +131,8 @@ export interface ViralVideoJob {
|
||||
bgm_preference?: string
|
||||
intent_result?: IntentResult
|
||||
intent_text?: string
|
||||
/** v1.6 编导分镜脚本(核心产物) */
|
||||
copy_result?: CopyResult
|
||||
/** 向后兼容:= copy_result.shots */
|
||||
storyboard?: ShotScript[]
|
||||
image_analysis?: ImageAnalysisResult
|
||||
/** 视频比例:9:16 / 16:9 / 1:1,默认 9:16 */
|
||||
video_ratio?: string
|
||||
/** Seedance 模型 ID(空=后端默认) */
|
||||
video_model?: string
|
||||
/** 视频时长(秒,5-30,默认15) */
|
||||
duration?: number
|
||||
progress_stage?: ViralVideoStage
|
||||
progress_percent?: number
|
||||
progress_message?: string
|
||||
@@ -220,7 +162,6 @@ export interface GenerateViralVideoRequest {
|
||||
persona_id?: string
|
||||
viral_structure?: string
|
||||
marketing_purpose?: string
|
||||
/** 视频时长(5-30秒,默认15) */
|
||||
duration?: number
|
||||
video_model?: string
|
||||
video_ratio?: string
|
||||
@@ -234,84 +175,3 @@ export interface HistoryResponse {
|
||||
page: number
|
||||
page_size: number
|
||||
}
|
||||
|
||||
/** v1.6 阶段1请求:图片/视频分析(POST /viral-video/analyze-images) */
|
||||
export interface AnalyzeImagesRequest {
|
||||
images: string[]
|
||||
reference_video_url?: string
|
||||
style_template_id?: string
|
||||
style_strength?: StyleStrength
|
||||
/** TTS 音色 ID(STEP1 已选音色时传) */
|
||||
voice_id?: string
|
||||
/** 音色来源:preset | library | clone | upload */
|
||||
voice_source?: "preset" | "library" | "clone" | "upload"
|
||||
/** Seedance 视频比例:9:16 | 16:9 | 1:1 */
|
||||
video_ratio?: string
|
||||
/** Seedance 模型 ID(空则使用服务端默认) */
|
||||
video_model?: string
|
||||
/** 视频时长(秒,5-30,默认15) */
|
||||
duration?: number
|
||||
}
|
||||
|
||||
/** v1.6 阶段2请求:填完营销参数后生成编导分镜脚本(POST /viral-video/{id}/generate-copy) */
|
||||
export interface GenerateCopyRequest {
|
||||
industry?: string
|
||||
target_customer?: string
|
||||
persona_id?: string
|
||||
viral_structure?: string
|
||||
marketing_purpose?: string
|
||||
bgm_preference?: string
|
||||
/** 视频时长(秒,5-30,默认15) */
|
||||
duration?: number
|
||||
user_copy_text?: string
|
||||
fusion_level?: FusionLevel
|
||||
reference_audio_path?: string
|
||||
reference_video_url?: string
|
||||
style_strength?: StyleStrength
|
||||
style_template_id?: string
|
||||
style_guide?: string | Record<string, unknown>
|
||||
/** TTS 音色 ID(优先级高于 persona_id) */
|
||||
voice_id?: string
|
||||
/** 音色来源:preset | library | clone | upload */
|
||||
voice_source?: "preset" | "library" | "clone" | "upload"
|
||||
/** Seedance 视频比例(9:16/16:9/1:1 等) */
|
||||
video_ratio?: string
|
||||
/** Seedance 模型 ID(空则使用服务端默认) */
|
||||
video_model?: string
|
||||
}
|
||||
|
||||
/** 视频模型描述(GET /viral-video/models) */
|
||||
export interface ViralVideoModel {
|
||||
key: string
|
||||
display_name: string
|
||||
supports_audio: boolean
|
||||
supported_resolutions: string[]
|
||||
max_duration: number
|
||||
/** 计费模式(可选):per_second / per_video / token 等 */
|
||||
billing_mode?: string
|
||||
is_default?: boolean
|
||||
}
|
||||
|
||||
/** GET /viral-video/models 响应包装 */
|
||||
export interface ViralVideoModelsResponse {
|
||||
models: ViralVideoModel[]
|
||||
}
|
||||
|
||||
/** v1.6 阶段3请求:用户确认/编辑口播文案后开始单次 Seedance 出片(POST /viral-video/{id}/confirm-copy) */
|
||||
export interface ConfirmCopyRequest {
|
||||
/** 用户编辑后的口播文案;为空则使用 AI 生成的 voiceover_script */
|
||||
edited_copy?: string
|
||||
/** 视频模型 key,覆盖默认 */
|
||||
video_model?: string
|
||||
}
|
||||
|
||||
/** 旧分镜片段结构(保留兼容;新代码请使用 ShotScript) */
|
||||
export interface StoryboardSegment {
|
||||
order: number
|
||||
type: string
|
||||
description: string
|
||||
text: string
|
||||
duration: number
|
||||
ken_burns?: string
|
||||
transition?: string
|
||||
}
|
||||
|
||||
@@ -80,8 +80,6 @@ const AiAvatarPage: React.FC = () => {
|
||||
const [finalizeLoading, setFinalizeLoading] = useState(false)
|
||||
|
||||
/* ── 对口型轮询 ── */
|
||||
/** 对口型轮询总时长上限(10分钟):超过后停止轮询并提示去历史记录查看 */
|
||||
const LIPSYNC_POLL_MAX_MS = 10 * 60 * 1000
|
||||
const lipsyncTimerRef = useRef<ReturnType<typeof setInterval> | null>(null)
|
||||
/* ── 渲染进度轮询 ── */
|
||||
const renderTimerRef = useRef<ReturnType<typeof setInterval> | null>(null)
|
||||
@@ -272,24 +270,7 @@ const AiAvatarPage: React.FC = () => {
|
||||
// 如果是预合成模式,后端会同步把状态置为 submitted(甚至可能已返回 running),
|
||||
// 但仍需轮询等 completed
|
||||
if (lipsyncTimerRef.current) clearInterval(lipsyncTimerRef.current)
|
||||
// 轮询间隔 5 秒;单请求超时 5 分钟(见 api/aiAvatar.ts);总轮询上限 10 分钟
|
||||
// 单次请求失败/超时不中断轮询,继续下一轮;超过总上限后停止并提示用户去历史记录查看
|
||||
lipsyncTimerRef.current = setInterval(async () => {
|
||||
// 总时长保护:超过 10 分钟停止轮询
|
||||
if (Date.now() - lipsyncStartAtRef.current > LIPSYNC_POLL_MAX_MS) {
|
||||
if (lipsyncTimerRef.current) {
|
||||
clearInterval(lipsyncTimerRef.current)
|
||||
lipsyncTimerRef.current = null
|
||||
}
|
||||
if (lipsyncTickRef.current) {
|
||||
clearInterval(lipsyncTickRef.current)
|
||||
lipsyncTickRef.current = null
|
||||
}
|
||||
setLipsyncStatus("failed")
|
||||
setLipsyncErrorMessage("渲染时间较长,请稍后在历史记录中查看")
|
||||
message.warning("对口型渲染时间较长,已停止自动刷新,请稍后在历史记录中查看")
|
||||
return
|
||||
}
|
||||
try {
|
||||
const updated = await getLipsyncJob(job.id)
|
||||
state.setLipsyncJob(updated)
|
||||
@@ -315,10 +296,9 @@ const AiAvatarPage: React.FC = () => {
|
||||
setLipsyncErrorMessage(updated.error_message || "对口型生成失败")
|
||||
}
|
||||
} catch (err) {
|
||||
// 单次轮询失败(含 timeout):不中断轮询,打印日志后等下一轮
|
||||
console.warn("[对口型] 轮询请求失败,将继续下一轮:", err)
|
||||
console.error("[对口型] 轮询错误:", err)
|
||||
}
|
||||
}, 5000)
|
||||
}, 3000)
|
||||
} catch (err) {
|
||||
console.error("[对口型] 创建失败:", {
|
||||
status: (err as { response?: { status?: number } })?.response?.status,
|
||||
|
||||
@@ -72,8 +72,7 @@ export const previewTts = async (data: {
|
||||
}
|
||||
|
||||
export const getLipsyncJob = async (id: string): Promise<LipsyncJob> => {
|
||||
// MuseTalk 渲染 8s 视频约 54s + 排队时间,给足 5 分钟超时避免单次轮询 AxiosError 中断
|
||||
const response = await apiClient.get<LipsyncJob>(`/lipsync/jobs/${id}`, { timeout: 300_000 })
|
||||
const response = await apiClient.get<LipsyncJob>(`/lipsync/jobs/${id}`, { timeout: 60000 })
|
||||
return response.data
|
||||
}
|
||||
|
||||
@@ -92,10 +91,7 @@ export const submitRender = async (data: {
|
||||
}
|
||||
|
||||
export const getRenderJob = async (jobId: string): Promise<RenderJob> => {
|
||||
// 渲染链路(对口型+B-roll+标题+合成+上传)耗时较长,给足 5 分钟超时
|
||||
const response = await apiClient.get<RenderJob>(`/ai-avatar/render/${jobId}`, {
|
||||
timeout: 300_000,
|
||||
})
|
||||
const response = await apiClient.get<RenderJob>(`/ai-avatar/render/${jobId}`, { timeout: 60000 })
|
||||
return response.data
|
||||
}
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -199,16 +199,7 @@ export default function AssetPickerModal({
|
||||
<div className="vv-empty-icon">
|
||||
{kind === "image" ? "🖼️" : kind === "video" ? "🎬" : "🎵"}
|
||||
</div>
|
||||
{kind === "voice" ? (
|
||||
<>
|
||||
<div style={{ marginTop: 8, fontSize: 13 }}>暂无配音素材</div>
|
||||
<div style={{ marginTop: 4, fontSize: 12, color: "#9ca3af" }}>
|
||||
请先在「配音/我的音色」中上传音频文件,或在素材库管理中添加
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>该素材库暂无{KIND_LABEL[kind]}素材</>
|
||||
)}
|
||||
该素材库暂无{KIND_LABEL[kind]}素材
|
||||
</div>
|
||||
) : (
|
||||
<div className={`vv-asset-thumbs vv-asset-${kind}`}>
|
||||
@@ -230,12 +221,7 @@ export default function AssetPickerModal({
|
||||
)}
|
||||
{active && <div className="vv-thumb-check">✓</div>}
|
||||
<div className="vv-thumb-name" title={asset.name}>
|
||||
<span className="vv-thumb-name-txt">{asset.name}</span>
|
||||
{kind === "voice" &&
|
||||
typeof asset.duration === "number" &&
|
||||
asset.duration > 0 && (
|
||||
<span className="vv-thumb-dur">{Math.round(asset.duration)}s</span>
|
||||
)}
|
||||
{asset.name}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
@@ -245,11 +231,12 @@ export default function AssetPickerModal({
|
||||
</div>
|
||||
{multiple && (
|
||||
<div className="vv-modal-foot">
|
||||
<button className="vv-btn vv-btn-ghost" onClick={onClose}>
|
||||
<button className="vv-btn vv-btn-ghost vv-btn-sm" onClick={onClose}>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
className="vv-btn vv-btn-primary"
|
||||
style={{ width: "auto", marginTop: 0, padding: "8px 18px" }}
|
||||
onClick={handleConfirm}
|
||||
disabled={picked.size === 0}
|
||||
>
|
||||
|
||||
@@ -34,63 +34,83 @@ interface Props {
|
||||
|
||||
/** 兜底 mock 音色(后端 /api/v1/tts/presets 返回字段不够时使用) */
|
||||
const MOCK_VOICES: PresetVoice[] = [
|
||||
// ⚠️ 兜底 mock,仅在 /voices/presets 接口不可达时使用;ID 必须与后端
|
||||
// packages/domain/preset_voices.py PRESET_VOICES 的 voice_id 对齐(v3后缀)
|
||||
{
|
||||
id: "longxiaochun_v3",
|
||||
id: "long-xiaochun",
|
||||
name: "龙小淳",
|
||||
gender: "female",
|
||||
category: "女声",
|
||||
desc: "知性积极女声,适合语音助手",
|
||||
},
|
||||
{
|
||||
id: "longxiaoxia_v3",
|
||||
id: "long-xiaoxia",
|
||||
name: "龙小夏",
|
||||
gender: "female",
|
||||
category: "女声",
|
||||
desc: "沉稳权威女声,适合新闻播报",
|
||||
},
|
||||
{
|
||||
id: "longsanshu_v3",
|
||||
id: "long-xiaoyan",
|
||||
name: "龙小颜",
|
||||
gender: "female",
|
||||
category: "女声",
|
||||
desc: "温柔甜美女声,适合情感口播",
|
||||
},
|
||||
{
|
||||
id: "long-xiaotong",
|
||||
name: "龙小彤",
|
||||
gender: "female",
|
||||
category: "女声",
|
||||
desc: "活力少女音,适合短视频带货",
|
||||
},
|
||||
{
|
||||
id: "long-sanshu",
|
||||
name: "龙三叔",
|
||||
gender: "male",
|
||||
category: "男声",
|
||||
desc: "沉稳质感男声,适合有声书",
|
||||
},
|
||||
{
|
||||
id: "longyue_v3",
|
||||
id: "long-xiaogang",
|
||||
name: "龙小刚",
|
||||
gender: "male",
|
||||
category: "男声",
|
||||
desc: "阳光活力男声,适合解说",
|
||||
},
|
||||
{
|
||||
id: "long-xiaocheng",
|
||||
name: "龙小诚",
|
||||
gender: "male",
|
||||
category: "男声",
|
||||
desc: "磁性商务男声,适合品牌宣传",
|
||||
},
|
||||
{
|
||||
id: "long-xiaozhi",
|
||||
name: "龙小智",
|
||||
gender: "child",
|
||||
category: "童声",
|
||||
desc: "可爱童声,适合亲子内容",
|
||||
},
|
||||
{
|
||||
id: "long-yue",
|
||||
name: "龙悦",
|
||||
gender: "female",
|
||||
category: "女声",
|
||||
desc: "温暖磁性女声,适合广告配音",
|
||||
category: "情绪",
|
||||
desc: "温柔治愈女声,适合睡前/助眠",
|
||||
},
|
||||
{
|
||||
id: "longshu_v3",
|
||||
name: "龙书",
|
||||
id: "long-xiaodong",
|
||||
name: "龙晓东",
|
||||
gender: "male",
|
||||
category: "男声",
|
||||
desc: "沉稳青年男声,适合教育讲解",
|
||||
category: "方言",
|
||||
desc: "东北方言男声,接地气",
|
||||
},
|
||||
{ id: "long-xiaoling", name: "龙小玲", gender: "female", category: "方言", desc: "粤语女声" },
|
||||
{
|
||||
id: "longyingjing_v3",
|
||||
name: "龙应静",
|
||||
id: "long-xiaoxiao-neural",
|
||||
name: "晓晓",
|
||||
gender: "female",
|
||||
category: "女声",
|
||||
desc: "低调冷静女声,适合纪录片解说",
|
||||
},
|
||||
{
|
||||
id: "longshuo_v3",
|
||||
name: "龙硕",
|
||||
gender: "male",
|
||||
category: "男声",
|
||||
desc: "博才干练男声,适合科技类内容",
|
||||
},
|
||||
{
|
||||
id: "longtian_v3",
|
||||
name: "龙甜",
|
||||
gender: "female",
|
||||
category: "女声",
|
||||
desc: "活泼女声,适合短视频配音",
|
||||
desc: "温柔女声",
|
||||
},
|
||||
]
|
||||
|
||||
@@ -158,22 +178,23 @@ const PresetVoicePickerModal: React.FC<Props> = ({
|
||||
|
||||
// 合并真实数据和 mock:如果真实数据 gender/category 缺失,用 mock 兜底
|
||||
const allVoices: PresetVoice[] = useMemo(() => {
|
||||
// 真实 API 返回的 voice_id 以 API 为准(如 longxiaochun_v3),前端不做硬编码覆盖
|
||||
const realList: PresetVoice[] = (voices || []).map((v) => {
|
||||
// 按 id 精确匹配 mock 获取补充元信息(id 即 voice_id,唯一稳定键)
|
||||
const mockMatch = MOCK_VOICES.find((m) => m.id === v.id)
|
||||
// 按 name 模糊匹配 mock 获取补充信息
|
||||
const mockMatch = MOCK_VOICES.find(
|
||||
(m) => v.name?.includes(m.name.slice(1)) || m.name.includes(v.name?.slice(0, 2) || "___"),
|
||||
)
|
||||
return {
|
||||
...v,
|
||||
gender: v.gender || mockMatch?.gender,
|
||||
category:
|
||||
v.category ||
|
||||
mockMatch?.category ||
|
||||
(v.gender === "female" ? "女声" : v.gender === "male" ? "男声" : "其他"),
|
||||
(v.gender === "female" ? "女声" : v.gender === "male" ? "男声" : undefined),
|
||||
desc: v.desc || mockMatch?.desc,
|
||||
sample_audio_url: v.sample_audio_url,
|
||||
}
|
||||
})
|
||||
// 如果没有真实数据,使用兜底 mock(接口失败时)
|
||||
// 如果没有真实数据,使用 mock
|
||||
return realList.length > 0 ? realList : MOCK_VOICES
|
||||
}, [voices])
|
||||
|
||||
|
||||
@@ -1,45 +0,0 @@
|
||||
import { describe, it, expect } from "vitest"
|
||||
import { getErrorMessage, isErrorMsgShown } from "@/api/errors"
|
||||
|
||||
describe("api/errors", () => {
|
||||
it("returns string error directly", () => {
|
||||
expect(getErrorMessage("plain")).toBe("plain")
|
||||
})
|
||||
it("uses Error.message", () => {
|
||||
expect(getErrorMessage(new Error("boom"))).toBe("boom")
|
||||
})
|
||||
it("returns fallback for empty/unknown", () => {
|
||||
expect(getErrorMessage(null)).toBe("操作失败,请稍后重试")
|
||||
expect(getErrorMessage(undefined, "f")).toBe("f")
|
||||
})
|
||||
it("reads axios-like response.data.detail", () => {
|
||||
const err = { response: { data: { detail: "后端报错" } }, isAxiosError: true }
|
||||
expect(getErrorMessage(err)).toContain("后端报错")
|
||||
})
|
||||
it("reads axios-like response.data.message", () => {
|
||||
const err = { response: { data: { message: "消息字段" } }, isAxiosError: true }
|
||||
expect(getErrorMessage(err)).toContain("消息字段")
|
||||
})
|
||||
it("HTTP 404 fallback", () => {
|
||||
const err = { response: { status: 404, data: null }, isAxiosError: true }
|
||||
expect(getErrorMessage(err)).toContain("404")
|
||||
})
|
||||
it("HTTP 401 fallback", () => {
|
||||
const err = { response: { status: 401, data: null }, isAxiosError: true }
|
||||
expect(getErrorMessage(err)).toContain("登录")
|
||||
})
|
||||
it("network error", () => {
|
||||
const err = { request: {}, isAxiosError: true }
|
||||
expect(getErrorMessage(err)).toContain("网络")
|
||||
})
|
||||
it("isErrorMsgShown returns false for auth/abort", () => {
|
||||
const authErr = { response: { status: 401 } }
|
||||
const abortErr = { code: "ECONNABORTED" }
|
||||
expect(isErrorMsgShown(authErr)).toBe(false)
|
||||
expect(isErrorMsgShown(abortErr)).toBe(false)
|
||||
const e: any = new Error("x")
|
||||
e.__msgShown = true
|
||||
expect(isErrorMsgShown(e)).toBe(true)
|
||||
expect(isErrorMsgShown(new Error("x"))).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -1,226 +0,0 @@
|
||||
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest"
|
||||
import {
|
||||
generateViralVideo,
|
||||
getViralVideoJob,
|
||||
confirmViralVideoIntent,
|
||||
retryViralVideo,
|
||||
getViralVideoHistory,
|
||||
getViralStyleTemplates,
|
||||
analyzeViralStyle,
|
||||
mockImageAnalysis,
|
||||
mockGenerateCopy,
|
||||
analyzeViralImages,
|
||||
generateViralCopy,
|
||||
confirmViralCopy,
|
||||
} from "@/api/viral-video"
|
||||
import {
|
||||
VALID_DURATIONS,
|
||||
VALID_RATIOS,
|
||||
isVideoStage,
|
||||
isImageAnalysisStage,
|
||||
isCopyStage,
|
||||
isAnalysisStage,
|
||||
} from "@/api/viral-video/types"
|
||||
|
||||
const mockGet = vi.fn()
|
||||
const mockPost = vi.fn()
|
||||
|
||||
vi.mock("@/api/client", () => ({
|
||||
default: {
|
||||
get: (...args: unknown[]) => mockGet(...args),
|
||||
post: (...args: unknown[]) => mockPost(...args),
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock("antd", () => ({ message: { error: vi.fn(), success: vi.fn() } }))
|
||||
|
||||
// 让 setTimeout 同步执行,避免测试等待 1.8s/2.2s
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
vi.clearAllMocks()
|
||||
mockGet.mockResolvedValue({ data: {} })
|
||||
mockPost.mockResolvedValue({ data: {} })
|
||||
})
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
describe("viral-video constants & stage helpers", () => {
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it("VALID_DURATIONS/VALID_RATIOS", () => {
|
||||
expect(VALID_DURATIONS).toEqual([5, 10, 15, 20, 25, 30])
|
||||
expect(VALID_RATIOS).toEqual(expect.arrayContaining(["9:16", "16:9", "1:1"]))
|
||||
})
|
||||
|
||||
it("isVideoStage", () => {
|
||||
expect(isVideoStage("tts")).toBe(true)
|
||||
expect(isVideoStage("rendering")).toBe(true)
|
||||
expect(isVideoStage("uploading")).toBe(true)
|
||||
expect(isVideoStage("script_generation")).toBe(false)
|
||||
expect(isVideoStage("completed")).toBe(false)
|
||||
expect(isVideoStage(undefined)).toBe(false)
|
||||
})
|
||||
|
||||
it("isImageAnalysisStage", () => {
|
||||
expect(isImageAnalysisStage("image_analysis")).toBe(true)
|
||||
expect(isImageAnalysisStage("video_analysis")).toBe(true)
|
||||
expect(isImageAnalysisStage("script_generation")).toBe(false)
|
||||
expect(isImageAnalysisStage(undefined)).toBe(false)
|
||||
})
|
||||
|
||||
it("isCopyStage", () => {
|
||||
expect(isCopyStage("intent_parsing")).toBe(true)
|
||||
expect(isCopyStage("script_generation")).toBe(true)
|
||||
expect(isCopyStage("review")).toBe(true)
|
||||
expect(isCopyStage("tts")).toBe(false)
|
||||
})
|
||||
|
||||
it("isAnalysisStage is union", () => {
|
||||
expect(isAnalysisStage("image_analysis")).toBe(true)
|
||||
expect(isAnalysisStage("script_generation")).toBe(true)
|
||||
expect(isAnalysisStage("tts")).toBe(false)
|
||||
expect(isAnalysisStage(undefined)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe("viral-video API wrappers", () => {
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
vi.clearAllMocks()
|
||||
mockGet.mockResolvedValue({ data: {} })
|
||||
mockPost.mockResolvedValue({ data: {} })
|
||||
})
|
||||
|
||||
it("generateViralVideo", async () => {
|
||||
mockPost.mockResolvedValue({ data: { id: "j1" } })
|
||||
const r = generateViralVideo({ images: ["img1"] } as never)
|
||||
vi.runAllTimersAsync()
|
||||
expect(await r).toEqual({ id: "j1" })
|
||||
expect(mockPost).toHaveBeenCalledWith("/viral-video/generate", { images: ["img1"] })
|
||||
})
|
||||
|
||||
it("getViralVideoJob", async () => {
|
||||
mockGet.mockResolvedValue({ data: { id: "j2" } })
|
||||
const r = getViralVideoJob("j2")
|
||||
vi.runAllTimersAsync()
|
||||
expect(await r).toEqual({ id: "j2" })
|
||||
expect(mockGet).toHaveBeenCalledWith("/viral-video/j2")
|
||||
})
|
||||
|
||||
it("confirmViralVideoIntent", async () => {
|
||||
mockPost.mockResolvedValue({ data: { id: "j3" } })
|
||||
const r = confirmViralVideoIntent("j3", { confirmed_copy: "hi" })
|
||||
vi.runAllTimersAsync()
|
||||
await r
|
||||
expect(mockPost).toHaveBeenCalledWith("/viral-video/j3/confirm-intent", {
|
||||
confirmed_copy: "hi",
|
||||
})
|
||||
})
|
||||
|
||||
it("retryViralVideo", async () => {
|
||||
mockPost.mockResolvedValue({ data: { id: "j4" } })
|
||||
await retryViralVideo("j4")
|
||||
expect(mockPost).toHaveBeenCalledWith("/viral-video/j4/retry")
|
||||
})
|
||||
|
||||
it("getViralVideoHistory", async () => {
|
||||
mockGet.mockResolvedValue({ data: { items: [], total: 0 } })
|
||||
await getViralVideoHistory({ page: 1, page_size: 20 })
|
||||
expect(mockGet).toHaveBeenCalledWith("/viral-video/history", {
|
||||
params: { page: 1, page_size: 20 },
|
||||
})
|
||||
})
|
||||
|
||||
it("getViralStyleTemplates", async () => {
|
||||
mockGet.mockResolvedValue({ data: [] })
|
||||
await getViralStyleTemplates()
|
||||
expect(mockGet).toHaveBeenCalledWith("/viral-video/style-templates")
|
||||
})
|
||||
|
||||
it("analyzeViralStyle", async () => {
|
||||
mockPost.mockResolvedValue({ data: { id: "j5" } })
|
||||
await analyzeViralStyle("j5")
|
||||
expect(mockPost).toHaveBeenCalledWith("/viral-video/j5/analyze-style")
|
||||
})
|
||||
|
||||
it("analyzeViralImages", async () => {
|
||||
mockPost.mockResolvedValue({ data: { id: "j6" } })
|
||||
await analyzeViralImages({ images: ["a.png"] } as never)
|
||||
expect(mockPost).toHaveBeenCalledWith("/viral-video/analyze-images", { images: ["a.png"] })
|
||||
})
|
||||
|
||||
it("generateViralCopy", async () => {
|
||||
mockPost.mockResolvedValue({ data: { id: "j7" } })
|
||||
await generateViralCopy("j7", { duration: 15 } as never)
|
||||
expect(mockPost).toHaveBeenCalledWith("/viral-video/j7/generate-copy", { duration: 15 })
|
||||
})
|
||||
|
||||
it("confirmViralCopy", async () => {
|
||||
mockPost.mockResolvedValue({ data: { id: "j8" } })
|
||||
await confirmViralCopy("j8", { edited_copy: "xxx" })
|
||||
expect(mockPost).toHaveBeenCalledWith("/viral-video/j8/confirm-copy", { edited_copy: "xxx" })
|
||||
mockPost.mockClear()
|
||||
await confirmViralCopy("j8")
|
||||
expect(mockPost).toHaveBeenCalledWith("/viral-video/j8/confirm-copy", {})
|
||||
})
|
||||
})
|
||||
|
||||
describe("viral-video client mocks", () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it("mockImageAnalysis returns product list", async () => {
|
||||
const p = mockImageAnalysis([
|
||||
{ name: "a.png" },
|
||||
{ name: "b.jpg" },
|
||||
{ name: "c.webp" },
|
||||
{ name: "d.png" },
|
||||
])
|
||||
vi.advanceTimersByTime(2000)
|
||||
const r = await p
|
||||
expect(r.products).toHaveLength(3)
|
||||
expect(r.products[0].image_index).toBe(0)
|
||||
expect(r.products[0].brand).toBe("示例品牌")
|
||||
expect(r.products[1].spec).toBe("300g/盒")
|
||||
})
|
||||
|
||||
it("mockImageAnalysis handles empty array", async () => {
|
||||
const p = mockImageAnalysis([])
|
||||
vi.advanceTimersByTime(2000)
|
||||
const r = await p
|
||||
expect(r.products).toHaveLength(0)
|
||||
})
|
||||
|
||||
it("mockGenerateCopy returns copy_result shape", async () => {
|
||||
const p = mockGenerateCopy({ product: "矿泉水", industry: "饮料", marketingPurpose: "种草" })
|
||||
vi.advanceTimersByTime(3000)
|
||||
const r = await p
|
||||
expect(r.title).toContain("种草")
|
||||
expect(r.title).toContain("矿泉水")
|
||||
expect(r.final_copy.length).toBeGreaterThan(50)
|
||||
expect(r.suggested_copy).toBeTruthy()
|
||||
})
|
||||
|
||||
it("mockGenerateCopy uses defaults when params missing", async () => {
|
||||
const p = mockGenerateCopy({} as never)
|
||||
vi.advanceTimersByTime(3000)
|
||||
const r = await p
|
||||
expect(r.title).toContain("品牌种草")
|
||||
expect(r.final_copy).toContain("这款产品")
|
||||
})
|
||||
})
|
||||
@@ -1,21 +0,0 @@
|
||||
import { describe, it, expect } from "vitest"
|
||||
import { getGenerationPhase } from "@/pages/generate/hooks/generate-video/phase"
|
||||
|
||||
describe("getGenerationPhase", () => {
|
||||
it("returns 分析素材与配置 for p<20", () => {
|
||||
expect(getGenerationPhase(0)).toEqual({ label: "分析素材与配置", icon: "🔍" })
|
||||
expect(getGenerationPhase(19).label).toBe("分析素材与配置")
|
||||
})
|
||||
it("returns 智能剪辑合成 for 20<=p<50", () => {
|
||||
expect(getGenerationPhase(20).label).toBe("智能剪辑合成")
|
||||
expect(getGenerationPhase(49).label).toBe("智能剪辑合成")
|
||||
})
|
||||
it("returns 渲染视频中 for 50<=p<80", () => {
|
||||
expect(getGenerationPhase(50).label).toBe("渲染视频中")
|
||||
expect(getGenerationPhase(79).label).toBe("渲染视频中")
|
||||
})
|
||||
it("returns 即将完成 for p>=80", () => {
|
||||
expect(getGenerationPhase(80)).toEqual({ label: "即将完成", icon: "✨" })
|
||||
expect(getGenerationPhase(100).label).toBe("即将完成")
|
||||
})
|
||||
})
|
||||
@@ -1,26 +0,0 @@
|
||||
import { describe, it, expect, vi, afterEach } from "vitest"
|
||||
import { formatDuration, formatFileSize, formatDate } from "@/pages/products/detailUtils"
|
||||
|
||||
describe("products/detailUtils", () => {
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
it("formatDuration", () => {
|
||||
expect(formatDuration(0)).toBe("00:00")
|
||||
expect(formatDuration(-1)).toBe("00:00")
|
||||
expect(formatDuration(5)).toBe("00:05")
|
||||
expect(formatDuration(65)).toBe("01:05")
|
||||
expect(formatDuration(3600)).toBe("60:00")
|
||||
})
|
||||
it("formatFileSize MB/GB", () => {
|
||||
expect(formatFileSize(0)).toBe("-")
|
||||
expect(formatFileSize(-1)).toBe("-")
|
||||
expect(formatFileSize(5.3)).toBe("5.3 MB")
|
||||
expect(formatFileSize(2048)).toBe("2.00 GB")
|
||||
})
|
||||
it("formatDate returns zh-CN format", () => {
|
||||
vi.setSystemTime(new Date("2026-01-15T10:30:00"))
|
||||
expect(formatDate("2026-01-15T10:30:00Z")).toMatch(/2026/)
|
||||
expect(formatDate("")).toBe("-")
|
||||
})
|
||||
})
|
||||
@@ -1,54 +0,0 @@
|
||||
import { describe, it, expect, beforeEach, vi, afterEach } from "vitest"
|
||||
import { renderHook, act } from "@testing-library/react"
|
||||
import { useViralVideoPolling } from "@/pages/viral-video/hooks/useViralVideoPolling"
|
||||
|
||||
const getViralVideoJobMock = vi.fn()
|
||||
vi.mock("@/api/viral-video", () => ({
|
||||
getViralVideoJob: (...args: unknown[]) => getViralVideoJobMock(...args),
|
||||
}))
|
||||
|
||||
describe("useViralVideoPolling", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.useFakeTimers()
|
||||
})
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it("不传入 jobId 时不发起请求", () => {
|
||||
renderHook(() => useViralVideoPolling(null, vi.fn()))
|
||||
expect(getViralVideoJobMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("传入 jobId 后立即调用 getViralVideoJob", () => {
|
||||
getViralVideoJobMock.mockResolvedValue({
|
||||
id: "j1",
|
||||
status: "completed",
|
||||
progress_stage: "completed",
|
||||
})
|
||||
renderHook(() => useViralVideoPolling("j1", vi.fn()))
|
||||
expect(getViralVideoJobMock).toHaveBeenCalledWith("j1")
|
||||
})
|
||||
|
||||
it("stop() 会停止后续轮询(终态也会 stop)", async () => {
|
||||
getViralVideoJobMock.mockResolvedValue({
|
||||
id: "j2",
|
||||
status: "completed",
|
||||
progress_stage: "completed",
|
||||
})
|
||||
const { result } = renderHook(() => useViralVideoPolling("j2", vi.fn(), { intervalMs: 50 }))
|
||||
// 等第一次 promise 完成
|
||||
await act(async () => {
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
})
|
||||
// 终态后不会再调度新请求
|
||||
const calls = getViralVideoJobMock.mock.calls.length
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(2000)
|
||||
})
|
||||
expect(getViralVideoJobMock).toHaveBeenCalledTimes(calls)
|
||||
expect(result.current.stop).toBeTypeOf("function")
|
||||
})
|
||||
})
|
||||
@@ -1,35 +0,0 @@
|
||||
import { describe, it, expect } from "vitest"
|
||||
import {
|
||||
genderLabel,
|
||||
languageLabel,
|
||||
genderClass,
|
||||
formatTime,
|
||||
formatFileSize,
|
||||
} from "@/pages/voices/utils/format"
|
||||
|
||||
describe("voices utils/format", () => {
|
||||
it("genderLabel returns label or falls back to value", () => {
|
||||
expect(genderLabel("female")).toContain("女")
|
||||
expect(genderLabel("male")).toContain("男")
|
||||
expect(genderLabel("unknown" as never)).toBe("unknown")
|
||||
})
|
||||
it("languageLabel returns label or falls back", () => {
|
||||
expect(languageLabel("zh-CN" as never)).toBeTruthy()
|
||||
expect(languageLabel("xx-XX" as never)).toBe("xx-XX")
|
||||
})
|
||||
it("genderClass returns css class", () => {
|
||||
expect(genderClass("female")).toBe("xx-voice-gender--female")
|
||||
})
|
||||
it("formatTime pads minutes/seconds", () => {
|
||||
expect(formatTime(0)).toBe("00:00")
|
||||
expect(formatTime(5)).toBe("00:05")
|
||||
expect(formatTime(65)).toBe("01:05")
|
||||
expect(formatTime(3600)).toBe("60:00")
|
||||
})
|
||||
it("formatFileSize human-readable", () => {
|
||||
expect(formatFileSize(0)).toBe("0 B")
|
||||
expect(formatFileSize(512)).toBe("512 B")
|
||||
expect(formatFileSize(2048)).toBe("2.0 KB")
|
||||
expect(formatFileSize(2 * 1024 * 1024)).toBe("2.0 MB")
|
||||
})
|
||||
})
|
||||
@@ -28,12 +28,11 @@ export default defineConfig({
|
||||
"src/pages/editing-planner/EditingPlanner.tsx",
|
||||
"src/pages/assets/AssetLibrary.tsx",
|
||||
"src/pages/voice-materials/VoiceMaterialLibrary.tsx",
|
||||
"src/pages/viral-video/ViralVideoPage.tsx",
|
||||
],
|
||||
// CI 覆盖率门禁(Phase 4 后提升,逐步逼近目标)
|
||||
// 当前实际:行 ~62% / 分支 ~61% / 函数 ~25%
|
||||
thresholds: {
|
||||
lines: 49,
|
||||
lines: 50,
|
||||
branches: 50,
|
||||
functions: 20,
|
||||
},
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -104,43 +104,6 @@ Staging 当前可以保持 no-op;Production 开启前必须先验证 SMTP/Redi
|
||||
|
||||
---
|
||||
|
||||
## Staging 服务器 Docker 凭证配置
|
||||
|
||||
Staging 服务器(116.62.226.203)需要配置 ACR 和 Gitea Registry 凭证,否则 docker pull 和 Watchtower 自动更新会失败。
|
||||
|
||||
### 凭证文件位置
|
||||
- Docker 配置文件:`/root/.docker/config.json`
|
||||
- 包含两个 registry 的认证信息:
|
||||
- `xiaoxia-registry.cn-hangzhou.cr.aliyuncs.com`(阿里云 ACR)
|
||||
- `git.xiaoxiajianji.com`(Gitea 容器镜像仓库)
|
||||
|
||||
### 服务器迁移后恢复步骤
|
||||
```bash
|
||||
# 1. 登录 ACR
|
||||
docker login xiaoxia-registry.cn-hangzhou.cr.aliyuncs.com -u <ACR_USERNAME>
|
||||
|
||||
# 2. 登录 Gitea Registry
|
||||
docker login git.xiaoxiajianji.com -u xiaoxia -p <GITEA_REGISTRY_TOKEN>
|
||||
|
||||
# 3. 重启 Watchtower(确保挂载最新 config.json)
|
||||
docker restart watchtower
|
||||
```
|
||||
|
||||
### Watchtower 配置
|
||||
- 容器名:`watchtower`
|
||||
- 检查间隔:300 秒(5 分钟)
|
||||
- 监控容器:`xiaoxia-api-staging`、`xiaoxia-worker-staging`、`xiaoxia-web-staging`
|
||||
- 必须挂载 `-v /root/.docker/config.json:/config.json` 才能拉取私有镜像
|
||||
- 必须挂载 `-v /var/run/docker.sock:/var/run/docker.sock` 才能管理容器
|
||||
- 容器使用 `:dev` 稳定 tag,Watchtower 通过检测 `:dev` tag 的 digest 变化来发现更新
|
||||
|
||||
### 镜像 Tag 策略
|
||||
- CI 每次构建推送三种 tag:`${GITHUB_SHA}`(精确版本)、`${GITHUB_REF_NAME}`(分支名)、`:dev`(滚动 tag,仅 develop 分支)
|
||||
- Staging 容器统一使用 `:dev` tag 启动,确保 Watchtower 能自动发现新版本
|
||||
- Migration(alembic)使用 commit SHA tag 执行,不依赖 Watchtower
|
||||
|
||||
---
|
||||
|
||||
## Gitea Actions 约定
|
||||
|
||||
- `develop` 分支触发 staging 部署。
|
||||
|
||||
@@ -30,10 +30,6 @@ COPY deploy/configs/douyin_cookies.txt /app/configs/douyin_cookies.txt
|
||||
# 强制升级 yt-dlp 到最新(抖音反爬经常变更,旧版 cookies 支持失效;#1968/#1963)
|
||||
RUN pip install --no-cache-dir -i https://mirrors.aliyun.com/pypi/simple/ --trusted-host mirrors.aliyun.com --upgrade "yt-dlp>=2026.8.19"
|
||||
|
||||
# API 启动入口(幂等迁移 + uvicorn)—— #2129: watchtower 自动部署兜底
|
||||
COPY infra/docker/entrypoint-api.sh /usr/local/bin/entrypoint-api.sh
|
||||
RUN chmod +x /usr/local/bin/entrypoint-api.sh
|
||||
|
||||
# 设置环境变量
|
||||
ENV PATH="/opt/venv/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"
|
||||
ENV PYTHONPATH=/app:/app/apps/api
|
||||
@@ -45,4 +41,4 @@ HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
|
||||
CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:8000/health', timeout=5)"
|
||||
|
||||
# API 入口点
|
||||
ENTRYPOINT ["/usr/local/bin/entrypoint-api.sh"]
|
||||
CMD ["uvicorn", "apps.api.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||
|
||||
@@ -1,22 +0,0 @@
|
||||
#!/bin/bash
|
||||
# API 启动入口:先幂等执行数据库迁移,再启动传入的 CMD(默认 uvicorn)
|
||||
# 解决 watchtower 自动拉取新镜像后容器重启、未跑 alembic upgrade head 导致新列缺失 500 的问题(#2129)
|
||||
set -e
|
||||
|
||||
cd /app
|
||||
|
||||
echo "[entrypoint-api] Running alembic upgrade head..."
|
||||
if alembic upgrade head; then
|
||||
echo "[entrypoint-api] Migrations ok."
|
||||
else
|
||||
echo "[entrypoint-api] WARNING: alembic upgrade failed, continuing (existing columns should be fine)..." >&2
|
||||
fi
|
||||
|
||||
# 若有显式 CMD(CI 部署时 docker compose run --rm api sh -c '...' 传入),直接 exec 它
|
||||
if [ "$#" -gt 0 ]; then
|
||||
echo "[entrypoint-api] Exec custom command: $*"
|
||||
exec "$@"
|
||||
fi
|
||||
|
||||
echo "[entrypoint-api] Starting uvicorn..."
|
||||
exec uvicorn apps.api.main:app --host 0.0.0.0 --port 8000
|
||||
@@ -18,17 +18,6 @@
|
||||
|
||||
set -e
|
||||
|
||||
# #2129: 幂等执行数据库迁移(watchtower 自动部署兜底)
|
||||
# worker 容器独立启动,不能依赖 API 容器先跑迁移
|
||||
cd /app
|
||||
echo "[entrypoint-worker] Running alembic upgrade head..."
|
||||
if alembic upgrade head; then
|
||||
echo "[entrypoint-worker] Migrations ok."
|
||||
else
|
||||
echo "[entrypoint-worker] WARNING: alembic upgrade failed, continuing to start workers..." >&2
|
||||
fi
|
||||
cd - >/dev/null
|
||||
|
||||
MAX_TASKS="${WORKER_MAX_TASKS_PER_CHILD:-100}"
|
||||
|
||||
# ── 并发计算:显式 env 优先;否则从 WORKER_CONCURRENCY 按比例推导 ──
|
||||
|
||||
@@ -34,7 +34,6 @@ ENV APP_VERSION=$APP_VERSION
|
||||
|
||||
# 复制文件(按变化频率从低到高排序,最大化层缓存命中)
|
||||
COPY alembic.ini /app/alembic.ini
|
||||
COPY alembic/ /app/alembic/
|
||||
COPY migrations/ /app/migrations/
|
||||
COPY packages/ /app/packages/
|
||||
# PR #1844 起,worker 还需要加载 apps.api.app.tasks.lipsync_tts,
|
||||
|
||||
@@ -56,7 +56,7 @@ class UserModel(Base):
|
||||
is_member = Column(Boolean, nullable=False, default=False)
|
||||
member_type = Column(String(20), nullable=True)
|
||||
member_expires_at = Column(DateTime, nullable=True)
|
||||
points_balance = Column(Float, nullable=False, default=0)
|
||||
points_balance = Column(Integer, nullable=False, default=0)
|
||||
created_at = Column(DateTime, nullable=False, default=lambda: datetime.now(UTC))
|
||||
|
||||
|
||||
@@ -775,9 +775,9 @@ class PointsAccountModel(Base):
|
||||
|
||||
id = Column(String(36), primary_key=True)
|
||||
user_id = Column(String(36), nullable=False, unique=True, index=True)
|
||||
balance = Column(Float, nullable=False, default=0)
|
||||
total_earned = Column(Float, nullable=False, default=0)
|
||||
total_spent = Column(Float, nullable=False, default=0)
|
||||
balance = Column(Integer, nullable=False, default=0)
|
||||
total_earned = Column(Integer, nullable=False, default=0)
|
||||
total_spent = Column(Integer, nullable=False, default=0)
|
||||
created_at = Column(DateTime, nullable=False, default=lambda: datetime.now(UTC))
|
||||
updated_at = Column(DateTime, nullable=False, default=lambda: datetime.now(UTC))
|
||||
|
||||
@@ -792,8 +792,8 @@ class PointsTransactionModel(Base):
|
||||
account_id = Column(String(36), nullable=False, index=True)
|
||||
type = Column(String(20), nullable=False, index=True) # earn / spend / refund
|
||||
source = Column(String(50), nullable=False, index=True)
|
||||
amount = Column(Float, nullable=False)
|
||||
balance_after = Column(Float, nullable=False)
|
||||
amount = Column(Integer, nullable=False)
|
||||
balance_after = Column(Integer, nullable=False)
|
||||
description = Column(String(255), nullable=False, default="")
|
||||
ref_id = Column(String(100), nullable=False, default="")
|
||||
created_at = Column(DateTime, nullable=False, default=lambda: datetime.now(UTC))
|
||||
@@ -943,28 +943,12 @@ class ViralVideoJobModel(Base):
|
||||
style_strength = Column(String(20), nullable=False, default="medium")
|
||||
style_guide = Column(JSON, nullable=True)
|
||||
style_template_id = Column(String(36), nullable=False, default="", index=True)
|
||||
# v1.5 音频/视频参数
|
||||
voice_id = Column(String(200), nullable=False, default="")
|
||||
voice_source = Column(String(20), nullable=False, default="")
|
||||
video_ratio = Column(String(10), nullable=False, default="9:16")
|
||||
video_model = Column(String(100), nullable=False, default="")
|
||||
# 结果与状态
|
||||
status = Column(String(30), nullable=False, default="pending", index=True)
|
||||
current_stage = Column(String(200), nullable=False, default="") # 细粒度阶段 snake_case
|
||||
phase_message = Column(String(500), nullable=False, default="") # 阶段中文提示文案
|
||||
heartbeat_at = Column(DateTime, nullable=True, index=True) # worker 心跳,用于僵尸任务超时回收
|
||||
intent_result = Column(JSON, nullable=True)
|
||||
image_analysis = Column(JSON, nullable=True)
|
||||
storyboard = Column(JSON, nullable=True)
|
||||
generated_copy_text = Column(Text, nullable=False, default="")
|
||||
copy_result = Column(
|
||||
JSON, nullable=True
|
||||
) # v1.6: 编导脚本结构{overview,scene_and_lighting,shots,hard_constraints,negative_prompts,voiceover_script}
|
||||
result_video_url = Column(String(1000), nullable=False, default="")
|
||||
credits_cost = Column(Float, nullable=False, default=0)
|
||||
video_resolution = Column(String(20), nullable=False, default="720p")
|
||||
credits_prepaid = Column(Float, nullable=False, default=0.0)
|
||||
credits_transaction_id = Column(String(36), nullable=False, default="")
|
||||
credits_cost = Column(Integer, nullable=False, default=0)
|
||||
error_msg = Column(Text, nullable=False, default="")
|
||||
retry_count = Column(Integer, nullable=False, default=0)
|
||||
started_at = Column(DateTime(timezone=True), nullable=True)
|
||||
|
||||
@@ -81,41 +81,6 @@ def ensure_database_exists(database_url: str) -> None:
|
||||
admin_engine.dispose()
|
||||
|
||||
|
||||
_VIRAL_VIDEO_BACKFILL_COLS = [
|
||||
("storyboard", "JSON"),
|
||||
("generated_copy_text", "TEXT NOT NULL DEFAULT ''"),
|
||||
("voice_id", "VARCHAR(200) NOT NULL DEFAULT ''"),
|
||||
("voice_source", "VARCHAR(20) NOT NULL DEFAULT ''"),
|
||||
("video_ratio", "VARCHAR(10) NOT NULL DEFAULT '9:16'"),
|
||||
("video_model", "VARCHAR(100) NOT NULL DEFAULT ''"),
|
||||
("copy_result", "JSON"),
|
||||
]
|
||||
|
||||
|
||||
def _ensure_viral_video_columns(connection) -> None:
|
||||
"""Idempotently add new columns to viral_video_jobs; create_all will not ALTER existing tables."""
|
||||
from sqlalchemy import inspect as _inspect
|
||||
|
||||
try:
|
||||
insp = _inspect(connection)
|
||||
if not insp.has_table("viral_video_jobs"):
|
||||
return
|
||||
existing = {c["name"] for c in insp.get_columns("viral_video_jobs")}
|
||||
except Exception:
|
||||
return
|
||||
import logging as _logging
|
||||
|
||||
_log = _logging.getLogger(__name__)
|
||||
for col, ddl in _VIRAL_VIDEO_BACKFILL_COLS:
|
||||
if col in existing:
|
||||
continue
|
||||
try:
|
||||
connection.execute(text(f"ALTER TABLE viral_video_jobs ADD COLUMN {col} {ddl}"))
|
||||
_log.info("added column viral_video_jobs.%s", col)
|
||||
except Exception as e:
|
||||
_log.warning("add column %s failed: %s", col, e)
|
||||
|
||||
|
||||
def initialize_database(engine) -> None:
|
||||
"""初始化数据库 schema。
|
||||
|
||||
@@ -135,5 +100,4 @@ def initialize_database(engine) -> None:
|
||||
text("SELECT pg_advisory_unlock(:lock_id)"),
|
||||
{"lock_id": SCHEMA_INIT_LOCK_ID},
|
||||
)
|
||||
_ensure_viral_video_columns(connection)
|
||||
connection.commit()
|
||||
|
||||
@@ -39,9 +39,6 @@ class SQLAlchemyUserRepository(UserRepository):
|
||||
model.phone_verified = user.phone_verified
|
||||
model.binding_completed_at = user.binding_completed_at
|
||||
model.profile_completed = user.profile_completed
|
||||
model.is_member = user.is_member
|
||||
model.member_type = user.member_type
|
||||
model.member_expires_at = user.member_expires_at
|
||||
model.created_at = user.created_at
|
||||
|
||||
self.session.commit()
|
||||
@@ -118,8 +115,5 @@ class SQLAlchemyUserRepository(UserRepository):
|
||||
phone_verified=model.phone_verified or False,
|
||||
binding_completed_at=model.binding_completed_at,
|
||||
profile_completed=model.profile_completed if model.profile_completed is not None else True,
|
||||
is_member=model.is_member if model.is_member is not None else False,
|
||||
member_type=model.member_type,
|
||||
member_expires_at=model.member_expires_at,
|
||||
created_at=model.created_at,
|
||||
)
|
||||
|
||||
@@ -24,7 +24,7 @@ def _to_domain(model: ViralVideoJobModel) -> ViralVideoJob:
|
||||
viral_structure=model.viral_structure or "",
|
||||
marketing_purpose=model.marketing_purpose or "",
|
||||
bgm_preference=model.bgm_preference or "",
|
||||
duration=model.duration or 15,
|
||||
duration=model.duration or 30,
|
||||
user_copy_text=model.user_copy_text or "",
|
||||
fusion_level=model.fusion_level or "ai_polish",
|
||||
reference_audio_path=model.reference_audio_path or "",
|
||||
@@ -32,24 +32,11 @@ def _to_domain(model: ViralVideoJobModel) -> ViralVideoJob:
|
||||
style_strength=getattr(model, "style_strength", "medium") or "medium",
|
||||
style_guide=dict(model.style_guide) if model.style_guide else None,
|
||||
style_template_id=getattr(model, "style_template_id", "") or "",
|
||||
voice_id=getattr(model, "voice_id", "") or "",
|
||||
voice_source=getattr(model, "voice_source", "") or "",
|
||||
video_ratio=getattr(model, "video_ratio", "9:16") or "9:16",
|
||||
video_model=getattr(model, "video_model", "") or "",
|
||||
status=ViralVideoStatus(model.status) if model.status else ViralVideoStatus.PENDING,
|
||||
current_stage=getattr(model, "current_stage", "") or "",
|
||||
phase_message=getattr(model, "phase_message", "") or "",
|
||||
heartbeat_at=getattr(model, "heartbeat_at", None),
|
||||
intent_result=dict(model.intent_result) if model.intent_result else None,
|
||||
image_analysis=dict(model.image_analysis) if getattr(model, "image_analysis", None) else None,
|
||||
storyboard=list(model.storyboard) if getattr(model, "storyboard", None) else None,
|
||||
generated_copy_text=getattr(model, "generated_copy_text", "") or "",
|
||||
copy_result=dict(model.copy_result) if getattr(model, "copy_result", None) else None,
|
||||
result_video_url=model.result_video_url or "",
|
||||
video_resolution=getattr(model, "video_resolution", "720p") or "720p",
|
||||
credits_prepaid=float(getattr(model, "credits_prepaid", 0) or 0),
|
||||
credits_transaction_id=getattr(model, "credits_transaction_id", "") or "",
|
||||
credits_cost=float(model.credits_cost or 0),
|
||||
credits_cost=model.credits_cost or 0,
|
||||
error_msg=model.error_msg or "",
|
||||
retry_count=model.retry_count or 0,
|
||||
started_at=model.started_at,
|
||||
@@ -84,24 +71,11 @@ class SQLAlchemyViralVideoJobRepository:
|
||||
style_strength=job.style_strength,
|
||||
style_guide=job.style_guide,
|
||||
style_template_id=job.style_template_id,
|
||||
voice_id=job.voice_id,
|
||||
voice_source=job.voice_source,
|
||||
video_ratio=job.video_ratio,
|
||||
video_model=job.video_model,
|
||||
status=job.status,
|
||||
current_stage=job.current_stage or "",
|
||||
phase_message=job.phase_message or "",
|
||||
heartbeat_at=job.heartbeat_at,
|
||||
intent_result=job.intent_result,
|
||||
image_analysis=job.image_analysis,
|
||||
storyboard=job.storyboard,
|
||||
generated_copy_text=job.generated_copy_text,
|
||||
copy_result=job.copy_result,
|
||||
result_video_url=job.result_video_url,
|
||||
video_resolution=getattr(job, "video_resolution", "720p") or "720p",
|
||||
credits_prepaid=float(getattr(job, "credits_prepaid", 0) or 0),
|
||||
credits_transaction_id=getattr(job, "credits_transaction_id", "") or "",
|
||||
credits_cost=float(getattr(job, "credits_cost", 0) or 0),
|
||||
credits_cost=job.credits_cost,
|
||||
error_msg=job.error_msg,
|
||||
retry_count=job.retry_count,
|
||||
started_at=job.started_at,
|
||||
@@ -118,42 +92,15 @@ class SQLAlchemyViralVideoJobRepository:
|
||||
if model is None:
|
||||
raise ValueError(f"ViralVideoJob {job.id} not found")
|
||||
model.status = job.status
|
||||
model.current_stage = job.current_stage or ""
|
||||
model.phase_message = job.phase_message or ""
|
||||
model.heartbeat_at = job.heartbeat_at
|
||||
model.intent_result = job.intent_result
|
||||
model.image_analysis = job.image_analysis
|
||||
model.storyboard = job.storyboard
|
||||
model.generated_copy_text = job.generated_copy_text or ""
|
||||
model.copy_result = job.copy_result
|
||||
model.result_video_url = job.result_video_url
|
||||
model.video_resolution = getattr(job, "video_resolution", "720p") or "720p"
|
||||
model.credits_prepaid = float(getattr(job, "credits_prepaid", 0) or 0)
|
||||
model.credits_transaction_id = getattr(job, "credits_transaction_id", "") or ""
|
||||
model.credits_cost = float(getattr(job, "credits_cost", 0) or 0)
|
||||
model.credits_cost = job.credits_cost
|
||||
model.error_msg = job.error_msg
|
||||
model.retry_count = job.retry_count
|
||||
model.started_at = job.started_at
|
||||
model.completed_at = job.completed_at
|
||||
model.style_guide = job.style_guide
|
||||
# v1.5 three-stage: persist user-editable params so resume uses latest values
|
||||
model.user_copy_text = job.user_copy_text
|
||||
model.industry = job.industry
|
||||
model.target_customer = job.target_customer
|
||||
model.persona_id = job.persona_id
|
||||
model.viral_structure = job.viral_structure
|
||||
model.marketing_purpose = job.marketing_purpose
|
||||
model.bgm_preference = job.bgm_preference
|
||||
model.duration = job.duration
|
||||
model.fusion_level = job.fusion_level
|
||||
model.reference_audio_path = job.reference_audio_path
|
||||
model.reference_video_url = job.reference_video_url
|
||||
model.style_strength = job.style_strength
|
||||
model.style_template_id = job.style_template_id
|
||||
model.voice_id = job.voice_id or ""
|
||||
model.voice_source = job.voice_source or ""
|
||||
model.video_ratio = job.video_ratio or "9:16"
|
||||
model.video_model = job.video_model or ""
|
||||
model.updated_at = datetime.now(timezone.utc)
|
||||
self.session.commit()
|
||||
|
||||
@@ -179,9 +126,7 @@ class SQLAlchemyViralVideoJobRepository:
|
||||
self.session.query(ViralVideoJobModel)
|
||||
.filter(
|
||||
ViralVideoJobModel.user_id == user_id,
|
||||
ViralVideoJobModel.status.in_(
|
||||
["pending", "running", "wait_user_confirm", "image_analyzed", "copy_generated"]
|
||||
),
|
||||
ViralVideoJobModel.status.in_(["pending", "running", "wait_user_confirm"]),
|
||||
)
|
||||
.count()
|
||||
)
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
"""应用层:对外展示目录(套餐/积分包)。"""
|
||||
@@ -1,152 +0,0 @@
|
||||
"""读取管理后台配置的会员套餐 / 积分充值包(共享库真实数据)。
|
||||
|
||||
替代旧的硬编码 MEMBERSHIP_PRICES / POINTS_PACKAGES。
|
||||
短 TTL 缓存(30 秒),后台改价/启停后用户端最多 30 秒可见。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import threading
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
_CACHE_TTL = 30.0
|
||||
_lock = threading.Lock()
|
||||
_cache: dict[str, tuple[float, Any]] = {}
|
||||
|
||||
_QUOTA_LABELS = {
|
||||
"4k": "4K 超清分辨率",
|
||||
"batch_render": "批量渲染",
|
||||
"priority_queue": "优先处理队列",
|
||||
"ai_matting": "AI 智能抠像",
|
||||
"remove_watermark": "去水印",
|
||||
}
|
||||
|
||||
|
||||
def _cached(key: str, loader):
|
||||
now = time.time()
|
||||
hit = _cache.get(key)
|
||||
if hit and now - hit[0] < _CACHE_TTL:
|
||||
return hit[1]
|
||||
with _lock:
|
||||
hit = _cache.get(key)
|
||||
if hit and time.time() - hit[0] < _CACHE_TTL:
|
||||
return hit[1]
|
||||
value = loader()
|
||||
_cache[key] = (time.time(), value)
|
||||
return value
|
||||
|
||||
|
||||
def _quota_features(quotas: dict[str, Any] | None) -> dict[str, Any]:
|
||||
quotas = quotas or {}
|
||||
features: dict[str, Any] = {}
|
||||
for k, v in quotas.items():
|
||||
if k == "credits_per_month":
|
||||
features["credits_per_month"] = v
|
||||
elif k in _QUOTA_LABELS:
|
||||
features[_QUOTA_LABELS[k]] = v
|
||||
else:
|
||||
features[k] = v
|
||||
return features
|
||||
|
||||
|
||||
def get_membership_plans() -> list[dict[str, Any]]:
|
||||
"""读取 is_enabled=true 的套餐,按年/月周期展开为用户端档位。"""
|
||||
|
||||
def _load() -> list[dict[str, Any]]:
|
||||
from sqlalchemy import text
|
||||
|
||||
from packages.adapters.sqlalchemy_impl.session import SessionLocal
|
||||
|
||||
if SessionLocal is None:
|
||||
return []
|
||||
|
||||
session = SessionLocal()
|
||||
try:
|
||||
rows = session.execute(text("""
|
||||
SELECT plan_key, name, description, monthly_price, yearly_price,
|
||||
quotas, display_order
|
||||
FROM plans
|
||||
WHERE is_enabled = TRUE
|
||||
ORDER BY display_order NULLS LAST, created_at
|
||||
""")).fetchall()
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
plans: list[dict[str, Any]] = []
|
||||
for r in rows:
|
||||
base_features = _quota_features(r.quotas if isinstance(r.quotas, dict) else None)
|
||||
if r.yearly_price and float(r.yearly_price) > 0:
|
||||
plans.append(
|
||||
{
|
||||
"plan_id": r.plan_key,
|
||||
"billing_cycle": "yearly",
|
||||
"name": r.name,
|
||||
"description": r.description,
|
||||
"price_cents": int(round(float(r.yearly_price) * 100)),
|
||||
"monthly_price_cents": int(round(float(r.yearly_price) * 100 / 12)),
|
||||
"duration_days": 365,
|
||||
"features": dict(base_features),
|
||||
}
|
||||
)
|
||||
if r.monthly_price and float(r.monthly_price) > 0:
|
||||
plans.append(
|
||||
{
|
||||
"plan_id": r.plan_key,
|
||||
"billing_cycle": "monthly",
|
||||
"name": r.name,
|
||||
"description": r.description,
|
||||
"price_cents": int(round(float(r.monthly_price) * 100)),
|
||||
"monthly_price_cents": int(round(float(r.monthly_price) * 100)),
|
||||
"duration_days": 30,
|
||||
"features": dict(base_features),
|
||||
}
|
||||
)
|
||||
return plans
|
||||
|
||||
return _cached("membership_plans", _load)
|
||||
|
||||
|
||||
def get_points_packages() -> list[dict[str, Any]]:
|
||||
"""读取 is_active=true 的积分充值包。"""
|
||||
|
||||
def _load() -> list[dict[str, Any]]:
|
||||
from sqlalchemy import text
|
||||
|
||||
from packages.adapters.sqlalchemy_impl.session import SessionLocal
|
||||
|
||||
if SessionLocal is None:
|
||||
return []
|
||||
|
||||
session = SessionLocal()
|
||||
try:
|
||||
rows = session.execute(text("""
|
||||
SELECT package_key, name, price, credits, bonus_credits,
|
||||
is_recommended, description, sort_order
|
||||
FROM credit_packages
|
||||
WHERE is_active = TRUE
|
||||
ORDER BY sort_order NULLS LAST, price
|
||||
""")).fetchall()
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
packages: list[dict[str, Any]] = []
|
||||
for r in rows:
|
||||
total_points = int(r.credits or 0) + int(r.bonus_credits or 0)
|
||||
price_cents = int(round(float(r.price) * 100))
|
||||
unit = (price_cents / 100 / total_points) if total_points else 0
|
||||
packages.append(
|
||||
{
|
||||
"code": r.package_key,
|
||||
"name": r.name,
|
||||
"points": total_points,
|
||||
"bonus_credits": int(r.bonus_credits or 0),
|
||||
"price_cents": price_cents,
|
||||
"unit_price": f"¥{unit:.3f}/积分",
|
||||
"is_recommended": bool(r.is_recommended),
|
||||
"description": r.description,
|
||||
}
|
||||
)
|
||||
return packages
|
||||
|
||||
return _cached("points_packages", _load)
|
||||
+3
-29
@@ -90,42 +90,16 @@ class SharedSettings(BaseSettings):
|
||||
|
||||
# ── 豆包大模型(火山引擎方舟) ────────────────────────────────────────
|
||||
doubao_api_key: str = ""
|
||||
doubao_model: str = "doubao-seed-2-1-pro-260915" # 推理模型(Seed 2.1 Pro,深度思考+多模态;原 seed-1-6 已下线)
|
||||
doubao_fast_model: str = (
|
||||
"doubao-seed-2-1-lite-260915" # 快速模型(Seed 2.1 Lite,高 RPM,编导/审核/VLM lite;原 1-5-pro-32k 已 Retiring)
|
||||
)
|
||||
doubao_model: str = "doubao-seed-1-6-250615"
|
||||
doubao_base_url: str = "https://ark.cn-beijing.volces.com/api/v3"
|
||||
doubao_timeout: int = 30
|
||||
doubao_max_retries: int = 2
|
||||
doubao_vision_model: str = (
|
||||
"doubao-seed-2-1-pro-260915" # 高精度视觉(Seed 2.1 Pro 原生多模态;原 vision-pro-250328 已下线)
|
||||
)
|
||||
doubao_vision_lite_model: str = (
|
||||
"doubao-seed-2-1-lite-260915" # 快速视觉(Seed 2.1 Lite 原生多模态;原 vision-lite-250315 不可用)
|
||||
)
|
||||
doubao_vision_use_lite: bool = True # viral-video 图片分析默认用 lite 提速
|
||||
doubao_embedding_model: str = "doubao-embedding-vision-251215" # 多模态向量化(原 large-text-240915 已 Retiring)
|
||||
doubao_vision_model: str = "doubao-1-5-vision-pro-250915"
|
||||
doubao_embedding_model: str = "doubao-embedding-large-text-240915"
|
||||
doubao_video_model: str = "doubao-seedance-2-5-260628"
|
||||
doubao_video_timeout: int = 600 # 视频生成轮询总超时(秒)
|
||||
doubao_video_poll_interval: int = 10 # 轮询间隔(秒)
|
||||
|
||||
# ── DashScope (阿里云百炼 Wan 3.0 等) ─────────────────────────────────
|
||||
dashscope_api_key: str = ""
|
||||
dashscope_base_url: str = "https://dashscope.aliyuncs.com/api/v1"
|
||||
dashscope_video_timeout: int = 900 # Wan 视频任务轮询总超时(秒)
|
||||
dashscope_video_poll_interval: int = 10
|
||||
|
||||
# ── 即梦(Jimeng)视觉 API —— 火山引擎 cvtob ──────────────────────────
|
||||
# #2169: 真人参考图在方舟 Seedance 走 B 端审核会被 50411 拦截,
|
||||
# 即梦走 C 端审核链路,普通真人照片可过审,作为参考图场景兜底通道。
|
||||
# 鉴权:AK/SK V4 签名(Region=cn-north-1, Service=cv)
|
||||
jimeng_ak: str = ""
|
||||
jimeng_sk: str = ""
|
||||
jimeng_base_url: str = "https://visual.volcengineapi.com"
|
||||
jimeng_req_key: str = "jimeng_i2v_first_v30" # 视频3.0 720P 首帧图生视频(1张图,稳定版;1080P 标注下线中)
|
||||
jimeng_video_timeout: int = 600 # 即梦轮询总超时(秒)
|
||||
jimeng_video_poll_interval: int = 5 # 轮询间隔(秒)
|
||||
|
||||
# ── MediaKit (火山引擎 AI 媒体工具) ──────────────────────────────────
|
||||
mediakit_api_key: str = ""
|
||||
mediakit_base_url: str = "https://mediakit.cn-beijing.volces.com/api/v1"
|
||||
|
||||
@@ -60,11 +60,6 @@ class User:
|
||||
# 资料是否已完善(微信新用户首次设置昵称后置 True;邮箱注册默认 True)
|
||||
profile_completed: bool = True
|
||||
|
||||
# 会员字段 (#1895):与 users 表列对应
|
||||
is_member: bool = False
|
||||
member_type: str | None = None
|
||||
member_expires_at: datetime | None = None
|
||||
|
||||
created_at: datetime = field(default_factory=lambda: datetime.now(UTC))
|
||||
|
||||
|
||||
|
||||
@@ -9,9 +9,9 @@ from uuid import uuid4
|
||||
class PointsAccount:
|
||||
id: str
|
||||
user_id: str
|
||||
balance: float = 0.0
|
||||
total_earned: float = 0.0
|
||||
total_spent: float = 0.0
|
||||
balance: int = 0
|
||||
total_earned: int = 0
|
||||
total_spent: int = 0
|
||||
created_at: datetime = field(default_factory=lambda: datetime.now(UTC))
|
||||
updated_at: datetime = field(default_factory=lambda: datetime.now(UTC))
|
||||
|
||||
|
||||
+54
-349
@@ -1,340 +1,32 @@
|
||||
"""积分消耗规则配置 (#1895)
|
||||
|
||||
v1.6.1: 按产品决策,智能混剪/AI数字人/AI配音/抖音解析/改写/标题/封面 全部免费,
|
||||
仅保留声音克隆合成(voice_clone_synth)的扣点逻辑;声音克隆训练保持免费。
|
||||
爆款视频(viral_video)走动态定价,见本文件 VIRAL_VIDEO_MODEL_PRICES + calculate_viral_video_credits。
|
||||
"""
|
||||
"""积分消耗规则配置 (#1895)"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
|
||||
# ============ 爆款视频动态定价 (#2151) ============
|
||||
# key = (model_id, resolution, has_video_input),单位:
|
||||
# - billing_mode=token: 元/百万tokens(输出)
|
||||
# - billing_mode=per_second: 元/秒(视频时长)
|
||||
VIRAL_VIDEO_MODEL_PRICES: dict[tuple[str, str, bool], float] = {
|
||||
("seedance-2.5", "480p", False): 70.0,
|
||||
("seedance-2.5", "720p", False): 70.0,
|
||||
("seedance-2.5", "1080p", False): 77.0,
|
||||
("seedance-2.5", "480p", True): 42.0,
|
||||
("seedance-2.5", "720p", True): 42.0,
|
||||
("seedance-2.5", "1080p", True): 46.0,
|
||||
("seedance-2.0", "480p", False): 46.0,
|
||||
("seedance-2.0", "720p", False): 46.0,
|
||||
("seedance-2.0", "1080p", False): 51.0,
|
||||
("seedance-2.0", "4k", False): 80.0,
|
||||
("seedance-2.0-fast", "480p", False): 28.0,
|
||||
("seedance-2.0-fast", "720p", False): 28.0,
|
||||
("seedance-2.0-mini", "480p", False): 9.2,
|
||||
("seedance-2.0-mini", "720p", False): 9.2,
|
||||
("wan-3.0", "480p", False): 0.3,
|
||||
("wan-3.0", "720p", False): 0.6,
|
||||
("wan-3.0", "1080p", False): 1.2,
|
||||
# #2169: 即梦(Jimeng)视频3.0 720P 首帧图生视频,0.28 元/秒(C 端审核,真人可过)
|
||||
("jimeng-3.0", "720p", False): 0.28,
|
||||
}
|
||||
|
||||
# 固定成本(元):VLM 分析 + LLM 文案 + TTS + OSS + 服务器
|
||||
VIRAL_VIDEO_FIXED_COST = 0.15
|
||||
# 利润系数
|
||||
VIRAL_VIDEO_PROFIT_MULTIPLIER = 1.3
|
||||
# Seedance 输出帧率
|
||||
VIRAL_VIDEO_FPS = 24
|
||||
|
||||
# 分辨率别名映射 -> 标准 key
|
||||
_RESOLUTION_ALIASES: dict[str, str] = {
|
||||
"480p": "480p",
|
||||
"普清": "480p",
|
||||
"default": "480p",
|
||||
"low": "480p",
|
||||
"sd": "480p",
|
||||
"720p": "720p",
|
||||
"高清": "720p",
|
||||
"medium": "720p",
|
||||
"hd": "720p",
|
||||
"1080p": "1080p",
|
||||
"超清": "1080p",
|
||||
"high": "1080p",
|
||||
"ultra": "1080p",
|
||||
"全能": "1080p",
|
||||
"fhd": "1080p",
|
||||
}
|
||||
# 分辨率 -> 短边像素数(p 值代表短边,不是 height)
|
||||
_RESOLUTION_SHORT_SIDE: dict[str, int] = {"480p": 480, "720p": 720, "1080p": 1080, "4k": 2160}
|
||||
_RESOLUTION_ALIASES["4k"] = "4k"
|
||||
_RESOLUTION_ALIASES["2160p"] = "4k"
|
||||
_RESOLUTION_ALIASES["uhd"] = "4k"
|
||||
|
||||
|
||||
def resolve_video_dimensions(resolution: str, ratio: str) -> tuple[int, int]:
|
||||
"""把 (resolution, ratio) 解析为 (width, height)。
|
||||
|
||||
resolution 数字代表短边像素数(480p/720p/1080p 等):
|
||||
- 横屏 16:9:短边是 height,width = short * 16/9
|
||||
- 竖屏 9:16:短边是 width,height = short * 16/9
|
||||
- 方屏 1:1:width = height = short
|
||||
"""
|
||||
key = str(resolution or "").strip()
|
||||
key_l = key.lower()
|
||||
res_key = _RESOLUTION_ALIASES.get(key_l) or _RESOLUTION_ALIASES.get(key) or "720p"
|
||||
short = _RESOLUTION_SHORT_SIDE.get(res_key, 720)
|
||||
r = str(ratio or "").strip().lower()
|
||||
if r == "16:9":
|
||||
# 横屏:短边是 height,width 向上取整并对齐偶数
|
||||
w = math.ceil(short * 16 / 9)
|
||||
h = short
|
||||
elif r == "1:1":
|
||||
w, h = short, short
|
||||
else:
|
||||
# 9:16 竖屏(默认):短边是 width,height 向上取整并对齐偶数
|
||||
w = short
|
||||
h = math.ceil(short * 16 / 9)
|
||||
# 对齐到偶数(视频编码要求)
|
||||
w = w + (w % 2)
|
||||
h = h + (h % 2)
|
||||
return int(w), int(h)
|
||||
|
||||
|
||||
# ── 爆款视频多模型元数据 (#2159) ──────────────────────────────────────
|
||||
VIRAL_VIDEO_MODEL_CONFIG: dict[str, dict] = {
|
||||
"seedance-2.5": {
|
||||
"key": "seedance-2.5",
|
||||
"display_name": "Seedance 2.5 — 最新最强",
|
||||
"model_id": "doubao-seedance-2-5-260628",
|
||||
"provider": "doubao",
|
||||
"supports_audio": True,
|
||||
"supported_resolutions": ["480p", "720p", "1080p"],
|
||||
"max_duration": 30,
|
||||
"billing_mode": "token",
|
||||
"is_default": True,
|
||||
},
|
||||
"seedance-2.0": {
|
||||
"key": "seedance-2.0",
|
||||
"display_name": "Seedance 2.0 — 正式首选",
|
||||
"model_id": "doubao-seedance-2-0-260128",
|
||||
"provider": "doubao",
|
||||
"supports_audio": True,
|
||||
"supported_resolutions": ["480p", "720p", "1080p", "4k"],
|
||||
"max_duration": 15,
|
||||
"billing_mode": "token",
|
||||
"is_default": False,
|
||||
},
|
||||
"seedance-2.0-fast": {
|
||||
"key": "seedance-2.0-fast",
|
||||
"display_name": "Seedance 2.0 Fast — 快速低成本",
|
||||
"model_id": "doubao-seedance-2-0-fast-260128",
|
||||
"provider": "doubao",
|
||||
"supports_audio": True,
|
||||
"supported_resolutions": ["480p", "720p"],
|
||||
"max_duration": 15,
|
||||
"billing_mode": "token",
|
||||
"is_default": False,
|
||||
},
|
||||
"seedance-2.0-mini": {
|
||||
"key": "seedance-2.0-mini",
|
||||
"display_name": "Seedance 2.0 Mini — 低成本测试",
|
||||
"model_id": "doubao-seedance-2-0-mini-260615",
|
||||
"provider": "doubao",
|
||||
"supports_audio": True,
|
||||
"supported_resolutions": ["480p", "720p"],
|
||||
"max_duration": 15,
|
||||
"billing_mode": "token",
|
||||
"is_default": False,
|
||||
},
|
||||
"wan-3.0": {
|
||||
"key": "wan-3.0",
|
||||
"display_name": "Wan 3.0 — 通义万相(阿里云)",
|
||||
"model_id": "wan3.0-video",
|
||||
"provider": "dashscope",
|
||||
"supports_audio": True,
|
||||
"supported_resolutions": ["480p", "720p", "1080p"],
|
||||
"max_duration": 30,
|
||||
"billing_mode": "per_second",
|
||||
"is_default": False,
|
||||
},
|
||||
# #2169: 即梦视频3.0(内部兜底通道,方舟 Seedance 返回真人拦截 50411 时自动切到即梦重试,
|
||||
# 不暴露给前端让用户直接选择,但需支持计费结算)
|
||||
"jimeng-3.0": {
|
||||
"key": "jimeng-3.0",
|
||||
"display_name": "即梦3.0 — 真人图生视频(兜底)",
|
||||
"model_id": "jimeng_i2v_first_v30",
|
||||
"provider": "jimeng",
|
||||
"supports_audio": False, # 即梦返回无声视频,音频由后续 ffmpeg 合成 TTS
|
||||
"supported_resolutions": ["720p"],
|
||||
"max_duration": 10, # 即梦 i2v 首帧最长 10s(frames=241)
|
||||
"billing_mode": "per_second",
|
||||
"is_default": False,
|
||||
"_internal_fallback_only": True, # 标记:不对外暴露到模型选择列表
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def get_viral_video_model_config(model_key: str | None) -> dict:
|
||||
"""获取模型配置,未知 key 回落到默认 seedance-2.5。"""
|
||||
key = (model_key or "").strip().lower()
|
||||
if key and key in VIRAL_VIDEO_MODEL_CONFIG:
|
||||
return VIRAL_VIDEO_MODEL_CONFIG[key]
|
||||
return VIRAL_VIDEO_MODEL_CONFIG["seedance-2.5"]
|
||||
|
||||
|
||||
def list_viral_video_models(
|
||||
include_placeholder: bool = False,
|
||||
dashscope_available: bool = False,
|
||||
) -> list[dict]:
|
||||
"""返回前端可用的模型列表(供 GET /api/v1/viral-video/models 端点用)。"""
|
||||
out: list[dict] = []
|
||||
for _k, cfg in VIRAL_VIDEO_MODEL_CONFIG.items():
|
||||
if cfg.get("_placeholder") and not include_placeholder:
|
||||
continue
|
||||
if cfg.get("provider") == "dashscope" and not dashscope_available:
|
||||
continue
|
||||
# #2169: 即梦是内部兜底通道,不在前端模型列表展示
|
||||
if cfg.get("_internal_fallback_only"):
|
||||
continue
|
||||
out.append(
|
||||
{
|
||||
"key": cfg["key"],
|
||||
"display_name": cfg["display_name"],
|
||||
"supports_audio": bool(cfg.get("supports_audio", True)),
|
||||
"supported_resolutions": list(cfg.get("supported_resolutions", ["720p"])),
|
||||
"max_duration": int(cfg.get("max_duration", 15)),
|
||||
"billing_mode": cfg.get("billing_mode", "token"),
|
||||
"is_default": bool(cfg.get("is_default", False)),
|
||||
}
|
||||
)
|
||||
return out
|
||||
|
||||
|
||||
def _match_model_prefix(model: str | None) -> str:
|
||||
"""匹配 model key(支持全部内部别名,未知回落到 seedance-2.5)。
|
||||
|
||||
按 key 长度从长到短匹配,避免 "seedance-2.0-fast" 被 "seedance-2.0" 前缀命中。
|
||||
"""
|
||||
mm = (model or "").strip().lower()
|
||||
for k in sorted(VIRAL_VIDEO_MODEL_CONFIG.keys(), key=len, reverse=True):
|
||||
if mm == k or mm.startswith(k):
|
||||
return k
|
||||
return "seedance-2.5"
|
||||
|
||||
|
||||
def _infer_resolution_key(width: int, height: int) -> str:
|
||||
"""从实际 (width, height) 用短边推断 resolution key。"""
|
||||
short = min(int(width or 720), int(height or 720))
|
||||
if short >= 1900:
|
||||
return "4k"
|
||||
if short >= 1000:
|
||||
return "1080p"
|
||||
if short >= 650:
|
||||
return "720p"
|
||||
return "480p"
|
||||
|
||||
|
||||
def calculate_viral_video_credits_with_breakdown(
|
||||
duration_seconds: int,
|
||||
width: int,
|
||||
height: int,
|
||||
model: str = "seedance-2.5",
|
||||
has_video_input: bool = False,
|
||||
actual_tokens: int | None = None,
|
||||
fps: int = VIRAL_VIDEO_FPS,
|
||||
) -> tuple[float, dict]:
|
||||
"""计算爆款视频所需积分(1 积分 = 1 元),并返回计费公式明细。
|
||||
|
||||
公式:
|
||||
tokens = duration * width * height * fps / 1024
|
||||
video_cost = tokens / 1_000_000 * model_token_price
|
||||
total = round((video_cost + fixed_cost) * profit_multiplier, 2)
|
||||
若传入 actual_tokens 则用它替代计算值。
|
||||
|
||||
Returns:
|
||||
(credits, breakdown) 二元组:
|
||||
- credits: 四舍五入保留两位小数的最终积分
|
||||
- breakdown: dict,包含 tokens / video_cost / fixed_cost / profit_multiplier /
|
||||
model_price / width / height / fps 字段,便于前端展示计费明细。
|
||||
"""
|
||||
w = max(1, int(width or 1))
|
||||
h = max(1, int(height or 1))
|
||||
effective_fps = int(fps or VIRAL_VIDEO_FPS)
|
||||
|
||||
prefix = _match_model_prefix(model)
|
||||
cfg = get_viral_video_model_config(prefix)
|
||||
res_key = _infer_resolution_key(w, h)
|
||||
billing = cfg.get("billing_mode", "token")
|
||||
key = (prefix, res_key, bool(has_video_input))
|
||||
price = VIRAL_VIDEO_MODEL_PRICES.get(key)
|
||||
if price is None:
|
||||
price = VIRAL_VIDEO_MODEL_PRICES.get(("seedance-2.5", res_key, False), 70.0)
|
||||
dur = max(1, int(duration_seconds or 15))
|
||||
if billing == "per_second":
|
||||
tokens = 0.0
|
||||
video_cost = dur * float(price)
|
||||
billing_unit = "second"
|
||||
else:
|
||||
if actual_tokens is not None and actual_tokens > 0:
|
||||
tokens = float(actual_tokens)
|
||||
else:
|
||||
tokens = dur * w * h * effective_fps / 1024.0
|
||||
video_cost = tokens / 1_000_000.0 * float(price)
|
||||
billing_unit = "token"
|
||||
|
||||
total = (video_cost + VIRAL_VIDEO_FIXED_COST) * VIRAL_VIDEO_PROFIT_MULTIPLIER
|
||||
credits = round(float(total), 2)
|
||||
breakdown = {
|
||||
"tokens": float(tokens),
|
||||
"video_cost": float(video_cost),
|
||||
"fixed_cost": float(VIRAL_VIDEO_FIXED_COST),
|
||||
"profit_multiplier": float(VIRAL_VIDEO_PROFIT_MULTIPLIER),
|
||||
"model_price": float(price),
|
||||
"model_key": prefix,
|
||||
"billing_mode": billing,
|
||||
"billing_unit": billing_unit,
|
||||
"width": int(w),
|
||||
"height": int(h),
|
||||
"fps": int(effective_fps),
|
||||
"duration": dur,
|
||||
}
|
||||
return credits, breakdown
|
||||
|
||||
|
||||
def calculate_viral_video_credits(
|
||||
duration_seconds: int,
|
||||
width: int,
|
||||
height: int,
|
||||
model: str = "seedance-2.5",
|
||||
has_video_input: bool = False,
|
||||
actual_tokens: int | None = None,
|
||||
fps: int = VIRAL_VIDEO_FPS,
|
||||
) -> float:
|
||||
"""计算爆款视频所需积分(1 积分 = 1 元),仅返回积分值(向后兼容包装器)。
|
||||
|
||||
内部调用 calculate_viral_video_credits_with_breakdown,仅返回 credits 部分,
|
||||
保持旧调用方签名与返回值类型不变。
|
||||
|
||||
公式:
|
||||
tokens = duration * width * height * fps / 1024
|
||||
video_cost = tokens / 1_000_000 * model_token_price
|
||||
total = round((video_cost + fixed_cost) * profit_multiplier, 2)
|
||||
若传入 actual_tokens 则用它替代计算值。
|
||||
"""
|
||||
credits, _ = calculate_viral_video_credits_with_breakdown(
|
||||
duration_seconds=duration_seconds,
|
||||
width=width,
|
||||
height=height,
|
||||
model=model,
|
||||
has_video_input=has_video_input,
|
||||
actual_tokens=actual_tokens,
|
||||
fps=fps,
|
||||
)
|
||||
return credits
|
||||
|
||||
|
||||
# ============ 场景定义 ============
|
||||
# 每个场景: base_points(基础积分), unit(计费单位), name(显示名称), dynamic(是否动态定价)
|
||||
# 说明:爆款视频(viral_video)走动态定价(预扣→结算多退少补),因此不使用 @points_gate
|
||||
# 装饰器,base_points=0,dynamic=True;前端展示场景列表时仍可看到。
|
||||
# 每个场景: base_points(基础积分), unit(计费单位), name(显示名称)
|
||||
|
||||
POINTS_SCENES: dict[str, dict] = {
|
||||
"ai_voice": {
|
||||
"base_points": 1,
|
||||
"unit": "分钟",
|
||||
"name": "AI 配音",
|
||||
"description": "AI 配音每分钟消耗 1 积分(免费用户上浮 15%,会员 8~9 折)",
|
||||
},
|
||||
"ai_video": {
|
||||
"base_points": 3,
|
||||
"unit": "条",
|
||||
"name": "智能混剪",
|
||||
"extra_per_30s": 1,
|
||||
"description": "智能混剪每条 3 积分起,视频超过 30 秒后每 30 秒加 1 积分;免费用户每日 2 条免费额度",
|
||||
},
|
||||
"ai_digital_human": {
|
||||
"base_points": 15,
|
||||
"unit": "分钟",
|
||||
"name": "AI 数字人",
|
||||
"description": "AI 数字人每分钟消耗 15 积分",
|
||||
},
|
||||
"voice_clone_train": {
|
||||
"base_points": 0,
|
||||
"unit": "次",
|
||||
@@ -347,16 +39,23 @@ POINTS_SCENES: dict[str, dict] = {
|
||||
"name": "声音克隆合成",
|
||||
"description": "克隆音色合成每分钟消耗 1 积分",
|
||||
},
|
||||
"viral_video": {
|
||||
"base_points": 0,
|
||||
"douyin_extract": {
|
||||
"base_points": 1,
|
||||
"unit": "次",
|
||||
"name": "爆款视频",
|
||||
"dynamic": True,
|
||||
"description": "爆款视频动态定价(按视频时长/分辨率/模型计算,预扣→结算多退少补)",
|
||||
"name": "抖音链接提取",
|
||||
"description": "抖音文案提取每次 1 积分",
|
||||
},
|
||||
"ai_rewrite": {"base_points": 1, "unit": "次", "name": "AI 改写文案", "description": "AI 改写文案每次 1 积分"},
|
||||
"ai_title": {
|
||||
"base_points": 1,
|
||||
"unit": "次",
|
||||
"name": "AI 标题生成",
|
||||
"description": "AI 生成标题每次 1 积分(免费用户实际上浮后 2 积分/次)",
|
||||
},
|
||||
"ai_cover": {"base_points": 1, "unit": "张", "name": "AI 封面生成", "description": "AI 封面生成每张 1 积分"},
|
||||
}
|
||||
|
||||
# 免费用户积分消耗上浮系数(仅对 voice_clone_synth 生效)
|
||||
# 免费用户积分消耗上浮系数
|
||||
FREE_USER_MULTIPLIER = 1.15
|
||||
|
||||
# ============ 积分包定义 ============
|
||||
@@ -382,6 +81,9 @@ MEMBER_DISCOUNT: dict[str, float] = {
|
||||
"yearly": 0.8,
|
||||
}
|
||||
|
||||
# 每日免费混剪次数(免费用户)
|
||||
DAILY_FREE_CLIP_LIMIT = 2
|
||||
|
||||
|
||||
def calculate_points_cost(
|
||||
scene_key: str,
|
||||
@@ -389,45 +91,48 @@ def calculate_points_cost(
|
||||
quantity: int = 1,
|
||||
duration_minutes: float = 0,
|
||||
member_type: str | None = None,
|
||||
) -> float:
|
||||
) -> int:
|
||||
"""计算指定场景的积分消耗。
|
||||
|
||||
Args:
|
||||
scene_key: 场景标识(当前支持 voice_clone_train/voice_clone_synth/viral_video;
|
||||
viral_video 为动态定价场景,此处返回 0,由业务侧调用
|
||||
calculate_viral_video_credits 手动计算)
|
||||
scene_key: 场景标识,如 "ai_voice"、"ai_video"
|
||||
is_member: 是否付费会员
|
||||
quantity: 数量(按次计费场景)
|
||||
duration_minutes: 时长分钟数(按时长计费场景)
|
||||
member_type: 会员类型 (monthly/quarterly/yearly),用于折扣
|
||||
|
||||
Returns:
|
||||
实际消耗积分(float;已含免费用户 ×1.15 上浮或会员折扣);免费/动态/已下线场景统一返回 0。
|
||||
实际消耗积分(已含免费用户 ×1.15 上浮或会员折扣)
|
||||
|
||||
Raises:
|
||||
ValueError: 未知场景标识
|
||||
"""
|
||||
scene = POINTS_SCENES.get(scene_key)
|
||||
if not scene:
|
||||
# 已下线/未注册的场景统一返回 0(免费),保持向后兼容
|
||||
return 0.0
|
||||
|
||||
# 动态定价场景(如 viral_video)由业务侧手动计算,这里统一返回 0
|
||||
if scene.get("dynamic"):
|
||||
return 0.0
|
||||
raise ValueError(f"Unknown points scene: {scene_key}")
|
||||
|
||||
base = scene["base_points"]
|
||||
if base == 0:
|
||||
return 0.0
|
||||
return 0
|
||||
|
||||
# —— 计算基础消耗 ——
|
||||
unit = scene["unit"]
|
||||
if unit == "分钟":
|
||||
total_base = base * max(1, math.ceil(duration_minutes))
|
||||
elif unit in ("次", "张"):
|
||||
elif unit in ("条", "次", "张"):
|
||||
total_base = base * quantity
|
||||
# 混剪特殊逻辑:视频超过 30s 后每 +30s 额外加 1 积分
|
||||
if scene_key == "ai_video" and duration_minutes > 0.5:
|
||||
extra_segments = math.ceil((duration_minutes * 60 - 30) / 30)
|
||||
if extra_segments > 0:
|
||||
total_base += scene.get("extra_per_30s", 1) * extra_segments
|
||||
else:
|
||||
total_base = base
|
||||
|
||||
# —— 会员折扣 / 免费用户上浮 ——
|
||||
if is_member and member_type and member_type in MEMBER_DISCOUNT:
|
||||
total_base = max(1, math.floor(total_base * MEMBER_DISCOUNT[member_type]))
|
||||
elif not is_member:
|
||||
total_base = math.ceil(total_base * FREE_USER_MULTIPLIER)
|
||||
|
||||
return float(total_base)
|
||||
return total_base
|
||||
|
||||
@@ -13,6 +13,7 @@ from typing import Any
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from packages.domain.points_rules import (
|
||||
DAILY_FREE_CLIP_LIMIT,
|
||||
POINTS_PACKAGES,
|
||||
)
|
||||
|
||||
@@ -83,7 +84,7 @@ class PointsService:
|
||||
|
||||
# ──────────────── 余额检查 ────────────────
|
||||
|
||||
def check_balance(self, user_id: str, amount: float, db: Session) -> dict[str, Any]:
|
||||
def check_balance(self, user_id: str, amount: int, db: Session) -> dict[str, Any]:
|
||||
"""检查余额是否足够。"""
|
||||
account_data = self.get_or_create_account(user_id, db)
|
||||
balance = account_data["balance"]
|
||||
@@ -99,7 +100,7 @@ class PointsService:
|
||||
def deduct_points(
|
||||
self,
|
||||
user_id: str,
|
||||
amount: float,
|
||||
amount: int,
|
||||
source: str,
|
||||
db: Session,
|
||||
description: str = "",
|
||||
@@ -108,7 +109,7 @@ class PointsService:
|
||||
"""扣减积分(事务性:SELECT FOR UPDATE → 检查余额 → 扣减 → 流水 → 同步用户表)。
|
||||
|
||||
Returns:
|
||||
{"success": True/False, "balance": float, "transaction_id": str|None}
|
||||
{"success": True/False, "balance": int, "transaction_id": str|None}
|
||||
"""
|
||||
PointsAccountModel, PointsTransactionModel, _, _, UserModel = _get_models()
|
||||
|
||||
@@ -172,7 +173,7 @@ class PointsService:
|
||||
except Exception:
|
||||
db.rollback()
|
||||
logger.exception(
|
||||
"积分扣减失败: user_id=%s, amount=%.2f, source=%s",
|
||||
"积分扣减失败: user_id=%s, amount=%d, source=%s",
|
||||
user_id,
|
||||
amount,
|
||||
source,
|
||||
@@ -184,7 +185,7 @@ class PointsService:
|
||||
def add_points(
|
||||
self,
|
||||
user_id: str,
|
||||
amount: float,
|
||||
amount: int,
|
||||
source: str,
|
||||
db: Session,
|
||||
description: str = "",
|
||||
@@ -241,7 +242,7 @@ class PointsService:
|
||||
except Exception:
|
||||
db.rollback()
|
||||
logger.exception(
|
||||
"积分增加失败: user_id=%s, amount=%.2f, source=%s",
|
||||
"积分增加失败: user_id=%s, amount=%d, source=%s",
|
||||
user_id,
|
||||
amount,
|
||||
source,
|
||||
@@ -253,7 +254,7 @@ class PointsService:
|
||||
def refund_points(
|
||||
self,
|
||||
user_id: str,
|
||||
amount: float,
|
||||
amount: int,
|
||||
source: str,
|
||||
db: Session,
|
||||
ref_id: str = "",
|
||||
@@ -269,92 +270,6 @@ class PointsService:
|
||||
ref_id=ref_id,
|
||||
)
|
||||
|
||||
# ──────────────── 爆款视频(viral_video)动态定价 ────────────────
|
||||
|
||||
def deduct_viral_video(self, user_id: str, credits: float, job_id: str, db: Session) -> dict[str, Any]:
|
||||
"""爆款视频预扣积分(confirm-copy 阶段)。"""
|
||||
return self.deduct_points(
|
||||
user_id=user_id,
|
||||
amount=float(credits or 0),
|
||||
source="viral_video",
|
||||
db=db,
|
||||
description="爆款视频生成",
|
||||
ref_id=job_id,
|
||||
)
|
||||
|
||||
def settle_viral_video(
|
||||
self,
|
||||
user_id: str,
|
||||
estimated: float,
|
||||
actual: float,
|
||||
txn_id: str,
|
||||
db: Session,
|
||||
) -> dict[str, Any]:
|
||||
"""爆款视频完成后按实际 tokens 结算(多退少补)。
|
||||
|
||||
- actual < estimated: 退差额
|
||||
- actual > estimated: 补扣差额(余额不足时记 warning,不阻塞完成)
|
||||
- |diff| < 0.01: 不动
|
||||
"""
|
||||
diff = round(float(actual or 0) - float(estimated or 0), 2)
|
||||
if abs(diff) < 0.01:
|
||||
return {"success": True, "action": "none", "diff": 0.0}
|
||||
if diff < 0:
|
||||
refund = round(-diff, 2)
|
||||
try:
|
||||
res = self.refund_points(
|
||||
user_id=user_id,
|
||||
amount=refund,
|
||||
source="viral_video",
|
||||
db=db,
|
||||
ref_id=txn_id,
|
||||
description="爆款视频结算退费",
|
||||
)
|
||||
return {"success": bool(res.get("success")), "action": "refund", "diff": -refund, "amount": refund}
|
||||
except Exception:
|
||||
logger.exception("[viral_video] 结算退费异常 user_id=%s refund=%.2f", user_id, refund)
|
||||
return {"success": False, "action": "refund", "diff": -refund}
|
||||
else:
|
||||
extra = round(diff, 2)
|
||||
try:
|
||||
res = self.deduct_points(
|
||||
user_id=user_id,
|
||||
amount=extra,
|
||||
source="viral_video",
|
||||
db=db,
|
||||
description="爆款视频结算补扣",
|
||||
ref_id=txn_id,
|
||||
)
|
||||
if not res.get("success"):
|
||||
logger.warning(
|
||||
"[viral_video] 结算补扣余额不足 user_id=%s extra=%.2f balance=%s (不阻塞任务完成)",
|
||||
user_id,
|
||||
extra,
|
||||
res.get("balance"),
|
||||
)
|
||||
return {"success": bool(res.get("success")), "action": "deduct", "diff": extra, "amount": extra}
|
||||
except Exception:
|
||||
logger.exception("[viral_video] 结算补扣异常 user_id=%s extra=%.2f", user_id, extra)
|
||||
return {"success": False, "action": "deduct", "diff": extra}
|
||||
|
||||
def refund_viral_video(self, user_id: str, credits: float, txn_id: str, db: Session) -> dict[str, Any]:
|
||||
"""爆款视频失败全额退款。"""
|
||||
amount = float(credits or 0)
|
||||
if amount <= 0:
|
||||
return {"success": True, "action": "none", "amount": 0.0}
|
||||
try:
|
||||
return self.refund_points(
|
||||
user_id=user_id,
|
||||
amount=amount,
|
||||
source="viral_video",
|
||||
db=db,
|
||||
ref_id=txn_id,
|
||||
description="爆款视频失败退款",
|
||||
)
|
||||
except Exception:
|
||||
logger.exception("[viral_video] 失败退款异常 user_id=%s amount=%.2f", user_id, amount)
|
||||
return {"success": False, "action": "refund", "amount": amount}
|
||||
|
||||
# ──────────────── 流水查询 ────────────────
|
||||
|
||||
def get_transactions(
|
||||
@@ -409,16 +324,132 @@ class PointsService:
|
||||
"page_size": page_size,
|
||||
}
|
||||
|
||||
# ──────────────── 每日免费混剪额度(已下线:智能混剪全免费) ────────────────
|
||||
# ──────────────── 每日免费混剪额度 ────────────────
|
||||
|
||||
def _daily_key(self, user_id: str) -> str:
|
||||
"""生成 Redis 每日额度 key。格式: daily_usage:{user_id}:{YYYYMMDD}:free_clip"""
|
||||
today = datetime.now(UTC).strftime("%Y%m%d")
|
||||
return f"daily_usage:{user_id}:{today}:free_clip"
|
||||
|
||||
def check_daily_free_clip(self, user_id: str, db: Session) -> bool:
|
||||
"""检查今日是否还有免费混剪额度。
|
||||
|
||||
优先查 Redis,Redis 不可用时降级到 DB。
|
||||
"""
|
||||
redis_client = _get_redis_client()
|
||||
if redis_client:
|
||||
try:
|
||||
key = self._daily_key(user_id)
|
||||
current = redis_client.get(key)
|
||||
if current is None:
|
||||
return True
|
||||
return int(current) < DAILY_FREE_CLIP_LIMIT
|
||||
except Exception:
|
||||
logger.warning("Redis 不可用,降级到 DB 查询每日额度")
|
||||
|
||||
# 降级到 DB
|
||||
_, _, _, DailyUsageRecordModel, _ = _get_models()
|
||||
today_start = datetime.now(UTC).replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
record = (
|
||||
db.query(DailyUsageRecordModel)
|
||||
.filter(
|
||||
DailyUsageRecordModel.user_id == user_id,
|
||||
DailyUsageRecordModel.usage_type == "free_clip",
|
||||
DailyUsageRecordModel.usage_date >= today_start,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
if record is None:
|
||||
return True
|
||||
return record.count < DAILY_FREE_CLIP_LIMIT
|
||||
|
||||
def record_daily_free_clip(self, user_id: str, db: Session) -> bool:
|
||||
"""记录使用一次免费混剪。
|
||||
|
||||
先 INCR Redis;如果超限回退 Redis。DB 使用 upsert 语义(唯一约束)。
|
||||
"""
|
||||
redis_client = _get_redis_client()
|
||||
if redis_client:
|
||||
try:
|
||||
key = self._daily_key(user_id)
|
||||
new_count = redis_client.incr(key)
|
||||
if new_count == 1:
|
||||
redis_client.expire(key, 48 * 3600) # TTL 48h
|
||||
if new_count <= DAILY_FREE_CLIP_LIMIT:
|
||||
return True
|
||||
# 超限,回退 Redis
|
||||
redis_client.decr(key)
|
||||
except Exception:
|
||||
logger.warning("Redis 不可用,降级到 DB 记录每日额度")
|
||||
|
||||
# 降级/兜底到 DB(upsert 语义)
|
||||
_, _, _, DailyUsageRecordModel, _ = _get_models()
|
||||
today_start = datetime.now(UTC).replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
|
||||
record = (
|
||||
db.query(DailyUsageRecordModel)
|
||||
.filter(
|
||||
DailyUsageRecordModel.user_id == user_id,
|
||||
DailyUsageRecordModel.usage_type == "free_clip",
|
||||
DailyUsageRecordModel.usage_date >= today_start,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
|
||||
if record is None:
|
||||
if DAILY_FREE_CLIP_LIMIT <= 0:
|
||||
return False
|
||||
record = DailyUsageRecordModel(
|
||||
id=uuid.uuid4().hex,
|
||||
user_id=user_id,
|
||||
usage_type="free_clip",
|
||||
usage_date=datetime.now(UTC),
|
||||
count=1,
|
||||
)
|
||||
db.add(record)
|
||||
else:
|
||||
if record.count >= DAILY_FREE_CLIP_LIMIT:
|
||||
return False
|
||||
record.count += 1
|
||||
|
||||
db.commit()
|
||||
return True
|
||||
|
||||
def get_daily_usage(self, user_id: str, db: Session) -> dict[str, Any]:
|
||||
"""查询今日免费额度使用情况(智能混剪已全免费,返回 unlimited)。"""
|
||||
"""查询今日免费额度使用情况。"""
|
||||
redis_client = _get_redis_client()
|
||||
used = 0
|
||||
|
||||
if redis_client:
|
||||
try:
|
||||
key = self._daily_key(user_id)
|
||||
val = redis_client.get(key)
|
||||
used = int(val) if val else 0
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if used == 0:
|
||||
# 从 DB 查
|
||||
_, _, _, DailyUsageRecordModel, _ = _get_models()
|
||||
today_start = datetime.now(UTC).replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
record = (
|
||||
db.query(DailyUsageRecordModel)
|
||||
.filter(
|
||||
DailyUsageRecordModel.user_id == user_id,
|
||||
DailyUsageRecordModel.usage_type == "free_clip",
|
||||
DailyUsageRecordModel.usage_date >= today_start,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
used = record.count if record else 0
|
||||
|
||||
now = datetime.now(UTC)
|
||||
tomorrow = (now + timedelta(days=1)).replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
|
||||
return {
|
||||
"free_clips_used": 0,
|
||||
"free_clips_limit": -1, # -1 表示 unlimited
|
||||
"free_clips_remaining": -1,
|
||||
"free_clips_used": used,
|
||||
"free_clips_limit": DAILY_FREE_CLIP_LIMIT,
|
||||
"free_clips_remaining": max(0, DAILY_FREE_CLIP_LIMIT - used),
|
||||
"reset_at": tomorrow.isoformat(),
|
||||
}
|
||||
|
||||
|
||||
+38
-106
@@ -1,11 +1,10 @@
|
||||
"""ViralVideoJob 领域模型 — 爆款视频任务.
|
||||
|
||||
v1.6 重大简化:Seedance 2.5 单次最长30秒,单次调用直接出片,不再分段/拼接/ffmpeg concat。
|
||||
状态机(三步分步):
|
||||
pending -> running -> image_analyzed -> running -> copy_generated -> running -> completed
|
||||
wait_user_confirm -> running -> completed (旧路径兼容)
|
||||
任意阶段 fail; 任意非终态 cancel.
|
||||
failed -> pending (retry 重置后重跑)。
|
||||
状态机:
|
||||
pending → running → completed
|
||||
↘ failed → pending (retry)
|
||||
↘ cancelled
|
||||
running 中可暂停:running → wait_user_confirm → running (confirm-intent resume)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -27,10 +26,10 @@ from uuid import uuid4
|
||||
|
||||
|
||||
class ViralVideoStatus(StrEnum):
|
||||
"""爆款视频任务状态枚举。"""
|
||||
|
||||
PENDING = "pending"
|
||||
RUNNING = "running"
|
||||
IMAGE_ANALYZED = "image_analyzed"
|
||||
COPY_GENERATED = "copy_generated"
|
||||
WAIT_USER_CONFIRM = "wait_user_confirm"
|
||||
COMPLETED = "completed"
|
||||
FAILED = "failed"
|
||||
@@ -38,52 +37,69 @@ class ViralVideoStatus(StrEnum):
|
||||
|
||||
|
||||
class ViralVideoStage(StrEnum):
|
||||
"""编排流水线阶段枚举(用于 WS 进度推送)。"""
|
||||
|
||||
IMAGE_ANALYSIS = "image_analysis"
|
||||
VIDEO_ANALYSIS = "video_analysis"
|
||||
INTENT_PARSING = "intent_parsing"
|
||||
SCRIPT_GENERATION = "script_generation" # v1.6: 编导分镜脚本(融合原 copy_fusion+storyboard+review)
|
||||
COPY_FUSION = "copy_fusion"
|
||||
STORYBOARD = "storyboard"
|
||||
REVIEW = "review"
|
||||
TTS = "tts"
|
||||
RENDERING = "rendering" # v1.6: 单次 Seedance 生成(BGM/音效/画面一次出片)
|
||||
BGM_SELECT = "bgm_select"
|
||||
RENDERING = "rendering"
|
||||
MUSETALK = "musetalk"
|
||||
UPLOADING = "uploading"
|
||||
|
||||
|
||||
class FusionLevel(StrEnum):
|
||||
"""文案融合级别。"""
|
||||
|
||||
AI_FULL = "ai_full"
|
||||
AI_POLISH = "ai_polish"
|
||||
USER_PRIMARY = "user_primary"
|
||||
|
||||
|
||||
class StyleStrength(StrEnum):
|
||||
"""风格强度。"""
|
||||
|
||||
LIGHT = "light"
|
||||
MEDIUM = "medium"
|
||||
STRICT = "strict"
|
||||
|
||||
|
||||
class PromptType(StrEnum):
|
||||
"""Prompt 模板类型(与 #2040 seed 对齐)。"""
|
||||
|
||||
IMAGE_ANALYSIS = "image_analysis"
|
||||
INTENT_PARSING = "intent_parsing"
|
||||
SCRIPT_GENERATION = "script_generation"
|
||||
COPY_FUSION = "copy_fusion"
|
||||
STORYBOARD = "storyboard"
|
||||
REVIEW = "review"
|
||||
VIDEO_STYLE_INTEGRATION = "video_style_integration"
|
||||
STYLE_CONSTRAINT = "style_constraint"
|
||||
|
||||
|
||||
CREDITS_VIRAL_VIDEO_COST = 50
|
||||
|
||||
STAGE_LABELS = {
|
||||
ViralVideoStage.IMAGE_ANALYSIS: "图片分析",
|
||||
ViralVideoStage.VIDEO_ANALYSIS: "视频风格分析",
|
||||
ViralVideoStage.INTENT_PARSING: "意图解析",
|
||||
ViralVideoStage.SCRIPT_GENERATION: "编导脚本生成",
|
||||
ViralVideoStage.COPY_FUSION: "文案融合",
|
||||
ViralVideoStage.STORYBOARD: "分镜脚本",
|
||||
ViralVideoStage.REVIEW: "合规审核",
|
||||
ViralVideoStage.TTS: "AI 配音",
|
||||
ViralVideoStage.RENDERING: "视频生成",
|
||||
ViralVideoStage.BGM_SELECT: "BGM 选择",
|
||||
ViralVideoStage.RENDERING: "视频渲染",
|
||||
ViralVideoStage.MUSETALK: "数字人口型",
|
||||
ViralVideoStage.UPLOADING: "上传发布",
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class ViralVideoJob:
|
||||
"""爆款视频任务领域实体(v1.6 单次 Seedance 出片版)。"""
|
||||
"""爆款视频任务领域实体。"""
|
||||
|
||||
user_id: str
|
||||
images: list[str] = field(default_factory=list)
|
||||
@@ -93,36 +109,23 @@ class ViralVideoJob:
|
||||
viral_structure: str = ""
|
||||
marketing_purpose: str = ""
|
||||
bgm_preference: str = ""
|
||||
duration: int = 15 # v1.6: 默认15秒,上限30秒(Seedance 2.5 单次最大30s)
|
||||
duration: int = 30
|
||||
user_copy_text: str = ""
|
||||
fusion_level: str = FusionLevel.AI_POLISH
|
||||
reference_audio_path: str = ""
|
||||
# v1.3
|
||||
reference_video_url: str = ""
|
||||
style_strength: str = StyleStrength.MEDIUM
|
||||
style_guide: dict | None = None
|
||||
style_template_id: str = ""
|
||||
# v1.5.1 音频/视频参数
|
||||
voice_id: str = ""
|
||||
voice_source: str = ""
|
||||
video_ratio: str = "9:16"
|
||||
video_model: str = ""
|
||||
# v1.4+ 产物
|
||||
# v1.4 图片分析结果(run_pipeline 持久化,resume 时读取给文案/分镜)
|
||||
image_analysis: dict | None = None
|
||||
intent_result: dict | None = None
|
||||
generated_copy_text: str = "" # v1.6: 存 voiceover_script(纯口播对白),字段名兼容
|
||||
storyboard: list | None = None # v1.6: 存 copy_result.shots,字段名兼容
|
||||
copy_result: dict | None = None # v1.6: 完整编导脚本结构
|
||||
# 状态
|
||||
id: str = field(default_factory=lambda: uuid4().hex)
|
||||
status: ViralVideoStatus = ViralVideoStatus.PENDING
|
||||
current_stage: str = "" # 细粒度阶段(ViralVideoStage.value,snake_case)
|
||||
phase_message: str = "" # 阶段中文提示文案,前端轮询直接展示
|
||||
heartbeat_at: datetime | None = None # worker 心跳时间,用于超时僵尸任务检测
|
||||
intent_result: dict | None = None
|
||||
result_video_url: str = ""
|
||||
video_resolution: str = "720p"
|
||||
credits_prepaid: float = 0.0
|
||||
credits_transaction_id: str = ""
|
||||
credits_cost: float = 0.0
|
||||
credits_cost: int = 0
|
||||
error_msg: str = ""
|
||||
retry_count: int = 0
|
||||
started_at: datetime | None = None
|
||||
@@ -130,54 +133,13 @@ class ViralVideoJob:
|
||||
created_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
|
||||
updated_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
|
||||
|
||||
# -- 状态转换 --
|
||||
# ── 状态转换 ──
|
||||
|
||||
def mark_running(self) -> None:
|
||||
if self.status not in (
|
||||
ViralVideoStatus.PENDING,
|
||||
ViralVideoStatus.IMAGE_ANALYZED,
|
||||
ViralVideoStatus.COPY_GENERATED,
|
||||
ViralVideoStatus.WAIT_USER_CONFIRM,
|
||||
ViralVideoStatus.RUNNING,
|
||||
):
|
||||
if self.status not in (ViralVideoStatus.PENDING, ViralVideoStatus.RUNNING):
|
||||
raise ValueError(f"Cannot transition from {self.status} to running")
|
||||
self.status = ViralVideoStatus.RUNNING
|
||||
now = datetime.now(timezone.utc)
|
||||
if self.started_at is None:
|
||||
self.started_at = now
|
||||
self.heartbeat_at = now
|
||||
self.updated_at = now
|
||||
|
||||
def touch_heartbeat(self) -> None:
|
||||
"""更新心跳时间(worker 在长任务中周期性调用,用于超时检测)。"""
|
||||
now = datetime.now(timezone.utc)
|
||||
if self.started_at is None:
|
||||
self.started_at = now
|
||||
self.heartbeat_at = now
|
||||
self.updated_at = now
|
||||
|
||||
def mark_image_analyzed(self) -> None:
|
||||
if self.status not in (ViralVideoStatus.PENDING, ViralVideoStatus.RUNNING):
|
||||
raise ValueError(f"Cannot transition from {self.status} to image_analyzed")
|
||||
self.status = ViralVideoStatus.IMAGE_ANALYZED
|
||||
if self.started_at is None:
|
||||
self.started_at = datetime.now(timezone.utc)
|
||||
self.updated_at = datetime.now(timezone.utc)
|
||||
|
||||
def mark_copy_generated(self, copy_result: dict) -> None:
|
||||
"""v1.6 阶段2完成:编导脚本(含 voiceover_script/shots/硬约束/负面词)已生成。"""
|
||||
if self.status not in (
|
||||
ViralVideoStatus.IMAGE_ANALYZED,
|
||||
ViralVideoStatus.RUNNING,
|
||||
ViralVideoStatus.PENDING,
|
||||
):
|
||||
raise ValueError(f"Cannot transition from {self.status} to copy_generated")
|
||||
self.status = ViralVideoStatus.COPY_GENERATED
|
||||
self.copy_result = copy_result or {}
|
||||
if isinstance(copy_result, dict):
|
||||
self.generated_copy_text = copy_result.get("voiceover_script", "") or ""
|
||||
shots = copy_result.get("shots") or []
|
||||
self.storyboard = list(shots) if isinstance(shots, list) else []
|
||||
self.started_at = datetime.now(timezone.utc)
|
||||
self.updated_at = datetime.now(timezone.utc)
|
||||
|
||||
def mark_wait_user_confirm(self, intent_result: dict) -> None:
|
||||
@@ -187,25 +149,6 @@ class ViralVideoJob:
|
||||
self.intent_result = intent_result
|
||||
self.updated_at = datetime.now(timezone.utc)
|
||||
|
||||
def resume_from_image_analyzed(self, **kwargs) -> None:
|
||||
if self.status not in (ViralVideoStatus.IMAGE_ANALYZED, ViralVideoStatus.PENDING):
|
||||
raise ValueError(f"Cannot resume from {self.status} to copy-gen")
|
||||
for k, v in kwargs.items():
|
||||
if hasattr(self, k) and v not in (None, "", []):
|
||||
setattr(self, k, v)
|
||||
self.status = ViralVideoStatus.RUNNING
|
||||
self.updated_at = datetime.now(timezone.utc)
|
||||
|
||||
def resume_from_copy_generated(self, edited_copy: str | None = None) -> None:
|
||||
"""阶段2->阶段3:用户确认/编辑口播文案,开始跑 TTS+单次Seedance渲染。"""
|
||||
if self.status != ViralVideoStatus.COPY_GENERATED:
|
||||
raise ValueError(f"Cannot resume from {self.status} to render")
|
||||
if edited_copy and isinstance(self.copy_result, dict):
|
||||
self.copy_result = {**self.copy_result, "voiceover_script": edited_copy}
|
||||
self.generated_copy_text = edited_copy
|
||||
self.status = ViralVideoStatus.RUNNING
|
||||
self.updated_at = datetime.now(timezone.utc)
|
||||
|
||||
def resume_from_confirm(self) -> None:
|
||||
if self.status != ViralVideoStatus.WAIT_USER_CONFIRM:
|
||||
raise ValueError(f"Cannot resume from {self.status}")
|
||||
@@ -238,14 +181,3 @@ class ViralVideoJob:
|
||||
ViralVideoStatus.FAILED,
|
||||
ViralVideoStatus.CANCELLED,
|
||||
)
|
||||
|
||||
@property
|
||||
def effective_copy_text(self) -> str:
|
||||
"""TTS 用的最终口播文案:优先 copy_result.voiceover_script,兼容老字段。"""
|
||||
if isinstance(self.copy_result, dict) and self.copy_result.get("voiceover_script"):
|
||||
return self.copy_result["voiceover_script"]
|
||||
return self.generated_copy_text or self.user_copy_text or "你好,给大家推荐一款好物"
|
||||
|
||||
@property
|
||||
def voiceover_script(self) -> str:
|
||||
return self.effective_copy_text
|
||||
|
||||
@@ -185,6 +185,19 @@ def _execute_with_gate_impl(
|
||||
is_member = getattr(user, "is_member", False)
|
||||
member_type = getattr(user, "member_type", None)
|
||||
|
||||
if scene_key == "ai_video":
|
||||
from packages.domain.points_service import PointsService
|
||||
|
||||
svc = PointsService()
|
||||
if not is_member:
|
||||
if svc.check_daily_free_clip(user.id, db):
|
||||
svc.record_daily_free_clip(user.id, db)
|
||||
kwargs["_points_deducted"] = 0
|
||||
kwargs["_is_free_quota"] = True
|
||||
if is_async:
|
||||
return _run_async_impl(func, args, _filter_kwargs_impl(func, kwargs))
|
||||
return func(*args, **_filter_kwargs_impl(func, kwargs))
|
||||
|
||||
if per_unit is not None:
|
||||
total_points = per_unit
|
||||
else:
|
||||
|
||||
+90
-613
@@ -22,153 +22,9 @@ import httpx
|
||||
|
||||
from packages.shared.config import get_shared_settings
|
||||
|
||||
# 网络/超时类异常父类集合:覆盖 Timeout/Connect/Network/ReadTimeout/WriteTimeout/PoolTimeout
|
||||
_HTTP_NETWORK_ERRORS = ()
|
||||
try:
|
||||
_HTTP_NETWORK_ERRORS = (httpx.TimeoutException, httpx.NetworkError)
|
||||
except Exception:
|
||||
_HTTP_NETWORK_ERRORS = (Exception,)
|
||||
|
||||
_HTTP_STATUS_ERROR = httpx.HTTPStatusError if hasattr(httpx, "HTTPStatusError") else Exception
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# 视频模型 ID 解析逻辑(#2159 多模型支持,#2169 接入即梦)。
|
||||
# 内部使用简短别名(seedance-2.5 / seedance-2.0 / wan-3.0 / jimeng-3.0 等)做 PRICING key;
|
||||
# 实际调用时按 VIRAL_VIDEO_MODEL_CONFIG(domain/points_rules.py)的 provider/model_id 分发。
|
||||
# - provider=doubao → 火山方舟 Seedance
|
||||
# - provider=dashscope → 阿里云 DashScope(Wan 系列)
|
||||
# - provider=jimeng → 火山引擎即梦 cvtob(jimeng_i2v_first_v30,真人参考图走 C 端审核)
|
||||
|
||||
|
||||
def _resolve_video_provider_and_id(model: str | None) -> tuple[str, str, dict]:
|
||||
"""把内部 model key 解析成 (provider, model_id, cfg)。
|
||||
|
||||
- provider: "doubao" | "dashscope" | "jimeng"
|
||||
- model_id: 对应 API 的真实模型 ID
|
||||
- cfg: VIRAL_VIDEO_MODEL_CONFIG 条目
|
||||
未识别或空值回落到默认 seedance-2.5。已经是 doubao-/ep- 开头的完整 ID 视为 doubao provider;
|
||||
"jimeng" 开头视为 jimeng provider(内部兜底,不暴露给前端)。
|
||||
"""
|
||||
from packages.domain.points_rules import get_viral_video_model_config
|
||||
|
||||
settings = get_shared_settings()
|
||||
default_id = getattr(settings, "doubao_video_model", None) or "doubao-seedance-2-5-260628"
|
||||
m = (model or "").strip()
|
||||
if not m:
|
||||
cfg = get_viral_video_model_config("seedance-2.5")
|
||||
return "doubao", default_id, cfg
|
||||
# 已经是 doubao-/ep- 开头:直接透传,默认视为 doubao provider
|
||||
if m.startswith("doubao-") or m.startswith("ep-"):
|
||||
return "doubao", m, {"provider": "doubao", "model_id": m, "supports_audio": True}
|
||||
# 显式 jimeng 关键字:路由到即梦(内部兜底通道使用)
|
||||
if m.startswith("jimeng"):
|
||||
cfg = get_viral_video_model_config("jimeng-3.0")
|
||||
return "jimeng", cfg.get("model_id", "jimeng_i2v_first_v30"), cfg
|
||||
# 别名 → 从 domain config 查
|
||||
cfg = get_viral_video_model_config(m)
|
||||
provider = cfg.get("provider", "doubao")
|
||||
resolved_id = cfg.get("model_id", "")
|
||||
if not resolved_id:
|
||||
logger.warning("[ai_client] model %r 无 model_id,回落到默认 %s", m, default_id)
|
||||
return "doubao", default_id, cfg
|
||||
return provider, resolved_id, cfg
|
||||
|
||||
|
||||
def _resolve_video_model_id(model: str | None) -> str:
|
||||
"""兼容旧调用:只返回 doubao model_id。wan/dashscope 调用方应直接用 _resolve_video_provider_and_id。"""
|
||||
_provider, mid, _cfg = _resolve_video_provider_and_id(model)
|
||||
return mid
|
||||
|
||||
|
||||
# ── 视频错误分类(给前端/用户展示友好提示)────────────────────────────
|
||||
|
||||
|
||||
def _classify_video_error(status_code: int, body: str, err: Exception | None) -> tuple[str, str]:
|
||||
"""根据 HTTP 状态码和响应 body 判断错误类型。
|
||||
|
||||
返回 (error_code, user_message):
|
||||
- error_code: 机器可读的错误码("portrait_intercept" / "quota_exceeded" / "model_not_found"
|
||||
/ "invalid_param" / "auth_error" / "rate_limit" / "network_error" / "task_failed" / "unknown")
|
||||
- user_message: 给用户看的中文提示
|
||||
"""
|
||||
body_lower = (body or "").lower()
|
||||
code_in_body = ""
|
||||
msg_in_body = ""
|
||||
try:
|
||||
import json as _json
|
||||
|
||||
parsed = _json.loads(body or "{}")
|
||||
if isinstance(parsed, dict):
|
||||
err_obj = parsed.get("error") or {}
|
||||
if isinstance(err_obj, dict):
|
||||
code_in_body = str(err_obj.get("code", "") or "")
|
||||
msg_in_body = str(err_obj.get("message", "") or err_obj.get("msg", "") or "")
|
||||
else:
|
||||
msg_in_body = str(parsed.get("message", "") or "")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 真人肖像/内容安全拦截
|
||||
if (
|
||||
status_code == 400
|
||||
and any(
|
||||
kw in body_lower
|
||||
for kw in ("portrait", "real_face", "human_face", "真人", "肖像", "人脸", "privacy", "real person", "face")
|
||||
)
|
||||
) or (
|
||||
"content" in body_lower
|
||||
and ("risk" in body_lower or "block" in body_lower or "reject" in body_lower)
|
||||
and status_code == 400
|
||||
):
|
||||
return (
|
||||
"portrait_intercept",
|
||||
"参考素材包含真人照片被安全策略拦截,AI视频模型暂不支持上传真人照片作为参考图,请移除真人图片后重试。",
|
||||
)
|
||||
|
||||
# 配额/计费问题
|
||||
if status_code in (402, 429) or any(
|
||||
kw in body_lower for kw in ("quota", "billing", "insufficient", "欠费", "余额", "限流", "rate limit")
|
||||
):
|
||||
if "rate" in body_lower or status_code == 429:
|
||||
return "rate_limit", "视频生成服务当前繁忙(限流),请稍等1-2分钟后重试。"
|
||||
return "quota_exceeded", "视频生成服务配额不足,请联系管理员充值或稍后重试。"
|
||||
|
||||
# 模型/Endpoint 不存在
|
||||
if status_code == 404 or any(
|
||||
kw in body_lower for kw in ("model not found", "endpoint not found", "不存在", "not found", "model_not_exist")
|
||||
):
|
||||
return "model_not_found", f"视频模型未开通或模型ID无效({code_in_body or ''}),请联系管理员。"
|
||||
|
||||
# 鉴权失败
|
||||
if status_code in (401, 403):
|
||||
return "auth_error", "视频生成服务鉴权失败(API Key无效或过期),请联系管理员。"
|
||||
|
||||
# 任务本身失败(轮询阶段拿到 status=failed)
|
||||
if err and "task failed" in str(err).lower():
|
||||
detail = msg_in_body or str(err)[:200]
|
||||
# 失败原因里再细分真人拦截
|
||||
if any(kw in detail.lower() for kw in ("portrait", "真人", "肖像", "人脸", "content_risk")):
|
||||
return (
|
||||
"portrait_intercept",
|
||||
"视频内容被安全策略拦截(疑似包含真人肖像),请更换参考图或调整文案后重试。",
|
||||
)
|
||||
return "task_failed", f"视频生成失败:{detail}"
|
||||
|
||||
# 参数错误
|
||||
if status_code == 400:
|
||||
return "invalid_param", f"视频生成参数错误:{msg_in_body or body[:200]}"
|
||||
|
||||
# 网络/连接问题
|
||||
if status_code == 0:
|
||||
return "network_error", "视频生成服务连接失败(网络超时),请稍后重试。"
|
||||
|
||||
# 默认
|
||||
detail = msg_in_body or (str(err) if err else "") or body[:200]
|
||||
return "unknown", f"视频生成失败(HTTP {status_code}):{detail}"
|
||||
|
||||
|
||||
class DoubaoClient:
|
||||
"""豆包大模型 API 客户端.
|
||||
|
||||
@@ -184,11 +40,6 @@ class DoubaoClient:
|
||||
self.timeout: int = settings.doubao_timeout
|
||||
self.max_retries: int = settings.doubao_max_retries
|
||||
self.vision_model: str = settings.doubao_vision_model
|
||||
self.vision_lite_model: str = settings.doubao_vision_lite_model
|
||||
self.fast_model: str = settings.doubao_fast_model
|
||||
self.embedding_model: str = settings.doubao_embedding_model
|
||||
# 最近一次视频生成的详细错误(error_code + user_message + raw detail),供上层读取后展示给用户
|
||||
self.last_video_error: dict = {}
|
||||
|
||||
def embed_text(self, text: str, timeout: int | None = None) -> list[float] | None:
|
||||
"""调用豆包文本 Embedding API,返回浮点向量;失败返回 None。"""
|
||||
@@ -201,7 +52,7 @@ class DoubaoClient:
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
payload: dict[str, Any] = {
|
||||
"model": self.embedding_model,
|
||||
"model": getattr(self, "embedding_model", None) or "doubao-embedding-large-text-240915",
|
||||
"input": text.strip(),
|
||||
"encoding_format": "float",
|
||||
}
|
||||
@@ -241,7 +92,6 @@ class DoubaoClient:
|
||||
messages: list[dict[str, str]],
|
||||
temperature: float = 0.7,
|
||||
max_tokens: int = 1024,
|
||||
model: str | None = None,
|
||||
) -> Optional[str]:
|
||||
"""调用 Chat Completion 接口.
|
||||
|
||||
@@ -262,7 +112,7 @@ class DoubaoClient:
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
payload: dict[str, Any] = {
|
||||
"model": model or self.model,
|
||||
"model": self.model,
|
||||
"messages": messages,
|
||||
"temperature": temperature,
|
||||
"max_tokens": max_tokens,
|
||||
@@ -304,12 +154,11 @@ class DoubaoClient:
|
||||
max_tokens: int = 2048,
|
||||
temperature: float = 0.3,
|
||||
timeout: int | None = None,
|
||||
model: str | None = None,
|
||||
) -> Optional[str]:
|
||||
"""调用豆包视觉理解 API(OpenAI 兼容多模态格式).
|
||||
|
||||
将 images 附加到最后一条 user message 的 content 中,
|
||||
使用 vision_model(默认 doubao-1-5-vision-pro-250328)。
|
||||
使用 vision_model(默认 doubao-1-5-vision-pro-250915)。
|
||||
|
||||
Args:
|
||||
messages: 对话消息列表。最后一条 user message 会被注入图片内容。
|
||||
@@ -355,7 +204,7 @@ class DoubaoClient:
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
payload: dict[str, Any] = {
|
||||
"model": model or self.vision_model,
|
||||
"model": self.vision_model,
|
||||
"messages": vision_messages,
|
||||
"temperature": temperature,
|
||||
"max_tokens": max_tokens,
|
||||
@@ -401,151 +250,50 @@ class DoubaoClient:
|
||||
duration: int = 5,
|
||||
ratio: str | None = "9:16",
|
||||
resolution: str = "720p",
|
||||
generate_audio: bool = True,
|
||||
generate_audio: bool = False,
|
||||
watermark: bool = False,
|
||||
output_dir: str | None = None,
|
||||
model: str | None = None,
|
||||
reference_images: list[str] | None = None,
|
||||
reference_audios: list[str] | None = None,
|
||||
reference_videos: list[str] | None = None,
|
||||
) -> dict | None:
|
||||
"""调用 Seedance 2.5 生视频(异步任务→轮询→下载)。
|
||||
) -> str | None:
|
||||
"""调用 Seedance 2.5 文生/图生视频(异步任务→轮询→下载),返回本地 MP4 路径;失败返回 None。
|
||||
|
||||
成功返回 {"video_path": str, "usage": dict | None},失败返回 None。
|
||||
失败时把详细错误信息(HTTP状态码、响应 body、分类后的用户提示)写入 self.last_video_error,
|
||||
上层可通过 get_last_video_error() 读取并展示给用户,不再笼统显示"返回为空"。
|
||||
Args:
|
||||
prompt: 文本提示词
|
||||
image_url: 首帧参考图 URL(可选,提供则走图生视频)
|
||||
duration: 视频时长 2~30 秒,默认 5
|
||||
ratio: 宽高比 16:9/9:16/1:1/4:3/3:4/21:9/adaptive
|
||||
resolution: 480p/720p/1080p
|
||||
generate_audio: 是否生成模型自带音效(默认 False,我们自己混 TTS)
|
||||
watermark: 是否加水印
|
||||
output_dir: 下载目录,默认 /tmp
|
||||
|
||||
【v1.6.1 修复】严格按官方 content 数组协议构造请求:
|
||||
- 所有参考(图/音/视)必须放进 content 数组并带 role 字段,不能放顶层 reference_audios/reference_videos(非官方字段,会被忽略或导致异常)。
|
||||
- 首帧图(first_frame 模式)Seedance 2.5 强制 ratio=adaptive;走 omni_reference(参考生视频)模式时才能指定 9:16/1:1 等具体比例。
|
||||
判定:传了参考音频/视频或 ≥1 张多参考图时,走 omni_reference(首张图 role=reference_image);纯首帧无参考时走 first_frame(ratio 强制 adaptive)。
|
||||
- 创建任务若因 ratio 报错(HTTP 400),自动回退到 ratio=adaptive 重试一次。
|
||||
Returns:
|
||||
本地 MP4 文件路径,失败返回 None。
|
||||
"""
|
||||
# 每次调用前清空上次错误
|
||||
self.last_video_error = {}
|
||||
|
||||
if not self.is_available:
|
||||
self.last_video_error = {
|
||||
"error_code": "auth_error",
|
||||
"user_message": "视频生成服务未配置(API Key 缺失),请联系管理员。",
|
||||
"status_code": 0,
|
||||
"detail": "DoubaoClient not available (api_key empty)",
|
||||
}
|
||||
return None
|
||||
if not prompt or not prompt.strip():
|
||||
self.last_video_error = {
|
||||
"error_code": "invalid_param",
|
||||
"user_message": "视频生成提示词不能为空。",
|
||||
"status_code": 0,
|
||||
"detail": "empty prompt",
|
||||
}
|
||||
return None
|
||||
|
||||
settings = get_shared_settings()
|
||||
poll_interval = getattr(settings, "doubao_video_poll_interval", 10) or 10
|
||||
# 收紧总超时:轮询 8min + 下载 2min = 最长 ~10min,防止出现 20min 卡死
|
||||
total_timeout = getattr(settings, "doubao_video_timeout", 480) or 480
|
||||
# 内部 key → (provider, 实际模型 ID, cfg),按 provider 分发
|
||||
provider, video_model, model_cfg = _resolve_video_provider_and_id(model)
|
||||
if provider == "dashscope":
|
||||
from packages.shared.dashscope_client import get_dashscope_client
|
||||
total_timeout = getattr(settings, "doubao_video_timeout", 600) or 600
|
||||
video_model = getattr(settings, "doubao_video_model", None) or "doubao-seedance-2-5-260628"
|
||||
|
||||
ds = get_dashscope_client()
|
||||
if ds is None:
|
||||
err_msg = "DashScope client 不可用(未配置 DASHSCOPE_API_KEY)"
|
||||
logger.error("%s, video_model=%s", err_msg, model)
|
||||
self.last_video_error = {
|
||||
"error_code": "auth_error",
|
||||
"user_message": "Wan 3.0 视频模型未配置 API Key,请联系管理员。",
|
||||
"status_code": 0,
|
||||
"detail": err_msg,
|
||||
}
|
||||
return None
|
||||
try:
|
||||
# DashScope 客户端也设置 last_video_error 语义(如果它支持)
|
||||
if hasattr(ds, "last_video_error"):
|
||||
ds.last_video_error = {}
|
||||
result = ds.video_generation(
|
||||
prompt=prompt,
|
||||
image_url=image_url,
|
||||
duration=duration,
|
||||
ratio=ratio,
|
||||
resolution=resolution,
|
||||
output_dir=output_dir,
|
||||
model=video_model,
|
||||
)
|
||||
if not result and hasattr(ds, "last_video_error") and ds.last_video_error:
|
||||
self.last_video_error = dict(ds.last_video_error)
|
||||
return result
|
||||
except Exception as de:
|
||||
logger.error("DashScope video_generation 异常: %s", de, exc_info=True)
|
||||
self.last_video_error = {
|
||||
"error_code": "unknown",
|
||||
"user_message": f"Wan 3.0 视频生成异常:{de!s}"[:200],
|
||||
"status_code": 0,
|
||||
"detail": str(de),
|
||||
}
|
||||
return None
|
||||
|
||||
if provider == "jimeng":
|
||||
# #2169: 即梦 cvtob(jimeng_i2v_first_v30)— 真人参考图兜底通道
|
||||
return self._call_jimeng_video_generation(
|
||||
prompt=prompt,
|
||||
image_url=image_url,
|
||||
duration=duration,
|
||||
ratio=ratio,
|
||||
resolution=resolution,
|
||||
output_dir=output_dir,
|
||||
generate_audio=generate_audio,
|
||||
)
|
||||
|
||||
ref_audios = [u for u in (reference_audios or [])[:10] if u and isinstance(u, str)]
|
||||
ref_videos = [u for u in (reference_videos or [])[:3] if u and isinstance(u, str)]
|
||||
ref_imgs = [u for u in (reference_images or [])[:9] if u and isinstance(u, str)]
|
||||
|
||||
# 判断任务模式:有参考音/视/多图 → omni_reference(支持指定 ratio);纯首帧 → first_frame(ratio=adaptive)
|
||||
has_extra_refs = bool(ref_audios or ref_videos or ref_imgs)
|
||||
is_first_frame_mode = bool(image_url) and not has_extra_refs
|
||||
# 最终 ratio:first_frame 模式强制 adaptive,否则按用户传值(默认 9:16)
|
||||
final_ratio = "adaptive" if is_first_frame_mode else (ratio or "9:16")
|
||||
|
||||
# 构造 content 数组:text + 图 + 音 + 视
|
||||
content: list[dict[str, Any]] = [{"type": "text", "text": prompt.strip()}]
|
||||
if image_url:
|
||||
if has_extra_refs:
|
||||
# omni_reference:首张图作为 reference_image,允许指定 ratio
|
||||
content.append(
|
||||
{
|
||||
"type": "image_url",
|
||||
"image_url": {"url": image_url},
|
||||
"role": "reference_image",
|
||||
}
|
||||
)
|
||||
else:
|
||||
# 纯首帧:显式 role=first_frame
|
||||
content.append(
|
||||
{
|
||||
"type": "image_url",
|
||||
"image_url": {"url": image_url},
|
||||
"role": "first_frame",
|
||||
}
|
||||
)
|
||||
for u in ref_imgs:
|
||||
content.append({"type": "image_url", "image_url": {"url": u}, "role": "reference_image"})
|
||||
for u in ref_audios:
|
||||
content.append({"type": "audio_url", "audio_url": {"url": u}, "role": "reference_audio"})
|
||||
for u in ref_videos:
|
||||
content.append({"type": "video_url", "video_url": {"url": u}, "role": "reference_video"})
|
||||
content.append({"type": "image_url", "image_url": {"url": image_url}})
|
||||
|
||||
create_payload: dict[str, Any] = {
|
||||
"model": video_model,
|
||||
"content": content,
|
||||
"generate_audio": bool(generate_audio),
|
||||
"generate_audio": generate_audio,
|
||||
"duration": int(duration),
|
||||
"resolution": resolution,
|
||||
"watermark": bool(watermark),
|
||||
"ratio": final_ratio,
|
||||
"watermark": watermark,
|
||||
}
|
||||
# Bug #2110: ratio=None 时不传(首帧图生视频跟随原图比例,传 ratio 会 400 InvalidParameter)
|
||||
if ratio:
|
||||
create_payload["ratio"] = ratio
|
||||
|
||||
headers = {
|
||||
"Authorization": f"Bearer {self.api_key}",
|
||||
@@ -553,398 +301,127 @@ class DoubaoClient:
|
||||
}
|
||||
create_url = f"{self.base_url}/contents/generations/tasks"
|
||||
logger.info(
|
||||
"Seedance 创建任务: model=%s dur=%ds ratio=%s mode=%s gen_audio=%s img=%d aud=%d vid=%d",
|
||||
"Seedance 创建任务请求: url=%s model=%s duration=%ds ratio=%s gen_audio=%s image_url=%s",
|
||||
create_url,
|
||||
video_model,
|
||||
duration,
|
||||
final_ratio,
|
||||
"first_frame" if is_first_frame_mode else "omni_ref",
|
||||
ratio or "(follow-image)",
|
||||
generate_audio,
|
||||
(1 if image_url else 0) + len(ref_imgs),
|
||||
len(ref_audios),
|
||||
len(ref_videos),
|
||||
bool(image_url),
|
||||
)
|
||||
# 打印完整 payload 便于排查(截断 prompt)
|
||||
debug_payload = dict(create_payload)
|
||||
if "content" in debug_payload:
|
||||
dbg_content = []
|
||||
for item in debug_payload["content"]:
|
||||
item_copy = dict(item)
|
||||
if item_copy.get("type") == "text" and isinstance(item_copy.get("text"), str):
|
||||
item_copy["text"] = item_copy["text"][:200] + ("..." if len(item_copy["text"]) > 200 else "")
|
||||
dbg_content.append(item_copy)
|
||||
debug_payload["content"] = dbg_content
|
||||
logger.info("Seedance 创建任务 payload: %s", json_safe_dumps(debug_payload))
|
||||
|
||||
def _do_create(payload: dict) -> tuple[str | None, Exception | None, int, str]:
|
||||
"""返回 (task_id, last_err, status_code, body_text)。"""
|
||||
last_err: Exception | None = None
|
||||
last_sc = 0
|
||||
last_body = ""
|
||||
for attempt in range(self.max_retries + 1):
|
||||
# 1) 创建任务(带重试)
|
||||
task_id: str | None = None
|
||||
last_error: Exception | None = None
|
||||
for attempt in range(self.max_retries + 1):
|
||||
try:
|
||||
resp = httpx.post(create_url, headers=headers, json=create_payload, timeout=self.timeout)
|
||||
# 测试环境下 MagicMock().status_code 是 MagicMock,与 int 比较会抛 TypeError;
|
||||
# 用显式 int() 转换+类型判断,避免误判。
|
||||
try:
|
||||
resp = httpx.post(create_url, headers=headers, json=payload, timeout=self.timeout)
|
||||
sc = int(getattr(resp, "status_code", 0) or 0)
|
||||
body = (getattr(resp, "text", "") or "")[:2000]
|
||||
last_sc = sc
|
||||
last_body = body
|
||||
if sc >= 400:
|
||||
logger.error("Seedance 创建任务 HTTP %d: body=%s", sc, body)
|
||||
try:
|
||||
resp.raise_for_status()
|
||||
except Exception as ee:
|
||||
last_err = ee
|
||||
if attempt < self.max_retries and sc >= 500:
|
||||
# 仅 5xx 重试,4xx 不重试(参数/鉴权/配额错误重试无意义)
|
||||
time.sleep(0.5 * (2**attempt))
|
||||
continue
|
||||
return None, last_err, sc, body
|
||||
data = resp.json()
|
||||
tid = data.get("id")
|
||||
if tid:
|
||||
return tid, None, sc, body
|
||||
last_err = RuntimeError(f"create ok but no id: {str(data)[:300]}")
|
||||
except _HTTP_NETWORK_ERRORS as ne:
|
||||
last_err = ne
|
||||
last_sc = 0
|
||||
last_body = f"network error: {ne}"
|
||||
logger.warning(
|
||||
"Seedance 创建网络异常(%s),重试 %d/%d", type(ne).__name__, attempt + 1, self.max_retries + 1
|
||||
_status = int(resp.status_code)
|
||||
except (TypeError, ValueError):
|
||||
_status = 200
|
||||
if _status >= 400:
|
||||
# 把响应体完整打出来(通常含 error.code/message,能直接定位:模型未开通/Key 无权限/模型 ID 错误)
|
||||
logger.error(
|
||||
"Seedance 创建任务 HTTP %d: body=%s",
|
||||
resp.status_code,
|
||||
(resp.text or "")[:1000],
|
||||
)
|
||||
if attempt < self.max_retries:
|
||||
time.sleep(0.5 * (2**attempt))
|
||||
continue
|
||||
except Exception as e:
|
||||
last_err = e
|
||||
if attempt < self.max_retries and not isinstance(e, _HTTP_STATUS_ERROR):
|
||||
wait = 0.5 * (2**attempt)
|
||||
logger.warning(
|
||||
"Seedance 创建任务失败,%.1fs 后重试 (%d/%d): %s",
|
||||
wait,
|
||||
attempt + 1,
|
||||
self.max_retries + 1,
|
||||
e,
|
||||
)
|
||||
time.sleep(wait)
|
||||
return None, last_err, last_sc, last_body
|
||||
|
||||
# 第一次尝试
|
||||
task_id, last_err, sc, body = _do_create(create_payload)
|
||||
|
||||
# ratio 兜底:HTTP 400 且 body 提到 ratio / adaptive → 回退 adaptive 再试一次
|
||||
if (
|
||||
not task_id
|
||||
and sc == 400
|
||||
and final_ratio != "adaptive"
|
||||
and (
|
||||
"ratio" in (body or "").lower()
|
||||
or "aspect" in (body or "").lower()
|
||||
or "adaptive" in (body or "").lower()
|
||||
)
|
||||
):
|
||||
logger.warning("Seedance 创建因 ratio 失败,回退 ratio=adaptive 重试")
|
||||
create_payload["ratio"] = "adaptive"
|
||||
task_id, last_err, sc2, body2 = _do_create(create_payload)
|
||||
if task_id:
|
||||
sc, body = sc2, body2
|
||||
else:
|
||||
# 保留第二次的错误信息
|
||||
sc, body = sc2, body2
|
||||
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
task_id = data.get("id")
|
||||
if task_id:
|
||||
break
|
||||
last_error = RuntimeError(f"create task returned no id: {str(data)[:200]}")
|
||||
except Exception as e:
|
||||
last_error = e
|
||||
if attempt < self.max_retries:
|
||||
wait = 0.5 * (2**attempt)
|
||||
logger.warning(
|
||||
"Seedance 创建任务失败,%.1fs 后重试 (%d/%d): %s", wait, attempt + 1, self.max_retries + 1, e
|
||||
)
|
||||
time.sleep(wait)
|
||||
if not task_id:
|
||||
err_code, user_msg = _classify_video_error(sc, body, last_err)
|
||||
self.last_video_error = {
|
||||
"error_code": err_code,
|
||||
"user_message": user_msg,
|
||||
"status_code": sc,
|
||||
"detail": (body or "")[:500] or (str(last_err) if last_err else ""),
|
||||
"model": video_model,
|
||||
"base_url": self.base_url,
|
||||
}
|
||||
logger.error(
|
||||
"Seedance 创建任务最终失败: model=%s base_url=%s status=%d code=%s err=%s body=%s",
|
||||
"Seedance 创建任务最终失败: model=%s base_url=%s err=%s 【排查建议】"
|
||||
"1) 确认方舟控制台已开通 Doubao-Seedance-2.5 模型;"
|
||||
"2) DOUBAO_API_KEY 对应的账号有该模型调用权限;"
|
||||
"3) DOUBAO_BASE_URL 必须为 https://ark.cn-beijing.volces.com/api/v3;"
|
||||
"4) 若控制台用「推理接入点」(endpoint),请把 DOUBAO_VIDEO_MODEL 改为 ep-xxx 接入点 ID。",
|
||||
video_model,
|
||||
self.base_url,
|
||||
sc,
|
||||
err_code,
|
||||
last_err,
|
||||
(body or "")[:500],
|
||||
last_error,
|
||||
)
|
||||
# #2169: 方舟返回 portrait_intercept 且有参考图 → 自动切即梦重试一次(保留首帧图)
|
||||
if err_code == "portrait_intercept" and image_url:
|
||||
logger.warning(
|
||||
"[viral-video] Seedance 真人拦截(code=%s),自动切即梦通道重试(首帧图) img=%s",
|
||||
err_code,
|
||||
bool(image_url),
|
||||
)
|
||||
jm_result = self._call_jimeng_video_generation(
|
||||
prompt=prompt,
|
||||
image_url=image_url,
|
||||
duration=duration,
|
||||
ratio=ratio,
|
||||
resolution=resolution,
|
||||
output_dir=output_dir,
|
||||
generate_audio=False, # 即梦 i2v 不带音频,音频由后续 ffmpeg 合成
|
||||
_portrait_fallback=True,
|
||||
)
|
||||
if jm_result is not None:
|
||||
return jm_result
|
||||
# 即梦也失败了,保留即梦的 last_video_error(已经由 _call_jimeng 设置)
|
||||
logger.error("[viral-video] 即梦通道重试也失败: %s", self.last_video_error)
|
||||
return None
|
||||
return None
|
||||
|
||||
logger.info("Seedance 任务已创建: task_id=%s ratio=%s", task_id, create_payload["ratio"])
|
||||
logger.info("Seedance 任务已创建: task_id=%s model=%s duration=%ds", task_id, video_model, duration)
|
||||
|
||||
# 2) 轮询状态
|
||||
poll_url = f"{create_url}/{task_id}"
|
||||
deadline = time.time() + total_timeout
|
||||
video_url: str | None = None
|
||||
usage: dict | None = None
|
||||
last_status: str = "queued"
|
||||
poll_count = 0
|
||||
last_poll_body: str = ""
|
||||
last_poll_sc: int = 0
|
||||
while time.time() < deadline:
|
||||
poll_count += 1
|
||||
try:
|
||||
resp = httpx.get(poll_url, headers=headers, timeout=self.timeout)
|
||||
last_poll_sc = int(getattr(resp, "status_code", 200) or 200)
|
||||
last_poll_body = (getattr(resp, "text", "") or "")[:1500]
|
||||
if last_poll_sc >= 400:
|
||||
logger.warning("Seedance 轮询 HTTP %d: %s", last_poll_sc, last_poll_body[:300])
|
||||
if poll_count < 3:
|
||||
time.sleep(poll_interval)
|
||||
continue
|
||||
last_err = RuntimeError(f"poll HTTP {last_poll_sc}: {last_poll_body[:200]}")
|
||||
break
|
||||
try:
|
||||
if int(getattr(resp, "status_code", 200)) >= 400:
|
||||
resp.raise_for_status()
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
data = resp.json()
|
||||
status = data.get("status", "")
|
||||
last_status = status
|
||||
if status == "succeeded":
|
||||
content_obj = data.get("content") or {}
|
||||
video_url = content_obj.get("video_url")
|
||||
usage = data.get("usage") or content_obj.get("usage") or None
|
||||
if video_url:
|
||||
logger.info("Seedance 任务成功: task_id=%s polls=%d usage=%s", task_id, poll_count, usage)
|
||||
break
|
||||
# 成功但没 video_url:记录完整响应便于排查
|
||||
logger.error(
|
||||
"Seedance succeeded 但无 video_url: task_id=%s full_response=%s",
|
||||
task_id,
|
||||
str(data)[:1000],
|
||||
)
|
||||
last_err = RuntimeError("task succeeded but no video_url in response")
|
||||
last_poll_body = str(data)[:1000]
|
||||
last_error = RuntimeError(f"task succeeded but no video_url: {str(data)[:300]}")
|
||||
break
|
||||
if status == "failed":
|
||||
err = data.get("error") or {}
|
||||
err_code = str(err.get("code", "") or "")
|
||||
err_msg = str(err.get("message", "") or err.get("msg", "") or "")
|
||||
last_err = RuntimeError(f"task failed: code={err_code} msg={err_msg}")
|
||||
logger.error("Seedance 任务失败 task_id=%s code=%s msg=%s", task_id, err_code, err_msg)
|
||||
last_poll_body = str(data)[:1000]
|
||||
last_error = RuntimeError(f"task failed: {err.get('code','')} {err.get('message','')}")
|
||||
break
|
||||
if status in ("expired", "cancelled"):
|
||||
last_err = RuntimeError(f"task {status}")
|
||||
logger.error("Seedance 任务 %s: task_id=%s", status, task_id)
|
||||
last_error = RuntimeError(f"task {status}")
|
||||
break
|
||||
# 每 5 次轮询打一次 info 日志,便于观察进度
|
||||
if poll_count % 5 == 0:
|
||||
logger.info("Seedance 轮询中: task_id=%s status=%s polls=%d", task_id, status, poll_count)
|
||||
# queued / running: 继续轮询
|
||||
except httpx.HTTPStatusError as e:
|
||||
last_err = e
|
||||
last_poll_sc = e.response.status_code
|
||||
last_poll_body = (e.response.text or "")[:500]
|
||||
logger.warning("Seedance 轮询 HTTP %d: %s", e.response.status_code, last_poll_body[:300])
|
||||
last_error = e
|
||||
logger.warning(
|
||||
"Seedance 轮询 HTTP %d: body=%s",
|
||||
e.response.status_code,
|
||||
(e.response.text or "")[:500],
|
||||
)
|
||||
except Exception as e:
|
||||
last_err = e
|
||||
last_error = e
|
||||
logger.debug("Seedance 轮询异常: %s", e)
|
||||
time.sleep(poll_interval)
|
||||
|
||||
if not video_url:
|
||||
# 区分轮询超时 vs 任务失败
|
||||
if last_status in ("queued", "running", "pending") and poll_count > 0 and time.time() >= deadline:
|
||||
err_code, user_msg = (
|
||||
"network_error",
|
||||
f"视频生成超时(>{total_timeout}s),任务仍在排队,请稍后重试或联系管理员。",
|
||||
)
|
||||
detail = f"timeout after {total_timeout}s, polls={poll_count}, last_status={last_status}"
|
||||
else:
|
||||
err_code, user_msg = _classify_video_error(last_poll_sc, last_poll_body, last_err)
|
||||
detail = (last_poll_body or "")[:500] or (str(last_err) if last_err else f"last_status={last_status}")
|
||||
self.last_video_error = {
|
||||
"error_code": err_code,
|
||||
"user_message": user_msg,
|
||||
"status_code": last_poll_sc,
|
||||
"detail": detail,
|
||||
"task_id": task_id,
|
||||
"last_status": last_status,
|
||||
}
|
||||
logger.error(
|
||||
"Seedance 任务未成功: task_id=%s last_status=%s polls=%d code=%s err=%s (总等待 %.0fs)",
|
||||
task_id,
|
||||
last_status,
|
||||
poll_count,
|
||||
err_code,
|
||||
last_err,
|
||||
total_timeout,
|
||||
)
|
||||
logger.error("Seedance 任务未成功: task_id=%s status=%s err=%s", task_id, last_status, last_error)
|
||||
return None
|
||||
|
||||
# 3) 下载到本地(下载超时收紧到 120s)
|
||||
# 3) 下载到本地
|
||||
try:
|
||||
out_dir = output_dir or "/tmp"
|
||||
os.makedirs(out_dir, exist_ok=True)
|
||||
local_path = f"{out_dir}/seedance_{task_id}_{uuid.uuid4().hex[:8]}.mp4"
|
||||
download_timeout = 120.0
|
||||
logger.info(
|
||||
"Seedance 开始下载: task_id=%s url=%s timeout=%.0fs", task_id, video_url[:120], download_timeout
|
||||
)
|
||||
with httpx.stream("GET", video_url, timeout=download_timeout) as r:
|
||||
with httpx.stream("GET", video_url, timeout=300) as r:
|
||||
r.raise_for_status()
|
||||
downloaded = 0
|
||||
with open(local_path, "wb") as f:
|
||||
for chunk in r.iter_bytes(chunk_size=1024 * 256):
|
||||
if chunk:
|
||||
f.write(chunk)
|
||||
downloaded += len(chunk)
|
||||
size = os.path.getsize(local_path)
|
||||
logger.info("Seedance 视频下载完成: %s size=%d bytes", local_path, size)
|
||||
if size == 0:
|
||||
logger.error("Seedance 下载文件大小为 0")
|
||||
try:
|
||||
os.remove(local_path)
|
||||
except Exception:
|
||||
pass
|
||||
self.last_video_error = {
|
||||
"error_code": "unknown",
|
||||
"user_message": "视频生成成功但下载文件为空,请稍后重试。",
|
||||
"status_code": 0,
|
||||
"detail": f"downloaded 0 bytes from {video_url[:120]}",
|
||||
}
|
||||
return None
|
||||
return {"video_path": local_path, "usage": usage}
|
||||
logger.info("Seedance 视频下载完成: %s (%d bytes)", local_path, os.path.getsize(local_path))
|
||||
return local_path
|
||||
except Exception as e:
|
||||
logger.error("Seedance 视频下载失败: %s", e, exc_info=True)
|
||||
self.last_video_error = {
|
||||
"error_code": "network_error",
|
||||
"user_message": f"视频下载失败:{e!s}"[:200],
|
||||
"status_code": 0,
|
||||
"detail": str(e),
|
||||
}
|
||||
logger.error("Seedance 视频下载失败: %s", e)
|
||||
return None
|
||||
|
||||
def _call_jimeng_video_generation(
|
||||
self,
|
||||
*,
|
||||
prompt: str,
|
||||
image_url: str | None,
|
||||
duration: int,
|
||||
ratio: str | None,
|
||||
resolution: str,
|
||||
output_dir: str | None,
|
||||
generate_audio: bool = False,
|
||||
_portrait_fallback: bool = False,
|
||||
) -> dict | None:
|
||||
"""#2169: 调用即梦 cvtob 客户端做图生视频(真人参考图兜底通道)。
|
||||
|
||||
- 即梦 i2v 首帧接口只接受 1 张图、无原生音频(返回无声视频,音频由 ffmpeg 后合)。
|
||||
- 成功返回 {"video_path": str, "usage": {...}};失败写 self.last_video_error 并返回 None。
|
||||
- _portrait_fallback=True 时在日志里标注是从方舟拦截切过来的。
|
||||
"""
|
||||
from packages.shared.jimeng_client import get_jimeng_client
|
||||
|
||||
jm = get_jimeng_client()
|
||||
if jm is None:
|
||||
detail = "即梦 client 不可用(JIMENG_AK/SK 未配置)"
|
||||
if _portrait_fallback:
|
||||
# 从真人拦截切过来但即梦没配,仍把错误归到 portrait_intercept,让上层提示用户
|
||||
self.last_video_error = {
|
||||
"error_code": "portrait_intercept",
|
||||
"user_message": "参考素材包含真人照片被安全策略拦截,即梦兜底通道未启用,请联系管理员配置 JIMENG_AK/SK。",
|
||||
"status_code": 0,
|
||||
"detail": detail,
|
||||
"provider": "jimeng",
|
||||
}
|
||||
else:
|
||||
self.last_video_error = {
|
||||
"error_code": "auth_error",
|
||||
"user_message": "即梦视频通道未配置(JIMENG_AK/SK 缺失),请联系管理员。",
|
||||
"status_code": 0,
|
||||
"detail": detail,
|
||||
"provider": "jimeng",
|
||||
}
|
||||
logger.error("[jimeng] %s, portrait_fallback=%s", detail, _portrait_fallback)
|
||||
return None
|
||||
if not image_url:
|
||||
self.last_video_error = {
|
||||
"error_code": "invalid_param",
|
||||
"user_message": "即梦图生视频必须提供参考图片。",
|
||||
"status_code": 0,
|
||||
"detail": "empty image_url for jimeng i2v",
|
||||
"provider": "jimeng",
|
||||
}
|
||||
return None
|
||||
# 即梦 i2v 无声视频,generate_audio 强制 False
|
||||
jm.last_video_error = {}
|
||||
tag = "[portrait-fallback→jimeng]" if _portrait_fallback else "[jimeng-direct]"
|
||||
logger.info("%s 调用即梦: dur=%s ratio=%s res=%s img=%s", tag, duration, ratio, resolution, bool(image_url))
|
||||
try:
|
||||
result = jm.video_generation(
|
||||
prompt=prompt,
|
||||
image_url=image_url,
|
||||
duration=duration,
|
||||
ratio=ratio,
|
||||
resolution=resolution,
|
||||
output_dir=output_dir,
|
||||
generate_audio=False,
|
||||
)
|
||||
except Exception as je:
|
||||
logger.error("%s 即梦 video_generation 异常: %s", tag, je, exc_info=True)
|
||||
self.last_video_error = {
|
||||
"error_code": "unknown",
|
||||
"user_message": f"即梦视频生成异常:{je!s}"[:200],
|
||||
"status_code": 0,
|
||||
"detail": str(je),
|
||||
"provider": "jimeng",
|
||||
}
|
||||
return None
|
||||
if not result and jm.last_video_error:
|
||||
# 透传即梦错误;如果即梦也返回 portrait_intercept,说明图片真的有问题,直接给用户
|
||||
jm_err = dict(jm.last_video_error)
|
||||
jm_err["provider"] = "jimeng"
|
||||
if _portrait_fallback and jm_err.get("error_code") == "portrait_intercept":
|
||||
jm_err["user_message"] = (
|
||||
"参考素材真人肖像审核未通过(方舟+即梦双通道均被拦截),请更换非真人或授权清晰的照片后重试。"
|
||||
)
|
||||
self.last_video_error = jm_err
|
||||
return None
|
||||
if result:
|
||||
# 补充 usage 里的 provider 标记
|
||||
u = result.get("usage") or {}
|
||||
u.setdefault("provider", "jimeng")
|
||||
u.setdefault("model_key", "jimeng-3.0")
|
||||
result["usage"] = u
|
||||
logger.info("%s 即梦生成成功: %s", tag, result.get("video_path"))
|
||||
return result
|
||||
|
||||
def get_last_video_error(self) -> dict:
|
||||
"""返回最近一次 video_generation 失败的详细错误。空 dict 表示上次成功或未调用。"""
|
||||
return dict(self.last_video_error or {})
|
||||
|
||||
|
||||
def json_safe_dumps(obj: Any, max_len: int = 2000) -> str:
|
||||
"""安全 json 序列化,失败则 fallback 到 repr,超长截断。"""
|
||||
try:
|
||||
import json as _json
|
||||
|
||||
s = _json.dumps(obj, ensure_ascii=False, default=str)
|
||||
except Exception:
|
||||
s = repr(obj)
|
||||
if len(s) > max_len:
|
||||
s = s[:max_len] + f"...(truncated, total {len(s)})"
|
||||
return s
|
||||
|
||||
|
||||
# ── 单例 ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -496,32 +496,16 @@ def run_generate_cover(
|
||||
# ── 通用 LLM / Vision 调用(#2039 ViralVideoOrchestrator 使用,复用现有豆包客户端)──
|
||||
|
||||
|
||||
def call_llm(
|
||||
prompt: str,
|
||||
temperature: float = 0.7,
|
||||
max_tokens: int = 2048,
|
||||
model: str | None = None,
|
||||
system_prompt: str | None = None,
|
||||
) -> object:
|
||||
"""调用豆包大模型(文本对话),返回解析后的 JSON(dict/list)或原文字符串;失败返回 None。
|
||||
|
||||
Args:
|
||||
prompt: 用户侧提示。
|
||||
temperature: 采样温度。
|
||||
max_tokens: 输出上限(结构化任务默认 2048,长文案可按需加大)。
|
||||
model: 覆盖默认模型(如 fast_model 提速用),None 走配置默认推理模型。
|
||||
system_prompt: 覆盖默认 system prompt。
|
||||
"""
|
||||
def call_llm(prompt: str, temperature: float = 0.7) -> object:
|
||||
"""调用豆包大模型(文本对话),返回解析后的 JSON(dict/list)或原文字符串;失败返回 None。"""
|
||||
client = get_doubao_client()
|
||||
if not client.is_available:
|
||||
return None
|
||||
if system_prompt is None:
|
||||
system_prompt = "你是专业的短视频内容策划助手。需要结构化输出时请严格使用 JSON。"
|
||||
messages = [
|
||||
{"role": "system", "content": system_prompt},
|
||||
{"role": "system", "content": "你是专业的短视频内容策划助手。需要结构化输出时请严格使用 JSON。"},
|
||||
{"role": "user", "content": prompt},
|
||||
]
|
||||
raw = client.chat_completion(messages, temperature=temperature, max_tokens=max_tokens, model=model)
|
||||
raw = client.chat_completion(messages, temperature=temperature, max_tokens=4096)
|
||||
if raw is None:
|
||||
return None
|
||||
try:
|
||||
@@ -530,26 +514,14 @@ def call_llm(
|
||||
return raw
|
||||
|
||||
|
||||
def call_vision(
|
||||
image_url: str,
|
||||
prompt: str,
|
||||
*,
|
||||
model: str | None = None,
|
||||
max_tokens: int = 1024,
|
||||
temperature: float = 0.2,
|
||||
timeout: int = 45,
|
||||
system_prompt: str | None = None,
|
||||
) -> object:
|
||||
def call_vision(image_url: str, prompt: str) -> object:
|
||||
"""调用豆包视觉大模型分析图片,返回解析后的 JSON 或原文字符串;失败返回 None。
|
||||
|
||||
Args:
|
||||
image_url: 可公网访问的图片 URL(直接传给豆包视觉模型,无需本地下载)。
|
||||
prompt: 用户侧文本提示。
|
||||
model: 覆盖默认视觉模型(如 vision_lite_model 提速用),None 走配置默认。
|
||||
max_tokens: 输出上限,商品识别用 800~1200 足够,避免长输出拖慢首 token。
|
||||
temperature: 温度。
|
||||
timeout: 单次请求超时(秒)。
|
||||
system_prompt: 覆盖默认 system prompt(viral-video 商品分析会传专门的详细 prompt)。
|
||||
Bug #2114 (VLM 牛头不对马嘴根因修复):
|
||||
之前误走 client.chat_completion(用文本模型 doubao-seed-1.6),多模态 content list 被当成
|
||||
纯文本发给文本模型 → 模型要么看不到图、要么抛 400,静默被 except 吞掉 → 返回 None →
|
||||
_step_image_analysis fallback 到 {"name":"未识别"} → 后续文案/分镜完全没图的信息。
|
||||
现改走 vision_completion,走视觉模型 doubao-1-5-vision-pro-250915。
|
||||
"""
|
||||
client = get_doubao_client()
|
||||
if not client.is_available:
|
||||
@@ -559,33 +531,28 @@ def call_vision(
|
||||
logger.warning("[call_vision] 空 image_url,跳过视觉分析")
|
||||
return None
|
||||
|
||||
if system_prompt is None:
|
||||
system_prompt = (
|
||||
"你是资深电商视觉分析师。请严格基于用户提供的图片观察回答,"
|
||||
"图片里没有的信息不要凭空想象或编造;看不清或无法判断时明确说"
|
||||
"「无法判断」,不要猜测。输出必须是严格 JSON,不要附加 Markdown 或解释文字。"
|
||||
)
|
||||
system_prompt = (
|
||||
"你是资深电商视觉分析师。请严格基于用户提供的图片观察回答,"
|
||||
"图片里没有的信息不要凭空想象或编造;看不清或无法判断时明确说"
|
||||
"「图片中无法判断」,不要猜测。输出必须是严格 JSON,不要附加 Markdown 或解释文字。"
|
||||
)
|
||||
messages = [
|
||||
{"role": "system", "content": system_prompt},
|
||||
{"role": "user", "content": prompt},
|
||||
]
|
||||
|
||||
used_model = model or getattr(client, "vision_model", "?")
|
||||
logger.info(
|
||||
"[call_vision] 调用豆包视觉模型 model=%s image_url=%s prompt_len=%d max_tokens=%d timeout=%d",
|
||||
used_model,
|
||||
"[call_vision] 调用豆包视觉模型 vision_model=%s image_url=%s prompt_len=%d",
|
||||
getattr(client, "vision_model", "?"),
|
||||
image_url[:120],
|
||||
len(prompt),
|
||||
max_tokens,
|
||||
timeout,
|
||||
)
|
||||
raw = client.vision_completion(
|
||||
messages=messages,
|
||||
images=[image_url],
|
||||
temperature=temperature,
|
||||
max_tokens=max_tokens,
|
||||
timeout=timeout,
|
||||
model=model,
|
||||
temperature=0.2,
|
||||
max_tokens=2048,
|
||||
timeout=60,
|
||||
)
|
||||
if raw is None:
|
||||
logger.warning("[call_vision] 视觉模型返回 None (image_url=%s)", image_url[:80])
|
||||
@@ -608,69 +575,36 @@ def call_video_generation(
|
||||
prompt: str,
|
||||
*,
|
||||
image_url: str | None = None,
|
||||
duration: int = 15,
|
||||
duration: int = 5,
|
||||
ratio: str | None = "9:16",
|
||||
resolution: str = "720p",
|
||||
output_dir: str | None = None,
|
||||
model: str | None = None,
|
||||
generate_audio: bool = True,
|
||||
reference_images: list[str] | None = None,
|
||||
reference_audios: list[str] | None = None,
|
||||
reference_videos: list[str] | None = None,
|
||||
) -> dict | None:
|
||||
"""调用 Seedance / Wan 视频生成(v1.6.2 多模型版)。
|
||||
) -> str | None:
|
||||
"""调用 Seedance 2.5 生成视频段,返回本地 MP4 路径;失败返回 None。
|
||||
|
||||
成功返回 {"video_path": str, "usage": dict | None}(usage 含 completion_tokens),失败返回 None。
|
||||
失败时错误详情会写入 client.last_video_error,可通过 get_last_video_error() 读取:
|
||||
{"error_code": str, "user_message": str, "status_code": int, "detail": str, ...}
|
||||
封装 ai_client.video_generation:提交异步任务→轮询→下载到本地。
|
||||
Bug #2110: 首帧参考图模式下不传 ratio(API 要求跟随首帧图比例,传 ratio=9:16
|
||||
会返回 400 InvalidParameter)。
|
||||
"""
|
||||
client = get_doubao_client()
|
||||
if not client.is_available:
|
||||
msg = "豆包客户端未配置(DOUBAO_API_KEY 缺失),跳过视频生成"
|
||||
logger.warning("[ai_service] %s", msg)
|
||||
# 写入 last_video_error 供上层读取
|
||||
client.last_video_error = {
|
||||
"error_code": "auth_error",
|
||||
"user_message": "视频生成服务未配置,请联系管理员。",
|
||||
"status_code": 0,
|
||||
"detail": msg,
|
||||
}
|
||||
logger.warning("[ai_service] 豆包客户端未配置,跳过视频生成")
|
||||
return None
|
||||
effective_ratio = ratio or "9:16"
|
||||
# 首帧模式:不强制 ratio,让模型跟随首帧图比例
|
||||
effective_ratio = None if image_url else ratio
|
||||
try:
|
||||
kwargs: dict = dict(
|
||||
prompt=prompt,
|
||||
image_url=image_url,
|
||||
duration=int(duration),
|
||||
duration=duration,
|
||||
resolution=resolution,
|
||||
generate_audio=bool(generate_audio),
|
||||
generate_audio=False, # 我们自己混 TTS
|
||||
watermark=False,
|
||||
output_dir=output_dir,
|
||||
model=model,
|
||||
reference_images=reference_images,
|
||||
reference_audios=reference_audios,
|
||||
reference_videos=reference_videos,
|
||||
)
|
||||
if effective_ratio:
|
||||
kwargs["ratio"] = effective_ratio
|
||||
return client.video_generation(**kwargs)
|
||||
except Exception as e:
|
||||
logger.error("[ai_service] call_video_generation 异常: %s", e, exc_info=True)
|
||||
client.last_video_error = {
|
||||
"error_code": "unknown",
|
||||
"user_message": f"视频生成异常:{e!s}"[:200],
|
||||
"status_code": 0,
|
||||
"detail": str(e),
|
||||
}
|
||||
return None
|
||||
|
||||
|
||||
def get_last_video_error() -> dict:
|
||||
"""读取最近一次视频生成失败的详细错误(含 error_code/user_message/status_code/detail)。
|
||||
成功或未调用过返回空 dict。
|
||||
"""
|
||||
try:
|
||||
client = get_doubao_client()
|
||||
return client.get_last_video_error() if hasattr(client, "get_last_video_error") else {}
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
@@ -1,344 +0,0 @@
|
||||
"""DashScope 客户端(阿里云百炼 Wan 3.0 等非方舟模型)。
|
||||
|
||||
#2159: 新增 Wan 3.0 视频生成支持。DashScope 异步协议:
|
||||
- POST {base_url}/services/aigc/video-generation/video-synthesis (X-DashScope-Async: enable)
|
||||
→ 返回 output.task_id
|
||||
- GET {base_url}/tasks/{task_id} 轮询状态
|
||||
→ SUCCEEDED 时 output.video_url 可下载
|
||||
认证:Authorization: Bearer {DASHSCOPE_API_KEY}
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from urllib.parse import urlparse
|
||||
|
||||
import httpx
|
||||
|
||||
from packages.shared.config import get_shared_settings
|
||||
|
||||
# 网络/超时类异常父类集合:覆盖 Timeout/Connect/Network/ReadTimeout/WriteTimeout/PoolTimeout
|
||||
_HTTP_NETWORK_ERRORS = ()
|
||||
try:
|
||||
_HTTP_NETWORK_ERRORS = (httpx.TimeoutException, httpx.NetworkError)
|
||||
except Exception:
|
||||
_HTTP_NETWORK_ERRORS = (Exception,)
|
||||
|
||||
_HTTP_STATUS_ERROR = httpx.HTTPStatusError if hasattr(httpx, "HTTPStatusError") else Exception
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_DASHSCOPE_CLIENT_SINGLETON: "DashScopeClient | None" = None
|
||||
|
||||
|
||||
def _classify_dashscope_error(status_code: int, body: str, task_msg: str = "") -> tuple[str, str]:
|
||||
"""DashScope 错误分类,返回 (error_code, user_message)。"""
|
||||
body_lower = (body or "").lower()
|
||||
msg_in_body = task_msg or ""
|
||||
try:
|
||||
import json as _json
|
||||
|
||||
parsed = _json.loads(body or "{}")
|
||||
if isinstance(parsed, dict):
|
||||
msg_in_body = msg_in_body or str(parsed.get("message", "") or "")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if status_code in (401, 403):
|
||||
return "auth_error", "Wan 3.0 服务鉴权失败(DASHSCOPE_API_KEY 无效或过期),请联系管理员。"
|
||||
if status_code == 429 or "rate" in body_lower or "throttl" in body_lower:
|
||||
return "rate_limit", "Wan 3.0 服务繁忙(限流),请稍等1-2分钟后重试。"
|
||||
if status_code == 400 and any(
|
||||
kw in body_lower for kw in ("portrait", "真人", "人脸", "肖像", "content_violation", "risk", "blocked")
|
||||
):
|
||||
return (
|
||||
"portrait_intercept",
|
||||
"参考素材包含真人照片或违规内容被安全策略拦截,请移除真人图片或调整文案后重试。",
|
||||
)
|
||||
if status_code == 404 or ("not found" in body_lower) or ("model" in body_lower and "not exist" in body_lower):
|
||||
return "model_not_found", "Wan 3.0 模型未开通或模型ID无效,请联系管理员。"
|
||||
if status_code in (402, 400) and ("quota" in body_lower or "billing" in body_lower or "insufficient" in body_lower):
|
||||
return "quota_exceeded", "Wan 3.0 服务配额不足,请联系管理员充值或稍后重试。"
|
||||
if status_code == 400:
|
||||
return "invalid_param", f"Wan 3.0 参数错误:{msg_in_body or body[:200]}"
|
||||
if status_code == 0:
|
||||
return "network_error", "Wan 3.0 服务连接失败(网络超时),请稍后重试。"
|
||||
# 任务内失败
|
||||
if task_msg and any(kw in task_msg.lower() for kw in ("portrait", "真人", "人脸", "violation", "blocked")):
|
||||
return "portrait_intercept", "Wan 3.0 视频内容被安全策略拦截,请调整文案或参考图后重试。"
|
||||
detail = msg_in_body or body[:200]
|
||||
return "unknown", f"Wan 3.0 视频生成失败(HTTP {status_code}):{detail}"
|
||||
|
||||
|
||||
class DashScopeClient:
|
||||
"""阿里云 DashScope 异步 API 客户端(Wan 3.0 等视频生成)。"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
settings = get_shared_settings()
|
||||
self.api_key: str = getattr(settings, "dashscope_api_key", "") or os.getenv("DASHSCOPE_API_KEY", "")
|
||||
self.base_url: str = (
|
||||
getattr(settings, "dashscope_base_url", "") or "https://dashscope.aliyuncs.com/api/v1"
|
||||
).rstrip("/")
|
||||
self.poll_interval: int = int(getattr(settings, "dashscope_video_poll_interval", 10) or 10)
|
||||
self.total_timeout: int = int(getattr(settings, "dashscope_video_timeout", 900) or 900)
|
||||
self.max_retries: int = 2
|
||||
self.last_video_error: dict = {}
|
||||
|
||||
@property
|
||||
def is_available(self) -> bool:
|
||||
return bool(self.api_key)
|
||||
|
||||
def get_last_video_error(self) -> dict:
|
||||
return dict(self.last_video_error or {})
|
||||
|
||||
def _set_error(self, error_code: str, user_message: str, status_code: int = 0, detail: str = "", **extra) -> None:
|
||||
self.last_video_error = {
|
||||
"error_code": error_code,
|
||||
"user_message": user_message,
|
||||
"status_code": status_code,
|
||||
"detail": detail[:500] if detail else "",
|
||||
**extra,
|
||||
}
|
||||
|
||||
def video_generation(
|
||||
self,
|
||||
prompt: str,
|
||||
*,
|
||||
image_url: str | None = None,
|
||||
duration: int = 5,
|
||||
ratio: str | None = "9:16",
|
||||
resolution: str = "720p",
|
||||
watermark: bool = False,
|
||||
output_dir: str | None = None,
|
||||
model: str = "wan3.0-video",
|
||||
) -> dict | None:
|
||||
"""调用 DashScope 异步视频合成接口,轮询完成后下载到本地。
|
||||
|
||||
返回 {"video_path": str, "usage": dict | None};失败返回 None,错误详情写入 self.last_video_error。
|
||||
"""
|
||||
self.last_video_error = {}
|
||||
if not self.is_available:
|
||||
self._set_error("auth_error", "Wan 3.0 API key 未配置,请联系管理员。", detail="dashscope api_key empty")
|
||||
logger.error("[dashscope] API key 未配置,无法调用视频生成")
|
||||
return None
|
||||
if not prompt or not prompt.strip():
|
||||
self._set_error("invalid_param", "视频生成提示词不能为空。", detail="empty prompt")
|
||||
return None
|
||||
|
||||
# DashScope 分辨率参数:720P / 1080P / 480P(大写 P)
|
||||
res_upper = (resolution or "720p").upper().replace("P", "P")
|
||||
if res_upper == "480P":
|
||||
ds_res = "480P"
|
||||
elif res_upper == "1080P":
|
||||
ds_res = "1080P"
|
||||
else:
|
||||
ds_res = "720P"
|
||||
|
||||
# 构造 input+parameters
|
||||
input_obj: dict[str, Any] = {"prompt": prompt.strip()}
|
||||
if image_url:
|
||||
input_obj["img_url"] = image_url
|
||||
params: dict[str, Any] = {
|
||||
"resolution": ds_res,
|
||||
"duration": str(float(duration)),
|
||||
"watermark": bool(watermark),
|
||||
}
|
||||
# 比例透传:Wan 支持 "9:16" / "16:9" / "1:1" 等
|
||||
if ratio and ratio != "adaptive":
|
||||
params["aspect_ratio"] = ratio
|
||||
|
||||
payload: dict[str, Any] = {
|
||||
"model": model,
|
||||
"input": input_obj,
|
||||
"parameters": params,
|
||||
}
|
||||
headers = {
|
||||
"Authorization": f"Bearer {self.api_key}",
|
||||
"Content-Type": "application/json",
|
||||
"X-DashScope-Async": "enable",
|
||||
}
|
||||
create_url = f"{self.base_url}/services/aigc/video-generation/video-synthesis"
|
||||
logger.info(
|
||||
"[dashscope] 创建任务: model=%s dur=%ds ratio=%s res=%s img=%s",
|
||||
model,
|
||||
duration,
|
||||
ratio,
|
||||
ds_res,
|
||||
bool(image_url),
|
||||
)
|
||||
logger.info("[dashscope] 创建任务 payload: model=%s params=%s", model, params)
|
||||
|
||||
# 创建任务
|
||||
task_id: str | None = None
|
||||
last_sc = 0
|
||||
last_body = ""
|
||||
for attempt in range(self.max_retries + 1):
|
||||
try:
|
||||
resp = httpx.post(create_url, headers=headers, json=payload, timeout=60)
|
||||
sc = int(getattr(resp, "status_code", 0) or 0)
|
||||
body_text = (getattr(resp, "text", "") or "")[:2000]
|
||||
last_sc = sc
|
||||
last_body = body_text
|
||||
if sc >= 400:
|
||||
logger.error("[dashscope] 创建任务 HTTP %d: %s", sc, body_text)
|
||||
if sc >= 500 and attempt < self.max_retries:
|
||||
time.sleep(0.5 * (2**attempt))
|
||||
continue
|
||||
err_code, user_msg = _classify_dashscope_error(sc, body_text)
|
||||
self._set_error(err_code, user_msg, sc, body_text, model=model)
|
||||
return None
|
||||
data = resp.json()
|
||||
tid = (data.get("output") or {}).get("task_id")
|
||||
if tid:
|
||||
task_id = tid
|
||||
break
|
||||
# 部分情况下 code != 错误
|
||||
code = data.get("code")
|
||||
if code and code != "":
|
||||
err_code, user_msg = _classify_dashscope_error(400, body_text, str(code))
|
||||
self._set_error(err_code, user_msg, sc, body_text, model=model)
|
||||
return None
|
||||
else:
|
||||
self._set_error("unknown", "Wan 3.0 响应格式异常,未返回任务ID", sc, str(data)[:500], model=model)
|
||||
return None
|
||||
except _HTTP_NETWORK_ERRORS as ne:
|
||||
last_sc = 0
|
||||
last_body = f"network error: {ne}"
|
||||
logger.warning(
|
||||
"[dashscope] 网络异常 %s,重试 %d/%d", type(ne).__name__, attempt + 1, self.max_retries + 1
|
||||
)
|
||||
if attempt < self.max_retries:
|
||||
time.sleep(0.5 * (2**attempt))
|
||||
continue
|
||||
self._set_error("network_error", "Wan 3.0 服务连接失败(网络超时),请稍后重试。", 0, str(ne))
|
||||
return None
|
||||
except Exception as _e:
|
||||
if attempt < self.max_retries:
|
||||
time.sleep(0.5 * (2**attempt))
|
||||
continue
|
||||
logger.error("[dashscope] 创建任务最终失败: %s", _e)
|
||||
self._set_error("unknown", f"Wan 3.0 创建任务异常:{_e!s}"[:200], 0, str(_e))
|
||||
return None
|
||||
if not task_id:
|
||||
if not self.last_video_error:
|
||||
err_code, user_msg = _classify_dashscope_error(last_sc, last_body)
|
||||
self._set_error(err_code, user_msg, last_sc, last_body, model=model)
|
||||
return None
|
||||
|
||||
# 轮询任务
|
||||
poll_url = f"{self.base_url}/tasks/{task_id}"
|
||||
deadline = time.time() + self.total_timeout
|
||||
video_url: str | None = None
|
||||
usage: dict | None = None
|
||||
poll_count = 0
|
||||
last_status = ""
|
||||
while time.time() < deadline:
|
||||
poll_count += 1
|
||||
try:
|
||||
r = httpx.get(poll_url, headers=headers, timeout=30)
|
||||
psc = int(getattr(r, "status_code", 0) or 0)
|
||||
pbody = (getattr(r, "text", "") or "")[:1500]
|
||||
if psc >= 400:
|
||||
logger.warning("[dashscope] 轮询 HTTP %d: %s", psc, pbody[:300])
|
||||
if poll_count < 3:
|
||||
time.sleep(self.poll_interval)
|
||||
continue
|
||||
err_code, user_msg = _classify_dashscope_error(psc, pbody)
|
||||
self._set_error(err_code, user_msg, psc, pbody, task_id=task_id)
|
||||
return None
|
||||
d = r.json()
|
||||
out = d.get("output") or {}
|
||||
task_status = out.get("task_status") or d.get("task_status") or ""
|
||||
last_status = task_status
|
||||
if task_status == "SUCCEEDED":
|
||||
video_url = out.get("video_url") or ""
|
||||
usage = d.get("usage")
|
||||
if not video_url:
|
||||
# 结果在 results 数组
|
||||
results = out.get("results") or []
|
||||
if results and isinstance(results, list):
|
||||
video_url = results[0].get("url") or results[0].get("video_url")
|
||||
if video_url:
|
||||
logger.info("[dashscope] 任务 %s 完成: %s", task_id, video_url[:120])
|
||||
break
|
||||
logger.error("[dashscope] 任务 %s SUCCEEDED 但无 video_url: %s", task_id, str(d)[:500])
|
||||
self._set_error(
|
||||
"unknown",
|
||||
"Wan 3.0 任务成功但未返回视频URL,请联系管理员。",
|
||||
200,
|
||||
str(d)[:500],
|
||||
task_id=task_id,
|
||||
)
|
||||
return None
|
||||
if task_status in ("FAILED", "FAILED_WITH_ERROR", "ERROR"):
|
||||
msg = out.get("message") or d.get("message") or out.get("error_msg") or "unknown error"
|
||||
logger.error("[dashscope] 任务 %s 失败: %s", task_id, msg)
|
||||
err_code, user_msg = _classify_dashscope_error(200, "", msg)
|
||||
self._set_error(err_code, user_msg, 200, msg, task_id=task_id, last_status=task_status)
|
||||
return None
|
||||
if task_status in ("CANCELED", "CANCELLED"):
|
||||
logger.warning("[dashscope] 任务 %s 被取消", task_id)
|
||||
self._set_error("unknown", "Wan 3.0 任务被取消。", 200, "task cancelled", task_id=task_id)
|
||||
return None
|
||||
# PENDING / RUNNING / SUSPENDED → 继续轮询
|
||||
if poll_count % 5 == 0:
|
||||
logger.info("[dashscope] 轮询中 task=%s status=%s polls=%d", task_id, task_status, poll_count)
|
||||
except Exception as e:
|
||||
logger.warning("[dashscope] 轮询异常: %s", e)
|
||||
time.sleep(self.poll_interval)
|
||||
if not video_url:
|
||||
logger.error("[dashscope] 任务 %s 轮询超时(%ds)", task_id, self.total_timeout)
|
||||
self._set_error(
|
||||
"network_error",
|
||||
f"Wan 3.0 视频生成超时(>{self.total_timeout}s),任务仍在排队,请稍后重试。",
|
||||
0,
|
||||
f"timeout after {self.total_timeout}s, polls={poll_count}, last_status={last_status}",
|
||||
task_id=task_id,
|
||||
last_status=last_status,
|
||||
)
|
||||
return None
|
||||
|
||||
# 下载视频
|
||||
out_dir = output_dir or os.path.join(os.getcwd(), "seedance_outputs")
|
||||
os.makedirs(out_dir, exist_ok=True)
|
||||
suffix = Path(urlparse(video_url).path).suffix or ".mp4"
|
||||
if suffix.lower() not in (".mp4", ".mov", ".webm"):
|
||||
suffix = ".mp4"
|
||||
safe_tid = "".join(c if c.isalnum() or c in "-_" else "_" for c in task_id)[:40]
|
||||
out_path = os.path.join(out_dir, f"wan_{safe_tid}{suffix}")
|
||||
try:
|
||||
with httpx.stream("GET", video_url, timeout=300, follow_redirects=True) as resp:
|
||||
dsc = int(getattr(resp, "status_code", 0) or 0)
|
||||
if dsc >= 400:
|
||||
logger.error("[dashscope] 下载 HTTP %d", dsc)
|
||||
self._set_error("network_error", "Wan 3.0 视频下载失败(HTTP错误),请稍后重试。", dsc)
|
||||
return None
|
||||
with open(out_path, "wb") as f:
|
||||
for chunk in resp.iter_bytes(chunk_size=1024 * 256):
|
||||
if chunk:
|
||||
f.write(chunk)
|
||||
except Exception as e:
|
||||
logger.error("[dashscope] 下载视频失败: %s", e, exc_info=True)
|
||||
self._set_error("network_error", f"Wan 3.0 视频下载失败:{e!s}"[:200], 0, str(e))
|
||||
return None
|
||||
size = os.path.getsize(out_path) if os.path.exists(out_path) else 0
|
||||
if size < 1024:
|
||||
logger.error("[dashscope] 下载文件过小: %d bytes", size)
|
||||
self._set_error("unknown", "Wan 3.0 视频下载文件过小,请稍后重试。", 0, f"downloaded only {size} bytes")
|
||||
return None
|
||||
logger.info("[dashscope] 视频已下载: %s (%d bytes)", out_path, size)
|
||||
return {"video_path": out_path, "usage": usage}
|
||||
|
||||
|
||||
def get_dashscope_client() -> DashScopeClient | None:
|
||||
"""返回 DashScope 客户端单例;未配置 API key 时返回 None。"""
|
||||
global _DASHSCOPE_CLIENT_SINGLETON
|
||||
if _DASHSCOPE_CLIENT_SINGLETON is None:
|
||||
_DASHSCOPE_CLIENT_SINGLETON = DashScopeClient()
|
||||
if not _DASHSCOPE_CLIENT_SINGLETON.is_available:
|
||||
return None
|
||||
return _DASHSCOPE_CLIENT_SINGLETON
|
||||
@@ -1,526 +0,0 @@
|
||||
"""即梦(Jimeng)视觉 API 客户端 —— 火山引擎 cvtob。
|
||||
|
||||
#2169: 真人参考图在方舟 Seedance 走 B 端审核会被 50411 拦截,
|
||||
即梦走 C 端审核链路,普通真人照片可过审。接入即梦 i2v 作为参考图场景兜底通道。
|
||||
|
||||
接口协议(jimeng_i2v_first_v30 —— 视频3.0 720P 首帧图生视频):
|
||||
- 接口地址:https://visual.volcengineapi.com
|
||||
- 鉴权:火山 V4 签名(Region=cn-north-1, Service=cv),使用 AK/SK
|
||||
- 提交任务:POST ?Action=CVSync2AsyncSubmitTask&Version=2022-08-31
|
||||
body: {"req_key": "jimeng_i2v_first_v30", "image_urls": ["<url>"], "prompt": "...", "seed": -1, "frames": 121}
|
||||
-> {"code": 10000, "data": {"task_id": "..."}}
|
||||
- 查询任务:POST ?Action=CVSync2AsyncGetResult&Version=2022-08-31
|
||||
body: {"req_key": "jimeng_i2v_first_v30", "task_id": "..."}
|
||||
-> {"code": 10000, "data": {"status": "in_queue|generating|done", "video_url": "..."}}
|
||||
- 视频 URL 有效期 1 小时,必须立即下载到本地。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import time
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from urllib.parse import quote, urlparse
|
||||
|
||||
import httpx
|
||||
|
||||
from packages.shared.config import get_shared_settings
|
||||
|
||||
# 网络/超时类异常父类集合
|
||||
_HTTP_NETWORK_ERRORS = ()
|
||||
try:
|
||||
_HTTP_NETWORK_ERRORS = (httpx.TimeoutException, httpx.NetworkError)
|
||||
except Exception:
|
||||
_HTTP_NETWORK_ERRORS = (Exception,)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_JIMENG_CLIENT_SINGLETON: "JimengClient | None" = None
|
||||
|
||||
# ── V4 签名常量 ────────────────────────────────────────────────────────
|
||||
_JIMENG_REGION = "cn-north-1"
|
||||
_JIMENG_SERVICE = "cv"
|
||||
_JIMENG_VERSION = "2022-08-31"
|
||||
_ACTION_SUBMIT = "CVSync2AsyncSubmitTask"
|
||||
_ACTION_POLL = "CVSync2AsyncGetResult"
|
||||
_CONTENT_TYPE = "application/json"
|
||||
_SIGNED_HEADERS_LIST = ["content-type", "host", "x-content-sha256", "x-date"]
|
||||
_SIGNED_HEADERS_STR = ";".join(_SIGNED_HEADERS_LIST)
|
||||
|
||||
|
||||
def _norm_query(params: dict[str, str]) -> str:
|
||||
"""构造规范查询串:按 key 排序,URL 编码(safe=-_.~),空格->%20。"""
|
||||
parts = []
|
||||
for k in sorted(params.keys()):
|
||||
v = params[k]
|
||||
ek = quote(str(k), safe="-_.~")
|
||||
ev = quote(str(v), safe="-_.~").replace("+", "%20")
|
||||
parts.append(f"{ek}={ev}")
|
||||
return "&".join(parts)
|
||||
|
||||
|
||||
def _hmac_sha256(key: bytes, msg: str) -> bytes:
|
||||
return hmac.new(key, msg.encode("utf-8"), hashlib.sha256).digest()
|
||||
|
||||
|
||||
def _sha256_hex(data: bytes) -> str:
|
||||
return hashlib.sha256(data).hexdigest()
|
||||
|
||||
|
||||
def _sign_v4(
|
||||
ak: str,
|
||||
sk: str,
|
||||
method: str,
|
||||
host: str,
|
||||
query: dict[str, str],
|
||||
body_bytes: bytes,
|
||||
x_date: str,
|
||||
) -> dict[str, str]:
|
||||
"""火山 V4 签名,返回需要附加到请求的 headers 字典。
|
||||
|
||||
x_date 形如 "20260101T120000Z"(UTC)。
|
||||
short_date = x_date[:8](YYYYMMDD)。
|
||||
"""
|
||||
short_date = x_date[:8]
|
||||
payload_hash = _sha256_hex(body_bytes)
|
||||
canon_uri = "/"
|
||||
canon_query = _norm_query(query)
|
||||
canon_headers = f"content-type:{_CONTENT_TYPE}\nhost:{host}\nx-content-sha256:{payload_hash}\nx-date:{x_date}\n"
|
||||
canon_request = f"{method}\n{canon_uri}\n{canon_query}\n{canon_headers}\n{_SIGNED_HEADERS_STR}\n{payload_hash}"
|
||||
credential_scope = f"{short_date}/{_JIMENG_REGION}/{_JIMENG_SERVICE}/request"
|
||||
string_to_sign = f"HMAC-SHA256\n{x_date}\n{credential_scope}\n{_sha256_hex(canon_request.encode('utf-8'))}"
|
||||
k_date = _hmac_sha256(sk.encode("utf-8"), short_date)
|
||||
k_region = _hmac_sha256(k_date, _JIMENG_REGION)
|
||||
k_service = _hmac_sha256(k_region, _JIMENG_SERVICE)
|
||||
k_signing = _hmac_sha256(k_service, "request")
|
||||
signature = hmac.new(k_signing, string_to_sign.encode("utf-8"), hashlib.sha256).hexdigest()
|
||||
authorization = (
|
||||
f"HMAC-SHA256 Credential={ak}/{credential_scope}, SignedHeaders={_SIGNED_HEADERS_STR}, Signature={signature}"
|
||||
)
|
||||
return {
|
||||
"Content-Type": _CONTENT_TYPE,
|
||||
"Host": host,
|
||||
"X-Content-Sha256": payload_hash,
|
||||
"X-Date": x_date,
|
||||
"Authorization": authorization,
|
||||
}
|
||||
|
||||
|
||||
# ── 错误分类 ──────────────────────────────────────────────────────────
|
||||
|
||||
# 即梦业务码 -> 是否可重试映射
|
||||
_JIMENG_RETRYABLE_CODES = {50511, 50516, 50429, 50430, 50500, 50501}
|
||||
_JIMENG_NON_RETRYABLE_CODES = {50411, 50412, 50413, 50512, 50513, 50514}
|
||||
|
||||
|
||||
def _classify_jimeng_error(status_code: int, body: str, biz_code: int | None = None) -> tuple[str, str, bool]:
|
||||
"""即梦错误分类,返回 (error_code, user_message, is_retryable)。"""
|
||||
code = biz_code if biz_code is not None else 0
|
||||
body_lower = (body or "").lower()
|
||||
|
||||
# 业务码优先
|
||||
if code == 50411:
|
||||
return (
|
||||
"portrait_intercept",
|
||||
"即梦通道:参考图片前审核未通过(Pre Img Risk Not Pass),请更换参考图后重试。",
|
||||
False,
|
||||
)
|
||||
if code == 50511:
|
||||
return "task_failed", "即梦通道:输出图片后审核未通过,可稍后重试。", True
|
||||
if code in (50412, 50413, 50512):
|
||||
return "invalid_param", "即梦通道:提示词或文本审核不通过,请调整文案后重试。", False
|
||||
if code == 50516:
|
||||
return "task_failed", "即梦通道:输出视频后审核未通过,可稍后重试。", True
|
||||
if code in (50429, 50430):
|
||||
return "rate_limit", "即梦通道:QPS/并发超限,请稍等 1-2 分钟后重试。", True
|
||||
if code in (50500, 50501):
|
||||
return "network_error", "即梦通道:服务内部错误,可稍后重试。", True
|
||||
|
||||
# HTTP 层兜底
|
||||
if status_code in (401, 403):
|
||||
return "auth_error", "即梦通道:AK/SK 鉴权失败,请联系管理员检查 JIMENG_AK/SK 配置。", False
|
||||
if status_code == 429:
|
||||
return "rate_limit", "即梦通道:服务限流,请稍后重试。", True
|
||||
if status_code == 404:
|
||||
return "model_not_found", "即梦通道:接口不存在(req_key 或 Action 错误),请联系管理员。", False
|
||||
if status_code in (402, 400) and any(kw in body_lower for kw in ("quota", "billing", "insufficient", "余额")):
|
||||
return "quota_exceeded", "即梦通道:账户余额/配额不足,请联系管理员充值。", False
|
||||
if status_code == 400:
|
||||
msg = ""
|
||||
try:
|
||||
msg = str(json.loads(body or "{}").get("message", "") or "")
|
||||
except Exception:
|
||||
pass
|
||||
return "invalid_param", f"即梦通道:参数错误:{msg or body[:200]}", False
|
||||
if status_code == 0:
|
||||
return "network_error", "即梦通道:网络连接失败,请稍后重试。", True
|
||||
# 任务内失败
|
||||
if code and code != 10000:
|
||||
return "unknown", f"即梦通道:视频生成失败(错误码 {code}),请稍后重试。", code in _JIMENG_RETRYABLE_CODES
|
||||
detail = body[:200]
|
||||
return "unknown", f"即梦通道:视频生成失败(HTTP {status_code}):{detail}", False
|
||||
|
||||
|
||||
# ── 即梦客户端 ────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class JimengClient:
|
||||
"""火山引擎即梦视觉 API(cvtob)异步客户端,支持图生视频首帧(jimeng_i2v_first_v30)。"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
settings = get_shared_settings()
|
||||
self.ak: str = getattr(settings, "jimeng_ak", "") or os.getenv("JIMENG_AK", "")
|
||||
self.sk: str = getattr(settings, "jimeng_sk", "") or os.getenv("JIMENG_SK", "")
|
||||
self.base_url: str = (getattr(settings, "jimeng_base_url", "") or "https://visual.volcengineapi.com").rstrip(
|
||||
"/"
|
||||
)
|
||||
self.req_key: str = getattr(settings, "jimeng_req_key", "") or "jimeng_i2v_first_v30"
|
||||
self.poll_interval: int = int(getattr(settings, "jimeng_video_poll_interval", 5) or 5)
|
||||
self.total_timeout: int = int(getattr(settings, "jimeng_video_timeout", 600) or 600)
|
||||
self.max_retries: int = 2
|
||||
self.last_video_error: dict = {}
|
||||
# 解析 base_url 里的 host(用于签名 Host 头)
|
||||
parsed = urlparse(self.base_url)
|
||||
self.host: str = parsed.netloc or "visual.volcengineapi.com"
|
||||
|
||||
@property
|
||||
def is_available(self) -> bool:
|
||||
return bool(self.ak and self.sk)
|
||||
|
||||
def get_last_video_error(self) -> dict:
|
||||
return dict(self.last_video_error or {})
|
||||
|
||||
def _set_error(self, error_code: str, user_message: str, status_code: int = 0, detail: str = "", **extra) -> None:
|
||||
self.last_video_error = {
|
||||
"error_code": error_code,
|
||||
"user_message": user_message,
|
||||
"status_code": status_code,
|
||||
"detail": detail[:500] if detail else "",
|
||||
"provider": "jimeng",
|
||||
**extra,
|
||||
}
|
||||
|
||||
# ── 内部 HTTP:签名 + 请求 ──────────────────────────────────────
|
||||
|
||||
def _signed_request(
|
||||
self,
|
||||
method: str,
|
||||
action: str,
|
||||
body_obj: dict[str, Any],
|
||||
timeout: float = 60.0,
|
||||
) -> tuple[int, str, dict]:
|
||||
"""发送一次带 V4 签名的请求,返回 (status_code, body_text, parsed_json)。"""
|
||||
body_bytes = json.dumps(body_obj, ensure_ascii=False).encode("utf-8")
|
||||
query = {"Action": action, "Version": _JIMENG_VERSION}
|
||||
x_date = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
|
||||
headers = _sign_v4(self.ak, self.sk, method, self.host, query, body_bytes, x_date)
|
||||
url = f"{self.base_url}/?{_norm_query(query)}"
|
||||
resp = httpx.request(
|
||||
method,
|
||||
url,
|
||||
headers=headers,
|
||||
content=body_bytes,
|
||||
timeout=timeout,
|
||||
)
|
||||
sc = int(getattr(resp, "status_code", 0) or 0)
|
||||
text = getattr(resp, "text", "") or ""
|
||||
try:
|
||||
data = resp.json()
|
||||
except Exception:
|
||||
data = {}
|
||||
return sc, text, data
|
||||
|
||||
# ── 提交任务 ────────────────────────────────────────────────────
|
||||
|
||||
def _submit_task(
|
||||
self,
|
||||
prompt: str,
|
||||
image_url: str,
|
||||
frames: int = 121,
|
||||
seed: int = -1,
|
||||
) -> str | None:
|
||||
"""提交图生视频任务,成功返回 task_id;失败写 last_video_error 并返回 None。"""
|
||||
body: dict[str, Any] = {
|
||||
"req_key": self.req_key,
|
||||
"prompt": prompt.strip()[:800],
|
||||
"image_urls": [image_url],
|
||||
"seed": int(seed) if seed and seed > 0 else -1,
|
||||
"frames": int(frames),
|
||||
}
|
||||
last_sc = 0
|
||||
last_body = ""
|
||||
for attempt in range(self.max_retries + 1):
|
||||
try:
|
||||
sc, text, data = self._signed_request("POST", _ACTION_SUBMIT, body, timeout=60.0)
|
||||
last_sc, last_body = sc, text
|
||||
if sc >= 400:
|
||||
logger.error("[jimeng] 提交 HTTP %d: %s", sc, text[:500])
|
||||
if sc >= 500 and attempt < self.max_retries:
|
||||
time.sleep(0.8 * (2**attempt))
|
||||
continue
|
||||
biz_code = data.get("code") if isinstance(data, dict) else None
|
||||
err_code, user_msg, _ = _classify_jimeng_error(sc, text, biz_code)
|
||||
self._set_error(err_code, user_msg, sc, text, req_key=self.req_key)
|
||||
return None
|
||||
code = data.get("code") if isinstance(data, dict) else None
|
||||
if code == 10000:
|
||||
d = data.get("data") or {}
|
||||
tid = d.get("task_id")
|
||||
if tid:
|
||||
return str(tid)
|
||||
err_code, user_msg, retry = _classify_jimeng_error(sc, text, code)
|
||||
logger.error(
|
||||
"[jimeng] 提交业务错误 code=%s msg=%s",
|
||||
code,
|
||||
(data.get("message") if isinstance(data, dict) else ""),
|
||||
)
|
||||
if retry and attempt < self.max_retries:
|
||||
time.sleep(0.8 * (2**attempt))
|
||||
continue
|
||||
self._set_error(err_code, user_msg, sc, text, req_key=self.req_key, biz_code=code)
|
||||
return None
|
||||
except _HTTP_NETWORK_ERRORS as ne:
|
||||
last_sc, last_body = 0, f"network error: {ne}"
|
||||
logger.warning(
|
||||
"[jimeng] 提交网络异常 %s,重试 %d/%d", type(ne).__name__, attempt + 1, self.max_retries + 1
|
||||
)
|
||||
if attempt < self.max_retries:
|
||||
time.sleep(0.8 * (2**attempt))
|
||||
continue
|
||||
self._set_error("network_error", "即梦通道:提交任务网络异常,请稍后重试。", 0, str(ne))
|
||||
return None
|
||||
except Exception as e:
|
||||
last_sc, last_body = 0, f"exception: {e}"
|
||||
logger.error("[jimeng] 提交异常: %s", e, exc_info=True)
|
||||
if attempt < self.max_retries:
|
||||
time.sleep(0.8 * (2**attempt))
|
||||
continue
|
||||
self._set_error("unknown", f"即梦通道:提交任务异常:{e!s}"[:200], 0, str(e))
|
||||
return None
|
||||
if not self.last_video_error:
|
||||
err_code, user_msg, _ = _classify_jimeng_error(last_sc, last_body)
|
||||
self._set_error(err_code, user_msg, last_sc, last_body)
|
||||
return None
|
||||
|
||||
# ── 轮询结果 ────────────────────────────────────────────────────
|
||||
|
||||
def _poll_result(self, task_id: str) -> str | None:
|
||||
"""轮询任务直到 done/failed/expired/timeout,成功返回 video_url。"""
|
||||
deadline = time.time() + self.total_timeout
|
||||
poll_count = 0
|
||||
last_status = ""
|
||||
poll_body = {"req_key": self.req_key, "task_id": task_id}
|
||||
while time.time() < deadline:
|
||||
poll_count += 1
|
||||
try:
|
||||
sc, text, data = self._signed_request("POST", _ACTION_POLL, poll_body, timeout=30.0)
|
||||
if sc >= 400:
|
||||
logger.warning("[jimeng] 轮询 HTTP %d: %s", sc, text[:300])
|
||||
if poll_count < 3:
|
||||
time.sleep(self.poll_interval)
|
||||
continue
|
||||
err_code, user_msg, _ = _classify_jimeng_error(sc, text)
|
||||
self._set_error(err_code, user_msg, sc, text, task_id=task_id)
|
||||
return None
|
||||
code = data.get("code") if isinstance(data, dict) else None
|
||||
d = data.get("data") if isinstance(data, dict) else None
|
||||
if code != 10000 or not isinstance(d, dict):
|
||||
err_code, user_msg, retry = _classify_jimeng_error(sc, text, code)
|
||||
logger.error(
|
||||
"[jimeng] 轮询业务错误 task=%s code=%s msg=%s",
|
||||
task_id,
|
||||
code,
|
||||
(data.get("message") if isinstance(data, dict) else ""),
|
||||
)
|
||||
if retry and poll_count < 3:
|
||||
time.sleep(self.poll_interval)
|
||||
continue
|
||||
self._set_error(err_code, user_msg, sc, text, task_id=task_id, biz_code=code)
|
||||
return None
|
||||
status = d.get("status", "") or ""
|
||||
last_status = status
|
||||
if status == "done":
|
||||
video_url = d.get("video_url") or ""
|
||||
if video_url:
|
||||
logger.info("[jimeng] 任务 %s 完成 polls=%d", task_id, poll_count)
|
||||
return str(video_url)
|
||||
logger.error("[jimeng] 任务 %s done 但无 video_url: %s", task_id, str(d)[:500])
|
||||
self._set_error(
|
||||
"unknown",
|
||||
"即梦通道:任务成功但未返回视频URL,请联系管理员。",
|
||||
200,
|
||||
str(d)[:500],
|
||||
task_id=task_id,
|
||||
)
|
||||
return None
|
||||
if status in ("not_found", "expired"):
|
||||
logger.error("[jimeng] 任务 %s 状态 %s", task_id, status)
|
||||
self._set_error(
|
||||
"network_error" if status == "expired" else "unknown",
|
||||
f"即梦通道:任务{'已过期' if status == 'expired' else '未找到'},请重新提交。",
|
||||
200,
|
||||
f"task {status}",
|
||||
task_id=task_id,
|
||||
)
|
||||
return None
|
||||
if poll_count % 6 == 0:
|
||||
logger.info("[jimeng] 轮询中 task=%s status=%s polls=%d", task_id, status, poll_count)
|
||||
except _HTTP_NETWORK_ERRORS as ne:
|
||||
logger.warning("[jimeng] 轮询网络异常 %s", ne)
|
||||
except Exception as e:
|
||||
logger.debug("[jimeng] 轮询异常: %s", e)
|
||||
time.sleep(self.poll_interval)
|
||||
logger.error(
|
||||
"[jimeng] 任务 %s 轮询超时(%ds)polls=%d last_status=%s",
|
||||
task_id,
|
||||
self.total_timeout,
|
||||
poll_count,
|
||||
last_status,
|
||||
)
|
||||
self._set_error(
|
||||
"network_error",
|
||||
f"即梦通道:视频生成超时(>{self.total_timeout}s),任务仍在排队,请稍后重试。",
|
||||
0,
|
||||
f"timeout after {self.total_timeout}s, polls={poll_count}, last_status={last_status}",
|
||||
task_id=task_id,
|
||||
last_status=last_status,
|
||||
)
|
||||
return None
|
||||
|
||||
# ── 下载视频 ────────────────────────────────────────────────────
|
||||
|
||||
def _download_video(self, video_url: str, output_dir: str, task_id: str) -> str | None:
|
||||
os.makedirs(output_dir, exist_ok=True)
|
||||
suffix = Path(urlparse(video_url).path).suffix or ".mp4"
|
||||
if suffix.lower() not in (".mp4", ".mov", ".webm"):
|
||||
suffix = ".mp4"
|
||||
safe_tid = "".join(c if c.isalnum() or c in "-_" else "_" for c in task_id)[:40]
|
||||
out_path = os.path.join(output_dir, f"jimeng_{safe_tid}_{uuid.uuid4().hex[:8]}{suffix}")
|
||||
try:
|
||||
with httpx.stream("GET", video_url, timeout=180, follow_redirects=True) as resp:
|
||||
dsc = int(getattr(resp, "status_code", 0) or 0)
|
||||
if dsc >= 400:
|
||||
logger.error("[jimeng] 下载 HTTP %d", dsc)
|
||||
self._set_error("network_error", "即梦通道:视频下载失败(HTTP错误),请稍后重试。", dsc)
|
||||
return None
|
||||
with open(out_path, "wb") as f:
|
||||
for chunk in resp.iter_bytes(chunk_size=1024 * 256):
|
||||
if chunk:
|
||||
f.write(chunk)
|
||||
except Exception as e:
|
||||
logger.error("[jimeng] 下载视频失败: %s", e, exc_info=True)
|
||||
self._set_error("network_error", f"即梦通道:视频下载失败:{e!s}"[:200], 0, str(e))
|
||||
return None
|
||||
size = os.path.getsize(out_path) if os.path.exists(out_path) else 0
|
||||
if size < 1024:
|
||||
logger.error("[jimeng] 下载文件过小: %d bytes", size)
|
||||
self._set_error("unknown", "即梦通道:视频下载文件过小,请稍后重试。", 0, f"downloaded only {size} bytes")
|
||||
try:
|
||||
os.remove(out_path)
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
logger.info("[jimeng] 视频已下载: %s (%d bytes)", out_path, size)
|
||||
return out_path
|
||||
|
||||
# ── 对外主入口 ──────────────────────────────────────────────────
|
||||
|
||||
def video_generation(
|
||||
self,
|
||||
prompt: str,
|
||||
*,
|
||||
image_url: str,
|
||||
duration: int = 5,
|
||||
ratio: str | None = "9:16",
|
||||
resolution: str = "720p",
|
||||
output_dir: str | None = None,
|
||||
generate_audio: bool = False,
|
||||
) -> dict | None:
|
||||
"""即梦图生视频主入口。
|
||||
|
||||
成功返回 {"video_path": str, "usage": {"provider","duration_seconds","frames","req_key","billing_mode"}};
|
||||
失败返回 None,详情在 self.last_video_error。
|
||||
|
||||
注意:jimeng_i2v_first_v30 不支持原生音频(generate_audio 被忽略,返回无声视频),
|
||||
音频由后续 ffmpeg 合成阶段叠加 TTS。
|
||||
支持时长:5s(frames=121)/10s(frames=241),>10s 截断并打 warning。
|
||||
分辨率固定 720P;ratio 对首帧 i2v 无效(自动按图片比例)。
|
||||
"""
|
||||
self.last_video_error = {}
|
||||
if not self.is_available:
|
||||
self._set_error(
|
||||
"auth_error",
|
||||
"即梦通道未配置(JIMENG_AK/SK 缺失),请联系管理员。",
|
||||
detail="jimeng ak/sk empty",
|
||||
)
|
||||
logger.error("[jimeng] AK/SK 未配置,无法调用")
|
||||
return None
|
||||
if not prompt or not prompt.strip():
|
||||
self._set_error("invalid_param", "视频生成提示词不能为空。", detail="empty prompt")
|
||||
return None
|
||||
if not image_url or not image_url.strip():
|
||||
self._set_error("invalid_param", "即梦图生视频必须提供参考图片。", detail="empty image_url")
|
||||
return None
|
||||
|
||||
dur = int(duration or 5)
|
||||
if dur <= 5:
|
||||
frames = 121
|
||||
real_dur = 5
|
||||
elif dur <= 10:
|
||||
frames = 241
|
||||
real_dur = 10
|
||||
else:
|
||||
logger.warning("[jimeng] 请求时长 %ds 超出即梦 i2v 上限 10s,截断到 10s(frames=241)", dur)
|
||||
frames = 241
|
||||
real_dur = 10
|
||||
|
||||
out_dir = output_dir or "/tmp"
|
||||
logger.info(
|
||||
"[jimeng] 提交任务: req_key=%s dur=%ds(frames=%d) ratio=%s res=%s gen_audio=%s img=%s",
|
||||
self.req_key,
|
||||
real_dur,
|
||||
frames,
|
||||
ratio,
|
||||
resolution,
|
||||
generate_audio,
|
||||
bool(image_url),
|
||||
)
|
||||
|
||||
task_id = self._submit_task(prompt=prompt, image_url=image_url, frames=frames, seed=-1)
|
||||
if not task_id:
|
||||
return None
|
||||
logger.info("[jimeng] 任务已提交: task_id=%s", task_id)
|
||||
|
||||
video_url = self._poll_result(task_id)
|
||||
if not video_url:
|
||||
return None
|
||||
|
||||
local_path = self._download_video(video_url, out_dir, task_id)
|
||||
if not local_path:
|
||||
return None
|
||||
|
||||
usage = {
|
||||
"provider": "jimeng",
|
||||
"duration_seconds": real_dur,
|
||||
"frames": frames,
|
||||
"req_key": self.req_key,
|
||||
"billing_mode": "per_second",
|
||||
}
|
||||
return {"video_path": local_path, "usage": usage}
|
||||
|
||||
|
||||
def get_jimeng_client() -> "JimengClient | None":
|
||||
"""返回即梦客户端单例;未配置 AK/SK 时返回 None。"""
|
||||
global _JIMENG_CLIENT_SINGLETON
|
||||
if _JIMENG_CLIENT_SINGLETON is None:
|
||||
_JIMENG_CLIENT_SINGLETON = JimengClient()
|
||||
if not _JIMENG_CLIENT_SINGLETON.is_available:
|
||||
return None
|
||||
return _JIMENG_CLIENT_SINGLETON
|
||||
@@ -368,21 +368,6 @@ fi
|
||||
|
||||
echo "All images pulled."
|
||||
|
||||
# ====== 打稳定 tag(:dev),供 Watchtower 监控 ======
|
||||
# Watchtower 只能检测同一个 tag 的 digest 变化。
|
||||
# commit SHA tag 每次构建都不同,Watchtower 无法感知更新。
|
||||
# 因此每次部署都将最新镜像 tag 为 :dev,容器统一使用 :dev 启动。
|
||||
DEV_API="${REGISTRY}/xiaoxia-saas-api:dev"
|
||||
DEV_WORKER="${REGISTRY}/xiaoxia-saas-worker:dev"
|
||||
DEV_WEB="${REGISTRY}/xiaoxia-saas-web:dev"
|
||||
docker tag "$REGISTRY_API" "$DEV_API"
|
||||
docker tag "$REGISTRY_WORKER" "$DEV_WORKER"
|
||||
docker tag "$REGISTRY_WEB" "$DEV_WEB"
|
||||
echo "✅ Tagged images as :dev for Watchtower monitoring"
|
||||
echo " API: $DEV_API"
|
||||
echo " Worker: $DEV_WORKER"
|
||||
echo " Web: $DEV_WEB"
|
||||
|
||||
# ====== 镜像内容校验 ======
|
||||
echo ""
|
||||
echo "=========================================="
|
||||
@@ -548,7 +533,7 @@ docker run -d \
|
||||
--health-retries 3 \
|
||||
--health-start-period 40s \
|
||||
$LOG_OPTS \
|
||||
"$DEV_API" &
|
||||
"$REGISTRY_API" &
|
||||
PID_API_START=$!
|
||||
|
||||
# ── Worker: 通过 compose 启动(单一事实来源)──
|
||||
@@ -556,7 +541,7 @@ PID_API_START=$!
|
||||
# healthcheck 匹配 'celery.*worker'(不把 beat 算活)、资源限制 4C/8G。
|
||||
# WORKER_IMAGE 通过环境变量覆盖镜像 tag(compose.yml 默认 :dev)。
|
||||
echo "Starting worker via docker compose (from $INFRA_DOCKER_DIR)..."
|
||||
WORKER_IMAGE="$DEV_WORKER" APP_VERSION="$IMAGE_TAG" compose up -d --no-deps worker &
|
||||
WORKER_IMAGE="$REGISTRY_WORKER" APP_VERSION="$IMAGE_TAG" compose up -d --no-deps worker &
|
||||
PID_WORKER_START=$!
|
||||
|
||||
# ── Web: 暂保留 docker run(TODO: 后续收敛到 compose)──
|
||||
@@ -572,7 +557,7 @@ docker run -d \
|
||||
--health-timeout 5s \
|
||||
--health-retries 3 \
|
||||
$LOG_OPTS \
|
||||
"$DEV_WEB" &
|
||||
"$REGISTRY_WEB" &
|
||||
PID_WEB_START=$!
|
||||
|
||||
wait $PID_API_START $PID_WORKER_START $PID_WEB_START
|
||||
@@ -703,5 +688,5 @@ echo "=== Staging deployment complete ==="
|
||||
echo "API: http://127.0.0.1:8000"
|
||||
echo "Web: http://127.0.0.1:3001"
|
||||
echo "Worker: managed by docker compose (project=$COMPOSE_PROJECT)"
|
||||
echo "Version: $IMAGE_TAG (running as :dev for Watchtower)"
|
||||
echo "Version: $IMAGE_TAG"
|
||||
docker ps --format "table {{.Names}}\t{{.Status}}\t{{.Image}}" | grep staging
|
||||
|
||||
@@ -57,7 +57,7 @@ if [ "$TARGET_ENV" = "staging" ]; then
|
||||
fi
|
||||
|
||||
# 共用 secrets 直接导出(如果存在)
|
||||
SHARED_SECRETS="OSS_ACCESS_KEY_ID OSS_ACCESS_KEY_SECRET COSYVOICE_API_KEY DASHSCOPE_API_KEY MEDIAKIT_API_KEY DOUBAO_API_KEY DOUBAO_MODEL DOUBAO_FAST_MODEL DOUBAO_BASE_URL DOUBAO_VISION_MODEL DOUBAO_VISION_LITE_MODEL DOUBAO_VISION_USE_LITE WECHAT_APP_ID WECHAT_APP_SECRET TIKHUB_API_KEY APIZERO_API_KEY GPU_WORKER_TOKEN"
|
||||
SHARED_SECRETS="OSS_ACCESS_KEY_ID OSS_ACCESS_KEY_SECRET COSYVOICE_API_KEY DASHSCOPE_API_KEY MEDIAKIT_API_KEY DOUBAO_API_KEY DOUBAO_MODEL DOUBAO_BASE_URL DOUBAO_VISION_MODEL WECHAT_APP_ID WECHAT_APP_SECRET TIKHUB_API_KEY APIZERO_API_KEY GPU_WORKER_TOKEN"
|
||||
for var in $SHARED_SECRETS; do
|
||||
value="${!var:-}"
|
||||
# 已经在环境中了,无需额外操作
|
||||
|
||||
@@ -377,12 +377,32 @@ class TestPrepareNarrativeVoice:
|
||||
assert ei.value.status_code == 502
|
||||
assert "配音合成失败" in ei.value.message
|
||||
|
||||
def test_no_points_service_invoked(self, monkeypatch):
|
||||
"""v1.6.2: 叙事配音已免费,不再实例化 PointsService / 扣点/退费。"""
|
||||
# 确认 narrative_service 已不再暴露 PointsService
|
||||
assert not hasattr(ns, "PointsService"), "narrative_service 不应再导入 PointsService"
|
||||
def test_points_insufficient_402(self, monkeypatch):
|
||||
class FakePoints:
|
||||
def deduct_points(self, *a, **k):
|
||||
return {"success": False, "balance": 0}
|
||||
|
||||
class FakeWorkflow:
|
||||
monkeypatch.setattr(ns, "PointsService", lambda: FakePoints())
|
||||
deps = self._deps(points_enabled=True)
|
||||
with pytest.raises(NarrativeError) as ei:
|
||||
prepare_narrative_voice(**deps)
|
||||
assert ei.value.status_code == 402
|
||||
|
||||
def test_points_refund_on_failure(self, monkeypatch):
|
||||
class FakePoints:
|
||||
def __init__(self):
|
||||
self.refunded = 0
|
||||
|
||||
def deduct_points(self, *a, **k):
|
||||
return {"success": True, "balance": 100}
|
||||
|
||||
def refund_points(self, user_id, amount, source, db, ref_id="", **k):
|
||||
self.refunded += amount
|
||||
|
||||
points = FakePoints()
|
||||
monkeypatch.setattr(ns, "PointsService", lambda: points)
|
||||
|
||||
class FailingWorkflow:
|
||||
def __init__(self, *, repository, cosyvoice_service):
|
||||
pass
|
||||
|
||||
@@ -392,22 +412,11 @@ class TestPrepareNarrativeVoice:
|
||||
def process_synthesis_failure(self, job_id, error):
|
||||
return None
|
||||
|
||||
monkeypatch.setattr(ns, "TTSWorkflowService", FakeWorkflow)
|
||||
monkeypatch.setattr(ns, "TTSWorkflowService", FailingWorkflow)
|
||||
deps = self._deps(points_enabled=True)
|
||||
with pytest.raises(NarrativeError) as ei:
|
||||
with pytest.raises(NarrativeError):
|
||||
prepare_narrative_voice(**deps)
|
||||
# 走 502 业务错误路径,不再退费
|
||||
assert ei.value.status_code == 502
|
||||
|
||||
def test_module_has_no_points_imports(self):
|
||||
"""模块源码不再包含扣点相关符号。"""
|
||||
import inspect
|
||||
|
||||
src = inspect.getsource(ns)
|
||||
assert "PointsService" not in src
|
||||
assert "calculate_points_cost" not in src
|
||||
assert "_points_scene" not in src
|
||||
assert "_POINTS_SCENE" not in src
|
||||
assert points.refunded > 0
|
||||
|
||||
def test_clone_source_resolves_profile(self, monkeypatch):
|
||||
captured = {}
|
||||
|
||||
@@ -14,13 +14,10 @@ from packages.shared.ai_client import DoubaoClient
|
||||
class _FakeSettings:
|
||||
doubao_api_key = "test-key"
|
||||
doubao_model = "test-model"
|
||||
doubao_fast_model = "test-fast-model"
|
||||
doubao_base_url = "https://ark.cn-beijing.volces.com/api/v3"
|
||||
doubao_timeout = 10
|
||||
doubao_max_retries = 0
|
||||
doubao_vision_model = "test-vision"
|
||||
doubao_vision_lite_model = "test-vision-lite"
|
||||
doubao_vision_use_lite = False
|
||||
doubao_embedding_model = "test-embedding"
|
||||
|
||||
|
||||
@@ -87,9 +84,7 @@ _GEN_TASKS_PATH = Path(__file__).resolve().parents[2] / "apps/api/app/api/routes
|
||||
def _load_infer_func():
|
||||
src = _GEN_TASKS_PATH.read_text()
|
||||
start = src.index("# #2035:文案关键词")
|
||||
# 用紧跟 _infer_expected_categories 后的 logger 行作为结束锚点
|
||||
end_marker = "\nlogger = logging.getLogger"
|
||||
end = src.index(end_marker, start)
|
||||
end = src.index("from packages.middleware")
|
||||
code = src[start:end]
|
||||
ns: dict = {}
|
||||
exec(code, ns)
|
||||
|
||||
@@ -1,25 +1,48 @@
|
||||
"""AI 数字人渲染 — v1.6.2 起免费,不扣积分"""
|
||||
"""AI数字人渲染 积分扣点单元测试 (#1895 P2 step 2.6)"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
class TestAiAvatarRenderFree:
|
||||
def test_ai_digital_human_returns_zero_cost(self):
|
||||
import pytest
|
||||
|
||||
import packages.middleware.points_gate as _pg_module
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _enable(monkeypatch):
|
||||
monkeypatch.setattr(_pg_module, "_points_gate_enabled", lambda: True)
|
||||
yield
|
||||
|
||||
|
||||
class TestAiAvatarRenderPoints:
|
||||
def test_ai_digital_human_per_unit(self):
|
||||
from packages.domain.points_rules import calculate_points_cost
|
||||
|
||||
assert calculate_points_cost("ai_digital_human", is_member=False, duration_minutes=1) == 0
|
||||
assert calculate_points_cost("ai_digital_human", is_member=True, duration_minutes=5) == 0
|
||||
cost = calculate_points_cost("ai_digital_human", is_member=False, duration_minutes=1)
|
||||
assert cost >= 15
|
||||
|
||||
def test_no_points_gate_decorator(self):
|
||||
def test_decorator_attached(self):
|
||||
from app.api.routes.ai_avatar_render import create_render_job
|
||||
|
||||
assert not hasattr(create_render_job, "__wrapped__")
|
||||
assert hasattr(create_render_job, "__wrapped__"), "missing @points_gate"
|
||||
|
||||
def test_module_has_no_points_imports(self):
|
||||
import inspect
|
||||
def test_insufficient_raises_402(self):
|
||||
from app.api.routes.ai_avatar_render import create_render_job
|
||||
from app.schemas.ai_avatar_render import CreateAiAvatarRenderRequest
|
||||
from fastapi import HTTPException
|
||||
|
||||
from app.api.routes import ai_avatar_render as mod
|
||||
|
||||
src = inspect.getsource(mod)
|
||||
assert "PointsService" not in src
|
||||
assert "points_gate" not in src
|
||||
db = MagicMock()
|
||||
cu = MagicMock()
|
||||
cu.user.id = "u1"
|
||||
cu.user.is_member = False
|
||||
cu.user.member_type = None
|
||||
svc = MagicMock()
|
||||
body = CreateAiAvatarRenderRequest(lipsync_job_id="lip1")
|
||||
with patch("packages.domain.points_service.PointsService") as MS:
|
||||
msvc = MagicMock()
|
||||
msvc.deduct_points.return_value = {"success": False, "balance": 0}
|
||||
MS.return_value = msvc
|
||||
with pytest.raises(HTTPException) as ei:
|
||||
create_render_job(body=body, current_user=cu, svc=svc, db=db)
|
||||
assert ei.value.status_code == 402
|
||||
|
||||
@@ -112,10 +112,10 @@ class TestVideoGenerationHappyPath:
|
||||
resolution="720p",
|
||||
output_dir=str(tmp_path),
|
||||
)
|
||||
assert out is not None and isinstance(out, dict)
|
||||
assert Path(out["video_path"]).exists()
|
||||
assert Path(out["video_path"]).name == "seedance_task-001_abcd1234.mp4"
|
||||
assert Path(out["video_path"]).read_bytes() == b"FAKEMP4DATA"
|
||||
assert out is not None
|
||||
assert Path(out).exists()
|
||||
assert Path(out).name == "seedance_task-001_abcd1234.mp4"
|
||||
assert Path(out).read_bytes() == b"FAKEMP4DATA"
|
||||
assert calls["post"] == 1
|
||||
assert calls["get"] == 1
|
||||
|
||||
@@ -325,8 +325,8 @@ class TestVideoGenerationPollLoop:
|
||||
doubao_video_poll_interval=0, doubao_video_timeout=200, doubao_video_model="seedance"
|
||||
)
|
||||
out = client.video_generation("p", output_dir=str(tmp_path))
|
||||
assert out is not None and isinstance(out, dict)
|
||||
assert Path(out["video_path"]).read_bytes() == b"DATA"
|
||||
assert out is not None
|
||||
assert Path(out).read_bytes() == b"DATA"
|
||||
# queued 和 running 各 sleep 一次
|
||||
assert len(sleeps) >= 2
|
||||
|
||||
@@ -373,8 +373,8 @@ class TestVideoGenerationPollLoop:
|
||||
doubao_video_poll_interval=0, doubao_video_timeout=100, doubao_video_model="seedance"
|
||||
)
|
||||
out = client.video_generation("p", output_dir=str(tmp_path))
|
||||
assert out is not None and isinstance(out, dict)
|
||||
assert Path(out["video_path"]).exists()
|
||||
assert out is not None
|
||||
assert Path(out).exists()
|
||||
assert poll_calls["n"] == 2
|
||||
|
||||
def test_default_output_dir_and_audio_watermark(self, tmp_path, monkeypatch):
|
||||
@@ -442,8 +442,8 @@ class TestVideoGenerationPollLoop:
|
||||
out = client.video_generation(
|
||||
"p", duration=3, ratio="1:1", resolution="480p", generate_audio=True, watermark=True
|
||||
)
|
||||
assert out is not None and isinstance(out, dict)
|
||||
assert out["video_path"] == "/tmp/seedance_t-default_00000001.mp4"
|
||||
assert out is not None
|
||||
assert "/tmp/seedance_t-default_00000001.mp4" in out
|
||||
assert captured["json"]["generate_audio"] is True
|
||||
assert captured["json"]["watermark"] is True
|
||||
assert captured["json"]["ratio"] == "1:1"
|
||||
@@ -483,180 +483,3 @@ class TestVideoGenerationCancelled:
|
||||
doubao_video_poll_interval=0, doubao_video_timeout=10, doubao_video_model="seedance"
|
||||
)
|
||||
assert client.video_generation("p", output_dir=str(tmp_path)) is None
|
||||
|
||||
|
||||
# ============ #2157 _resolve_video_model_id 模型ID映射单测 ============
|
||||
|
||||
|
||||
class TestResolveVideoModelId:
|
||||
"""覆盖 _resolve_video_model_id 各分支(#2157 P0 修复)。"""
|
||||
|
||||
def _import_target(self):
|
||||
from packages.shared.ai_client import _resolve_video_model_id
|
||||
|
||||
return _resolve_video_model_id
|
||||
|
||||
def test_none_uses_default(self):
|
||||
fn = self._import_target()
|
||||
with patch("packages.shared.ai_client.get_shared_settings") as ms:
|
||||
ms.return_value = MagicMock(doubao_video_model="doubao-seedance-2-5-260628")
|
||||
assert fn(None) == "doubao-seedance-2-5-260628"
|
||||
|
||||
def test_empty_uses_default(self):
|
||||
fn = self._import_target()
|
||||
with patch("packages.shared.ai_client.get_shared_settings") as ms:
|
||||
ms.return_value = MagicMock(doubao_video_model="doubao-seedance-2-5-260628")
|
||||
assert fn(" ") == "doubao-seedance-2-5-260628"
|
||||
|
||||
def test_doubao_prefix_passthrough(self):
|
||||
fn = self._import_target()
|
||||
assert fn("doubao-seedance-2-5-260628") == "doubao-seedance-2-5-260628"
|
||||
|
||||
def test_ep_prefix_passthrough(self):
|
||||
fn = self._import_target()
|
||||
assert fn("ep-20260721114705-b568m") == "ep-20260721114705-b568m"
|
||||
|
||||
def test_seedance_2_5_alias(self):
|
||||
fn = self._import_target()
|
||||
assert fn("seedance-2.5") == "doubao-seedance-2-5-260628"
|
||||
|
||||
def test_seedance_2_0_alias(self):
|
||||
fn = self._import_target()
|
||||
assert fn("seedance-2.0") == "doubao-seedance-2-0-260128"
|
||||
|
||||
def test_seedance_2_0_fast_alias(self):
|
||||
fn = self._import_target()
|
||||
assert fn("seedance-2.0-fast") == "doubao-seedance-2-0-fast-260128"
|
||||
|
||||
def test_seedance_2_0_mini_alias(self):
|
||||
fn = self._import_target()
|
||||
assert fn("seedance-2.0-mini") == "doubao-seedance-2-0-mini-260615"
|
||||
|
||||
def test_wan_3_0_returns_dashscope_provider(self):
|
||||
from packages.shared.ai_client import _resolve_video_provider_and_id
|
||||
|
||||
prov, mid, cfg = _resolve_video_provider_and_id("wan-3.0")
|
||||
assert prov == "dashscope"
|
||||
assert mid == "wan3.0-video"
|
||||
assert cfg.get("billing_mode") == "per_second"
|
||||
|
||||
def test_seedance_2_5_uppercase(self):
|
||||
fn = self._import_target()
|
||||
assert fn("Seedance-2.5") == "doubao-seedance-2-5-260628"
|
||||
|
||||
def test_seedance_dot_normalize(self):
|
||||
fn = self._import_target()
|
||||
# dot 形式 "seedance-2.5" 直接命中 domain config 的 key(与 2-5 同等)
|
||||
assert fn("seedance-2.5") == "doubao-seedance-2-5-260628"
|
||||
|
||||
def test_unknown_model_falls_back_to_default_seedance_2_5(self, caplog):
|
||||
fn = self._import_target()
|
||||
import logging
|
||||
|
||||
# 未知 model key 会通过 get_viral_video_model_config 回落到 seedance-2.5
|
||||
with caplog.at_level(logging.WARNING, logger="shared.ai_client"):
|
||||
assert fn("some-random-model") == "doubao-seedance-2-5-260628"
|
||||
|
||||
|
||||
# ── #2165 详细错误信息和 last_video_error ─────────────────────────
|
||||
|
||||
|
||||
class TestVideoGenerationLastError:
|
||||
def test_create_400_portrait_returns_user_message(self, tmp_path):
|
||||
"""#2169: HTTP 400 + 真人拦截关键词 → 自动尝试即梦兜底;即梦未配时返回 portrait_intercept。"""
|
||||
client = _make_client(max_retries=0)
|
||||
create_resp = MagicMock()
|
||||
create_resp.status_code = 400
|
||||
create_resp.text = '{"error":{"code":"ContentRisk","message":"Real person face detected in reference image, portrait blocked"}}'
|
||||
create_resp.json.return_value = {"error": {"code": "ContentRisk", "message": "..."}}
|
||||
create_resp.raise_for_status.side_effect = httpx.HTTPStatusError(
|
||||
"bad", request=MagicMock(), response=create_resp
|
||||
)
|
||||
with (
|
||||
patch("packages.shared.ai_client.httpx.post", return_value=create_resp),
|
||||
patch("packages.shared.ai_client.time.sleep", return_value=None),
|
||||
patch("packages.shared.ai_client.get_shared_settings") as mock_s,
|
||||
# jimeng 未配置,fallback 后仍返回 portrait_intercept(提示用户需要配置即梦)
|
||||
patch("packages.shared.jimeng_client.get_jimeng_client", return_value=None),
|
||||
):
|
||||
mock_s.return_value = MagicMock(
|
||||
doubao_video_poll_interval=0, doubao_video_timeout=1, doubao_video_model="seedance"
|
||||
)
|
||||
result = client.video_generation("p", output_dir=str(tmp_path), image_url="https://img/x.jpg")
|
||||
assert result is None
|
||||
err = client.get_last_video_error()
|
||||
assert err["error_code"] == "portrait_intercept"
|
||||
# 即梦兜底未启用时提示包含"真人照片"/"即梦"等关键字
|
||||
assert "真人" in err["user_message"] or "肖像" in err["user_message"] or "即梦" in err["user_message"]
|
||||
# 方舟本身 status_code=400(最后一个错误可能被即梦兜底覆盖,但 error_code 不变)
|
||||
assert err["status_code"] in (0, 400)
|
||||
|
||||
def test_create_401_returns_auth_error(self, tmp_path):
|
||||
client = _make_client(max_retries=0)
|
||||
create_resp = MagicMock()
|
||||
create_resp.status_code = 401
|
||||
create_resp.text = '{"error":{"message":"Unauthorized"}}'
|
||||
create_resp.json.return_value = {"error": {"message": "Unauthorized"}}
|
||||
create_resp.raise_for_status.side_effect = httpx.HTTPStatusError(
|
||||
"auth", request=MagicMock(), response=create_resp
|
||||
)
|
||||
with (
|
||||
patch("packages.shared.ai_client.httpx.post", return_value=create_resp),
|
||||
patch("packages.shared.ai_client.time.sleep", return_value=None),
|
||||
patch("packages.shared.ai_client.get_shared_settings") as mock_s,
|
||||
):
|
||||
mock_s.return_value = MagicMock(
|
||||
doubao_video_poll_interval=0, doubao_video_timeout=1, doubao_video_model="seedance"
|
||||
)
|
||||
result = client.video_generation("p", output_dir=str(tmp_path))
|
||||
assert result is None
|
||||
err = client.get_last_video_error()
|
||||
assert err["error_code"] == "auth_error"
|
||||
assert err["status_code"] == 401
|
||||
|
||||
def test_poll_failed_returns_task_failed_error(self, tmp_path):
|
||||
"""轮询 status=failed 时应记录 task_failed 错误并含 detail。"""
|
||||
client = _make_client(max_retries=0)
|
||||
create_resp = MagicMock()
|
||||
create_resp.status_code = 200
|
||||
create_resp.json.return_value = {"id": "t-fail"}
|
||||
create_resp.raise_for_status = MagicMock()
|
||||
poll_resp = MagicMock()
|
||||
poll_resp.status_code = 200
|
||||
poll_resp.json.return_value = {
|
||||
"status": "failed",
|
||||
"error": {"code": "InvalidParam", "message": "resolution invalid"},
|
||||
}
|
||||
poll_resp.raise_for_status = MagicMock()
|
||||
with (
|
||||
patch("packages.shared.ai_client.httpx.post", return_value=create_resp),
|
||||
patch("packages.shared.ai_client.httpx.get", return_value=poll_resp),
|
||||
patch("packages.shared.ai_client.time.sleep", return_value=None),
|
||||
patch("packages.shared.ai_client.time.time", side_effect=_fake_time_factory()),
|
||||
patch("packages.shared.ai_client.get_shared_settings") as mock_s,
|
||||
):
|
||||
mock_s.return_value = MagicMock(
|
||||
doubao_video_poll_interval=0, doubao_video_timeout=10, doubao_video_model="seedance"
|
||||
)
|
||||
result = client.video_generation("p", output_dir=str(tmp_path))
|
||||
assert result is None
|
||||
err = client.get_last_video_error()
|
||||
assert err["error_code"] == "task_failed"
|
||||
assert "InvalidParam" in err.get("detail", "") or err["status_code"] == 200
|
||||
|
||||
|
||||
class TestAiServiceLastVideoError:
|
||||
def test_call_video_generation_returns_none_sets_error(self):
|
||||
"""失败后 get_last_video_error 应返回结构化错误信息。"""
|
||||
from packages.shared import ai_service
|
||||
|
||||
mock_client = MagicMock()
|
||||
mock_client.is_available = True
|
||||
mock_client.last_video_error = {"error_code": "unknown", "user_message": "test"}
|
||||
mock_client.get_last_video_error.return_value = {"error_code": "unknown", "user_message": "test"}
|
||||
mock_client.video_generation.return_value = None
|
||||
with patch("packages.shared.ai_service.get_doubao_client", return_value=mock_client):
|
||||
assert ai_service.call_video_generation("p") is None
|
||||
err = ai_service.get_last_video_error()
|
||||
assert err["error_code"] == "unknown"
|
||||
assert "user_message" in err
|
||||
|
||||
@@ -1,198 +0,0 @@
|
||||
"""catalog 应用服务单测:会员套餐 / 积分包从共享库读取与字段映射。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _clear_cache():
|
||||
from packages.application.catalog import admin_catalog
|
||||
|
||||
admin_catalog._cache.clear()
|
||||
yield
|
||||
admin_catalog._cache.clear()
|
||||
|
||||
|
||||
def _row(**kw):
|
||||
row = MagicMock()
|
||||
for k, v in kw.items():
|
||||
setattr(row, k, v)
|
||||
return row
|
||||
|
||||
|
||||
class TestMembershipPlans:
|
||||
def test_yearly_plan_mapping(self):
|
||||
from packages.application.catalog import admin_catalog
|
||||
|
||||
row = _row(
|
||||
plan_key="premium_yearly",
|
||||
name="高级会员年卡",
|
||||
description="年度订阅",
|
||||
monthly_price=0,
|
||||
yearly_price=399,
|
||||
quotas={"4k": True, "batch_render": True, "credits_per_month": 500},
|
||||
display_order=1,
|
||||
)
|
||||
session = MagicMock()
|
||||
session.execute.return_value.fetchall.return_value = [row]
|
||||
sl = MagicMock(return_value=session)
|
||||
|
||||
with patch("packages.adapters.sqlalchemy_impl.session.SessionLocal", sl, create=True):
|
||||
plans = admin_catalog.get_membership_plans()
|
||||
|
||||
assert len(plans) == 1
|
||||
p = plans[0]
|
||||
assert p["plan_id"] == "premium_yearly"
|
||||
assert p["billing_cycle"] == "yearly"
|
||||
assert p["price_cents"] == 39900
|
||||
assert p["monthly_price_cents"] == 3325
|
||||
assert p["duration_days"] == 365
|
||||
assert p["features"]["4K 超清分辨率"] is True
|
||||
assert p["features"]["credits_per_month"] == 500
|
||||
session.close.assert_called_once()
|
||||
|
||||
def test_monthly_plan_mapping(self):
|
||||
from packages.application.catalog import admin_catalog
|
||||
|
||||
row = _row(
|
||||
plan_key="premium_monthly",
|
||||
name="高级会员月卡",
|
||||
description=None,
|
||||
monthly_price=39,
|
||||
yearly_price=0,
|
||||
quotas=None,
|
||||
display_order=2,
|
||||
)
|
||||
session = MagicMock()
|
||||
session.execute.return_value.fetchall.return_value = [row]
|
||||
sl = MagicMock(return_value=session)
|
||||
|
||||
with patch("packages.adapters.sqlalchemy_impl.session.SessionLocal", sl, create=True):
|
||||
plans = admin_catalog.get_membership_plans()
|
||||
|
||||
assert len(plans) == 1
|
||||
p = plans[0]
|
||||
assert p["billing_cycle"] == "monthly"
|
||||
assert p["price_cents"] == 3900
|
||||
assert p["monthly_price_cents"] == 3900
|
||||
assert p["duration_days"] == 30
|
||||
assert p["features"] == {}
|
||||
|
||||
def test_both_cycles_expanded(self):
|
||||
from packages.application.catalog import admin_catalog
|
||||
|
||||
row = _row(
|
||||
plan_key="premium",
|
||||
name="高级会员",
|
||||
description=None,
|
||||
monthly_price=39,
|
||||
yearly_price=399,
|
||||
quotas={},
|
||||
display_order=1,
|
||||
)
|
||||
session = MagicMock()
|
||||
session.execute.return_value.fetchall.return_value = [row]
|
||||
sl = MagicMock(return_value=session)
|
||||
|
||||
with patch("packages.adapters.sqlalchemy_impl.session.SessionLocal", sl, create=True):
|
||||
plans = admin_catalog.get_membership_plans()
|
||||
|
||||
cycles = {p["billing_cycle"] for p in plans}
|
||||
assert cycles == {"yearly", "monthly"}
|
||||
|
||||
def test_no_session_returns_empty(self):
|
||||
from packages.application.catalog import admin_catalog
|
||||
|
||||
with patch("packages.adapters.sqlalchemy_impl.session.SessionLocal", None, create=True):
|
||||
assert admin_catalog.get_membership_plans() == []
|
||||
|
||||
|
||||
class TestPointsPackages:
|
||||
def test_package_mapping_with_bonus(self):
|
||||
from packages.application.catalog import admin_catalog
|
||||
|
||||
row = _row(
|
||||
package_key="pkg_100",
|
||||
name="100元充值包",
|
||||
price=100,
|
||||
credits=1000,
|
||||
bonus_credits=100,
|
||||
is_recommended=True,
|
||||
description="推荐",
|
||||
sort_order=4,
|
||||
)
|
||||
session = MagicMock()
|
||||
session.execute.return_value.fetchall.return_value = [row]
|
||||
sl = MagicMock(return_value=session)
|
||||
|
||||
with patch("packages.adapters.sqlalchemy_impl.session.SessionLocal", sl, create=True):
|
||||
packages = admin_catalog.get_points_packages()
|
||||
|
||||
assert len(packages) == 1
|
||||
pkg = packages[0]
|
||||
assert pkg["code"] == "pkg_100"
|
||||
assert pkg["points"] == 1100
|
||||
assert pkg["price_cents"] == 10000
|
||||
assert pkg["is_recommended"] is True
|
||||
assert pkg["unit_price"] == "¥0.091/积分"
|
||||
|
||||
def test_zero_credits_unit_price_safe(self):
|
||||
from packages.application.catalog import admin_catalog
|
||||
|
||||
row = _row(
|
||||
package_key="pkg_0",
|
||||
name="空包",
|
||||
price=0,
|
||||
credits=0,
|
||||
bonus_credits=0,
|
||||
is_recommended=False,
|
||||
description=None,
|
||||
sort_order=0,
|
||||
)
|
||||
session = MagicMock()
|
||||
session.execute.return_value.fetchall.return_value = [row]
|
||||
sl = MagicMock(return_value=session)
|
||||
|
||||
with patch("packages.adapters.sqlalchemy_impl.session.SessionLocal", sl, create=True):
|
||||
packages = admin_catalog.get_points_packages()
|
||||
|
||||
assert packages[0]["points"] == 0
|
||||
assert packages[0]["price_cents"] == 0
|
||||
assert packages[0]["unit_price"] == "¥0.000/积分"
|
||||
|
||||
def test_no_session_returns_empty(self):
|
||||
from packages.application.catalog import admin_catalog
|
||||
|
||||
with patch("packages.adapters.sqlalchemy_impl.session.SessionLocal", None, create=True):
|
||||
assert admin_catalog.get_points_packages() == []
|
||||
|
||||
|
||||
class TestPackagesRoute:
|
||||
def test_get_packages_route_returns_items(self):
|
||||
from app.api.routes.points import get_packages
|
||||
|
||||
cu = MagicMock()
|
||||
cu.user.member_type = None
|
||||
rows = [
|
||||
{
|
||||
"code": "pkg_10",
|
||||
"name": "10元充值包",
|
||||
"points": 100,
|
||||
"price_cents": 1000,
|
||||
"unit_price": "¥0.100/积分",
|
||||
}
|
||||
]
|
||||
with patch(
|
||||
"packages.application.catalog.admin_catalog.get_points_packages",
|
||||
return_value=rows,
|
||||
):
|
||||
resp = get_packages(current_user=cu)
|
||||
|
||||
assert len(resp.packages) == 1
|
||||
item = resp.packages[0]
|
||||
assert item.code == "pkg_10"
|
||||
assert item.points == 100
|
||||
assert item.price_cents == 1000
|
||||
@@ -95,29 +95,25 @@ class TestCheckEndpointWhenDisabled:
|
||||
# 不再走免费额度判定
|
||||
svc.check_daily_free_clip.assert_not_called()
|
||||
|
||||
def test_unknown_scene_allowed_when_disabled(self):
|
||||
"""任意 scene_key(含未知/已下线)系统关闭时都返回 allowed=True, cost=0。"""
|
||||
def test_unknown_scene_still_400_when_disabled(self):
|
||||
"""未知 scene 即使系统关闭也返回 400(参数校验先于开关)。"""
|
||||
from app.api.routes.points import check_points
|
||||
from app.schemas.points import PointsCheckRequest
|
||||
from fastapi import HTTPException
|
||||
|
||||
svc = MagicMock()
|
||||
svc.get_or_create_account.return_value = {"balance": 0}
|
||||
with (
|
||||
patch("app.api.routes.points._credits_enabled", return_value=False),
|
||||
patch("app.api.routes.points._get_service", return_value=svc),
|
||||
):
|
||||
resp = check_points(body=PointsCheckRequest(scene_key="nope"), current_user=_make_cu(), db=MagicMock())
|
||||
assert resp.allowed is True
|
||||
assert resp.required_points == 0
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
check_points(body=PointsCheckRequest(scene_key="nope"), current_user=_make_cu(), db=MagicMock())
|
||||
assert exc.value.status_code == 400
|
||||
|
||||
def test_check_enabled_calculates_cost(self):
|
||||
"""开关开启时保持原有计费校验(voice_clone_synth 正常计费)。"""
|
||||
"""开关开启时保持原有计费校验。"""
|
||||
from app.api.routes.points import check_points
|
||||
from app.schemas.points import PointsCheckRequest
|
||||
|
||||
svc = MagicMock()
|
||||
svc.check_daily_free_clip.return_value = False
|
||||
svc.get_or_create_account.return_value = {"balance": 100}
|
||||
body = PointsCheckRequest(scene_key="voice_clone_synth", quantity=1, duration_minutes=1)
|
||||
body = PointsCheckRequest(scene_key="ai_title", quantity=1)
|
||||
|
||||
with (
|
||||
patch("app.api.routes.points._credits_enabled", return_value=True),
|
||||
@@ -127,23 +123,6 @@ class TestCheckEndpointWhenDisabled:
|
||||
|
||||
assert resp.required_points == 2 # 免费用户 ceil(1*1.15)=2
|
||||
|
||||
def test_retired_scene_free_when_enabled(self):
|
||||
"""开关开启时,已下线场景返回 cost=0,直接放行。"""
|
||||
from app.api.routes.points import check_points
|
||||
from app.schemas.points import PointsCheckRequest
|
||||
|
||||
svc = MagicMock()
|
||||
svc.get_or_create_account.return_value = {"balance": 0}
|
||||
with (
|
||||
patch("app.api.routes.points._credits_enabled", return_value=True),
|
||||
patch("app.api.routes.points._get_service", return_value=svc),
|
||||
):
|
||||
for scene in ["ai_voice", "ai_title", "ai_video", "ai_digital_human", "nope"]:
|
||||
body = PointsCheckRequest(scene_key=scene, quantity=1)
|
||||
resp = check_points(body=body, current_user=_make_cu(), db=MagicMock())
|
||||
assert resp.required_points == 0, f"{scene} should be free"
|
||||
assert resp.allowed is True
|
||||
|
||||
|
||||
# ── /points/deduct:关闭时 no-op,余额不变 ────────────────────────────────
|
||||
|
||||
@@ -238,24 +217,13 @@ class TestQueryEndpointsRemainAvailable:
|
||||
|
||||
|
||||
class TestBusinessRoutesBypassWhenDisabled:
|
||||
def test_lipsync_route_has_no_points_logic(self):
|
||||
"""lipsync 路由:已移除手动扣点代码(不导入 PointsService/calculate_points_cost)。"""
|
||||
import inspect
|
||||
|
||||
def test_lipsync_route_skips_points(self):
|
||||
"""lipsync 创建任务路由:settings.points_enabled=False 时不构造 PointsService。"""
|
||||
from app.api.routes import lipsync as lipsync_mod
|
||||
|
||||
src = inspect.getsource(lipsync_mod)
|
||||
assert "PointsService" not in src
|
||||
assert "calculate_points_cost" not in src
|
||||
assert "_points_deducted" not in src
|
||||
|
||||
def test_tts_route_has_no_points_logic(self):
|
||||
"""tts 路由:已移除手动扣点代码(不导入 PointsService/calculate_points_cost)。"""
|
||||
import inspect
|
||||
assert bool(getattr(lipsync_mod.settings, "points_enabled", False)) is False
|
||||
|
||||
def test_tts_route_skips_points(self):
|
||||
from app.api.routes import tts as tts_mod
|
||||
|
||||
src = inspect.getsource(tts_mod)
|
||||
assert "PointsService" not in src
|
||||
assert "calculate_points_cost" not in src
|
||||
assert "_points_deducted" not in src
|
||||
assert bool(getattr(tts_mod.settings, "points_enabled", False)) is False
|
||||
|
||||
@@ -1,178 +0,0 @@
|
||||
"""tests for packages/shared/dashscope_client.py (#2159 Wan 3.0 DashScope client)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import MagicMock, mock_open, patch
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
_SINGLETON = "_DASHSCOPE_CLIENT_SINGLETON"
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def reset_singleton():
|
||||
import packages.shared.dashscope_client as d
|
||||
|
||||
# 兼容实际 singleton 名
|
||||
for name in ("_DASHSCOPE_CLIENT_SINGLETON", "_dashscope_client"):
|
||||
if hasattr(d, name):
|
||||
setattr(d, name, None)
|
||||
yield
|
||||
for name in ("_DASHSCOPE_CLIENT_SINGLETON", "_dashscope_client"):
|
||||
if hasattr(d, name):
|
||||
setattr(d, name, None)
|
||||
|
||||
|
||||
def _make_settings(api_key="test-key"):
|
||||
return MagicMock(
|
||||
dashscope_api_key=api_key,
|
||||
dashscope_base_url="https://dashscope.aliyuncs.com/api/v1",
|
||||
dashscope_video_timeout=10,
|
||||
dashscope_video_poll_interval=0,
|
||||
video_dir="/tmp/videos",
|
||||
)
|
||||
|
||||
|
||||
class TestDashScopeAvailability:
|
||||
def test_unavailable_without_key(self):
|
||||
from packages.shared.dashscope_client import get_dashscope_client
|
||||
|
||||
with patch("packages.shared.dashscope_client.get_shared_settings") as ms:
|
||||
ms.return_value = _make_settings(api_key="")
|
||||
assert get_dashscope_client() is None
|
||||
|
||||
def test_available_with_key(self):
|
||||
from packages.shared.dashscope_client import get_dashscope_client
|
||||
|
||||
with patch("packages.shared.dashscope_client.get_shared_settings") as ms:
|
||||
ms.return_value = _make_settings()
|
||||
c = get_dashscope_client()
|
||||
assert c is not None
|
||||
assert c.is_available is True
|
||||
|
||||
|
||||
def _mock_stream_response(min_size=2048):
|
||||
"""构造 httpx.stream 上下文返回值,模拟返回若干字节的 mp4 内容。"""
|
||||
m = MagicMock()
|
||||
m.status_code = 200
|
||||
chunk = b"x" * min_size
|
||||
m.iter_bytes.return_value = [chunk]
|
||||
ctx = MagicMock()
|
||||
ctx.__enter__.return_value = m
|
||||
return ctx
|
||||
|
||||
|
||||
class TestDashScopeVideoGeneration:
|
||||
def test_happy_path_returns_video_path(self):
|
||||
"""POST create → GET poll (SUCCEEDED) → download → returns path + correct payload."""
|
||||
import packages.shared.dashscope_client as d
|
||||
|
||||
with patch("packages.shared.dashscope_client.get_shared_settings") as ms:
|
||||
ms.return_value = _make_settings()
|
||||
c = d.DashScopeClient()
|
||||
|
||||
create_resp = MagicMock(status_code=200)
|
||||
create_resp.json.return_value = {"output": {"task_id": "task-abc"}}
|
||||
poll_resp = MagicMock(status_code=200)
|
||||
poll_resp.json.return_value = {
|
||||
"output": {"task_status": "SUCCEEDED", "video_url": "http://x/y.mp4"},
|
||||
"usage": {"billed_duration": 10},
|
||||
}
|
||||
# fake file: write enough bytes to pass the size>=1024 check
|
||||
m_open = mock_open()
|
||||
m_open.return_value.write.return_value = None
|
||||
fake_size = {"/tmp/videos/wan_task-abc.mp4": 4096}
|
||||
|
||||
def fake_getsize(p):
|
||||
return fake_size.get(p, 0)
|
||||
|
||||
def fake_exists(p):
|
||||
return p in fake_size
|
||||
|
||||
with (
|
||||
patch.object(d.httpx, "post", return_value=create_resp) as mock_post,
|
||||
patch.object(d.httpx, "get", return_value=poll_resp),
|
||||
patch.object(d.httpx, "stream", return_value=_mock_stream_response()),
|
||||
patch("packages.shared.dashscope_client.time.sleep"),
|
||||
patch("packages.shared.dashscope_client.os.makedirs"),
|
||||
patch("builtins.open", m_open),
|
||||
patch("packages.shared.dashscope_client.os.path.getsize", side_effect=fake_getsize),
|
||||
patch("packages.shared.dashscope_client.os.path.exists", side_effect=fake_exists),
|
||||
):
|
||||
res = c.video_generation(
|
||||
prompt="test",
|
||||
duration=5,
|
||||
ratio="9:16",
|
||||
resolution="720p",
|
||||
output_dir="/tmp/videos",
|
||||
)
|
||||
assert res is not None, "expected success"
|
||||
assert res["video_path"] == "/tmp/videos/wan_task-abc.mp4"
|
||||
_, kwargs = mock_post.call_args
|
||||
body = kwargs["json"]
|
||||
assert body["parameters"]["resolution"] == "720P"
|
||||
assert body["model"] == "wan3.0-video"
|
||||
|
||||
def test_create_http_error_returns_none(self):
|
||||
import packages.shared.dashscope_client as d
|
||||
|
||||
with patch("packages.shared.dashscope_client.get_shared_settings") as ms:
|
||||
ms.return_value = _make_settings()
|
||||
c = d.DashScopeClient()
|
||||
err_resp = MagicMock(status_code=400, text="bad")
|
||||
err_resp.raise_for_status.side_effect = RuntimeError("bad")
|
||||
with patch.object(d.httpx, "post", return_value=err_resp):
|
||||
res = c.video_generation(
|
||||
prompt="test", duration=5, ratio="9:16", resolution="720p", output_dir="/tmp/videos"
|
||||
)
|
||||
assert res is None
|
||||
|
||||
def test_poll_failed_returns_none(self):
|
||||
import packages.shared.dashscope_client as d
|
||||
|
||||
with patch("packages.shared.dashscope_client.get_shared_settings") as ms:
|
||||
ms.return_value = _make_settings()
|
||||
c = d.DashScopeClient()
|
||||
create_resp = MagicMock(status_code=200)
|
||||
create_resp.json.return_value = {"output": {"task_id": "task-abc"}}
|
||||
poll_resp = MagicMock(status_code=200)
|
||||
poll_resp.json.return_value = {"output": {"task_status": "FAILED", "message": "nope"}}
|
||||
with (
|
||||
patch.object(d.httpx, "post", return_value=create_resp),
|
||||
patch.object(d.httpx, "get", return_value=poll_resp),
|
||||
patch("packages.shared.dashscope_client.time.sleep"),
|
||||
):
|
||||
res = c.video_generation(
|
||||
prompt="test", duration=5, ratio="9:16", resolution="720p", output_dir="/tmp/videos"
|
||||
)
|
||||
assert res is None
|
||||
|
||||
def test_empty_prompt_returns_none(self):
|
||||
import packages.shared.dashscope_client as d
|
||||
|
||||
with patch("packages.shared.dashscope_client.get_shared_settings") as ms:
|
||||
ms.return_value = _make_settings()
|
||||
c = d.DashScopeClient()
|
||||
assert (
|
||||
c.video_generation(prompt=" ", duration=5, ratio="9:16", resolution="720p", output_dir="/tmp/videos")
|
||||
is None
|
||||
)
|
||||
|
||||
def test_create_400_sets_last_video_error(self, tmp_path):
|
||||
"""创建任务 HTTP 400 时应写 last_video_error。"""
|
||||
from packages.shared import dashscope_client as dc
|
||||
|
||||
dc._DASHSCOPE_CLIENT_SINGLETON = None
|
||||
with patch.dict("os.environ", {"DASHSCOPE_API_KEY": "test-key"}):
|
||||
c = dc.DashScopeClient()
|
||||
r = MagicMock()
|
||||
r.status_code = 401
|
||||
r.text = '{"code":"InvalidApiKey","message":"bad key"}'
|
||||
r.raise_for_status.side_effect = httpx.HTTPStatusError("auth", request=MagicMock(), response=r)
|
||||
with patch.object(dc.httpx, "post", return_value=r), patch.object(dc, "time"):
|
||||
out = c.video_generation("p", output_dir=str(tmp_path))
|
||||
assert out is None
|
||||
err = c.get_last_video_error()
|
||||
assert err["error_code"] == "auth_error"
|
||||
assert c.last_video_error is not None
|
||||
@@ -1,25 +1,28 @@
|
||||
"""封面生成 — v1.6.2 起免费,不扣积分"""
|
||||
"""AI封面生成 积分扣点单元测试 (#1895 P2 step 2.7)"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
class TestGenerationCoverFree:
|
||||
def test_ai_cover_returns_zero_cost(self):
|
||||
import pytest
|
||||
|
||||
import packages.middleware.points_gate as _pg_module
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _enable(monkeypatch):
|
||||
monkeypatch.setattr(_pg_module, "_points_gate_enabled", lambda: True)
|
||||
yield
|
||||
|
||||
|
||||
class TestGenerationCoverPoints:
|
||||
def test_ai_cover_cost(self):
|
||||
from packages.domain.points_rules import calculate_points_cost
|
||||
|
||||
assert calculate_points_cost("ai_cover", is_member=False, quantity=1) == 0
|
||||
assert calculate_points_cost("ai_cover", is_member=True, quantity=10) == 0
|
||||
assert calculate_points_cost("ai_cover", is_member=False) == 2
|
||||
assert calculate_points_cost("ai_cover", is_member=True, member_type="yearly") >= 0
|
||||
|
||||
def test_no_points_gate_decorator(self):
|
||||
def test_decorator_attached(self):
|
||||
from app.api.routes.generation_cover import generate_cover
|
||||
|
||||
assert not hasattr(generate_cover, "__wrapped__")
|
||||
|
||||
def test_endpoint_has_no_points_logic(self):
|
||||
import inspect
|
||||
|
||||
from app.api.routes import generation_cover as mod
|
||||
|
||||
src = inspect.getsource(mod)
|
||||
assert "PointsService" not in src
|
||||
assert "deduct_points" not in src
|
||||
assert hasattr(generate_cover, "__wrapped__"), "missing @points_gate"
|
||||
|
||||
@@ -1,31 +1,54 @@
|
||||
"""视频预览生成 — v1.6.2 起免费,不扣积分"""
|
||||
"""视频预览生成 积分扣点单元测试 (#1895 P2 step 2.5)"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
class TestGenerationPreviewFree:
|
||||
def test_ai_video_returns_zero_cost(self):
|
||||
import packages.middleware.points_gate as _pg_module
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _enable(monkeypatch):
|
||||
monkeypatch.setattr(_pg_module, "_points_gate_enabled", lambda: True)
|
||||
yield
|
||||
|
||||
|
||||
class TestGenerationPreviewPoints:
|
||||
def test_ai_video_cost(self):
|
||||
from packages.domain.points_rules import calculate_points_cost
|
||||
|
||||
assert calculate_points_cost("ai_video", is_member=False) == 0
|
||||
assert calculate_points_cost("ai_video", is_member=True, member_type="monthly", duration_minutes=10) == 0
|
||||
assert calculate_points_cost("ai_video", is_member=False) == 4
|
||||
assert calculate_points_cost("ai_video", is_member=True, member_type="monthly") == 2
|
||||
|
||||
def test_no_points_gate_decorator(self):
|
||||
"""预览生成路由已移除 @points_gate。"""
|
||||
def test_insufficient_raises_402(self):
|
||||
from app.api.routes.generation_preview import create_preview_generation_task
|
||||
from app.schemas.generation_task import CreatePreviewGenerationTaskRequest
|
||||
from fastapi import HTTPException
|
||||
|
||||
db = MagicMock()
|
||||
cu = MagicMock()
|
||||
cu.user.id = "u1"
|
||||
cu.user.is_member = False
|
||||
cu.user.member_type = None
|
||||
req = CreatePreviewGenerationTaskRequest(template_id="t1", asset_ids=["a1"], preview_count=1)
|
||||
with patch("packages.domain.points_service.PointsService") as MS:
|
||||
svc = MagicMock()
|
||||
svc.check_daily_free_clip.return_value = False
|
||||
svc.deduct_points.return_value = {"success": False, "balance": 0}
|
||||
MS.return_value = svc
|
||||
with pytest.raises(HTTPException) as ei:
|
||||
create_preview_generation_task(
|
||||
request=req,
|
||||
authenticated_user=cu,
|
||||
db=db,
|
||||
generation_task_repository=MagicMock(),
|
||||
asset_repo=MagicMock(),
|
||||
)
|
||||
assert ei.value.status_code == 402
|
||||
|
||||
def test_decorator_attached(self):
|
||||
from app.api.routes.generation_preview import create_preview_generation_task
|
||||
|
||||
# 移除装饰器后 __wrapped__ 不再存在
|
||||
assert not hasattr(create_preview_generation_task, "__wrapped__")
|
||||
|
||||
def test_endpoint_does_not_deduct_points(self):
|
||||
"""端点不再实例化 PointsService / 调用 deduct_points(直接走业务逻辑)。"""
|
||||
import inspect
|
||||
|
||||
from app.api.routes.generation_preview import create_preview_generation_task
|
||||
|
||||
src = inspect.getsource(create_preview_generation_task)
|
||||
assert "PointsService" not in src
|
||||
assert "deduct_points" not in src
|
||||
assert "calculate_points_cost" not in src
|
||||
assert hasattr(create_preview_generation_task, "__wrapped__"), "missing @points_gate"
|
||||
|
||||
@@ -1,28 +1,63 @@
|
||||
"""智能混剪任务 — v1.6.2 起免费,不扣积分"""
|
||||
"""视频生成 积分扣点单元测试 (#1895 P2 step 2.4)"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import MagicMock
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
import packages.middleware.points_gate as _pg_module
|
||||
|
||||
|
||||
class TestGenerationTasksFree:
|
||||
def test_ai_video_returns_zero_cost(self):
|
||||
@pytest.fixture(autouse=True)
|
||||
def _enable(monkeypatch):
|
||||
monkeypatch.setattr(_pg_module, "_points_gate_enabled", lambda: True)
|
||||
yield
|
||||
|
||||
|
||||
class TestGenerationTasksPoints:
|
||||
def test_ai_video_base_cost(self):
|
||||
from packages.domain.points_rules import calculate_points_cost
|
||||
|
||||
assert calculate_points_cost("ai_video", is_member=False, duration_minutes=5) == 0
|
||||
assert calculate_points_cost("ai_video", is_member=True, duration_minutes=10) == 0
|
||||
assert calculate_points_cost("ai_video", is_member=False) == 4
|
||||
assert calculate_points_cost("ai_video", is_member=True, member_type="monthly") == 2
|
||||
|
||||
def test_no_points_gate_decorator(self):
|
||||
def test_ai_video_quantity_scales(self):
|
||||
from packages.domain.points_rules import calculate_points_cost
|
||||
|
||||
c1 = calculate_points_cost("ai_video", is_member=False, quantity=1)
|
||||
c3 = calculate_points_cost("ai_video", is_member=False, quantity=3)
|
||||
assert c3 > c1
|
||||
|
||||
def test_insufficient_raises_402(self):
|
||||
from app.api.routes.generation_tasks import create_generation_task
|
||||
from app.schemas.generation_task import CreateGenerationTaskRequest
|
||||
from fastapi import HTTPException
|
||||
|
||||
db = MagicMock()
|
||||
cu = MagicMock()
|
||||
cu.user.id = "u1"
|
||||
cu.user.is_member = False
|
||||
cu.user.member_type = None
|
||||
req = CreateGenerationTaskRequest(template_id="t1", asset_ids=["a1"], count=1)
|
||||
with patch("packages.domain.points_service.PointsService") as MS:
|
||||
svc = MagicMock()
|
||||
svc.check_daily_free_clip.return_value = False
|
||||
svc.deduct_points.return_value = {"success": False, "balance": 0}
|
||||
MS.return_value = svc
|
||||
with pytest.raises(HTTPException) as ei:
|
||||
create_generation_task(
|
||||
request=req,
|
||||
authenticated_user=cu,
|
||||
db=db,
|
||||
generation_task_repository=MagicMock(),
|
||||
project_repository=MagicMock(),
|
||||
asset_library_repository=MagicMock(),
|
||||
asset_repository=MagicMock(),
|
||||
)
|
||||
assert ei.value.status_code == 402
|
||||
|
||||
def test_decorator_attached(self):
|
||||
from app.api.routes.generation_tasks import create_generation_task
|
||||
|
||||
assert not hasattr(create_generation_task, "__wrapped__")
|
||||
|
||||
def test_create_task_accepts_request_without_points_block(self):
|
||||
"""路由函数签名不再做扣点,但参数 points_enabled/is_member/member_type 仍保留以兼容调用方。"""
|
||||
import inspect
|
||||
|
||||
from app.api.routes.generation_tasks import create_generation_task
|
||||
|
||||
sig = inspect.signature(create_generation_task)
|
||||
# 函数存在
|
||||
assert callable(create_generation_task)
|
||||
assert hasattr(create_generation_task, "__wrapped__"), "missing @points_gate"
|
||||
|
||||
@@ -44,7 +44,7 @@ class TestCheckDatabase:
|
||||
assert result["type"] == "postgresql"
|
||||
assert result["message"] == "Database connection successful"
|
||||
mock_psycopg.connect.assert_called_once_with(
|
||||
"postgresql://test:test@localhost/test", connect_timeout=3
|
||||
"postgresql+psycopg://test:test@localhost/test", connect_timeout=3
|
||||
)
|
||||
mock_cur.execute.assert_called_once_with("SELECT 1")
|
||||
mock_conn.close.assert_called_once()
|
||||
@@ -96,7 +96,7 @@ class TestCheckMigrations:
|
||||
assert result["status"] == "healthy"
|
||||
assert result["message"] == "Database migrations applied"
|
||||
mock_psycopg.connect.assert_called_once_with(
|
||||
"postgresql://test:test@localhost/test", connect_timeout=3
|
||||
"postgresql+psycopg://test:test@localhost/test", connect_timeout=3
|
||||
)
|
||||
mock_conn.close.assert_called_once()
|
||||
|
||||
|
||||
@@ -1,376 +0,0 @@
|
||||
"""tests for packages/shared/jimeng_client.py (#2169 即梦 i2v 客户端)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
from unittest.mock import MagicMock, mock_open, patch
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def reset_singleton():
|
||||
import packages.shared.jimeng_client as j
|
||||
|
||||
j._JIMENG_CLIENT_SINGLETON = None
|
||||
yield
|
||||
j._JIMENG_CLIENT_SINGLETON = None
|
||||
|
||||
|
||||
def _make_settings(ak="test-ak", sk="test-sk", req_key="jimeng_i2v_first_v30", timeout=60, poll_interval=2):
|
||||
return MagicMock(
|
||||
jimeng_ak=ak,
|
||||
jimeng_sk=sk,
|
||||
jimeng_base_url="https://visual.volcengineapi.com",
|
||||
jimeng_req_key=req_key,
|
||||
jimeng_video_timeout=timeout,
|
||||
jimeng_video_poll_interval=poll_interval,
|
||||
)
|
||||
|
||||
|
||||
# ── V4 签名单元测试 ──────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestV4Signature:
|
||||
def test_sign_returns_required_headers(self):
|
||||
from packages.shared.jimeng_client import _sign_v4
|
||||
|
||||
headers = _sign_v4(
|
||||
ak="AK_TEST",
|
||||
sk="SK_TEST",
|
||||
method="POST",
|
||||
host="visual.volcengineapi.com",
|
||||
query={"Action": "CVSync2AsyncSubmitTask", "Version": "2022-08-31"},
|
||||
body_bytes=b'{"req_key":"jimeng_i2v_first_v30"}',
|
||||
x_date="20260101T120000Z",
|
||||
)
|
||||
assert headers["Content-Type"] == "application/json"
|
||||
assert headers["Host"] == "visual.volcengineapi.com"
|
||||
assert headers["X-Date"] == "20260101T120000Z"
|
||||
assert "X-Content-Sha256" in headers
|
||||
assert headers["Authorization"].startswith("HMAC-SHA256 Credential=AK_TEST/20260101/cn-north-1/cv/request")
|
||||
assert "SignedHeaders=content-type;host;x-content-sha256;x-date" in headers["Authorization"]
|
||||
assert "Signature=" in headers["Authorization"]
|
||||
# 签名是 64 字符 hex
|
||||
sig = headers["Authorization"].split("Signature=")[-1]
|
||||
assert len(sig) == 64
|
||||
assert all(c in "0123456789abcdef" for c in sig)
|
||||
|
||||
def test_sign_deterministic(self):
|
||||
"""相同输入必须产生相同签名(幂等)。"""
|
||||
from packages.shared.jimeng_client import _sign_v4
|
||||
|
||||
kwargs = dict(
|
||||
ak="AK",
|
||||
sk="SK",
|
||||
method="POST",
|
||||
host="h",
|
||||
query={"A": "1", "B": "2"},
|
||||
body_bytes=b"{}",
|
||||
x_date="20260101T000000Z",
|
||||
)
|
||||
h1 = _sign_v4(**kwargs)
|
||||
h2 = _sign_v4(**kwargs)
|
||||
assert h1["Authorization"] == h2["Authorization"]
|
||||
assert h1["X-Content-Sha256"] == h2["X-Content-Sha256"]
|
||||
|
||||
def test_sign_different_body_different_sig(self):
|
||||
from packages.shared.jimeng_client import _sign_v4
|
||||
|
||||
base = dict(ak="AK", sk="SK", method="POST", host="h", query={}, x_date="20260101T000000Z")
|
||||
h1 = _sign_v4(body_bytes=b"a", **base)
|
||||
h2 = _sign_v4(body_bytes=b"b", **base)
|
||||
assert h1["Authorization"] != h2["Authorization"]
|
||||
|
||||
def test_payload_sha256_matches(self):
|
||||
import hashlib
|
||||
|
||||
from packages.shared.jimeng_client import _sign_v4
|
||||
|
||||
body = b'{"prompt":"hello"}'
|
||||
h = _sign_v4("ak", "sk", "POST", "h", {}, body, "20260101T000000Z")
|
||||
expected = hashlib.sha256(body).hexdigest()
|
||||
assert h["X-Content-Sha256"] == expected
|
||||
|
||||
def test_norm_query_sorted_and_encoded(self):
|
||||
from packages.shared.jimeng_client import _norm_query
|
||||
|
||||
q = _norm_query({"B": "2", "A": "1", "C": "a b"})
|
||||
# key 排序 + 空格→%20
|
||||
assert q.startswith("A=1")
|
||||
assert "B=2" in q
|
||||
assert "C=a%20b" in q
|
||||
|
||||
|
||||
# ── 可用性 / 单例 ─────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestAvailability:
|
||||
def test_unavailable_without_ak_sk(self):
|
||||
from packages.shared.jimeng_client import JimengClient, get_jimeng_client
|
||||
|
||||
with patch("packages.shared.jimeng_client.get_shared_settings") as ms:
|
||||
ms.return_value = _make_settings(ak="", sk="")
|
||||
# 重置单例
|
||||
import packages.shared.jimeng_client as j
|
||||
|
||||
j._JIMENG_CLIENT_SINGLETON = None
|
||||
assert get_jimeng_client() is None
|
||||
|
||||
def test_available_with_ak_sk(self):
|
||||
from packages.shared.jimeng_client import get_jimeng_client
|
||||
|
||||
with patch("packages.shared.jimeng_client.get_shared_settings") as ms:
|
||||
ms.return_value = _make_settings()
|
||||
import packages.shared.jimeng_client as j
|
||||
|
||||
j._JIMENG_CLIENT_SINGLETON = None
|
||||
c = get_jimeng_client()
|
||||
assert c is not None
|
||||
assert c.is_available is True
|
||||
assert c.req_key == "jimeng_i2v_first_v30"
|
||||
|
||||
|
||||
# ── 错误分类 ────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestClassifyError:
|
||||
def test_50411_is_portrait_intercept_non_retryable(self):
|
||||
from packages.shared.jimeng_client import _classify_jimeng_error
|
||||
|
||||
code, msg, retry = _classify_jimeng_error(200, '{"code":50411,"message":"Pre Img Risk"}', 50411)
|
||||
assert code == "portrait_intercept"
|
||||
assert retry is False
|
||||
|
||||
def test_50429_is_rate_limit_retryable(self):
|
||||
from packages.shared.jimeng_client import _classify_jimeng_error
|
||||
|
||||
code, msg, retry = _classify_jimeng_error(200, "", 50429)
|
||||
assert code == "rate_limit"
|
||||
assert retry is True
|
||||
|
||||
def test_50430_is_rate_limit(self):
|
||||
from packages.shared.jimeng_client import _classify_jimeng_error
|
||||
|
||||
code, _, _ = _classify_jimeng_error(200, "", 50430)
|
||||
assert code == "rate_limit"
|
||||
|
||||
def test_50500_is_network_error_retryable(self):
|
||||
from packages.shared.jimeng_client import _classify_jimeng_error
|
||||
|
||||
code, _, retry = _classify_jimeng_error(200, "", 50500)
|
||||
assert code == "network_error"
|
||||
assert retry is True
|
||||
|
||||
def test_50412_is_invalid_param_non_retryable(self):
|
||||
from packages.shared.jimeng_client import _classify_jimeng_error
|
||||
|
||||
code, _, retry = _classify_jimeng_error(200, "", 50412)
|
||||
assert code == "invalid_param"
|
||||
assert retry is False
|
||||
|
||||
def test_401_auth(self):
|
||||
from packages.shared.jimeng_client import _classify_jimeng_error
|
||||
|
||||
code, msg, retry = _classify_jimeng_error(401, "auth fail", None)
|
||||
assert code == "auth_error"
|
||||
assert retry is False
|
||||
|
||||
def test_400_text_audit(self):
|
||||
from packages.shared.jimeng_client import _classify_jimeng_error
|
||||
|
||||
code, _, _ = _classify_jimeng_error(400, "text error", None)
|
||||
assert code == "invalid_param"
|
||||
|
||||
|
||||
# ── video_generation 主流程 ──────────────────────────────────────────
|
||||
|
||||
|
||||
class TestVideoGenerationHappyPath:
|
||||
def test_missing_ak_returns_none(self):
|
||||
from packages.shared.jimeng_client import JimengClient
|
||||
|
||||
with patch("packages.shared.jimeng_client.get_shared_settings") as ms:
|
||||
ms.return_value = _make_settings(ak="", sk="")
|
||||
c = JimengClient()
|
||||
assert c.video_generation("hi", image_url="http://x/y.jpg") is None
|
||||
err = c.last_video_error
|
||||
assert err["error_code"] == "auth_error"
|
||||
|
||||
def test_empty_prompt_returns_none(self):
|
||||
from packages.shared.jimeng_client import JimengClient
|
||||
|
||||
with patch("packages.shared.jimeng_client.get_shared_settings") as ms:
|
||||
ms.return_value = _make_settings()
|
||||
c = JimengClient()
|
||||
assert c.video_generation(" ", image_url="http://x/y.jpg") is None
|
||||
assert c.last_video_error["error_code"] == "invalid_param"
|
||||
|
||||
def test_empty_image_url_returns_none(self):
|
||||
from packages.shared.jimeng_client import JimengClient
|
||||
|
||||
with patch("packages.shared.jimeng_client.get_shared_settings") as ms:
|
||||
ms.return_value = _make_settings()
|
||||
c = JimengClient()
|
||||
assert c.video_generation("prompt", image_url="") is None
|
||||
assert c.last_video_error["error_code"] == "invalid_param"
|
||||
|
||||
def test_duration_5s_frames_121(self):
|
||||
"""5s → frames=121,10s→frames=241,>10s 截断到10s。"""
|
||||
from packages.shared.jimeng_client import JimengClient
|
||||
|
||||
with patch("packages.shared.jimeng_client.get_shared_settings") as ms:
|
||||
ms.return_value = _make_settings(timeout=1, poll_interval=0)
|
||||
c = JimengClient()
|
||||
|
||||
captured = {}
|
||||
|
||||
def fake_submit(prompt, image_url, frames, seed=-1):
|
||||
captured["frames"] = frames
|
||||
return "task-xyz"
|
||||
|
||||
def fake_poll(tid):
|
||||
captured["tid"] = tid
|
||||
return "http://example.com/v.mp4"
|
||||
|
||||
def fake_download(url, out_dir, tid):
|
||||
captured["url"] = url
|
||||
return "/tmp/fake.mp4"
|
||||
|
||||
# 构造一个假文件
|
||||
os.makedirs("/tmp", exist_ok=True)
|
||||
with open("/tmp/fake.mp4", "wb") as f:
|
||||
f.write(b"x" * 2048)
|
||||
|
||||
with (
|
||||
patch.object(c, "_submit_task", side_effect=fake_submit),
|
||||
patch.object(c, "_poll_result", side_effect=fake_poll),
|
||||
patch.object(c, "_download_video", side_effect=fake_download),
|
||||
):
|
||||
r = c.video_generation("test", image_url="http://x/y.jpg", duration=5, output_dir="/tmp")
|
||||
assert r is not None
|
||||
assert captured["frames"] == 121
|
||||
assert r["usage"]["duration_seconds"] == 5
|
||||
assert r["usage"]["billing_mode"] == "per_second"
|
||||
assert r["usage"]["req_key"] == "jimeng_i2v_first_v30"
|
||||
|
||||
def test_duration_10s_frames_241(self):
|
||||
from packages.shared.jimeng_client import JimengClient
|
||||
|
||||
with patch("packages.shared.jimeng_client.get_shared_settings") as ms:
|
||||
ms.return_value = _make_settings(timeout=1, poll_interval=0)
|
||||
c = JimengClient()
|
||||
captured = {}
|
||||
|
||||
def fake_submit(prompt, image_url, frames, seed=-1):
|
||||
captured["frames"] = frames
|
||||
return "tid"
|
||||
|
||||
def fake_poll(tid):
|
||||
return "http://x/v.mp4"
|
||||
|
||||
def fake_download(url, out_dir, tid):
|
||||
with open("/tmp/fake2.mp4", "wb") as f:
|
||||
f.write(b"x" * 2048)
|
||||
return "/tmp/fake2.mp4"
|
||||
|
||||
with (
|
||||
patch.object(c, "_submit_task", side_effect=fake_submit),
|
||||
patch.object(c, "_poll_result", side_effect=fake_poll),
|
||||
patch.object(c, "_download_video", side_effect=fake_download),
|
||||
):
|
||||
r = c.video_generation("hi", image_url="http://x/y.jpg", duration=10, output_dir="/tmp")
|
||||
assert captured["frames"] == 241
|
||||
assert r["usage"]["duration_seconds"] == 10
|
||||
|
||||
def test_duration_over_10s_truncates_to_10s(self):
|
||||
from packages.shared.jimeng_client import JimengClient
|
||||
|
||||
with patch("packages.shared.jimeng_client.get_shared_settings") as ms:
|
||||
ms.return_value = _make_settings(timeout=1, poll_interval=0)
|
||||
c = JimengClient()
|
||||
captured = {}
|
||||
|
||||
def fake_submit(prompt, image_url, frames, seed=-1):
|
||||
captured["frames"] = frames
|
||||
return "tid"
|
||||
|
||||
def fake_poll(tid):
|
||||
return "http://x/v.mp4"
|
||||
|
||||
def fake_download(url, out_dir, tid):
|
||||
with open("/tmp/fake3.mp4", "wb") as f:
|
||||
f.write(b"x" * 2048)
|
||||
return "/tmp/fake3.mp4"
|
||||
|
||||
with (
|
||||
patch.object(c, "_submit_task", side_effect=fake_submit),
|
||||
patch.object(c, "_poll_result", side_effect=fake_poll),
|
||||
patch.object(c, "_download_video", side_effect=fake_download),
|
||||
):
|
||||
r = c.video_generation("hi", image_url="http://x/y.jpg", duration=30, output_dir="/tmp")
|
||||
assert captured["frames"] == 241
|
||||
assert r["usage"]["duration_seconds"] == 10
|
||||
|
||||
|
||||
class TestSubmitTaskErrors:
|
||||
def test_submit_50411_writes_portrait_intercept(self):
|
||||
from packages.shared.jimeng_client import JimengClient
|
||||
|
||||
with patch("packages.shared.jimeng_client.get_shared_settings") as ms:
|
||||
ms.return_value = _make_settings()
|
||||
c = JimengClient()
|
||||
fake_resp = MagicMock(status_code=200, text='{"code":50411,"message":"Pre Img Risk Not Pass"}')
|
||||
fake_resp.json.return_value = {"code": 50411, "message": "Pre Img Risk Not Pass"}
|
||||
with patch("packages.shared.jimeng_client.httpx.request", return_value=fake_resp):
|
||||
tid = c._submit_task("p", "http://x/y.jpg", frames=121)
|
||||
assert tid is None
|
||||
assert c.last_video_error["error_code"] == "portrait_intercept"
|
||||
|
||||
def test_submit_returns_task_id(self):
|
||||
from packages.shared.jimeng_client import JimengClient
|
||||
|
||||
with patch("packages.shared.jimeng_client.get_shared_settings") as ms:
|
||||
ms.return_value = _make_settings()
|
||||
c = JimengClient()
|
||||
fake_resp = MagicMock(status_code=200, text='{"code":10000,"data":{"task_id":"abc"}}')
|
||||
fake_resp.json.return_value = {"code": 10000, "data": {"task_id": "abc"}}
|
||||
with patch("packages.shared.jimeng_client.httpx.request", return_value=fake_resp):
|
||||
tid = c._submit_task("p", "http://x/y.jpg", frames=121)
|
||||
assert tid == "abc"
|
||||
|
||||
|
||||
class TestPollResult:
|
||||
def test_poll_done_returns_video_url(self):
|
||||
from packages.shared.jimeng_client import JimengClient
|
||||
|
||||
with patch("packages.shared.jimeng_client.get_shared_settings") as ms:
|
||||
ms.return_value = _make_settings(timeout=10, poll_interval=0)
|
||||
c = JimengClient()
|
||||
done_resp = MagicMock(status_code=200)
|
||||
done_resp.json.return_value = {"code": 10000, "data": {"status": "done", "video_url": "http://x/v.mp4"}}
|
||||
with (
|
||||
patch("packages.shared.jimeng_client.httpx.request", return_value=done_resp),
|
||||
patch("packages.shared.jimeng_client.time.sleep"),
|
||||
):
|
||||
url = c._poll_result("abc")
|
||||
assert url == "http://x/v.mp4"
|
||||
|
||||
def test_poll_timeout_returns_none(self):
|
||||
from packages.shared.jimeng_client import JimengClient
|
||||
|
||||
with patch("packages.shared.jimeng_client.get_shared_settings") as ms:
|
||||
ms.return_value = _make_settings(timeout=1, poll_interval=0)
|
||||
c = JimengClient()
|
||||
queue_resp = MagicMock(status_code=200)
|
||||
queue_resp.json.return_value = {"code": 10000, "data": {"status": "in_queue"}}
|
||||
# time.time 会被调用,模拟超时
|
||||
with (
|
||||
patch("packages.shared.jimeng_client.httpx.request", return_value=queue_resp),
|
||||
patch("packages.shared.jimeng_client.time.sleep"),
|
||||
):
|
||||
url = c._poll_result("abc")
|
||||
assert url is None
|
||||
assert c.last_video_error["error_code"] == "network_error"
|
||||
assert "超时" in c.last_video_error["user_message"]
|
||||
@@ -1,16 +1,15 @@
|
||||
"""lipsync 口型同步 — v1.6.2 起免费,不扣积分"""
|
||||
"""lipsync 积分扣点单元测试 (#1895 P2 step 2.2)"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import MagicMock, patch
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
from fastapi import HTTPException
|
||||
|
||||
|
||||
def _cu(user_id="u1", is_member=False, member_type=None):
|
||||
def _make_cu(user_id="user-1", is_member=False, member_type=None):
|
||||
cu = MagicMock()
|
||||
cu.user.id = user_id
|
||||
cu.user.is_member = is_member
|
||||
@@ -18,6 +17,99 @@ def _cu(user_id="u1", is_member=False, member_type=None):
|
||||
return cu
|
||||
|
||||
|
||||
class TestLipsyncDurationEstimate:
|
||||
@pytest.mark.parametrize(
|
||||
"text,expected",
|
||||
[
|
||||
("你好", 1.0),
|
||||
("你" * 240, 1.0),
|
||||
("你" * 241, 2.0),
|
||||
("你" * 1000, 5.0),
|
||||
],
|
||||
)
|
||||
def test_text_estimate(self, text, expected):
|
||||
est = max(1.0, math.ceil(len(text) / 240))
|
||||
assert est == expected
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"seconds,expected",
|
||||
[
|
||||
(30, 1.0),
|
||||
(60, 1.0),
|
||||
(61, 2.0),
|
||||
(120, 2.0),
|
||||
(180, 3.0),
|
||||
],
|
||||
)
|
||||
def test_audio_duration_estimate(self, seconds, expected):
|
||||
est = max(1.0, math.ceil(seconds / 60.0))
|
||||
assert est == expected
|
||||
|
||||
|
||||
class TestLipsyncPointsDeduction:
|
||||
def _deduct(self, text="你好", audio_duration=None, enabled=True, success=True, balance=100, **cu_kw):
|
||||
from packages.domain.points_rules import calculate_points_cost
|
||||
|
||||
svc = MagicMock() if enabled else None
|
||||
cu = _make_cu(**cu_kw)
|
||||
if svc is None:
|
||||
return 0, cu
|
||||
if audio_duration and audio_duration > 0:
|
||||
est = max(1.0, math.ceil(audio_duration / 60.0))
|
||||
elif text:
|
||||
est = max(1.0, math.ceil(len(text) / 240))
|
||||
else:
|
||||
est = 1.0
|
||||
cost = calculate_points_cost(
|
||||
"ai_digital_human",
|
||||
is_member=getattr(cu.user, "is_member", False),
|
||||
duration_minutes=est,
|
||||
member_type=getattr(cu.user, "member_type", None),
|
||||
)
|
||||
svc.deduct_points.return_value = {"success": success, "balance": balance}
|
||||
res = svc.deduct_points(cu.user.id, cost, "ai_digital_human", MagicMock())
|
||||
if not res["success"]:
|
||||
raise HTTPException(status_code=402, detail={"code": "INSUFFICIENT_POINTS"})
|
||||
return cost, cu
|
||||
|
||||
def test_disabled(self):
|
||||
cost, _ = self._deduct(enabled=False)
|
||||
assert cost == 0
|
||||
|
||||
def test_short_text_min_1min(self):
|
||||
cost, _ = self._deduct(text="你好")
|
||||
assert cost >= 15 # 15 base/min for free user × 1.15
|
||||
|
||||
def test_audio_duration_used(self):
|
||||
cost_long, _ = self._deduct(audio_duration=180) # 3min
|
||||
cost_short, _ = self._deduct(audio_duration=30) # 1min
|
||||
assert cost_long > cost_short
|
||||
|
||||
def test_insufficient_402(self):
|
||||
with pytest.raises(HTTPException) as ei:
|
||||
self._deduct(text="你" * 500, success=False, balance=0)
|
||||
assert ei.value.status_code == 402
|
||||
|
||||
def test_member_cheaper(self):
|
||||
cm, _ = self._deduct(text="你" * 500, is_member=True, member_type="yearly")
|
||||
cf, _ = self._deduct(text="你" * 500, is_member=False)
|
||||
assert cm < cf
|
||||
|
||||
|
||||
# ── 直接调用 create_lipsync_job 覆盖扣点/402/退费分支 ──
|
||||
import importlib
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import patch
|
||||
|
||||
import packages.middleware.points_gate as _pg_module
|
||||
|
||||
|
||||
# Ensure the enable-gate fixture for lipsync also covers @points_gate (if any)
|
||||
# (the existing autouse _enable is below; importlib to avoid duplicate)
|
||||
def _do_enable(monkeypatch):
|
||||
monkeypatch.setattr(_pg_module, "_points_gate_enabled", lambda: True)
|
||||
|
||||
|
||||
def _body(**kw):
|
||||
b = MagicMock()
|
||||
defaults = dict(
|
||||
@@ -38,76 +130,113 @@ def _body(**kw):
|
||||
return b
|
||||
|
||||
|
||||
class TestLipsyncFree:
|
||||
"""lipsync 已移除手动扣点,业务异常仍按原状态码抛出。"""
|
||||
def _cu(user_id="u1", is_member=False, member_type=None):
|
||||
cu = MagicMock()
|
||||
cu.user.id = user_id
|
||||
cu.user.is_member = is_member
|
||||
cu.user.member_type = member_type
|
||||
return cu
|
||||
|
||||
def test_ai_digital_human_returns_zero_cost(self):
|
||||
from packages.domain.points_rules import calculate_points_cost
|
||||
|
||||
assert calculate_points_cost("ai_digital_human", is_member=False, duration_minutes=1) == 0
|
||||
assert calculate_points_cost("ai_digital_human", is_member=True, duration_minutes=10) == 0
|
||||
|
||||
def test_module_has_no_points_imports(self):
|
||||
import inspect
|
||||
|
||||
from app.api.routes import lipsync as mod
|
||||
|
||||
src = inspect.getsource(mod)
|
||||
assert "PointsService" not in src
|
||||
assert "calculate_points_cost" not in src
|
||||
assert "_points_deducted" not in src
|
||||
assert "settings" not in src # settings was only used for points_enabled
|
||||
|
||||
def test_docstring_at_top_of_create_lipsync_job(self):
|
||||
"""扣点块删除后,docstring 必须在函数体第一行(防止函数体中段 docstring 丢失)。"""
|
||||
import ast
|
||||
import inspect
|
||||
|
||||
class TestLipsyncEndpointPoints:
|
||||
def test_insufficient_raises_402(self, monkeypatch):
|
||||
_do_enable(monkeypatch)
|
||||
from app.api.routes.lipsync import create_lipsync_job
|
||||
|
||||
src = inspect.getsource(create_lipsync_job)
|
||||
tree = ast.parse(src)
|
||||
fn = tree.body[0]
|
||||
# docstring 应为函数体第一条语句
|
||||
assert (
|
||||
isinstance(fn.body[0], ast.Expr)
|
||||
and isinstance(fn.body[0].value, ast.Constant)
|
||||
and isinstance(fn.body[0].value.value, str)
|
||||
), "create_lipsync_job docstring 不在函数体开头"
|
||||
db = MagicMock()
|
||||
svc = MagicMock()
|
||||
ps = MagicMock()
|
||||
ps.deduct_points.return_value = {"success": False, "balance": 0}
|
||||
fs = MagicMock(points_enabled=True)
|
||||
with (
|
||||
patch("app.api.routes.lipsync.PointsService", return_value=ps),
|
||||
patch("app.api.routes.lipsync.settings", fs),
|
||||
):
|
||||
with pytest.raises(HTTPException) as ei:
|
||||
create_lipsync_job(body=_body(script_text="你" * 500), current_user=_cu(), db=db, svc=svc)
|
||||
assert ei.value.status_code == 402
|
||||
|
||||
def test_docstring_at_top_of_preview_tts(self):
|
||||
import ast
|
||||
import inspect
|
||||
|
||||
from app.api.routes.lipsync import preview_tts
|
||||
|
||||
src = inspect.getsource(preview_tts)
|
||||
tree = ast.parse(src)
|
||||
fn = tree.body[0]
|
||||
assert (
|
||||
isinstance(fn.body[0], ast.Expr)
|
||||
and isinstance(fn.body[0].value, ast.Constant)
|
||||
and isinstance(fn.body[0].value.value, str)
|
||||
), "preview_tts docstring 不在函数体开头"
|
||||
|
||||
def test_value_error_still_raises_400(self):
|
||||
"""业务异常仍抛 400(不再退费)。"""
|
||||
def test_value_error_refunds(self, monkeypatch):
|
||||
_do_enable(monkeypatch)
|
||||
from app.api.routes.lipsync import create_lipsync_job
|
||||
|
||||
db = MagicMock()
|
||||
svc = MagicMock()
|
||||
svc.create_job.side_effect = ValueError("bad input")
|
||||
with pytest.raises(HTTPException) as ei:
|
||||
create_lipsync_job(body=_body(), current_user=_cu(), db=db, svc=svc)
|
||||
assert ei.value.status_code == 400
|
||||
ps = MagicMock()
|
||||
ps.deduct_points.return_value = {"success": True, "balance": 99}
|
||||
fs = MagicMock(points_enabled=True)
|
||||
with (
|
||||
patch("app.api.routes.lipsync.PointsService", return_value=ps),
|
||||
patch("app.api.routes.lipsync.settings", fs),
|
||||
):
|
||||
with pytest.raises(HTTPException) as ei:
|
||||
create_lipsync_job(body=_body(), current_user=_cu(), db=db, svc=svc)
|
||||
assert ei.value.status_code == 400
|
||||
assert ps.refund_points.called
|
||||
|
||||
def test_success_returns_job(self):
|
||||
def test_mediakit_error_refunds(self, monkeypatch):
|
||||
_do_enable(monkeypatch)
|
||||
from app.api.routes.lipsync import create_lipsync_job
|
||||
from app.services.mediakit_client import MediaKitError
|
||||
|
||||
db = MagicMock()
|
||||
svc = MagicMock()
|
||||
svc.create_job.side_effect = MediaKitError("fail", code="InvalidInput")
|
||||
ps = MagicMock()
|
||||
ps.deduct_points.return_value = {"success": True, "balance": 99}
|
||||
fs = MagicMock(points_enabled=True)
|
||||
with (
|
||||
patch("app.api.routes.lipsync.PointsService", return_value=ps),
|
||||
patch("app.api.routes.lipsync.settings", fs),
|
||||
):
|
||||
with pytest.raises(HTTPException) as ei:
|
||||
create_lipsync_job(body=_body(), current_user=_cu(), db=db, svc=svc)
|
||||
assert ei.value.status_code == 400
|
||||
assert ps.refund_points.called
|
||||
|
||||
def test_generic_exception_refunds(self, monkeypatch):
|
||||
_do_enable(monkeypatch)
|
||||
from app.api.routes.lipsync import create_lipsync_job
|
||||
|
||||
db = MagicMock()
|
||||
svc = MagicMock()
|
||||
svc.create_job.side_effect = RuntimeError("boom")
|
||||
ps = MagicMock()
|
||||
ps.deduct_points.return_value = {"success": True, "balance": 99}
|
||||
fs = MagicMock(points_enabled=True)
|
||||
with (
|
||||
patch("app.api.routes.lipsync.PointsService", return_value=ps),
|
||||
patch("app.api.routes.lipsync.settings", fs),
|
||||
):
|
||||
with pytest.raises(HTTPException) as ei:
|
||||
create_lipsync_job(body=_body(), current_user=_cu(), db=db, svc=svc)
|
||||
assert ei.value.status_code == 400
|
||||
assert ps.refund_points.called
|
||||
|
||||
def test_audio_duration_estimation(self, monkeypatch):
|
||||
_do_enable(monkeypatch)
|
||||
from app.api.routes.lipsync import create_lipsync_job
|
||||
|
||||
from packages.domain.points_rules import calculate_points_cost
|
||||
|
||||
db = MagicMock()
|
||||
svc = MagicMock()
|
||||
job = SimpleNamespace(id="job-1", status="queued")
|
||||
svc.create_job.return_value = job
|
||||
# 不再依赖 settings/PointsService patch
|
||||
result = create_lipsync_job(body=_body(), current_user=_cu(), db=db, svc=svc)
|
||||
assert result is job
|
||||
ps = MagicMock()
|
||||
ps.deduct_points.return_value = {"success": True, "balance": 99}
|
||||
fs = MagicMock(points_enabled=True)
|
||||
with (
|
||||
patch("app.api.routes.lipsync.PointsService", return_value=ps),
|
||||
patch("app.api.routes.lipsync.settings", fs),
|
||||
):
|
||||
create_lipsync_job(
|
||||
body=_body(audio_url="http://x/a.mp3", audio_duration=180, script_text=None),
|
||||
current_user=_cu(),
|
||||
db=db,
|
||||
svc=svc,
|
||||
)
|
||||
# 180 seconds -> 3 minutes; assert deduct called with cost >= 15*3
|
||||
args = ps.deduct_points.call_args[0]
|
||||
assert args[1] >= calculate_points_cost("ai_digital_human", is_member=False, duration_minutes=3)
|
||||
|
||||
@@ -44,7 +44,7 @@ class TestExtractKwargs:
|
||||
|
||||
class TestPointsGateSync:
|
||||
def test_no_user_raises_401(self):
|
||||
@points_gate("voice_clone_synth")
|
||||
@points_gate("ai_rewrite")
|
||||
def my_func(db=None):
|
||||
return "ok"
|
||||
|
||||
@@ -53,7 +53,7 @@ class TestPointsGateSync:
|
||||
assert exc_info.value.status_code == 401
|
||||
|
||||
def test_no_db_raises_500(self):
|
||||
@points_gate("voice_clone_synth")
|
||||
@points_gate("ai_rewrite")
|
||||
def my_func(current_user=None, db=None):
|
||||
return "ok"
|
||||
|
||||
@@ -85,14 +85,7 @@ class TestPointsGateExecuteLogic:
|
||||
with patch("packages.domain.points_service.PointsService", return_value=mock_svc):
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
_execute_with_gate(
|
||||
my_func,
|
||||
(),
|
||||
{"current_user": cu, "db": db},
|
||||
"voice_clone_synth",
|
||||
per_unit=10,
|
||||
unit_field=None,
|
||||
quantity_field=None,
|
||||
is_async=False,
|
||||
my_func, (), {"current_user": cu, "db": db}, "ai_rewrite", None, None, None, is_async=False
|
||||
)
|
||||
assert exc_info.value.status_code == 402
|
||||
|
||||
@@ -122,7 +115,7 @@ class TestPointsGateExecuteLogic:
|
||||
my_func,
|
||||
(),
|
||||
{"current_user": cu, "db": db},
|
||||
"voice_clone_synth",
|
||||
"ai_rewrite",
|
||||
per_unit=10,
|
||||
unit_field=None,
|
||||
quantity_field=None,
|
||||
@@ -146,7 +139,7 @@ class TestPointsGateExecuteLogic:
|
||||
failing_func,
|
||||
(),
|
||||
{"current_user": cu, "db": db},
|
||||
"voice_clone_synth",
|
||||
"ai_rewrite",
|
||||
per_unit=10,
|
||||
unit_field=None,
|
||||
quantity_field=None,
|
||||
@@ -154,21 +147,21 @@ class TestPointsGateExecuteLogic:
|
||||
)
|
||||
mock_svc.refund_points.assert_called_once()
|
||||
|
||||
def test_retired_scene_passes_through_with_zero_deduction(self):
|
||||
"""已下线场景(如 ai_video/ai_rewrite/ai_voice 等)直接放行,不扣积分。"""
|
||||
def test_ai_video_free_quota_for_free_user(self):
|
||||
cu = _make_current_user(is_member=False)
|
||||
db = MagicMock()
|
||||
mock_svc = MagicMock()
|
||||
mock_svc.check_daily_free_clip.return_value = True
|
||||
mock_svc.record_daily_free_clip.return_value = True
|
||||
|
||||
def my_func(current_user=cu, db=db, **kwargs):
|
||||
return kwargs.get("_points_deducted", -1)
|
||||
return kwargs.get("_is_free_quota", False)
|
||||
|
||||
# 不应调用 PointsService
|
||||
with patch("packages.domain.points_service.PointsService") as mock_svc_cls:
|
||||
with patch("packages.domain.points_service.PointsService", return_value=mock_svc):
|
||||
result = _execute_with_gate(
|
||||
my_func, (), {"current_user": cu, "db": db}, "ai_video", None, None, None, is_async=False
|
||||
)
|
||||
assert result == 0
|
||||
mock_svc_cls.assert_not_called()
|
||||
assert result is True
|
||||
|
||||
|
||||
class TestPointsGateAsync:
|
||||
@@ -179,7 +172,7 @@ class TestPointsGateAsync:
|
||||
mock_svc = MagicMock()
|
||||
mock_svc.deduct_points.return_value = {"success": True, "balance": 90, "transaction_id": "t1"}
|
||||
|
||||
@points_gate("voice_clone_synth", per_unit=5)
|
||||
@points_gate("ai_rewrite", per_unit=5)
|
||||
async def my_async_func(current_user=None, db=None, **kwargs):
|
||||
return kwargs.get("_points_deducted", 0)
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
覆盖:
|
||||
- P0-1: POST /points/recharge 返回 pay_params / points_amount / expire_at
|
||||
- P0-2: POST /points/check 任意 scene_key 均可查询(已下线场景返回 cost=0,不报错)
|
||||
- P0-2: POST /points/check 未知 scene_key 返回 400(非 500)
|
||||
- P1-3: GET /points/rules 返回 description 字段
|
||||
- P1-6: GET /subscription/plans 返回档位列表
|
||||
- P1-7: multiplier 实际扣费一致(calculate_points_cost 统一应用)
|
||||
@@ -76,40 +76,39 @@ class TestRechargeOrderResponse:
|
||||
assert exc.value.status_code == 400
|
||||
|
||||
|
||||
# ── P0-2: check 任意 scene_key(已下线场景返回 cost=0) ──────────────────
|
||||
# ── P0-2: check unknown scene → 400 ───────────────────────────────────
|
||||
|
||||
|
||||
class TestCheckPointsUnknownScene:
|
||||
def test_unknown_scene_returns_zero_cost_not_error(self):
|
||||
"""任意 scene_key 均可查询,已下线/未知场景返回 cost=0(免费放行)。"""
|
||||
def test_unknown_scene_returns_400_not_500(self):
|
||||
"""未知 scene_key(如 ai_script)应返回 400 UNKNOWN_SCENE,而不是 500。"""
|
||||
from app.api.routes.points import check_points
|
||||
from app.schemas.points import PointsCheckRequest
|
||||
|
||||
svc = MagicMock()
|
||||
svc.get_or_create_account.return_value = {"balance": 0}
|
||||
db = MagicMock()
|
||||
cu = _make_cu()
|
||||
body = PointsCheckRequest(scene_key="ai_script", quantity=1)
|
||||
|
||||
with (
|
||||
patch("app.api.routes.points._credits_enabled", return_value=True),
|
||||
patch("app.api.routes.points._get_service", return_value=svc),
|
||||
):
|
||||
for scene in ["ai_script", "ai_voice", "ai_video", "ai_title", "ai_cover", "nonexistent"]:
|
||||
body = PointsCheckRequest(scene_key=scene, quantity=1)
|
||||
resp = check_points(body=body, current_user=cu, db=db)
|
||||
assert resp.required_points == 0, f"{scene} should be free"
|
||||
assert resp.allowed is True
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
check_points(body=body, current_user=cu, db=db)
|
||||
assert exc.value.status_code == 400
|
||||
detail = exc.value.detail
|
||||
assert detail["code"] == "UNKNOWN_SCENE"
|
||||
assert "ai_script" in detail["message"]
|
||||
assert "ai_voice" in detail["valid_scenes"]
|
||||
assert "ai_title" in detail["valid_scenes"]
|
||||
|
||||
def test_voice_clone_synth_still_charges(self):
|
||||
"""合法付费场景 voice_clone_synth 正常计费:免费用户 1 分钟 = ceil(1*1.15)=2 积分。"""
|
||||
def test_known_scene_still_works(self):
|
||||
"""合法 scene_key 正常返回,免费用户 ai_voice 1 分钟 = 2 积分。"""
|
||||
from app.api.routes.points import check_points
|
||||
from app.schemas.points import PointsCheckRequest
|
||||
|
||||
svc = MagicMock()
|
||||
svc.check_daily_free_clip.return_value = False
|
||||
svc.get_or_create_account.return_value = {"balance": 50}
|
||||
db = MagicMock()
|
||||
cu = _make_cu()
|
||||
body = PointsCheckRequest(scene_key="voice_clone_synth", quantity=1, duration_minutes=1)
|
||||
body = PointsCheckRequest(scene_key="ai_voice", quantity=1, duration_minutes=1)
|
||||
|
||||
with (
|
||||
patch("app.api.routes.points._credits_enabled", return_value=True),
|
||||
@@ -129,9 +128,7 @@ class TestPointsRulesDescription:
|
||||
from app.api.routes.points import get_rules
|
||||
|
||||
resp = get_rules(_current_user=_make_cu())
|
||||
# 场景列表包含 voice_clone_train / voice_clone_synth / viral_video(爆款视频为动态定价)
|
||||
keys = {r.scene_key for r in resp.rules}
|
||||
assert {"voice_clone_train", "voice_clone_synth", "viral_video"}.issubset(keys)
|
||||
assert len(resp.rules) >= 9
|
||||
for rule in resp.rules:
|
||||
assert rule.description, f"{rule.scene_key} missing description"
|
||||
assert isinstance(rule.description, str)
|
||||
@@ -173,63 +170,43 @@ class TestSubscriptionPlans:
|
||||
_spec.loader.exec_module(_mod)
|
||||
return _mod.list_membership_plans
|
||||
|
||||
def test_plans_endpoint_reads_admin_table(self):
|
||||
"""/subscription/plans 改读管理后台 plans 表:返回 catalog 服务提供的真实档位。"""
|
||||
list_membership_plans = self._import_plans_fn()
|
||||
real_plan = {
|
||||
"plan_id": "premium_yearly",
|
||||
"billing_cycle": "yearly",
|
||||
"name": "高级会员年卡",
|
||||
"description": "高级会员年度订阅,享受全部功能",
|
||||
"price_cents": 39900,
|
||||
"monthly_price_cents": 3325,
|
||||
"duration_days": 365,
|
||||
"features": {
|
||||
"4K 超清分辨率": True,
|
||||
"批量渲染": True,
|
||||
"优先处理队列": True,
|
||||
"credits_per_month": 500,
|
||||
},
|
||||
}
|
||||
with patch(
|
||||
"packages.application.catalog.admin_catalog.get_membership_plans",
|
||||
return_value=[real_plan],
|
||||
):
|
||||
resp = list_membership_plans(current_user=_make_cu())
|
||||
plans = resp["plans"]
|
||||
assert len(plans) == 1
|
||||
p0 = plans[0]
|
||||
assert p0["plan_id"] == "premium_yearly"
|
||||
assert p0["price_cents"] == 39900
|
||||
assert p0["duration_days"] == 365
|
||||
assert p0["features"]["4K 超清分辨率"] is True
|
||||
def test_plans_endpoint_returns_three_tiers(self):
|
||||
import os # noqa: F401 (used by _import_plans_fn)
|
||||
|
||||
def test_plans_endpoint_empty_when_all_disabled(self):
|
||||
"""后台停用全部套餐时,用户端返回空列表。"""
|
||||
list_membership_plans = self._import_plans_fn()
|
||||
with patch(
|
||||
"packages.application.catalog.admin_catalog.get_membership_plans",
|
||||
return_value=[],
|
||||
):
|
||||
resp = list_membership_plans(current_user=_make_cu())
|
||||
assert resp["plans"] == []
|
||||
resp = list_membership_plans(current_user=_make_cu())
|
||||
plans = resp["plans"]
|
||||
plan_ids = {p["plan_id"] for p in plans}
|
||||
assert plan_ids == {"monthly", "quarterly", "yearly"}
|
||||
for p in plans:
|
||||
assert p["price_cents"] > 0
|
||||
assert p["duration_days"] in (30, 90, 365)
|
||||
assert 0 < p["points_discount"] <= 1.0
|
||||
assert "max_resolution" in p["features"]
|
||||
|
||||
def test_longer_plans_cheaper_per_month(self):
|
||||
import os # noqa: F401
|
||||
|
||||
list_membership_plans = self._import_plans_fn()
|
||||
resp = list_membership_plans(current_user=_make_cu())
|
||||
plans = resp["plans"]
|
||||
monthly = next(p for p in plans if p["plan_id"] == "monthly")
|
||||
quarterly = next(p for p in plans if p["plan_id"] == "quarterly")
|
||||
yearly = next(p for p in plans if p["plan_id"] == "yearly")
|
||||
assert monthly["monthly_price_cents"] == 1990
|
||||
assert quarterly["monthly_price_cents"] < monthly["monthly_price_cents"]
|
||||
assert yearly["monthly_price_cents"] < quarterly["monthly_price_cents"]
|
||||
|
||||
|
||||
# ── P1-7: multiplier consistency ──────────────────────────────────────
|
||||
|
||||
|
||||
class TestMultiplierConsistency:
|
||||
def test_free_user_voice_clone_synth_1min_costs_2(self):
|
||||
"""voice_clone_synth base=1,免费用户 ceil(1*1.15)=2。"""
|
||||
def test_free_user_ai_title_costs_2(self):
|
||||
"""ai_title base=1,免费用户 ceil(1*1.15)=2。"""
|
||||
from packages.domain.points_rules import calculate_points_cost
|
||||
|
||||
assert calculate_points_cost("voice_clone_synth", is_member=False, duration_minutes=1) == 2
|
||||
|
||||
def test_retired_scenes_return_zero(self):
|
||||
"""已下线场景(ai_voice/ai_title/ai_cover/ai_rewrite 等)calculate_points_cost 统一返回 0。"""
|
||||
from packages.domain.points_rules import calculate_points_cost
|
||||
|
||||
for scene in ["ai_voice", "ai_title", "ai_cover", "ai_rewrite", "ai_video", "ai_digital_human"]:
|
||||
assert calculate_points_cost(scene, is_member=False, quantity=1) == 0
|
||||
assert calculate_points_cost("ai_title", is_member=False, quantity=1) == 2
|
||||
|
||||
def test_check_matches_direct_calculation(self):
|
||||
"""check 端点 required_points 与 calculate_points_cost 结果一致。"""
|
||||
@@ -239,14 +216,15 @@ class TestMultiplierConsistency:
|
||||
from packages.domain.points_rules import calculate_points_cost
|
||||
|
||||
svc = MagicMock()
|
||||
svc.check_daily_free_clip.return_value = False
|
||||
svc.get_or_create_account.return_value = {"balance": 999}
|
||||
db = MagicMock()
|
||||
cu = _make_cu()
|
||||
|
||||
with patch("app.api.routes.points._credits_enabled", return_value=True):
|
||||
for scene in ["voice_clone_synth", "voice_clone_train", "ai_voice", "ai_video", "ai_title"]:
|
||||
body = PointsCheckRequest(scene_key=scene, quantity=1, duration_minutes=1)
|
||||
for scene in ["ai_voice", "ai_title", "ai_cover", "ai_rewrite"]:
|
||||
body = PointsCheckRequest(scene_key=scene, quantity=1)
|
||||
with patch("app.api.routes.points._get_service", return_value=svc):
|
||||
resp = check_points(body=body, current_user=cu, db=db)
|
||||
expected = calculate_points_cost(scene, is_member=False, quantity=1, duration_minutes=1)
|
||||
expected = calculate_points_cost(scene, is_member=False, quantity=1)
|
||||
assert resp.required_points == expected, f"{scene}: got {resp.required_points}, expected {expected}"
|
||||
|
||||
+62
-565
@@ -1,4 +1,4 @@
|
||||
"""积分消耗规则单元测试 (#1895) — v1.6.2: 仅保留 voice_clone 相关"""
|
||||
"""积分消耗规则单元测试 (#1895)"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -7,6 +7,7 @@ import math
|
||||
import pytest
|
||||
|
||||
from packages.domain.points_rules import (
|
||||
DAILY_FREE_CLIP_LIMIT,
|
||||
FREE_USER_MULTIPLIER,
|
||||
MEMBER_DISCOUNT,
|
||||
MEMBERSHIP_PRICES,
|
||||
@@ -19,22 +20,8 @@ from packages.domain.points_rules import (
|
||||
class TestPointsScenesConfig:
|
||||
"""场景配置完整性"""
|
||||
|
||||
def test_registered_scenes_include_voice_clone_and_viral_video(self):
|
||||
"""场景配置:包含声音克隆(训练/合成)+ 爆款视频(动态定价)。"""
|
||||
assert {"voice_clone_train", "voice_clone_synth", "viral_video"}.issubset(set(POINTS_SCENES.keys()))
|
||||
|
||||
def test_viral_video_scene_is_dynamic_with_zero_base(self):
|
||||
"""viral_video 必须注册但 base_points=0 且 dynamic=True,不使用 @points_gate。"""
|
||||
vv = POINTS_SCENES["viral_video"]
|
||||
assert vv["base_points"] == 0
|
||||
assert vv["dynamic"] is True
|
||||
assert vv["unit"] == "次"
|
||||
assert vv["name"] == "爆款视频"
|
||||
|
||||
def test_voice_clone_scenes_defined(self):
|
||||
# 保留声音克隆两个场景
|
||||
assert "voice_clone_train" in POINTS_SCENES
|
||||
assert "voice_clone_synth" in POINTS_SCENES
|
||||
def test_all_nine_scenes_defined(self):
|
||||
assert len(POINTS_SCENES) == 9
|
||||
|
||||
def test_required_keys_present(self):
|
||||
for key, scene in POINTS_SCENES.items():
|
||||
@@ -45,14 +32,8 @@ class TestPointsScenesConfig:
|
||||
def test_voice_clone_train_is_free(self):
|
||||
assert POINTS_SCENES["voice_clone_train"]["base_points"] == 0
|
||||
|
||||
def test_voice_clone_synth_is_per_minute(self):
|
||||
assert POINTS_SCENES["voice_clone_synth"]["base_points"] == 1
|
||||
assert POINTS_SCENES["voice_clone_synth"]["unit"] == "分钟"
|
||||
|
||||
def test_calculate_points_cost_returns_zero_for_dynamic_viral_video(self):
|
||||
"""calculate_points_cost 对动态场景 viral_video 必须返回 0(由业务侧手动计算)。"""
|
||||
assert calculate_points_cost("viral_video", is_member=False) == 0.0
|
||||
assert calculate_points_cost("viral_video", is_member=True, member_type="monthly") == 0.0
|
||||
def test_ai_video_has_extra_per_30s(self):
|
||||
assert POINTS_SCENES["ai_video"]["extra_per_30s"] == 1
|
||||
|
||||
|
||||
class TestPointsPackages:
|
||||
@@ -71,23 +52,43 @@ class TestMembershipPrices:
|
||||
assert MEMBERSHIP_PRICES["yearly"]["price_cents"] == 15900
|
||||
|
||||
|
||||
class TestDailyFreeLimit:
|
||||
def test_limit_is_2(self):
|
||||
assert DAILY_FREE_CLIP_LIMIT == 2
|
||||
|
||||
|
||||
class TestCalculatePointsCost:
|
||||
"""核心计费逻辑"""
|
||||
|
||||
# ── 声音克隆合成(按时长计费) ──
|
||||
# ── 按次计费 ──
|
||||
|
||||
def test_voice_clone_synth_base(self):
|
||||
cost = calculate_points_cost("voice_clone_synth", is_member=False, duration_minutes=3)
|
||||
assert cost == math.ceil(3 * FREE_USER_MULTIPLIER)
|
||||
|
||||
def test_voice_clone_synth_rounds_up(self):
|
||||
cost = calculate_points_cost("voice_clone_synth", is_member=False, duration_minutes=2.3)
|
||||
assert cost == math.ceil(3 * FREE_USER_MULTIPLIER)
|
||||
|
||||
def test_voice_clone_synth_minimum_1_minute(self):
|
||||
cost = calculate_points_cost("voice_clone_synth", is_member=False, duration_minutes=0.1)
|
||||
def test_per_time_base_cost(self):
|
||||
# ai_rewrite: 1积分/次,免费用户 ceil(1 * 1.15) = 2
|
||||
cost = calculate_points_cost("ai_rewrite", is_member=False, quantity=1)
|
||||
assert cost == math.ceil(1 * FREE_USER_MULTIPLIER)
|
||||
|
||||
def test_per_time_multiple(self):
|
||||
# ai_cover: 1积分/张,3张 → base=3, free: ceil(3*1.15)=4
|
||||
cost = calculate_points_cost("ai_cover", is_member=False, quantity=3)
|
||||
assert cost == math.ceil(3 * FREE_USER_MULTIPLIER)
|
||||
|
||||
# ── 按时长计费 ──
|
||||
|
||||
def test_per_minute_base(self):
|
||||
# ai_voice: 1积分/分钟,3分钟 → base=3, free: ceil(3*1.15)=4
|
||||
cost = calculate_points_cost("ai_voice", is_member=False, duration_minutes=3)
|
||||
assert cost == math.ceil(3 * FREE_USER_MULTIPLIER)
|
||||
|
||||
def test_per_minute_rounds_up(self):
|
||||
# 2.3分钟 → ceil(2.3)=3分钟 → base=3
|
||||
cost = calculate_points_cost("ai_voice", is_member=False, duration_minutes=2.3)
|
||||
assert cost == math.ceil(3 * FREE_USER_MULTIPLIER)
|
||||
|
||||
def test_digital_human_expensive(self):
|
||||
# ai_digital_human: 15积分/分钟,1分钟 → base=15, free: ceil(15*1.15)=18
|
||||
cost = calculate_points_cost("ai_digital_human", is_member=False, duration_minutes=1)
|
||||
assert cost == 18
|
||||
|
||||
# ── 免费场景 ──
|
||||
|
||||
def test_voice_clone_train_free(self):
|
||||
@@ -98,546 +99,42 @@ class TestCalculatePointsCost:
|
||||
cost = calculate_points_cost("voice_clone_train", is_member=True)
|
||||
assert cost == 0
|
||||
|
||||
# ── 混剪额外逻辑 ──
|
||||
|
||||
def test_ai_video_short_no_extra(self):
|
||||
# 20s (0.33min) ≤ 30s,不额外加积分,base=3, free: ceil(3*1.15)=4
|
||||
cost = calculate_points_cost("ai_video", is_member=False, quantity=1, duration_minutes=0.33)
|
||||
assert cost == math.ceil(3 * FREE_USER_MULTIPLIER)
|
||||
|
||||
def test_ai_video_long_extra_charge(self):
|
||||
# 80s → base=3 + extra ceil((80-30)/30)=2 → total_base=5, free: ceil(5*1.15)=6
|
||||
cost = calculate_points_cost("ai_video", is_member=False, quantity=1, duration_minutes=80 / 60)
|
||||
assert cost == math.ceil(5 * FREE_USER_MULTIPLIER)
|
||||
|
||||
# ── 会员折扣 ──
|
||||
|
||||
def test_monthly_member_discount(self):
|
||||
cost = calculate_points_cost("voice_clone_synth", is_member=True, duration_minutes=1, member_type="monthly")
|
||||
assert cost == max(1, math.floor(1 * MEMBER_DISCOUNT["monthly"]))
|
||||
# ai_voice 1分钟 base=1, 月卡0.9 → floor(1*0.9)=1 → max(1,1)=1
|
||||
cost = calculate_points_cost("ai_voice", is_member=True, duration_minutes=1, member_type="monthly")
|
||||
assert cost == max(1, math.floor(1 * 0.9))
|
||||
|
||||
def test_yearly_member_deep_discount(self):
|
||||
# ai_digital_human 2分钟 base=30, 年卡0.8 → floor(30*0.8)=24
|
||||
cost = calculate_points_cost(
|
||||
"voice_clone_synth",
|
||||
"ai_digital_human",
|
||||
is_member=True,
|
||||
duration_minutes=2,
|
||||
member_type="yearly",
|
||||
)
|
||||
assert cost == max(1, math.floor(2 * MEMBER_DISCOUNT["yearly"]))
|
||||
assert cost == max(1, math.floor(30 * 0.8))
|
||||
|
||||
def test_member_without_type_no_discount(self):
|
||||
cost = calculate_points_cost("voice_clone_synth", is_member=True, duration_minutes=1)
|
||||
assert cost == 1
|
||||
# is_member=True 但没传 member_type → 不按会员折扣
|
||||
cost = calculate_points_cost("ai_voice", is_member=True, duration_minutes=1)
|
||||
assert cost == 1 # base=1, no discount applied
|
||||
|
||||
# ── 已下线/未知场景(向后兼容:返回 0) ──
|
||||
# ── 异常 ──
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"scene",
|
||||
[
|
||||
"ai_voice",
|
||||
"ai_video",
|
||||
"ai_digital_human",
|
||||
"ai_rewrite",
|
||||
"ai_cover",
|
||||
"ai_title",
|
||||
"douyin_extract",
|
||||
"nonexistent",
|
||||
],
|
||||
)
|
||||
def test_retired_scenes_return_zero(self, scene):
|
||||
assert calculate_points_cost(scene, is_member=False) == 0
|
||||
assert calculate_points_cost(scene, is_member=True, duration_minutes=10) == 0
|
||||
|
||||
|
||||
# ============ 爆款视频动态定价 (#2151) ============
|
||||
|
||||
|
||||
class TestResolveVideoDimensions:
|
||||
"""resolve_video_dimensions(): 分辨率别名、比例、默认兜底。"""
|
||||
|
||||
def test_1080p_16_9(self):
|
||||
"""1080p + 16:9 → w=1920, h=1080。"""
|
||||
from packages.domain.points_rules import resolve_video_dimensions
|
||||
|
||||
w, h = resolve_video_dimensions("1080p", "16:9")
|
||||
assert (w, h) == (1920, 1080)
|
||||
|
||||
def test_480p_16_9(self):
|
||||
"""480p + 16:9 → 854×480(ceil(480*16/9)=854,偶对齐)。"""
|
||||
from packages.domain.points_rules import resolve_video_dimensions
|
||||
|
||||
w, h = resolve_video_dimensions("480p", "16:9")
|
||||
assert (w, h) == (854, 480)
|
||||
|
||||
def test_720p_1_1(self):
|
||||
"""1:1 正方形 → w == h。"""
|
||||
from packages.domain.points_rules import resolve_video_dimensions
|
||||
|
||||
w, h = resolve_video_dimensions("720p", "1:1")
|
||||
assert (w, h) == (720, 720)
|
||||
|
||||
def test_1080p_1_1(self):
|
||||
from packages.domain.points_rules import resolve_video_dimensions
|
||||
|
||||
w, h = resolve_video_dimensions("1080p", "1:1")
|
||||
assert (w, h) == (1080, 1080)
|
||||
|
||||
def test_resolution_aliases(self):
|
||||
"""中文/英文别名应正确映射到对应高度。"""
|
||||
from packages.domain.points_rules import resolve_video_dimensions
|
||||
|
||||
cases = [
|
||||
("普清", 480),
|
||||
("sd", 480),
|
||||
("low", 480),
|
||||
("default", 480),
|
||||
("高清", 720),
|
||||
("medium", 720),
|
||||
("hd", 720),
|
||||
("超清", 1080),
|
||||
("fhd", 1080),
|
||||
("ultra", 1080),
|
||||
("全能", 1080),
|
||||
("high", 1080),
|
||||
]
|
||||
for alias, expected_h in cases:
|
||||
_, h = resolve_video_dimensions(alias, "1:1")
|
||||
assert h == expected_h, f"{alias} -> h={h}, expected {expected_h}"
|
||||
|
||||
def test_unknown_resolution_falls_back_to_720p(self):
|
||||
"""未知分辨率字符串兜底到 720p。"""
|
||||
from packages.domain.points_rules import resolve_video_dimensions
|
||||
|
||||
w, h = resolve_video_dimensions("garbage-xxx", "1:1")
|
||||
assert h == 720
|
||||
assert w == 720
|
||||
|
||||
def test_4k_16_9(self):
|
||||
"""#2159 4k 横屏:短边=height=2160,width=3840。"""
|
||||
from packages.domain.points_rules import resolve_video_dimensions
|
||||
|
||||
w, h = resolve_video_dimensions("4k", "16:9")
|
||||
assert (w, h) == (3840, 2160)
|
||||
|
||||
def test_2160p_alias(self):
|
||||
"""2160p 别名→4k。"""
|
||||
from packages.domain.points_rules import resolve_video_dimensions
|
||||
|
||||
w, h = resolve_video_dimensions("2160p", "9:16")
|
||||
assert (w, h) == (2160, 3840)
|
||||
|
||||
def test_empty_resolution_defaults_to_720p_9_16(self):
|
||||
"""空 resolution + 空 ratio → 默认 720p + 9:16 竖屏 (720×1280)。"""
|
||||
from packages.domain.points_rules import resolve_video_dimensions
|
||||
|
||||
w, h = resolve_video_dimensions("", "")
|
||||
assert (w, h) == (720, 1280)
|
||||
|
||||
def test_none_resolution_default_ratio(self):
|
||||
"""None resolution + None ratio → 720p + 9:16 竖屏默认。"""
|
||||
from packages.domain.points_rules import resolve_video_dimensions
|
||||
|
||||
w, h = resolve_video_dimensions(None, None)
|
||||
assert (w, h) == (720, 1280)
|
||||
|
||||
def test_720p_9_16_portrait(self):
|
||||
"""720p + 9:16 竖屏 → 短边是 width=720,height=1280(v10实测)。"""
|
||||
from packages.domain.points_rules import resolve_video_dimensions
|
||||
|
||||
w, h = resolve_video_dimensions("720p", "9:16")
|
||||
assert (w, h) == (720, 1280)
|
||||
|
||||
def test_1080p_9_16_portrait(self):
|
||||
"""1080p + 9:16 竖屏 → 1080×1920。"""
|
||||
from packages.domain.points_rules import resolve_video_dimensions
|
||||
|
||||
w, h = resolve_video_dimensions("1080p", "9:16")
|
||||
assert (w, h) == (1080, 1920)
|
||||
|
||||
def test_480p_9_16_portrait(self):
|
||||
"""480p + 9:16 竖屏 → 480×854。"""
|
||||
from packages.domain.points_rules import resolve_video_dimensions
|
||||
|
||||
w, h = resolve_video_dimensions("480p", "9:16")
|
||||
assert (w, h) == (480, 854)
|
||||
|
||||
def test_all_dimensions_even(self):
|
||||
"""所有返回尺寸都应是偶数(视频编码要求)。"""
|
||||
from packages.domain.points_rules import resolve_video_dimensions
|
||||
|
||||
for res in ("480p", "720p", "1080p", "普清", "高清", "超清"):
|
||||
for ratio in ("16:9", "9:16", "1:1"):
|
||||
w, h = resolve_video_dimensions(res, ratio)
|
||||
assert w % 2 == 0 and h % 2 == 0, f"{res}/{ratio} -> ({w},{h}) not even"
|
||||
|
||||
def test_whitespace_resolution_case_insensitive(self):
|
||||
"""前后空格 + 大写应被规范化处理。"""
|
||||
from packages.domain.points_rules import resolve_video_dimensions
|
||||
|
||||
w, h = resolve_video_dimensions(" 1080P ", " 16:9 ")
|
||||
assert (w, h) == (1920, 1080)
|
||||
|
||||
|
||||
class TestMatchModelPrefix:
|
||||
"""_match_model_prefix() 前缀匹配 + 兜底。"""
|
||||
|
||||
def test_seedance_2_5_exact(self):
|
||||
from packages.domain.points_rules import _match_model_prefix
|
||||
|
||||
assert _match_model_prefix("seedance-2.5") == "seedance-2.5"
|
||||
|
||||
def test_seedance_2_5_with_variant(self):
|
||||
"""带后缀版本号(如 seedance-2.5-pro)仍匹配 seedance-2.5。"""
|
||||
from packages.domain.points_rules import _match_model_prefix
|
||||
|
||||
assert _match_model_prefix("seedance-2.5-pro") == "seedance-2.5"
|
||||
|
||||
def test_seedance_2_0_exact(self):
|
||||
from packages.domain.points_rules import _match_model_prefix
|
||||
|
||||
assert _match_model_prefix("seedance-2.0") == "seedance-2.0"
|
||||
|
||||
def test_seedance_2_0_with_variant(self):
|
||||
from packages.domain.points_rules import _match_model_prefix
|
||||
|
||||
assert _match_model_prefix("seedance-2.0-lite") == "seedance-2.0"
|
||||
|
||||
def test_unknown_model_falls_back_to_2_5(self):
|
||||
"""未知模型前缀兜底 seedance-2.5。"""
|
||||
from packages.domain.points_rules import _match_model_prefix
|
||||
|
||||
assert _match_model_prefix("kling-v1") == "seedance-2.5"
|
||||
assert _match_model_prefix("") == "seedance-2.5"
|
||||
assert _match_model_prefix(None) == "seedance-2.5"
|
||||
|
||||
def test_case_insensitive(self):
|
||||
from packages.domain.points_rules import _match_model_prefix
|
||||
|
||||
assert _match_model_prefix("SEEDANCE-2.0") == "seedance-2.0"
|
||||
|
||||
|
||||
class TestInferResolutionKey:
|
||||
"""_infer_resolution_key(w, h): 按短边 1000+/650-999/<650 三档。"""
|
||||
|
||||
def test_short_side_ge_1000_is_1080p(self):
|
||||
from packages.domain.points_rules import _infer_resolution_key
|
||||
|
||||
assert _infer_resolution_key(1920, 1080) == "1080p" # 横屏
|
||||
assert _infer_resolution_key(1080, 1920) == "1080p" # 竖屏
|
||||
assert _infer_resolution_key(1080, 1080) == "1080p" # 方屏
|
||||
|
||||
def test_short_side_650_to_999_is_720p(self):
|
||||
from packages.domain.points_rules import _infer_resolution_key
|
||||
|
||||
assert _infer_resolution_key(1280, 720) == "720p"
|
||||
assert _infer_resolution_key(720, 1280) == "720p"
|
||||
assert _infer_resolution_key(720, 720) == "720p"
|
||||
|
||||
def test_short_side_lt_650_is_480p(self):
|
||||
from packages.domain.points_rules import _infer_resolution_key
|
||||
|
||||
assert _infer_resolution_key(854, 480) == "480p"
|
||||
assert _infer_resolution_key(480, 854) == "480p"
|
||||
assert _infer_resolution_key(480, 480) == "480p"
|
||||
# 极小值兜底
|
||||
assert _infer_resolution_key(1, 1) == "480p"
|
||||
|
||||
def test_portrait_1280_height_is_720p_short_side(self):
|
||||
"""竖屏 720×1280 短边=720,应识别为 720p 而非 1080p(老bug回归)。"""
|
||||
from packages.domain.points_rules import _infer_resolution_key
|
||||
|
||||
assert _infer_resolution_key(720, 1280) == "720p"
|
||||
|
||||
|
||||
class TestCalculateViralVideoCredits:
|
||||
"""calculate_viral_video_credits():爆款视频动态定价核心函数。"""
|
||||
|
||||
def test_default_args_returns_float(self):
|
||||
"""默认参数返回 float。"""
|
||||
from packages.domain.points_rules import calculate_viral_video_credits
|
||||
|
||||
credits = calculate_viral_video_credits(15, 1280, 720)
|
||||
assert isinstance(credits, float)
|
||||
|
||||
def test_return_is_rounded_to_two_decimals(self):
|
||||
"""round(..., 2) 后值本身就是两位小数(再 round 不变化)。"""
|
||||
from packages.domain.points_rules import calculate_viral_video_credits
|
||||
|
||||
for dur, w, h in [(15, 1280, 720), (5, 854, 480), (30, 1920, 1080), (10, 720, 720)]:
|
||||
credits = calculate_viral_video_credits(dur, w, h)
|
||||
assert round(credits, 2) == credits
|
||||
|
||||
def test_has_video_input_uses_lower_price(self):
|
||||
"""has_video_input=True 时使用参考视频价格(有视频输入便宜)。"""
|
||||
from packages.domain.points_rules import calculate_viral_video_credits
|
||||
|
||||
no_input = calculate_viral_video_credits(15, 1280, 720, model="seedance-2.5", has_video_input=False)
|
||||
with_input = calculate_viral_video_credits(15, 1280, 720, model="seedance-2.5", has_video_input=True)
|
||||
assert with_input < no_input
|
||||
|
||||
def test_unknown_model_falls_back_to_seedance_2_5(self):
|
||||
"""未知 model 前缀兜底到 seedance-2.5 价格,与默认等价。"""
|
||||
from packages.domain.points_rules import calculate_viral_video_credits
|
||||
|
||||
unknown = calculate_viral_video_credits(15, 1280, 720, model="unknown-model")
|
||||
default = calculate_viral_video_credits(15, 1280, 720, model="seedance-2.5")
|
||||
assert unknown == default
|
||||
|
||||
def test_actual_tokens_overrides_calculation(self):
|
||||
"""传入 actual_tokens>0 时用它替代公式计算的 tokens。"""
|
||||
from packages.domain.points_rules import (
|
||||
VIRAL_VIDEO_FIXED_COST,
|
||||
VIRAL_VIDEO_MODEL_PRICES,
|
||||
VIRAL_VIDEO_PROFIT_MULTIPLIER,
|
||||
calculate_viral_video_credits,
|
||||
)
|
||||
|
||||
price = VIRAL_VIDEO_MODEL_PRICES[("seedance-2.5", "720p", False)]
|
||||
actual_tokens = 2_000_000
|
||||
expected = round(
|
||||
(actual_tokens / 1_000_000 * price + VIRAL_VIDEO_FIXED_COST) * VIRAL_VIDEO_PROFIT_MULTIPLIER, 2
|
||||
)
|
||||
credits = calculate_viral_video_credits(15, 1280, 720, actual_tokens=actual_tokens)
|
||||
assert credits == expected
|
||||
|
||||
def test_zero_duration_width_height_defensive_max1(self):
|
||||
"""duration/width/height 为 0/None 时 max(1,...) 防御,结果>0。"""
|
||||
from packages.domain.points_rules import calculate_viral_video_credits
|
||||
|
||||
c_zero = calculate_viral_video_credits(0, 0, 0)
|
||||
assert c_zero > 0
|
||||
c_none = calculate_viral_video_credits(None, None, None)
|
||||
assert c_none > 0
|
||||
c_one = calculate_viral_video_credits(1, 1, 1)
|
||||
assert c_none == c_one
|
||||
|
||||
def test_non_default_fps_affects_tokens(self):
|
||||
"""fps 非默认值(30) 应比默认(24) 积分高。"""
|
||||
from packages.domain.points_rules import calculate_viral_video_credits
|
||||
|
||||
c24 = calculate_viral_video_credits(15, 1280, 720, fps=24)
|
||||
c30 = calculate_viral_video_credits(15, 1280, 720, fps=30)
|
||||
assert c30 > c24
|
||||
|
||||
def test_seedance_2_0_priced_lower_than_2_5_at_1080p(self):
|
||||
"""seedance-2.0 在 1080p 无视频输入时定价低于 seedance-2.5。"""
|
||||
from packages.domain.points_rules import calculate_viral_video_credits
|
||||
|
||||
c20 = calculate_viral_video_credits(15, 1920, 1080, model="seedance-2.0", has_video_input=False)
|
||||
c25 = calculate_viral_video_credits(15, 1920, 1080, model="seedance-2.5", has_video_input=False)
|
||||
assert c20 < c25
|
||||
|
||||
def test_formula_includes_fixed_cost_and_multiplier(self):
|
||||
"""手算公式结果应与函数返回一致(固定成本 + 利润系数)。"""
|
||||
from packages.domain.points_rules import (
|
||||
VIRAL_VIDEO_FIXED_COST,
|
||||
VIRAL_VIDEO_FPS,
|
||||
VIRAL_VIDEO_MODEL_PRICES,
|
||||
VIRAL_VIDEO_PROFIT_MULTIPLIER,
|
||||
calculate_viral_video_credits,
|
||||
)
|
||||
|
||||
dur, w, h = 10, 1280, 720
|
||||
price = VIRAL_VIDEO_MODEL_PRICES[("seedance-2.5", "720p", False)]
|
||||
tokens = dur * w * h * VIRAL_VIDEO_FPS / 1024.0
|
||||
expected = round((tokens / 1_000_000 * price + VIRAL_VIDEO_FIXED_COST) * VIRAL_VIDEO_PROFIT_MULTIPLIER, 2)
|
||||
assert calculate_viral_video_credits(dur, w, h) == expected
|
||||
|
||||
def test_seedance_2_0_with_video_input_falls_back_to_seedance_2_5_price(self):
|
||||
"""seedance-2.0 + has_video_input=True 组合不在价格表,走 line 111 fallback 到 seedance-2.5 的 720p False 价格。"""
|
||||
from packages.domain.points_rules import (
|
||||
VIRAL_VIDEO_FIXED_COST,
|
||||
VIRAL_VIDEO_FPS,
|
||||
VIRAL_VIDEO_MODEL_PRICES,
|
||||
VIRAL_VIDEO_PROFIT_MULTIPLIER,
|
||||
calculate_viral_video_credits,
|
||||
)
|
||||
|
||||
dur, w, h = 10, 1280, 720
|
||||
# 兜底价格 = seedance-2.5/720p/False = 70.0
|
||||
price = VIRAL_VIDEO_MODEL_PRICES[("seedance-2.5", "720p", False)]
|
||||
assert price == 70.0
|
||||
tokens = dur * w * h * VIRAL_VIDEO_FPS / 1024.0
|
||||
expected = round((tokens / 1_000_000 * price + VIRAL_VIDEO_FIXED_COST) * VIRAL_VIDEO_PROFIT_MULTIPLIER, 2)
|
||||
credits = calculate_viral_video_credits(dur, w, h, model="seedance-2.0", has_video_input=True)
|
||||
assert credits == expected
|
||||
|
||||
def test_fps_zero_or_none_falls_back_to_default(self):
|
||||
"""fps=0/None 时 int(fps or 24) 兜底到默认 24,结果与 fps=24 一致。"""
|
||||
from packages.domain.points_rules import calculate_viral_video_credits
|
||||
|
||||
c_default = calculate_viral_video_credits(10, 1280, 720, fps=24)
|
||||
c_zero = calculate_viral_video_credits(10, 1280, 720, fps=0)
|
||||
c_none = calculate_viral_video_credits(10, 1280, 720, fps=None)
|
||||
assert c_zero == c_default
|
||||
assert c_none == c_default
|
||||
|
||||
# ──────── P0 计费回归:短边规则 + 价格精确断言 ────────
|
||||
|
||||
def test_15s_720p_portrait_is_29_68(self):
|
||||
"""P0 回归:15s/720p/9:16 竖屏 (720×1280) 必须 =29.68 积分。"""
|
||||
from packages.domain.points_rules import (
|
||||
calculate_viral_video_credits,
|
||||
resolve_video_dimensions,
|
||||
)
|
||||
|
||||
w, h = resolve_video_dimensions("720p", "9:16")
|
||||
assert (w, h) == (720, 1280)
|
||||
assert calculate_viral_video_credits(15, w, h) == 29.68
|
||||
|
||||
def test_30s_1080p_portrait_is_146_14(self):
|
||||
"""P0 回归:30s/1080p/9:16 竖屏 (1080×1920) =146.14 积分。"""
|
||||
from packages.domain.points_rules import (
|
||||
calculate_viral_video_credits,
|
||||
resolve_video_dimensions,
|
||||
)
|
||||
|
||||
w, h = resolve_video_dimensions("1080p", "9:16")
|
||||
assert (w, h) == (1080, 1920)
|
||||
assert calculate_viral_video_credits(30, w, h) == 146.14
|
||||
|
||||
def test_portrait_landscape_same_pixels_same_price(self):
|
||||
"""相同像素数(横竖屏旋转)积分一致。"""
|
||||
from packages.domain.points_rules import calculate_viral_video_credits
|
||||
|
||||
assert calculate_viral_video_credits(15, 1280, 720) == calculate_viral_video_credits(15, 720, 1280)
|
||||
|
||||
|
||||
class TestViralVideoCreditsWithBreakdown:
|
||||
"""calculate_viral_video_credits_with_breakdown:返回 (credits, breakdown_dict)。"""
|
||||
|
||||
def test_returns_credits_matching_plain_version(self):
|
||||
"""新函数返回的 credits 必须与 calculate_viral_video_credits 完全一致,且 breakdown 字段齐全。"""
|
||||
from packages.domain.points_rules import (
|
||||
calculate_viral_video_credits,
|
||||
calculate_viral_video_credits_with_breakdown,
|
||||
)
|
||||
|
||||
for dur, w, h, model, hvi in [
|
||||
(15, 1280, 720, "seedance-2.5", False),
|
||||
(10, 720, 1280, "seedance-2.0", False),
|
||||
(30, 1920, 1080, "seedance-2.5", False),
|
||||
(5, 480, 480, "", False),
|
||||
]:
|
||||
c1 = calculate_viral_video_credits(dur, w, h, model=model, has_video_input=hvi)
|
||||
c2, bd = calculate_viral_video_credits_with_breakdown(dur, w, h, model=model, has_video_input=hvi)
|
||||
assert c1 == c2
|
||||
assert isinstance(bd, dict)
|
||||
for key in (
|
||||
"tokens",
|
||||
"video_cost",
|
||||
"fixed_cost",
|
||||
"profit_multiplier",
|
||||
"model_price",
|
||||
"width",
|
||||
"height",
|
||||
"fps",
|
||||
):
|
||||
assert key in bd, f"breakdown missing key: {key}"
|
||||
assert bd["fixed_cost"] == 0.15
|
||||
assert bd["profit_multiplier"] == 1.3
|
||||
assert bd["width"] == w
|
||||
assert bd["height"] == h
|
||||
assert bd["fps"] == 24
|
||||
assert bd["tokens"] > 0
|
||||
assert bd["model_price"] > 0
|
||||
expected = round((bd["video_cost"] + bd["fixed_cost"]) * bd["profit_multiplier"], 2)
|
||||
assert expected == c2
|
||||
|
||||
def test_actual_tokens_overrides_computed(self):
|
||||
"""actual_tokens 传入时应覆盖按公式计算的 tokens。"""
|
||||
from packages.domain.points_rules import calculate_viral_video_credits_with_breakdown
|
||||
|
||||
c, bd = calculate_viral_video_credits_with_breakdown(
|
||||
15,
|
||||
1280,
|
||||
720,
|
||||
actual_tokens=1_000_000,
|
||||
)
|
||||
assert bd["tokens"] == 1_000_000.0
|
||||
# video_cost = 1M/1M * 70 = 70; total = (70+0.15)*1.3 = 91.195 → 91.20
|
||||
assert c == 91.20
|
||||
|
||||
|
||||
# ============ #2159 多模型定价单测 ============
|
||||
|
||||
|
||||
class TestMultiModelCredits:
|
||||
"""#2159 多模型积分估算正确性(含 token/second 两种计费模式)。"""
|
||||
|
||||
def test_seedance_2_5_15s_720p_9x16(self):
|
||||
from packages.domain.points_rules import calculate_viral_video_credits
|
||||
|
||||
# 15s/720p/9:16 → 720×1280
|
||||
# tokens = 15*720*1280*24/1024 = 324000
|
||||
# video_cost = 324000/1M*70 = 22.68
|
||||
# total = (22.68+0.15)*1.3 = 29.679 ≈ 29.68
|
||||
c = calculate_viral_video_credits(15, 720, 1280, model="seedance-2.5")
|
||||
assert c == 29.68, f"got {c}"
|
||||
|
||||
def test_seedance_2_0_30s_1080p_9x16(self):
|
||||
# 30s/1080p/9:16 → 1080×1920
|
||||
# tokens = 30*1080*1920*24/1024 = 1,458,000
|
||||
# video_cost = 1.458M/1M*51 = 74.358
|
||||
# total = (74.358+0.15)*1.3 = 96.86
|
||||
from packages.domain.points_rules import calculate_viral_video_credits
|
||||
|
||||
c = calculate_viral_video_credits(30, 1080, 1920, model="seedance-2.0")
|
||||
assert c == 96.86, f"got {c}"
|
||||
|
||||
def test_seedance_2_0_fast_15s_720p_9x16(self):
|
||||
# 15s/720p/9:16 tokens=324000, price=28
|
||||
# video_cost = 0.324*28 = 9.072
|
||||
# total = (9.072+0.15)*1.3 = 11.99
|
||||
from packages.domain.points_rules import calculate_viral_video_credits
|
||||
|
||||
c = calculate_viral_video_credits(15, 720, 1280, model="seedance-2.0-fast")
|
||||
assert c == 11.99, f"got {c}"
|
||||
|
||||
def test_seedance_2_0_mini_15s_720p_9x16(self):
|
||||
# price=9.2, tokens=324000
|
||||
# video_cost = 0.324*9.2 = 2.9808
|
||||
# total = (2.9808+0.15)*1.3 = 4.07
|
||||
from packages.domain.points_rules import calculate_viral_video_credits
|
||||
|
||||
c = calculate_viral_video_credits(15, 720, 1280, model="seedance-2.0-mini")
|
||||
assert c == 4.07, f"got {c}"
|
||||
|
||||
def test_wan_3_0_per_second_billing(self):
|
||||
# per_second: 10s/720p price=0.6元/秒
|
||||
# video_cost = 10*0.6 = 6.0
|
||||
# total = (6.0+0.15)*1.3 = 7.995 ≈ 8.00
|
||||
from packages.domain.points_rules import calculate_viral_video_credits
|
||||
|
||||
c = calculate_viral_video_credits(10, 720, 1280, model="wan-3.0")
|
||||
assert c == 8.0, f"got {c}"
|
||||
|
||||
def test_seedance_2_0_4k_16x9(self):
|
||||
# 5s/4k/16:9 → 3840×2160, price=80
|
||||
# tokens = 5*3840*2160*24/1024 = 972000
|
||||
# video_cost = 0.972*80 = 77.76
|
||||
# total = (77.76+0.15)*1.3 = 101.28
|
||||
from packages.domain.points_rules import calculate_viral_video_credits
|
||||
|
||||
c = calculate_viral_video_credits(5, 3840, 2160, model="seedance-2.0")
|
||||
assert c == 101.28, f"got {c}"
|
||||
|
||||
def test_model_config_has_all_6_models(self):
|
||||
from packages.domain.points_rules import VIRAL_VIDEO_MODEL_CONFIG
|
||||
|
||||
expected = {"seedance-2.5", "seedance-2.0", "seedance-2.0-fast", "seedance-2.0-mini", "wan-3.0"}
|
||||
assert expected.issubset(set(VIRAL_VIDEO_MODEL_CONFIG.keys()))
|
||||
|
||||
def test_list_models_hides_wan_when_dashscope_unavailable(self):
|
||||
from packages.domain.points_rules import list_viral_video_models
|
||||
|
||||
all_models = list_viral_video_models(include_placeholder=False, dashscope_available=False)
|
||||
keys = {m["key"] for m in all_models}
|
||||
assert "wan-3.0" not in keys
|
||||
assert "seedance-2.5" in keys
|
||||
# is_default
|
||||
defaults = [m for m in all_models if m["is_default"]]
|
||||
assert len(defaults) == 1
|
||||
assert defaults[0]["key"] == "seedance-2.5"
|
||||
|
||||
def test_list_models_includes_wan_when_dashscope_available(self):
|
||||
from packages.domain.points_rules import list_viral_video_models
|
||||
|
||||
models = list_viral_video_models(include_placeholder=False, dashscope_available=True)
|
||||
keys = {m["key"] for m in models}
|
||||
assert "wan-3.0" in keys
|
||||
|
||||
def test_infer_4k(self):
|
||||
from packages.domain.points_rules import _infer_resolution_key
|
||||
|
||||
assert _infer_resolution_key(3840, 2160) == "4k"
|
||||
assert _infer_resolution_key(2160, 3840) == "4k"
|
||||
assert _infer_resolution_key(1920, 1080) == "1080p"
|
||||
def test_unknown_scene_raises(self):
|
||||
with pytest.raises(ValueError, match="Unknown points scene"):
|
||||
calculate_points_cost("nonexistent_scene", is_member=False)
|
||||
|
||||
@@ -72,7 +72,7 @@ class TestCheckBalance:
|
||||
|
||||
class TestDeductPoints:
|
||||
def test_deduct_fails_insufficient_balance(self, service, db_session, user_id):
|
||||
result = service.deduct_points(user_id, 100, "voice_clone_synth", db_session)
|
||||
result = service.deduct_points(user_id, 100, "ai_voice", db_session)
|
||||
assert result["success"] is False
|
||||
assert result["transaction_id"] is None
|
||||
|
||||
@@ -80,13 +80,13 @@ class TestDeductPoints:
|
||||
# 先充值
|
||||
service.add_points(user_id, 50, "recharge", db_session)
|
||||
# 再扣减
|
||||
result = service.deduct_points(user_id, 20, "voice_clone_synth", db_session)
|
||||
result = service.deduct_points(user_id, 20, "ai_voice", db_session)
|
||||
assert result["success"] is True
|
||||
assert result["balance"] == 30
|
||||
|
||||
def test_deduct_creates_transaction(self, service, db_session, user_id):
|
||||
service.add_points(user_id, 100, "recharge", db_session)
|
||||
result = service.deduct_points(user_id, 30, "voice_clone_synth", db_session)
|
||||
result = service.deduct_points(user_id, 30, "ai_voice", db_session)
|
||||
assert result["success"] is True
|
||||
|
||||
txns = service.get_transactions(user_id, db_session)
|
||||
@@ -111,14 +111,14 @@ class TestAddPoints:
|
||||
class TestRefundPoints:
|
||||
def test_refund_adds_back(self, service, db_session, user_id):
|
||||
service.add_points(user_id, 100, "recharge", db_session)
|
||||
service.deduct_points(user_id, 20, "voice_clone_synth", db_session)
|
||||
result = service.refund_points(user_id, 20, "voice_clone_synth", db_session)
|
||||
service.deduct_points(user_id, 20, "ai_voice", db_session)
|
||||
result = service.refund_points(user_id, 20, "ai_voice", db_session)
|
||||
assert result["success"] is True
|
||||
assert result["balance"] == 100
|
||||
|
||||
def test_refund_creates_refund_transaction(self, service, db_session, user_id):
|
||||
service.add_points(user_id, 100, "recharge", db_session)
|
||||
service.refund_points(user_id, 10, "voice_clone_synth", db_session)
|
||||
service.refund_points(user_id, 10, "ai_rewrite", db_session)
|
||||
|
||||
txns = service.get_transactions(user_id, db_session)
|
||||
refund_txns = [t for t in txns["items"] if t["type"] == "add" and "refund" in t["source"]]
|
||||
@@ -145,15 +145,21 @@ class TestGetTransactions:
|
||||
|
||||
|
||||
class TestGetDailyUsage:
|
||||
"""智能混剪已免费,get_daily_usage 返回 unlimited(-1)占位。"""
|
||||
|
||||
def test_returns_unlimited(self, service, db_session, user_id):
|
||||
result = service.get_daily_usage(user_id, db_session)
|
||||
def test_zero_usage(self, service, db_session, user_id):
|
||||
with patch("packages.domain.points_service._get_redis_client", return_value=None):
|
||||
result = service.get_daily_usage(user_id, db_session)
|
||||
assert result["free_clips_used"] == 0
|
||||
assert result["free_clips_limit"] == -1 # -1 表示 unlimited
|
||||
assert result["free_clips_remaining"] == -1
|
||||
assert result["free_clips_limit"] == 2
|
||||
assert result["free_clips_remaining"] == 2
|
||||
assert "reset_at" in result
|
||||
|
||||
def test_after_recording(self, service, db_session, user_id):
|
||||
with patch("packages.domain.points_service._get_redis_client", return_value=None):
|
||||
service.record_daily_free_clip(user_id, db_session)
|
||||
result = service.get_daily_usage(user_id, db_session)
|
||||
assert result["free_clips_used"] == 1
|
||||
assert result["free_clips_remaining"] == 1
|
||||
|
||||
|
||||
class TestCreateOrder:
|
||||
def test_points_order(self, service, db_session, user_id):
|
||||
@@ -179,172 +185,3 @@ class TestCreateOrder:
|
||||
def test_unknown_order_type_raises(self, service, db_session, user_id):
|
||||
with pytest.raises(ValueError, match="Unknown order type"):
|
||||
service.create_order(user_id, "insurance", "basic", db_session)
|
||||
|
||||
|
||||
# ============ 爆款视频(viral_video)动态定价方法 ============
|
||||
|
||||
|
||||
class TestDeductViralVideo:
|
||||
"""deduct_viral_video(): 预扣积分,委托给 deduct_points。"""
|
||||
|
||||
def test_delegates_to_deduct_points_with_correct_args(self, service, db_session, user_id):
|
||||
"""deduct_viral_video 应以 source='viral_video', ref_id=job_id 调用 deduct_points。"""
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
expected = {"success": True, "balance": 50.0, "transaction_id": "t1"}
|
||||
with patch.object(service, "deduct_points", return_value=expected) as mock_dp:
|
||||
result = service.deduct_viral_video(user_id, 10.5, "job-abc", db_session)
|
||||
|
||||
assert result == expected
|
||||
mock_dp.assert_called_once()
|
||||
kwargs = mock_dp.call_args.kwargs
|
||||
assert kwargs["user_id"] == user_id
|
||||
assert kwargs["amount"] == 10.5
|
||||
assert kwargs["source"] == "viral_video"
|
||||
assert kwargs["db"] is db_session
|
||||
assert kwargs["description"] == "爆款视频生成"
|
||||
assert kwargs["ref_id"] == "job-abc"
|
||||
|
||||
def test_none_credits_coerced_to_zero(self, service, db_session, user_id):
|
||||
"""credits=None 时应被 float(credits or 0) 转为 0,不抛异常。"""
|
||||
|
||||
with patch.object(
|
||||
service, "deduct_points", return_value={"success": True, "balance": 0, "transaction_id": "t"}
|
||||
) as mock_dp:
|
||||
service.deduct_viral_video(user_id, None, "job-nil", db_session)
|
||||
assert mock_dp.call_args.kwargs["amount"] == 0.0
|
||||
|
||||
|
||||
class TestSettleViralVideo:
|
||||
"""settle_viral_video(): 多退少补结算。"""
|
||||
|
||||
def test_no_action_when_diff_below_epsilon(self, service, db_session, user_id):
|
||||
"""|diff|<0.01 时返回 action=none,不调 refund/deduct。"""
|
||||
|
||||
with (
|
||||
patch.object(service, "refund_points") as mock_refund,
|
||||
patch.object(service, "deduct_points") as mock_deduct,
|
||||
):
|
||||
result = service.settle_viral_video(user_id, estimated=10.00, actual=10.001, txn_id="t1", db=db_session)
|
||||
|
||||
assert result["success"] is True
|
||||
assert result["action"] == "none"
|
||||
assert result["diff"] == 0.0
|
||||
mock_refund.assert_not_called()
|
||||
mock_deduct.assert_not_called()
|
||||
|
||||
def test_refund_when_actual_less_than_estimated(self, service, db_session, user_id):
|
||||
"""actual<estimated 时走 refund_points,返回 action=refund。"""
|
||||
|
||||
refund_res = {"success": True, "balance": 60.0, "transaction_id": "tr-1"}
|
||||
with patch.object(service, "refund_points", return_value=refund_res) as mock_refund:
|
||||
result = service.settle_viral_video(user_id, estimated=20.0, actual=15.0, txn_id="t2", db=db_session)
|
||||
|
||||
assert result["success"] is True
|
||||
assert result["action"] == "refund"
|
||||
assert result["amount"] == 5.0
|
||||
assert result["diff"] == -5.0
|
||||
mock_refund.assert_called_once()
|
||||
rk = mock_refund.call_args.kwargs
|
||||
assert rk["user_id"] == user_id
|
||||
assert rk["amount"] == 5.0
|
||||
assert rk["source"] == "viral_video"
|
||||
assert rk["ref_id"] == "t2"
|
||||
assert rk["description"] == "爆款视频结算退费"
|
||||
|
||||
def test_refund_exception_returns_failure(self, service, db_session, user_id):
|
||||
"""refund_points 抛异常时,应捕获并返回 success=False。"""
|
||||
|
||||
with patch.object(service, "refund_points", side_effect=RuntimeError("db down")):
|
||||
result = service.settle_viral_video(user_id, estimated=20.0, actual=10.0, txn_id="t3", db=db_session)
|
||||
|
||||
assert result["success"] is False
|
||||
assert result["action"] == "refund"
|
||||
|
||||
def test_deduct_when_actual_greater_than_estimated_success(self, service, db_session, user_id):
|
||||
"""actual>estimated 且补扣成功 → action=deduct, success=True。"""
|
||||
|
||||
deduct_res = {"success": True, "balance": 40.0, "transaction_id": "td-1"}
|
||||
with patch.object(service, "deduct_points", return_value=deduct_res) as mock_deduct:
|
||||
result = service.settle_viral_video(user_id, estimated=15.0, actual=20.0, txn_id="t4", db=db_session)
|
||||
|
||||
assert result["success"] is True
|
||||
assert result["action"] == "deduct"
|
||||
assert result["amount"] == 5.0
|
||||
assert result["diff"] == 5.0
|
||||
mock_deduct.assert_called_once()
|
||||
dk = mock_deduct.call_args.kwargs
|
||||
assert dk["amount"] == 5.0
|
||||
assert dk["source"] == "viral_video"
|
||||
assert dk["ref_id"] == "t4"
|
||||
|
||||
def test_deduct_insufficient_balance_returns_success_false_not_raise(self, service, db_session, user_id):
|
||||
"""actual>estimated 补扣时余额不足(success=False)应记录 warning 但不抛异常。"""
|
||||
import logging
|
||||
|
||||
deduct_res = {"success": False, "balance": 2.0, "transaction_id": None}
|
||||
with (
|
||||
patch.object(service, "deduct_points", return_value=deduct_res),
|
||||
patch("packages.domain.points_service.logger") as mock_logger,
|
||||
):
|
||||
result = service.settle_viral_video(user_id, estimated=15.0, actual=20.0, txn_id="t5", db=db_session)
|
||||
|
||||
# 即使补扣失败,函数也返回 action=deduct 但 success=False(不阻塞任务完成)
|
||||
assert result["success"] is False
|
||||
assert result["action"] == "deduct"
|
||||
assert result["amount"] == 5.0
|
||||
# 应打印 warning
|
||||
mock_logger.warning.assert_called_once()
|
||||
|
||||
def test_deduct_exception_returns_failure(self, service, db_session, user_id):
|
||||
"""deduct_points 抛异常时应捕获并返回 success=False。"""
|
||||
|
||||
with patch.object(service, "deduct_points", side_effect=RuntimeError("db boom")):
|
||||
result = service.settle_viral_video(user_id, estimated=10.0, actual=20.0, txn_id="t6", db=db_session)
|
||||
|
||||
assert result["success"] is False
|
||||
assert result["action"] == "deduct"
|
||||
assert result["diff"] == 10.0
|
||||
|
||||
|
||||
class TestRefundViralVideo:
|
||||
"""refund_viral_video(): 爆款视频失败全额退款。"""
|
||||
|
||||
def test_zero_amount_returns_none_action(self, service, db_session, user_id):
|
||||
"""amount<=0 直接返回 none action,不调 refund_points。"""
|
||||
|
||||
with patch.object(service, "refund_points") as mock_refund:
|
||||
r1 = service.refund_viral_video(user_id, 0, "t0", db_session)
|
||||
r2 = service.refund_viral_video(user_id, None, "t0", db_session)
|
||||
r3 = service.refund_viral_video(user_id, -1.5, "t0", db_session)
|
||||
|
||||
assert r1 == {"success": True, "action": "none", "amount": 0.0}
|
||||
assert r2 == {"success": True, "action": "none", "amount": 0.0}
|
||||
assert r3["action"] == "none"
|
||||
mock_refund.assert_not_called()
|
||||
|
||||
def test_success_path_delegates_to_refund_points(self, service, db_session, user_id):
|
||||
"""成功路径:透传 user_id/amount/ref_id=txn_id/source=viral_video。"""
|
||||
|
||||
expected = {"success": True, "balance": 80.0, "transaction_id": "rf-1"}
|
||||
with patch.object(service, "refund_points", return_value=expected) as mock_refund:
|
||||
result = service.refund_viral_video(user_id, 30.0, "txn-xyz", db_session)
|
||||
|
||||
assert result == expected
|
||||
mock_refund.assert_called_once()
|
||||
rk = mock_refund.call_args.kwargs
|
||||
assert rk["user_id"] == user_id
|
||||
assert rk["amount"] == 30.0
|
||||
assert rk["source"] == "viral_video"
|
||||
assert rk["ref_id"] == "txn-xyz"
|
||||
assert rk["description"] == "爆款视频失败退款"
|
||||
|
||||
def test_exception_returns_failure(self, service, db_session, user_id):
|
||||
"""refund_points 抛异常时返回 success=False/action=refund。"""
|
||||
|
||||
with patch.object(service, "refund_points", side_effect=RuntimeError("conn lost")):
|
||||
result = service.refund_viral_video(user_id, 25.0, "txn-err", db_session)
|
||||
|
||||
assert result["success"] is False
|
||||
assert result["action"] == "refund"
|
||||
assert result["amount"] == 25.0
|
||||
|
||||
@@ -1,31 +1,76 @@
|
||||
"""scripts_ai (抖音解析/改写/标题) — v1.6.2 起全部免费,不扣积分"""
|
||||
"""scripts_ai 积分扣点单元测试 (#1895 P2 step 2.3)"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
class TestScriptsAiFree:
|
||||
"""三个端点都已移除 @points_gate,不再扣点。"""
|
||||
import pytest
|
||||
from fastapi import HTTPException
|
||||
|
||||
def test_all_scenes_return_zero_cost(self):
|
||||
from packages.domain.points_rules import calculate_points_cost
|
||||
import packages.middleware.points_gate as _pg_module
|
||||
|
||||
for scene in ("douyin_extract", "ai_rewrite", "ai_title"):
|
||||
assert calculate_points_cost(scene, is_member=False) == 0
|
||||
assert calculate_points_cost(scene, is_member=True) == 0
|
||||
|
||||
def test_no_points_gate_decorators(self):
|
||||
def _make_cu(user_id="u1", is_member=False, member_type=None):
|
||||
cu = MagicMock()
|
||||
cu.user.id = user_id
|
||||
cu.user.is_member = is_member
|
||||
cu.user.member_type = member_type
|
||||
return cu
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _enable_gate(monkeypatch):
|
||||
monkeypatch.setattr(_pg_module, "_points_gate_enabled", lambda: True)
|
||||
yield
|
||||
|
||||
|
||||
class TestScriptsAiPointsGate:
|
||||
"""测试 scripts_ai 三个端点都挂了 @points_gate 并正确扣费。"""
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"scene,endpoint_fn_name",
|
||||
[
|
||||
("douyin_extract", "extract_from_douyin"),
|
||||
("ai_rewrite", "ai_rewrite"),
|
||||
("ai_title", "ai_generate_titles"),
|
||||
],
|
||||
)
|
||||
def test_insufficient_points_raises_402(self, scene, endpoint_fn_name):
|
||||
"""积分不足时抛 402。"""
|
||||
from app.api.routes import scripts_ai
|
||||
from app.schemas.scripts_ai import (
|
||||
AiGenerateTitlesRequest,
|
||||
AiRewriteRequest,
|
||||
ExtractFromDouyinRequest,
|
||||
)
|
||||
|
||||
for fn_name in ("extract_from_douyin", "ai_rewrite", "ai_generate_titles"):
|
||||
fn = getattr(scripts_ai, fn_name)
|
||||
assert not hasattr(fn, "__wrapped__"), f"{fn_name} still has @points_gate"
|
||||
fn = getattr(scripts_ai, endpoint_fn_name)
|
||||
db = MagicMock()
|
||||
cu = _make_cu()
|
||||
if scene == "douyin_extract":
|
||||
req = ExtractFromDouyinRequest(url="https://v.douyin.com/abc/")
|
||||
elif scene == "ai_rewrite":
|
||||
req = AiRewriteRequest(content="测试文案")
|
||||
else:
|
||||
req = AiGenerateTitlesRequest(content="测试文案", count=3)
|
||||
|
||||
def test_module_no_points_imports(self):
|
||||
import inspect
|
||||
with patch("packages.domain.points_service.PointsService") as MockSvc:
|
||||
svc = MagicMock()
|
||||
svc.deduct_points.return_value = {"success": False, "balance": 0}
|
||||
MockSvc.return_value = svc
|
||||
with pytest.raises(HTTPException) as ei:
|
||||
fn(request=req, current_user=cu, db=db)
|
||||
assert ei.value.status_code == 402
|
||||
|
||||
def test_disabled_passthrough_no_user_error(self, monkeypatch):
|
||||
"""关闭时不需要 user/db 也能被装饰器透传(验证 gate 关闭零副作用)。"""
|
||||
from app.api.routes import scripts_ai
|
||||
from app.schemas.scripts_ai import AiRewriteRequest
|
||||
|
||||
src = inspect.getsource(scripts_ai)
|
||||
assert "PointsService" not in src
|
||||
assert "points_gate" not in src
|
||||
assert "calculate_points_cost" not in src
|
||||
monkeypatch.setattr(_pg_module, "_points_gate_enabled", lambda: False)
|
||||
fn = scripts_ai.ai_rewrite
|
||||
# 不带 db/current_user 也应透传(后续业务逻辑可能报错但不是 401/500 gate 错误)
|
||||
with pytest.raises(Exception) as ei:
|
||||
fn(request=AiRewriteRequest(content="x"), current_user=None, db=None)
|
||||
# 不应是 gate 抛的 401/500
|
||||
assert isinstance(ei.value, AttributeError) or ei.value.status_code not in (401, 500)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"""TTS (免费) + voice_clone 预览 (扣点) 单测 (#1895 P2 step 2.1)
|
||||
"""TTS + voice_clone 积分扣点单元测试 (#1895 P2 step 2.1)
|
||||
|
||||
v1.6.2: TTS 合成/预览(ai_voice)已免费,不再扣点;voice_clone 预览(voice_clone_synth)仍保持 1积分/分钟扣点。
|
||||
覆盖 synthesize / voice_clone preview 在积分开关下的扣点、余额不足、失败退费、会员折扣等分支。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -44,10 +44,21 @@ def _make_request(text="你好世界", voice_id="v1", **kw):
|
||||
return r
|
||||
|
||||
|
||||
class TestTtsSynthesizeFree:
|
||||
"""TTS synthesize/preview 已移除手动扣点,不再实例化 PointsService。"""
|
||||
def _est_minutes(chars: int) -> float:
|
||||
return max(1.0, math.ceil(chars / 240))
|
||||
|
||||
def _setup(self, start_synth_raises=None):
|
||||
|
||||
class TestEstimateMinutes:
|
||||
@pytest.mark.parametrize(
|
||||
"chars,expected",
|
||||
[(1, 1.0), (240, 1.0), (241, 2.0), (480, 2.0), (481, 3.0), (1000, 5.0)],
|
||||
)
|
||||
def test_estimate(self, chars, expected):
|
||||
assert _est_minutes(chars) == expected
|
||||
|
||||
|
||||
class TestTtsSynthesizePointsDeduction:
|
||||
def _setup(self, text="你好", deduct_success=True, balance=0, start_synth_raises=None, send_task_raises=None):
|
||||
db = MagicMock()
|
||||
cu = _make_cu()
|
||||
repo = MagicMock()
|
||||
@@ -66,27 +77,43 @@ class TestTtsSynthesizeFree:
|
||||
wf.start_synthesis.side_effect = start_synth_raises
|
||||
vc_repo = MagicMock()
|
||||
vc_repo.get.return_value = None
|
||||
return db, cu, repo, uc, wf, vc_repo, job
|
||||
svc = MagicMock()
|
||||
svc.deduct_points.return_value = {"success": deduct_success, "balance": balance}
|
||||
fake_settings = MagicMock(points_enabled=True)
|
||||
return db, cu, repo, uc, wf, vc_repo, svc, fake_settings, job
|
||||
|
||||
def test_module_has_no_points_imports(self):
|
||||
import inspect
|
||||
|
||||
from app.api.routes import tts as mod
|
||||
|
||||
src = inspect.getsource(mod)
|
||||
assert "PointsService" not in src
|
||||
assert "calculate_points_cost" not in src
|
||||
assert "_points_deducted" not in src
|
||||
assert "import math" not in src
|
||||
|
||||
def test_success_returns_job_without_points(self):
|
||||
db, cu, repo, uc, wf, vc_repo, job = self._setup()
|
||||
def test_insufficient_raises_402(self):
|
||||
db, cu, repo, uc, wf, vc_repo, svc, fs, _ = self._setup(text="你好" * 200, deduct_success=False, balance=0)
|
||||
from app.api.routes.tts import synthesize
|
||||
|
||||
with (
|
||||
patch("app.api.routes.tts.CreateTTSJobUseCase", return_value=uc),
|
||||
patch("app.api.routes.tts.TTSWorkflowService", return_value=wf),
|
||||
patch("app.api.routes.tts.celery_app.send_task"),
|
||||
patch("app.api.routes.tts.PointsService", return_value=svc),
|
||||
patch("app.api.routes.tts.settings", fs),
|
||||
):
|
||||
with pytest.raises(HTTPException) as ei:
|
||||
synthesize(
|
||||
request=_make_request(text="你好" * 200),
|
||||
authenticated_user=cu,
|
||||
db=db,
|
||||
repository=repo,
|
||||
cosyvoice_service=MagicMock(),
|
||||
voice_clone_repo=vc_repo,
|
||||
)
|
||||
assert ei.value.status_code == 402
|
||||
assert ei.value.detail["code"] == "INSUFFICIENT_POINTS"
|
||||
|
||||
def test_success_deducts_points(self):
|
||||
db, cu, repo, uc, wf, vc_repo, svc, fs, job = self._setup()
|
||||
from app.api.routes.tts import synthesize
|
||||
|
||||
with (
|
||||
patch("app.api.routes.tts.CreateTTSJobUseCase", return_value=uc),
|
||||
patch("app.api.routes.tts.TTSWorkflowService", return_value=wf),
|
||||
patch("app.api.routes.tts.PointsService", return_value=svc),
|
||||
patch("app.api.routes.tts.celery_app.send_task") as _st,
|
||||
patch("app.api.routes.tts.settings", fs),
|
||||
):
|
||||
resp = synthesize(
|
||||
request=_make_request(text="测试"),
|
||||
@@ -96,17 +123,60 @@ class TestTtsSynthesizeFree:
|
||||
cosyvoice_service=MagicMock(),
|
||||
voice_clone_repo=vc_repo,
|
||||
)
|
||||
svc.deduct_points.assert_called_once()
|
||||
assert resp.job_id == job.id
|
||||
|
||||
def test_ai_voice_cost_zero(self):
|
||||
def test_synthesis_failure_refunds(self):
|
||||
db, cu, repo, uc, wf, vc_repo, svc, fs, _ = self._setup(start_synth_raises=RuntimeError("boom"))
|
||||
from app.api.routes.tts import synthesize
|
||||
|
||||
with (
|
||||
patch("app.api.routes.tts.CreateTTSJobUseCase", return_value=uc),
|
||||
patch("app.api.routes.tts.TTSWorkflowService", return_value=wf),
|
||||
patch("app.api.routes.tts.PointsService", return_value=svc),
|
||||
patch("app.api.routes.tts.celery_app.send_task"),
|
||||
patch("app.api.routes.tts.settings", fs),
|
||||
):
|
||||
synthesize(
|
||||
request=_make_request(text="测试"),
|
||||
authenticated_user=cu,
|
||||
db=db,
|
||||
repository=repo,
|
||||
cosyvoice_service=MagicMock(),
|
||||
voice_clone_repo=vc_repo,
|
||||
)
|
||||
assert svc.refund_points.called
|
||||
|
||||
def test_celery_send_failure_refunds(self):
|
||||
db, cu, repo, uc, wf, vc_repo, svc, fs, _ = self._setup(send_task_raises=RuntimeError("celery down"))
|
||||
from app.api.routes.tts import synthesize
|
||||
|
||||
with (
|
||||
patch("app.api.routes.tts.CreateTTSJobUseCase", return_value=uc),
|
||||
patch("app.api.routes.tts.TTSWorkflowService", return_value=wf),
|
||||
patch("app.api.routes.tts.PointsService", return_value=svc),
|
||||
patch("app.api.routes.tts.celery_app.send_task", side_effect=RuntimeError("celery down")),
|
||||
patch("app.api.routes.tts.settings", fs),
|
||||
):
|
||||
synthesize(
|
||||
request=_make_request(text="测试"),
|
||||
authenticated_user=cu,
|
||||
db=db,
|
||||
repository=repo,
|
||||
cosyvoice_service=MagicMock(),
|
||||
voice_clone_repo=vc_repo,
|
||||
)
|
||||
assert svc.refund_points.called
|
||||
|
||||
def test_member_cheaper(self):
|
||||
from packages.domain.points_rules import calculate_points_cost
|
||||
|
||||
assert calculate_points_cost("ai_voice", is_member=False, duration_minutes=10) == 0
|
||||
cf = calculate_points_cost("ai_voice", is_member=False, duration_minutes=2)
|
||||
cm = calculate_points_cost("ai_voice", is_member=True, member_type="monthly", duration_minutes=2)
|
||||
assert cm < cf
|
||||
|
||||
|
||||
class TestVoiceClonePreviewPoints:
|
||||
"""voice_clone 预览(voice_clone_synth)保持 1 积分/分钟扣点。"""
|
||||
|
||||
def _setup(self, text="你好", deduct_success=True, balance=0, synth_raises=None):
|
||||
db = MagicMock()
|
||||
cu = _make_cu()
|
||||
@@ -195,10 +265,3 @@ class TestVoiceClonePreviewPoints:
|
||||
)
|
||||
svc.deduct_points.assert_called_once()
|
||||
assert resp.audio_url.startswith("http")
|
||||
|
||||
def test_member_cheaper(self):
|
||||
from packages.domain.points_rules import calculate_points_cost
|
||||
|
||||
cf = calculate_points_cost("voice_clone_synth", is_member=False, duration_minutes=2)
|
||||
cm = calculate_points_cost("voice_clone_synth", is_member=True, member_type="monthly", duration_minutes=2)
|
||||
assert cm < cf
|
||||
|
||||
+57
-106
@@ -17,6 +17,7 @@ import pytest
|
||||
from pydantic import ValidationError
|
||||
|
||||
from packages.domain.viral_video import (
|
||||
CREDITS_VIRAL_VIDEO_COST,
|
||||
STAGE_LABELS,
|
||||
FusionLevel,
|
||||
StyleStrength,
|
||||
@@ -119,7 +120,7 @@ class TestViralVideoJobDefaults:
|
||||
job = ViralVideoJob(user_id="u1")
|
||||
assert job.images == []
|
||||
assert job.industry == ""
|
||||
assert job.duration == 15
|
||||
assert job.duration == 30
|
||||
assert job.fusion_level == FusionLevel.AI_POLISH
|
||||
assert job.style_strength == StyleStrength.MEDIUM
|
||||
assert job.status == ViralVideoStatus.PENDING
|
||||
@@ -128,6 +129,9 @@ class TestViralVideoJobDefaults:
|
||||
assert job.result_video_url == ""
|
||||
assert job.error_msg == ""
|
||||
|
||||
def test_credits_cost_constant(self):
|
||||
assert CREDITS_VIRAL_VIDEO_COST == 50
|
||||
|
||||
|
||||
class TestViralVideoStage:
|
||||
"""阶段枚举测试。"""
|
||||
@@ -141,10 +145,13 @@ class TestViralVideoStage:
|
||||
"image_analysis",
|
||||
"video_analysis",
|
||||
"intent_parsing",
|
||||
"script_generation",
|
||||
"copy_fusion",
|
||||
"storyboard",
|
||||
"review",
|
||||
"tts",
|
||||
"bgm_select",
|
||||
"rendering",
|
||||
"musetalk",
|
||||
"uploading",
|
||||
]
|
||||
actual_order = [s.value for s in ViralVideoStage]
|
||||
@@ -164,7 +171,7 @@ class TestViralVideoSchemas:
|
||||
assert req.images == ["https://example.com/img.jpg"]
|
||||
assert req.fusion_level == "ai_polish"
|
||||
assert req.style_strength == "medium"
|
||||
assert req.duration == 15
|
||||
assert req.duration == 30
|
||||
|
||||
def test_create_request_empty_images_raises(self):
|
||||
from app.schemas.viral_video import CreateViralVideoRequest
|
||||
@@ -358,10 +365,9 @@ class TestViralVideoPipeline:
|
||||
industry="美妆",
|
||||
target_customer="年轻女性",
|
||||
marketing_purpose="品牌推广",
|
||||
duration=15,
|
||||
duration=30,
|
||||
user_copy_text="这款产品超好用",
|
||||
fusion_level="ai_polish",
|
||||
video_ratio="9:16",
|
||||
)
|
||||
|
||||
@patch("packages.shared.ai_service.call_vision")
|
||||
@@ -399,110 +405,63 @@ class TestViralVideoPipeline:
|
||||
assert "intent" in result
|
||||
|
||||
@patch("packages.shared.ai_service.call_llm")
|
||||
def test_script_generation_returns_copy_result(self, mock_llm, mock_job):
|
||||
"""v1.6: _step_script_generation 返回 dict 形式的 CopyResult,含 voiceover_script + shots。"""
|
||||
from apps.worker.worker_app.tasks.viral_video import _step_script_generation
|
||||
def test_copy_fusion_ai_polish(self, mock_llm, mock_job):
|
||||
from apps.worker.worker_app.tasks.viral_video import _step_copy_fusion
|
||||
|
||||
mock_llm.return_value = {
|
||||
"overview": {"theme": "口红推荐", "total_duration": 15, "aspect_ratio": "9:16"},
|
||||
"scene_and_lighting": "明亮化妆台,柔和自然光",
|
||||
"shots": [
|
||||
{
|
||||
"time_range": "0-5秒",
|
||||
"shot_type_angle_movement": "近景平视,缓慢推镜",
|
||||
"scene_and_dialogue": "女主微笑展示口红:大家好,今天分享一款口红",
|
||||
"action_details": "手持口红特写",
|
||||
"audio_bgm": "轻快流行BGM",
|
||||
"transition": "硬切",
|
||||
"reference_image_index": 0,
|
||||
},
|
||||
{
|
||||
"time_range": "5-15秒",
|
||||
"shot_type_angle_movement": "特写,固定镜头",
|
||||
"scene_and_dialogue": "涂抹口红:颜色特别好看很显白",
|
||||
"action_details": "嘴唇涂抹特写",
|
||||
"audio_bgm": "轻快BGM继续",
|
||||
"transition": "结束",
|
||||
"reference_image_index": 1,
|
||||
},
|
||||
],
|
||||
"hard_constraints": ["无字幕无水印"],
|
||||
"negative_prompts": ["字幕", "水印"],
|
||||
"voiceover_script": "大家好,今天分享一款口红,颜色特别好看很显白。",
|
||||
}
|
||||
result = _step_script_generation(
|
||||
mock_job, {"intent": "推广口红", "key_messages": [], "tone": "亲切"}, {"products": []}
|
||||
)
|
||||
assert isinstance(result, dict)
|
||||
assert "voiceover_script" in result
|
||||
assert "shots" in result
|
||||
assert isinstance(result["shots"], list)
|
||||
assert len(result["shots"]) == 2
|
||||
assert result["overview"]["total_duration"] == 15
|
||||
# final_copy 必须 = voiceover_script(向后兼容)
|
||||
assert result.get("final_copy") == result["voiceover_script"]
|
||||
mock_llm.return_value = "融合后的文案内容"
|
||||
result = _step_copy_fusion(mock_job, {"intent": "推广"}, {"products": []})
|
||||
assert isinstance(result, str)
|
||||
assert len(result) > 0
|
||||
|
||||
@patch("packages.shared.ai_service.call_llm")
|
||||
def test_script_generation_fallback(self, mock_llm, mock_job):
|
||||
"""LLM 返回异常时使用兜底脚本(不会抛错)。"""
|
||||
from apps.worker.worker_app.tasks.viral_video import _fallback_script
|
||||
def test_storyboard_generation(self, mock_llm, mock_job):
|
||||
from apps.worker.worker_app.tasks.viral_video import _step_storyboard
|
||||
|
||||
result = _fallback_script(mock_job)
|
||||
assert isinstance(result, dict)
|
||||
assert result["voiceover_script"]
|
||||
assert len(result["shots"]) >= 1
|
||||
mock_llm.return_value = [
|
||||
{"order": 0, "type": "product_shot", "duration": 10},
|
||||
{"order": 1, "type": "closing", "duration": 5},
|
||||
]
|
||||
result = _step_storyboard(mock_job, "测试文案", {})
|
||||
assert isinstance(result, list)
|
||||
assert len(result) == 2
|
||||
|
||||
@patch("packages.shared.ai_service.call_llm")
|
||||
def test_review_pass_v16(self, mock_llm, mock_job):
|
||||
"""v1.6 _step_review 接收 copy_result dict。"""
|
||||
def test_review_pass(self, mock_llm, mock_job):
|
||||
from apps.worker.worker_app.tasks.viral_video import _step_review
|
||||
|
||||
mock_llm.return_value = {"passed": True, "score": 90, "details": {}}
|
||||
cr = {"voiceover_script": "大家好", "shots": []}
|
||||
result = _step_review(mock_job, cr)
|
||||
result = _step_review(mock_job, "测试文案", [])
|
||||
assert result["passed"] is True
|
||||
|
||||
def test_assemble_seedance_prompt(self, mock_job):
|
||||
"""编导脚本必须能拼出完整的 Seedance prompt,含总览/场景/逐镜头/约束。"""
|
||||
from apps.worker.worker_app.tasks.viral_video import _assemble_seedance_prompt
|
||||
def test_bgm_select(self, mock_job):
|
||||
from apps.worker.worker_app.tasks.viral_video import _step_bgm_select
|
||||
|
||||
cr = {
|
||||
"overview": {"theme": "口红", "total_duration": 15, "aspect_ratio": "9:16"},
|
||||
"scene_and_lighting": "明亮化妆台",
|
||||
"shots": [
|
||||
{
|
||||
"time_range": "0-15秒",
|
||||
"shot_type_angle_movement": "中景平视",
|
||||
"scene_and_dialogue": "你好分享",
|
||||
"action_details": "展示",
|
||||
"audio_bgm": "BGM",
|
||||
"transition": "结束",
|
||||
"reference_image_index": 0,
|
||||
}
|
||||
],
|
||||
"hard_constraints": ["无字幕"],
|
||||
"negative_prompts": ["水印"],
|
||||
}
|
||||
prompt = _assemble_seedance_prompt(cr, mock_job)
|
||||
assert "【视频总览】" in prompt
|
||||
assert "【逐镜头时间轴】" in prompt
|
||||
assert "【硬性约束】" in prompt
|
||||
assert "【负面提示词】" in prompt
|
||||
assert "0-15秒" in prompt
|
||||
# P1: BGM 素材未就绪前 _step_bgm_select 统一返回 None(跳过 BGM 混音)
|
||||
mock_job.bgm_preference = "upbeat"
|
||||
bgm = _step_bgm_select(mock_job)
|
||||
assert bgm is None
|
||||
|
||||
def test_bgm_select_default(self, mock_job):
|
||||
from apps.worker.worker_app.tasks.viral_video import _step_bgm_select
|
||||
|
||||
mock_job.bgm_preference = ""
|
||||
bgm = _step_bgm_select(mock_job)
|
||||
assert bgm is None
|
||||
|
||||
|
||||
# ── 端到端流水线集成测试 ────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestPipelineIntegration:
|
||||
"""v1.6 流水线端到端集成测试(mock 外部依赖):TTS+单次 Seedance+上传。"""
|
||||
"""流水线端到端集成测试(mock 外部依赖)。"""
|
||||
|
||||
@patch("apps.worker.worker_app.tasks.viral_video._step_upload")
|
||||
@patch("apps.worker.worker_app.tasks.viral_video._step_render")
|
||||
@patch("apps.worker.worker_app.tasks.viral_video._upload_tts_to_oss")
|
||||
@patch("apps.worker.worker_app.tasks.viral_video._step_bgm_select")
|
||||
@patch("apps.worker.worker_app.tasks.viral_video._step_tts")
|
||||
@patch("apps.worker.worker_app.tasks.viral_video._step_review")
|
||||
@patch("apps.worker.worker_app.tasks.viral_video._step_script_generation")
|
||||
@patch("apps.worker.worker_app.tasks.viral_video._step_storyboard")
|
||||
@patch("apps.worker.worker_app.tasks.viral_video._step_copy_fusion")
|
||||
@patch("apps.worker.worker_app.tasks.viral_video._step_intent_parsing")
|
||||
@patch("apps.worker.worker_app.tasks.viral_video._step_video_analysis")
|
||||
@patch("apps.worker.worker_app.tasks.viral_video._step_image_analysis")
|
||||
@@ -515,47 +474,39 @@ class TestPipelineIntegration:
|
||||
mock_img_analysis,
|
||||
mock_video_analysis,
|
||||
mock_intent,
|
||||
mock_script,
|
||||
mock_copy_fusion,
|
||||
mock_storyboard,
|
||||
mock_review,
|
||||
mock_tts,
|
||||
mock_tts_upload,
|
||||
mock_bgm,
|
||||
mock_render,
|
||||
mock_upload,
|
||||
):
|
||||
"""v1.6: TTS整段合成 → 上传TTS到OSS → 单次 Seedance → 上传成片。"""
|
||||
"""测试 resume 流水线能从确认状态走到完成。"""
|
||||
from apps.worker.worker_app.tasks.viral_video import (
|
||||
resume_viral_video_pipeline,
|
||||
)
|
||||
|
||||
# 构造 mock job
|
||||
job = ViralVideoJob(
|
||||
user_id="user-001",
|
||||
images=["https://img.com/1.jpg"],
|
||||
industry="美妆",
|
||||
status=ViralVideoStatus.RUNNING,
|
||||
intent_result={"intent": "推广"},
|
||||
duration=15,
|
||||
video_ratio="9:16",
|
||||
)
|
||||
|
||||
mock_repo = MagicMock()
|
||||
mock_session = MagicMock()
|
||||
mock_get_repo.return_value = (mock_session, mock_repo, job)
|
||||
|
||||
# v1.6: 如果没有 copy_result 会现场补生成
|
||||
mock_intent.return_value = {"intent": "推广", "key_messages": [], "tone": "亲切"}
|
||||
mock_script.return_value = {
|
||||
"overview": {"theme": "口红", "total_duration": 15, "aspect_ratio": "9:16"},
|
||||
"scene_and_lighting": "明亮化妆台",
|
||||
"shots": [],
|
||||
"hard_constraints": [],
|
||||
"negative_prompts": [],
|
||||
"voiceover_script": "大家好,分享一款口红。",
|
||||
"final_copy": "大家好,分享一款口红。",
|
||||
}
|
||||
# 设置各步骤返回值
|
||||
mock_copy_fusion.return_value = "融合文案"
|
||||
mock_storyboard.return_value = [{"order": 0, "duration": 10}]
|
||||
mock_review.return_value = {"passed": True, "score": 90}
|
||||
mock_tts.return_value = None # TTS 失败也能走下去(Seedance generate_audio=True 会自己合成音效)
|
||||
mock_tts_upload.return_value = None
|
||||
mock_render.return_value = ("/tmp/video.mp4", {"completion_tokens": 1000000})
|
||||
mock_tts.return_value = None # P1: TTS 返回 Path|None,mock 用 None 跳过混音
|
||||
mock_bgm.return_value = None # P1: BGM 未就绪前返回 None
|
||||
mock_render.return_value = "/tmp/video.mp4"
|
||||
mock_upload.return_value = "https://oss.example.com/final.mp4"
|
||||
|
||||
result = resume_viral_video_pipeline.run("job-001")
|
||||
@@ -563,4 +514,4 @@ class TestPipelineIntegration:
|
||||
assert result["ok"] is True
|
||||
assert result["video_url"] == "https://oss.example.com/final.mp4"
|
||||
assert job.status == ViralVideoStatus.COMPLETED
|
||||
assert isinstance(job.credits_cost, float)
|
||||
assert job.credits_cost == CREDITS_VIRAL_VIDEO_COST
|
||||
|
||||
@@ -1,38 +1,99 @@
|
||||
"""tests for GET /api/v1/viral-video/models route function (#2159)."""
|
||||
"""爆款视频 DB 模型单元测试(#2039 PR1:DB + migration)。
|
||||
|
||||
验证:
|
||||
- 3 张新表可在内存 SQLite 上创建
|
||||
- 默认值与基本 CRUD 正常
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import MagicMock, patch
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
|
||||
from packages.adapters.sqlalchemy_impl.models import (
|
||||
Base,
|
||||
ViralVideoJobModel,
|
||||
ViralVideoPromptTemplateModel,
|
||||
ViralVideoStyleTemplateModel,
|
||||
)
|
||||
|
||||
|
||||
class TestModelsRoute:
|
||||
def _call(self):
|
||||
from apps.api.app.api.routes import viral_video as routes
|
||||
def _make_session():
|
||||
engine = create_engine("sqlite:///:memory:")
|
||||
Base.metadata.create_all(engine)
|
||||
return sessionmaker(bind=engine)()
|
||||
|
||||
return routes.list_available_models()
|
||||
|
||||
def test_without_dashscope_hides_wan(self):
|
||||
with patch("apps.api.app.api.routes.viral_video.get_dashscope_client", return_value=None):
|
||||
result = self._call()
|
||||
assert "models" in result
|
||||
keys = {m["key"] for m in result["models"]}
|
||||
assert "seedance-2.5" in keys
|
||||
assert "wan-3.0" not in keys
|
||||
for m in result["models"]:
|
||||
if m["key"].startswith("seedance"):
|
||||
assert m["supports_audio"] is True
|
||||
class TestViralVideoJobModel:
|
||||
def test_create_and_get(self):
|
||||
session = _make_session()
|
||||
job = ViralVideoJobModel(
|
||||
id="job-001",
|
||||
user_id="user-001",
|
||||
images=["https://img.com/1.jpg"],
|
||||
industry="美妆",
|
||||
duration=60,
|
||||
)
|
||||
session.add(job)
|
||||
session.commit()
|
||||
|
||||
def test_with_dashscope_includes_wan(self):
|
||||
with patch("apps.api.app.api.routes.viral_video.get_dashscope_client", return_value=MagicMock()):
|
||||
result = self._call()
|
||||
keys = {m["key"] for m in result["models"]}
|
||||
assert "wan-3.0" in keys
|
||||
wan = next(m for m in result["models"] if m["key"] == "wan-3.0")
|
||||
assert wan["billing_mode"] == "per_second"
|
||||
fetched = session.query(ViralVideoJobModel).filter_by(id="job-001").one()
|
||||
assert fetched.user_id == "user-001"
|
||||
assert fetched.images == ["https://img.com/1.jpg"]
|
||||
assert fetched.industry == "美妆"
|
||||
assert fetched.duration == 60
|
||||
|
||||
def test_exactly_one_default(self):
|
||||
with patch("apps.api.app.api.routes.viral_video.get_dashscope_client", return_value=None):
|
||||
result = self._call()
|
||||
defaults = [m for m in result["models"] if m["is_default"]]
|
||||
assert len(defaults) == 1
|
||||
assert defaults[0]["key"] == "seedance-2.5"
|
||||
def test_default_values(self):
|
||||
session = _make_session()
|
||||
job = ViralVideoJobModel(id="job-002", user_id="user-002")
|
||||
session.add(job)
|
||||
session.commit()
|
||||
|
||||
fetched = session.get(ViralVideoJobModel, "job-002")
|
||||
assert fetched.images == []
|
||||
assert fetched.fusion_level == "ai_polish"
|
||||
assert fetched.style_strength == "medium"
|
||||
assert fetched.status == "pending"
|
||||
assert fetched.credits_cost == 0
|
||||
assert fetched.retry_count == 0
|
||||
assert fetched.style_guide is None
|
||||
assert fetched.intent_result is None
|
||||
|
||||
|
||||
class TestViralVideoStyleTemplateModel:
|
||||
def test_create_and_get(self):
|
||||
session = _make_session()
|
||||
tpl = ViralVideoStyleTemplateModel(
|
||||
id="tpl-001",
|
||||
name="快节奏",
|
||||
style_config={"cut_speed": "fast"},
|
||||
sort_order=1,
|
||||
)
|
||||
session.add(tpl)
|
||||
session.commit()
|
||||
|
||||
fetched = session.get(ViralVideoStyleTemplateModel, "tpl-001")
|
||||
assert fetched.name == "快节奏"
|
||||
assert fetched.style_config == {"cut_speed": "fast"}
|
||||
assert fetched.sort_order == 1
|
||||
|
||||
|
||||
class TestViralVideoPromptTemplateModel:
|
||||
def test_create_and_get(self):
|
||||
session = _make_session()
|
||||
tpl = ViralVideoPromptTemplateModel(
|
||||
id="pt-001",
|
||||
prompt_type="image_analysis",
|
||||
name="图片分析模板",
|
||||
content="请分析图片:{image_url}",
|
||||
variables=["image_url"],
|
||||
)
|
||||
session.add(tpl)
|
||||
session.commit()
|
||||
|
||||
fetched = session.get(ViralVideoPromptTemplateModel, "pt-001")
|
||||
assert fetched.prompt_type == "image_analysis"
|
||||
assert fetched.content == "请分析图片:{image_url}"
|
||||
assert fetched.variables == ["image_url"]
|
||||
assert fetched.version == 1
|
||||
assert fetched.is_active is True
|
||||
|
||||
@@ -67,70 +67,49 @@ class TestImageAnalysisField:
|
||||
# ── P0-1: storyboard 规范化 ────────────────────────────────────────
|
||||
|
||||
|
||||
class TestScriptGenerationV16:
|
||||
"""v1.6 编导分镜脚本生成相关纯函数测试。"""
|
||||
class TestStoryboardNormalize:
|
||||
def test_normalize_fills_defaults(self):
|
||||
from apps.worker.worker_app.tasks.viral_video import _normalize_storyboard
|
||||
|
||||
def test_fallback_script_has_required_fields(self, mock_job):
|
||||
from apps.worker.worker_app.tasks.viral_video import _fallback_script
|
||||
raw = [{"order": 0, "description": "镜头一"}]
|
||||
out = _normalize_storyboard(raw, total_duration=10, n_segments=1, copy_text="文案")
|
||||
assert len(out) == 1
|
||||
assert out[0]["duration"] >= 3
|
||||
assert out[0]["ken_burns"] in {"zoom_in", "zoom_out", "pan_left", "pan_right", "static"}
|
||||
assert out[0]["type"] == "product_shot"
|
||||
assert out[0]["text"] == ""
|
||||
|
||||
out = _fallback_script(mock_job)
|
||||
assert isinstance(out, dict)
|
||||
assert "overview" in out
|
||||
assert "shots" in out
|
||||
assert "voiceover_script" in out
|
||||
assert "hard_constraints" in out
|
||||
assert "negative_prompts" in out
|
||||
assert out["overview"]["total_duration"] == mock_job.duration
|
||||
assert out["final_copy"] == out["voiceover_script"]
|
||||
assert len(out["shots"]) >= 1
|
||||
def test_normalize_scales_to_total_duration(self):
|
||||
from apps.worker.worker_app.tasks.viral_video import _normalize_storyboard
|
||||
|
||||
def test_safe_json_loads_parses_fenced_code(self):
|
||||
from apps.worker.worker_app.tasks.viral_video import _safe_json_loads
|
||||
raw = [
|
||||
{"order": 0, "duration": 10, "description": "a"},
|
||||
{"order": 1, "duration": 10, "description": "b"},
|
||||
]
|
||||
out = _normalize_storyboard(raw, total_duration=10, n_segments=2, copy_text="x")
|
||||
total = sum(s["duration"] for s in out)
|
||||
assert total == 10
|
||||
|
||||
fenced = '```json\n{"voiceover_script": "你好", "shots": []}\n```'
|
||||
out = _safe_json_loads(fenced)
|
||||
assert out is not None
|
||||
assert out["voiceover_script"] == "你好"
|
||||
def test_fallback_storyboard(self):
|
||||
from apps.worker.worker_app.tasks.viral_video import _fallback_storyboard
|
||||
|
||||
def test_safe_json_loads_handles_none(self):
|
||||
from apps.worker.worker_app.tasks.viral_video import _safe_json_loads
|
||||
out = _fallback_storyboard("文案", total_duration=15, n_segments=3)
|
||||
assert len(out) == 3
|
||||
assert sum(s["duration"] for s in out) == 15
|
||||
assert all(s["duration"] >= 3 for s in out)
|
||||
|
||||
assert _safe_json_loads(None) is None
|
||||
assert _safe_json_loads("not json") is None
|
||||
def test_storyboard_llm_list(self, mock_job):
|
||||
from apps.worker.worker_app.tasks.viral_video import _step_storyboard
|
||||
|
||||
def test_validate_normalize_fills_defaults(self, mock_job):
|
||||
from apps.worker.worker_app.tasks.viral_video import _validate_and_normalize_script
|
||||
|
||||
raw = {"voiceover_script": "你好", "shots": [{"scene_and_dialogue": "测试"}]}
|
||||
out = _validate_and_normalize_script(raw, mock_job)
|
||||
assert out["voiceover_script"] == "你好"
|
||||
assert len(out["shots"]) == 1
|
||||
assert out["shots"][0]["shot_type_angle_movement"]
|
||||
assert out["overview"]["total_duration"] == mock_job.duration
|
||||
|
||||
def test_assemble_seedance_prompt_contains_sections(self, mock_job):
|
||||
from apps.worker.worker_app.tasks.viral_video import _assemble_seedance_prompt
|
||||
|
||||
cr = {
|
||||
"overview": {"theme": "测试", "total_duration": 15, "aspect_ratio": "9:16"},
|
||||
"scene_and_lighting": "明亮",
|
||||
"shots": [
|
||||
{
|
||||
"time_range": "0-15秒",
|
||||
"shot_type_angle_movement": "中景",
|
||||
"scene_and_dialogue": "你好",
|
||||
"action_details": "展示",
|
||||
"audio_bgm": "BGM",
|
||||
"transition": "结束",
|
||||
"reference_image_index": 0,
|
||||
}
|
||||
],
|
||||
"hard_constraints": ["无字幕"],
|
||||
"negative_prompts": ["水印"],
|
||||
}
|
||||
p = _assemble_seedance_prompt(cr, mock_job)
|
||||
for key in ("【视频总览】", "【场景与光线】", "【逐镜头时间轴】", "【硬性约束】", "【负面提示词】"):
|
||||
assert key in p
|
||||
with patch("packages.shared.ai_service.call_llm") as mock_llm:
|
||||
mock_llm.return_value = [
|
||||
{"order": 0, "description": "产品特写", "duration": 5, "text": "t1"},
|
||||
{"order": 1, "description": "使用场景", "duration": 5, "text": "t2"},
|
||||
{"order": 2, "description": "CTA", "duration": 5, "text": "t3"},
|
||||
]
|
||||
result = _step_storyboard(mock_job, "文案", {"products": []})
|
||||
assert len(result) == 3
|
||||
assert all("description" in s for s in result)
|
||||
|
||||
|
||||
# ── P1: TTS 返回 Path|None ────────────────────────────────────────
|
||||
@@ -171,24 +150,12 @@ class TestTTSPath:
|
||||
# ── P1: BGM 跳过 / MuseTalk 无 persona 跳过 ───────────────────────
|
||||
|
||||
|
||||
class TestDurationClamp:
|
||||
"""v1.6 mark_copy_generated 派生字段 + duration clamp。"""
|
||||
class TestBGMSkip:
|
||||
def test_bgm_returns_none(self, mock_job):
|
||||
from apps.worker.worker_app.tasks.viral_video import _step_bgm_select
|
||||
|
||||
def test_mark_copy_generated_derives_fields(self):
|
||||
job = ViralVideoJob(user_id="u1", duration=15)
|
||||
cr = {
|
||||
"overview": {"theme": "x", "total_duration": 15, "aspect_ratio": "9:16"},
|
||||
"scene_and_lighting": "亮",
|
||||
"shots": [{"time_range": "0-15秒", "scene_and_dialogue": "对白"}],
|
||||
"voiceover_script": "你好",
|
||||
"hard_constraints": [],
|
||||
"negative_prompts": [],
|
||||
}
|
||||
job.mark_copy_generated(cr)
|
||||
assert job.copy_result is cr
|
||||
assert job.generated_copy_text == "你好"
|
||||
assert job.storyboard == cr["shots"]
|
||||
assert job.effective_copy_text == "你好"
|
||||
mock_job.bgm_preference = "upbeat"
|
||||
assert _step_bgm_select(mock_job) is None
|
||||
|
||||
|
||||
# ── P0-1: call_video_generation 参数构造 ──────────────────────────
|
||||
@@ -212,76 +179,32 @@ class TestCallVideoGeneration:
|
||||
with patch("packages.shared.ai_service.get_doubao_client") as mock_get:
|
||||
mock_client = MagicMock()
|
||||
mock_client.is_available = True
|
||||
mock_client.video_generation.return_value = {
|
||||
"video_path": str(out),
|
||||
"usage": {"completion_tokens": 1000000},
|
||||
}
|
||||
mock_client.video_generation.return_value = str(out)
|
||||
mock_get.return_value = mock_client
|
||||
result = call_video_generation(prompt="测试", image_url="https://img/x.jpg", duration=5, ratio="9:16")
|
||||
assert result is not None and result["video_path"] == str(out)
|
||||
assert result == str(out)
|
||||
mock_client.video_generation.assert_called_once()
|
||||
kwargs = mock_client.video_generation.call_args.kwargs
|
||||
assert kwargs["prompt"] == "测试"
|
||||
assert kwargs["image_url"] == "https://img/x.jpg"
|
||||
assert kwargs["duration"] == 5
|
||||
assert kwargs["generate_audio"] is True
|
||||
|
||||
|
||||
# ── P0-1: _step_render 占位片段生成 ──────────────────────────────
|
||||
|
||||
|
||||
class TestCallVideoGenerationV16:
|
||||
"""v1.6 call_video_generation 透传 reference_audios/reference_images 等参数到 client。"""
|
||||
class TestPlaceholderClip:
|
||||
def test_make_placeholder_clip(self, tmp_path):
|
||||
import shutil
|
||||
|
||||
def test_passes_reference_params_to_client(self, tmp_path):
|
||||
from packages.shared.ai_service import call_video_generation
|
||||
from apps.worker.worker_app.tasks.viral_video import _make_placeholder_clip, _probe_ok
|
||||
|
||||
out = tmp_path / "v.mp4"
|
||||
out.write_bytes(b"fake")
|
||||
with patch("packages.shared.ai_service.get_doubao_client") as mock_get:
|
||||
mock_client = MagicMock()
|
||||
mock_client.is_available = True
|
||||
mock_client.video_generation.return_value = {
|
||||
"video_path": str(out),
|
||||
"usage": {"completion_tokens": 1500000},
|
||||
}
|
||||
mock_get.return_value = mock_client
|
||||
result = call_video_generation(
|
||||
prompt="测试",
|
||||
image_url="https://img/x.jpg",
|
||||
duration=15,
|
||||
ratio="9:16",
|
||||
reference_images=["https://img/r1.jpg"],
|
||||
reference_audios=["https://oss/tts.mp3"],
|
||||
reference_videos=["https://oss/ref.mp4"],
|
||||
generate_audio=True,
|
||||
model="doubao-seedance-2-5-260628",
|
||||
)
|
||||
assert result is not None and result["video_path"] == str(out)
|
||||
kwargs = mock_client.video_generation.call_args.kwargs
|
||||
# 图生视频也必须传 ratio(避免首帧方图导致默认输出 1:1)
|
||||
assert kwargs.get("ratio") == "9:16", f"ratio 应透传,got {kwargs.get('ratio')!r}"
|
||||
assert kwargs["image_url"] == "https://img/x.jpg"
|
||||
assert kwargs["reference_audios"] == ["https://oss/tts.mp3"]
|
||||
assert kwargs["reference_images"] == ["https://img/r1.jpg"]
|
||||
assert kwargs["reference_videos"] == ["https://oss/ref.mp4"]
|
||||
assert kwargs["generate_audio"] is True
|
||||
assert kwargs["model"] == "doubao-seedance-2-5-260628"
|
||||
if not shutil.which("ffmpeg"):
|
||||
pytest.skip("ffmpeg not available")
|
||||
|
||||
def test_ratio_passed_when_no_image(self, tmp_path):
|
||||
from packages.shared.ai_service import call_video_generation
|
||||
|
||||
out = tmp_path / "v.mp4"
|
||||
out.write_bytes(b"fake")
|
||||
with patch("packages.shared.ai_service.get_doubao_client") as mock_get:
|
||||
mock_client = MagicMock()
|
||||
mock_client.is_available = True
|
||||
mock_client.video_generation.return_value = {"video_path": str(out), "usage": None}
|
||||
mock_get.return_value = mock_client
|
||||
call_video_generation(prompt="测试", duration=10, ratio="16:9")
|
||||
kwargs = mock_client.video_generation.call_args.kwargs
|
||||
assert kwargs["ratio"] == "16:9"
|
||||
assert kwargs["image_url"] is None
|
||||
out = _make_placeholder_clip(tmp_path, 0, 3)
|
||||
assert out.exists()
|
||||
assert _probe_ok(str(out))
|
||||
|
||||
|
||||
# ── P0-1: DoubaoClient.video_generation 在不可用时返回 None ───────
|
||||
@@ -301,145 +224,10 @@ class TestDoubaoClientVideoGen:
|
||||
|
||||
class TestResumeReadsImageAnalysis:
|
||||
def test_resume_uses_persisted_image_analysis(self):
|
||||
"""resume/render pipeline 应从 job.image_analysis 读(v1.5 _run_render_pipeline 共享渲染逻辑)。"""
|
||||
"""resume_pipeline 应从 job.image_analysis 读(P0-3 持久化)。"""
|
||||
import inspect
|
||||
|
||||
from apps.worker.worker_app.tasks import viral_video as vv
|
||||
|
||||
# v1.5 改造后 resume 委托给 _run_render_pipeline,那里读取 job.image_analysis
|
||||
src = inspect.getsource(vv._run_render_pipeline)
|
||||
src = inspect.getsource(vv.resume_viral_video_pipeline)
|
||||
assert "job.image_analysis" in src
|
||||
assert "image_analysis" in src
|
||||
# resume 本身应该调用 _run_render_pipeline
|
||||
resume_src = inspect.getsource(vv.resume_viral_video_pipeline)
|
||||
assert "_run_render_pipeline" in resume_src
|
||||
|
||||
|
||||
# ============ #2157 _replace_henjin_everywhere 递归替换单测 ============
|
||||
|
||||
|
||||
class TestReplaceHenjinEverywhere:
|
||||
"""覆盖 #2157 P1:递归替换 copy_result 中所有层级的'很近'→'最近'。"""
|
||||
|
||||
def _import(self):
|
||||
from apps.worker.worker_app.tasks.viral_video import _replace_henjin_everywhere
|
||||
|
||||
return _replace_henjin_everywhere
|
||||
|
||||
def test_plain_string_no_henjin(self):
|
||||
fn = self._import()
|
||||
assert fn("最近好物推荐") == "最近好物推荐"
|
||||
assert fn("") == ""
|
||||
assert fn(None) is None
|
||||
assert fn(123) == 123
|
||||
|
||||
def test_string_with_henjin(self):
|
||||
fn = self._import()
|
||||
assert fn("很近是不是总觉得颈肩发僵") == "最近是不是总觉得颈肩发僵"
|
||||
# 多次出现
|
||||
assert fn("很近很近都很近") == "最近最近都最近"
|
||||
|
||||
def test_list_recursive(self):
|
||||
fn = self._import()
|
||||
out = fn(["很近a", "b", ["很近c", "d"]])
|
||||
assert out == ["最近a", "b", ["最近c", "d"]]
|
||||
|
||||
def test_dict_recursive_nested(self):
|
||||
fn = self._import()
|
||||
obj = {
|
||||
"overview": {"theme": "很近颈肩", "title": "x"},
|
||||
"scene_and_lighting": "很近才好用",
|
||||
"voiceover_script": "很近是不是",
|
||||
"final_copy": "很近好物",
|
||||
"shots": [
|
||||
{"scene_and_dialogue": "很近第一镜", "action_details": "很近动作", "audio_bgm": "很近音乐"},
|
||||
{"nested": {"deep": "很近深层"}},
|
||||
],
|
||||
"int_field": 42,
|
||||
}
|
||||
import json
|
||||
|
||||
out = fn(obj)
|
||||
assert "很近" not in json.dumps(out, ensure_ascii=False)
|
||||
assert out["overview"]["theme"] == "最近颈肩"
|
||||
assert out["shots"][0]["scene_and_dialogue"] == "最近第一镜"
|
||||
assert out["shots"][1]["nested"]["deep"] == "最近深层"
|
||||
assert out["int_field"] == 42
|
||||
|
||||
|
||||
class TestMarkFailedAndNotifySessionFallback:
|
||||
"""#2157 P1:_mark_failed_and_notify 在原session失效时fallback到新SessionLocal。"""
|
||||
|
||||
def test_fallback_to_new_session_when_original_save_raises(self, tmp_path):
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from apps.worker.worker_app.tasks import viral_video as vv
|
||||
|
||||
job = MagicMock()
|
||||
job.is_terminal = False
|
||||
job.mark_failed = MagicMock()
|
||||
|
||||
# 原 session 保存抛异常
|
||||
orig_session = MagicMock()
|
||||
orig_repo = MagicMock()
|
||||
|
||||
def _raise(*a, **kw):
|
||||
raise RuntimeError("session in rollback")
|
||||
|
||||
# 第一次调用_save_job抛异常,触发fallback
|
||||
with patch.object(vv, "_save_job", side_effect=_raise):
|
||||
fake_ssn = MagicMock()
|
||||
fake_repo = MagicMock()
|
||||
fake_job_in_db = MagicMock()
|
||||
fake_job_in_db.is_terminal = False
|
||||
fake_repo.get.return_value = fake_job_in_db
|
||||
with patch.object(vv, "SessionLocal", return_value=fake_ssn):
|
||||
with patch.object(vv, "SQLAlchemyViralVideoJobRepository", return_value=fake_repo):
|
||||
with patch.object(vv, "_emit_progress") as mock_emit:
|
||||
vv._mark_failed_and_notify("job-1", orig_session, orig_repo, job, "boom", stage="render")
|
||||
# 原session上mark_failed被调用过
|
||||
job.mark_failed.assert_called()
|
||||
# fallback路径:新session上repo.get(job-1)被调用,且新job被mark_failed并commit
|
||||
fake_repo.get.assert_called_with("job-1")
|
||||
fake_job_in_db.mark_failed.assert_called_with("boom")
|
||||
fake_repo.update.assert_called_with(fake_job_in_db)
|
||||
fake_ssn.commit.assert_called()
|
||||
fake_ssn.close.assert_called()
|
||||
mock_emit.assert_called_once()
|
||||
|
||||
def test_original_session_happy_path_no_fallback(self):
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from apps.worker.worker_app.tasks import viral_video as vv
|
||||
|
||||
job = MagicMock()
|
||||
job.is_terminal = False
|
||||
session = MagicMock()
|
||||
repo = MagicMock()
|
||||
with patch.object(vv, "_save_job") as mock_save:
|
||||
with patch.object(vv, "_emit_progress") as mock_emit:
|
||||
# 不mock SessionLocal,如果fallback被错误触发会抛AttributeError
|
||||
vv._mark_failed_and_notify("job-2", session, repo, job, "err", stage="copy")
|
||||
job.mark_failed.assert_called_with("err")
|
||||
mock_save.assert_called()
|
||||
mock_emit.assert_called_once()
|
||||
|
||||
def test_terminal_job_not_marked(self):
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from apps.worker.worker_app.tasks import viral_video as vv
|
||||
|
||||
job = MagicMock()
|
||||
job.is_terminal = True # 已终态
|
||||
session = MagicMock()
|
||||
repo = MagicMock()
|
||||
with patch.object(vv, "_save_job") as mock_save:
|
||||
with patch.object(vv, "_emit_progress"):
|
||||
fake_ssn = MagicMock()
|
||||
with patch.object(vv, "SessionLocal", return_value=fake_ssn):
|
||||
with patch.object(vv, "SQLAlchemyViralVideoJobRepository") as mock_repo_cls:
|
||||
vv._mark_failed_and_notify("job-3", session, repo, job, "x")
|
||||
# 终态job不调用mark_failed
|
||||
job.mark_failed.assert_not_called()
|
||||
# 且因 job 已终态,_save_job 也不应被调用(marked=False 才fallback;但此处 job 非 None 且 is_terminal=True,marked 保持 False 进入 fallback)
|
||||
# fallback路径会重新打开session,get到的job也是终态,不会update
|
||||
|
||||
@@ -14,8 +14,6 @@ from __future__ import annotations
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
def _auth_user(uid: str = "u1"):
|
||||
return SimpleNamespace(user=SimpleNamespace(id=uid))
|
||||
@@ -36,7 +34,7 @@ def _make_job(job_id: str = "job-1", user_id: str = "u1", status: str = "pending
|
||||
"viral_structure": "",
|
||||
"marketing_purpose": "",
|
||||
"bgm_preference": "",
|
||||
"duration": 15,
|
||||
"duration": 30,
|
||||
"user_copy_text": "",
|
||||
"fusion_level": "ai_polish",
|
||||
"reference_audio_path": "",
|
||||
@@ -53,25 +51,7 @@ def _make_job(job_id: str = "job-1", user_id: str = "u1", status: str = "pending
|
||||
"stage": "",
|
||||
"progress": 0.0,
|
||||
"intent_result": None,
|
||||
"image_analysis": None,
|
||||
"storyboard": None,
|
||||
"copy_result": None,
|
||||
"generated_copy_text": "",
|
||||
"voice_id": "",
|
||||
"voice_source": "",
|
||||
"voice_mode": "global",
|
||||
"video_ratio": "9:16",
|
||||
"video_model": "",
|
||||
"video_resolution": "720p",
|
||||
"credits_prepaid": 0.0,
|
||||
"credits_transaction_id": "",
|
||||
"credits_cost": 0.0,
|
||||
"current_stage": "",
|
||||
"phase_message": "",
|
||||
"updated_at": None,
|
||||
"is_terminal": False,
|
||||
"effective_copy_text": "",
|
||||
"voiceover_script": "",
|
||||
}.items():
|
||||
setattr(job, k, kwargs.pop(k, v))
|
||||
return job
|
||||
@@ -138,163 +118,6 @@ class TestRetryViralVideo:
|
||||
mock_send.assert_called_once_with("worker.run_viral_video_pipeline", args=["job-retry"])
|
||||
assert resp.id == "job-retry"
|
||||
|
||||
def test_retry_without_body_keeps_original_params(self):
|
||||
"""不传 body 时,保持原参数且不调用积分服务。"""
|
||||
from app.api.routes import viral_video as vv_mod
|
||||
|
||||
from packages.domain.viral_video import ViralVideoStatus
|
||||
|
||||
user = _auth_user("u1")
|
||||
session = MagicMock()
|
||||
job = _make_job(
|
||||
job_id="job-retry2", user_id="u1", status=ViralVideoStatus.FAILED,
|
||||
duration=15, video_ratio="9:16", video_resolution="720p", video_model="seedance-2.5",
|
||||
credits_prepaid=5.0, credits_transaction_id="txn1", retry_count=0,
|
||||
)
|
||||
repo = MagicMock()
|
||||
repo.get.return_value = job
|
||||
|
||||
with (
|
||||
patch.object(vv_mod, "_get_job_repo", return_value=repo),
|
||||
patch("app.config.settings") as mock_settings,
|
||||
patch("packages.domain.points_service.PointsService") as MockSvc,
|
||||
patch.object(vv_mod.celery_app, "send_task"),
|
||||
):
|
||||
mock_settings.points_enabled = True
|
||||
# request=None (未传 body)
|
||||
resp = vv_mod.retry_viral_video_job("job-retry2", None, authenticated_user=user, session=session)
|
||||
|
||||
MockSvc.assert_not_called()
|
||||
assert resp.id == "job-retry2"
|
||||
assert job.status == ViralVideoStatus.PENDING
|
||||
assert job.duration == 15 # 参数不变
|
||||
|
||||
def test_retry_insufficient_points_raises_402(self):
|
||||
"""参数变更导致新预估更高且余额不足时,抛 402 阻止重试。"""
|
||||
from app.api.routes import viral_video as vv_mod
|
||||
from app.schemas.viral_video import RetryViralVideoRequest
|
||||
from fastapi import HTTPException
|
||||
|
||||
from packages.domain.viral_video import ViralVideoStatus
|
||||
|
||||
user = _auth_user("u1")
|
||||
session = MagicMock()
|
||||
job = _make_job(
|
||||
job_id="job-retry3a", user_id="u1", status=ViralVideoStatus.FAILED,
|
||||
duration=15, video_ratio="9:16", video_resolution="720p", video_model="seedance-2.5",
|
||||
credits_prepaid=5.0, credits_transaction_id="txn-old", retry_count=0,
|
||||
)
|
||||
repo = MagicMock()
|
||||
repo.get.return_value = job
|
||||
|
||||
fake_svc = MagicMock()
|
||||
fake_svc.deduct_viral_video.return_value = {"success": False, "balance": 1.0}
|
||||
req = RetryViralVideoRequest(duration=30, video_resolution="1080p", video_ratio="16:9", video_model="seedance-2.5")
|
||||
|
||||
with (
|
||||
patch.object(vv_mod, "_get_job_repo", return_value=repo),
|
||||
patch("app.config.settings") as mock_settings,
|
||||
patch("packages.domain.points_service.PointsService", return_value=fake_svc),
|
||||
patch("packages.domain.points_rules.resolve_video_dimensions", return_value=(1920, 1080)),
|
||||
patch("packages.domain.points_rules.calculate_viral_video_credits_with_breakdown",
|
||||
return_value=(15.0, {})),
|
||||
patch.object(vv_mod.celery_app, "send_task"),
|
||||
):
|
||||
mock_settings.points_enabled = True
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
vv_mod.retry_viral_video_job("job-retry3a", req, authenticated_user=user, session=session)
|
||||
assert exc.value.status_code == 402
|
||||
fake_svc.deduct_viral_video.assert_called_once()
|
||||
|
||||
def test_retry_higher_estimation_calls_deduct_delta(self):
|
||||
"""参数变更新预估更高时调用 deduct_viral_video 补扣差额,并更新 job 参数。"""
|
||||
from app.api.routes import viral_video as vv_mod
|
||||
from app.schemas.viral_video import RetryViralVideoRequest
|
||||
|
||||
from packages.domain.viral_video import ViralVideoStatus
|
||||
|
||||
user = _auth_user("u1")
|
||||
session = MagicMock()
|
||||
job = _make_job(
|
||||
job_id="job-retry3b", user_id="u1", status=ViralVideoStatus.FAILED,
|
||||
duration=15, video_ratio="9:16", video_resolution="720p", video_model="seedance-2.5",
|
||||
credits_prepaid=5.0, credits_transaction_id="txn-old", retry_count=0,
|
||||
)
|
||||
# 用 SimpleNamespace 让属性真正可写
|
||||
from types import SimpleNamespace
|
||||
job.credits_prepaid = 5.0
|
||||
repo = MagicMock()
|
||||
repo.get.return_value = job
|
||||
|
||||
fake_svc = MagicMock()
|
||||
fake_svc.deduct_viral_video.return_value = {"success": True, "balance": 50.0, "transaction_id": "txn-new"}
|
||||
req = RetryViralVideoRequest(duration=30, video_resolution="1080p", video_ratio="16:9", video_model="seedance-2.5")
|
||||
|
||||
new_est = 15.0
|
||||
with (
|
||||
patch.object(vv_mod, "_get_job_repo", return_value=repo),
|
||||
patch("app.config.settings") as mock_settings,
|
||||
patch("packages.domain.points_service.PointsService", return_value=fake_svc),
|
||||
patch("packages.domain.points_rules.resolve_video_dimensions", return_value=(1920, 1080)),
|
||||
patch("packages.domain.points_rules.calculate_viral_video_credits_with_breakdown",
|
||||
return_value=(new_est, {})),
|
||||
patch.object(vv_mod.celery_app, "send_task"),
|
||||
):
|
||||
mock_settings.points_enabled = True
|
||||
resp = vv_mod.retry_viral_video_job("job-retry3b", req, authenticated_user=user, session=session)
|
||||
|
||||
assert job.duration == 30
|
||||
assert job.video_resolution == "1080p"
|
||||
assert job.video_ratio == "16:9"
|
||||
# 补扣差额 = 15-5 = 10
|
||||
fake_svc.deduct_viral_video.assert_called_once()
|
||||
call_args = fake_svc.deduct_viral_video.call_args
|
||||
assert call_args.args[1] == 10.0 # credits 是位置参数
|
||||
assert resp.id == "job-retry3b"
|
||||
|
||||
def test_retry_lower_estimation_calls_refund_delta(self):
|
||||
"""参数变更新预估更低时,调用 refund_points 退还差额,并更新 job 参数。"""
|
||||
from app.api.routes import viral_video as vv_mod
|
||||
from app.schemas.viral_video import RetryViralVideoRequest
|
||||
|
||||
from packages.domain.viral_video import ViralVideoStatus
|
||||
|
||||
user = _auth_user("u1")
|
||||
session = MagicMock()
|
||||
job = _make_job(
|
||||
job_id="job-retry4", user_id="u1", status=ViralVideoStatus.FAILED,
|
||||
duration=20, video_ratio="16:9", video_resolution="1080p", video_model="seedance-2.5",
|
||||
credits_prepaid=10.0, credits_transaction_id="txn-old", retry_count=0,
|
||||
)
|
||||
job.credits_prepaid = 10.0
|
||||
repo = MagicMock()
|
||||
repo.get.return_value = job
|
||||
|
||||
fake_svc = MagicMock()
|
||||
fake_svc.refund_points.return_value = {"success": True}
|
||||
req = RetryViralVideoRequest(duration=5, video_resolution="480p", video_ratio="9:16")
|
||||
|
||||
new_est = 3.0
|
||||
with (
|
||||
patch.object(vv_mod, "_get_job_repo", return_value=repo),
|
||||
patch("app.config.settings") as mock_settings,
|
||||
patch("packages.domain.points_service.PointsService", return_value=fake_svc),
|
||||
patch("packages.domain.points_rules.resolve_video_dimensions", return_value=(270, 480)),
|
||||
patch("packages.domain.points_rules.calculate_viral_video_credits_with_breakdown",
|
||||
return_value=(new_est, {})),
|
||||
patch.object(vv_mod.celery_app, "send_task"),
|
||||
):
|
||||
mock_settings.points_enabled = True
|
||||
vv_mod.retry_viral_video_job("job-retry4", req, authenticated_user=user, session=session)
|
||||
|
||||
assert job.duration == 5
|
||||
assert job.video_resolution == "480p"
|
||||
assert job.video_ratio == "9:16"
|
||||
fake_svc.refund_points.assert_called_once()
|
||||
call_args = fake_svc.refund_points.call_args
|
||||
# 退差额 = 10-3 = 7
|
||||
assert call_args.kwargs["amount"] == 7.0
|
||||
|
||||
|
||||
# ── confirm-intent ──────────────────────────────────────────────────────
|
||||
|
||||
@@ -351,516 +174,3 @@ class TestAnalyzeStyle:
|
||||
mock_send.assert_called_once_with("worker.run_video_style_analysis", args=["job-sty"])
|
||||
assert resp.job_id == "job-sty"
|
||||
assert resp.status == "analyzing"
|
||||
|
||||
|
||||
# ── v1.5 three-stage endpoints ─────────────────────────────────────────
|
||||
|
||||
|
||||
class TestAnalyzeImages:
|
||||
def test_analyze_images_creates_job_and_dispatches(self):
|
||||
"""POST /analyze-images: 创建任务 + 入队 run_viral_video_analyze。"""
|
||||
from unittest.mock import patch
|
||||
|
||||
from app.api.routes import viral_video as vv_mod
|
||||
from app.schemas.viral_video import AnalyzeImagesRequest
|
||||
|
||||
user = _auth_user("u1")
|
||||
session = MagicMock()
|
||||
repo = MagicMock()
|
||||
req = AnalyzeImagesRequest(images=["https://x.com/a.jpg"], reference_video_url="", style_template_id="")
|
||||
|
||||
saved = {}
|
||||
|
||||
def fake_save(job):
|
||||
saved["job"] = job
|
||||
return job
|
||||
|
||||
repo.save.side_effect = fake_save
|
||||
|
||||
with (
|
||||
patch.object(vv_mod, "_get_job_repo", return_value=repo),
|
||||
patch.object(vv_mod.celery_app, "send_task") as mock_send,
|
||||
):
|
||||
resp = vv_mod.analyze_images(req, authenticated_user=user, session=session)
|
||||
|
||||
job = saved["job"]
|
||||
assert job.user_id == "u1"
|
||||
assert job.images == ["https://x.com/a.jpg"]
|
||||
mock_send.assert_called_once_with("worker.run_viral_video_analyze", args=[job.id])
|
||||
assert resp.status == "pending"
|
||||
|
||||
|
||||
class TestGenerateCopy:
|
||||
def test_generate_copy_updates_params_and_dispatches(self):
|
||||
"""POST /{id}/generate-copy: 在 image_analyzed 状态下写营销参数 + 入队 run_viral_video_generate_copy。"""
|
||||
from unittest.mock import patch
|
||||
|
||||
from app.api.routes import viral_video as vv_mod
|
||||
from app.schemas.viral_video import GenerateCopyRequest
|
||||
|
||||
from packages.domain.viral_video import ViralVideoStatus
|
||||
|
||||
user = _auth_user("u1")
|
||||
session = MagicMock()
|
||||
job = _make_job(job_id="job-gc", user_id="u1", status=ViralVideoStatus.IMAGE_ANALYZED)
|
||||
repo = MagicMock()
|
||||
repo.get.return_value = job
|
||||
req = GenerateCopyRequest(
|
||||
industry="美妆",
|
||||
target_customer="年轻女性",
|
||||
duration=25,
|
||||
fusion_level="ai_full",
|
||||
user_copy_text="试试这个",
|
||||
)
|
||||
|
||||
with (
|
||||
patch.object(vv_mod, "_get_job_repo", return_value=repo),
|
||||
patch.object(vv_mod.celery_app, "send_task") as mock_send,
|
||||
):
|
||||
resp = vv_mod.generate_copy("job-gc", req, authenticated_user=user, session=session)
|
||||
|
||||
# 参数写入
|
||||
assert job.industry == "美妆"
|
||||
assert job.target_customer == "年轻女性"
|
||||
assert job.duration == 25
|
||||
assert job.fusion_level == "ai_full"
|
||||
assert job.user_copy_text == "试试这个"
|
||||
job.resume_from_image_analyzed.assert_called_once()
|
||||
repo.update.assert_called()
|
||||
mock_send.assert_called_once_with("worker.run_viral_video_generate_copy", args=["job-gc"])
|
||||
assert resp.id == "job-gc"
|
||||
|
||||
def test_generate_copy_rejects_wrong_status(self):
|
||||
"""任务在 copy_generated/completed 时不能再 generate-copy(状态保护)。"""
|
||||
import pytest
|
||||
from app.api.routes import viral_video as vv_mod
|
||||
from app.schemas.viral_video import GenerateCopyRequest
|
||||
from fastapi import HTTPException
|
||||
|
||||
from packages.domain.viral_video import ViralVideoStatus
|
||||
|
||||
user = _auth_user("u1")
|
||||
session = MagicMock()
|
||||
job = _make_job(job_id="job-gc2", user_id="u1", status=ViralVideoStatus.COPY_GENERATED)
|
||||
repo = MagicMock()
|
||||
repo.get.return_value = job
|
||||
|
||||
with (patch.object(vv_mod, "_get_job_repo", return_value=repo),):
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
vv_mod.generate_copy("job-gc2", GenerateCopyRequest(), authenticated_user=user, session=session)
|
||||
assert exc.value.status_code == 409
|
||||
|
||||
def test_generate_copy_persists_voice_and_ratio(self):
|
||||
"""generate-copy 应把 voice_id/voice_source/video_ratio 写入 job。"""
|
||||
from unittest.mock import patch
|
||||
|
||||
from app.api.routes import viral_video as vv_mod
|
||||
from app.schemas.viral_video import GenerateCopyRequest
|
||||
|
||||
from packages.domain.viral_video import ViralVideoStatus
|
||||
|
||||
user = _auth_user("u1")
|
||||
session = MagicMock()
|
||||
job = _make_job(job_id="job-gc3", user_id="u1", status=ViralVideoStatus.IMAGE_ANALYZED)
|
||||
repo = MagicMock()
|
||||
repo.get.return_value = job
|
||||
req = GenerateCopyRequest(
|
||||
voice_id="cosy_voice_001",
|
||||
voice_source="library",
|
||||
video_ratio="16:9",
|
||||
)
|
||||
with (
|
||||
patch.object(vv_mod, "_get_job_repo", return_value=repo),
|
||||
patch.object(vv_mod.celery_app, "send_task"),
|
||||
):
|
||||
vv_mod.generate_copy("job-gc3", req, authenticated_user=user, session=session)
|
||||
assert job.voice_id == "cosy_voice_001"
|
||||
assert job.voice_source == "library"
|
||||
assert job.video_ratio == "16:9"
|
||||
|
||||
|
||||
class TestAnalyzeImagesPersist:
|
||||
def test_analyze_images_persists_voice_and_ratio(self):
|
||||
"""analyze-images 创建任务时应带上 voice/video_ratio 字段。"""
|
||||
from unittest.mock import patch
|
||||
|
||||
from app.api.routes import viral_video as vv_mod
|
||||
from app.schemas.viral_video import AnalyzeImagesRequest
|
||||
|
||||
user = _auth_user("u1")
|
||||
session = MagicMock()
|
||||
saved = {}
|
||||
|
||||
class FakeRepo:
|
||||
def save(self, job):
|
||||
saved["job"] = job
|
||||
|
||||
def get(self, jid):
|
||||
return None
|
||||
|
||||
req = AnalyzeImagesRequest(
|
||||
images=["img-1"],
|
||||
voice_id="preset_v1",
|
||||
voice_source="preset",
|
||||
video_ratio="1:1",
|
||||
)
|
||||
with (
|
||||
patch.object(vv_mod, "_get_job_repo", return_value=FakeRepo()),
|
||||
patch.object(vv_mod.celery_app, "send_task"),
|
||||
):
|
||||
resp = vv_mod.analyze_images(req, authenticated_user=user, session=session)
|
||||
job = saved["job"]
|
||||
assert job.voice_id == "preset_v1"
|
||||
assert job.voice_source == "preset"
|
||||
assert job.video_ratio == "1:1"
|
||||
assert resp.images == ["img-1"]
|
||||
|
||||
|
||||
class TestConfirmCopy:
|
||||
def test_confirm_copy_dispatches_render(self):
|
||||
"""POST /{id}/confirm-copy: copy_generated -> RUNNING + 入队 run_viral_video_render,编辑文案写入。"""
|
||||
from unittest.mock import patch
|
||||
|
||||
from app.api.routes import viral_video as vv_mod
|
||||
from app.schemas.viral_video import ConfirmCopyRequest
|
||||
|
||||
from packages.domain.viral_video import ViralVideoStatus
|
||||
|
||||
user = _auth_user("u1")
|
||||
session = MagicMock()
|
||||
job = _make_job(job_id="job-cc", user_id="u1", status=ViralVideoStatus.COPY_GENERATED)
|
||||
repo = MagicMock()
|
||||
repo.get.return_value = job
|
||||
req = ConfirmCopyRequest(edited_copy="我改了文案")
|
||||
|
||||
with (
|
||||
patch.object(vv_mod, "_get_job_repo", return_value=repo),
|
||||
patch.object(vv_mod.celery_app, "send_task") as mock_send,
|
||||
):
|
||||
resp = vv_mod.confirm_copy("job-cc", req, authenticated_user=user, session=session)
|
||||
|
||||
job.resume_from_copy_generated.assert_called_once_with(edited_copy="我改了文案")
|
||||
mock_send.assert_called_once_with("worker.run_viral_video_render", args=["job-cc"])
|
||||
assert resp.id == "job-cc"
|
||||
|
||||
def test_confirm_copy_rejects_wrong_status(self):
|
||||
import pytest
|
||||
from app.api.routes import viral_video as vv_mod
|
||||
from app.schemas.viral_video import ConfirmCopyRequest
|
||||
from fastapi import HTTPException
|
||||
|
||||
from packages.domain.viral_video import ViralVideoStatus
|
||||
|
||||
user = _auth_user("u1")
|
||||
session = MagicMock()
|
||||
job = _make_job(job_id="job-cc2", user_id="u1", status=ViralVideoStatus.IMAGE_ANALYZED)
|
||||
repo = MagicMock()
|
||||
repo.get.return_value = job
|
||||
|
||||
with patch.object(vv_mod, "_get_job_repo", return_value=repo):
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
vv_mod.confirm_copy("job-cc2", ConfirmCopyRequest(), authenticated_user=user, session=session)
|
||||
assert exc.value.status_code == 409
|
||||
|
||||
|
||||
# ── confirm-copy 积分预扣 + estimate-credits 端点 (#2151) ──────────────
|
||||
|
||||
|
||||
class TestConfirmCopyPointsDeduction:
|
||||
"""confirm_copy 中积分预扣分支(points_enabled=True)。"""
|
||||
|
||||
def test_points_enabled_deducts_successfully(self):
|
||||
"""points_enabled=True + 未预付 → 计算预估积分 → deduct_viral_video → 写入 credits_prepaid。"""
|
||||
from app.api.routes import viral_video as vv_mod
|
||||
from app.schemas.viral_video import ConfirmCopyRequest
|
||||
from fastapi import HTTPException
|
||||
|
||||
from packages.domain.viral_video import ViralVideoStatus
|
||||
|
||||
user = _auth_user("u1")
|
||||
session = MagicMock()
|
||||
job = _make_job(job_id="job-pay", user_id="u1", status=ViralVideoStatus.COPY_GENERATED)
|
||||
# 默认 credits_prepaid=0, credits_cost=0 → 触发预扣
|
||||
repo = MagicMock()
|
||||
repo.get.return_value = job
|
||||
req = ConfirmCopyRequest(edited_copy="改好的文案")
|
||||
|
||||
mock_svc = MagicMock()
|
||||
mock_svc.deduct_viral_video.return_value = {"success": True, "balance": 100.0, "transaction_id": "txn-1"}
|
||||
|
||||
with (
|
||||
patch.object(vv_mod, "_get_job_repo", return_value=repo),
|
||||
patch.object(vv_mod, "_settings", None, create=True), # ensure not cached
|
||||
patch("app.config.settings") as mock_settings,
|
||||
patch("packages.domain.points_rules.calculate_viral_video_credits", return_value=5.2),
|
||||
patch("packages.domain.points_rules.resolve_video_dimensions", return_value=(720, 1280)),
|
||||
patch("packages.domain.points_service.PointsService", return_value=mock_svc),
|
||||
patch.object(vv_mod.celery_app, "send_task"),
|
||||
):
|
||||
mock_settings.points_enabled = True
|
||||
resp = vv_mod.confirm_copy("job-pay", req, authenticated_user=user, session=session)
|
||||
|
||||
# deduct_viral_video 被调用
|
||||
mock_svc.deduct_viral_video.assert_called_once()
|
||||
call_args = mock_svc.deduct_viral_video.call_args
|
||||
assert call_args.args[0] == "u1" # user_id
|
||||
assert call_args.args[1] == 5.2 # credits
|
||||
assert call_args.args[2] == "job-pay" # job_id
|
||||
# credits_prepaid / credits_transaction_id 被写入
|
||||
assert job.credits_prepaid == 5.2
|
||||
assert job.credits_transaction_id == "txn-1"
|
||||
assert resp.id == "job-pay"
|
||||
# resume + repo.update 至少调用过(其中一次是 credits 字段更新,一次是 resume 后)
|
||||
job.resume_from_copy_generated.assert_called_once_with(edited_copy="改好的文案")
|
||||
|
||||
def test_points_enabled_insufficient_balance_raises_402(self):
|
||||
"""余额不足(deduct_viral_video 返回 success=False)→ HTTP 402。"""
|
||||
import pytest
|
||||
from app.api.routes import viral_video as vv_mod
|
||||
from app.schemas.viral_video import ConfirmCopyRequest
|
||||
from fastapi import HTTPException
|
||||
|
||||
from packages.domain.viral_video import ViralVideoStatus
|
||||
|
||||
user = _auth_user("u1")
|
||||
session = MagicMock()
|
||||
job = _make_job(job_id="job-402", user_id="u1", status=ViralVideoStatus.COPY_GENERATED)
|
||||
repo = MagicMock()
|
||||
repo.get.return_value = job
|
||||
req = ConfirmCopyRequest()
|
||||
|
||||
mock_svc = MagicMock()
|
||||
mock_svc.deduct_viral_video.return_value = {"success": False, "balance": 1.5, "transaction_id": None}
|
||||
|
||||
with (
|
||||
patch.object(vv_mod, "_get_job_repo", return_value=repo),
|
||||
patch("app.config.settings") as mock_settings,
|
||||
patch("packages.domain.points_rules.calculate_viral_video_credits", return_value=10.0),
|
||||
patch("packages.domain.points_rules.resolve_video_dimensions", return_value=(720, 1280)),
|
||||
patch("packages.domain.points_service.PointsService", return_value=mock_svc),
|
||||
patch.object(vv_mod.celery_app, "send_task"),
|
||||
):
|
||||
mock_settings.points_enabled = True
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
vv_mod.confirm_copy("job-402", req, authenticated_user=user, session=session)
|
||||
|
||||
assert exc.value.status_code == 402
|
||||
detail = exc.value.detail
|
||||
assert detail["code"] == "INSUFFICIENT_POINTS"
|
||||
assert detail["required"] == 10.0
|
||||
assert detail["balance"] == 1.5
|
||||
# 预扣失败不应调用 resume 或 send_task
|
||||
job.resume_from_copy_generated.assert_not_called()
|
||||
|
||||
def test_already_paid_skips_deduction(self):
|
||||
"""credits_prepaid>0(已经扣过费/重试场景) → 跳过预扣,不调用 PointsService。"""
|
||||
from app.api.routes import viral_video as vv_mod
|
||||
from app.schemas.viral_video import ConfirmCopyRequest
|
||||
|
||||
from packages.domain.viral_video import ViralVideoStatus
|
||||
|
||||
user = _auth_user("u1")
|
||||
session = MagicMock()
|
||||
job = _make_job(
|
||||
job_id="job-paid",
|
||||
user_id="u1",
|
||||
status=ViralVideoStatus.COPY_GENERATED,
|
||||
credits_prepaid=8.5,
|
||||
credits_transaction_id="txn-old",
|
||||
)
|
||||
repo = MagicMock()
|
||||
repo.get.return_value = job
|
||||
req = ConfirmCopyRequest(edited_copy="继续")
|
||||
|
||||
with (
|
||||
patch.object(vv_mod, "_get_job_repo", return_value=repo),
|
||||
patch("app.config.settings") as mock_settings,
|
||||
patch("packages.domain.points_service.PointsService") as MockSvc,
|
||||
patch.object(vv_mod.celery_app, "send_task") as mock_send,
|
||||
):
|
||||
mock_settings.points_enabled = True
|
||||
resp = vv_mod.confirm_copy("job-paid", req, authenticated_user=user, session=session)
|
||||
|
||||
# PointsService 不应被实例化(没预扣)
|
||||
MockSvc.assert_not_called()
|
||||
job.resume_from_copy_generated.assert_called_once_with(edited_copy="继续")
|
||||
mock_send.assert_called_once_with("worker.run_viral_video_render", args=["job-paid"])
|
||||
assert resp.id == "job-paid"
|
||||
# credits_prepaid 保持不变
|
||||
assert job.credits_prepaid == 8.5
|
||||
|
||||
def test_already_paid_via_credits_cost_skips_deduction(self):
|
||||
"""credits_cost>0 也算已付费(兼容旧字段),跳过预扣。"""
|
||||
from app.api.routes import viral_video as vv_mod
|
||||
from app.schemas.viral_video import ConfirmCopyRequest
|
||||
|
||||
from packages.domain.viral_video import ViralVideoStatus
|
||||
|
||||
user = _auth_user("u1")
|
||||
session = MagicMock()
|
||||
job = _make_job(
|
||||
job_id="job-paid2",
|
||||
user_id="u1",
|
||||
status=ViralVideoStatus.COPY_GENERATED,
|
||||
credits_prepaid=0,
|
||||
credits_cost=7.0,
|
||||
)
|
||||
repo = MagicMock()
|
||||
repo.get.return_value = job
|
||||
req = ConfirmCopyRequest()
|
||||
|
||||
with (
|
||||
patch.object(vv_mod, "_get_job_repo", return_value=repo),
|
||||
patch("app.config.settings") as mock_settings,
|
||||
patch("packages.domain.points_service.PointsService") as MockSvc,
|
||||
patch.object(vv_mod.celery_app, "send_task"),
|
||||
):
|
||||
mock_settings.points_enabled = True
|
||||
vv_mod.confirm_copy("job-paid2", req, authenticated_user=user, session=session)
|
||||
|
||||
MockSvc.assert_not_called()
|
||||
|
||||
def test_points_disabled_skips_deduction(self):
|
||||
"""points_enabled=False 时不进入预扣逻辑,保持原流程。"""
|
||||
from app.api.routes import viral_video as vv_mod
|
||||
from app.schemas.viral_video import ConfirmCopyRequest
|
||||
|
||||
from packages.domain.viral_video import ViralVideoStatus
|
||||
|
||||
user = _auth_user("u1")
|
||||
session = MagicMock()
|
||||
job = _make_job(job_id="job-free", user_id="u1", status=ViralVideoStatus.COPY_GENERATED)
|
||||
repo = MagicMock()
|
||||
repo.get.return_value = job
|
||||
req = ConfirmCopyRequest()
|
||||
|
||||
with (
|
||||
patch.object(vv_mod, "_get_job_repo", return_value=repo),
|
||||
patch("app.config.settings") as mock_settings,
|
||||
patch("packages.domain.points_service.PointsService") as MockSvc,
|
||||
patch.object(vv_mod.celery_app, "send_task"),
|
||||
):
|
||||
mock_settings.points_enabled = False
|
||||
vv_mod.confirm_copy("job-free", req, authenticated_user=user, session=session)
|
||||
|
||||
MockSvc.assert_not_called()
|
||||
job.resume_from_copy_generated.assert_called_once()
|
||||
|
||||
|
||||
class TestEstimateCredits:
|
||||
"""POST /estimate-credits: 纯计算预估积分。"""
|
||||
|
||||
def test_estimate_returns_float_with_breakdown(self):
|
||||
"""正常参数应返回 estimated_credits(float, >0, 两位小数) + formula_breakdown。"""
|
||||
from app.api.routes import viral_video as vv_mod
|
||||
from app.schemas.viral_video import EstimateCreditsRequest
|
||||
|
||||
req = EstimateCreditsRequest(model="seedance-2.5", resolution="720p", ratio="9:16", duration=15)
|
||||
user = _auth_user("u1")
|
||||
|
||||
resp = vv_mod.estimate_credits(req, authenticated_user=user)
|
||||
assert isinstance(resp.estimated_credits, float)
|
||||
assert resp.estimated_credits > 0
|
||||
assert round(resp.estimated_credits, 2) == resp.estimated_credits
|
||||
# formula_breakdown 必须返回并包含全部字段
|
||||
bd = resp.formula_breakdown
|
||||
assert bd.tokens > 0
|
||||
assert bd.video_cost >= 0
|
||||
assert bd.fixed_cost > 0
|
||||
assert bd.profit_multiplier == 1.3
|
||||
assert bd.model_price > 0
|
||||
assert bd.width > 0
|
||||
assert bd.height > 0
|
||||
assert bd.fps > 0
|
||||
|
||||
def test_estimate_uses_dimensions_resolver_and_with_breakdown(self):
|
||||
"""estimate_credits 调用 resolve_video_dimensions 与 calculate_viral_video_credits_with_breakdown。"""
|
||||
from app.api.routes import viral_video as vv_mod
|
||||
from app.schemas.viral_video import EstimateCreditsRequest
|
||||
|
||||
req = EstimateCreditsRequest(model="seedance-2.5", resolution="1080p", ratio="16:9", duration=20)
|
||||
user = _auth_user("u1")
|
||||
fake_bd = {
|
||||
"tokens": 1000.0, "video_cost": 1.0, "fixed_cost": 0.15,
|
||||
"profit_multiplier": 1.3, "model_price": 70.0,
|
||||
"width": 1920, "height": 1080, "fps": 24,
|
||||
}
|
||||
|
||||
with (
|
||||
patch("packages.domain.points_rules.resolve_video_dimensions", return_value=(1920, 1080)) as mock_res,
|
||||
patch(
|
||||
"packages.domain.points_rules.calculate_viral_video_credits_with_breakdown",
|
||||
return_value=(8.88, fake_bd),
|
||||
) as mock_calc,
|
||||
):
|
||||
resp = vv_mod.estimate_credits(req, authenticated_user=user)
|
||||
|
||||
mock_res.assert_called_once_with("1080p", "16:9")
|
||||
mock_calc.assert_called_once()
|
||||
args, kwargs = mock_calc.call_args
|
||||
assert args[0] == 20
|
||||
assert args[1] == 1920
|
||||
assert args[2] == 1080
|
||||
assert args[3] == "seedance-2.5"
|
||||
assert resp.estimated_credits == 8.88
|
||||
assert resp.formula_breakdown.width == 1920
|
||||
assert resp.formula_breakdown.height == 1080
|
||||
assert resp.formula_breakdown.model_price == 70.0
|
||||
|
||||
def test_estimate_empty_model_defaults_to_seedance_2_5(self):
|
||||
"""model 为空字符串时,传入 calculate 的 model 参数应为 'seedance-2.5'。"""
|
||||
from app.api.routes import viral_video as vv_mod
|
||||
from app.schemas.viral_video import EstimateCreditsRequest
|
||||
|
||||
req = EstimateCreditsRequest(model="", resolution="720p", ratio="9:16", duration=10)
|
||||
user = _auth_user("u1")
|
||||
fake_bd = {
|
||||
"tokens": 500.0, "video_cost": 0.5, "fixed_cost": 0.15,
|
||||
"profit_multiplier": 1.3, "model_price": 70.0,
|
||||
"width": 720, "height": 1280, "fps": 24,
|
||||
}
|
||||
|
||||
with (
|
||||
patch("packages.domain.points_rules.resolve_video_dimensions", return_value=(720, 1280)),
|
||||
patch(
|
||||
"packages.domain.points_rules.calculate_viral_video_credits_with_breakdown",
|
||||
return_value=(3.5, fake_bd),
|
||||
) as mock_calc,
|
||||
):
|
||||
resp = vv_mod.estimate_credits(req, authenticated_user=user)
|
||||
|
||||
args, kwargs = mock_calc.call_args
|
||||
assert args[3] == "seedance-2.5"
|
||||
assert resp.estimated_credits == 3.5
|
||||
assert resp.formula_breakdown.height == 1280
|
||||
|
||||
def test_estimate_accepts_video_model_alias(self):
|
||||
"""前端传 video_model/video_resolution/video_ratio(别名)也应被正确解析。"""
|
||||
from app.api.routes import viral_video as vv_mod
|
||||
from app.schemas.viral_video import EstimateCreditsRequest
|
||||
|
||||
req = EstimateCreditsRequest.model_validate(
|
||||
{"video_model": "seedance-2.0", "video_resolution": "480p", "video_ratio": "1:1", "duration": 5}
|
||||
)
|
||||
user = _auth_user("u1")
|
||||
fake_bd = {
|
||||
"tokens": 100.0, "video_cost": 0.1, "fixed_cost": 0.15,
|
||||
"profit_multiplier": 1.3, "model_price": 46.0,
|
||||
"width": 480, "height": 480, "fps": 24,
|
||||
}
|
||||
|
||||
with (
|
||||
patch("packages.domain.points_rules.resolve_video_dimensions", return_value=(480, 480)) as mock_res,
|
||||
patch(
|
||||
"packages.domain.points_rules.calculate_viral_video_credits_with_breakdown",
|
||||
return_value=(1.0, fake_bd),
|
||||
) as mock_calc,
|
||||
):
|
||||
vv_mod.estimate_credits(req, authenticated_user=user)
|
||||
|
||||
mock_res.assert_called_once_with("480p", "1:1")
|
||||
args, kwargs = mock_calc.call_args
|
||||
assert args[0] == 5
|
||||
assert args[1] == 480
|
||||
assert args[2] == 480
|
||||
assert args[3] == "seedance-2.0"
|
||||
|
||||
Reference in New Issue
Block a user